tailfs: clean up naming and package structure

- Restyles tailfs -> tailFS
- Defines interfaces for main TailFS types
- Moves implemenatation of TailFS into tailfsimpl package

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
This commit is contained in:
Percy Wegmann
2024-02-09 11:26:43 -06:00
committed by Percy Wegmann
parent 79b547804b
commit abab0d4197
50 changed files with 753 additions and 683 deletions

View File

@@ -0,0 +1,192 @@
// 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

@@ -0,0 +1,71 @@
// 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

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

@@ -0,0 +1,256 @@
// 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

@@ -0,0 +1,89 @@
// 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
}