mirror of https://github.com/tailscale/tailscale/
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>pull/11127/head
parent
2e404b769d
commit
993acf4475
@ -0,0 +1,209 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/tailfs"
|
||||
)
|
||||
|
||||
const (
|
||||
shareAddUsage = "[ALPHA] share add <name> <path>"
|
||||
shareRemoveUsage = "[ALPHA] share remove <name>"
|
||||
shareListUsage = "[ALPHA] share list"
|
||||
)
|
||||
|
||||
var shareCmd = &ffcli.Command{
|
||||
Name: "share",
|
||||
ShortHelp: "Share a directory with your tailnet",
|
||||
ShortUsage: strings.Join([]string{
|
||||
shareAddUsage,
|
||||
shareRemoveUsage,
|
||||
shareListUsage,
|
||||
}, "\n "),
|
||||
LongHelp: buildShareLongHelp(),
|
||||
UsageFunc: usageFuncNoDefaultValues,
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
Exec: runShareAdd,
|
||||
ShortHelp: "add a share",
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
ShortHelp: "remove a share",
|
||||
Exec: runShareRemove,
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
ShortHelp: "list current shares",
|
||||
Exec: runShareList,
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
},
|
||||
Exec: func(context.Context, []string) error {
|
||||
return errors.New("share subcommand required; run 'tailscale share -h' for details")
|
||||
},
|
||||
}
|
||||
|
||||
// runShareAdd is the entry point for the "tailscale share add" command.
|
||||
func runShareAdd(ctx context.Context, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("usage: tailscale %v", shareAddUsage)
|
||||
}
|
||||
|
||||
name, path := args[0], args[1]
|
||||
|
||||
err := localClient.TailfsShareAdd(ctx, &tailfs.Share{
|
||||
Name: name,
|
||||
Path: path,
|
||||
})
|
||||
if err == nil {
|
||||
fmt.Printf("Added share %q at %q\n", name, path)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// runShareRemove is the entry point for the "tailscale share remove" command.
|
||||
func runShareRemove(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("usage: tailscale %v", shareRemoveUsage)
|
||||
}
|
||||
name := args[0]
|
||||
|
||||
err := localClient.TailfsShareRemove(ctx, name)
|
||||
if err == nil {
|
||||
fmt.Printf("Removed share %q\n", name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// runShareList is the entry point for the "tailscale share list" command.
|
||||
func runShareList(ctx context.Context, args []string) error {
|
||||
if len(args) != 0 {
|
||||
return fmt.Errorf("usage: tailscale %v", shareListUsage)
|
||||
}
|
||||
|
||||
sharesMap, err := localClient.TailfsShareList(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shares := make([]*tailfs.Share, 0, len(sharesMap))
|
||||
for _, share := range sharesMap {
|
||||
shares = append(shares, share)
|
||||
}
|
||||
|
||||
sort.Slice(shares, func(i, j int) bool {
|
||||
return shares[i].Name < shares[j].Name
|
||||
})
|
||||
|
||||
longestName := 4 // "name"
|
||||
longestPath := 4 // "path"
|
||||
longestAs := 2 // "as"
|
||||
for _, share := range shares {
|
||||
if len(share.Name) > longestName {
|
||||
longestName = len(share.Name)
|
||||
}
|
||||
if len(share.Path) > longestPath {
|
||||
longestPath = len(share.Path)
|
||||
}
|
||||
if len(share.As) > longestAs {
|
||||
longestAs = len(share.As)
|
||||
}
|
||||
}
|
||||
formatString := fmt.Sprintf("%%-%ds %%-%ds %%s\n", longestName, longestPath)
|
||||
fmt.Printf(formatString, "name", "path", "as")
|
||||
fmt.Printf(formatString, strings.Repeat("-", longestName), strings.Repeat("-", longestPath), strings.Repeat("-", longestAs))
|
||||
for _, share := range shares {
|
||||
fmt.Printf(formatString, share.Name, share.Path, share.As)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildShareLongHelp() string {
|
||||
longHelpAs := ""
|
||||
if tailfs.AllowShareAs() {
|
||||
longHelpAs = shareLongHelpAs
|
||||
}
|
||||
return fmt.Sprintf(shareLongHelpBase, longHelpAs)
|
||||
}
|
||||
|
||||
var shareLongHelpBase = `Tailscale share allows you to share directories with other machines on your tailnet.
|
||||
|
||||
Each share is identified by a name and points to a directory at a specific path. For example, to share the path /Users/me/Documents under the name "docs", you would run:
|
||||
|
||||
$ tailscale share add docs /Users/me/Documents
|
||||
|
||||
Note that the system forces share names to lowercase to avoid problems with clients that don't support case-sensitive filenames.
|
||||
|
||||
Share names may only contain the letters a-z, underscore _, parentheses (), or spaces. Leading and trailing spaces are omitted.
|
||||
|
||||
All Tailscale shares have a globally unique path consisting of the tailnet, the machine name and the share name. For example, if the above share was created on the machine "mylaptop" on the tailnet "mydomain.com", the share's path would be:
|
||||
|
||||
/mydomain.com/mylaptop/docs
|
||||
|
||||
In order to access this share, other machines on the tailnet can connect to the above path on a WebDAV server running at 100.100.100.100:8080, for example:
|
||||
|
||||
http://100.100.100.100:8080/mydomain.com/mylaptop/docs
|
||||
|
||||
Permissions to access shares are controlled via ACLs. For example, to give yourself read/write access and give the group "home" read-only access to the above share, use the below ACL grants:
|
||||
|
||||
{
|
||||
"src": ["mylogin@domain.com"],
|
||||
"dst": ["mylaptop's ip address"],
|
||||
"app": {
|
||||
"tailscale.com/cap/tailfs": [{
|
||||
"shares": ["docs"],
|
||||
"access": "rw"
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"src": ["group:home"],
|
||||
"dst": ["mylaptop"],
|
||||
"app": {
|
||||
"tailscale.com/cap/tailfs": [{
|
||||
"shares": ["docs"],
|
||||
"access": "ro"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
To categorically give yourself access to all your shares, you can use the below ACL grant:
|
||||
{
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["autogroup:self"],
|
||||
"app": {
|
||||
"tailscale.com/cap/tailfs": [{
|
||||
"shares": ["*"],
|
||||
"access": "rw"
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Whenever either you or anyone in the group "home" connects to the share, they connect as if they are using your local machine user. They'll be able to read the same files as your user and if they create files, those files will be owned by your user.%s
|
||||
|
||||
You can remove shares by name, for example you could remove the above share by running:
|
||||
|
||||
$ tailscale share remove docs
|
||||
|
||||
You can get a list of currently published shares by running:
|
||||
|
||||
$ tailscale share list`
|
||||
|
||||
var shareLongHelpAs = `
|
||||
|
||||
If you want a share to be accessed as a different user, you can use sudo to accomplish this. For example, to create the aforementioned share as "theuser", you could run:
|
||||
|
||||
$ sudo -u theuser tailscale share add docs /Users/theuser/Documents`
|
@ -1 +1 @@
|
||||
sha256-OfwliH0EmrGoOVog8b27F0p5+foElOrRZkOtGR4+Sts=
|
||||
sha256-eci4f6golU1eIQOezplA+I+gmOfof40ktIdpr0v/uMc=
|
||||
|
@ -0,0 +1,318 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/logtail/backoff"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
const (
|
||||
// TailfsLocalPort is the port on which the Tailfs listens for location
|
||||
// connections on quad 100.
|
||||
TailfsLocalPort = 8080
|
||||
|
||||
tailfsSharesStateKey = ipn.StateKey("_tailfs-shares")
|
||||
)
|
||||
|
||||
var (
|
||||
shareNameRegex = regexp.MustCompile(`^[a-z0-9_\(\) ]+$`)
|
||||
errInvalidShareName = errors.New("Share names may only contain the letters a-z, underscore _, parentheses (), or spaces")
|
||||
)
|
||||
|
||||
// TailfsSharingEnabled reports whether sharing to remote nodes via tailfs is
|
||||
// enabled. This is currently based on checking for the tailfs:share node
|
||||
// attribute.
|
||||
func (b *LocalBackend) TailfsSharingEnabled() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.tailfsSharingEnabledLocked()
|
||||
}
|
||||
|
||||
func (b *LocalBackend) tailfsSharingEnabledLocked() bool {
|
||||
return b.netMap != nil && b.netMap.SelfNode.HasCap(tailcfg.NodeAttrsTailfsSharingEnabled)
|
||||
}
|
||||
|
||||
// TailfsSetFileServerAddr tells tailfs to use the given address for connecting
|
||||
// to the tailfs.FileServer that's exposing local files as an unprivileged
|
||||
// user.
|
||||
func (b *LocalBackend) TailfsSetFileServerAddr(addr string) error {
|
||||
b.mu.Lock()
|
||||
fs := b.tailfsForRemote
|
||||
b.mu.Unlock()
|
||||
if fs == nil {
|
||||
return errors.New("tailfs not enabled")
|
||||
}
|
||||
|
||||
fs.SetFileServerAddr(addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TailfsAddShare adds the given share if no share with that name exists, or
|
||||
// replaces the existing share if one with the same name already exists.
|
||||
// To avoid potential incompatibilities across file systems, share names are
|
||||
// limited to alphanumeric characters and the underscore _.
|
||||
func (b *LocalBackend) TailfsAddShare(share *tailfs.Share) error {
|
||||
var err error
|
||||
share.Name, err = normalizeShareName(share.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
shares, err := b.tailfsAddShareLocked(share)
|
||||
b.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.tailfsNotifyShares(shares)
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeShareName normalizes the given share name and returns an error if
|
||||
// it contains any disallowed characters.
|
||||
func normalizeShareName(name string) (string, error) {
|
||||
// Force all share names to lowercase to avoid potential incompatibilities
|
||||
// with clients that don't support case-sensitive filenames.
|
||||
name = strings.ToLower(name)
|
||||
|
||||
// Trim whitespace
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
if !shareNameRegex.MatchString(name) {
|
||||
return "", errInvalidShareName
|
||||
}
|
||||
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) tailfsAddShareLocked(share *tailfs.Share) (map[string]string, error) {
|
||||
if b.tailfsForRemote == nil {
|
||||
return nil, errors.New("tailfs not enabled")
|
||||
}
|
||||
|
||||
shares, err := b.tailfsGetSharesLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shares[share.Name] = share
|
||||
data, err := json.Marshal(shares)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
err = b.store.WriteState(tailfsSharesStateKey, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("write state: %w", err)
|
||||
}
|
||||
b.tailfsForRemote.SetShares(shares)
|
||||
|
||||
return shareNameMap(shares), nil
|
||||
}
|
||||
|
||||
// TailfsRemoveShare removes the named share. Share names are forced to
|
||||
// lowercase.
|
||||
func (b *LocalBackend) TailfsRemoveShare(name string) error {
|
||||
// Force all share names to lowercase to avoid potential incompatibilities
|
||||
// with clients that don't support case-sensitive filenames.
|
||||
name = strings.ToLower(name)
|
||||
|
||||
b.mu.Lock()
|
||||
shares, err := b.tailfsRemoveShareLocked(name)
|
||||
b.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.tailfsNotifyShares(shares)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) tailfsRemoveShareLocked(name string) (map[string]string, error) {
|
||||
if b.tailfsForRemote == nil {
|
||||
return nil, errors.New("tailfs not enabled")
|
||||
}
|
||||
|
||||
shares, err := b.tailfsGetSharesLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, shareExists := shares[name]
|
||||
if !shareExists {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
delete(shares, name)
|
||||
data, err := json.Marshal(shares)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
err = b.store.WriteState(tailfsSharesStateKey, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("write state: %w", err)
|
||||
}
|
||||
b.tailfsForRemote.SetShares(shares)
|
||||
|
||||
return shareNameMap(shares), nil
|
||||
}
|
||||
|
||||
func shareNameMap(sharesByName map[string]*tailfs.Share) map[string]string {
|
||||
sharesMap := make(map[string]string, len(sharesByName))
|
||||
for _, share := range sharesByName {
|
||||
sharesMap[share.Name] = share.Path
|
||||
}
|
||||
return sharesMap
|
||||
}
|
||||
|
||||
// tailfsNotifyShares notifies IPN bus listeners (e.g. Mac Application process)
|
||||
// about the latest set of shares, supplied as a map of name -> directory.
|
||||
func (b *LocalBackend) tailfsNotifyShares(shares map[string]string) {
|
||||
b.send(ipn.Notify{TailfsShares: shares})
|
||||
}
|
||||
|
||||
// tailfsNotifyCurrentSharesLocked sends an ipn.Notify with the current set of
|
||||
// tailfs shares.
|
||||
func (b *LocalBackend) tailfsNotifyCurrentSharesLocked() {
|
||||
shares, err := b.tailfsGetSharesLocked()
|
||||
if err != nil {
|
||||
b.logf("error notifying current tailfs shares: %v", err)
|
||||
return
|
||||
}
|
||||
// Do the below on a goroutine to avoid deadlocking on b.mu in b.send().
|
||||
go b.tailfsNotifyShares(shareNameMap(shares))
|
||||
}
|
||||
|
||||
// TailfsGetShares() returns the current set of shares from the state store.
|
||||
func (b *LocalBackend) TailfsGetShares() (map[string]*tailfs.Share, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
return b.tailfsGetSharesLocked()
|
||||
}
|
||||
|
||||
func (b *LocalBackend) tailfsGetSharesLocked() (map[string]*tailfs.Share, error) {
|
||||
data, err := b.store.ReadState(tailfsSharesStateKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, ipn.ErrStateNotExist) {
|
||||
return make(map[string]*tailfs.Share), nil
|
||||
}
|
||||
return nil, fmt.Errorf("read state: %w", err)
|
||||
}
|
||||
|
||||
var shares map[string]*tailfs.Share
|
||||
err = json.Unmarshal(data, &shares)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
// updateTailfsListenersLocked creates listeners on the local Tailfs port.
|
||||
// This is needed to properly route local traffic when using kernel networking
|
||||
// mode.
|
||||
func (b *LocalBackend) updateTailfsListenersLocked() {
|
||||
if b.netMap == nil {
|
||||
return
|
||||
}
|
||||
|
||||
addrs := b.netMap.GetAddresses()
|
||||
oldListeners := b.tailfsListeners
|
||||
newListeners := make(map[netip.AddrPort]*localListener, addrs.Len())
|
||||
for i := range addrs.LenIter() {
|
||||
if fs, ok := b.sys.TailfsForLocal.GetOK(); ok {
|
||||
addrPort := netip.AddrPortFrom(addrs.At(i).Addr(), TailfsLocalPort)
|
||||
if sl, ok := b.tailfsListeners[addrPort]; ok {
|
||||
newListeners[addrPort] = sl
|
||||
delete(oldListeners, addrPort)
|
||||
continue // already listening
|
||||
}
|
||||
|
||||
sl := b.newTailfsListener(context.Background(), fs, addrPort, b.logf)
|
||||
newListeners[addrPort] = sl
|
||||
go sl.Run()
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, anything left in oldListeners can be stopped.
|
||||
for _, sl := range oldListeners {
|
||||
sl.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// newTailfsListener returns a listener for local connections to a tailfs
|
||||
// WebDAV FileSystem.
|
||||
func (b *LocalBackend) newTailfsListener(ctx context.Context, fs *tailfs.FileSystemForLocal, ap netip.AddrPort, logf logger.Logf) *localListener {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &localListener{
|
||||
b: b,
|
||||
ap: ap,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logf: logf,
|
||||
|
||||
handler: func(conn net.Conn) error {
|
||||
return fs.HandleConn(conn, conn.RemoteAddr())
|
||||
},
|
||||
bo: backoff.NewBackoff(fmt.Sprintf("tailfs-listener-%d", ap.Port()), logf, 30*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// updateTailfsPeersLocked sets all applicable peers from the netmap as tailfs
|
||||
// remotes.
|
||||
func (b *LocalBackend) updateTailfsPeersLocked(nm *netmap.NetworkMap) {
|
||||
fs, ok := b.sys.TailfsForLocal.GetOK()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
tailfsRemotes := make([]*tailfs.Remote, 0, len(nm.Peers))
|
||||
for _, p := range nm.Peers {
|
||||
peerID := p.ID()
|
||||
url := fmt.Sprintf("%s/%s", peerAPIBase(nm, p), tailfsPrefix[1:])
|
||||
tailfsRemotes = append(tailfsRemotes, &tailfs.Remote{
|
||||
Name: p.DisplayName(false),
|
||||
URL: url,
|
||||
Available: func() bool {
|
||||
// TODO(oxtoacart): need to figure out a performant and reliable way to only
|
||||
// show the peers that have shares to which we have access
|
||||
// This will require work on the control server to transmit the inverse
|
||||
// of the "tailscale.com/cap/tailfs" capability.
|
||||
// For now, at least limit it only to nodes that are online.
|
||||
// Note, we have to iterate the latest netmap because the peer we got from the first iteration may not be it
|
||||
b.mu.Lock()
|
||||
latestNetMap := b.netMap
|
||||
b.mu.Unlock()
|
||||
|
||||
for _, candidate := range latestNetMap.Peers {
|
||||
if candidate.ID() == peerID {
|
||||
online := candidate.Online()
|
||||
// TODO(oxtoacart): for some reason, this correctly
|
||||
// catches when a node goes from offline to online,
|
||||
// but not the other way around...
|
||||
return online != nil && *online
|
||||
}
|
||||
}
|
||||
|
||||
// peer not found, must not be available
|
||||
return false
|
||||
},
|
||||
})
|
||||
}
|
||||
fs.SetRemotes(b.netMap.Domain, tailfsRemotes, &tailfsTransport{b: b})
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeShareName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: " (_this is A 5 nAme )_ ",
|
||||
want: "(_this is a 5 name )_",
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
err: errInvalidShareName,
|
||||
},
|
||||
{
|
||||
name: "generally good except for .",
|
||||
err: errInvalidShareName,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("name %q", tt.name), func(t *testing.T) {
|
||||
got, err := normalizeShareName(tt.name)
|
||||
if tt.err != nil && err != tt.err {
|
||||
t.Errorf("wanted error %v, got %v", tt.err, err)
|
||||
} else if got != tt.want {
|
||||
t.Errorf("wanted %q, got %q", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue