Skip to content

Commit f1f5c28

Browse files
authored
Fix case sensitivity fswatch and users (#64210)
1 parent fafb768 commit f1f5c28

16 files changed

Lines changed: 1436 additions & 99 deletions

‎tsc/internal/execute/watchmanager/watchmanager.go‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -293,14 +293,16 @@ func (wm *WatchManager) createDirWatches(updates []dirWatchUpdate) error {
293293
// already present in the set, or when it is contained within a recursive watch
294294
// directory already in the set.
295295
type DirWatchSet struct {
296-
opts tspath.ComparePathsOptions
297-
dirs map[string]bool
296+
opts tspath.ComparePathsOptions
297+
dirs map[string]bool
298+
names map[string]string
298299
}
299300

300301
func NewDirWatchSet(opts tspath.ComparePathsOptions) *DirWatchSet {
301302
return &DirWatchSet{
302-
opts: opts,
303-
dirs: make(map[string]bool),
303+
opts: opts,
304+
dirs: make(map[string]bool),
305+
names: make(map[string]string),
304306
}
305307
}
306308

@@ -309,7 +311,11 @@ func (s *DirWatchSet) canonical(dir string) string {
309311
}
310312

311313
func (s *DirWatchSet) Set(dir string, recursive bool) {
314+
original := dir
312315
dir = s.canonical(dir)
316+
if _, exists := s.names[dir]; !exists {
317+
s.names[dir] = original
318+
}
313319
s.dirs[dir] = s.dirs[dir] || recursive
314320
}
315321

@@ -329,7 +335,11 @@ func (s *DirWatchSet) Covered(dir string) bool {
329335
}
330336

331337
func (s *DirWatchSet) Dirs() map[string]bool {
332-
return s.dirs
338+
dirs := make(map[string]bool, len(s.dirs))
339+
for key, recursive := range s.dirs {
340+
dirs[s.names[key]] = recursive
341+
}
342+
return dirs
333343
}
334344

335345
func (wm *WatchManager) IsPathUnderWatch(path string, opts tspath.ComparePathsOptions) bool {

‎tsc/internal/execute/watchmanager/watchmanager_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ func TestDirWatchSetCanonicalDedup(t *testing.T) {
8383

8484
dirs := insensitive.Dirs()
8585
assert.Equal(t, len(dirs), 1, "differently-cased dirs must collapse to one entry")
86-
_, canonical := dirs["/repo/node_modules/pkgname"]
87-
assert.Assert(t, canonical, "Dirs must be keyed by the canonicalized path")
86+
_, original := dirs["/repo/Node_Modules/PkgName"]
87+
assert.Assert(t, original, "Dirs must retain the original spelling used for registration")
8888

8989
sensitive := NewDirWatchSet(caseSensitiveOpts)
9090
sensitive.Set("/repo/Node_Modules/PkgName", false)

‎tsc/internal/fswatch/CHANGES.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,34 @@ logical root, physical root, event-ID cutoff, and termination state, so
149149
late-added watches don't receive older queued events and symlinked watch roots
150150
continue reporting caller-visible paths.
151151

152+
### macOS path comparison
153+
154+
FSEvents and kqueue use the watched volume's case sensitivity, queried with
155+
`pathconf`, rather than assuming event paths have the same spelling as the
156+
subscription. On case-insensitive volumes, CoreFoundation case folding and NFC
157+
normalization recognize Unicode aliases, including expansions such as sharp s /
158+
`SS` and ligatures / letter sequences. This is not width- or
159+
diacritic-insensitive comparison.
160+
161+
Folded forms are comparison keys, never displayed or opened paths. Watch roots
162+
and subscribed filenames are normalized to NFC. Directory events retain the
163+
caller's root casing, with NFC suffixes for FSEvents and on-disk child spellings
164+
for kqueue; `WatchFile` events use the subscribed NFC filename. Rebasing uses
165+
original path boundaries rather than folded byte lengths. FSEvents routing,
166+
shared callback filtering, overflow matching, and logical-root deletion use the
167+
same comparison rules.
168+
169+
An allocation-free ASCII comparison fast path avoids native folding. Watch-root
170+
comparison forms are prepared at subscription time, while event paths are
171+
folded lazily and reused across routing comparisons and within callback
172+
filtering passes. `WatchFile` reuses its parent subscription's comparer rather
173+
than querying filesystem case sensitivity twice.
174+
175+
The native fold has been compared with aliases and distinct names on
176+
case-insensitive APFS, but is not a guarantee of identical lookup tables on every
177+
filesystem or macOS version. Case-sensitive comparison and watcher backends on
178+
other platforms remain unchanged.
179+
152180
## New backends
153181

154182
**fanotify** (Linux, kernel ≥ 5.13) is the default on Linux when available. It

‎tsc/internal/fswatch/README.md‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,20 @@ if errors.Is(err, fswatch.ErrWatchTerminated) {
9090
- Event order within a batch is **not guaranteed**.
9191
- The callback runs on a library goroutine, not the caller's. Each watch's
9292
callback is serialized (never concurrent with itself).
93-
- Paths in events are absolute. **Resolve symlinks before subscribing**;
94-
backends report canonical paths:
95-
96-
```go
97-
realDir, err := filepath.EvalSymlinks(dir)
98-
```
93+
- Paths in events are absolute. Subscribing through a directory symlink follows
94+
its target while preserving the caller-visible root in delivered paths.
95+
96+
On macOS, watch roots and subscribed filenames are normalized to NFC. On volumes
97+
reporting case-insensitive lookup, FSEvents and kqueue match paths using
98+
CoreFoundation's case-insensitive fold, including expansions such as sharp s /
99+
`SS` and ligatures / letter sequences. This is not width- or
100+
diacritic-insensitive comparison. Folded forms are only comparison keys:
101+
directory events retain the caller's root casing, with an NFC suffix for
102+
FSEvents and the on-disk child spelling for kqueue; file events use the
103+
subscribed NFC filename. Symlink-root subscriptions likewise retain the
104+
caller-visible root.
105+
106+
The fold has been compared with actual aliases and distinct names on
107+
case-insensitive APFS. It is not a guarantee of identical Unicode lookup
108+
tables on every filesystem or macOS version. Case-sensitive volumes and
109+
watcher backends on other platforms retain exact comparison.

‎tsc/internal/fswatch/canonicalize_darwin.go‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,35 @@
22

33
package fswatch
44

5-
// canonicalizePath returns the path in the form the library uses for
6-
// internal bookkeeping and event delivery. On macOS, paths from FSEvents
7-
// arrive using whatever Unicode normalization form is stored on disk;
8-
// usually NFC, but sometimes NFD (e.g. files created on legacy HFS+
9-
// volumes or copied from systems that use NFD). APFS resolves either form
10-
// to the same inode, but raw string comparisons against caller-supplied
11-
// paths (typically NFC) silently break. Normalizing every path the
12-
// library ingests to NFC keeps watch keys, dirWatch lookups, WatchFile
13-
// filters, and event paths all in one consistent form.
5+
import (
6+
"os"
7+
8+
"golang.org/x/sys/unix"
9+
)
10+
11+
// canonicalizePath normalizes watch keys, subscribed filenames, and incoming
12+
// FSEvents paths to NFC. kqueue retains on-disk child spellings for its fd
13+
// bookkeeping and directory events; on case-insensitive volumes, the native
14+
// path comparer handles normalization differences when filtering WatchFile.
1415
func canonicalizePath(p string) string { return normalizeNFC(p) }
16+
17+
func (w *watcher) pathComparer(dir string) (pathComparer, error) {
18+
if w.name != "fsevents" && w.name != "kqueue" {
19+
return pathComparer{}, nil
20+
}
21+
c, err := PathComparerForPath(dir)
22+
return c.comparer, err
23+
}
24+
25+
// PathComparerForPath queries an existing path's volume. Errors are returned to
26+
// the caller; a failed query must not silently enable or disable native folding.
27+
func PathComparerForPath(path string) (PathComparer, error) {
28+
// _PC_CASE_SENSITIVE from sys/unistd.h. Query the watched volume rather
29+
// than assuming every volume mounted on macOS is case-insensitive.
30+
const pcCaseSensitive = 11
31+
sensitive, err := unix.Pathconf(path, pcCaseSensitive)
32+
if err != nil {
33+
return PathComparer{}, &os.PathError{Op: "pathconf", Path: path, Err: err}
34+
}
35+
return PathComparer{comparer: pathComparer{ignoreCase: sensitive == 0}}, nil
36+
}

‎tsc/internal/fswatch/canonicalize_other.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,23 @@
22

33
package fswatch
44

5+
const nativePathFolding = false
6+
7+
func foldNativePath(string) string {
8+
panic("fswatch: native path folding is only available on Darwin")
9+
}
10+
511
// canonicalizePath is a no-op on platforms whose watchers report paths
612
// using the same bytes the caller provided. See canonicalize_darwin.go
713
// for the rationale on macOS.
814
func canonicalizePath(p string) string { return p }
15+
16+
func (w *watcher) pathComparer(dir string) (pathComparer, error) {
17+
return pathComparer{}, nil
18+
}
19+
20+
// PathComparerForPath returns exact comparison on platforms without native
21+
// Darwin watch aliases. It does not inspect the host filesystem.
22+
func PathComparerForPath(path string) (PathComparer, error) {
23+
return PathComparer{}, nil
24+
}

‎tsc/internal/fswatch/fsevents_darwin.go‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
507507
if path == "" {
508508
continue
509509
}
510+
comparison := comparisonPath{path: path}
510511

511512
isRemoved := flag&flagItemRemoved != 0
512513
isRenamed := flag&flagItemRenamed != 0
@@ -527,7 +528,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
527528
if watch.state.terminated.Load() {
528529
continue
529530
}
530-
if fseventsOverflowMatches(watch.w, path) {
531+
if fseventsOverflowMatchesPrepared(watch.w, &comparison) {
531532
watch.w.events.setError(overflow)
532533
touched[watch.w] = struct{}{}
533534
}
@@ -551,7 +552,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
551552
continue
552553
}
553554
w := watch.w
554-
displayPath, ok := fseventsDisplayPath(w, rawPath)
555+
displayPath, ok := fseventsDisplayPathPrepared(w, &comparison)
555556
if !ok {
556557
continue
557558
}
@@ -623,18 +624,42 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
623624
}
624625

625626
func fseventsDisplayPath(w *dirWatch, rawPath string) (string, bool) {
626-
if isInDirectoryOrSelf(w.physicalDir, rawPath) {
627-
return w.displayPath(rawPath), true
627+
path := comparisonPath{path: rawPath}
628+
return fseventsDisplayPathPrepared(w, &path)
629+
}
630+
631+
func fseventsDisplayPathPrepared(w *dirWatch, rawPath *comparisonPath) (string, bool) {
632+
physical := comparisonPath{path: w.physicalDir, folded: w.physicalDirFold, ready: w.physicalDirFold != ""}
633+
if path, ok := w.comparer.rebasePrepared(rawPath, physical, w.dir); ok {
634+
return path, true
628635
}
629-
if w.physicalDir != w.dir && isInDirectoryOrSelf(w.dir, rawPath) {
630-
return rawPath, true
636+
if w.physicalDir != w.dir {
637+
logical := comparisonPath{path: w.dir, folded: w.dirFold, ready: w.dirFold != ""}
638+
return w.comparer.rebasePrepared(rawPath, logical, w.dir)
631639
}
632640
return "", false
633641
}
634642

635643
func fseventsOverflowMatches(w *dirWatch, rawPath string) bool {
636-
if isInDirectoryOrSelf(w.physicalDir, rawPath) || isInDirectoryOrSelf(rawPath, w.physicalDir) {
644+
path := comparisonPath{path: rawPath}
645+
return fseventsOverflowMatchesPrepared(w, &path)
646+
}
647+
648+
func fseventsOverflowMatchesPrepared(w *dirWatch, rawPath *comparisonPath) bool {
649+
physical := comparisonPath{path: w.physicalDir, folded: w.physicalDirFold, ready: w.physicalDirFold != ""}
650+
if _, ok := w.comparer.suffixPrepared(physical, rawPath); ok {
637651
return true
638652
}
639-
return w.physicalDir != w.dir && (isInDirectoryOrSelf(w.dir, rawPath) || isInDirectoryOrSelf(rawPath, w.dir))
653+
if _, ok := w.comparer.suffixPrepared(*rawPath, &physical); ok {
654+
return true
655+
}
656+
if w.physicalDir != w.dir {
657+
logical := comparisonPath{path: w.dir, folded: w.dirFold, ready: w.dirFold != ""}
658+
if _, ok := w.comparer.suffixPrepared(logical, rawPath); ok {
659+
return true
660+
}
661+
_, ok := w.comparer.suffixPrepared(*rawPath, &logical)
662+
return ok
663+
}
664+
return false
640665
}

‎tsc/internal/fswatch/fsevents_darwin_ffi.go‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"os"
99
"runtime"
1010
"slices"
11+
"strings"
1112
"syscall"
13+
"unicode/utf8"
1214
"unsafe"
1315

1416
"golang.org/x/sys/unix"
@@ -150,13 +152,16 @@ func cfArrayGetValueAtIndex(array uintptr, index int) uintptr {
150152
// FSEvents reports paths using whatever bytes are stored on disk. APFS is
151153
// normalization-insensitive for lookups (a file created as NFD opens fine
152154
// under the NFC form, and vice versa) but it stores and reports the original
153-
// bytes. The library normalizes every path that crosses the darwin boundary
154-
// to Unicode NFC so that:
155+
// bytes. The library normalizes watch paths and incoming FSEvents paths to
156+
// Unicode NFC so that:
155157
// - WatchDirectory("/.../caf\u00e9") and WatchDirectory("/.../cafe\u0301")
156158
// coalesce to a single dir watch;
157-
// - WatchFile filters by exact-string compare in NFC always match;
159+
// - WatchFile filters and directory routing compare the same normalized paths;
158160
// - subscribers can compare event paths against their own NFC strings.
159161
//
162+
// kqueue retains on-disk child spellings; its WatchFile comparisons also use
163+
// the native fold below on volumes reporting case-insensitive lookup.
164+
//
160165
// All-ASCII inputs are bit-identical in NFC and NFD, so the hot path skips
161166
// the FFI entirely. The rare non-ASCII case round-trips through CoreFoundation
162167
// (UTF-8 → CFString → CFMutableString → CFStringNormalize → UTF-8) with no Go
@@ -183,6 +188,46 @@ func cfStringNormalize(mutStr uintptr, form uintptr) {
183188
_, _, _ = syscall_syscall6(fse_CFStringNormalize_trampoline_addr, mutStr, form, 0, 0, 0, 0)
184189
}
185190

191+
//go:cgo_import_dynamic fse_CFStringFold CFStringFold "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
192+
193+
var fse_CFStringFold_trampoline_addr uintptr
194+
195+
const nativePathFolding = true
196+
197+
// foldNativePath is a comparison form, never a displayed or opened path.
198+
// Case folding expands sharp s and ligatures without making diacritics,
199+
// dotless i, circled letters, or character widths interchangeable.
200+
func foldNativePath(s string) string {
201+
if isASCII(s) {
202+
return strings.ToLower(s)
203+
}
204+
if !utf8.ValidString(s) || strings.IndexByte(s, 0) >= 0 {
205+
return ""
206+
}
207+
cstr := append([]byte(s), 0)
208+
src := cfStringCreate(0, unsafe.Pointer(&cstr[0]), cfStringEncodingUTF8)
209+
if src == 0 {
210+
panic("fswatch: cannot create CFString for path folding")
211+
}
212+
defer cfRelease(src)
213+
mut := cfStringCreateMutableCopy(0, 0, src)
214+
if mut == 0 {
215+
panic("fswatch: cannot copy CFString for path folding")
216+
}
217+
defer cfRelease(mut)
218+
// Normalize before folding as well: a decomposed capital I with dot
219+
// must have the same comparison form as precomposed dotted capital I.
220+
cfStringNormalize(mut, cfStringNormalizationFormC)
221+
const cfCompareCaseInsensitive = 1
222+
_, _, _ = syscall_syscall6(fse_CFStringFold_trampoline_addr, mut, cfCompareCaseInsensitive, 0, 0, 0, 0)
223+
cfStringNormalize(mut, cfStringNormalizationFormC)
224+
folded := cfStringToGo(mut)
225+
if folded == "" {
226+
panic("fswatch: cannot extract folded CFString")
227+
}
228+
return folded
229+
}
230+
186231
//go:cgo_import_dynamic fse_CFStringGetLength CFStringGetLength "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
187232

188233
var fse_CFStringGetLength_trampoline_addr uintptr

‎tsc/internal/fswatch/fsevents_darwin_ffi.s‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ TEXT fse_CFStringNormalize_trampoline<>(SB), NOSPLIT, $0-0
6262
GLOBL ·fse_CFStringNormalize_trampoline_addr(SB), RODATA, $8
6363
DATA ·fse_CFStringNormalize_trampoline_addr(SB)/8, $fse_CFStringNormalize_trampoline<>(SB)
6464

65+
TEXT fse_CFStringFold_trampoline<>(SB), NOSPLIT, $0-0
66+
JMP fse_CFStringFold(SB)
67+
68+
GLOBL ·fse_CFStringFold_trampoline_addr(SB), RODATA, $8
69+
DATA ·fse_CFStringFold_trampoline_addr(SB)/8, $fse_CFStringFold_trampoline<>(SB)
70+
6571
TEXT fse_CFStringGetLength_trampoline<>(SB), NOSPLIT, $0-0
6672
JMP fse_CFStringGetLength(SB)
6773

0 commit comments

Comments
 (0)