tailfs: initial implementation
Add a WebDAV-based folder sharing mechanism that is exposed to local clients at 100.100.100.100:8080 and to remote peers via a new peerapi endpoint at /v0/tailfs. Add the ability to manage folder sharing via the new 'share' CLI sub-command. Updates tailscale/corp#16827 Signed-off-by: Percy Wegmann <percy@tailscale.com>
This commit is contained in:
committed by
Percy Wegmann
parent
2e404b769d
commit
993acf4475
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/djherbis/times"
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
)
|
||||
|
||||
// birthTimingFS extends a webdav.FileSystem to return FileInfos that implement
|
||||
// the webdav.BirthTimer interface.
|
||||
type birthTimingFS struct {
|
||||
webdav.FileSystem
|
||||
}
|
||||
|
||||
func (fs *birthTimingFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
fi, err := fs.FileSystem.Stat(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &birthTimingFileInfo{fi}, nil
|
||||
}
|
||||
|
||||
func (fs *birthTimingFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
f, err := fs.FileSystem.OpenFile(ctx, name, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &birthTimingFile{f}, nil
|
||||
}
|
||||
|
||||
// birthTimingFileInfo extends an os.FileInfo to implement the BirthTimer
|
||||
// interface.
|
||||
type birthTimingFileInfo struct {
|
||||
os.FileInfo
|
||||
}
|
||||
|
||||
func (fi *birthTimingFileInfo) BirthTime(ctx context.Context) (time.Time, error) {
|
||||
if fi.Sys() == nil {
|
||||
return time.Time{}, webdav.ErrNotImplemented
|
||||
}
|
||||
|
||||
if !times.HasBirthTime {
|
||||
return time.Time{}, webdav.ErrNotImplemented
|
||||
}
|
||||
|
||||
return times.Get(fi.FileInfo).BirthTime(), nil
|
||||
}
|
||||
|
||||
// birthTimingFile extends a webdav.File to return FileInfos that implement the
|
||||
// BirthTimer interface.
|
||||
type birthTimingFile struct {
|
||||
webdav.File
|
||||
}
|
||||
|
||||
func (f *birthTimingFile) Stat() (fs.FileInfo, error) {
|
||||
fi, err := f.File.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &birthTimingFileInfo{fi}, nil
|
||||
}
|
||||
|
||||
func (f *birthTimingFile) Readdir(count int) ([]fs.FileInfo, error) {
|
||||
fis, err := f.File.Readdir(count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, fi := range fis {
|
||||
fis[i] = &birthTimingFileInfo{fi}
|
||||
}
|
||||
|
||||
return fis, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// BirthTime is not supported on Linux, so only run the test on windows and Mac.
|
||||
|
||||
//go:build windows || darwin
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
)
|
||||
|
||||
func TestBirthTiming(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
dir := t.TempDir()
|
||||
fs := &birthTimingFS{webdav.Dir(dir)}
|
||||
|
||||
// create a file
|
||||
filename := "thefile"
|
||||
fullPath := filepath.Join(dir, filename)
|
||||
err := os.WriteFile(fullPath, []byte("hello beautiful world"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("writing file failed: %s", err)
|
||||
}
|
||||
|
||||
// wait a little bit
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// append to the file to change its mtime
|
||||
file, err := os.OpenFile(fullPath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("opening file failed: %s", err)
|
||||
}
|
||||
_, err = file.Write([]byte("lookin' good!"))
|
||||
if err != nil {
|
||||
t.Fatalf("appending to file failed: %s", err)
|
||||
}
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("closing file failed: %s", err)
|
||||
}
|
||||
|
||||
checkFileInfo := func(fi os.FileInfo) {
|
||||
if fi.ModTime().IsZero() {
|
||||
t.Fatal("FileInfo should have a non-zero ModTime")
|
||||
}
|
||||
bt, ok := fi.(webdav.BirthTimer)
|
||||
if !ok {
|
||||
t.Fatal("FileInfo should be a BirthTimer")
|
||||
}
|
||||
birthTime, err := bt.BirthTime(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("BirthTime() failed: %s", err)
|
||||
}
|
||||
if birthTime.IsZero() {
|
||||
t.Fatal("BirthTime() should return a non-zero time")
|
||||
}
|
||||
if !fi.ModTime().After(birthTime) {
|
||||
t.Fatal("ModTime() should be after BirthTime()")
|
||||
}
|
||||
}
|
||||
|
||||
fi, err := fs.Stat(ctx, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("statting file failed: %s", err)
|
||||
}
|
||||
checkFileInfo(fi)
|
||||
|
||||
wfile, err := fs.OpenFile(ctx, filename, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("opening file failed: %s", err)
|
||||
}
|
||||
defer wfile.Close()
|
||||
fi, err = wfile.Stat()
|
||||
if err != nil {
|
||||
t.Fatalf("statting file failed: %s", err)
|
||||
}
|
||||
if fi == nil {
|
||||
t.Fatal("statting file returned nil FileInfo")
|
||||
}
|
||||
checkFileInfo(fi)
|
||||
|
||||
dfile, err := fs.OpenFile(ctx, ".", os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("opening directory failed: %s", err)
|
||||
}
|
||||
defer dfile.Close()
|
||||
fis, err := dfile.Readdir(0)
|
||||
if err != nil {
|
||||
t.Fatalf("readdir failed: %s", err)
|
||||
}
|
||||
if len(fis) != 1 {
|
||||
t.Fatalf("readdir should have returned 1 file info, but returned %d", 1)
|
||||
}
|
||||
checkFileInfo(fis[0])
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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/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
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
// 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/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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"tailscale.com/tailfs/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)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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/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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"tailscale.com/tailfs/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)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"tailscale.com/tailfs/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)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
|
||||
"tailscale.com/tailfs/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
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type connListener struct {
|
||||
ch chan net.Conn
|
||||
closedCh chan any
|
||||
closeMu sync.Mutex
|
||||
}
|
||||
|
||||
// newConnListener creates a net.Listener to which one can hand connections
|
||||
// directly.
|
||||
func newConnListener() *connListener {
|
||||
return &connListener{
|
||||
ch: make(chan net.Conn),
|
||||
closedCh: make(chan any),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *connListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case <-l.closedCh:
|
||||
// TODO(oxtoacart): make this error match what a regular net.Listener does
|
||||
return nil, syscall.EINVAL
|
||||
case conn := <-l.ch:
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Addr implements net.Listener. This always returns nil. It is assumed that
|
||||
// this method is currently unused, so it logs a warning if it ever does get
|
||||
// called.
|
||||
func (l *connListener) Addr() net.Addr {
|
||||
log.Println("warning: unexpected call to connListener.Addr()")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *connListener) Close() error {
|
||||
l.closeMu.Lock()
|
||||
defer l.closeMu.Unlock()
|
||||
|
||||
select {
|
||||
case <-l.closedCh:
|
||||
// Already closed.
|
||||
return syscall.EINVAL
|
||||
default:
|
||||
// We don't close l.ch because someone maybe trying to send to that,
|
||||
// which would cause a panic.
|
||||
close(l.closedCh)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *connListener) HandleConn(c net.Conn, remoteAddr net.Addr) error {
|
||||
select {
|
||||
case <-l.closedCh:
|
||||
return syscall.EINVAL
|
||||
case l.ch <- &connWithRemoteAddr{Conn: c, remoteAddr: remoteAddr}:
|
||||
// Connection has been accepted.
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type connWithRemoteAddr struct {
|
||||
net.Conn
|
||||
remoteAddr net.Addr
|
||||
}
|
||||
|
||||
func (c *connWithRemoteAddr) RemoteAddr() net.Addr {
|
||||
return c.remoteAddr
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConnListener(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to Listen: %s", err)
|
||||
}
|
||||
|
||||
cl := newConnListener()
|
||||
// Test that we can accept a connection
|
||||
cc, err := net.Dial("tcp", l.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to Dial: %s", err)
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
sc, err := l.Accept()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to Accept: %s", err)
|
||||
}
|
||||
|
||||
remoteAddr := &net.TCPAddr{IP: net.ParseIP("10.10.10.10"), Port: 1234}
|
||||
go func() {
|
||||
err := cl.HandleConn(sc, remoteAddr)
|
||||
if err != nil {
|
||||
log.Printf("failed to HandleConn: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
clc, err := cl.Accept()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to Accept: %s", err)
|
||||
}
|
||||
defer clc.Close()
|
||||
|
||||
if clc.RemoteAddr().String() != remoteAddr.String() {
|
||||
t.Fatalf("ConnListener accepted the wrong connection, got %q, want %q", clc.RemoteAddr(), remoteAddr)
|
||||
}
|
||||
|
||||
err = cl.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to Close: %s", err)
|
||||
}
|
||||
|
||||
err = cl.Close()
|
||||
if err == nil {
|
||||
t.Fatal("should have failed on second Close")
|
||||
}
|
||||
|
||||
err = cl.HandleConn(sc, remoteAddr)
|
||||
if err == nil {
|
||||
t.Fatal("should have failed on HandleConn after Close")
|
||||
}
|
||||
|
||||
_, err = cl.Accept()
|
||||
if err == nil {
|
||||
t.Fatal("should have failed on Accept after Close")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/tailfs/shared"
|
||||
)
|
||||
|
||||
// FileServer is a standalone WebDAV server that dynamically serves up shares.
|
||||
// It's typically used in a separate process from the actual Tailfs server to
|
||||
// serve up files as an unprivileged user.
|
||||
type FileServer struct {
|
||||
l net.Listener
|
||||
shareHandlers map[string]http.Handler
|
||||
sharesMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewFileServer constructs a FileServer.
|
||||
//
|
||||
// The server attempts to listen at a random address on 127.0.0.1.
|
||||
// The listen address is available via the Addr() method.
|
||||
//
|
||||
// The server has to be told about shares before it can serve them. This is
|
||||
// accomplished either by calling SetShares(), or locking the shares with
|
||||
// LockShares(), clearing them with ClearSharesLocked(), adding them
|
||||
// individually with AddShareLocked(), and finally unlocking them with
|
||||
// UnlockShares().
|
||||
//
|
||||
// The server doesn't actually process requests until the Serve() method is
|
||||
// called.
|
||||
func NewFileServer() (*FileServer, error) {
|
||||
// path := filepath.Join(os.TempDir(), fmt.Sprintf("%v.socket", uuid.New().String()))
|
||||
// l, err := safesocket.Listen(path)
|
||||
// if err != nil {
|
||||
// TODO(oxtoacart): actually get safesocket working in more environments (MacOS Sandboxed, Windows, ???)
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// }
|
||||
return &FileServer{
|
||||
l: l,
|
||||
shareHandlers: make(map[string]http.Handler),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Addr returns the address at which this FileServer is listening.
|
||||
func (s *FileServer) Addr() string {
|
||||
return s.l.Addr().String()
|
||||
}
|
||||
|
||||
// Serve() starts serving files and blocks until it encounters a fatal error.
|
||||
func (s *FileServer) Serve() error {
|
||||
return http.Serve(s.l, s)
|
||||
}
|
||||
|
||||
// LockShares locks the map of shares in preparation for manipulating it.
|
||||
func (s *FileServer) LockShares() {
|
||||
s.sharesMu.Lock()
|
||||
}
|
||||
|
||||
// UnlockShares unlocks the map of shares.
|
||||
func (s *FileServer) UnlockShares() {
|
||||
s.sharesMu.Unlock()
|
||||
}
|
||||
|
||||
// ClearSharesLocked clears the map of shares, assuming that LockShares() has
|
||||
// been called first.
|
||||
func (s *FileServer) ClearSharesLocked() {
|
||||
s.shareHandlers = make(map[string]http.Handler)
|
||||
}
|
||||
|
||||
// AddShareLocked adds a share to the map of shares, assuming that LockShares()
|
||||
// has been called first.
|
||||
func (s *FileServer) AddShareLocked(share, path string) {
|
||||
s.shareHandlers[share] = &webdav.Handler{
|
||||
FileSystem: &birthTimingFS{webdav.Dir(path)},
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetShares sets the full map of shares to the new value, mapping name->path.
|
||||
func (s *FileServer) SetShares(shares map[string]string) {
|
||||
s.LockShares()
|
||||
defer s.UnlockShares()
|
||||
s.ClearSharesLocked()
|
||||
for name, path := range shares {
|
||||
s.AddShareLocked(name, path)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements the http.Handler interface.
|
||||
func (s *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
parts := shared.CleanAndSplit(r.URL.Path)
|
||||
r.URL.Path = shared.Join(parts[1:]...)
|
||||
share := parts[0]
|
||||
s.sharesMu.RLock()
|
||||
h, found := s.shareHandlers[share]
|
||||
s.sharesMu.RUnlock()
|
||||
if !found {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *FileServer) Close() error {
|
||||
return s.l.Close()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/tailfs/compositefs"
|
||||
"tailscale.com/tailfs/webdavfs"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// Remote represents a remote Tailfs node.
|
||||
type Remote struct {
|
||||
Name string
|
||||
URL string
|
||||
Available func() bool
|
||||
}
|
||||
|
||||
// NewFileSystemForLocal starts serving a filesystem for local clients.
|
||||
// Inbound connections must be handed to HandleConn.
|
||||
func NewFileSystemForLocal(logf logger.Logf) *FileSystemForLocal {
|
||||
if logf == nil {
|
||||
logf = log.Printf
|
||||
}
|
||||
fs := &FileSystemForLocal{
|
||||
logf: logf,
|
||||
cfs: compositefs.New(compositefs.Options{Logf: logf}),
|
||||
listener: newConnListener(),
|
||||
}
|
||||
fs.startServing()
|
||||
return fs
|
||||
}
|
||||
|
||||
// FileSystemForLocal is the Tailfs filesystem exposed to local clients. It
|
||||
// provides a unified WebDAV interface to remote Tailfs shares on other nodes.
|
||||
type FileSystemForLocal struct {
|
||||
logf logger.Logf
|
||||
cfs *compositefs.CompositeFileSystem
|
||||
listener *connListener
|
||||
}
|
||||
|
||||
func (s *FileSystemForLocal) startServing() {
|
||||
hs := &http.Server{
|
||||
Handler: &webdav.Handler{
|
||||
FileSystem: s.cfs,
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
},
|
||||
}
|
||||
go func() {
|
||||
err := hs.Serve(s.listener)
|
||||
if err != nil {
|
||||
// TODO(oxtoacart): should we panic or something different here?
|
||||
log.Printf("serve: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// HandleConn handles connections from local WebDAV clients
|
||||
func (s *FileSystemForLocal) HandleConn(conn net.Conn, remoteAddr net.Addr) error {
|
||||
return s.listener.HandleConn(conn, remoteAddr)
|
||||
}
|
||||
|
||||
// SetRemotes sets the complete set of remotes on the given tailnet domain
|
||||
// using a map of name -> url. If transport is specified, that transport
|
||||
// will be used to connect to these remotes.
|
||||
func (s *FileSystemForLocal) SetRemotes(domain string, remotes []*Remote, transport http.RoundTripper) {
|
||||
children := make([]*compositefs.Child, 0, len(remotes))
|
||||
for _, remote := range remotes {
|
||||
opts := webdavfs.Options{
|
||||
URL: remote.URL,
|
||||
Transport: transport,
|
||||
StatCacheTTL: statCacheTTL,
|
||||
Logf: s.logf,
|
||||
}
|
||||
children = append(children, &compositefs.Child{
|
||||
Name: remote.Name,
|
||||
FS: webdavfs.New(opts),
|
||||
Available: remote.Available,
|
||||
})
|
||||
}
|
||||
|
||||
domainChild, found := s.cfs.GetChild(domain)
|
||||
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
|
||||
func (s *FileSystemForLocal) Close() error {
|
||||
s.cfs.Close()
|
||||
return s.listener.Close()
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailfs/compositefs"
|
||||
"tailscale.com/tailfs/shared"
|
||||
"tailscale.com/tailfs/webdavfs"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
disallowShareAs = false
|
||||
)
|
||||
|
||||
// AllowShareAs reports whether sharing files as a specific user is allowed.
|
||||
func AllowShareAs() bool {
|
||||
return !disallowShareAs && doAllowShareAs()
|
||||
}
|
||||
|
||||
// Share represents a folder that's shared with remote Tailfs nodes.
|
||||
type Share struct {
|
||||
// Name is how this share appears on remote nodes.
|
||||
Name string `json:"name"`
|
||||
// Path is the path to the directory on this machine that's being shared.
|
||||
Path string `json:"path"`
|
||||
// As is the UNIX or Windows username of the local account used for this
|
||||
// share. File read/write permissions are enforced based on this username.
|
||||
As string `json:"who"`
|
||||
}
|
||||
|
||||
func NewFileSystemForRemote(logf logger.Logf) *FileSystemForRemote {
|
||||
if logf == nil {
|
||||
logf = log.Printf
|
||||
}
|
||||
fs := &FileSystemForRemote{
|
||||
logf: logf,
|
||||
lockSystem: webdav.NewMemLS(),
|
||||
fileSystems: make(map[string]webdav.FileSystem),
|
||||
userServers: make(map[string]*userServer),
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
// FileSystemForRemote is the Tailfs filesystem exposed to remote nodes. It
|
||||
// provides a unified WebDAV interface to local directories that have been
|
||||
// shared.
|
||||
type FileSystemForRemote struct {
|
||||
logf logger.Logf
|
||||
lockSystem webdav.LockSystem
|
||||
|
||||
// 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
|
||||
fileServerAddr string
|
||||
shares map[string]*Share
|
||||
fileSystems map[string]webdav.FileSystem
|
||||
userServers map[string]*userServer
|
||||
}
|
||||
|
||||
// SetFileServerAddr sets the address of the file server to which we
|
||||
// should proxy. This is used on platforms like Windows and MacOS
|
||||
// sandboxed where we can't spawn user-specific sub-processes and instead
|
||||
// rely on the UI application that's already running as an unprivileged
|
||||
// user to access the filesystem for us.
|
||||
func (s *FileSystemForRemote) SetFileServerAddr(addr string) {
|
||||
s.mu.Lock()
|
||||
s.fileServerAddr = addr
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetShares sets the complete set of shares exposed by this node. If
|
||||
// AllowShareAs() reports true, we will use one subprocess per user to
|
||||
// access the filesystem (see userServer). Otherwise, we will use the file
|
||||
// server configured via SetFileServerAddr.
|
||||
func (s *FileSystemForRemote) SetShares(shares map[string]*Share) {
|
||||
userServers := make(map[string]*userServer)
|
||||
if AllowShareAs() {
|
||||
// set up per-user server
|
||||
for _, share := range shares {
|
||||
p, found := userServers[share.As]
|
||||
if !found {
|
||||
p = &userServer{
|
||||
logf: s.logf,
|
||||
}
|
||||
userServers[share.As] = p
|
||||
}
|
||||
p.shares = append(p.shares, share)
|
||||
}
|
||||
for _, p := range userServers {
|
||||
go p.runLoop()
|
||||
}
|
||||
}
|
||||
|
||||
fileSystems := make(map[string]webdav.FileSystem, len(shares))
|
||||
for _, share := range shares {
|
||||
fileSystems[share.Name] = s.buildWebDAVFS(share)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.shares = shares
|
||||
oldFileSystems := s.fileSystems
|
||||
oldUserServers := s.userServers
|
||||
s.fileSystems = fileSystems
|
||||
s.userServers = userServers
|
||||
s.mu.Unlock()
|
||||
|
||||
s.stopUserServers(oldUserServers)
|
||||
s.closeFileSystems(oldFileSystems)
|
||||
}
|
||||
|
||||
func (s *FileSystemForRemote) buildWebDAVFS(share *Share) webdav.FileSystem {
|
||||
return webdavfs.New(webdavfs.Options{
|
||||
Logf: s.logf,
|
||||
URL: fmt.Sprintf("http://%v/%v", hex.EncodeToString([]byte(share.Name)), share.Name),
|
||||
Transport: &http.Transport{
|
||||
Dial: func(_, shareAddr string) (net.Conn, error) {
|
||||
shareNameHex, _, err := net.SplitHostPort(shareAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse share address %v: %w", shareAddr, err)
|
||||
}
|
||||
|
||||
// We had to encode the share name in hex to make sure it's a valid hostname
|
||||
shareNameBytes, err := hex.DecodeString(shareNameHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to decode share name from host %v: %v", shareNameHex, err)
|
||||
}
|
||||
shareName := string(shareNameBytes)
|
||||
|
||||
s.mu.RLock()
|
||||
share, shareFound := s.shares[shareName]
|
||||
userServers := s.userServers
|
||||
fileServerAddr := s.fileServerAddr
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !shareFound {
|
||||
return nil, fmt.Errorf("unknown share %v", shareName)
|
||||
}
|
||||
|
||||
var addr string
|
||||
if !AllowShareAs() {
|
||||
addr = fileServerAddr
|
||||
} else {
|
||||
userServer, found := userServers[share.As]
|
||||
if found {
|
||||
userServer.mu.RLock()
|
||||
addr = userServer.addr
|
||||
userServer.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
if addr == "" {
|
||||
return nil, fmt.Errorf("unable to determine address for share %v", shareName)
|
||||
}
|
||||
|
||||
_, err = netip.ParseAddrPort(addr)
|
||||
if err == nil {
|
||||
// this is a regular network address, dial normally
|
||||
return net.Dial("tcp", addr)
|
||||
}
|
||||
// assume this is a safesocket address
|
||||
return safesocket.Connect(addr)
|
||||
},
|
||||
},
|
||||
StatRoot: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeHTTPWithPerms behaves like the similar method from http.Handler but
|
||||
// also accepts a Permissions map that captures the permissions of the
|
||||
// connecting node.
|
||||
func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions Permissions, w http.ResponseWriter, r *http.Request) {
|
||||
isWrite := writeMethods[r.Method]
|
||||
if isWrite {
|
||||
share := shared.CleanAndSplit(r.URL.Path)[0]
|
||||
switch permissions.For(share) {
|
||||
case PermissionNone:
|
||||
// If we have no permissions to this share, treat it as not found
|
||||
// to avoid leaking any information about the share's existence.
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
case PermissionReadOnly:
|
||||
http.Error(w, "permission denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
fileSystems := s.fileSystems
|
||||
s.mu.RUnlock()
|
||||
|
||||
children := make([]*compositefs.Child, 0, len(fileSystems))
|
||||
// filter out shares to which the connecting principal has no access
|
||||
for name, fs := range fileSystems {
|
||||
if permissions.For(name) == PermissionNone {
|
||||
continue
|
||||
}
|
||||
|
||||
children = append(children, &compositefs.Child{Name: name, FS: fs})
|
||||
}
|
||||
|
||||
cfs := compositefs.New(
|
||||
compositefs.Options{
|
||||
Logf: s.logf,
|
||||
StatChildren: true,
|
||||
})
|
||||
cfs.SetChildren(children...)
|
||||
h := webdav.Handler{
|
||||
FileSystem: cfs,
|
||||
LockSystem: s.lockSystem,
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *FileSystemForRemote) stopUserServers(userServers map[string]*userServer) {
|
||||
for _, server := range userServers {
|
||||
if err := server.Close(); err != nil {
|
||||
s.logf("error closing tailfs user server: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FileSystemForRemote) closeFileSystems(fileSystems map[string]webdav.FileSystem) {
|
||||
for _, fs := range fileSystems {
|
||||
closer, ok := fs.(interface{ Close() error })
|
||||
if ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
s.logf("error closing tailfs filesystem: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close() stops serving the WebDAV content
|
||||
func (s *FileSystemForRemote) Close() error {
|
||||
s.mu.Lock()
|
||||
userServers := s.userServers
|
||||
fileSystems := s.fileSystems
|
||||
s.mu.Unlock()
|
||||
|
||||
s.stopUserServers(userServers)
|
||||
s.closeFileSystems(fileSystems)
|
||||
return nil
|
||||
}
|
||||
|
||||
// userServer runs tailscaled serve-tailfs to serve webdav content for the
|
||||
// given Shares. All Shares are assumed to have the same Share.As, and the
|
||||
// content is served as that Share.As user.
|
||||
type userServer struct {
|
||||
logf logger.Logf
|
||||
shares []*Share
|
||||
|
||||
// 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
|
||||
cmd *exec.Cmd
|
||||
addr string
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *userServer) Close() error {
|
||||
s.mu.Lock()
|
||||
cmd := s.cmd
|
||||
s.closed = true
|
||||
s.mu.Unlock()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
// not running, that's okay
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userServer) runLoop() {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
s.logf("can't find executable: %v", err)
|
||||
return
|
||||
}
|
||||
maxSleepTime := 30 * time.Second
|
||||
consecutiveFailures := float64(0)
|
||||
var timeOfLastFailure time.Time
|
||||
for {
|
||||
s.mu.RLock()
|
||||
closed := s.closed
|
||||
s.mu.RUnlock()
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
|
||||
err := s.run(executable)
|
||||
now := time.Now()
|
||||
timeSinceLastFailure := now.Sub(timeOfLastFailure)
|
||||
timeOfLastFailure = now
|
||||
if timeSinceLastFailure < maxSleepTime {
|
||||
consecutiveFailures++
|
||||
} else {
|
||||
consecutiveFailures = 1
|
||||
}
|
||||
sleepTime := time.Duration(math.Pow(2, consecutiveFailures)) * time.Millisecond
|
||||
if sleepTime > maxSleepTime {
|
||||
sleepTime = maxSleepTime
|
||||
}
|
||||
s.logf("user server % v stopped with error %v, will try again in %v", executable, err, sleepTime)
|
||||
time.Sleep(sleepTime)
|
||||
}
|
||||
}
|
||||
|
||||
// Run runs the executable (tailscaled). This function only works on UNIX systems,
|
||||
// but those are the only ones on which we use userServers anyway.
|
||||
func (s *userServer) run(executable string) error {
|
||||
// set up the command
|
||||
args := []string{"serve-tailfs"}
|
||||
for _, s := range s.shares {
|
||||
args = append(args, s.Name, s.Path)
|
||||
}
|
||||
allArgs := []string{"-u", s.shares[0].As, executable}
|
||||
allArgs = append(allArgs, args...)
|
||||
cmd := exec.Command("sudo", allArgs...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
defer stdout.Close()
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
defer stderr.Close()
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.cmd = cmd
|
||||
s.mu.Unlock()
|
||||
|
||||
// read address
|
||||
stdoutScanner := bufio.NewScanner(stdout)
|
||||
stdoutScanner.Scan()
|
||||
if stdoutScanner.Err() != nil {
|
||||
return fmt.Errorf("read addr: %w", stdoutScanner.Err())
|
||||
}
|
||||
addr := stdoutScanner.Text()
|
||||
// send the rest of stdout and stderr to logger to avoid blocking
|
||||
go func() {
|
||||
for stdoutScanner.Scan() {
|
||||
s.logf("tailscaled serve-tailfs stdout: %v", stdoutScanner.Text())
|
||||
}
|
||||
}()
|
||||
stderrScanner := bufio.NewScanner(stderr)
|
||||
go func() {
|
||||
for stderrScanner.Scan() {
|
||||
s.logf("tailscaled serve-tailfs stderr: %v", stderrScanner.Text())
|
||||
}
|
||||
}()
|
||||
s.mu.Lock()
|
||||
s.addr = strings.TrimSpace(addr)
|
||||
s.mu.Unlock()
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
var writeMethods = map[string]bool{
|
||||
"PUT": true,
|
||||
"POST": true,
|
||||
"COPY": true,
|
||||
"LOCK": true,
|
||||
"UNLOCK": true,
|
||||
"MKCOL": true,
|
||||
"MOVE": true,
|
||||
"PROPPATCH": true,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !unix
|
||||
|
||||
package tailfs
|
||||
|
||||
func doAllowShareAs() bool {
|
||||
// On non-UNIX platforms, we use the GUI application (e.g. Windows taskbar
|
||||
// icon) to access the filesystem as whatever unprivileged user is running
|
||||
// the GUI app, so we cannot allow sharing as a different user.
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Permission uint8
|
||||
|
||||
const (
|
||||
PermissionNone Permission = iota
|
||||
PermissionReadOnly
|
||||
PermissionReadWrite
|
||||
)
|
||||
|
||||
const (
|
||||
accessReadOnly = "ro"
|
||||
accessReadWrite = "rw"
|
||||
|
||||
wildcardShare = "*"
|
||||
)
|
||||
|
||||
// Permissions represents the set of permissions for a given principal to a
|
||||
// set of shares.
|
||||
type Permissions map[string]Permission
|
||||
|
||||
type grant struct {
|
||||
Shares []string
|
||||
Access string
|
||||
}
|
||||
|
||||
// ParsePermissions builds a Permissions map from a lis of raw grants.
|
||||
func ParsePermissions(rawGrants [][]byte) (Permissions, error) {
|
||||
permissions := make(Permissions)
|
||||
for _, rawGrant := range rawGrants {
|
||||
var g grant
|
||||
err := json.Unmarshal(rawGrant, &g)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unmarshal raw grants: %v", err)
|
||||
}
|
||||
for _, share := range g.Shares {
|
||||
existingPermission := permissions[share]
|
||||
permission := PermissionReadOnly
|
||||
if g.Access == accessReadWrite {
|
||||
permission = PermissionReadWrite
|
||||
}
|
||||
if permission > existingPermission {
|
||||
permissions[share] = permission
|
||||
}
|
||||
}
|
||||
}
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
func (p Permissions) For(share string) Permission {
|
||||
specific := p[share]
|
||||
wildcard := p[wildcardShare]
|
||||
if specific > wildcard {
|
||||
return specific
|
||||
}
|
||||
return wildcard
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermissions(t *testing.T) {
|
||||
tests := []struct {
|
||||
perms []grant
|
||||
share string
|
||||
want Permission
|
||||
}{
|
||||
{[]grant{
|
||||
{Shares: []string{"*"}, Access: "ro"},
|
||||
{Shares: []string{"a"}, Access: "rw"},
|
||||
},
|
||||
"a",
|
||||
PermissionReadWrite,
|
||||
},
|
||||
{[]grant{
|
||||
{Shares: []string{"*"}, Access: "ro"},
|
||||
{Shares: []string{"a"}, Access: "rw"},
|
||||
},
|
||||
"b",
|
||||
PermissionReadOnly,
|
||||
},
|
||||
{[]grant{
|
||||
{Shares: []string{"a"}, Access: "rw"},
|
||||
},
|
||||
"c",
|
||||
PermissionNone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.share, func(t *testing.T) {
|
||||
var rawPerms [][]byte
|
||||
for _, perm := range tt.perms {
|
||||
b, err := json.Marshal(perm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawPerms = append(rawPerms, b)
|
||||
}
|
||||
|
||||
p, err := ParsePermissions(rawPerms)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := p.For(tt.share)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build unix
|
||||
|
||||
package tailfs
|
||||
|
||||
import "tailscale.com/version"
|
||||
|
||||
func doAllowShareAs() bool {
|
||||
// All UNIX platforms use user servers (sub-processes) to access the OS
|
||||
// filesystem as a specific unprivileged users, except for sandboxed macOS
|
||||
// which doesn't support impersonating users and instead accesses files
|
||||
// through the macOS GUI app as whatever unprivileged user is running it.
|
||||
return !version.IsSandboxedMacOS()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package shared
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file provides utility functions for working with URL paths. These are
|
||||
// similar to functions in package path in the standard library, but differ in
|
||||
// ways that are documented on the relevant functions.
|
||||
|
||||
const (
|
||||
sepString = "/"
|
||||
sepStringAndDot = "/."
|
||||
sep = '/'
|
||||
)
|
||||
|
||||
// CleanAndSplit cleans the provided path p and splits it into its constituent
|
||||
// parts. This is different from path.Split which just splits a path into prefix
|
||||
// and suffix.
|
||||
func CleanAndSplit(p string) []string {
|
||||
return strings.Split(strings.Trim(path.Clean(p), sepStringAndDot), sepString)
|
||||
}
|
||||
|
||||
// Join behaves like path.Join() but also includes a leading slash.
|
||||
func Join(parts ...string) string {
|
||||
fullParts := make([]string, 0, len(parts))
|
||||
fullParts = append(fullParts, sepString)
|
||||
for _, part := range parts {
|
||||
fullParts = append(fullParts, part)
|
||||
}
|
||||
return path.Join(fullParts...)
|
||||
}
|
||||
|
||||
// IsRoot determines whether a given path p is the root path, defined as either
|
||||
// empty or "/".
|
||||
func IsRoot(p string) bool {
|
||||
return p == "" || p == sepString
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package shared
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCleanAndSplit(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want []string
|
||||
}{
|
||||
{"", []string{""}},
|
||||
{"/", []string{""}},
|
||||
{"//", []string{""}},
|
||||
{"a", []string{"a"}},
|
||||
{"/a", []string{"a"}},
|
||||
{"a/", []string{"a"}},
|
||||
{"/a/", []string{"a"}},
|
||||
{"a/b", []string{"a", "b"}},
|
||||
{"/a/b", []string{"a", "b"}},
|
||||
{"a/b/", []string{"a", "b"}},
|
||||
{"/a/b/", []string{"a", "b"}},
|
||||
{"/a/../b", []string{"b"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
if got := CleanAndSplit(tt.path); !reflect.DeepEqual(tt.want, got) {
|
||||
t.Errorf("CleanAndSplit(%q) = %v; want %v", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoin(t *testing.T) {
|
||||
tests := []struct {
|
||||
parts []string
|
||||
want string
|
||||
}{
|
||||
{[]string{""}, "/"},
|
||||
{[]string{"a"}, "/a"},
|
||||
{[]string{"/a"}, "/a"},
|
||||
{[]string{"/a/"}, "/a"},
|
||||
{[]string{"/a/", "/b/"}, "/a/b"},
|
||||
{[]string{"/a/../b", "c"}, "/b/c"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(Join(tt.parts...), func(t *testing.T) {
|
||||
if got := Join(tt.parts...); !reflect.DeepEqual(tt.want, got) {
|
||||
t.Errorf("Join(%v) = %q; want %q", tt.parts, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package shared contains types and functions shared by different tailfs
|
||||
// packages.
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DirFile implements webdav.File for a virtual directory.
|
||||
// It mimics the behavior of an os.File that is pointing at a real directory.
|
||||
type DirFile struct {
|
||||
// Info provides the fs.FileInfo for this directory
|
||||
Info fs.FileInfo
|
||||
// LoadChildren is used to load the fs.FileInfos for this directory's
|
||||
// children. It is called at most once in order to support listing
|
||||
// children.
|
||||
LoadChildren func() ([]fs.FileInfo, error)
|
||||
|
||||
// loadChildrenMu guards children and loadedChildren.
|
||||
loadChildrenMu sync.Mutex
|
||||
children []fs.FileInfo
|
||||
loadedChildren bool
|
||||
}
|
||||
|
||||
// Readdir implements interface webdav.File. It lazily loads information about
|
||||
// children when it is called.
|
||||
func (d *DirFile) Readdir(count int) ([]fs.FileInfo, error) {
|
||||
err := d.loadChildrenIfNecessary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if count <= 0 {
|
||||
result := d.children
|
||||
d.children = nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
n := len(d.children)
|
||||
if count < n {
|
||||
n = count
|
||||
}
|
||||
result := d.children[:n]
|
||||
d.children = d.children[n:]
|
||||
if len(d.children) == 0 {
|
||||
err = io.EOF
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *DirFile) loadChildrenIfNecessary() error {
|
||||
d.loadChildrenMu.Lock()
|
||||
defer d.loadChildrenMu.Unlock()
|
||||
|
||||
if !d.loadedChildren {
|
||||
var err error
|
||||
d.children, err = d.LoadChildren()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.loadedChildren = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stat implements interface webdav.File.
|
||||
func (d *DirFile) Stat() (fs.FileInfo, error) {
|
||||
return d.Info, nil
|
||||
}
|
||||
|
||||
// Close implements interface webdav.File. It does nothing and never returns an
|
||||
// error.
|
||||
func (d *DirFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read implements interface webdav.File. As this is a directory, it always
|
||||
// fails with an fs.PathError.
|
||||
func (d *DirFile) Read(b []byte) (int, error) {
|
||||
return 0, &fs.PathError{
|
||||
Op: "read",
|
||||
Path: d.Info.Name(),
|
||||
Err: errors.New("is a directory"),
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements interface webdav.File. As this is a directory, it always
|
||||
// fails with an fs.PathError.
|
||||
func (d *DirFile) Write(b []byte) (int, error) {
|
||||
return 0, &fs.PathError{
|
||||
Op: "write",
|
||||
Path: d.Info.Name(),
|
||||
Err: errors.New("bad file descriptor"),
|
||||
}
|
||||
}
|
||||
|
||||
// Seek implements interface webdav.File. As this is a directory, it always
|
||||
// fails with an fs.PathError.
|
||||
func (d *DirFile) Seek(offset int64, whence int) (int64, error) {
|
||||
return 0, &fs.PathError{
|
||||
Op: "seek",
|
||||
Path: d.Info.Name(),
|
||||
Err: errors.New("invalid argument"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
)
|
||||
|
||||
// StaticFileInfo implements a static fs.FileInfo
|
||||
type StaticFileInfo struct {
|
||||
// Named controls Name()
|
||||
Named string
|
||||
// Sized controls Size()
|
||||
Sized int64
|
||||
// Moded controls Mode()
|
||||
Moded os.FileMode
|
||||
// BirthedTime controls BirthTime()
|
||||
BirthedTime time.Time
|
||||
// BirthedTimeErr stores any error encountered when trying to get BirthTime
|
||||
BirthedTimeErr error
|
||||
// ModdedTime controls ModTime()
|
||||
ModdedTime time.Time
|
||||
// Dir controls IsDir()
|
||||
Dir bool
|
||||
}
|
||||
|
||||
// BirthTime implements webdav.BirthTimer
|
||||
func (fi *StaticFileInfo) BirthTime(_ context.Context) (time.Time, error) {
|
||||
return fi.BirthedTime, fi.BirthedTimeErr
|
||||
}
|
||||
func (fi *StaticFileInfo) Name() string { return fi.Named }
|
||||
func (fi *StaticFileInfo) Size() int64 { return fi.Sized }
|
||||
func (fi *StaticFileInfo) Mode() os.FileMode { return fi.Moded }
|
||||
func (fi *StaticFileInfo) ModTime() time.Time { return fi.ModdedTime }
|
||||
func (fi *StaticFileInfo) IsDir() bool { return fi.Dir }
|
||||
func (fi *StaticFileInfo) Sys() any { return nil }
|
||||
|
||||
func RenamedFileInfo(ctx context.Context, name string, fi fs.FileInfo) *StaticFileInfo {
|
||||
var birthTime time.Time
|
||||
var birthTimeErr error
|
||||
birthTimer, ok := fi.(webdav.BirthTimer)
|
||||
if ok {
|
||||
birthTime, birthTimeErr = birthTimer.BirthTime(ctx)
|
||||
}
|
||||
|
||||
return &StaticFileInfo{
|
||||
Named: name,
|
||||
Sized: fi.Size(),
|
||||
Moded: fi.Mode(),
|
||||
BirthedTime: birthTime,
|
||||
BirthedTimeErr: birthTimeErr,
|
||||
ModdedTime: fi.ModTime(),
|
||||
Dir: fi.IsDir(),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadOnlyDirInfo returns a static fs.FileInfo for a read-only directory
|
||||
func ReadOnlyDirInfo(name string, ts time.Time) *StaticFileInfo {
|
||||
return &StaticFileInfo{
|
||||
Named: name,
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
BirthedTime: ts,
|
||||
ModdedTime: ts,
|
||||
Dir: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package tailfs provides a filesystem that allows sharing folders between
|
||||
// Tailscale nodes using WebDAV.
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// statCacheTTL causes the local WebDAV proxy to cache file metadata to
|
||||
// avoid excessive network roundtrips. 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)
|
||||
statCacheTTL = 10 * time.Second
|
||||
)
|
||||
@@ -0,0 +1,597 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/tailfs/shared"
|
||||
"tailscale.com/tailfs/webdavfs"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
const (
|
||||
domain = `test$%domain.com`
|
||||
|
||||
remote1 = `remote$%1`
|
||||
remote2 = `_remote$%2`
|
||||
share11 = `share$%11`
|
||||
share12 = `_share$%12`
|
||||
file111 = `file$%111.txt`
|
||||
)
|
||||
|
||||
func init() {
|
||||
// set AllowShareAs() to false so that we don't try to use sub-processes
|
||||
// for access files on disk.
|
||||
disallowShareAs = true
|
||||
}
|
||||
|
||||
// The tests in this file simulate real-life Tailfs scenarios, but without
|
||||
// going over the Tailscale network stack.
|
||||
func TestDirectoryListing(t *testing.T) {
|
||||
s := newSystem(t)
|
||||
defer s.stop()
|
||||
|
||||
s.addRemote(remote1)
|
||||
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("remote with no shares should be empty", shared.Join(domain, remote1))
|
||||
s.addShare(remote1, share11, PermissionReadWrite)
|
||||
s.checkDirList("remote with one share should contain that share", shared.Join(domain, remote1), share11)
|
||||
s.addShare(remote1, share12, PermissionReadOnly)
|
||||
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.addRemote(remote2)
|
||||
s.checkDirList("domain with two remotes should contain both in lexicographical order", shared.Join(domain), remote2, 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("directory listing for offline remote should return empty list", shared.Join(domain, remote1))
|
||||
s.unfreezeRemote(remote1)
|
||||
|
||||
s.checkDirList("attempt at lateral traversal should simply list shares", shared.Join(domain, remote1, share11, ".."), share12, share11)
|
||||
}
|
||||
|
||||
func TestFileManipulation(t *testing.T) {
|
||||
s := newSystem(t)
|
||||
defer s.stop()
|
||||
|
||||
s.addRemote(remote1)
|
||||
s.addShare(remote1, share11, PermissionReadWrite)
|
||||
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
|
||||
s.checkFileStatus(remote1, share11, file111)
|
||||
s.checkFileContents(remote1, share11, file111)
|
||||
|
||||
s.addShare(remote1, share12, PermissionReadOnly)
|
||||
s.writeFile("writing file to read-only remote should fail", remote1, share12, file111, "hello world", false)
|
||||
|
||||
s.writeFile("writing file to non-existent remote should fail", "non-existent", share11, 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, 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, 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 {
|
||||
l net.Listener
|
||||
fs *FileSystemForLocal
|
||||
}
|
||||
|
||||
type remote struct {
|
||||
l net.Listener
|
||||
fs *FileSystemForRemote
|
||||
fileServer *FileServer
|
||||
shares map[string]string
|
||||
permissions map[string]Permission
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (r *remote) freeze() {
|
||||
r.mu.Lock()
|
||||
}
|
||||
|
||||
func (r *remote) unfreeze() {
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
r.fs.ServeHTTPWithPerms(r.permissions, w, req)
|
||||
}
|
||||
|
||||
type system struct {
|
||||
t *testing.T
|
||||
local *local
|
||||
fs webdav.FileSystem
|
||||
remotes map[string]*remote
|
||||
}
|
||||
|
||||
func newSystem(t *testing.T) *system {
|
||||
// Make sure we don't leak goroutines
|
||||
tstest.ResourceCheck(t)
|
||||
|
||||
fs := NewFileSystemForLocal(log.Printf)
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to Listen: %s", err)
|
||||
}
|
||||
t.Logf("FileSystemForLocal listening at %s", l.Addr())
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
t.Logf("Accept: %v", err)
|
||||
return
|
||||
}
|
||||
go fs.HandleConn(conn, conn.RemoteAddr())
|
||||
}
|
||||
}()
|
||||
|
||||
return &system{
|
||||
t: t,
|
||||
local: &local{l: l, fs: fs},
|
||||
fs: webdavfs.New(webdavfs.Options{
|
||||
URL: fmt.Sprintf("http://%s", l.Addr()),
|
||||
Transport: &http.Transport{DisableKeepAlives: true},
|
||||
}),
|
||||
remotes: make(map[string]*remote),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *system) addRemote(name string) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Listen: %s", err)
|
||||
}
|
||||
s.t.Logf("Remote for %v listening at %s", name, l.Addr())
|
||||
|
||||
fileServer, err := NewFileServer()
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to call NewFileServer: %s", err)
|
||||
}
|
||||
go fileServer.Serve()
|
||||
s.t.Logf("FileServer for %v listening at %s", name, fileServer.Addr())
|
||||
|
||||
r := &remote{
|
||||
l: l,
|
||||
fileServer: fileServer,
|
||||
fs: NewFileSystemForRemote(log.Printf),
|
||||
shares: make(map[string]string),
|
||||
permissions: make(map[string]Permission),
|
||||
}
|
||||
r.fs.SetFileServerAddr(fileServer.Addr())
|
||||
go http.Serve(l, r)
|
||||
s.remotes[name] = r
|
||||
|
||||
remotes := make([]*Remote, 0, len(s.remotes))
|
||||
for name, r := range s.remotes {
|
||||
remotes = append(remotes, &Remote{
|
||||
Name: name,
|
||||
URL: fmt.Sprintf("http://%s", r.l.Addr()),
|
||||
})
|
||||
}
|
||||
s.local.fs.SetRemotes(domain, remotes, &http.Transport{})
|
||||
}
|
||||
|
||||
func (s *system) addShare(remoteName, shareName string, permission Permission) {
|
||||
r, ok := s.remotes[remoteName]
|
||||
if !ok {
|
||||
s.t.Fatalf("unknown remote %q", remoteName)
|
||||
}
|
||||
|
||||
f := s.t.TempDir()
|
||||
r.shares[shareName] = f
|
||||
r.permissions[shareName] = permission
|
||||
|
||||
shares := make(map[string]*Share, len(r.shares))
|
||||
for shareName, folder := range r.shares {
|
||||
shares[shareName] = &Share{
|
||||
Name: shareName,
|
||||
Path: folder,
|
||||
}
|
||||
}
|
||||
r.fs.SetShares(shares)
|
||||
r.fileServer.SetShares(r.shares)
|
||||
}
|
||||
|
||||
func (s *system) freezeRemote(remoteName string) {
|
||||
r, ok := s.remotes[remoteName]
|
||||
if !ok {
|
||||
s.t.Fatalf("unknown remote %q", remoteName)
|
||||
}
|
||||
r.freeze()
|
||||
}
|
||||
|
||||
func (s *system) unfreezeRemote(remoteName string) {
|
||||
r, ok := s.remotes[remoteName]
|
||||
if !ok {
|
||||
s.t.Fatalf("unknown remote %q", remoteName)
|
||||
}
|
||||
r.unfreeze()
|
||||
}
|
||||
|
||||
func (s *system) writeFile(label, remoteName, shareName, name, contents string, expectSuccess bool) {
|
||||
path := pathTo(remoteName, shareName, name)
|
||||
file, err := s.fs.OpenFile(context.Background(), path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if expectSuccess && err != nil {
|
||||
s.t.Fatalf("%v: expected success writing file %q, but got error %v", label, path, err)
|
||||
}
|
||||
defer func() {
|
||||
if !expectSuccess && err == nil {
|
||||
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) {
|
||||
expectedFI := s.stat(remoteName, shareName, name)
|
||||
actualFI := s.statViaWebDAV(remoteName, shareName, name)
|
||||
s.checkFileInfosEqual(expectedFI, actualFI, fmt.Sprintf("%s/%s/%s should show same FileInfo via WebDAV stat as local stat", remoteName, shareName, name))
|
||||
}
|
||||
|
||||
func (s *system) checkFileContents(remoteName, shareName, name string) {
|
||||
expected := s.read(remoteName, shareName, name)
|
||||
actual := s.readViaWebDAV(remoteName, shareName, name)
|
||||
if expected != actual {
|
||||
s.t.Errorf("%s/%s/%s should show same contents via WebDAV read as local read\nwant: %q\nhave: %q", remoteName, shareName, name, expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *system) checkDirList(label string, path string, want ...string) {
|
||||
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to OpenFile: %s", err)
|
||||
}
|
||||
|
||||
got, err := file.Readdir(0)
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Readdir: %s", err)
|
||||
}
|
||||
|
||||
if len(want) == 0 && len(got) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
gotNames := make([]string, 0, len(got))
|
||||
for _, fi := range got {
|
||||
gotNames = append(gotNames, fi.Name())
|
||||
}
|
||||
if diff := cmp.Diff(want, gotNames); diff != "" {
|
||||
s.t.Errorf("%v: (-got, +want):\n%s", label, diff)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
filename := filepath.Join(s.remotes[remoteName].shares[shareName], name)
|
||||
fi, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Stat: %s", err)
|
||||
}
|
||||
|
||||
return fi
|
||||
}
|
||||
|
||||
func (s *system) statViaWebDAV(remoteName, shareName, name string) os.FileInfo {
|
||||
path := pathTo(remoteName, shareName, name)
|
||||
fi, err := s.fs.Stat(context.Background(), path)
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Stat: %s", err)
|
||||
}
|
||||
|
||||
return fi
|
||||
}
|
||||
|
||||
func (s *system) read(remoteName, shareName, name string) string {
|
||||
filename := filepath.Join(s.remotes[remoteName].shares[shareName], name)
|
||||
b, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to ReadFile: %s", err)
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (s *system) readViaWebDAV(remoteName, shareName, name string) string {
|
||||
path := pathTo(remoteName, shareName, name)
|
||||
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *system) stop() {
|
||||
err := s.local.fs.Close()
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Close fs: %s", err)
|
||||
}
|
||||
|
||||
err = s.local.l.Close()
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Close listener: %s", err)
|
||||
}
|
||||
|
||||
for _, r := range s.remotes {
|
||||
err = r.fs.Close()
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Close remote fs: %s", err)
|
||||
}
|
||||
|
||||
err = r.l.Close()
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Close remote listener: %s", err)
|
||||
}
|
||||
|
||||
err = r.fileServer.Close()
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to Close remote fileserver: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *system) checkFileInfosEqual(expected, actual fs.FileInfo, label string) {
|
||||
if expected == nil && actual == nil {
|
||||
return
|
||||
}
|
||||
diff := cmp.Diff(fileInfoToStatic(expected, true), fileInfoToStatic(actual, false))
|
||||
if diff != "" {
|
||||
s.t.Errorf("%v (-got, +want):\n%s", label, diff)
|
||||
}
|
||||
}
|
||||
|
||||
func fileInfoToStatic(fi fs.FileInfo, fixupMode bool) fs.FileInfo {
|
||||
mode := fi.Mode()
|
||||
if fixupMode {
|
||||
// WebDAV doesn't transmit file modes, so we just mimic the defaults that
|
||||
// our WebDAV client uses.
|
||||
mode = os.FileMode(0664)
|
||||
if fi.IsDir() {
|
||||
mode = 0775 | os.ModeDir
|
||||
}
|
||||
}
|
||||
return &shared.StaticFileInfo{
|
||||
Named: fi.Name(),
|
||||
Sized: fi.Size(),
|
||||
Moded: mode,
|
||||
ModdedTime: fi.ModTime().Truncate(1 * time.Second).UTC(),
|
||||
Dir: fi.IsDir(),
|
||||
}
|
||||
}
|
||||
|
||||
func pathTo(remote, share, name string) string {
|
||||
return path.Join(domain, remote, share, name)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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/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
|
||||
}
|
||||
@@ -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/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
|
||||
}
|
||||
Reference in New Issue
Block a user