mirror of https://github.com/tailscale/tailscale/
tailfs: replace webdavfs with reverse proxies
Instead of modeling remote WebDAV servers as actual webdav.FS instances, we now just proxy traffic to them. This not only simplifies the code, but it also allows WebDAV locking to work correctly by making sure locks are handled by the servers that need to (i.e. the ones actually serving the files). Updates tailscale/corp#16827 Signed-off-by: Percy Wegmann <percy@tailscale.com>pull/11248/head
parent
e1bd7488d0
commit
50fb8b9123
@ -0,0 +1,233 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package compositedav provides an http.Handler that composes multiple WebDAV
|
||||
// services into a single WebDAV service that presents each of them as its own
|
||||
// folder.
|
||||
package compositedav
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/tailfs/tailfsimpl/dirfs"
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// Child is a child folder of this compositedav.
|
||||
type Child struct {
|
||||
*dirfs.Child
|
||||
|
||||
// BaseURL is the base URL of the WebDAV service to which we'll proxy
|
||||
// requests for this Child. We will append the filename from the original
|
||||
// URL to this.
|
||||
BaseURL string
|
||||
|
||||
// Transport (if specified) is the http transport to use when communicating
|
||||
// with this Child's WebDAV service.
|
||||
Transport http.RoundTripper
|
||||
|
||||
rp *httputil.ReverseProxy
|
||||
initOnce sync.Once
|
||||
}
|
||||
|
||||
// CloseIdleConnections forcibly closes any idle connections on this Child's
|
||||
// reverse proxy.
|
||||
func (c *Child) CloseIdleConnections() {
|
||||
tr, ok := c.Transport.(*http.Transport)
|
||||
if ok {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Child) init() {
|
||||
c.initOnce.Do(func() {
|
||||
c.rp = &httputil.ReverseProxy{
|
||||
Transport: c.Transport,
|
||||
Rewrite: func(r *httputil.ProxyRequest) {},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handler implements http.Handler by using a dirfs.FS for showing a virtual
|
||||
// read-only folder that represents the Child WebDAV services as sub-folders
|
||||
// and proxying all requests for resources on the children to those children
|
||||
// via httputil.ReverseProxy instances.
|
||||
type Handler struct {
|
||||
// Logf specifies a logging function to use.
|
||||
Logf logger.Logf
|
||||
|
||||
// Clock, if specified, determines the current time. If not specified, we
|
||||
// default to time.Now().
|
||||
Clock tstime.Clock
|
||||
|
||||
// StatCache is an optional cache for PROPFIND results.
|
||||
StatCache *StatCache
|
||||
|
||||
// childrenMu guards the fields below. Note that we do read the contents of
|
||||
// children after releasing the read lock, which we can do because we never
|
||||
// modify children but only ever replace it in SetChildren.
|
||||
childrenMu sync.RWMutex
|
||||
children []*Child
|
||||
staticRoot string
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PROPFIND" {
|
||||
h.handlePROPFIND(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != "GET" {
|
||||
// If the user is performing a modification (e.g. PUT, MKDIR, etc),
|
||||
// we need to invalidate the StatCache to make sure we're not knowingly
|
||||
// showing stale stats.
|
||||
// TODO(oxtoacart): maybe be more selective about invalidating cache
|
||||
h.StatCache.invalidate()
|
||||
}
|
||||
|
||||
mpl := h.maxPathLength(r)
|
||||
pathComponents := shared.CleanAndSplit(r.URL.Path)
|
||||
|
||||
if len(pathComponents) >= mpl {
|
||||
h.delegate(pathComponents[mpl-1:], w, r)
|
||||
return
|
||||
}
|
||||
h.handle(w, r)
|
||||
}
|
||||
|
||||
// handle handles the request locally using our dirfs.FS.
|
||||
func (h *Handler) handle(w http.ResponseWriter, r *http.Request) {
|
||||
h.childrenMu.RLock()
|
||||
clk, kids, root := h.Clock, h.children, h.staticRoot
|
||||
h.childrenMu.RUnlock()
|
||||
|
||||
children := make([]*dirfs.Child, 0, len(kids))
|
||||
for _, child := range kids {
|
||||
children = append(children, child.Child)
|
||||
}
|
||||
wh := &webdav.Handler{
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
FileSystem: &dirfs.FS{
|
||||
Clock: clk,
|
||||
Children: children,
|
||||
StaticRoot: root,
|
||||
},
|
||||
}
|
||||
|
||||
wh.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// delegate sends the request to the Child WebDAV server.
|
||||
func (h *Handler) delegate(pathComponents []string, w http.ResponseWriter, r *http.Request) string {
|
||||
childName := pathComponents[0]
|
||||
child := h.GetChild(childName)
|
||||
if child == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return childName
|
||||
}
|
||||
u, err := url.Parse(child.BaseURL)
|
||||
if err != nil {
|
||||
h.logf("warning: parse base URL %s failed: %s", child.BaseURL, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return childName
|
||||
}
|
||||
u.Path = path.Join(u.Path, shared.Join(pathComponents[1:]...))
|
||||
r.URL = u
|
||||
r.Host = u.Host
|
||||
child.rp.ServeHTTP(w, r)
|
||||
return childName
|
||||
}
|
||||
|
||||
// SetChildren replaces the entire existing set of children with the given
|
||||
// ones. If staticRoot is given, the children will appear with a subfolder
|
||||
// bearing named <staticRoot>.
|
||||
func (h *Handler) SetChildren(staticRoot string, children ...*Child) {
|
||||
for _, child := range children {
|
||||
child.init()
|
||||
}
|
||||
|
||||
slices.SortFunc(children, func(a, b *Child) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
h.childrenMu.Lock()
|
||||
oldChildren := children
|
||||
h.children = children
|
||||
h.staticRoot = staticRoot
|
||||
h.childrenMu.Unlock()
|
||||
|
||||
for _, child := range oldChildren {
|
||||
child.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// GetChild gets the Child identified by name, or nil if no matching child
|
||||
// found.
|
||||
func (h *Handler) GetChild(name string) *Child {
|
||||
h.childrenMu.RLock()
|
||||
defer h.childrenMu.RUnlock()
|
||||
|
||||
_, child := h.findChildLocked(name)
|
||||
return child
|
||||
}
|
||||
|
||||
// Close closes this Handler,including closing all idle connections on children
|
||||
// and stopping the StatCache (if caching is enabled).
|
||||
func (h *Handler) Close() {
|
||||
h.childrenMu.RLock()
|
||||
oldChildren := h.children
|
||||
h.children = nil
|
||||
h.childrenMu.RUnlock()
|
||||
|
||||
for _, child := range oldChildren {
|
||||
child.CloseIdleConnections()
|
||||
}
|
||||
|
||||
if h.StatCache != nil {
|
||||
h.StatCache.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) findChildLocked(name string) (int, *Child) {
|
||||
var child *Child
|
||||
i, found := slices.BinarySearchFunc(h.children, name, func(child *Child, name string) int {
|
||||
return strings.Compare(child.Name, name)
|
||||
})
|
||||
if found {
|
||||
return i, h.children[i]
|
||||
}
|
||||
return i, child
|
||||
}
|
||||
|
||||
func (h *Handler) logf(format string, args ...any) {
|
||||
if h.Logf != nil {
|
||||
h.Logf(format, args...)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
|
||||
// maxPathLength calculates the maximum length of a path that can be handled by
|
||||
// this handler without delegating to a Child. It's always at least 1, and if
|
||||
// staticRoot is configured, it's 2.
|
||||
func (h *Handler) maxPathLength(r *http.Request) int {
|
||||
h.childrenMu.RLock()
|
||||
defer h.childrenMu.RUnlock()
|
||||
|
||||
if h.staticRoot != "" {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositedav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
)
|
||||
|
||||
var (
|
||||
hrefRegex = regexp.MustCompile(`(?s)<D:href>/?([^<]*)/?</D:href>`)
|
||||
)
|
||||
|
||||
func (h *Handler) handlePROPFIND(w http.ResponseWriter, r *http.Request) {
|
||||
pathComponents := shared.CleanAndSplit(r.URL.Path)
|
||||
mpl := h.maxPathLength(r)
|
||||
if !shared.IsRoot(r.URL.Path) && len(pathComponents)+getDepth(r) > mpl {
|
||||
// Delegate to a Child.
|
||||
depth := getDepth(r)
|
||||
|
||||
cached := h.StatCache.get(r.URL.Path, depth)
|
||||
if cached != nil {
|
||||
w.Header().Del("Content-Length")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
w.Write(cached)
|
||||
return
|
||||
}
|
||||
|
||||
// Use a buffering ResponseWriter so that we can manipulate the result.
|
||||
// The only thing we use from the original ResponseWriter is Header().
|
||||
bw := &bufferingResponseWriter{ResponseWriter: w}
|
||||
|
||||
mpl := h.maxPathLength(r)
|
||||
h.delegate(pathComponents[mpl-1:], bw, r)
|
||||
|
||||
// Fixup paths to add the requested path as a prefix.
|
||||
pathPrefix := shared.Join(pathComponents[0:mpl]...)
|
||||
b := hrefRegex.ReplaceAll(bw.buf.Bytes(), []byte(fmt.Sprintf("<D:href>%s/$1</D:href>", pathPrefix)))
|
||||
|
||||
if h.StatCache != nil && bw.status == http.StatusMultiStatus && b != nil {
|
||||
h.StatCache.set(r.URL.Path, depth, b)
|
||||
}
|
||||
|
||||
w.Header().Del("Content-Length")
|
||||
w.WriteHeader(bw.status)
|
||||
w.Write(b)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.handle(w, r)
|
||||
}
|
||||
|
||||
func getDepth(r *http.Request) int {
|
||||
switch r.Header.Get("Depth") {
|
||||
case "0":
|
||||
return 0
|
||||
case "1":
|
||||
return 1
|
||||
case "infinity":
|
||||
return math.MaxInt
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type bufferingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (bw *bufferingResponseWriter) WriteHeader(statusCode int) {
|
||||
bw.status = statusCode
|
||||
}
|
||||
|
||||
func (bw *bufferingResponseWriter) Write(p []byte) (int, error) {
|
||||
return bw.buf.Write(p)
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositedav
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
)
|
||||
|
||||
// StatCache provides a cache for directory listings and file metadata.
|
||||
// Especially when used from the command-line, mapped WebDAV drives can
|
||||
// generate repetitive requests for the same file metadata. This cache helps
|
||||
// reduce the number of round-trips to the WebDAV server for such requests.
|
||||
// This is similar to the DirectoryCacheLifetime setting of Windows' built-in
|
||||
// SMB client, see
|
||||
// https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-7/ff686200(v=ws.10)
|
||||
type StatCache struct {
|
||||
TTL time.Duration
|
||||
|
||||
// mu guards the below values.
|
||||
mu sync.Mutex
|
||||
cachesByDepthAndPath map[int]*ttlcache.Cache[string, []byte]
|
||||
}
|
||||
|
||||
func (c *StatCache) get(name string, depth int) []byte {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.cachesByDepthAndPath == nil {
|
||||
return nil
|
||||
}
|
||||
cache := c.cachesByDepthAndPath[depth]
|
||||
if cache == nil {
|
||||
return nil
|
||||
}
|
||||
item := cache.Get(name)
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return item.Value()
|
||||
}
|
||||
|
||||
func (c *StatCache) set(name string, depth int, value []byte) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.cachesByDepthAndPath == nil {
|
||||
c.cachesByDepthAndPath = make(map[int]*ttlcache.Cache[string, []byte])
|
||||
}
|
||||
cache := c.cachesByDepthAndPath[depth]
|
||||
if cache == nil {
|
||||
cache = ttlcache.New(
|
||||
ttlcache.WithTTL[string, []byte](c.TTL),
|
||||
)
|
||||
go cache.Start()
|
||||
c.cachesByDepthAndPath[depth] = cache
|
||||
}
|
||||
cache.Set(name, value, ttlcache.DefaultTTL)
|
||||
}
|
||||
|
||||
func (c *StatCache) invalidate() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for _, cache := range c.cachesByDepthAndPath {
|
||||
cache.DeleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StatCache) stop() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for _, cache := range c.cachesByDepthAndPath {
|
||||
cache.Stop()
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositedav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
var (
|
||||
val = []byte("1")
|
||||
file = "file"
|
||||
)
|
||||
|
||||
func TestStatCacheNoTimeout(t *testing.T) {
|
||||
// Make sure we don't leak goroutines
|
||||
tstest.ResourceCheck(t)
|
||||
|
||||
c := &StatCache{TTL: 5 * time.Second}
|
||||
defer c.stop()
|
||||
|
||||
// check get before set
|
||||
fetched := c.get(file, 1)
|
||||
if fetched != nil {
|
||||
t.Errorf("got %q, want nil", fetched)
|
||||
}
|
||||
|
||||
// set new stat
|
||||
c.set(file, 1, val)
|
||||
fetched = c.get(file, 1)
|
||||
if !bytes.Equal(fetched, val) {
|
||||
t.Errorf("got %q, want %q", fetched, val)
|
||||
}
|
||||
|
||||
// fetch stat again, should still be cached
|
||||
fetched = c.get(file, 1)
|
||||
if !bytes.Equal(fetched, val) {
|
||||
t.Errorf("got %q, want %q", fetched, val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatCacheTimeout(t *testing.T) {
|
||||
// Make sure we don't leak goroutines
|
||||
tstest.ResourceCheck(t)
|
||||
|
||||
c := &StatCache{TTL: 250 * time.Millisecond}
|
||||
defer c.stop()
|
||||
|
||||
// set new stat
|
||||
c.set(file, 1, val)
|
||||
fetched := c.get(file, 1)
|
||||
if !bytes.Equal(fetched, val) {
|
||||
t.Errorf("got %q, want %q", fetched, val)
|
||||
}
|
||||
|
||||
// wait for cache to expire and refetch stat, should be empty now
|
||||
time.Sleep(c.TTL * 2)
|
||||
|
||||
fetched = c.get(file, 1)
|
||||
if fetched != nil {
|
||||
t.Errorf("invalidate should have cleared cached value")
|
||||
}
|
||||
|
||||
c.set(file, 1, val)
|
||||
// invalidate the cache and make sure nothing is returned
|
||||
c.invalidate()
|
||||
fetched = c.get(file, 1)
|
||||
if fetched != nil {
|
||||
t.Errorf("invalidate should have cleared cached value")
|
||||
}
|
||||
}
|
@ -1,227 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package compositefs provides a webdav.FileSystem that is composi
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// Child is a child filesystem of a CompositeFileSystem
|
||||
type Child struct {
|
||||
// Name is the name of the child
|
||||
Name string
|
||||
// FS is the child's FileSystem
|
||||
FS webdav.FileSystem
|
||||
// Available is a function indicating whether or not the child is currently
|
||||
// available.
|
||||
Available func() bool
|
||||
}
|
||||
|
||||
func (c *Child) isAvailable() bool {
|
||||
if c.Available == nil {
|
||||
return true
|
||||
}
|
||||
return c.Available()
|
||||
}
|
||||
|
||||
// Options specifies options for configuring a CompositeFileSystem.
|
||||
type Options struct {
|
||||
// Logf specifies a logging function to use
|
||||
Logf logger.Logf
|
||||
// StatChildren, if true, causes the CompositeFileSystem to stat its child
|
||||
// folders when generating a root directory listing. This gives more
|
||||
// accurate information but increases latency.
|
||||
StatChildren bool
|
||||
// Clock, if specified, determines the current time. If not specified, we
|
||||
// default to time.Now().
|
||||
Clock tstime.Clock
|
||||
}
|
||||
|
||||
// New constructs a CompositeFileSystem that logs using the given logf.
|
||||
func New(opts Options) *CompositeFileSystem {
|
||||
logf := opts.Logf
|
||||
if logf == nil {
|
||||
logf = log.Printf
|
||||
}
|
||||
fs := &CompositeFileSystem{
|
||||
logf: logf,
|
||||
statChildren: opts.StatChildren,
|
||||
}
|
||||
if opts.Clock != nil {
|
||||
fs.now = opts.Clock.Now
|
||||
} else {
|
||||
fs.now = time.Now
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
// CompositeFileSystem is a webdav.FileSystem that is composed of multiple
|
||||
// child webdav.FileSystems. Each child is identified by a name and appears
|
||||
// as a folder within the root of the CompositeFileSystem, with the children
|
||||
// sorted lexicographically by name.
|
||||
//
|
||||
// Children in a CompositeFileSystem can only be added or removed via calls to
|
||||
// the AddChild and RemoveChild methods, they cannot be added via operations
|
||||
// on the webdav.FileSystem interface like filesystem.Mkdir or filesystem.OpenFile.
|
||||
// In other words, the root of the CompositeFileSystem acts as read-only, not
|
||||
// permitting the addition, removal or renaming of folders.
|
||||
//
|
||||
// Rename is only supported within a single child. Renaming across children
|
||||
// is not supported, as it wouldn't be possible to perform it atomically.
|
||||
type CompositeFileSystem struct {
|
||||
logf logger.Logf
|
||||
statChildren bool
|
||||
now func() time.Time
|
||||
|
||||
// childrenMu guards children
|
||||
childrenMu sync.Mutex
|
||||
children []*Child
|
||||
}
|
||||
|
||||
// AddChild ads a single child with the given name, replacing any existing
|
||||
// child with the same name.
|
||||
func (cfs *CompositeFileSystem) AddChild(child *Child) {
|
||||
cfs.childrenMu.Lock()
|
||||
oldIdx, oldChild := cfs.findChildLocked(child.Name)
|
||||
if oldChild != nil {
|
||||
// replace old child
|
||||
cfs.children[oldIdx] = child
|
||||
} else {
|
||||
// insert new child
|
||||
cfs.children = slices.Insert(cfs.children, oldIdx, child)
|
||||
}
|
||||
cfs.childrenMu.Unlock()
|
||||
|
||||
if oldChild != nil {
|
||||
if c, ok := oldChild.FS.(io.Closer); ok {
|
||||
if err := c.Close(); err != nil {
|
||||
cfs.logf("closing child filesystem %v: %v", child.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveChild removes the child with the given name, if it exists.
|
||||
func (cfs *CompositeFileSystem) RemoveChild(name string) {
|
||||
cfs.childrenMu.Lock()
|
||||
oldPos, oldChild := cfs.findChildLocked(name)
|
||||
if oldChild != nil {
|
||||
// remove old child
|
||||
copy(cfs.children[oldPos:], cfs.children[oldPos+1:])
|
||||
cfs.children = cfs.children[:len(cfs.children)-1]
|
||||
}
|
||||
cfs.childrenMu.Unlock()
|
||||
|
||||
if oldChild != nil {
|
||||
closer, ok := oldChild.FS.(io.Closer)
|
||||
if ok {
|
||||
err := closer.Close()
|
||||
if err != nil {
|
||||
cfs.logf("failed to close child filesystem %v: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetChildren replaces the entire existing set of children with the given
|
||||
// ones.
|
||||
func (cfs *CompositeFileSystem) SetChildren(children ...*Child) {
|
||||
slices.SortFunc(children, func(a, b *Child) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
cfs.childrenMu.Lock()
|
||||
oldChildren := cfs.children
|
||||
cfs.children = children
|
||||
cfs.childrenMu.Unlock()
|
||||
|
||||
for _, child := range oldChildren {
|
||||
closer, ok := child.FS.(io.Closer)
|
||||
if ok {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetChild returns the child with the given name and a boolean indicating
|
||||
// whether or not it was found.
|
||||
func (cfs *CompositeFileSystem) GetChild(name string) (webdav.FileSystem, bool) {
|
||||
_, child := cfs.findChildLocked(name)
|
||||
if child == nil {
|
||||
return nil, false
|
||||
}
|
||||
return child.FS, true
|
||||
}
|
||||
|
||||
func (cfs *CompositeFileSystem) findChildLocked(name string) (int, *Child) {
|
||||
var child *Child
|
||||
i, found := slices.BinarySearchFunc(cfs.children, name, func(child *Child, name string) int {
|
||||
return strings.Compare(child.Name, name)
|
||||
})
|
||||
if found {
|
||||
child = cfs.children[i]
|
||||
}
|
||||
return i, child
|
||||
}
|
||||
|
||||
// pathInfoFor returns a pathInfo for the given filename. If the filename
|
||||
// refers to a Child that does not exist within this CompositeFileSystem,
|
||||
// it will return the error os.ErrNotExist. Even when returning an error,
|
||||
// it will still return a complete pathInfo.
|
||||
func (cfs *CompositeFileSystem) pathInfoFor(name string) (pathInfo, error) {
|
||||
cfs.childrenMu.Lock()
|
||||
defer cfs.childrenMu.Unlock()
|
||||
|
||||
var info pathInfo
|
||||
pathComponents := shared.CleanAndSplit(name)
|
||||
_, info.child = cfs.findChildLocked(pathComponents[0])
|
||||
info.refersToChild = len(pathComponents) == 1
|
||||
if !info.refersToChild {
|
||||
info.pathOnChild = path.Join(pathComponents[1:]...)
|
||||
}
|
||||
if info.child == nil {
|
||||
return info, os.ErrNotExist
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// pathInfo provides information about a path
|
||||
type pathInfo struct {
|
||||
// child is the Child corresponding to the first component of the path.
|
||||
child *Child
|
||||
// refersToChild indicates that that path refers directly to the child
|
||||
// (i.e. the path has only 1 component).
|
||||
refersToChild bool
|
||||
// pathOnChild is the path within the child (i.e. path minus leading component)
|
||||
// if and only if refersToChild is false.
|
||||
pathOnChild string
|
||||
}
|
||||
|
||||
func (cfs *CompositeFileSystem) Close() error {
|
||||
cfs.childrenMu.Lock()
|
||||
children := cfs.children
|
||||
cfs.childrenMu.Unlock()
|
||||
|
||||
for _, child := range children {
|
||||
closer, ok := child.FS.(io.Closer)
|
||||
if ok {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -1,497 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
func TestStat(t *testing.T) {
|
||||
cfs, dir1, _, clock, close := createFileSystem(t, nil)
|
||||
defer close()
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
name string
|
||||
expected fs.FileInfo
|
||||
err error
|
||||
}{
|
||||
{
|
||||
label: "root folder",
|
||||
name: "/",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "/",
|
||||
Sized: 0,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "remote1",
|
||||
name: "/remote1",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "/remote1",
|
||||
Sized: 0,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "remote2",
|
||||
name: "/remote2",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "/remote2",
|
||||
Sized: 0,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "non-existent remote",
|
||||
name: "/remote3",
|
||||
err: os.ErrNotExist,
|
||||
},
|
||||
{
|
||||
label: "file on remote1",
|
||||
name: "/remote1/file1.txt",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "/remote1/file1.txt",
|
||||
Sized: stat(t, filepath.Join(dir1, "file1.txt")).Size(),
|
||||
ModdedTime: stat(t, filepath.Join(dir1, "file1.txt")).ModTime(),
|
||||
Dir: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, test := range tests {
|
||||
t.Run(test.label, func(t *testing.T) {
|
||||
fi, err := cfs.Stat(ctx, test.name)
|
||||
if test.err != nil {
|
||||
if err == nil || !errors.Is(err, test.err) {
|
||||
t.Errorf("expected error: %v got: %v", test.err, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unable to stat file: %v", err)
|
||||
} else {
|
||||
infosEqual(t, test.expected, fi)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatWithStatChildren(t *testing.T) {
|
||||
cfs, dir1, dir2, _, close := createFileSystem(t, &Options{StatChildren: true})
|
||||
defer close()
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
name string
|
||||
expected fs.FileInfo
|
||||
}{
|
||||
{
|
||||
label: "root folder",
|
||||
name: "/",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "/",
|
||||
Sized: 0,
|
||||
ModdedTime: stat(t, dir2).ModTime(), // ModTime should be greatest modtime of children
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "remote1",
|
||||
name: "/remote1",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "/remote1",
|
||||
Sized: stat(t, dir1).Size(),
|
||||
ModdedTime: stat(t, dir1).ModTime(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "remote2",
|
||||
name: "/remote2",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "/remote2",
|
||||
Sized: stat(t, dir2).Size(),
|
||||
ModdedTime: stat(t, dir2).ModTime(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, test := range tests {
|
||||
t.Run(test.label, func(t *testing.T) {
|
||||
fi, err := cfs.Stat(ctx, test.name)
|
||||
if err != nil {
|
||||
t.Errorf("unable to stat file: %v", err)
|
||||
} else {
|
||||
infosEqual(t, test.expected, fi)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMkdir(t *testing.T) {
|
||||
fs, _, _, _, close := createFileSystem(t, nil)
|
||||
defer close()
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
name string
|
||||
perm os.FileMode
|
||||
err error
|
||||
}{
|
||||
{
|
||||
label: "attempt to create root folder",
|
||||
name: "/",
|
||||
},
|
||||
{
|
||||
label: "attempt to create remote",
|
||||
name: "/remote1",
|
||||
},
|
||||
{
|
||||
label: "attempt to create non-existent remote",
|
||||
name: "/remote3",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to create file on non-existent remote",
|
||||
name: "/remote3/somefile.txt",
|
||||
err: os.ErrNotExist,
|
||||
},
|
||||
{
|
||||
label: "success",
|
||||
name: "/remote1/newfile.txt",
|
||||
perm: 0772,
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, test := range tests {
|
||||
t.Run(test.label, func(t *testing.T) {
|
||||
err := fs.Mkdir(ctx, test.name, test.perm)
|
||||
if test.err != nil {
|
||||
if err == nil || !errors.Is(err, test.err) {
|
||||
t.Errorf("expected error: %v got: %v", test.err, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
} else {
|
||||
fi, err := fs.Stat(ctx, test.name)
|
||||
if err != nil {
|
||||
t.Errorf("unable to stat file: %v", err)
|
||||
} else {
|
||||
if fi.Name() != test.name {
|
||||
t.Errorf("expected name: %v got: %v", test.name, fi.Name())
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
t.Error("expected directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAll(t *testing.T) {
|
||||
fs, _, _, _, close := createFileSystem(t, nil)
|
||||
defer close()
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
label: "attempt to remove root folder",
|
||||
name: "/",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to remove remote",
|
||||
name: "/remote1",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to remove non-existent remote",
|
||||
name: "/remote3",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to remove file on non-existent remote",
|
||||
name: "/remote3/somefile.txt",
|
||||
err: os.ErrNotExist,
|
||||
},
|
||||
{
|
||||
label: "remove non-existent file",
|
||||
name: "/remote1/nonexistent.txt",
|
||||
},
|
||||
{
|
||||
label: "remove existing file",
|
||||
name: "/remote1/dir1",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, test := range tests {
|
||||
t.Run(test.label, func(t *testing.T) {
|
||||
err := fs.RemoveAll(ctx, test.name)
|
||||
if test.err != nil {
|
||||
if err == nil || !errors.Is(err, test.err) {
|
||||
t.Errorf("expected error: %v got: %v", test.err, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
} else {
|
||||
_, err := fs.Stat(ctx, test.name)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expected dir to be gone: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRename(t *testing.T) {
|
||||
fs, _, _, _, close := createFileSystem(t, nil)
|
||||
defer close()
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
oldName string
|
||||
newName string
|
||||
err error
|
||||
expectedNewInfo *shared.StaticFileInfo
|
||||
}{
|
||||
{
|
||||
label: "attempt to move root folder",
|
||||
oldName: "/",
|
||||
newName: "/remote2/copy.txt",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to move to root folder",
|
||||
oldName: "/remote1/file1.txt",
|
||||
newName: "/",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to move to remote",
|
||||
oldName: "/remote1/file1.txt",
|
||||
newName: "/remote2",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to move to non-existent remote",
|
||||
oldName: "/remote1/file1.txt",
|
||||
newName: "/remote3",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to move file from non-existent remote",
|
||||
oldName: "/remote3/file1.txt",
|
||||
newName: "/remote1/file1.txt",
|
||||
err: os.ErrNotExist,
|
||||
},
|
||||
{
|
||||
label: "attempt to move file to a non-existent remote",
|
||||
oldName: "/remote2/file2.txt",
|
||||
newName: "/remote3/file2.txt",
|
||||
err: os.ErrNotExist,
|
||||
},
|
||||
{
|
||||
label: "attempt to move file across remotes",
|
||||
oldName: "/remote1/file1.txt",
|
||||
newName: "/remote2/file1.txt",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to move remote itself",
|
||||
oldName: "/remote1",
|
||||
newName: "/remote2",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "attempt to move to a remote",
|
||||
oldName: "/remote1/file2.txt",
|
||||
newName: "/remote2",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
{
|
||||
label: "move file within remote",
|
||||
oldName: "/remote2/file2.txt",
|
||||
newName: "/remote2/file3.txt",
|
||||
expectedNewInfo: &shared.StaticFileInfo{
|
||||
Named: "/remote2/file3.txt",
|
||||
Sized: 5,
|
||||
Dir: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, test := range tests {
|
||||
t.Run(test.label, func(t *testing.T) {
|
||||
err := fs.Rename(ctx, test.oldName, test.newName)
|
||||
if test.err != nil {
|
||||
if err == nil || test.err.Error() != err.Error() {
|
||||
t.Errorf("expected error: %v got: %v", test.err, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
} else {
|
||||
fi, err := fs.Stat(ctx, test.newName)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
} else {
|
||||
// Override modTime to avoid having to compare it
|
||||
test.expectedNewInfo.ModdedTime = fi.ModTime()
|
||||
infosEqual(t, test.expectedNewInfo, fi)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createFileSystem(t *testing.T, opts *Options) (webdav.FileSystem, string, string, *tstest.Clock, func()) {
|
||||
l1, dir1 := startRemote(t)
|
||||
l2, dir2 := startRemote(t)
|
||||
|
||||
// Make some files, use perms 0666 as lowest common denominator that works
|
||||
// on both UNIX and Windows.
|
||||
err := os.WriteFile(filepath.Join(dir1, "file1.txt"), []byte("12345"), 0666)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(dir2, "file2.txt"), []byte("54321"), 0666)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// make some directories
|
||||
err = os.Mkdir(filepath.Join(dir1, "dir1"), 0666)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = os.Mkdir(filepath.Join(dir2, "dir2"), 0666)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &Options{}
|
||||
}
|
||||
if opts.Logf == nil {
|
||||
opts.Logf = t.Logf
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()})
|
||||
opts.Clock = clock
|
||||
|
||||
fs := New(*opts)
|
||||
fs.AddChild(&Child{Name: "remote4", FS: &closeableFS{webdav.Dir(dir2)}})
|
||||
fs.SetChildren(&Child{Name: "remote2", FS: webdav.Dir(dir2)},
|
||||
&Child{Name: "remote3", FS: &closeableFS{webdav.Dir(dir2)}},
|
||||
)
|
||||
fs.AddChild(&Child{Name: "remote1", FS: webdav.Dir(dir1)})
|
||||
fs.RemoveChild("remote3")
|
||||
|
||||
child, ok := fs.GetChild("remote1")
|
||||
if !ok || child == nil {
|
||||
t.Fatal("unable to GetChild(remote1)")
|
||||
}
|
||||
child, ok = fs.GetChild("remote2")
|
||||
if !ok || child == nil {
|
||||
t.Fatal("unable to GetChild(remote2)")
|
||||
}
|
||||
child, ok = fs.GetChild("remote3")
|
||||
if ok || child != nil {
|
||||
t.Fatal("should have been able to GetChild(remote3)")
|
||||
}
|
||||
child, ok = fs.GetChild("remote4")
|
||||
if ok || child != nil {
|
||||
t.Fatal("should have been able to GetChild(remote4)")
|
||||
}
|
||||
|
||||
return fs, dir1, dir2, clock, func() {
|
||||
defer l1.Close()
|
||||
defer os.RemoveAll(dir1)
|
||||
defer l2.Close()
|
||||
defer os.RemoveAll(dir2)
|
||||
}
|
||||
}
|
||||
|
||||
func stat(t *testing.T, path string) fs.FileInfo {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fi
|
||||
}
|
||||
|
||||
func startRemote(t *testing.T) (net.Listener, string) {
|
||||
dir := t.TempDir()
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := &webdav.Handler{
|
||||
FileSystem: webdav.Dir(dir),
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
}
|
||||
|
||||
s := &http.Server{Handler: h}
|
||||
go s.Serve(l)
|
||||
|
||||
return l, dir
|
||||
}
|
||||
|
||||
func infosEqual(t *testing.T, expected, actual fs.FileInfo) {
|
||||
t.Helper()
|
||||
if expected.Name() != actual.Name() {
|
||||
t.Errorf("expected name: %v got: %v", expected.Name(), actual.Name())
|
||||
}
|
||||
if expected.Size() != actual.Size() {
|
||||
t.Errorf("expected Size: %v got: %v", expected.Size(), actual.Size())
|
||||
}
|
||||
if !expected.ModTime().Truncate(time.Second).UTC().Equal(actual.ModTime().Truncate(time.Second).UTC()) {
|
||||
t.Errorf("expected ModTime: %v got: %v", expected.ModTime(), actual.ModTime())
|
||||
}
|
||||
if expected.IsDir() != actual.IsDir() {
|
||||
t.Errorf("expected IsDir: %v got: %v", expected.IsDir(), actual.IsDir())
|
||||
}
|
||||
}
|
||||
|
||||
// closeableFS is a webdav.FileSystem that implements io.Closer()
|
||||
type closeableFS struct {
|
||||
webdav.FileSystem
|
||||
}
|
||||
|
||||
func (cfs *closeableFS) Close() error {
|
||||
return nil
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
)
|
||||
|
||||
// Mkdir implements webdav.Filesystem. The root of this file system is
|
||||
// read-only, so any attempts to make directories within the root will fail
|
||||
// with os.ErrPermission. Attempts to make directories within one of the child
|
||||
// filesystems will be handled by the respective child.
|
||||
func (cfs *CompositeFileSystem) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
if shared.IsRoot(name) {
|
||||
// root directory already exists, consider this okay
|
||||
return nil
|
||||
}
|
||||
|
||||
pathInfo, err := cfs.pathInfoFor(name)
|
||||
if pathInfo.refersToChild {
|
||||
// children can't be made
|
||||
if pathInfo.child != nil {
|
||||
// since child already exists, consider this okay
|
||||
return nil
|
||||
}
|
||||
// since child doesn't exist, return permission error
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pathInfo.child.FS.Mkdir(ctx, pathInfo.pathOnChild, perm)
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
)
|
||||
|
||||
// OpenFile implements interface webdav.Filesystem.
|
||||
func (cfs *CompositeFileSystem) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
if !shared.IsRoot(name) {
|
||||
pathInfo, err := cfs.pathInfoFor(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pathInfo.refersToChild {
|
||||
// this is the child itself, ask it to open its root
|
||||
return pathInfo.child.FS.OpenFile(ctx, "/", flag, perm)
|
||||
}
|
||||
|
||||
return pathInfo.child.FS.OpenFile(ctx, pathInfo.pathOnChild, flag, perm)
|
||||
}
|
||||
|
||||
// the root directory contains one directory for each child
|
||||
di, err := cfs.Stat(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &shared.DirFile{
|
||||
Info: di,
|
||||
LoadChildren: func() ([]fs.FileInfo, error) {
|
||||
cfs.childrenMu.Lock()
|
||||
children := cfs.children
|
||||
cfs.childrenMu.Unlock()
|
||||
|
||||
childInfos := make([]fs.FileInfo, 0, len(cfs.children))
|
||||
for _, c := range children {
|
||||
if c.isAvailable() {
|
||||
var childInfo fs.FileInfo
|
||||
if cfs.statChildren {
|
||||
fi, err := c.FS.Stat(ctx, "/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// we use the full name
|
||||
childInfo = shared.RenamedFileInfo(ctx, c.Name, fi)
|
||||
} else {
|
||||
// always use now() as the modified time to bust caches
|
||||
childInfo = shared.ReadOnlyDirInfo(c.Name, cfs.now())
|
||||
}
|
||||
childInfos = append(childInfos, childInfo)
|
||||
}
|
||||
}
|
||||
return childInfos, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
)
|
||||
|
||||
// RemoveAll implements webdav.File. The root of this file system is read-only,
|
||||
// so attempting to call RemoveAll on the root will fail with os.ErrPermission.
|
||||
// RemoveAll within a child will be handled by the respective child.
|
||||
func (cfs *CompositeFileSystem) RemoveAll(ctx context.Context, name string) error {
|
||||
if shared.IsRoot(name) {
|
||||
// root directory is read-only
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
pathInfo, err := cfs.pathInfoFor(name)
|
||||
if pathInfo.refersToChild {
|
||||
// children can't be removed
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pathInfo.child.FS.RemoveAll(ctx, pathInfo.pathOnChild)
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
)
|
||||
|
||||
// Rename implements interface webdav.FileSystem. The root of this file system
|
||||
// is read-only, so any attempt to rename a child within the root of this
|
||||
// filesystem will fail with os.ErrPermission. Renaming across children is not
|
||||
// supported and will fail with os.ErrPermission. Renaming within a child will
|
||||
// be handled by the respective child.
|
||||
func (cfs *CompositeFileSystem) Rename(ctx context.Context, oldName, newName string) error {
|
||||
if shared.IsRoot(oldName) || shared.IsRoot(newName) {
|
||||
// root directory is read-only
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
oldPathInfo, err := cfs.pathInfoFor(oldName)
|
||||
if oldPathInfo.refersToChild {
|
||||
// children themselves are read-only
|
||||
return os.ErrPermission
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newPathInfo, err := cfs.pathInfoFor(newName)
|
||||
if newPathInfo.refersToChild {
|
||||
// children themselves are read-only
|
||||
return os.ErrPermission
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if oldPathInfo.child != newPathInfo.child {
|
||||
// moving a file across children is not permitted
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// file is moving within the same child, let the child handle it
|
||||
return oldPathInfo.child.FS.Rename(ctx, oldPathInfo.pathOnChild, newPathInfo.pathOnChild)
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
|
||||
"tailscale.com/tailfs/tailfsimpl/shared"
|
||||
)
|
||||
|
||||
// Stat implements webdav.FileSystem.
|
||||
func (cfs *CompositeFileSystem) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
|
||||
if shared.IsRoot(name) {
|
||||
// Root is a directory
|
||||
// always use now() as the modified time to bust caches
|
||||
fi := shared.ReadOnlyDirInfo(name, cfs.now())
|
||||
if cfs.statChildren {
|
||||
// update last modified time based on children
|
||||
cfs.childrenMu.Lock()
|
||||
children := cfs.children
|
||||
cfs.childrenMu.Unlock()
|
||||
for i, child := range children {
|
||||
childInfo, err := child.FS.Stat(ctx, "/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i == 0 || childInfo.ModTime().After(fi.ModTime(< |