mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-02 14:35:39 +00:00
993acf4475
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>
498 lines
11 KiB
Go
498 lines
11 KiB
Go
// 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
|
|
}
|