diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt
index 072d1bd5f..80aa3899c 100644
--- a/cmd/tailscaled/depaware.txt
+++ b/cmd/tailscaled/depaware.txt
@@ -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+
diff --git a/drive/driveimpl/compositedav/stat_cache.go b/drive/driveimpl/compositedav/stat_cache.go
index 2e53c8241..176a09d84 100644
--- a/drive/driveimpl/compositedav/stat_cache.go
+++ b/drive/driveimpl/compositedav/stat_cache.go
@@ -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 {
diff --git a/drive/driveimpl/compositedav/stat_cache_test.go b/drive/driveimpl/compositedav/stat_cache_test.go
index b982a3aad..9be286389 100644
--- a/drive/driveimpl/compositedav/stat_cache_test.go
+++ b/drive/driveimpl/compositedav/stat_cache_test.go
@@ -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(`%s%s`, unicodeParentResponse, unicodeChildResponse),
+ "\n", ""))
+ unicodeFullChild := []byte(
+ strings.ReplaceAll(
+ fmt.Sprintf(`%s`, 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)
+ }
+ }
+}
diff --git a/drive/driveimpl/fileserver.go b/drive/driveimpl/fileserver.go
index 6aedfef2c..41a6e71a0 100644
--- a/drive/driveimpl/fileserver.go
+++ b/drive/driveimpl/fileserver.go
@@ -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(),
}
}
diff --git a/drive/driveimpl/normfs.go b/drive/driveimpl/normfs.go
new file mode 100644
index 000000000..66f28f610
--- /dev/null
+++ b/drive/driveimpl/normfs.go
@@ -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
+}
diff --git a/drive/driveimpl/unicode_test.go b/drive/driveimpl/unicode_test.go
new file mode 100644
index 000000000..bdce6c3ac
--- /dev/null
+++ b/drive/driveimpl/unicode_test.go
@@ -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")
+ }
+}
diff --git a/flakehashes.json b/flakehashes.json
index f45fc81cb..20736d76b 100644
--- a/flakehashes.json
+++ b/flakehashes.json
@@ -4,7 +4,7 @@
"sri": "sha256-TwIaPmGVHO9ZwdaO/jDStgWGQtKa4smS3er1i5aTNTw="
},
"vendor": {
- "goModSum": "sha256-7j0GzgLJIFwoNkWuJaxiujq+cCnIJVs/z0cvhNek66U=",
+ "goModSum": "sha256-eB5NyqpDoiGHKrFlobSbkZLdjfA3lSSqpPoIMhFAurc=",
"sri": "sha256-5ClQ5fSyEHUlhPtZI0ir8ddQRXSnqOG5VIJ3KjWtXmw="
}
}
diff --git a/go.mod b/go.mod
index 0db851069..8cb2f365d 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/tstest/natlab/vmtest/drive_test.go b/tstest/natlab/vmtest/drive_test.go
new file mode 100644
index 000000000..ba94b314e
--- /dev/null
+++ b/tstest/natlab/vmtest/drive_test.go
@@ -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 // //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(`([^<]*)`)
+
+// 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
+}