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 +}