Files
tailscale/drive/driveimpl/compositedav/stat_cache.go
Brad Fitzpatrick 420a8e5a1a drive/driveimpl: handle Unicode normalization mismatches in filenames
Files whose names contain characters with Unicode decompositions (such
as umlauts or voiced kana) could not be opened or written over
Taildrive.

Background: keyboards and IMEs emit NFC (precomposed) characters on
every platform, so filenames on Linux (ext4 etc) and Windows (NTFS)
disks are usually NFC bytes. NFD (decomposed) names mostly come from
Apple software: HFS+ forced a variant of NFD on write, and Apple's
frameworks still decompose paths via fileSystemRepresentation. APFS
preserves whatever bytes it is given but does normalization-insensitive
lookups (it stores a hash of the normalized name), so canonically
equivalent names find the same file. ext4 and NTFS lookups, by
contrast, are byte-exact.

On the wire, the macOS WebDAV client sends paths in NFD form (they
pass through the decomposing file system representation, and unlike
Apple's NFS client there is no "nfc" mount option). Windows and Linux
WebDAV clients pass names through as the application provided them,
typically NFC. WebDAV itself mandates no normalization, and PROPFIND
hrefs reflect the server's on-disk bytes.

The two forms are canonically equivalent but byte-wise different, so a
macOS client requesting the NFD form of an NFC-named file on a Linux
or Windows host got a 404 from the exact-byte lookup. Even against an
APFS host, where the filesystem absorbs the mismatch, the client-side
StatCache could still infer a 404: a cached directory listing in one
form caused depth 0 PROPFINDs in the other form to be treated as not
found without ever reaching the server. The inverse direction (NFD
bytes on a Linux disk, copied there from a Mac, requested in NFC form
by a Windows or Linux client) was broken too.

Alternative regimes considered: normalizing names at storage time (as
Nextcloud and Syncthing's autoNormalize do) would rename user files in
shared directories as a side effect of serving them; normalizing
request paths to a fixed form on the wire is unsound because the
on-disk form is unknowable a priori (ext4 can hold either form, or
both). Instead, adopt the APFS model: preserve bytes, but make lookups
normalization-insensitive.

Concretely, wrap the remote file server's webdav.Dir in a
normalizingFS that, when an exact path lookup fails, rescans the
parent directory for an entry whose name is canonically equivalent,
comparing the NFC form of both sides (which also sidesteps Apple's
nonstandard decomposition tables). Exact matches always win, and newly
created files keep the exact bytes the client sent. Also NFC-normalize
StatCache keys so canonically equivalent names share a cache entry.

The change is covered at three levels: unit tests for the StatCache,
an in-process two-node test in drive/driveimpl, and a new TestTaildrive
VM integration test in tstest/natlab/vmtest that shares a directory
between two Ubuntu VMs and exercises the NFC/NFD cases over the real
stack with curl playing the part of a macOS WebDAV client.

Fixes #15020

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9c2f157e604efc629828581e08d5b3191dbb7d4e
2026-07-27 06:35:54 -07:00

298 lines
8.8 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package compositedav
import (
"bytes"
"encoding/xml"
"log"
"net/http"
"net/url"
"sync"
"time"
"github.com/jellydator/ttlcache/v3"
"golang.org/x/text/unicode/norm"
"tailscale.com/drive/driveimpl/shared"
)
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
// 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)
//
// StatCache is built specifically to cache the results of PROPFIND requests,
// which come back as MultiStatus XML responses. Typical clients will issue two
// kinds of PROPFIND:
//
// The first kind of PROPFIND is a directory listing performed to depth 1. At
// this depth, the resulting XML will contain stats for the requested folder as
// well as for all children of that folder.
//
// The second kind of PROPFIND is a file listing performed to depth 0. At this
// depth, the resulting XML will contain stats only for the requested file.
//
// In order to avoid round-trips, when a PROPFIND at depth 0 is attempted, and
// the requested file is not in the cache, StatCache will check to see if the
// parent folder of that file is cached. If so, StatCache infers the correct
// MultiStatus for the file according to the following logic:
//
// 1. If the parent folder is NotFound (404), treat the file itself as NotFound
// 2. If the parent folder's XML doesn't contain the file, treat it as
// NotFound.
// 3. If the parent folder's XML contains the file, build a MultiStatus for the
// file based on the parent's XML.
//
// To avoid inconsistencies from the perspective of the client, any operations
// that modify the filesystem (e.g. PUT, MKDIR, etc.) should call invalidate()
// to invalidate the cache.
type StatCache struct {
TTL time.Duration
// mu guards the below values.
mu sync.Mutex
cachesByDepthAndPath map[int]*ttlcache.Cache[string, *cacheEntry]
}
// getOr checks the cache for the named value at the given depth. If a cached
// value was found, it returns http.StatusMultiStatus along with the cached
// value. Otherwise, it executes the given function and returns the resulting
// status and value. If the function returned http.StatusMultiStatus, getOr
// caches the resulting value at the given name and depth before returning.
func (c *StatCache) getOr(name string, depth int, or func() (int, []byte)) (int, []byte) {
ce := c.get(name, depth)
if ce == nil {
// Not cached, fetch value.
status, raw := or()
ce = newCacheEntry(status, raw)
if status == http.StatusMultiStatus || status == http.StatusNotFound {
// Got a legit status, cache value
c.set(name, depth, ce)
}
}
return ce.Status, ce.Raw
}
// get retrieves the entry for the named file at the given depth. If no entry
// is found, and depth == 0, get will check to see if the parent path of name
// is present in the cache at depth 1. If so, it will infer that the child does
// not exist and return notFound (404).
func (c *StatCache) get(name string, depth int) *cacheEntry {
if c == nil {
return nil
}
name = normalize(name)
c.mu.Lock()
defer c.mu.Unlock()
ce := c.tryGetLocked(name, depth)
if ce != nil {
// Cache hit.
return ce
}
if depth > 0 {
// Cache miss.
return nil
}
// At depth 0, if child's parent is in the cache, and the child isn't
// cached, we can infer that the child is notFound.
p := c.tryGetLocked(shared.Parent(name), 1)
if p != nil {
return notFound
}
// No parent in cache, cache miss.
return nil
}
// tryGetLocked requires that c.mu be held.
func (c *StatCache) tryGetLocked(name string, depth int) *cacheEntry {
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()
}
// set stores the given cacheEntry in the cache at the given name and depth. If
// the depth is 1, set also populates depth 0 entries in the cache for the bare
// name. If status is StatusMultiStatus, set will parse the PROPFIND result and
// store depth 0 entries for all children. If parsing the result fails, nothing
// is cached.
func (c *StatCache) set(name string, depth int, ce *cacheEntry) {
if c == nil {
return
}
name = normalize(name)
var self *cacheEntry
var children map[string]*cacheEntry
if depth == 1 {
switch ce.Status {
case http.StatusNotFound:
// Record notFound as the self entry.
self = ce
case http.StatusMultiStatus:
// Parse the raw MultiStatus and extract specific responses
// corresponding to the self entry (e.g. the directory, but at depth 0)
// and children (e.g. files within the directory) so that subsequent
// requests for these can be satisfied from the cache.
var ms multiStatus
err := xml.Unmarshal(ce.Raw, &ms)
if err != nil {
// unparseable MultiStatus response, don't cache
log.Printf("statcache.set error: %s", err)
return
}
children = make(map[string]*cacheEntry, len(ms.Responses)-1)
for i := 0; i < len(ms.Responses); i++ {
response := ms.Responses[i]
name, err := url.PathUnescape(response.Href)
if err != nil {
log.Printf("statcache.set child parse error: %s", err)
return
}
name = normalize(name)
raw := marshalMultiStatus(response)
entry := newCacheEntry(ce.Status, raw)
if i == 0 {
self = entry
} else {
children[name] = entry
}
}
}
}
c.mu.Lock()
defer c.mu.Unlock()
c.setLocked(name, depth, ce)
if self != nil {
c.setLocked(name, 0, self)
}
for childName, child := range children {
c.setLocked(childName, 0, child)
}
}
// setLocked requires that c.mu be held.
func (c *StatCache) setLocked(name string, depth int, ce *cacheEntry) {
if c.cachesByDepthAndPath == nil {
c.cachesByDepthAndPath = make(map[int]*ttlcache.Cache[string, *cacheEntry])
}
cache := c.cachesByDepthAndPath[depth]
if cache == nil {
cache = ttlcache.New(
ttlcache.WithTTL[string, *cacheEntry](c.TTL),
)
go cache.Start()
c.cachesByDepthAndPath[depth] = cache
}
cache.Set(name, ce, ttlcache.DefaultTTL)
}
// invalidate invalidates the entire cache.
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()
}
}
type cacheEntry struct {
Status int
Raw []byte
}
func newCacheEntry(status int, raw []byte) *cacheEntry {
return &cacheEntry{Status: status, Raw: raw}
}
type propStat struct {
InnerXML []byte `xml:",innerxml"`
}
type response struct {
XMLName xml.Name `xml:"response"`
Href string `xml:"href"`
PropStats []*propStat `xml:"propstat"`
}
type multiStatus struct {
XMLName xml.Name `xml:"multistatus"`
Responses []*response `xml:"response"`
}
// marshalMultiStatus performs custom marshalling of a MultiStatus to preserve
// the original formatting, namespacing, etc. Doing this with Go's XML encoder
// is somewhere between difficult and impossible, which is why we use this more
// manual approach.
func marshalMultiStatus(response *response) []byte {
// TODO(percy): maybe pool these buffers
var buf bytes.Buffer
buf.WriteString(multistatusTemplateStart)
buf.WriteString(response.Href)
buf.WriteString(hrefEnd)
for _, propStat := range response.PropStats {
buf.WriteString(propstatStart)
buf.Write(propStat.InnerXML)
buf.WriteString(propstatEnd)
}
buf.WriteString(multistatusTemplateEnd)
return buf.Bytes()
}
const (
multistatusTemplateStart = `<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:"><D:response><D:href>`
hrefEnd = `</D:href>`
propstatStart = `<D:propstat>`
propstatEnd = `</D:propstat>`
multistatusTemplateEnd = `</D:response></D:multistatus>`
)