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>
This commit is contained in:
Percy Wegmann 2024-02-21 06:40:12 -06:00 committed by Percy Wegmann
parent e1bd7488d0
commit 50fb8b9123
33 changed files with 1186 additions and 2008 deletions

View File

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

View File

@ -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/iana from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/interfaces 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 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/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/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+ 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/scpd from github.com/tailscale/goupnp
github.com/tailscale/goupnp/soap 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/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 github.com/tailscale/hujson from tailscale.com/ipn/conffile
L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+ L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+
github.com/tailscale/peercred from tailscale.com/ipn/ipnauth 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/taildrop from tailscale.com/ipn/ipnlocal+
tailscale.com/tailfs from tailscale.com/client/tailscale+ tailscale.com/tailfs from tailscale.com/client/tailscale+
tailscale.com/tailfs/tailfsimpl from tailscale.com/cmd/tailscaled 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/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 💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock

2
go.mod
View File

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

4
go.sum
View File

@ -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.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 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/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 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= 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= 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/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 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= 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 h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 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= github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734 h1:93cvKHbvsPK3MKfFTvR00d0b0R0bzRKBW9yrj813fhI=

View File

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

View File

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

View File

@ -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)
}

View File

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

View File

@ -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")
}
}

View File

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

View File

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

View File

@ -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)
}

View File

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

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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()) {
fi.ModdedTime = childInfo.ModTime()
}
}
}
return fi, nil
}
pathInfo, err := cfs.pathInfoFor(name)
if err != nil {
return nil, err
}
if pathInfo.refersToChild && !cfs.statChildren {
// Return a read-only FileInfo for this child.
// Always use now() as the modified time to bust caches.
return shared.ReadOnlyDirInfo(name, cfs.now()), nil
}
fi, err := pathInfo.child.FS.Stat(ctx, pathInfo.pathOnChild)
if err != nil {
return nil, err
}
// we use the full name, which is different than what the child sees
return shared.RenamedFileInfo(ctx, name, fi), nil
}

View File

@ -0,0 +1,101 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package dirfs provides a webdav.FileSystem that looks like a read-only
// directory containing only subdirectories.
package dirfs
import (
"slices"
"strings"
"time"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstime"
)
// Child is subdirectory of an FS.
type Child struct {
// Name is the name of the child
Name string
// Available is a function indicating whether or not the child is currently
// available. Unavailable children are excluded from the FS's directory
// listing. Available must be safe for concurrent use.
Available func() bool
}
func (c *Child) isAvailable() bool {
if c.Available == nil {
return true
}
return c.Available()
}
// FS is a read-only webdav.FileSystem that is composed of multiple child
// folders.
//
// When listing the contents of this FileSystem's root directory, children will
// be ordered in the order they're given to the FS.
//
// Children in an FS cannot be added, removed or renamed via operations on the
// webdav.FileSystem interface like filesystem.Mkdir or filesystem.OpenFile.
//
// Any attempts to perform operations on paths inside of children will result
// in a panic, as these are not expected to be performed on this FS.
//
// An FS an optionally have a StaticRoot, which will insert a folder with that
// StaticRoot into the tree, like this:
//
// -- <StaticRoot>
// ----- <Child>
// ----- <Child>
type FS struct {
// Children configures the full set of children of this FS.
Children []*Child
// Clock, if given, will cause this FS to use Clock.now() as the current
// time.
Clock tstime.Clock
// StaticRoot, if given, will insert the given name as a static root into
// every path.
StaticRoot string
}
func (dfs *FS) findChild(name string) (int, *Child) {
var child *Child
i, found := slices.BinarySearchFunc(dfs.Children, name, func(child *Child, name string) int {
return strings.Compare(child.Name, name)
})
if found {
child = dfs.Children[i]
}
return i, child
}
// childFor returns the child for the given filename. If the filename refers to
// a path inside of a child, this will panic.
func (dfs *FS) childFor(name string) *Child {
pathComponents := shared.CleanAndSplit(name)
if len(pathComponents) != 1 {
panic("dirfs does not permit reaching into child directories")
}
_, child := dfs.findChild(pathComponents[0])
return child
}
func (dfs *FS) now() time.Time {
if dfs.Clock != nil {
return dfs.Clock.Now()
}
return time.Now()
}
func (dfs *FS) trimStaticRoot(name string) (string, bool) {
before, after, found := strings.Cut(name, "/"+dfs.StaticRoot)
if !found {
return before, false
}
return after, shared.IsRoot(after)
}

View File

@ -0,0 +1,348 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"errors"
"io/fs"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstest"
)
func TestStat(t *testing.T) {
cfs, _, _, clock := createFileSystem(t)
tests := []struct {
label string
name string
expected fs.FileInfo
err error
}{
{
label: "root folder",
name: "",
expected: &shared.StaticFileInfo{
Named: "",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "static root folder",
name: "/domain",
expected: &shared.StaticFileInfo{
Named: "domain",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote1",
name: "/domain/remote1",
expected: &shared.StaticFileInfo{
Named: "remote1",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote2",
name: "/domain/remote2",
expected: &shared.StaticFileInfo{
Named: "remote2",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "non-existent remote",
name: "remote3",
err: os.ErrNotExist,
},
}
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 !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
} else {
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
infosEqual(t, test.expected, fi)
}
}
})
}
}
func TestListDir(t *testing.T) {
cfs, _, _, clock := createFileSystem(t)
tests := []struct {
label string
name string
expected []fs.FileInfo
err error
}{
{
label: "root folder",
name: "",
expected: []fs.FileInfo{
&shared.StaticFileInfo{
Named: "domain",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
},
{
label: "static root folder",
name: "/domain",
expected: []fs.FileInfo{
&shared.StaticFileInfo{
Named: "remote1",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
&shared.StaticFileInfo{
Named: "remote2",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
&shared.StaticFileInfo{
Named: "remote4",
Sized: 0,
Moded: 0555,
ModdedTime: clock.Now(),
Dir: true,
},
},
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
var infos []fs.FileInfo
file, err := cfs.OpenFile(ctx, test.name, os.O_RDONLY, 0)
if err == nil {
defer file.Close()
infos, err = file.Readdir(0)
}
if test.err != nil {
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
} else {
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
if len(infos) != len(test.expected) {
t.Errorf("wrong number of file infos, want %d, got %d", len(test.expected), len(infos))
} else {
for i, expected := range test.expected {
infosEqual(t, expected, infos[i])
}
}
}
}
})
}
}
func TestMkdir(t *testing.T) {
fs, _, _, _ := createFileSystem(t)
tests := []struct {
label string
name string
perm os.FileMode
err error
}{
{
label: "attempt to create root folder",
name: "/",
},
{
label: "attempt to create static root folder",
name: "/domain",
},
{
label: "attempt to create remote",
name: "/domain/remote1",
},
{
label: "attempt to create non-existent remote",
name: "/domain/remote3",
err: os.ErrPermission,
},
}
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 !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestRemoveAll(t *testing.T) {
fs, _, _, _ := createFileSystem(t)
tests := []struct {
label string
name string
err error
}{
{
label: "attempt to remove root folder",
name: "/",
err: os.ErrPermission,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.RemoveAll(ctx, test.name)
if !errors.Is(err, test.err) {
t.Errorf("got %v, want %v", err, test.err)
}
})
}
}
func TestRename(t *testing.T) {
fs, _, _, _ := createFileSystem(t)
tests := []struct {
label string
oldName string
newName string
err error
}{
{
label: "attempt to move root folder",
oldName: "/",
newName: "/domain/remote2/copy.txt",
err: os.ErrPermission,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.Rename(ctx, test.oldName, test.newName)
if !errors.Is(err, test.err) {
t.Errorf("got %v, want: %v", err, test.err)
}
})
}
}
func createFileSystem(t *testing.T) (webdav.FileSystem, string, string, *tstest.Clock) {
s1, dir1 := startRemote(t)
s2, 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)
}
clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()})
fs := &FS{
Clock: clock,
StaticRoot: "domain",
Children: []*Child{
{Name: "remote1"},
{Name: "remote2"},
{Name: "remote4"},
},
}
t.Cleanup(func() {
defer s1.Close()
defer os.RemoveAll(dir1)
defer s2.Close()
defer os.RemoveAll(dir2)
})
return fs, dir1, dir2, clock
}
func startRemote(t *testing.T) (*httptest.Server, string) {
dir := t.TempDir()
h := &webdav.Handler{
FileSystem: webdav.Dir(dir),
LockSystem: webdav.NewMemLS(),
}
s := httptest.NewServer(h)
t.Cleanup(s.Close)
return s, dir
}
func infosEqual(t *testing.T, expected, actual fs.FileInfo) {
t.Helper()
sfi, ok := actual.(*shared.StaticFileInfo)
if ok {
// zero out BirthedTime because we don't want to compare that
sfi.BirthedTime = time.Time{}
}
if diff := cmp.Diff(actual, expected); diff != "" {
t.Errorf("Wrong file info (-got, +want):\n%s", diff)
}
}

