mirror of
https://github.com/tailscale/tailscale.git
synced 2025-08-11 13:18:53 +00:00
tailscale: update tailfs file and package names (#11590)
This change updates the tailfs file and package names to their new naming convention. Updates #tailscale/corp#16827 Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
This commit is contained in:

committed by
GitHub

parent
1c259100b0
commit
14683371ee
44
drive/drive_clone.go
Normal file
44
drive/drive_clone.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
|
||||
|
||||
package drive
|
||||
|
||||
// Clone makes a deep copy of Share.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *Share) Clone() *Share {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(Share)
|
||||
*dst = *src
|
||||
dst.BookmarkData = append(src.BookmarkData[:0:0], src.BookmarkData...)
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _ShareCloneNeedsRegeneration = Share(struct {
|
||||
Name string
|
||||
Path string
|
||||
As string
|
||||
BookmarkData []byte
|
||||
}{})
|
||||
|
||||
// Clone duplicates src into dst and reports whether it succeeded.
|
||||
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
|
||||
// where T is one of Share.
|
||||
func Clone(dst, src any) bool {
|
||||
switch src := src.(type) {
|
||||
case *Share:
|
||||
switch dst := dst.(type) {
|
||||
case *Share:
|
||||
*dst = *src.Clone()
|
||||
return true
|
||||
case **Share:
|
||||
*dst = src.Clone()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
75
drive/drive_view.go
Normal file
75
drive/drive_view.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=Share
|
||||
|
||||
// View returns a readonly view of Share.
|
||||
func (p *Share) View() ShareView {
|
||||
return ShareView{ж: p}
|
||||
}
|
||||
|
||||
// ShareView provides a read-only view over Share.
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type ShareView struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *Share
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v ShareView) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v ShareView) AsStruct() *Share {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v ShareView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *ShareView) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x Share
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v ShareView) Name() string { return v.ж.Name }
|
||||
func (v ShareView) Path() string { return v.ж.Path }
|
||||
func (v ShareView) As() string { return v.ж.As }
|
||||
func (v ShareView) BookmarkData() views.ByteSlice[[]byte] {
|
||||
return views.ByteSliceOf(v.ж.BookmarkData)
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _ShareViewNeedsRegeneration = Share(struct {
|
||||
Name string
|
||||
Path string
|
||||
As string
|
||||
BookmarkData []byte
|
||||
}{})
|
83
drive/driveimpl/birthtiming.go
Normal file
83
drive/driveimpl/birthtiming.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package driveimpl
|
||||
|
||||
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
|
||||
}
|
104
drive/driveimpl/birthtiming_test.go
Normal file
104
drive/driveimpl/birthtiming_test.go
Normal file
@@ -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 driveimpl
|
||||
|
||||
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])
|
||||
}
|
233
drive/driveimpl/compositedav/compositedav.go
Normal file
233
drive/driveimpl/compositedav/compositedav.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package compositedav provides an http.Handler that composes multiple WebDAV
|
||||
// services into a single WebDAV service that presents each of them as its own
|
||||
// folder.
|
||||
package compositedav
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/drive/driveimpl/dirfs"
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// Child is a child folder of this compositedav.
|
||||
type Child struct {
|
||||
*dirfs.Child
|
||||
|
||||
// BaseURL is the base URL of the WebDAV service to which we'll proxy
|
||||
// requests for this Child. We will append the filename from the original
|
||||
// URL to this.
|
||||
BaseURL string
|
||||
|
||||
// Transport (if specified) is the http transport to use when communicating
|
||||
// with this Child's WebDAV service.
|
||||
Transport http.RoundTripper
|
||||
|
||||
rp *httputil.ReverseProxy
|
||||
initOnce sync.Once
|
||||
}
|
||||
|
||||
// CloseIdleConnections forcibly closes any idle connections on this Child's
|
||||
// reverse proxy.
|
||||
func (c *Child) CloseIdleConnections() {
|
||||
tr, ok := c.Transport.(*http.Transport)
|
||||
if ok {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Child) init() {
|
||||
c.initOnce.Do(func() {
|
||||
c.rp = &httputil.ReverseProxy{
|
||||
Transport: c.Transport,
|
||||
Rewrite: func(r *httputil.ProxyRequest) {},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handler implements http.Handler by using a dirfs.FS for showing a virtual
|
||||
// read-only folder that represents the Child WebDAV services as sub-folders
|
||||
// and proxying all requests for resources on the children to those children
|
||||
// via httputil.ReverseProxy instances.
|
||||
type Handler struct {
|
||||
// Logf specifies a logging function to use.
|
||||
Logf logger.Logf
|
||||
|
||||
// Clock, if specified, determines the current time. If not specified, we
|
||||
// default to time.Now().
|
||||
Clock tstime.Clock
|
||||
|
||||
// StatCache is an optional cache for PROPFIND results.
|
||||
StatCache *StatCache
|
||||
|
||||
// childrenMu guards the fields below. Note that we do read the contents of
|
||||
// children after releasing the read lock, which we can do because we never
|
||||
// modify children but only ever replace it in SetChildren.
|
||||
childrenMu sync.RWMutex
|
||||
children []*Child
|
||||
staticRoot string
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PROPFIND" {
|
||||
h.handlePROPFIND(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != "GET" {
|
||||
// If the user is performing a modification (e.g. PUT, MKDIR, etc),
|
||||
// we need to invalidate the StatCache to make sure we're not knowingly
|
||||
// showing stale stats.
|
||||
// TODO(oxtoacart): maybe be more selective about invalidating cache
|
||||
h.StatCache.invalidate()
|
||||
}
|
||||
|
||||
mpl := h.maxPathLength(r)
|
||||
pathComponents := shared.CleanAndSplit(r.URL.Path)
|
||||
|
||||
if len(pathComponents) >= mpl {
|
||||
h.delegate(pathComponents[mpl-1:], w, r)
|
||||
return
|
||||
}
|
||||
h.handle(w, r)
|
||||
}
|
||||
|
||||
// handle handles the request locally using our dirfs.FS.
|
||||
func (h *Handler) handle(w http.ResponseWriter, r *http.Request) {
|
||||
h.childrenMu.RLock()
|
||||
clk, kids, root := h.Clock, h.children, h.staticRoot
|
||||
h.childrenMu.RUnlock()
|
||||
|
||||
children := make([]*dirfs.Child, 0, len(kids))
|
||||
for _, child := range kids {
|
||||
children = append(children, child.Child)
|
||||
}
|
||||
wh := &webdav.Handler{
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
FileSystem: &dirfs.FS{
|
||||
Clock: clk,
|
||||
Children: children,
|
||||
StaticRoot: root,
|
||||
},
|
||||
}
|
||||
|
||||
wh.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// delegate sends the request to the Child WebDAV server.
|
||||
func (h *Handler) delegate(pathComponents []string, w http.ResponseWriter, r *http.Request) string {
|
||||
childName := pathComponents[0]
|
||||
child := h.GetChild(childName)
|
||||
if child == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return childName
|
||||
}
|
||||
u, err := url.Parse(child.BaseURL)
|
||||
if err != nil {
|
||||
h.logf("warning: parse base URL %s failed: %s", child.BaseURL, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return childName
|
||||
}
|
||||
u.Path = path.Join(u.Path, shared.Join(pathComponents[1:]...))
|
||||
r.URL = u
|
||||
r.Host = u.Host
|
||||
child.rp.ServeHTTP(w, r)
|
||||
return childName
|
||||
}
|
||||
|
||||
// SetChildren replaces the entire existing set of children with the given
|
||||
// ones. If staticRoot is given, the children will appear with a subfolder
|
||||
// bearing named <staticRoot>.
|
||||
func (h *Handler) SetChildren(staticRoot string, children ...*Child) {
|
||||
for _, child := range children {
|
||||
child.init()
|
||||
}
|
||||
|
||||
slices.SortFunc(children, func(a, b *Child) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
h.childrenMu.Lock()
|
||||
oldChildren := children
|
||||
h.children = children
|
||||
h.staticRoot = staticRoot
|
||||
h.childrenMu.Unlock()
|
||||
|
||||
for _, child := range oldChildren {
|
||||
child.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// GetChild gets the Child identified by name, or nil if no matching child
|
||||
// found.
|
||||
func (h *Handler) GetChild(name string) *Child {
|
||||
h.childrenMu.RLock()
|
||||
defer h.childrenMu.RUnlock()
|
||||
|
||||
_, child := h.findChildLocked(name)
|
||||
return child
|
||||
}
|
||||
|
||||
// Close closes this Handler,including closing all idle connections on children
|
||||
// and stopping the StatCache (if caching is enabled).
|
||||
func (h *Handler) Close() {
|
||||
h.childrenMu.RLock()
|
||||
oldChildren := h.children
|
||||
h.children = nil
|
||||
h.childrenMu.RUnlock()
|
||||
|
||||
for _, child := range oldChildren {
|
||||
child.CloseIdleConnections()
|
||||
}
|
||||
|
||||
if h.StatCache != nil {
|
||||
h.StatCache.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) findChildLocked(name string) (int, *Child) {
|
||||
var child *Child
|
||||
i, found := slices.BinarySearchFunc(h.children, name, func(child *Child, name string) int {
|
||||
return strings.Compare(child.Name, name)
|
||||
})
|
||||
if found {
|
||||
return i, h.children[i]
|
||||
}
|
||||
return i, child
|
||||
}
|
||||
|
||||
func (h *Handler) logf(format string, args ...any) {
|
||||
if h.Logf != nil {
|
||||
h.Logf(format, args...)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
|
||||
// maxPathLength calculates the maximum length of a path that can be handled by
|
||||
// this handler without delegating to a Child. It's always at least 1, and if
|
||||
// staticRoot is configured, it's 2.
|
||||
func (h *Handler) maxPathLength(r *http.Request) int {
|
||||
h.childrenMu.RLock()
|
||||
defer h.childrenMu.RUnlock()
|
||||
|
||||
if h.staticRoot != "" {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
84
drive/driveimpl/compositedav/propfind.go
Normal file
84
drive/driveimpl/compositedav/propfind.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositedav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
)
|
||||
|
||||
var (
|
||||
hrefRegex = regexp.MustCompile(`(?s)<D:href>/?([^<]*)/?</D:href>`)
|
||||
)
|
||||
|
||||
func (h *Handler) handlePROPFIND(w http.ResponseWriter, r *http.Request) {
|
||||
pathComponents := shared.CleanAndSplit(r.URL.Path)
|
||||
mpl := h.maxPathLength(r)
|
||||
if !shared.IsRoot(r.URL.Path) && len(pathComponents)+getDepth(r) > mpl {
|
||||
// Delegate to a Child.
|
||||
depth := getDepth(r)
|
||||
|
||||
cached := h.StatCache.get(r.URL.Path, depth)
|
||||
if cached != nil {
|
||||
w.Header().Del("Content-Length")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
w.Write(cached)
|
||||
return
|
||||
}
|
||||
|
||||
// Use a buffering ResponseWriter so that we can manipulate the result.
|
||||
// The only thing we use from the original ResponseWriter is Header().
|
||||
bw := &bufferingResponseWriter{ResponseWriter: w}
|
||||
|
||||
mpl := h.maxPathLength(r)
|
||||
h.delegate(pathComponents[mpl-1:], bw, r)
|
||||
|
||||
// Fixup paths to add the requested path as a prefix.
|
||||
pathPrefix := shared.Join(pathComponents[0:mpl]...)
|
||||
b := hrefRegex.ReplaceAll(bw.buf.Bytes(), []byte(fmt.Sprintf("<D:href>%s/$1</D:href>", pathPrefix)))
|
||||
|
||||
if h.StatCache != nil && bw.status == http.StatusMultiStatus && b != nil {
|
||||
h.StatCache.set(r.URL.Path, depth, b)
|
||||
}
|
||||
|
||||
w.Header().Del("Content-Length")
|
||||
w.WriteHeader(bw.status)
|
||||
w.Write(b)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.handle(w, r)
|
||||
}
|
||||
|
||||
func getDepth(r *http.Request) int {
|
||||
switch r.Header.Get("Depth") {
|
||||
case "0":
|
||||
return 0
|
||||
case "1":
|
||||
return 1
|
||||
case "infinity":
|
||||
return math.MaxInt
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type bufferingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (bw *bufferingResponseWriter) WriteHeader(statusCode int) {
|
||||
bw.status = statusCode
|
||||
}
|
||||
|
||||
func (bw *bufferingResponseWriter) Write(p []byte) (int, error) {
|
||||
return bw.buf.Write(p)
|
||||
}
|
92
drive/driveimpl/compositedav/stat_cache.go
Normal file
92
drive/driveimpl/compositedav/stat_cache.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositedav
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
)
|
||||
|
||||
// StatCache provides a cache for directory listings 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.
|
||||
// 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)
|
||||
type StatCache struct {
|
||||
TTL time.Duration
|
||||
|
||||
// mu guards the below values.
|
||||
mu sync.Mutex
|
||||
cachesByDepthAndPath map[int]*ttlcache.Cache[string, []byte]
|
||||
}
|
||||
|
||||
func (c *StatCache) get(name string, depth int) []byte {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.cachesByDepthAndPath == nil {
|
||||
return nil
|
||||
}
|
||||
cache := c.cachesByDepthAndPath[depth]
|
||||
if cache == nil {
|
||||
return nil
|
||||
}
|
||||
item := cache.Get(name)
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return item.Value()
|
||||
}
|
||||
|
||||
func (c *StatCache) set(name string, depth int, value []byte) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.cachesByDepthAndPath == nil {
|
||||
c.cachesByDepthAndPath = make(map[int]*ttlcache.Cache[string, []byte])
|
||||
}
|
||||
cache := c.cachesByDepthAndPath[depth]
|
||||
if cache == nil {
|
||||
cache = ttlcache.New(
|
||||
ttlcache.WithTTL[string, []byte](c.TTL),
|
||||
)
|
||||
go cache.Start()
|
||||
c.cachesByDepthAndPath[depth] = cache
|
||||
}
|
||||
cache.Set(name, value, ttlcache.DefaultTTL)
|
||||
}
|
||||
|
||||
func (c *StatCache) invalidate() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for _, cache := range c.cachesByDepthAndPath {
|
||||
cache.DeleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StatCache) stop() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for _, cache := range c.cachesByDepthAndPath {
|
||||
cache.Stop()
|
||||
}
|
||||
}
|
75
drive/driveimpl/compositedav/stat_cache_test.go
Normal file
75
drive/driveimpl/compositedav/stat_cache_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package compositedav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
var (
|
||||
val = []byte("1")
|
||||
file = "file"
|
||||
)
|
||||
|
||||
func TestStatCacheNoTimeout(t *testing.T) {
|
||||
// Make sure we don't leak goroutines
|
||||
tstest.ResourceCheck(t)
|
||||
|
||||
c := &StatCache{TTL: 5 * time.Second}
|
||||
defer c.stop()
|
||||
|
||||
// check get before set
|
||||
fetched := c.get(file, 1)
|
||||
if fetched != nil {
|
||||
t.Errorf("got %q, want nil", fetched)
|
||||
}
|
||||
|
||||
// set new stat
|
||||
c.set(file, 1, val)
|
||||
fetched = c.get(file, 1)
|
||||
if !bytes.Equal(fetched, val) {
|
||||
t.Errorf("got %q, want %q", fetched, val)
|
||||
}
|
||||
|
||||
// fetch stat again, should still be cached
|
||||
fetched = c.get(file, 1)
|
||||
if !bytes.Equal(fetched, val) {
|
||||
t.Errorf("got %q, want %q", fetched, val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatCacheTimeout(t *testing.T) {
|
||||
// Make sure we don't leak goroutines
|
||||
tstest.ResourceCheck(t)
|
||||
|
||||
c := &StatCache{TTL: 250 * time.Millisecond}
|
||||
defer c.stop()
|
||||
|
||||
// set new stat
|
||||
c.set(file, 1, val)
|
||||
fetched := c.get(file, 1)
|
||||
if !bytes.Equal(fetched, val) {
|
||||
t.Errorf("got %q, want %q", fetched, val)
|
||||
}
|
||||
|
||||
// wait for cache to expire and refetch stat, should be empty now
|
||||
time.Sleep(c.TTL * 2)
|
||||
|
||||
fetched = c.get(file, 1)
|
||||
if fetched != nil {
|
||||
t.Errorf("invalidate should have cleared cached value")
|
||||
}
|
||||
|
||||
c.set(file, 1, val)
|
||||
// invalidate the cache and make sure nothing is returned
|
||||
c.invalidate()
|
||||
fetched = c.get(file, 1)
|
||||
if fetched != nil {
|
||||
t.Errorf("invalidate should have cleared cached value")
|
||||
}
|
||||
}
|
79
drive/driveimpl/connlistener.go
Normal file
79
drive/driveimpl/connlistener.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package driveimpl
|
||||
|
||||
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
|
||||
}
|
68
drive/driveimpl/connlistener_test.go
Normal file
68
drive/driveimpl/connlistener_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package driveimpl
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
101
drive/driveimpl/dirfs/dirfs.go
Normal file
101
drive/driveimpl/dirfs/dirfs.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package dirfs provides a webdav.FileSystem that looks like a read-only
|
||||
// directory containing only subdirectories.
|
||||
package dirfs
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
"tailscale.com/tstime"
|
||||
)
|
||||
|
||||
// Child is subdirectory of an FS.
|
||||
type Child struct {
|
||||
// Name is the name of the child
|
||||
Name string
|
||||
|
||||
// Available is a function indicating whether or not the child is currently
|
||||
// available. Unavailable children are excluded from the FS's directory
|
||||
// listing. Available must be safe for concurrent use.
|
||||
Available func() bool
|
||||
}
|
||||
|
||||
func (c *Child) isAvailable() bool {
|
||||
if c.Available == nil {
|
||||
return true
|
||||
}
|
||||
return c.Available()
|
||||
}
|
||||
|
||||
// FS is a read-only webdav.FileSystem that is composed of multiple child
|
||||
// folders.
|
||||
//
|
||||
// When listing the contents of this FileSystem's root directory, children will
|
||||
// be ordered in the order they're given to the FS.
|
||||
//
|
||||
// Children in an FS cannot be added, removed or renamed via operations on the
|
||||
// webdav.FileSystem interface like filesystem.Mkdir or filesystem.OpenFile.
|
||||
//
|
||||
// Any attempts to perform operations on paths inside of children will result
|
||||
// in a panic, as these are not expected to be performed on this FS.
|
||||
//
|
||||
// An FS an optionally have a StaticRoot, which will insert a folder with that
|
||||
// StaticRoot into the tree, like this:
|
||||
//
|
||||
// -- <StaticRoot>
|
||||
// ----- <Child>
|
||||
// ----- <Child>
|
||||
type FS struct {
|
||||
// Children configures the full set of children of this FS.
|
||||
Children []*Child
|
||||
|
||||
// Clock, if given, will cause this FS to use Clock.now() as the current
|
||||
// time.
|
||||
Clock tstime.Clock
|
||||
|
||||
// StaticRoot, if given, will insert the given name as a static root into
|
||||
// every path.
|
||||
StaticRoot string
|
||||
}
|
||||
|
||||
func (dfs *FS) findChild(name string) (int, *Child) {
|
||||
var child *Child
|
||||
i, found := slices.BinarySearchFunc(dfs.Children, name, func(child *Child, name string) int {
|
||||
return strings.Compare(child.Name, name)
|
||||
})
|
||||
if found {
|
||||
child = dfs.Children[i]
|
||||
}
|
||||
return i, child
|
||||
}
|
||||
|
||||
// childFor returns the child for the given filename. If the filename refers to
|
||||
// a path inside of a child, this will panic.
|
||||
func (dfs *FS) childFor(name string) *Child {
|
||||
pathComponents := shared.CleanAndSplit(name)
|
||||
if len(pathComponents) != 1 {
|
||||
panic("dirfs does not permit reaching into child directories")
|
||||
}
|
||||
_, child := dfs.findChild(pathComponents[0])
|
||||
return child
|
||||
}
|
||||
|
||||
func (dfs *FS) now() time.Time {
|
||||
if dfs.Clock != nil {
|
||||
return dfs.Clock.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (dfs *FS) trimStaticRoot(name string) (string, bool) {
|
||||
before, after, found := strings.Cut(name, "/"+dfs.StaticRoot)
|
||||
if !found {
|
||||
return before, false
|
||||
}
|
||||
return after, shared.IsRoot(after)
|
||||
}
|
348
drive/driveimpl/dirfs/dirfs_test.go
Normal file
348
drive/driveimpl/dirfs/dirfs_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package dirfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
func TestStat(t *testing.T) {
|
||||
cfs, _, _, clock := createFileSystem(t)
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
name string
|
||||
expected fs.FileInfo
|
||||
err error
|
||||
}{
|
||||
{
|
||||
label: "root folder",
|
||||
name: "",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "",
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "static root folder",
|
||||
name: "/domain",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "domain",
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "remote1",
|
||||
name: "/domain/remote1",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "remote1",
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "remote2",
|
||||
name: "/domain/remote2",
|
||||
expected: &shared.StaticFileInfo{
|
||||
Named: "remote2",
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "non-existent remote",
|
||||
name: "remote3",
|
||||
err: os.ErrNotExist,
|
||||
},
|
||||
}
|
||||
|
||||
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 !errors.Is(err, test.err) {
|
||||
t.Errorf("got %v, want %v", err, test.err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unable to stat file: %v", err)
|
||||
} else {
|
||||
infosEqual(t, test.expected, fi)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDir(t *testing.T) {
|
||||
cfs, _, _, clock := createFileSystem(t)
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
name string
|
||||
expected []fs.FileInfo
|
||||
err error
|
||||
}{
|
||||
{
|
||||
label: "root folder",
|
||||
name: "",
|
||||
expected: []fs.FileInfo{
|
||||
&shared.StaticFileInfo{
|
||||
Named: "domain",
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "static root folder",
|
||||
name: "/domain",
|
||||
expected: []fs.FileInfo{
|
||||
&shared.StaticFileInfo{
|
||||
Named: "remote1",
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
&shared.StaticFileInfo{
|
||||
Named: "remote2",
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
&shared.StaticFileInfo{
|
||||
Named: "remote4",
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
ModdedTime: clock.Now(),
|
||||
Dir: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, test := range tests {
|
||||
t.Run(test.label, func(t *testing.T) {
|
||||
var infos []fs.FileInfo
|
||||
file, err := cfs.OpenFile(ctx, test.name, os.O_RDONLY, 0)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
infos, err = file.Readdir(0)
|
||||
}
|
||||
if test.err != nil {
|
||||
if !errors.Is(err, test.err) {
|
||||
t.Errorf("got %v, want %v", err, test.err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unable to stat file: %v", err)
|
||||
} else {
|
||||
if len(infos) != len(test.expected) {
|
||||
t.Errorf("wrong number of file infos, want %d, got %d", len(test.expected), len(infos))
|
||||
} else {
|
||||
for i, expected := range test.expected {
|
||||
infosEqual(t, expected, infos[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMkdir(t *testing.T) {
|
||||
fs, _, _, _ := createFileSystem(t)
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
name string
|
||||
perm os.FileMode
|
||||
err error
|
||||
}{
|
||||
{
|
||||
label: "attempt to create root folder",
|
||||
name: "/",
|
||||
},
|
||||
{
|
||||
label: "attempt to create static root folder",
|
||||
name: "/domain",
|
||||
},
|
||||
{
|
||||
label: "attempt to create remote",
|
||||
name: "/domain/remote1",
|
||||
},
|
||||
{
|
||||
label: "attempt to create non-existent remote",
|
||||
name: "/domain/remote3",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
}
|
||||
|
||||
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 !errors.Is(err, test.err) {
|
||||
t.Errorf("got %v, want %v", err, test.err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAll(t *testing.T) {
|
||||
fs, _, _, _ := createFileSystem(t)
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
label: "attempt to remove root folder",
|
||||
name: "/",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, test := range tests {
|
||||
t.Run(test.label, func(t *testing.T) {
|
||||
err := fs.RemoveAll(ctx, test.name)
|
||||
if !errors.Is(err, test.err) {
|
||||
t.Errorf("got %v, want %v", err, test.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRename(t *testing.T) {
|
||||
fs, _, _, _ := createFileSystem(t)
|
||||
|
||||
tests := []struct {
|
||||
label string
|
||||
oldName string
|
||||
newName string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
label: "attempt to move root folder",
|
||||
oldName: "/",
|
||||
newName: "/domain/remote2/copy.txt",
|
||||
err: os.ErrPermission,
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, test := range tests {
|
||||
t.Run(test.label, func(t *testing.T) {
|
||||
err := fs.Rename(ctx, test.oldName, test.newName)
|
||||
if !errors.Is(err, test.err) {
|
||||
t.Errorf("got %v, want: %v", err, test.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createFileSystem(t *testing.T) (webdav.FileSystem, string, string, *tstest.Clock) {
|
||||
s1, dir1 := startRemote(t)
|
||||
s2, 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)
|
||||
}
|
||||
|
||||
clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()})
|
||||
fs := &FS{
|
||||
Clock: clock,
|
||||
StaticRoot: "domain",
|
||||
Children: []*Child{
|
||||
{Name: "remote1"},
|
||||
{Name: "remote2"},
|
||||
{Name: "remote4"},
|
||||
},
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
defer s1.Close()
|
||||
defer os.RemoveAll(dir1)
|
||||
defer s2.Close()
|
||||
defer os.RemoveAll(dir2)
|
||||
})
|
||||
|
||||
return fs, dir1, dir2, clock
|
||||
}
|
||||
|
||||
func startRemote(t *testing.T) (*httptest.Server, string) {
|
||||
dir := t.TempDir()
|
||||
|
||||
h := &webdav.Handler{
|
||||
FileSystem: webdav.Dir(dir),
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
}
|
||||
|
||||
s := httptest.NewServer(h)
|
||||
t.Cleanup(s.Close)
|
||||
return s, dir
|
||||
}
|
||||
|
||||
func infosEqual(t *testing.T, expected, actual fs.FileInfo) {
|
||||
t.Helper()
|
||||
sfi, ok := actual.(*shared.StaticFileInfo)
|
||||
if ok {
|
||||
// zero out BirthedTime because we don't want to compare that
|
||||
sfi.BirthedTime = time.Time{}
|
||||
}
|
||||
if diff := cmp.Diff(actual, expected); diff != "" {
|
||||
t.Errorf("Wrong file info (-got, +want):\n%s", diff)
|
||||
}
|
||||
}
|
30
drive/driveimpl/dirfs/mkdir.go
Normal file
30
drive/driveimpl/dirfs/mkdir.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package dirfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
)
|
||||
|
||||
// Mkdir implements webdav.FileSystem. All attempts to Mkdir a directory that
|
||||
// already exists will succeed. All other attempts will fail with
|
||||
// os.ErrPermission.
|
||||
func (dfs *FS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
nameWithoutStaticRoot, isStaticRoot := dfs.trimStaticRoot(name)
|
||||
if isStaticRoot || shared.IsRoot(name) {
|
||||
// root directory already exists, consider this okay
|
||||
return nil
|
||||
}
|
||||
|
||||
child := dfs.childFor(nameWithoutStaticRoot)
|
||||
if child != nil {
|
||||
// child already exists, consider this okay
|
||||
return nil
|
||||
}
|
||||
|
||||
return &os.PathError{Op: "mkdir", Path: name, Err: os.ErrPermission}
|
||||
}
|
63
drive/driveimpl/dirfs/openfile.go
Normal file
63
drive/driveimpl/dirfs/openfile.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package dirfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
)
|
||||
|
||||
// OpenFile implements interface webdav.Filesystem.
|
||||
func (dfs *FS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
_, isStaticRoot := dfs.trimStaticRoot(name)
|
||||
if !isStaticRoot && !shared.IsRoot(name) {
|
||||
// Show a folder with no children to represent the requested child. In
|
||||
// practice, the children of this folder are never read, we just need
|
||||
// to give webdav a file here which it uses to call file.Stat(). So,
|
||||
// even though the Child may in fact have its own children, it doesn't
|
||||
// matter here.
|
||||
return &shared.DirFile{
|
||||
Info: shared.ReadOnlyDirInfo(name, dfs.now()),
|
||||
LoadChildren: func() ([]fs.FileInfo, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
di, err := dfs.Stat(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dfs.StaticRoot != "" && !isStaticRoot {
|
||||
// Show a folder with a single subfolder that is the static root.
|
||||
return &shared.DirFile{
|
||||
Info: di,
|
||||
LoadChildren: func() ([]fs.FileInfo, error) {
|
||||
return []fs.FileInfo{
|
||||
shared.ReadOnlyDirInfo(dfs.StaticRoot, dfs.now()),
|
||||
}, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Show a folder with one subfolder for each Child of this FS.
|
||||
return &shared.DirFile{
|
||||
Info: di,
|
||||
LoadChildren: func() ([]fs.FileInfo, error) {
|
||||
childInfos := make([]fs.FileInfo, 0, len(dfs.Children))
|
||||
for _, c := range dfs.Children {
|
||||
if c.isAvailable() {
|
||||
childInfo := shared.ReadOnlyDirInfo(c.Name, dfs.now())
|
||||
childInfos = append(childInfos, childInfo)
|
||||
}
|
||||
}
|
||||
return childInfos, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
15
drive/driveimpl/dirfs/removeall.go
Normal file
15
drive/driveimpl/dirfs/removeall.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package dirfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
)
|
||||
|
||||
// RemoveAll implements webdav.File. No removal is supported and this always
|
||||
// returns os.ErrPermission.
|
||||
func (dfs *FS) RemoveAll(ctx context.Context, name string) error {
|
||||
return &os.PathError{Op: "rm", Path: name, Err: os.ErrPermission}
|
||||
}
|
15
drive/driveimpl/dirfs/rename.go
Normal file
15
drive/driveimpl/dirfs/rename.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package dirfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Rename implements interface webdav.FileSystem. No renaming is supported and
|
||||
// this always returns os.ErrPermission.
|
||||
func (dfs *FS) Rename(ctx context.Context, oldName, newName string) error {
|
||||
return &os.PathError{Op: "mv", Path: oldName, Err: os.ErrPermission}
|
||||
}
|
30
drive/driveimpl/dirfs/stat.go
Normal file
30
drive/driveimpl/dirfs/stat.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package dirfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
)
|
||||
|
||||
// Stat implements webdav.FileSystem.
|
||||
func (dfs *FS) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
|
||||
nameWithoutStaticRoot, isStaticRoot := dfs.trimStaticRoot(name)
|
||||
if isStaticRoot || shared.IsRoot(name) {
|
||||
// Static root is a directory, always use now() as the modified time to
|
||||
// bust caches.
|
||||
fi := shared.ReadOnlyDirInfo(name, dfs.now())
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
child := dfs.childFor(nameWithoutStaticRoot)
|
||||
if child == nil {
|
||||
return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist}
|
||||
}
|
||||
|
||||
return shared.ReadOnlyDirInfo(name, dfs.now()), nil
|
||||
}
|
410
drive/driveimpl/drive_test.go
Normal file
410
drive/driveimpl/drive_test.go
Normal file
@@ -0,0 +1,410 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package driveimpl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/studio-b12/gowebdav"
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
const (
|
||||
domain = `test$%domain.com`
|
||||
|
||||
remote1 = `rem ote$%1`
|
||||
remote2 = `_rem ote$%2`
|
||||
share11 = `sha re$%11`
|
||||
share12 = `_sha re$%12`
|
||||
file111 = `fi le$%111.txt`
|
||||
)
|
||||
|
||||
func init() {
|
||||
// set AllowShareAs() to false so that we don't try to use sub-processes
|
||||
// for access files on disk.
|
||||
drive.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)
|
||||
|
||||
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, drive.PermissionReadWrite)
|
||||
s.checkDirList("remote with one share should contain that share", shared.Join(domain, remote1), share11)
|
||||
s.addShare(remote1, share12, drive.PermissionReadOnly)
|
||||
s.checkDirList("remote with two shares should contain both in lexicographical order", shared.Join(domain, remote1), share12, share11)
|
||||
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
|
||||
s.checkDirList("remote share should contain file", shared.Join(domain, remote1, share11), file111)
|
||||
|
||||
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)
|
||||
_, err := s.client.ReadDir(shared.Join(domain, remote1))
|
||||
if err == nil {
|
||||
t.Error("directory listing for offline remote should fail")
|
||||
}
|
||||
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)
|
||||
|
||||
s.addRemote(remote1)
|
||||
s.addShare(remote1, share11, drive.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, drive.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)
|
||||
}
|
||||
|
||||
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]drive.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
|
||||
client *gowebdav.Client
|
||||
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())
|
||||
}
|
||||
}()
|
||||
|
||||
client := gowebdav.NewAuthClient(fmt.Sprintf("http://%s", l.Addr()), &noopAuthorizer{})
|
||||
client.SetTransport(&http.Transport{DisableKeepAlives: true})
|
||||
s := &system{
|
||||
t: t,
|
||||
local: &local{l: l, fs: fs},
|
||||
client: client,
|
||||
remotes: make(map[string]*remote),
|
||||
}
|
||||
t.Cleanup(s.stop)
|
||||
return s
|
||||
}
|
||||
|
||||
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]drive.Permission),
|
||||
}
|
||||
r.fs.SetFileServerAddr(fileServer.Addr())
|
||||
go http.Serve(l, r)
|
||||
s.remotes[name] = r
|
||||
|
||||
remotes := make([]*drive.Remote, 0, len(s.remotes))
|
||||
for name, r := range s.remotes {
|
||||
remotes = append(remotes, &drive.Remote{
|
||||
Name: name,
|
||||
URL: fmt.Sprintf("http://%s", r.l.Addr()),
|
||||
})
|
||||
}
|
||||
s.local.fs.SetRemotes(
|
||||
domain,
|
||||
remotes,
|
||||
&http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *system) addShare(remoteName, shareName string, permission drive.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([]*drive.Share, 0, len(r.shares))
|
||||
for shareName, folder := range r.shares {
|
||||
shares = append(shares, &drive.Share{
|
||||
Name: shareName,
|
||||
Path: folder,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(shares, drive.CompareShares)
|
||||
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)
|
||||
err := s.client.Write(path, []byte(contents), 0644)
|
||||
if expectSuccess && err != nil {
|
||||
s.t.Fatalf("%v: expected success writing file %q, but got error %v", label, path, err)
|
||||
} else if !expectSuccess && err == nil {
|
||||
s.t.Fatalf("%v: expected error writing file %q", label, path)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
got, err := s.client.ReadDir(path)
|
||||
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) 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.client.Stat(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)
|
||||
b, err := s.client.Read(path)
|
||||
if err != nil {
|
||||
s.t.Fatalf("failed to OpenFile: %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)
|
||||
}
|
||||
|
||||
// noopAuthorizer implements gowebdav.Authorizer. It does no actual
|
||||
// authorizing. We use it in place of gowebdav's built-in authorizer in order
|
||||
// to avoid a race condition in that authorizer.
|
||||
type noopAuthorizer struct{}
|
||||
|
||||
func (a *noopAuthorizer) NewAuthenticator(body io.Reader) (gowebdav.Authenticator, io.Reader) {
|
||||
return &noopAuthenticator{}, nil
|
||||
}
|
||||
|
||||
func (a *noopAuthorizer) AddAuthenticator(key string, fn gowebdav.AuthFactory) {
|
||||
}
|
||||
|
||||
type noopAuthenticator struct{}
|
||||
|
||||
func (a *noopAuthenticator) Authorize(c *http.Client, rq *http.Request, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *noopAuthenticator) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (a *noopAuthenticator) Clone() gowebdav.Authenticator {
|
||||
return &noopAuthenticator{}
|
||||
}
|
||||
|
||||
func (a *noopAuthenticator) Close() error {
|
||||
return nil
|
||||
}
|
115
drive/driveimpl/fileserver.go
Normal file
115
drive/driveimpl/fileserver.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package driveimpl
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/drive/driveimpl/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()
|
||||
}
|
93
drive/driveimpl/local_impl.go
Normal file
93
drive/driveimpl/local_impl.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package driveimpl provides an implementation of package drive.
|
||||
package driveimpl
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/drive/driveimpl/compositedav"
|
||||
"tailscale.com/drive/driveimpl/dirfs"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// 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,
|
||||
h: &compositedav.Handler{
|
||||
Logf: logf,
|
||||
StatCache: &compositedav.StatCache{TTL: statCacheTTL},
|
||||
},
|
||||
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
|
||||
h *compositedav.Handler
|
||||
listener *connListener
|
||||
}
|
||||
|
||||
func (s *FileSystemForLocal) startServing() {
|
||||
hs := &http.Server{Handler: s.h}
|
||||
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 []*drive.Remote, transport http.RoundTripper) {
|
||||
children := make([]*compositedav.Child, 0, len(remotes))
|
||||
for _, remote := range remotes {
|
||||
children = append(children, &compositedav.Child{
|
||||
Child: &dirfs.Child{
|
||||
Name: remote.Name,
|
||||
Available: remote.Available,
|
||||
},
|
||||
BaseURL: remote.URL,
|
||||
Transport: transport,
|
||||
})
|
||||
}
|
||||
|
||||
s.h.SetChildren(domain, children...)
|
||||
}
|
||||
|
||||
// Close() stops serving the WebDAV content
|
||||
func (s *FileSystemForLocal) Close() error {
|
||||
err := s.listener.Close()
|
||||
s.h.Close()
|
||||
return err
|
||||
}
|
412
drive/driveimpl/remote_impl.go
Normal file
412
drive/driveimpl/remote_impl.go
Normal file
@@ -0,0 +1,412 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package driveimpl
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/drive/driveimpl/compositedav"
|
||||
"tailscale.com/drive/driveimpl/dirfs"
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func NewFileSystemForRemote(logf logger.Logf) *FileSystemForRemote {
|
||||
if logf == nil {
|
||||
logf = log.Printf
|
||||
}
|
||||
fs := &FileSystemForRemote{
|
||||
logf: logf,
|
||||
lockSystem: webdav.NewMemLS(),
|
||||
children: make(map[string]*compositedav.Child),
|
||||
userServers: make(map[string]*userServer),
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
// FileSystemForRemote implements tailfs.FileSystemForRemote.
|
||||
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 []*drive.Share
|
||||
children map[string]*compositedav.Child
|
||||
userServers map[string]*userServer
|
||||
}
|
||||
|
||||
// SetFileServerAddr implements tailfs.FileSystemForRemote.
|
||||
func (s *FileSystemForRemote) SetFileServerAddr(addr string) {
|
||||
s.mu.Lock()
|
||||
s.fileServerAddr = addr
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetShares implements tailfs.FileSystemForRemote. Shares must be sorted
|
||||
// according to tailfs.CompareShares.
|
||||
func (s *FileSystemForRemote) SetShares(shares []*drive.Share) {
|
||||
userServers := make(map[string]*userServer)
|
||||
if drive.AllowShareAs() {
|
||||
// Set up per-user server by running the current executable as an
|
||||
// unprivileged user in order to avoid privilege escalation.
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
s.logf("can't find executable: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, share := range shares {
|
||||
p, found := userServers[share.As]
|
||||
if !found {
|
||||
p = &userServer{
|
||||
logf: s.logf,
|
||||
username: share.As,
|
||||
executable: executable,
|
||||
}
|
||||
userServers[share.As] = p
|
||||
}
|
||||
p.shares = append(p.shares, share)
|
||||
}
|
||||
for _, p := range userServers {
|
||||
go p.runLoop()
|
||||
}
|
||||
}
|
||||
|
||||
children := make(map[string]*compositedav.Child, len(shares))
|
||||
for _, share := range shares {
|
||||
children[share.Name] = s.buildChild(share)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.shares = shares
|
||||
oldUserServers := s.userServers
|
||||
oldChildren := s.children
|
||||
s.children = children
|
||||
s.userServers = userServers
|
||||
s.mu.Unlock()
|
||||
|
||||
s.stopUserServers(oldUserServers)
|
||||
s.closeChildren(oldChildren)
|
||||
}
|
||||
|
||||
func (s *FileSystemForRemote) buildChild(share *drive.Share) *compositedav.Child {
|
||||
return &compositedav.Child{
|
||||
Child: &dirfs.Child{
|
||||
Name: share.Name,
|
||||
},
|
||||
BaseURL: fmt.Sprintf("http://%v/%v", hex.EncodeToString([]byte(share.Name)), url.PathEscape(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()
|
||||
var share *drive.Share
|
||||
i, shareFound := slices.BinarySearchFunc(s.shares, shareName, func(s *drive.Share, name string) int {
|
||||
return strings.Compare(s.Name, name)
|
||||
})
|
||||
if shareFound {
|
||||
share = s.shares[i]
|
||||
}
|
||||
userServers := s.userServers
|
||||
fileServerAddr := s.fileServerAddr
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !shareFound {
|
||||
return nil, fmt.Errorf("unknown share %v", shareName)
|
||||
}
|
||||
|
||||
var addr string
|
||||
if !drive.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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTPWithPerms implements tailfs.FileSystemForRemote.
|
||||
func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions drive.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 drive.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 drive.PermissionReadOnly:
|
||||
http.Error(w, "permission denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
childrenMap := s.children
|
||||
s.mu.RUnlock()
|
||||
|
||||
children := make([]*compositedav.Child, 0, len(childrenMap))
|
||||
// filter out shares to which the connecting principal has no access
|
||||
for name, child := range childrenMap {
|
||||
if permissions.For(name) == drive.PermissionNone {
|
||||
continue
|
||||
}
|
||||
|
||||
children = append(children, child)
|
||||
}
|
||||
|
||||
h := compositedav.Handler{
|
||||
Logf: s.logf,
|
||||
}
|
||||
h.SetChildren("", children...)
|
||||
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) closeChildren(children map[string]*compositedav.Child) {
|
||||
for _, child := range children {
|
||||
child.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// Close() implements tailfs.FileSystemForRemote.
|
||||
func (s *FileSystemForRemote) Close() error {
|
||||
s.mu.Lock()
|
||||
userServers := s.userServers
|
||||
children := s.children
|
||||
s.userServers = make(map[string]*userServer)
|
||||
s.children = make(map[string]*compositedav.Child)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.stopUserServers(userServers)
|
||||
s.closeChildren(children)
|
||||
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 []*drive.Share
|
||||
username string
|
||||
executable string
|
||||
|
||||
// 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() {
|
||||
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()
|
||||
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", s.executable, err, sleepTime)
|
||||
time.Sleep(sleepTime)
|
||||
}
|
||||
}
|
||||
|
||||
// Run runs the user server using the configured executable. This function only
|
||||
// works on UNIX systems, but those are the only ones on which we use
|
||||
// userServers anyway.
|
||||
func (s *userServer) run() error {
|
||||
// set up the command
|
||||
args := []string{"serve-tailfs"}
|
||||
for _, s := range s.shares {
|
||||
args = append(args, s.Name, s.Path)
|
||||
}
|
||||
var cmd *exec.Cmd
|
||||
if s.canSudo() {
|
||||
s.logf("starting TailFS file server as user %q", s.username)
|
||||
allArgs := []string{"-n", "-u", s.username, s.executable}
|
||||
allArgs = append(allArgs, args...)
|
||||
cmd = exec.Command("sudo", allArgs...)
|
||||
} else {
|
||||
// If we were root, we should have been able to sudo as a specific
|
||||
// user, but let's check just to make sure, since we never want to
|
||||
// access shared folders as root.
|
||||
err := s.assertNotRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logf("starting TailFS file server as ourselves")
|
||||
cmd = exec.Command(s.executable, args...)
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
// canSudo checks wether we can sudo -u the configured executable as the
|
||||
// configured user by attempting to call the executable with the '-h' flag to
|
||||
// print help.
|
||||
func (s *userServer) canSudo() bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "sudo", "-n", "-u", s.username, s.executable, "-h").Run(); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// assertNotRoot returns an error if the current user has UID 0 or if we cannot
|
||||
// determine the current user.
|
||||
//
|
||||
// On Linux, root users will always have UID 0.
|
||||
//
|
||||
// On BSD, root users should always have UID 0.
|
||||
func (s *userServer) assertNotRoot() error {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("assertNotRoot failed to find current user: %s", err)
|
||||
}
|
||||
if u.Uid == "0" {
|
||||
return fmt.Errorf("%q is root", u.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
50
drive/driveimpl/shared/pathutil.go
Normal file
50
drive/driveimpl/shared/pathutil.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// Base is like path.Base except that it returns "" for the root folder
|
||||
func Base(p string) string {
|
||||
if IsRoot(p) {
|
||||
return ""
|
||||
}
|
||||
return path.Base(p)
|
||||
}
|
57
drive/driveimpl/shared/pathutil_test.go
Normal file
57
drive/driveimpl/shared/pathutil_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
111
drive/driveimpl/shared/readonlydir.go
Normal file
111
drive/driveimpl/shared/readonlydir.go
Normal file
@@ -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"),
|
||||
}
|
||||
}
|
73
drive/driveimpl/shared/stat.go
Normal file
73
drive/driveimpl/shared/stat.go
Normal file
@@ -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: Base(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: Base(name),
|
||||
Sized: 0,
|
||||
Moded: 0555,
|
||||
BirthedTime: ts,
|
||||
ModdedTime: ts,
|
||||
Dir: true,
|
||||
}
|
||||
}
|
37
drive/local.go
Normal file
37
drive/local.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package drive provides a filesystem that allows sharing folders between
|
||||
// Tailscale nodes using WebDAV. The actual implementation of the core drive
|
||||
// functionality lives in package driveimpl. These packages are separated to
|
||||
// allow users of drive to refer to the interfaces without having a hard
|
||||
// dependency on drive, so that programs which don't actually use drive can
|
||||
// avoid its transitive dependencies.
|
||||
package drive
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Remote represents a remote TailFS node.
|
||||
type Remote struct {
|
||||
Name string
|
||||
URL string
|
||||
Available func() bool
|
||||
}
|
||||
|
||||
// FileSystemForLocal is the TailFS filesystem exposed to local clients. It
|
||||
// provides a unified WebDAV interface to remote TailFS shares on other nodes.
|
||||
type FileSystemForLocal interface {
|
||||
// HandleConn handles connections from local WebDAV clients
|
||||
HandleConn(conn net.Conn, remoteAddr net.Addr) error
|
||||
|
||||
// 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.
|
||||
SetRemotes(domain string, remotes []*Remote, transport http.RoundTripper)
|
||||
|
||||
// Close() stops serving the WebDAV content
|
||||
Close() error
|
||||
}
|
105
drive/remote.go
Normal file
105
drive/remote.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package drive
|
||||
|
||||
//go:generate go run tailscale.com/cmd/viewer --type=Share --clonefunc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// DisallowShareAs forcibly disables sharing as a specific user, only used
|
||||
// for testing.
|
||||
DisallowShareAs = false
|
||||
)
|
||||
|
||||
// AllowShareAs reports whether sharing files as a specific user is allowed.
|
||||
func AllowShareAs() bool {
|
||||
return !DisallowShareAs && doAllowShareAs()
|
||||
}
|
||||
|
||||
// Share configures a folder to be shared through TailFS.
|
||||
type Share struct {
|
||||
// Name is how this share appears on remote nodes.
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Path is the path to the directory on this machine that's being shared.
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// 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.
|
||||
// Can be left blank to use the default value of "whoever is running the
|
||||
// Tailscale GUI".
|
||||
As string `json:"who,omitempty"`
|
||||
|
||||
// BookmarkData contains security-scoped bookmark data for the Sandboxed
|
||||
// Mac application. The Sandboxed Mac application gains permission to
|
||||
// access the Share's folder as a result of a user selecting it in a file
|
||||
// picker. In order to retain access to it across restarts, it needs to
|
||||
// hold on to a security-scoped bookmark. That bookmark is stored here. See
|
||||
// https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox#4144043
|
||||
BookmarkData []byte `json:"bookmarkData,omitempty"`
|
||||
}
|
||||
|
||||
func ShareViewsEqual(a, b ShareView) bool {
|
||||
if !a.Valid() && !b.Valid() {
|
||||
return true
|
||||
}
|
||||
if !a.Valid() || !b.Valid() {
|
||||
return false
|
||||
}
|
||||
return a.Name() == b.Name() && a.Path() == b.Path() && a.As() == b.As() && a.BookmarkData().Equal(b.ж.BookmarkData)
|
||||
}
|
||||
|
||||
func SharesEqual(a, b *Share) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return a.Name == b.Name && a.Path == b.Path && a.As == b.As && bytes.Equal(a.BookmarkData, b.BookmarkData)
|
||||
}
|
||||
|
||||
func CompareShares(a, b *Share) int {
|
||||
if a == nil && b == nil {
|
||||
return 0
|
||||
}
|
||||
if a == nil {
|
||||
return -1
|
||||
}
|
||||
if b == nil {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
|
||||
// FileSystemForRemote is the TailFS filesystem exposed to remote nodes. It
|
||||
// provides a unified WebDAV interface to local directories that have been
|
||||
// shared.
|
||||
type FileSystemForRemote interface {
|
||||
// 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.
|
||||
SetFileServerAddr(addr string)
|
||||
|
||||
// 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.
|
||||
SetShares(shares []*Share)
|
||||
|
||||
// ServeHTTPWithPerms behaves like the similar method from http.Handler but
|
||||
// also accepts a Permissions map that captures the permissions of the
|
||||
// connecting node.
|
||||
ServeHTTPWithPerms(permissions Permissions, w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// Close() stops serving the WebDAV content
|
||||
Close() error
|
||||
}
|
13
drive/remote_nonunix.go
Normal file
13
drive/remote_nonunix.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !unix
|
||||
|
||||
package drive
|
||||
|
||||
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
|
||||
}
|
65
drive/remote_permissions.go
Normal file
65
drive/remote_permissions.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package drive
|
||||
|
||||
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
|
||||
}
|
61
drive/remote_permissions_test.go
Normal file
61
drive/remote_permissions_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package drive
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
16
drive/remote_unix.go
Normal file
16
drive/remote_unix.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build unix
|
||||
|
||||
package drive
|
||||
|
||||
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()
|
||||
}
|
Reference in New Issue
Block a user