WIP: rebase fork onto upstream/main (v1.103.0) #15
@@ -573,7 +573,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
|
||||
golang.org/x/text/transform from golang.org/x/text/secure/bidirule+
|
||||
golang.org/x/text/unicode/bidi from golang.org/x/net/idna+
|
||||
golang.org/x/text/unicode/norm from golang.org/x/net/idna
|
||||
golang.org/x/text/unicode/norm from golang.org/x/net/idna+
|
||||
golang.org/x/time/rate from gvisor.dev/gvisor/pkg/log+
|
||||
vendor/golang.org/x/crypto/chacha20 from vendor/golang.org/x/crypto/chacha20poly1305
|
||||
vendor/golang.org/x/crypto/chacha20poly1305 from crypto/hpke+
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"tailscale.com/drive/driveimpl/shared"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,17 @@ var (
|
||||
notFound = newCacheEntry(http.StatusNotFound, nil)
|
||||
)
|
||||
|
||||
// normalize converts the given path into a canonical form for use as a cache
|
||||
// key. In addition to path cleanup, it applies Unicode NFC normalization so
|
||||
// that canonically equivalent names (e.g. the NFC name on disk and the NFD
|
||||
// name requested by a macOS WebDAV client) share a single cache entry.
|
||||
// Without this, a depth 0 lookup for the NFD form of a name would miss the
|
||||
// entry cached from the NFC href in the parent directory's listing, and get
|
||||
// would wrongly infer that the file doesn't exist.
|
||||
func normalize(p string) string {
|
||||
return norm.NFC.String(shared.Normalize(p))
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -89,7 +101,7 @@ func (c *StatCache) get(name string, depth int) *cacheEntry {
|
||||
return nil
|
||||
}
|
||||
|
||||
name = shared.Normalize(name)
|
||||
name = normalize(name)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -142,7 +154,7 @@ func (c *StatCache) set(name string, depth int, ce *cacheEntry) {
|
||||
return
|
||||
}
|
||||
|
||||
name = shared.Normalize(name)
|
||||
name = normalize(name)
|
||||
|
||||
var self *cacheEntry
|
||||
var children map[string]*cacheEntry
|
||||
@@ -171,7 +183,7 @@ func (c *StatCache) set(name string, depth int, ce *cacheEntry) {
|
||||
log.Printf("statcache.set child parse error: %s", err)
|
||||
return
|
||||
}
|
||||
name = shared.Normalize(name)
|
||||
name = normalize(name)
|
||||
raw := marshalMultiStatus(response)
|
||||
entry := newCacheEntry(ce.Status, raw)
|
||||
if i == 0 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -211,3 +212,51 @@ func TestParentChildRelationship(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnicodeNormalizationInsensitivity verifies that cache lookups treat
|
||||
// canonically equivalent names as the same key. A parent directory listing
|
||||
// caches children under the names from the server's hrefs (often NFC), while
|
||||
// macOS WebDAV clients request the same files using NFD names. Without
|
||||
// normalization, the depth 0 lookup would miss and get would wrongly infer
|
||||
// notFound from the cached parent.
|
||||
func TestUnicodeNormalizationInsensitivity(t *testing.T) {
|
||||
// Make sure we don't leak goroutines
|
||||
tstest.ResourceCheck(t)
|
||||
|
||||
c := &StatCache{TTL: 24 * time.Hour} // don't expire
|
||||
defer c.stop()
|
||||
|
||||
const (
|
||||
nfcParent = "\u30ae\u30bf\u30fc" // ギター in NFC: ギ is the single code point U+30AE
|
||||
nfdParent = "\u30ad\u3099\u30bf\u30fc" // same name in NFD: キ U+30AD plus combining voiced mark U+3099
|
||||
nfcChild = "\u30c6\u30ba\u30c8.wav" // テズト.wav in NFC: ズ is the single code point U+30BA
|
||||
nfdChild = "\u30c6\u30b9\u3099\u30c8.wav" // same name in NFD: ス U+30B9 plus combining voiced mark U+3099
|
||||
)
|
||||
nfcParentPath := "/" + nfcParent
|
||||
nfdParentPath := "/" + nfdParent
|
||||
nfcChildPath := nfcParentPath + "/" + nfcChild
|
||||
nfdChildPath := nfdParentPath + "/" + nfdChild
|
||||
|
||||
// The hrefs in a real PROPFIND response contain the percent-encoded UTF-8
|
||||
// bytes of the names as they appear on the server's disk, here NFC.
|
||||
unicodeParentResponse := strings.ReplaceAll(parentResponse, "/parent%20with%20spaces/", "/"+url.PathEscape(nfcParent)+"/")
|
||||
unicodeChildResponse := strings.ReplaceAll(childResponse, "/parent%20with%20spaces/child.txt", "/"+url.PathEscape(nfcParent)+"/"+url.PathEscape(nfcChild))
|
||||
unicodeFullParent := []byte(
|
||||
strings.ReplaceAll(
|
||||
fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:">%s%s</D:multistatus>`, unicodeParentResponse, unicodeChildResponse),
|
||||
"\n", ""))
|
||||
unicodeFullChild := []byte(
|
||||
strings.ReplaceAll(
|
||||
fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:">%s</D:multistatus>`, unicodeChildResponse),
|
||||
"\n", ""))
|
||||
|
||||
c.set(nfcParentPath, 1, newCacheEntry(http.StatusMultiStatus, unicodeFullParent))
|
||||
|
||||
want := newCacheEntry(http.StatusMultiStatus, unicodeFullChild)
|
||||
for _, childPath := range []string{nfcChildPath, nfdChildPath} {
|
||||
got := c.get(childPath, 0)
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Errorf("get(%q): unexpected cached value; (-got+want):%v", childPath, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func (s *FileServer) ClearSharesLocked() {
|
||||
// has been called first.
|
||||
func (s *FileServer) AddShareLocked(share, path string) {
|
||||
s.shareHandlers[share] = &webdav.Handler{
|
||||
FileSystem: &birthTimingFS{webdav.Dir(path)},
|
||||
FileSystem: &birthTimingFS{&normalizingFS{webdav.Dir(path)}},
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package driveimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
iofs "io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/tailscale/xnet/webdav"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// normalizingFS extends a webdav.FileSystem to resolve paths in a Unicode
|
||||
// normalization-insensitive way. Different clients encode non-ASCII filenames
|
||||
// differently: for example, macOS WebDAV clients request paths in NFD form,
|
||||
// while files on disk are often named in NFC form. On normalization-sensitive
|
||||
// filesystems like ext4, opening the NFD name of an NFC file fails. When an
|
||||
// exact lookup fails, normalizingFS rescans the parent directory for an entry
|
||||
// whose name is canonically equivalent to the requested one and uses that
|
||||
// instead. See https://github.com/tailscale/tailscale/issues/15020.
|
||||
type normalizingFS struct {
|
||||
webdav.FileSystem
|
||||
}
|
||||
|
||||
func (fs *normalizingFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
return fs.FileSystem.Mkdir(ctx, fs.resolve(ctx, name), perm)
|
||||
}
|
||||
|
||||
func (fs *normalizingFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
return fs.FileSystem.OpenFile(ctx, fs.resolve(ctx, name), flag, perm)
|
||||
}
|
||||
|
||||
func (fs *normalizingFS) RemoveAll(ctx context.Context, name string) error {
|
||||
return fs.FileSystem.RemoveAll(ctx, fs.resolve(ctx, name))
|
||||
}
|
||||
|
||||
func (fs *normalizingFS) Rename(ctx context.Context, oldName, newName string) error {
|
||||
return fs.FileSystem.Rename(ctx, fs.resolve(ctx, oldName), fs.resolve(ctx, newName))
|
||||
}
|
||||
|
||||
func (fs *normalizingFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
return fs.FileSystem.Stat(ctx, fs.resolve(ctx, name))
|
||||
}
|
||||
|
||||
// resolve maps name to the path of an existing file whose name is canonically
|
||||
// equivalent to name under Unicode NFC normalization. Exact matches always
|
||||
// win. Path components with no existing equivalent are left as given, so that
|
||||
// newly created files keep the exact name that the client requested.
|
||||
func (fs *normalizingFS) resolve(ctx context.Context, name string) string {
|
||||
if isASCII(name) {
|
||||
// ASCII strings are canonically equivalent only to themselves.
|
||||
return name
|
||||
}
|
||||
if _, err := fs.FileSystem.Stat(ctx, name); err == nil || !errors.Is(err, iofs.ErrNotExist) {
|
||||
return name
|
||||
}
|
||||
resolved := "/"
|
||||
parts := strings.Split(strings.Trim(path.Clean("/"+name), "/"), "/")
|
||||
for i, part := range parts {
|
||||
candidate := path.Join(resolved, part)
|
||||
if isASCII(part) {
|
||||
resolved = candidate
|
||||
continue
|
||||
}
|
||||
if _, err := fs.FileSystem.Stat(ctx, candidate); err == nil {
|
||||
resolved = candidate
|
||||
continue
|
||||
}
|
||||
match, ok := fs.findEquivalent(ctx, resolved, part)
|
||||
if !ok {
|
||||
// No equivalent entry exists. Keep the remaining components
|
||||
// as given; deeper lookups would fail anyway.
|
||||
return path.Join(append([]string{resolved}, parts[i:]...)...)
|
||||
}
|
||||
resolved = path.Join(resolved, match)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// findEquivalent scans the directory at dir for an entry whose name is
|
||||
// canonically equivalent to name, returning the on-disk name if found.
|
||||
func (fs *normalizingFS) findEquivalent(ctx context.Context, dir, name string) (string, bool) {
|
||||
d, err := fs.FileSystem.OpenFile(ctx, dir, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer d.Close()
|
||||
fis, err := d.Readdir(0)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
want := norm.NFC.String(name)
|
||||
for _, fi := range fis {
|
||||
if norm.NFC.String(fi.Name()) == want {
|
||||
return fi.Name(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= 0x80 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package driveimpl
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/drive"
|
||||
)
|
||||
|
||||
// Unicode filenames from https://github.com/tailscale/tailscale/issues/15020,
|
||||
// in both NFC (precomposed) and NFD (decomposed) forms. The two forms are
|
||||
// canonically equivalent but byte-wise different, so on
|
||||
// normalization-sensitive filesystems like ext4 they name different files.
|
||||
const (
|
||||
nfcName = "\u30c6\u30ba\u30c8 \u00e4.wav" // テズト ä.wav, precomposed
|
||||
nfdName = "\u30c6\u30b9\u3099\u30c8 a\u0308.wav" // same name, decomposed
|
||||
nfcDir = "\u30ae\u30bf\u30fc" // ギター, precomposed
|
||||
nfdDir = "\u30ad\u3099\u30bf\u30fc" // same name, decomposed
|
||||
)
|
||||
|
||||
// TestUnicodeRoundTrip verifies that a file written via WebDAV with a
|
||||
// non-ASCII name can be statted and read back with the identical name.
|
||||
func TestUnicodeRoundTrip(t *testing.T) {
|
||||
s := newSystem(t)
|
||||
s.addRemote(remote1)
|
||||
s.addShare(remote1, share11, drive.PermissionReadWrite)
|
||||
|
||||
s.writeFile("writing unicode file should succeed", remote1, share11, nfcName, "hello", true)
|
||||
s.checkFileStatus(remote1, share11, nfcName)
|
||||
s.checkFileContents(remote1, share11, nfcName)
|
||||
}
|
||||
|
||||
// TestUnicodeNormalizationMismatch verifies that a file whose on-disk name is
|
||||
// in one Unicode normalization form can be read, statted and overwritten via
|
||||
// WebDAV using a canonically equivalent name in the other form. This is what
|
||||
// happens when a macOS WebDAV client (which sends NFD paths) accesses a share
|
||||
// with NFC filenames on disk, and vice versa.
|
||||
func TestUnicodeNormalizationMismatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
onDisk, viaRequest string
|
||||
}{
|
||||
{"nfc-on-disk-nfd-request", nfcName, nfdName},
|
||||
{"nfd-on-disk-nfc-request", nfdName, nfcName},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newSystem(t)
|
||||
s.addRemote(remote1)
|
||||
s.addShare(remote1, share11, drive.PermissionReadWrite)
|
||||
|
||||
// Write the file directly to disk with one form, then read and
|
||||
// stat it via WebDAV using the other form.
|
||||
s.write(remote1, share11, tt.onDisk, "hello world")
|
||||
if got := s.readViaWebDAV(remote1, share11, tt.viaRequest); got != "hello world" {
|
||||
t.Errorf("read: got %q, want %q", got, "hello world")
|
||||
}
|
||||
s.statViaWebDAV(remote1, share11, tt.viaRequest)
|
||||
|
||||
// Overwriting via the other form must update the existing file
|
||||
// rather than creating a second one.
|
||||
s.writeFile("overwrite with equivalent name should succeed", remote1, share11, tt.viaRequest, "updated", true)
|
||||
if got := s.read(remote1, share11, tt.onDisk); got != "updated" {
|
||||
t.Errorf("read from disk after overwrite: got %q, want %q", got, "updated")
|
||||
}
|
||||
shareDir := s.remotes[remote1].shares[share11]
|
||||
entries, err := os.ReadDir(shareDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("got %d files on disk, want 1: %q", len(entries), entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnicodeNormalizationMismatchInDir is like
|
||||
// TestUnicodeNormalizationMismatch, but with the mismatch in a directory
|
||||
// component of the path rather than in the filename.
|
||||
func TestUnicodeNormalizationMismatchInDir(t *testing.T) {
|
||||
s := newSystem(t)
|
||||
s.addRemote(remote1)
|
||||
s.addShare(remote1, share11, drive.PermissionReadWrite)
|
||||
|
||||
shareDir := s.remotes[remote1].shares[share11]
|
||||
if err := os.Mkdir(filepath.Join(shareDir, nfcDir), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.write(remote1, share11, filepath.Join(nfcDir, nfcName), "hello world")
|
||||
|
||||
got := s.readViaWebDAV(remote1, share11, nfdDir+"/"+nfdName)
|
||||
if got != "hello world" {
|
||||
t.Errorf("read: got %q, want %q", got, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnicodeNewFileKeepsRequestedName verifies that creating a new file
|
||||
// keeps the exact bytes of the name that the client sent when no equivalent
|
||||
// file exists yet.
|
||||
func TestUnicodeNewFileKeepsRequestedName(t *testing.T) {
|
||||
s := newSystem(t)
|
||||
s.addRemote(remote1)
|
||||
s.addShare(remote1, share11, drive.PermissionReadWrite)
|
||||
|
||||
s.writeFile("writing new NFD file should succeed", remote1, share11, nfdName, "hello", true)
|
||||
if got := s.read(remote1, share11, nfdName); got != "hello" {
|
||||
t.Errorf("read from disk: got %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"sri": "sha256-TwIaPmGVHO9ZwdaO/jDStgWGQtKa4smS3er1i5aTNTw="
|
||||
},
|
||||
"vendor": {
|
||||
"goModSum": "sha256-7j0GzgLJIFwoNkWuJaxiujq+cCnIJVs/z0cvhNek66U=",
|
||||
"goModSum": "sha256-eB5NyqpDoiGHKrFlobSbkZLdjfA3lSSqpPoIMhFAurc=",
|
||||
"sri": "sha256-5ClQ5fSyEHUlhPtZI0ir8ddQRXSnqOG5VIJ3KjWtXmw="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +511,7 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/text v0.40.0
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package vmtest_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/creachadair/mds/shell"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstest/natlab/vmtest"
|
||||
"tailscale.com/tstest/natlab/vnet"
|
||||
)
|
||||
|
||||
const (
|
||||
driveShareName = "docs"
|
||||
driveShareDir = "/srv/taildrive-share"
|
||||
|
||||
driveASCIIName = "hello.txt"
|
||||
driveASCIIContent = "hello world"
|
||||
|
||||
// driveKanaNFC and driveKanaNFD are the same filename (テズト ä.wav) in
|
||||
// NFC (precomposed) and NFD (decomposed) Unicode normalization forms,
|
||||
// from tailscale/tailscale#15020. They are canonically equivalent but
|
||||
// byte-wise different: in NFC, ズ is the single code point U+30BA and ä
|
||||
// is U+00E4; in NFD they are ス U+30B9 plus the combining voiced sound
|
||||
// mark U+3099, and "a" plus the combining diaeresis U+0308.
|
||||
driveKanaNFC = "\u30c6\u30ba\u30c8 \u00e4.wav"
|
||||
driveKanaNFD = "\u30c6\u30b9\u3099\u30c8 a\u0308.wav"
|
||||
driveKanaContent = "kana content"
|
||||
)
|
||||
|
||||
// TestTaildrive shares a directory from one node and accesses it from another
|
||||
// via the accessing node's local Taildrive WebDAV proxy at
|
||||
// 100.100.100.100:8080, first with a plain ASCII filename and then with
|
||||
// filenames whose Unicode normalization form differs between the request and
|
||||
// the on-disk name.
|
||||
//
|
||||
// The Unicode cases simulate a macOS WebDAV client, which requests paths in
|
||||
// NFD form, accessing a share on a Linux disk whose filenames are NFC bytes
|
||||
// (tailscale/tailscale#15020). Requesting a directory listing immediately
|
||||
// before the NFD PROPFIND also exercises the accessing node's StatCache,
|
||||
// which caches children under their NFC hrefs and previously inferred 404
|
||||
// for the NFD name without contacting the share host.
|
||||
func TestTaildrive(t *testing.T) {
|
||||
env := vmtest.New(t, vmtest.AllOnline())
|
||||
|
||||
hostNet := env.AddNetwork("1.0.0.1", "192.168.1.1/24", vnet.EasyNAT)
|
||||
clientNet := env.AddNetwork("2.0.0.1", "192.168.2.1/24", vnet.EasyNAT)
|
||||
|
||||
// Taildrive is disabled unless control sends these node attributes.
|
||||
driveCaps := tailcfg.NodeCapMap{
|
||||
tailcfg.NodeAttrsTaildriveShare: nil,
|
||||
tailcfg.NodeAttrsTaildriveAccess: nil,
|
||||
}
|
||||
host := env.AddNode("drivehost", hostNet,
|
||||
vmtest.OS(vmtest.Ubuntu2404),
|
||||
driveCaps)
|
||||
client := env.AddNode("driveclient", clientNet,
|
||||
vmtest.OS(vmtest.Ubuntu2404),
|
||||
driveCaps)
|
||||
|
||||
// Declare test-specific steps for the web UI.
|
||||
setupStep := env.AddStep("Create files and share on host")
|
||||
waitStep := env.AddStep("Wait for share to be accessible from client")
|
||||
asciiStep := env.AddStep("ASCII file access (client -> host)")
|
||||
unicodeStep := env.AddStep("NFC/NFD normalization mismatch access")
|
||||
|
||||
env.Start()
|
||||
|
||||
// Access to a peer's shares additionally requires peer capabilities,
|
||||
// normally granted via ACL grants. Grant every peer read/write access to
|
||||
// every share, plus the sharer capability that makes share hosts show up
|
||||
// in directory listings on accessing nodes.
|
||||
env.ControlServer().SetGlobalAppCaps(tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityTaildrive: {`{"shares":["*"],"access":"rw"}`},
|
||||
tailcfg.PeerCapabilityTaildriveSharer: {`true`},
|
||||
})
|
||||
|
||||
setupStep.Begin()
|
||||
setupCmd := fmt.Sprintf("mkdir -p %s && printf %%s %s > %s && printf %%s %s > %s",
|
||||
shell.Quote(driveShareDir),
|
||||
shell.Quote(driveASCIIContent), shell.Quote(driveShareDir+"/"+driveASCIIName),
|
||||
shell.Quote(driveKanaContent), shell.Quote(driveShareDir+"/"+driveKanaNFC))
|
||||
if out, err := env.SSHExec(host, setupCmd); err != nil {
|
||||
setupStep.Fatalf("share dir setup: %v\n%s", err, out)
|
||||
return
|
||||
}
|
||||
if out, err := env.Tailscale(host, "drive", "share", driveShareName, driveShareDir); err != nil {
|
||||
setupStep.Fatalf("tailscale drive share: %v\n%s", err, out)
|
||||
return
|
||||
}
|
||||
setupStep.End(nil)
|
||||
|
||||
// Discover the tailnet domain (the top-level directory of the WebDAV
|
||||
// tree) by listing the root, then wait until the host's share is
|
||||
// reachable from the client. The peer capability grants pushed above
|
||||
// take a map update to arrive at both nodes.
|
||||
waitStep.Begin()
|
||||
var shareBase string // /<domain>/drivehost/docs, percent-encoded
|
||||
if err := tstest.WaitFor(2*time.Minute, func() error {
|
||||
status, body, err := webdavCurl(env, client, "PROPFIND", "/", "-H", "Depth: 1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 207 {
|
||||
return fmt.Errorf("PROPFIND /: status %d: %s", status, body)
|
||||
}
|
||||
domain := ""
|
||||
for _, m := range hrefRegex.FindAllStringSubmatch(body, -1) {
|
||||
if p := strings.Trim(m[1], "/"); p != "" {
|
||||
domain = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if domain == "" {
|
||||
return fmt.Errorf("no domain in root listing: %s", body)
|
||||
}
|
||||
shareBase = "/" + domain + "/" + url.PathEscape(host.Name()) + "/" + url.PathEscape(driveShareName)
|
||||
status, body, err = webdavCurl(env, client, "GET", shareBase+"/"+url.PathEscape(driveASCIIName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != 200 {
|
||||
return fmt.Errorf("GET %s: status %d: %s", driveASCIIName, status, body)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
waitStep.Fatalf("share never became accessible: %v", err)
|
||||
return
|
||||
}
|
||||
waitStep.End(nil)
|
||||
|
||||
asciiStep.Begin()
|
||||
status, body, err := webdavCurl(env, client, "GET", shareBase+"/"+url.PathEscape(driveASCIIName))
|
||||
if err != nil || status != 200 || body != driveASCIIContent {
|
||||
asciiStep.Fatalf("GET %s = %d, %q, %v; want 200, %q", driveASCIIName, status, body, err, driveASCIIContent)
|
||||
return
|
||||
}
|
||||
status, body, err = webdavCurl(env, client, "PROPFIND", shareBase+"/", "-H", "Depth: 1")
|
||||
if err != nil || status != 207 || !strings.Contains(body, driveASCIIName) {
|
||||
asciiStep.Fatalf("PROPFIND share = %d, %v; want 207 mentioning %s:\n%s", status, err, driveASCIIName, body)
|
||||
return
|
||||
}
|
||||
asciiStep.End(nil)
|
||||
|
||||
unicodeStep.Begin()
|
||||
// List the directory first. The response hrefs carry the NFC bytes from
|
||||
// the host's disk, and the listing primes the client's StatCache, whose
|
||||
// entries live for 10 seconds. The depth 0 PROPFIND for the NFD name
|
||||
// that follows is answered from that cache, so it must treat the two
|
||||
// forms as the same name.
|
||||
status, body, err = webdavCurl(env, client, "PROPFIND", shareBase+"/", "-H", "Depth: 1")
|
||||
if err != nil || status != 207 || !strings.Contains(body, url.PathEscape(driveKanaNFC)) {
|
||||
unicodeStep.Fatalf("PROPFIND share = %d, %v; want 207 mentioning NFC name:\n%s", status, err, body)
|
||||
return
|
||||
}
|
||||
nfdPath := shareBase + "/" + url.PathEscape(driveKanaNFD)
|
||||
status, body, err = webdavCurl(env, client, "PROPFIND", nfdPath, "-H", "Depth: 0")
|
||||
if err != nil || status != 207 {
|
||||
unicodeStep.Fatalf("PROPFIND NFD name = %d, %v; want 207:\n%s", status, err, body)
|
||||
return
|
||||
}
|
||||
status, body, err = webdavCurl(env, client, "GET", nfdPath)
|
||||
if err != nil || status != 200 || body != driveKanaContent {
|
||||
unicodeStep.Fatalf("GET NFD name = %d, %q, %v; want 200, %q", status, body, err, driveKanaContent)
|
||||
return
|
||||
}
|
||||
// Overwriting via the NFD name must update the NFC file on the host's
|
||||
// disk rather than creating a second file.
|
||||
const updatedContent = "updated kana content"
|
||||
status, body, err = webdavCurl(env, client, "PUT", nfdPath, "--data-binary", updatedContent)
|
||||
if err != nil || status/100 != 2 {
|
||||
unicodeStep.Fatalf("PUT NFD name = %d, %v; want 2xx:\n%s", status, err, body)
|
||||
return
|
||||
}
|
||||
out, err := env.SSHExec(host, "cat "+shell.Quote(driveShareDir+"/"+driveKanaNFC))
|
||||
if err != nil || out != updatedContent {
|
||||
unicodeStep.Fatalf("host file after PUT = %q, %v; want %q", out, err, updatedContent)
|
||||
return
|
||||
}
|
||||
out, err = env.SSHExec(host, "ls "+shell.Quote(driveShareDir)+" | wc -l")
|
||||
if err != nil || strings.TrimSpace(out) != "2" {
|
||||
unicodeStep.Fatalf("host share has %s files after PUT, %v; want 2", strings.TrimSpace(out), err)
|
||||
return
|
||||
}
|
||||
unicodeStep.End(nil)
|
||||
}
|
||||
|
||||
var hrefRegex = regexp.MustCompile(`<D:href>([^<]*)</D:href>`)
|
||||
|
||||
// webdavCurl performs a WebDAV request from the given node against the
|
||||
// node's local Taildrive WebDAV proxy at 100.100.100.100:8080 by running
|
||||
// curl over the node's debug SSH connection. The path must already be
|
||||
// percent-encoded; extraArgs are passed to curl before the URL. It returns
|
||||
// the HTTP status code and the response body.
|
||||
func webdavCurl(env *vmtest.Env, n *vmtest.Node, method, path string, extraArgs ...string) (status int, body string, err error) {
|
||||
cmd := "curl -s --max-time 15 -X " + shell.Quote(method)
|
||||
for _, arg := range extraArgs {
|
||||
cmd += " " + shell.Quote(arg)
|
||||
}
|
||||
// curl expands the \n itself, putting the status code on its own final
|
||||
// line after the unmodified response body.
|
||||
cmd += ` -w '\n%{http_code}' ` + shell.Quote("http://100.100.100.100:8080"+path)
|
||||
out, err := env.SSHExec(n, cmd)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("curl: %v\n%s", err, out)
|
||||
}
|
||||
i := strings.LastIndexByte(out, '\n')
|
||||
if i == -1 {
|
||||
return 0, "", fmt.Errorf("no status line in curl output: %q", out)
|
||||
}
|
||||
status, err = strconv.Atoi(strings.TrimSpace(out[i+1:]))
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("bad status line in curl output: %q", out)
|
||||
}
|
||||
return status, out[:i], nil
|
||||
}
|
||||
Reference in New Issue
Block a user