View File

@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"os"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// Mkdir implements webdav.FileSystem. All attempts to Mkdir a directory that
// already exists will succeed. All other attempts will fail with
// os.ErrPermission.
func (dfs *FS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
nameWithoutStaticRoot, isStaticRoot := dfs.trimStaticRoot(name)
if isStaticRoot || shared.IsRoot(name) {
// root directory already exists, consider this okay
return nil
}
child := dfs.childFor(nameWithoutStaticRoot)
if child != nil {
// child already exists, consider this okay
return nil
}
return &os.PathError{Op: "mkdir", Path: name, Err: os.ErrPermission}
}

View File

@ -0,0 +1,63 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"io/fs"
"os"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// OpenFile implements interface webdav.Filesystem.
func (dfs *FS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
_, isStaticRoot := dfs.trimStaticRoot(name)
if !isStaticRoot && !shared.IsRoot(name) {
// Show a folder with no children to represent the requested child. In
// practice, the children of this folder are never read, we just need
// to give webdav a file here which it uses to call file.Stat(). So,
// even though the Child may in fact have its own children, it doesn't
// matter here.
return &shared.DirFile{
Info: shared.ReadOnlyDirInfo(name, dfs.now()),
LoadChildren: func() ([]fs.FileInfo, error) {
return nil, nil
},
}, nil
}
di, err := dfs.Stat(ctx, name)
if err != nil {
return nil, err
}
if dfs.StaticRoot != "" && !isStaticRoot {
// Show a folder with a single subfolder that is the static root.
return &shared.DirFile{
Info: di,
LoadChildren: func() ([]fs.FileInfo, error) {
return []fs.FileInfo{
shared.ReadOnlyDirInfo(dfs.StaticRoot, dfs.now()),
}, nil
},
}, nil
}
// Show a folder with one subfolder for each Child of this FS.
return &shared.DirFile{
Info: di,
LoadChildren: func() ([]fs.FileInfo, error) {
childInfos := make([]fs.FileInfo, 0, len(dfs.Children))
for _, c := range dfs.Children {
if c.isAvailable() {
childInfo := shared.ReadOnlyDirInfo(c.Name, dfs.now())
childInfos = append(childInfos, childInfo)
}
}
return childInfos, nil
},
}, nil
}

View File

@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"os"
)
// RemoveAll implements webdav.File. No removal is supported and this always
// returns os.ErrPermission.
func (dfs *FS) RemoveAll(ctx context.Context, name string) error {
return &os.PathError{Op: "rm", Path: name, Err: os.ErrPermission}
}

View File

@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"os"
)
// Rename implements interface webdav.FileSystem. No renaming is supported and
// this always returns os.ErrPermission.
func (dfs *FS) Rename(ctx context.Context, oldName, newName string) error {
return &os.PathError{Op: "mv", Path: oldName, Err: os.ErrPermission}
}

View File

@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package dirfs
import (
"context"
"io/fs"
"os"
"tailscale.com/tailfs/tailfsimpl/shared"
)
// Stat implements webdav.FileSystem.
func (dfs *FS) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
nameWithoutStaticRoot, isStaticRoot := dfs.trimStaticRoot(name)
if isStaticRoot || shared.IsRoot(name) {
// Static root is a directory, always use now() as the modified time to
// bust caches.
fi := shared.ReadOnlyDirInfo(name, dfs.now())
return fi, nil
}
child := dfs.childFor(nameWithoutStaticRoot)
if child == nil {
return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist}
}
return shared.ReadOnlyDirInfo(name, dfs.now()), nil
}

View File

