diff options
Diffstat (limited to 'tailfs/webdavfs')
| -rw-r--r-- | tailfs/webdavfs/readonly_file.go | 178 | ||||
| -rw-r--r-- | tailfs/webdavfs/stat_cache.go | 70 | ||||
| -rw-r--r-- | tailfs/webdavfs/stat_cache_test.go | 104 | ||||
| -rw-r--r-- | tailfs/webdavfs/webdavfs.go | 251 | ||||
| -rw-r--r-- | tailfs/webdavfs/writeonly_file.go | 87 |
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 +} |
