summaryrefslogtreecommitdiffhomepage
path: root/tailfs/webdavfs
diff options
context:
space:
mode:
Diffstat (limited to 'tailfs/webdavfs')
-rw-r--r--tailfs/webdavfs/readonly_file.go178
-rw-r--r--tailfs/webdavfs/stat_cache.go70
-rw-r--r--tailfs/webdavfs/stat_cache_test.go104
-rw-r--r--tailfs/webdavfs/webdavfs.go251
-rw-r--r--tailfs/webdavfs/writeonly_file.go87
5 files changed, 690 insertions, 0 deletions
diff --git a/tailfs/webdavfs/readonly_file.go b/tailfs/webdavfs/readonly_file.go
new file mode 100644
index 000000000..cd3e1b35e
--- /dev/null
+++ b/tailfs/webdavfs/readonly_file.go
@@ -0,0 +1,178 @@
+// 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 {
+ io.ReadCloser
+ name string
+ initialFI fs.FileInfo
+ fi fs.FileInfo
+ client *gowebdav.Client
+ rewindBuffer []byte
+ position int
+ mu sync.RWMutex
+}
+
+// Readdir implements webdav.File.
+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.
+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.
+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
+}
diff --git a/tailfs/webdavfs/stat_cache.go b/tailfs/webdavfs/stat_cache.go
new file mode 100644
index 000000000..9117e5dbb
--- /dev/null
+++ b/tailfs/webdavfs/stat_cache.go
@@ -0,0 +1,70 @@
+// 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 {
+ cache *ttlcache.Cache[string, fs.FileInfo]
+ mx sync.Mutex
+}
+
+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.mx.Lock()
+ item := c.cache.Get(name)
+ c.mx.Unlock()
+
+ if item != nil {
+ return item.Value(), nil
+ }
+
+ fi, err := fetch(name)
+ if err == nil {
+ c.mx.Lock()
+ c.cache.Set(name, fi, ttlcache.DefaultTTL)
+ c.mx.Unlock()
+ }
+
+ return fi, err
+}
+
+func (c *statCache) set(parentPath string, infos []fs.FileInfo) {
+ c.mx.Lock()
+ defer c.mx.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.mx.Lock()
+ defer c.mx.Unlock()
+
+ c.cache.DeleteAll()
+}
+
+func (c *statCache) stop() {
+ c.cache.Stop()
+}
diff --git a/tailfs/webdavfs/stat_cache_test.go b/tailfs/webdavfs/stat_cache_test.go
new file mode 100644
index 000000000..3646382a5
--- /dev/null
+++ b/tailfs/webdavfs/stat_cache_test.go
@@ -0,0 +1,104 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package webdavfs
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "tailscale.com/tailfs/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()
+}
diff --git a/tailfs/webdavfs/webdavfs.go b/tailfs/webdavfs/webdavfs.go
new file mode 100644
index 000000000..8feeab0e0
--- /dev/null
+++ b/tailfs/webdavfs/webdavfs.go
@@ -0,0 +1,251 @@
+// 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/shared"
+ "tailscale.com/tstime"
+ "tailscale.com/types/logger"
+ "tailscale.com/util/pathutil"
+)
+
+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 Opts 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 *Opts) 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
+}
+
+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))
+}
+
+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
+ },
+ }
+}
+
+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)
+}
+
+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)
+}
+
+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)
+}
+
+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 && pathutil.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
+}
diff --git a/tailfs/webdavfs/writeonly_file.go b/tailfs/webdavfs/writeonly_file.go
new file mode 100644
index 000000000..e639a92da
--- /dev/null
+++ b/tailfs/webdavfs/writeonly_file.go
@@ -0,0 +1,87 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package webdavfs
+
+import (
+ "context"
+ "errors"
+ "io"
+ "io/fs"
+ "os"
+
+ "tailscale.com/tailfs/shared"
+)
+
+type writeOnlyFile struct {
+ io.WriteCloser
+ name string
+ perm os.FileMode
+ fs *webdavFS
+ finalError chan error
+}
+
+// Readdir implements webdav.File.
+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.
+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.
+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
+}