@ -10,10 +10,9 @@
"net/http" "net/http"
"time" "time"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs" "tailscale.com/tailfs"
"tailscale.com/tailfs/tailfsimpl/compositefs" "tailscale.com/tailfs/tailfsimpl/compositedav"
"tailscale.com/tailfs/tailfsimpl/webdavfs" "tailscale.com/tailfs/tailfsimpl/dirfs"
"tailscale.com/types/logger" "tailscale.com/types/logger"
) )
@ -33,7 +32,10 @@ func NewFileSystemForLocal(logf logger.Logf) *FileSystemForLocal {
} }
fs := &FileSystemForLocal{ fs := &FileSystemForLocal{
logf: logf, logf: logf,
cfs: compositefs.New(compositefs.Options{Logf: logf}), h: &compositedav.Handler{
Logf: logf,
StatCache: &compositedav.StatCache{TTL: statCacheTTL},
},
listener: newConnListener(), listener: newConnListener(),
} }
fs.startServing() fs.startServing()
@ -44,17 +46,12 @@ func NewFileSystemForLocal(logf logger.Logf) *FileSystemForLocal {
// provides a unified WebDAV interface to remote TailFS shares on other nodes. // provides a unified WebDAV interface to remote TailFS shares on other nodes.
type FileSystemForLocal struct { type FileSystemForLocal struct {
logf logger.Logf logf logger.Logf
cfs *compositefs.CompositeFileSystem h *compositedav.Handler
listener *connListener listener *connListener
} }
func (s *FileSystemForLocal) startServing() { func (s *FileSystemForLocal) startServing() {
hs := &http.Server{ hs := &http.Server{Handler: s.h}
Handler: &webdav.Handler{
FileSystem: s.cfs,
LockSystem: webdav.NewMemLS(),
},
}
go func() { go func() {
err := hs.Serve(s.listener) err := hs.Serve(s.listener)
if err != nil { if err != nil {
@ -73,31 +70,24 @@ func (s *FileSystemForLocal) HandleConn(conn net.Conn, remoteAddr net.Addr) erro
// using a map of name -> url. If transport is specified, that transport // using a map of name -> url. If transport is specified, that transport
// will be used to connect to these remotes. // will be used to connect to these remotes.
func (s *FileSystemForLocal) SetRemotes(domain string, remotes []*tailfs.Remote, transport http.RoundTripper) { func (s *FileSystemForLocal) SetRemotes(domain string, remotes []*tailfs.Remote, transport http.RoundTripper) {
children := make([]*compositefs.Child, 0, len(remotes)) children := make([]*compositedav.Child, 0, len(remotes))
for _, remote := range remotes { for _, remote := range remotes {
opts := webdavfs.Options{ children = append(children, &compositedav.Child{
URL: remote.URL, Child: &dirfs.Child{
Transport: transport,
StatCacheTTL: statCacheTTL,
Logf: s.logf,
}
children = append(children, &compositefs.Child{
Name: remote.Name, Name: remote.Name,
FS: webdavfs.New(opts),
Available: remote.Available, Available: remote.Available,
},
BaseURL: remote.URL,
Transport: transport,
}) })
} }
domainChild, found := s.cfs.GetChild(domain) s.h.SetChildren(domain, children...)
if !found {
domainChild = compositefs.New(compositefs.Options{Logf: s.logf})
s.cfs.SetChildren(&compositefs.Child{Name: domain, FS: domainChild})
}
domainChild.(*compositefs.CompositeFileSystem).SetChildren(children...)
} }
// Close() stops serving the WebDAV content // Close() stops serving the WebDAV content
func (s *FileSystemForLocal) Close() error { func (s *FileSystemForLocal) Close() error {
s.cfs.Close() err := s.listener.Close()
return s.listener.Close() s.h.Close()
return err
} }

View File

@ -12,6 +12,7 @@
"net" "net"
"net/http" "net/http"
"net/netip" "net/netip"
"net/url"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@ -21,9 +22,9 @@
"github.com/tailscale/xnet/webdav" "github.com/tailscale/xnet/webdav"
"tailscale.com/safesocket" "tailscale.com/safesocket"
"tailscale.com/tailfs" "tailscale.com/tailfs"
"tailscale.com/tailfs/tailfsimpl/compositefs" "tailscale.com/tailfs/tailfsimpl/compositedav"
"tailscale.com/tailfs/tailfsimpl/dirfs"
"tailscale.com/tailfs/tailfsimpl/shared" "tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tailfs/tailfsimpl/webdavfs"
"tailscale.com/types/logger" "tailscale.com/types/logger"
) )
@ -34,7 +35,7 @@ func NewFileSystemForRemote(logf logger.Logf) *FileSystemForRemote {
fs := &FileSystemForRemote{ fs := &FileSystemForRemote{
logf: logf, logf: logf,
lockSystem: webdav.NewMemLS(), lockSystem: webdav.NewMemLS(),
fileSystems: make(map[string]webdav.FileSystem), children: make(map[string]*compositedav.Child),
userServers: make(map[string]*userServer), userServers: make(map[string]*userServer),
} }
return fs return fs
@ -50,7 +51,7 @@ type FileSystemForRemote struct {
mu sync.RWMutex mu sync.RWMutex
fileServerAddr string fileServerAddr string
shares map[string]*tailfs.Share shares map[string]*tailfs.Share
fileSystems map[string]webdav.FileSystem children map[string]*compositedav.Child
userServers map[string]*userServer userServers map[string]*userServer
} }
@ -81,27 +82,29 @@ func (s *FileSystemForRemote) SetShares(shares map[string]*tailfs.Share) {
} }
} }
fileSystems := make(map[string]webdav.FileSystem, len(shares)) children := make(map[string]*compositedav.Child, len(shares))
for _, share := range shares { for _, share := range shares {
fileSystems[share.Name] = s.buildWebDAVFS(share) children[share.Name] = s.buildChild(share)
} }
s.mu.Lock() s.mu.Lock()
s.shares = shares s.shares = shares
oldFileSystems := s.fileSystems
oldUserServers := s.userServers oldUserServers := s.userServers
s.fileSystems = fileSystems oldChildren := s.children
s.children = children
s.userServers = userServers s.userServers = userServers
s.mu.Unlock() s.mu.Unlock()
s.stopUserServers(oldUserServers) s.stopUserServers(oldUserServers)
s.closeFileSystems(oldFileSystems) s.closeChildren(oldChildren)
} }
func (s *FileSystemForRemote) buildWebDAVFS(share *tailfs.Share) webdav.FileSystem { func (s *FileSystemForRemote) buildChild(share *tailfs.Share) *compositedav.Child {
return webdavfs.New(webdavfs.Options{ return &compositedav.Child{
Logf: s.logf, Child: &dirfs.Child{
URL: fmt.Sprintf("http://%v/%v", hex.EncodeToString([]byte(share.Name)), share.Name), Name: share.Name,
},
BaseURL: fmt.Sprintf("http://%v/%v", hex.EncodeToString([]byte(share.Name)), url.PathEscape(share.Name)),
Transport: &http.Transport{ Transport: &http.Transport{
Dial: func(_, shareAddr string) (net.Conn, error) { Dial: func(_, shareAddr string) (net.Conn, error) {
shareNameHex, _, err := net.SplitHostPort(shareAddr) shareNameHex, _, err := net.SplitHostPort(shareAddr)
@ -151,8 +154,7 @@ func (s *FileSystemForRemote) buildWebDAVFS(share *tailfs.Share) webdav.FileSyst
return safesocket.Connect(addr) return safesocket.Connect(addr)
}, },
}, },
StatRoot: true, }
})
} }
// ServeHTTPWithPerms implements tailfs.FileSystemForRemote. // ServeHTTPWithPerms implements tailfs.FileSystemForRemote.
@ -173,29 +175,23 @@ func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions tailfs.Permissions,
} }
s.mu.RLock() s.mu.RLock()
fileSystems := s.fileSystems childrenMap := s.children
s.mu.RUnlock() s.mu.RUnlock()
children := make([]*compositefs.Child, 0, len(fileSystems)) children := make([]*compositedav.Child, 0, len(childrenMap))
// filter out shares to which the connecting principal has no access // filter out shares to which the connecting principal has no access
for name, fs := range fileSystems { for name, child := range childrenMap {
if permissions.For(name) == tailfs.PermissionNone { if permissions.For(name) == tailfs.PermissionNone {
continue continue
} }
children = append(children, &compositefs.Child{Name: name, FS: fs}) children = append(children, child)
} }
cfs := compositefs.New( h := compositedav.Handler{
compositefs.Options{
Logf: s.logf, Logf: s.logf,
StatChildren: true,
})
cfs.SetChildren(children...)
h := webdav.Handler{
FileSystem: cfs,
LockSystem: s.lockSystem,
} }
h.SetChildren("", children...)
h.ServeHTTP(w, r) h.ServeHTTP(w, r)
} }
@ -207,14 +203,9 @@ func (s *FileSystemForRemote) stopUserServers(userServers map[string]*userServer
} }
} }
func (s *FileSystemForRemote) closeFileSystems(fileSystems map[string]webdav.FileSystem) { func (s *FileSystemForRemote) closeChildren(children map[string]*compositedav.Child) {
for _, fs := range fileSystems { for _, child := range children {
closer, ok := fs.(interface{ Close() error }) child.CloseIdleConnections()
if ok {
if err := closer.Close(); err != nil {
s.logf("error closing tailfs filesystem: %v", err)
}
}
} }
} }
@ -222,11 +213,13 @@ func (s *FileSystemForRemote) closeFileSystems(fileSystems map[string]webdav.Fil
func (s *FileSystemForRemote) Close() error { func (s *FileSystemForRemote) Close() error {
s.mu.Lock() s.mu.Lock()
userServers := s.userServers userServers := s.userServers
fileSystems := s.fileSystems children := s.children
s.userServers = make(map[string]*userServer)
s.children = make(map[string]*compositedav.Child)
s.mu.Unlock() s.mu.Unlock()
s.stopUserServers(userServers) s.stopUserServers(userServers)
s.closeFileSystems(fileSystems) s.closeChildren(children)
return nil return nil
} }

View File

@ -40,3 +40,11 @@ func Join(parts ...string) string {
func IsRoot(p string) bool { func IsRoot(p string) bool {
return p == "" || p == sepString return p == "" || p == sepString
} }
// Base is like path.Base except that it returns "" for the root folder
func Base(p string) string {
if IsRoot(p) {
return ""
}
return path.Base(p)
}

View File

@ -50,7 +50,7 @@ func RenamedFileInfo(ctx context.Context, name string, fi fs.FileInfo) *StaticFi
} }
return &StaticFileInfo{ return &StaticFileInfo{
Named: name, Named: Base(name),
Sized: fi.Size(), Sized: fi.Size(),
Moded: fi.Mode(), Moded: fi.Mode(),
BirthedTime: birthTime, BirthedTime: birthTime,
@ -63,7 +63,7 @@ func RenamedFileInfo(ctx context.Context, name string, fi fs.FileInfo) *StaticFi
// ReadOnlyDirInfo returns a static fs.FileInfo for a read-only directory // ReadOnlyDirInfo returns a static fs.FileInfo for a read-only directory
func ReadOnlyDirInfo(name string, ts time.Time) *StaticFileInfo { func ReadOnlyDirInfo(name string, ts time.Time) *StaticFileInfo {
return &StaticFileInfo{ return &StaticFileInfo{
Named: name, Named: Base(name),
Sized: 0, Sized: 0,
Moded: 0555, Moded: 0555,
BirthedTime: ts, BirthedTime: ts,

View File

@ -4,9 +4,7 @@
package tailfsimpl package tailfsimpl
import ( import (
"context"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log" "log"
"net" "net"
@ -19,10 +17,9 @@
"time" "time"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/tailscale/xnet/webdav" "github.com/studio-b12/gowebdav"
"tailscale.com/tailfs" "tailscale.com/tailfs"
"tailscale.com/tailfs/tailfsimpl/shared" "tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tailfs/tailfsimpl/webdavfs"
"tailscale.com/tstest" "tailscale.com/tstest"
) )
@ -46,24 +43,28 @@ func init() {
// going over the Tailscale network stack. // going over the Tailscale network stack.
func TestDirectoryListing(t *testing.T) { func TestDirectoryListing(t *testing.T) {
s := newSystem(t) s := newSystem(t)
defer s.stop()
s.addRemote(remote1) s.addRemote(remote1)
s.checkDirList("root directory should contain the one and only domain once a remote has been set", "/", domain) s.checkDirList("root directory should contain the one and only domain once a remote has been set", "/", domain)
s.checkDirList("domain should contain its only remote", shared.Join(domain), remote1) s.checkDirList("domain should contain its only remote", shared.Join(domain), remote1)
s.checkDirList("remote with no shares should be empty", shared.Join(domain, remote1)) s.checkDirList("remote with no shares should be empty", shared.Join(domain, remote1))
s.addShare(remote1, share11, tailfs.PermissionReadWrite) s.addShare(remote1, share11, tailfs.PermissionReadWrite)
s.checkDirList("remote with one share should contain that share", shared.Join(domain, remote1), share11) s.checkDirList("remote with one share should contain that share", shared.Join(domain, remote1), share11)
s.addShare(remote1, share12, tailfs.PermissionReadOnly) s.addShare(remote1, share12, tailfs.PermissionReadOnly)
s.checkDirList("remote with two shares should contain both in lexicographical order", shared.Join(domain, remote1), share12, share11) s.checkDirList("remote with two shares should contain both in lexicographical order", shared.Join(domain, remote1), share12, share11)
s.checkDirListIncremental("remote with two shares should contain both in lexicographical order even when reading directory incrementally", shared.Join(domain, remote1), share12, share11) s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
s.checkDirList("remote share should contain file", shared.Join(domain, remote1, share11), file111)
s.addRemote(remote2) s.addRemote(remote2)
s.checkDirList("domain with two remotes should contain both in lexicographical order", shared.Join(domain), remote2, remote1) s.checkDirList("domain with two remotes should contain both in lexicographical order", shared.Join(domain), remote2, remote1)
s.freezeRemote(remote1) s.freezeRemote(remote1)
s.checkDirList("domain with two remotes should contain both in lexicographical order even if one is unreachable", shared.Join(domain), remote2, remote1) s.checkDirList("domain with two remotes should contain both in lexicographical order even if one is unreachable", shared.Join(domain), remote2, remote1)
s.checkDirList("directory listing for offline remote should return empty list", shared.Join(domain, remote1)) _, err := s.client.ReadDir(shared.Join(domain, remote1))
if err == nil {
t.Error("directory listing for offline remote should fail")
}
s.unfreezeRemote(remote1) s.unfreezeRemote(remote1)
s.checkDirList("attempt at lateral traversal should simply list shares", shared.Join(domain, remote1, share11, ".."), share12, share11) s.checkDirList("attempt at lateral traversal should simply list shares", shared.Join(domain, remote1, share11, ".."), share12, share11)
@ -71,7 +72,6 @@ func TestDirectoryListing(t *testing.T) {
func TestFileManipulation(t *testing.T) { func TestFileManipulation(t *testing.T) {
s := newSystem(t) s := newSystem(t)
defer s.stop()
s.addRemote(remote1) s.addRemote(remote1)
s.addShare(remote1, share11, tailfs.PermissionReadWrite) s.addShare(remote1, share11, tailfs.PermissionReadWrite)
@ -86,178 +86,6 @@ func TestFileManipulation(t *testing.T) {
s.writeFile("writing file to non-existent share should fail", remote1, "non-existent", file111, "hello world", false) s.writeFile("writing file to non-existent share should fail", remote1, "non-existent", file111, "hello world", false)
} }
func TestFileOps(t *testing.T) {
ctx := context.Background()
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.addShare(remote1, share11, tailfs.PermissionReadWrite)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
fi, err := s.fs.Stat(context.Background(), pathTo(remote1, share11, file111))
if err != nil {
t.Fatalf("failed to Stat: %s", err)
}
bt, ok := fi.(webdav.BirthTimer)
if !ok {
t.Fatal("FileInfo should be a BirthTimer")
}
birthTime, err := bt.BirthTime(ctx)
if err != nil {
t.Fatalf("failed to BirthTime: %s", err)
}
if birthTime.IsZero() {
t.Fatal("BirthTime() should return a non-zero time")
}
_, err = s.fs.OpenFile(ctx, pathTo(remote1, share11, "nonexistent.txt"), os.O_RDONLY, 0)
if err == nil {
t.Fatal("opening non-existent file for read should fail")
}
dir, err := s.fs.OpenFile(ctx, shared.Join(domain, remote1), os.O_RDONLY, 0)
if err != nil {
t.Fatalf("failed to open directory for read: %s", err)
}
defer dir.Close()
_, err = dir.Seek(0, io.SeekStart)
if err == nil {
t.Fatal("seeking in directory should fail")
}
_, err = dir.Read(make([]byte, 8))
if err == nil {
t.Fatal("reading bytes from directory should fail")
}
_, err = dir.Write(make([]byte, 8))
if err == nil {
t.Fatal("writing bytes to directory should fail")
}
readOnlyFile, err := s.fs.OpenFile(ctx, pathTo(remote1, share11, file111), os.O_RDONLY, 0)
if err != nil {
t.Fatalf("failed to open file for read: %s", err)
}
defer readOnlyFile.Close()
n, err := readOnlyFile.Seek(0, io.SeekStart)
if err != nil {
t.Fatalf("failed to seek 0 from start of read-only file: %s", err)
}
if n != 0 {
t.Fatal("seeking 0 from start of read-only file should return 0")
}
n, err = readOnlyFile.Seek(1, io.SeekStart)
if err != nil {
t.Fatalf("failed to seek 1 from start of read-only file: %s", err)
}
if n != 1 {
t.Fatal("seeking 1 from start of read-only file should return 1")
}
n, err = readOnlyFile.Seek(0, io.SeekEnd)
if err != nil {
t.Fatalf("failed to seek 0 from end of read-only file: %s", err)
}
if n != fi.Size() {
t.Fatal("seeking 0 from end of read-only file should return file size")
}
_, err = readOnlyFile.Seek(1, io.SeekEnd)
if err == nil {
t.Fatal("seeking 1 from end of read-only file should fail")
}
_, err = readOnlyFile.Seek(0, io.SeekCurrent)
if err == nil {
t.Fatal("seeking from current of read-only file should fail")
}
_, err = readOnlyFile.Write(make([]byte, 8))
if err == nil {
t.Fatal("writing bytes to read-only file should fail")
}
writeOnlyFile, err := s.fs.OpenFile(ctx, pathTo(remote1, share11, file111), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0)
if err != nil {
t.Fatalf("failed to OpenFile for write: %s", err)
}
defer writeOnlyFile.Close()
_, err = writeOnlyFile.Seek(0, io.SeekStart)
if err == nil {
t.Fatal("seeking in write only file should fail")
}
_, err = writeOnlyFile.Read(make([]byte, 8))
if err == nil {
t.Fatal("reading bytes from a write only file should fail")
}
}
func TestFileRewind(t *testing.T) {
ctx := context.Background()
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.addShare(remote1, share11, tailfs.PermissionReadWrite)
// Create a file slightly longer than our max rewind buffer of 512
fileLength := webdavfs.MaxRewindBuffer + 1
data := make([]byte, fileLength)
for i := 0; i < fileLength; i++ {
data[i] = byte(i % 256)
}
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, string(data), true)
// Try reading and rewinding in every size up to the maximum buffer length
for i := 0; i < webdavfs.MaxRewindBuffer; i++ {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
f, err := s.fs.OpenFile(ctx, pathTo(remote1, share11, file111), os.O_RDONLY, 0)
if err != nil {
t.Fatalf("failed top OpenFile for read: %s", err)
}
defer f.Close()
b := make([]byte, fileLength)
n, err := io.ReadFull(f, b[:i])
if err != nil {
t.Fatalf("failed to read first %d bytes from file: %s", i, err)
}
if n != i {
log.Fatalf("Reading first %d bytes should report correct count, but reported %d", i, n)
}
_, err = f.Seek(0, io.SeekStart)
if err != nil {
t.Fatalf("failed to seek back %d bytes: %s", i, err)
}
n, err = io.ReadFull(f, b)
if err != nil {
t.Fatalf("failed to read full file: %s", err)
}
if n != fileLength {
t.Fatalf("reading full file reported incorrect count, got %d, want %d", n, fileLength)
}
if string(b) != string(data) {
t.Fatalf("read wrong data, got %q, want %q", b, data)
}
_, err = f.Seek(0, io.SeekStart)
if err == nil {
t.Fatal("Attempting to seek to beginning of file after having read past rewind buffer should fail")
}
})
}
}
type local struct { type local struct {
l net.Listener l net.Listener
fs *FileSystemForLocal fs *FileSystemForLocal
@ -289,7 +117,7 @@ func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) {
type system struct { type system struct {
t *testing.T t *testing.T
local *local local *local
fs webdav.FileSystem client *gowebdav.Client
remotes map[string]*remote remotes map[string]*remote
} }
@ -314,15 +142,16 @@ func newSystem(t *testing.T) *system {
} }
}() }()
return &system{ client := gowebdav.NewClient(fmt.Sprintf("http://%s", l.Addr()), "", "")
client.SetTransport(&http.Transport{DisableKeepAlives: true})
s := &system{
t: t, t: t,
local: &local{l: l, fs: fs}, local: &local{l: l, fs: fs},
fs: webdavfs.New(webdavfs.Options{ client: client,
URL: fmt.Sprintf("http://%s", l.Addr()),
Transport: &http.Transport{DisableKeepAlives: true},
}),
remotes: make(map[string]*remote), remotes: make(map[string]*remote),
} }
t.Cleanup(s.stop)
return s
} }
func (s *system) addRemote(name string) { func (s *system) addRemote(name string) {
@ -357,7 +186,13 @@ func (s *system) addRemote(name string) {
URL: fmt.Sprintf("http://%s", r.l.Addr()), URL: fmt.Sprintf("http://%s", r.l.Addr()),
}) })
} }
s.local.fs.SetRemotes(domain, remotes, &http.Transport{}) s.local.fs.SetRemotes(
domain,
remotes,
&http.Transport{
DisableKeepAlives: true,
ResponseHeaderTimeout: 5 * time.Second,
})
} }
func (s *system) addShare(remoteName, shareName string, permission tailfs.Permission) { func (s *system) addShare(remoteName, shareName string, permission tailfs.Permission) {
@ -399,27 +234,12 @@ func (s *system) unfreezeRemote(remoteName string) {
func (s *system) writeFile(label, remoteName, shareName, name, contents string, expectSuccess bool) { func (s *system) writeFile(label, remoteName, shareName, name, contents string, expectSuccess bool) {
path := pathTo(remoteName, shareName, name) path := pathTo(remoteName, shareName, name)
file, err := s.fs.OpenFile(context.Background(), path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) err := s.client.Write(path, []byte(contents), 0644)
if expectSuccess && err != nil { if expectSuccess && err != nil {
s.t.Fatalf("%v: expected success writing file %q, but got error %v", label, path, err) s.t.Fatalf("%v: expected success writing file %q, but got error %v", label, path, err)
} } else if !expectSuccess && err == nil {
defer func() {
if !expectSuccess && err == nil {
s.t.Fatalf("%v: expected error writing file %q", label, path) s.t.Fatalf("%v: expected error writing file %q", label, path)
} }
}()
defer func() {
err = file.Close()
if expectSuccess && err != nil {
s.t.Fatalf("error closing %v: %v", path, err)
}
}()
_, err = file.Write([]byte(contents))
if expectSuccess && err != nil {
s.t.Fatalf("%v: writing file %q: %v", label, path, err)
}
} }
func (s *system) checkFileStatus(remoteName, shareName, name string) { func (s *system) checkFileStatus(remoteName, shareName, name string) {
@ -437,12 +257,7 @@ func (s *system) checkFileContents(remoteName, shareName, name string) {
} }
func (s *system) checkDirList(label string, path string, want ...string) { func (s *system) checkDirList(label string, path string, want ...string) {
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0) got, err := s.client.ReadDir(path)
if err != nil {
s.t.Fatalf("failed to OpenFile: %s", err)
}
got, err := file.Readdir(0)
if err != nil { if err != nil {
s.t.Fatalf("failed to Readdir: %s", err) s.t.Fatalf("failed to Readdir: %s", err)
} }
@ -460,35 +275,6 @@ func (s *system) checkDirList(label string, path string, want ...string) {
} }
} }
func (s *system) checkDirListIncremental(label string, path string, want ...string) {
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
if err != nil {
s.t.Fatal(err)
}
var gotNames []string
for {
got, err := file.Readdir(1)
for _, fi := range got {
gotNames = append(gotNames, fi.Name())
}
if err == io.EOF {
break
}
if err != nil {
s.t.Fatalf("failed to Readdir: %s", err)
}
}
if len(want) == 0 && len(gotNames) == 0 {
return
}
if diff := cmp.Diff(want, gotNames); diff != "" {
s.t.Errorf("%v: (-got, +want):\n%s", label, diff)
}
}
func (s *system) stat(remoteName, shareName, name string) os.FileInfo { func (s *system) stat(remoteName, shareName, name string) os.FileInfo {
filename := filepath.Join(s.remotes[remoteName].shares[shareName], name) filename := filepath.Join(s.remotes[remoteName].shares[shareName], name)
fi, err := os.Stat(filename) fi, err := os.Stat(filename)
@ -501,7 +287,7 @@ func (s *system) stat(remoteName, shareName, name string) os.FileInfo {
func (s *system) statViaWebDAV(remoteName, shareName, name string) os.FileInfo { func (s *system) statViaWebDAV(remoteName, shareName, name string) os.FileInfo {
path := pathTo(remoteName, shareName, name) path := pathTo(remoteName, shareName, name)
fi, err := s.fs.Stat(context.Background(), path) fi, err := s.client.Stat(path)
if err != nil { if err != nil {
s.t.Fatalf("failed to Stat: %s", err) s.t.Fatalf("failed to Stat: %s", err)
} }
@ -521,17 +307,10 @@ func (s *system) read(remoteName, shareName, name string) string {
func (s *system) readViaWebDAV(remoteName, shareName, name string) string { func (s *system) readViaWebDAV(remoteName, shareName, name string) string {
path := pathTo(remoteName, shareName, name) path := pathTo(remoteName, shareName, name)
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0) b, err := s.client.Read(path)
if err != nil { if err != nil {
s.t.Fatalf("failed to OpenFile: %s", err) s.t.Fatalf("failed to OpenFile: %s", err)
} }
defer file.Close()
b, err := io.ReadAll(file)
if err != nil {
s.t.Fatalf("failed to ReadAll: %s", err)
}
return string(b) return string(b)
} }

View File

@ -1,192 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"context"
"errors"
"io"
"io/fs"
"os"
"sync"
"github.com/tailscale/gowebdav"
)
const (
// MaxRewindBuffer specifies the size of the rewind buffer for reading
// from files. For some files, net/http performs content type detection
// by reading up to the first 512 bytes of a file, then seeking back to the
// beginning before actually transmitting the file. To support this, we
// maintain a rewind buffer of 512 bytes.
MaxRewindBuffer = 512
)
type readOnlyFile struct {
name string
client *gowebdav.Client
rewindBuffer []byte
position int
// mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them.
mu sync.RWMutex
io.ReadCloser
initialFI fs.FileInfo
fi fs.FileInfo
}
// Readdir implements webdav.File. Since this is a file, it always failes with
// an os.PathError.
func (f *readOnlyFile) Readdir(count int) ([]fs.FileInfo, error) {
return nil, &os.PathError{
Op: "readdir",
Path: f.fi.Name(),
Err: errors.New("is a file"), // TODO(oxtoacart): make sure this and below errors match what a regular os.File does
}
}
// Seek implements webdav.File. Only the specific types of seek used by the
// webdav package are implemented, namely:
//
// - Seek to 0 from end of file
// - Seek to 0 from beginning of file, provided that fewer than 512 bytes
// have already been read.
// - Seek to n from beginning of file, provided that no bytes have already
// been read.
//
// Any other type of seek will fail with an os.PathError.
func (f *readOnlyFile) Seek(offset int64, whence int) (int64, error) {
err := f.statIfNecessary()
if err != nil {
return 0, err
}
switch whence {
case io.SeekEnd:
if offset == 0 {
// seek to end is usually done to check size, let's play along
size := f.fi.Size()
return size, nil
}
case io.SeekStart:
if offset == 0 {
// this is usually done to start reading after getting size
if f.position > MaxRewindBuffer {
return 0, errors.New("attempted seek after having read past rewind buffer")
}
f.position = 0
return 0, nil
} else if f.position == 0 {
// this is usually done to perform a range request to skip the head of the file
f.position = int(offset)
return offset, nil
}
}
// unknown seek scenario, error out
return 0, &os.PathError{
Op: "seek",
Path: f.fi.Name(),
Err: errors.New("seek not supported"),
}
}
// Stat implements webdav.File, returning either the FileInfo with which this
// file was initialized, or the more recently fetched FileInfo if available.
func (f *readOnlyFile) Stat() (fs.FileInfo, error) {
f.mu.RLock()
defer f.mu.RUnlock()
if f.fi != nil {
return f.fi, nil
}
return f.initialFI, nil
}
// Read implements webdav.File.
func (f *readOnlyFile) Read(p []byte) (int, error) {
err := f.initReaderIfNecessary()
if err != nil {
return 0, err
}
amountToReadFromBuffer := len(f.rewindBuffer) - f.position
if amountToReadFromBuffer > 0 {
n := copy(p, f.rewindBuffer)
f.position += n
return n, nil
}
n, err := f.ReadCloser.Read(p)
if n > 0 && f.position < MaxRewindBuffer {
amountToReadIntoBuffer := MaxRewindBuffer - f.position
if amountToReadIntoBuffer > n {
amountToReadIntoBuffer = n
}
f.rewindBuffer = append(f.rewindBuffer, p[:amountToReadIntoBuffer]...)
}
f.position += n
return n, err
}
// Write implements webdav.File. As this file is read-only, it always fails
// with an os.PathError.
func (f *readOnlyFile) Write(p []byte) (int, error) {
return 0, &os.PathError{
Op: "write",
Path: f.fi.Name(),
Err: errors.New("read-only"),
}
}
// Close implements webdav.File.
func (f *readOnlyFile) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.ReadCloser == nil {
return nil
}
return f.ReadCloser.Close()
}
// statIfNecessary lazily initializes the FileInfo, bypassing the stat cache to
// make sure we have fresh info before trying to read the file.
func (f *readOnlyFile) statIfNecessary() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fi == nil {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
var err error
f.fi, err = f.client.Stat(ctxWithTimeout, f.name)
if err != nil {
return translateWebDAVError(err)
}
}
return nil
}
// initReaderIfNecessary initializes the Reader if it hasn't been opened yet. We
// do this lazily because github.com/tailscale/xnet/webdav often opens files in
// read-only mode without ever actually reading from them, so we can improve
// performance by avoiding the round-trip to the server.
func (f *readOnlyFile) initReaderIfNecessary() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.ReadCloser == nil {
var err error
f.ReadCloser, err = f.client.ReadStreamOffset(context.Background(), f.name, f.position)
if err != nil {
return translateWebDAVError(err)
}
}
return nil
}

View File

@ -1,71 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"io/fs"
"path/filepath"
"sync"
"time"
"github.com/jellydator/ttlcache/v3"
)
// statCache provides a cache for file directory 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.
type statCache struct {
// mu guards the below values.
mu sync.Mutex
cache *ttlcache.Cache[string, fs.FileInfo]
}
func newStatCache(ttl time.Duration) *statCache {
cache := ttlcache.New(
ttlcache.WithTTL[string, fs.FileInfo](ttl),
)
go cache.Start()
return &statCache{cache: cache}
}
func (c *statCache) getOrFetch(name string, fetch func(string) (fs.FileInfo, error)) (fs.FileInfo, error) {
c.mu.Lock()
item := c.cache.Get(name)
c.mu.Unlock()
if item != nil {
return item.Value(), nil
}
fi, err := fetch(name)
if err == nil {
c.mu.Lock()
c.cache.Set(name, fi, ttlcache.DefaultTTL)
c.mu.Unlock()
}
return fi, err
}
func (c *statCache) set(parentPath string, infos []fs.FileInfo) {
c.mu.Lock()
defer c.mu.Unlock()
for _, info := range infos {
path := filepath.Join(parentPath, filepath.Base(info.Name()))
c.cache.Set(path, info, ttlcache.DefaultTTL)
}
}
func (c *statCache) invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache.DeleteAll()
}
func (c *statCache) stop() {
c.cache.Stop()
}

View File

@ -1,104 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"io/fs"
"os"
"path/filepath"
"testing"
"time"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstest"
)
func TestStatCache(t *testing.T) {
// Make sure we don't leak goroutines
tstest.ResourceCheck(t)
dir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
// create file of size 1
filename := filepath.Join(dir, "thefile")
err = os.WriteFile(filename, []byte("1"), 0644)
if err != nil {
t.Fatal(err)
}
stat := func(name string) (os.FileInfo, error) {
return os.Stat(name)
}
ttl := 1 * time.Second
c := newStatCache(ttl)
// fetch new stat
fi, err := c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 1 {
t.Errorf("got size %d, want 1", fi.Size())
}
// save original FileInfo as a StaticFileInfo so we can reuse it later
// without worrying about the underlying FileInfo changing.
originalFI := &shared.StaticFileInfo{
Named: fi.Name(),
Sized: fi.Size(),
Moded: fi.Mode(),
ModdedTime: fi.ModTime(),
Dir: fi.IsDir(),
}
// update file to size 2
err = os.WriteFile(filename, []byte("12"), 0644)
if err != nil {
t.Fatal(err)
}
// fetch stat again, should still be cached
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 1 {
t.Errorf("got size %d, want 1", fi.Size())
}
// wait for cache to expire and refetch stat, size should reflect new size
time.Sleep(ttl * 2)
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 2 {
t.Errorf("got size %d, want 2", fi.Size())
}
// explicitly set the original FileInfo and make sure it's returned
c.set(dir, []fs.FileInfo{originalFI})
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 1 {
t.Errorf("got size %d, want 1", fi.Size())
}
// invalidate the cache and make sure the new size is returned
c.invalidate()
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 2 {
t.Errorf("got size %d, want 2", fi.Size())
}
c.stop()
}

