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
Percy Wegmann 8 months ago committed by Percy Wegmann
parent e1bd7488d0
commit 50fb8b9123

@ -36,7 +36,7 @@ func TestDeps(t *testing.T) {
// Make sure we don't again accidentally bring in a dependency on
// TailFS or its transitive dependencies
"tailscale.com/tailfs/tailfsimpl": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/tailscale/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
},
}.Check(t)
}

@ -109,7 +109,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4
github.com/jellydator/ttlcache/v3 from tailscale.com/tailfs/tailfsimpl/webdavfs
github.com/jellydator/ttlcache/v3 from tailscale.com/tailfs/tailfsimpl/compositedav
L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
@ -155,7 +155,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tailscale/gowebdav from tailscale.com/tailfs/tailfsimpl/webdavfs
github.com/tailscale/hujson from tailscale.com/ipn/conffile
L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+
github.com/tailscale/peercred from tailscale.com/ipn/ipnauth
@ -323,9 +322,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+
tailscale.com/tailfs from tailscale.com/client/tailscale+
tailscale.com/tailfs/tailfsimpl from tailscale.com/cmd/tailscaled
tailscale.com/tailfs/tailfsimpl/compositefs from tailscale.com/tailfs/tailfsimpl
tailscale.com/tailfs/tailfsimpl/compositedav from tailscale.com/tailfs/tailfsimpl
tailscale.com/tailfs/tailfsimpl/dirfs from tailscale.com/tailfs/tailfsimpl+
tailscale.com/tailfs/tailfsimpl/shared from tailscale.com/tailfs/tailfsimpl+
tailscale.com/tailfs/tailfsimpl/webdavfs from tailscale.com/tailfs/tailfsimpl
💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock

@ -63,12 +63,12 @@ require (
github.com/prometheus/common v0.46.0
github.com/safchain/ethtool v0.3.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/studio-b12/gowebdav v0.9.0
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41
github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05
github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85

@ -853,6 +853,8 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU=
github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c h1:+aPplBwWcHBo6q9xrfWdMrT9o4kltkmmvpemgIjep/8=
@ -869,8 +871,6 @@ github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780 h1:U0J2C
github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126 h1:EBLH+PeC3efXmUi82yEMxjlcKhDwAUZTi0tIT4Q8oTg=
github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126/go.mod h1:UCbnLJ2ebWLs28V9ubpXbq4Qx3e0q1TVoM1AC3Z2b40=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734 h1:93cvKHbvsPK3MKfFTvR00d0b0R0bzRKBW9yrj813fhI=

@ -850,7 +850,7 @@ func TestDeps(t *testing.T) {
// Make sure we don't again accidentally bring in a dependency on
// TailFS or its transitive dependencies
"tailscale.com/tailfs/tailfsimpl": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/tailscale/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
},
}.Check(t)
}

@ -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(<