Files
kjol/tools/tsgo/internal/fswatch/fsevents_darwin_nfd_test.go
2026-07-09 16:50:43 -04:00

177 lines
5.3 KiB
Go

//go:build darwin && (amd64 || arm64)
package fswatch
import (
"os"
"path/filepath"
"testing"
)
// These tests document a real cross-normalization failure mode on macOS:
// the directory or file exists on disk under one Unicode normalization
// form (e.g. NFD, because it was created by an older Mac tool, copied
// from an HFS+ volume, or synced from another machine) but the caller
// subscribes using the canonical/precomposed (NFC) form, or vice versa.
// APFS is normalization-insensitive for *lookups* (open/stat both forms
// resolve to the same inode), but FSEvents reports paths with whatever
// bytes are stored on disk, so direct string comparisons inside the
// library and in WatchFile silently misfire.
// "é"
const (
nfcE = "\u00e9" // U+00E9
nfdE = "e\u0301" // U+0065 U+0301
)
// TestNormalizeNFC exercises the CoreFoundation-backed normalizer directly
// (without going through FSEvents) so a regression in the FFI plumbing is
// caught even if the end-to-end FSEvents tests are skipped.
func TestNormalizeNFC(t *testing.T) {
t.Parallel()
const (
// Latin combining marks (BMP, one combining mark per base).
nfcCafe = "caf" + nfcE
nfdCafe = "caf" + nfdE
// Hangul: composition is algorithmic, not table-driven.
// "한" (U+D55C) decomposes to ᄒ ᅡ ᆫ (U+1112 U+1161 U+11AB).
nfcHan = "\uD55C"
nfdHan = "\u1112\u1161\u11AB"
// Multi-codepoint compose: "ệ" (U+1EC7) ⇄ "e\u0323\u0302" (also valid as
// e\u0302\u0323 due to canonical ordering; CFStringNormalize handles both).
nfcEHook = "\u1EC7"
nfdEHook = "e\u0323\u0302"
)
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"ascii", "/var/folders/abc/hello.txt", "/var/folders/abc/hello.txt"},
{"ascii-only-high-bit-edge", "/\x7f/path", "/\x7f/path"},
{"already-NFC-latin", nfcCafe, nfcCafe},
{"NFD-to-NFC-latin", nfdCafe, nfcCafe},
{"already-NFC-hangul", nfcHan, nfcHan},
{"NFD-to-NFC-hangul", nfdHan, nfcHan},
{"already-NFC-multi-mark", nfcEHook, nfcEHook},
{"NFD-to-NFC-multi-mark", nfdEHook, nfcEHook},
{"mixed-ascii-and-NFD", "/tmp/" + nfdCafe + "/file.txt", "/tmp/" + nfcCafe + "/file.txt"},
{"non-bmp-passthrough", "/tmp/\U0001F600.txt", "/tmp/\U0001F600.txt"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := normalizeNFC(tt.in)
if got != tt.want {
t.Errorf("normalizeNFC(%q):\n want: %q (% x)\n got: %q (% x)",
tt.in, tt.want, tt.want, got, got)
}
})
}
}
// TestNormalizeNFCASCIIFastPath verifies the ASCII fast path returns the
// input unchanged with no Unicode round-trip.
func TestNormalizeNFCASCIIFastPath(t *testing.T) {
t.Parallel()
in := "/var/folders/abc/def/hello.txt"
out := normalizeNFC(in)
if out != in {
t.Fatalf("ascii input mutated: want %q, got %q", in, out)
}
}
func TestIsASCII(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want bool
}{
{"", true},
{"hello", true},
{"/tmp/file.txt", true},
{"\x7f", true}, // DEL is the last ASCII byte
{"\x80", false}, // first non-ASCII byte
{"caf\u00e9", false}, // NFC é
{"cafe\u0301", false}, // NFD é (combining mark is also non-ASCII)
{"a" + string([]byte{0xC2, 0xA9}), false}, // © (U+00A9)
}
for _, tt := range tests {
if got := isASCII(tt.in); got != tt.want {
t.Errorf("isASCII(%q) = %v, want %v", tt.in, got, tt.want)
}
}
}
// TestFSEventsNFDOnDiskNFCSubscribe creates the directory using its NFD
// byte sequence, subscribes via the NFC form (APFS resolves both to the
// same inode), and asserts that emitted event paths match what the
// caller subscribed with. Today the path comes back as NFD, so callers
// can't compare it against their own NFC paths.
func TestFSEventsNFDOnDiskNFCSubscribe(t *testing.T) {
t.Parallel()
parent := newTmpDir(t)
nfdDir := filepath.Join(parent, "caf"+nfdE+"-dir")
nfcDir := filepath.Join(parent, "caf"+nfcE+"-dir")
if err := os.Mkdir(nfdDir, 0o755); err != nil {
t.Fatal(err)
}
r, _ := subscribeFor(t, nfcDir, FSEvents())
nfcChild := filepath.Join(nfcDir, "hello.txt")
if err := os.WriteFile(nfcChild, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
got := r.next(r.deadline())
if len(got) == 0 {
t.Fatal("no events received")
}
for _, e := range got {
if e.Path != nfcChild {
t.Errorf("event path not in subscriber's (NFC) form:\n want: %q (% x)\n got: %q (% x)",
nfcChild, nfcChild, e.Path, e.Path)
}
}
}
// TestFSEventsNFDOnDiskNFCWatchFile shows WatchFile is silently broken
// across normalization forms: the file is created on disk as NFD, the
// caller watches the NFC path, and the e.Path == path filter in
// WatchFile drops every event.
func TestFSEventsNFDOnDiskNFCWatchFile(t *testing.T) {
t.Parallel()
dir := newTmpDir(t)
nfdTarget := filepath.Join(dir, "r"+nfdE+"sum"+nfdE+".txt")
nfcTarget := filepath.Join(dir, "r"+nfcE+"sum"+nfcE+".txt")
r, _ := subscribeFileFor(t, nfcTarget, FSEvents())
if err := os.WriteFile(nfdTarget, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
got := r.next(r.deadline())
if len(got) == 0 {
t.Fatal("WatchFile delivered no events: FSEvents reported the path in its on-disk (NFD) form and the e.Path == path filter in WatchFile dropped it")
}
for _, e := range got {
if e.Path != nfcTarget {
t.Errorf("event path mismatch:\n want: %q (% x)\n got: %q (% x)",
nfcTarget, nfcTarget, e.Path, e.Path)
}
}
}