View File

@ -1,256 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package webdavfs provides an implementation of webdav.FileSystem backed by
// a gowebdav.Client.
package webdavfs
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"time"
"github.com/tailscale/gowebdav"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/tailfsimpl/shared"
"tailscale.com/tstime"
"tailscale.com/types/logger"
)
const (
// keep requests from taking too long if the server is down or slow to respond
opTimeout = 2 * time.Second // TODO(oxtoacart): tune this
)
type Options struct {
// Logf us a logging function to use for debug and error logging.
Logf logger.Logf
// URL is the base URL of the remote WebDAV server.
URL string
// Transport is the http.Transport to use for connecting to the WebDAV
// server.
Transport http.RoundTripper
// StatRoot, if true, will cause this filesystem to actually stat its own
// root via the remote server. If false, it will use a static directory
// info for the root to avoid a round-trip.
StatRoot bool
// StatCacheTTL, when greater than 0, enables caching of file metadata
StatCacheTTL time.Duration
// Clock, if specified, determines the current time. If not specified, we
// default to time.Now().
Clock tstime.Clock
}
// webdavFS adapts gowebdav.Client to webdav.FileSystem
type webdavFS struct {
logf logger.Logf
transport http.RoundTripper
*gowebdav.Client
now func() time.Time
statRoot bool
statCache *statCache
}
// New creates a new webdav.FileSystem backed by the given gowebdav.Client.
// If cacheTTL is greater than zero, the filesystem will cache results from
// Stat calls for the given duration.
func New(opts Options) webdav.FileSystem {
if opts.Logf == nil {
opts.Logf = log.Printf
}
wfs := &webdavFS{
logf: opts.Logf,
transport: opts.Transport,
Client: gowebdav.New(&gowebdav.Opts{URI: opts.URL, Transport: opts.Transport}),
statRoot: opts.StatRoot,
}
if opts.StatCacheTTL > 0 {
wfs.statCache = newStatCache(opts.StatCacheTTL)
}
if opts.Clock != nil {
wfs.now = opts.Clock.Now
} else {
wfs.now = time.Now
}
return wfs
}
// Mkdir implements webdav.FileSystem.
func (wfs *webdavFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return translateWebDAVError(wfs.Client.Mkdir(ctxWithTimeout, name, perm))
}
// OpenFile implements webdav.FileSystem.
func (wfs *webdavFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if hasFlag(flag, os.O_APPEND) {
return nil, &os.PathError{
Op: "open",
Path: name,
Err: errors.New("mode APPEND not supported"),
}
}
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if hasFlag(flag, os.O_WRONLY) || hasFlag(flag, os.O_RDWR) {
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
fi, err := wfs.Stat(ctxWithTimeout, name)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err == nil && fi.IsDir() {
return nil, &os.PathError{
Op: "open",
Path: name,
Err: errors.New("is a directory"),
}
}
pipeReader, pipeWriter := io.Pipe()
f := &writeOnlyFile{
WriteCloser: pipeWriter,
name: name,
perm: perm,
fs: wfs,
finalError: make(chan error, 1),
}
go func() {
defer pipeReader.Close()
err := wfs.Client.WriteStream(context.Background(), name, pipeReader, perm)
f.finalError <- err
close(f.finalError)
}()
return f, nil
}
// Assume reading
fi, err := wfs.Stat(ctxWithTimeout, name)
if err != nil {
return nil, translateWebDAVError(err)
}
if fi.IsDir() {
return wfs.dirWithChildren(name, fi), nil
}
return &readOnlyFile{
client: wfs.Client,
name: name,
initialFI: fi,
rewindBuffer: make([]byte, 0, MaxRewindBuffer),
}, nil
}
func (wfs *webdavFS) dirWithChildren(name string, fi fs.FileInfo) webdav.File {
return &shared.DirFile{
Info: fi,
LoadChildren: func() ([]fs.FileInfo, error) {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
dirInfos, err := wfs.Client.ReadDir(ctxWithTimeout, name)
if err != nil {
wfs.logf("encountered error reading children of '%v', returning empty list: %v", name, err)
// We do not return the actual error here because some WebDAV clients
// will take that as an invitation to retry, hanging in the process.
return dirInfos, nil
}
if wfs.statCache != nil {
wfs.statCache.set(name, dirInfos)
}
return dirInfos, nil
},
}
}
// RemoveAll implements webdav.FileSystem.
func (wfs *webdavFS) RemoveAll(ctx context.Context, name string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return wfs.Client.RemoveAll(ctxWithTimeout, name)
}
// Rename implements webdav.FileSystem.
func (wfs *webdavFS) Rename(ctx context.Context, oldName, newName string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return wfs.Client.Rename(ctxWithTimeout, oldName, newName, false)
}
// Stat implements webdav.FileSystem.
func (wfs *webdavFS) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
if wfs.statCache != nil {
return wfs.statCache.getOrFetch(name, wfs.doStat)
}
return wfs.doStat(name)
}
// Close implements webdav.FileSystem.
func (wfs *webdavFS) Close() error {
if wfs.statCache != nil {
wfs.statCache.stop()
}
tr, ok := wfs.transport.(*http.Transport)
if ok {
tr.CloseIdleConnections()
}
return nil
}
func (wfs *webdavFS) doStat(name string) (fs.FileInfo, error) {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if !wfs.statRoot && shared.IsRoot(name) {
// use a static directory info for the root
// always use now() as the modified time to bust caches
return shared.ReadOnlyDirInfo(name, wfs.now()), nil
}
fi, err := wfs.Client.Stat(ctxWithTimeout, name)
return fi, translateWebDAVError(err)
}
func translateWebDAVError(err error) error {
if err == nil {
return nil
}
var se gowebdav.StatusError
if errors.As(err, &se) {
if se.Status == http.StatusNotFound {
return os.ErrNotExist
}
}
// Note, we intentionally don't wrap the error because we don't want
// github.com/tailscale/xnet/webdav to try to interpret the underlying
// error.
return fmt.Errorf("unexpected WebDAV error: %v", err)
}
func hasFlag(flags int, flag int) bool {
return (flags & flag) == flag
}

View File

@ -1,89 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"context"
"errors"
"io"
"io/fs"
"os"
"tailscale.com/tailfs/tailfsimpl/shared"
)
type writeOnlyFile struct {
io.WriteCloser
name string
perm os.FileMode
fs *webdavFS
finalError chan error
}
// Readdir implements webdav.File. As this is a file, this always fails with an
// os.PathError.
func (f *writeOnlyFile) Readdir(count int) ([]fs.FileInfo, error) {
return nil, &os.PathError{
Op: "readdir",
Path: f.name,
Err: errors.New("is a file"), // TODO(oxtoacart): make sure this and below errors match what a regular os.File does
}
}
// Seek implements webdav.File. This always fails with an os.PathError.
func (f *writeOnlyFile) Seek(offset int64, whence int) (int64, error) {
return 0, &os.PathError{
Op: "seek",
Path: f.name,
Err: errors.New("seek not supported"),
}
}
// Stat implements webdav.File.
func (f *writeOnlyFile) Stat() (fs.FileInfo, error) {
fi, err := f.fs.Stat(context.Background(), f.name)
if err != nil {
// use static info for newly created file
now := f.fs.now()
fi = &shared.StaticFileInfo{
Named: f.name,
Sized: 0,
Moded: f.perm,
BirthedTime: now,
ModdedTime: now,
Dir: false,
}
}
return fi, nil
}
// Read implements webdav.File. As this is a write-only file, it always fails
// with an os.PathError.
func (f *writeOnlyFile) Read(p []byte) (int, error) {
return 0, &os.PathError{
Op: "write",
Path: f.name,
Err: errors.New("write-only"),
}
}
// Write implements webdav.File.
func (f *writeOnlyFile) Write(p []byte) (int, error) {
select {
case err := <-f.finalError:
return 0, err
default:
return f.WriteCloser.Write(p)
}
}
// Close implements webdav.File.
func (f *writeOnlyFile) Close() error {
err := f.WriteCloser.Close()
writeErr := <-f.finalError
if writeErr != nil {
return writeErr
}
return err
}