chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package pathkey
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CaseInsensitivePaths reports whether path identity comparisons should
|
||||
// ignore letter case. The default is chosen for the host filesystem:
|
||||
// true on Windows and macOS (whose default NTFS / APFS volumes are
|
||||
// case-insensitive), false on Linux.
|
||||
//
|
||||
// The environment override GORTEX_CASE_SENSITIVE_PATHS lets an operator
|
||||
// correct the default for an unusual mount:
|
||||
//
|
||||
// - =1 (or true/yes) forces case-SENSITIVE comparisons — for a
|
||||
// case-sensitive APFS or NTFS volume on macOS / Windows.
|
||||
// - =0 (or false/no) forces case-INSENSITIVE comparisons — for a
|
||||
// case-insensitive mount (e.g. an exFAT / SMB share) on Linux.
|
||||
//
|
||||
// It is a settable package variable rather than a constant so that
|
||||
// higher-layer tests can flip it deterministically on any CI platform.
|
||||
// A test that flips it MUST restore it via t.Cleanup and MUST NOT call
|
||||
// t.Parallel — the variable is process-global.
|
||||
var CaseInsensitivePaths bool
|
||||
|
||||
func init() {
|
||||
CaseInsensitivePaths = defaultCaseInsensitive()
|
||||
}
|
||||
|
||||
// defaultCaseInsensitive resolves the initial value of CaseInsensitivePaths
|
||||
// from the environment override, falling back to the host GOOS default.
|
||||
func defaultCaseInsensitive() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("GORTEX_CASE_SENSITIVE_PATHS"))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return false
|
||||
case "0", "false", "no", "off":
|
||||
return true
|
||||
}
|
||||
return runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
||||
}
|
||||
|
||||
// foldPath reduces p to a canonical identity key: filepath.Clean removes
|
||||
// redundant separators and dot segments, Normalize folds Unicode to NFC,
|
||||
// the Windows-style volume component (drive letter or UNC root) is
|
||||
// upper-cased so "c:" and "C:" agree, and — when ci is true — the
|
||||
// remainder is lower-cased so a case-insensitive filesystem treats
|
||||
// "Documents" and "documents" as one path.
|
||||
//
|
||||
// It is a pure function of (string, bool): the volume is detected with
|
||||
// host-independent semantics (see volumeNameLen) so Windows and macOS
|
||||
// folding can be table-tested on a Linux CI runner. It never touches the
|
||||
// filesystem and never resolves symlinks — identity is lexical.
|
||||
func foldPath(p string, ci bool) string {
|
||||
p = filepath.Clean(p)
|
||||
p = Normalize(p)
|
||||
vl := volumeNameLen(p)
|
||||
vol := strings.ToUpper(p[:vl])
|
||||
rest := p[vl:]
|
||||
if ci {
|
||||
rest = strings.ToLower(rest)
|
||||
}
|
||||
return vol + rest
|
||||
}
|
||||
|
||||
// EqualPaths reports whether two absolute paths identify the same
|
||||
// location once folded. A byte-equal fast path mirrors Equal so the
|
||||
// common case allocates nothing.
|
||||
func EqualPaths(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return foldPath(a, CaseInsensitivePaths) == foldPath(b, CaseInsensitivePaths)
|
||||
}
|
||||
|
||||
// HasPathPrefix reports whether path is root itself or lies beneath it,
|
||||
// comparing on folded identity and respecting path-component boundaries:
|
||||
// "/Users/me/Doc" is not under "/Users/me/Documents". The volume root
|
||||
// ("/" on POSIX, "C:\" on Windows) already ends in a separator after
|
||||
// filepath.Clean, which the trailing-separator branch handles.
|
||||
func HasPathPrefix(path, root string) bool {
|
||||
fp := foldPath(path, CaseInsensitivePaths)
|
||||
fr := foldPath(root, CaseInsensitivePaths)
|
||||
if fp == fr {
|
||||
return true
|
||||
}
|
||||
sep := string(filepath.Separator)
|
||||
if strings.HasSuffix(fr, sep) {
|
||||
return strings.HasPrefix(fp, fr)
|
||||
}
|
||||
return strings.HasPrefix(fp, fr+sep)
|
||||
}
|
||||
|
||||
// NormalizeVolume returns p with its Windows-style volume component
|
||||
// (drive letter or UNC root) upper-cased, e.g. "c:\x" -> "C:\x". It is a
|
||||
// no-op when there is no volume component (every POSIX path) and when the
|
||||
// volume is already upper-cased, so it never allocates on the common path.
|
||||
//
|
||||
// It is applied at store time to NEW tracked-repo entries only, to
|
||||
// converge cosmetically with os.Getwd's convention (which upper-cases the
|
||||
// drive letter on Windows). The volume is never part of a repo basename,
|
||||
// so re-casing it cannot rotate a repo prefix or node IDs. It must never
|
||||
// be applied to an already-stored path.
|
||||
func NormalizeVolume(p string) string {
|
||||
vl := volumeNameLen(p)
|
||||
if vl == 0 {
|
||||
return p
|
||||
}
|
||||
up := strings.ToUpper(p[:vl])
|
||||
if up == p[:vl] {
|
||||
return p
|
||||
}
|
||||
return up + p[vl:]
|
||||
}
|
||||
|
||||
// SamePathIdentity is the store-time dedup predicate. It treats a and b
|
||||
// as the same repo when they fold equal AND, when they differ byte-wise
|
||||
// but both stat cleanly, os.SameFile confirms they resolve to the same
|
||||
// directory — so two genuinely distinct directories on a case-sensitive
|
||||
// volume are never merged. If either path fails to stat, the fold is
|
||||
// trusted: a path that cannot be stat'd cannot be a distinct live repo.
|
||||
//
|
||||
// It stats the filesystem, so it must never be called on a per-request
|
||||
// hot path — only when adding, removing, or deduping a tracked entry.
|
||||
func SamePathIdentity(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if !EqualPaths(a, b) {
|
||||
return false
|
||||
}
|
||||
ai, aerr := os.Stat(a)
|
||||
bi, berr := os.Stat(b)
|
||||
if aerr != nil || berr != nil {
|
||||
return true
|
||||
}
|
||||
return os.SameFile(ai, bi)
|
||||
}
|
||||
|
||||
// volumeNameLen returns the length of the leading Windows-style volume
|
||||
// component of p — a drive letter ("c:") or a UNC root ("\\host\share").
|
||||
// It uses Windows semantics regardless of the host OS so that path
|
||||
// folding is deterministic and testable on every platform; a POSIX path
|
||||
// (which begins with "/") has no volume and yields 0. Both '\\' and '/'
|
||||
// are accepted as separators because a Windows path may arrive with
|
||||
// either.
|
||||
func volumeNameLen(p string) int {
|
||||
if len(p) < 2 {
|
||||
return 0
|
||||
}
|
||||
// Drive letter, e.g. "c:".
|
||||
if p[1] == ':' && isDriveLetter(p[0]) {
|
||||
return 2
|
||||
}
|
||||
// UNC root, e.g. "\\host\share" or "//host/share".
|
||||
if isAnySeparator(p[0]) && isAnySeparator(p[1]) {
|
||||
n := len(p)
|
||||
i := 2
|
||||
host := i
|
||||
for i < n && !isAnySeparator(p[i]) {
|
||||
i++
|
||||
}
|
||||
if i == host {
|
||||
return 0 // "\\" with no host is not a UNC volume
|
||||
}
|
||||
if i < n {
|
||||
i++ // consume the separator before the share name
|
||||
}
|
||||
for i < n && !isAnySeparator(p[i]) {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isDriveLetter(c byte) bool {
|
||||
return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')
|
||||
}
|
||||
|
||||
func isAnySeparator(c byte) bool {
|
||||
return c == '\\' || c == '/'
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package pathkey
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withCaseInsensitive flips the process-global CaseInsensitivePaths for
|
||||
// the duration of a test and restores it on cleanup. Tests that call it
|
||||
// must not run in parallel.
|
||||
func withCaseInsensitive(t *testing.T, v bool) {
|
||||
t.Helper()
|
||||
prev := CaseInsensitivePaths
|
||||
CaseInsensitivePaths = v
|
||||
t.Cleanup(func() { CaseInsensitivePaths = prev })
|
||||
}
|
||||
|
||||
func TestFoldPath_CaseInsensitiveLowersRemainder(t *testing.T) {
|
||||
// With ci=true, two casings of the same path fold to one key.
|
||||
a := foldPath("/Users/me/Documents/Project", true)
|
||||
b := foldPath("/Users/me/documents/project", true)
|
||||
if a != b {
|
||||
t.Fatalf("foldPath ci=true did not converge: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldPath_CaseSensitiveKeepsRemainder(t *testing.T) {
|
||||
// With ci=false, differing casings stay distinct.
|
||||
a := foldPath("/Users/me/Documents", false)
|
||||
b := foldPath("/Users/me/documents", false)
|
||||
if a == b {
|
||||
t.Fatalf("foldPath ci=false collapsed distinct casings: both %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldPath_UppercasesDriveLetter(t *testing.T) {
|
||||
// Windows drive letters fold identically regardless of case, even
|
||||
// when the rest of the path is compared case-sensitively.
|
||||
for _, ci := range []bool{false, true} {
|
||||
a := foldPath(`c:\work\git\myrepo`, ci)
|
||||
b := foldPath(`C:\work\git\myrepo`, ci)
|
||||
if a != b {
|
||||
t.Fatalf("foldPath ci=%v did not fold drive-letter case: %q vs %q", ci, a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldPath_CleansRedundantSegments(t *testing.T) {
|
||||
got := foldPath("/Users/me/./project/../project", false)
|
||||
want := "/Users/me/project"
|
||||
if got != want {
|
||||
t.Fatalf("foldPath did not clean: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldPath_FoldsNFDBeforeCasing(t *testing.T) {
|
||||
// The NFC fold runs before case folding, so an NFD macOS spelling
|
||||
// and an NFC git spelling of the same accented path converge.
|
||||
a := foldPath("/x/"+cafeNFD, true)
|
||||
b := foldPath("/x/"+cafeNFC, true)
|
||||
if a != b {
|
||||
t.Fatalf("foldPath did not reconcile NFD vs NFC: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqualPaths_CaseVariants(t *testing.T) {
|
||||
withCaseInsensitive(t, true)
|
||||
if !EqualPaths("/Users/me/Documents/Project", "/Users/me/documents/project") {
|
||||
t.Fatal("EqualPaths should treat case variants as equal when case-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqualPaths_CaseSensitiveRejects(t *testing.T) {
|
||||
withCaseInsensitive(t, false)
|
||||
if EqualPaths("/Users/me/Documents", "/Users/me/documents") {
|
||||
t.Fatal("EqualPaths must keep case variants distinct when case-sensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqualPaths_ByteEqualFastPath(t *testing.T) {
|
||||
withCaseInsensitive(t, false)
|
||||
if !EqualPaths("/a/b/c", "/a/b/c") {
|
||||
t.Fatal("EqualPaths byte-equal fast path failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqualPaths_DistinctPaths(t *testing.T) {
|
||||
withCaseInsensitive(t, true)
|
||||
if EqualPaths("/Users/me/alpha", "/Users/me/beta") {
|
||||
t.Fatal("EqualPaths reported genuinely distinct paths as equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPathPrefix_RootItself(t *testing.T) {
|
||||
withCaseInsensitive(t, true)
|
||||
if !HasPathPrefix("/Users/me/Repo", "/Users/me/repo") {
|
||||
t.Fatal("a path is a prefix of itself (case-insensitive)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPathPrefix_ChildUnderRoot(t *testing.T) {
|
||||
withCaseInsensitive(t, true)
|
||||
if !HasPathPrefix("/Users/me/Repo/pkg/file.go", "/Users/me/repo") {
|
||||
t.Fatal("child path should be under root regardless of case")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPathPrefix_ComponentBoundary(t *testing.T) {
|
||||
withCaseInsensitive(t, false)
|
||||
// A shared textual prefix that is not a path-component prefix must
|
||||
// not match: "/Users/me/Doc" is NOT under "/Users/me/Documents".
|
||||
if HasPathPrefix("/Users/me/Documents", "/Users/me/Doc") {
|
||||
t.Fatal("prefix-but-not-component must not match")
|
||||
}
|
||||
if HasPathPrefix("/Users/me/Doc", "/Users/me/Documents") {
|
||||
t.Fatal("shorter sibling must not be under longer sibling")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPathPrefix_PosixRoot(t *testing.T) {
|
||||
withCaseInsensitive(t, false)
|
||||
if !HasPathPrefix("/anything/here", "/") {
|
||||
t.Fatal("everything is under the POSIX root")
|
||||
}
|
||||
if !HasPathPrefix("/", "/") {
|
||||
t.Fatal("root is a prefix of itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPathPrefix_TrailingSeparatorRoot(t *testing.T) {
|
||||
withCaseInsensitive(t, true)
|
||||
// A root passed with a trailing separator is cleaned; the child
|
||||
// still matches.
|
||||
if !HasPathPrefix("/Users/me/Repo/x", "/Users/me/repo/") {
|
||||
t.Fatal("trailing-separator root should still match its children")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPathPrefix_WindowsDriveRoot(t *testing.T) {
|
||||
withCaseInsensitive(t, true)
|
||||
// The drive-letter fold — the #277 fix — is exercised by the
|
||||
// root-equals-cwd path (VS Code passes "c:\..." for a config-stored
|
||||
// "C:\..."). This holds with either separator on any host OS.
|
||||
if !HasPathPrefix(`C:\work\git\myrepo`, `c:\work\git\myrepo`) {
|
||||
t.Fatal("Windows drive-letter case must not defeat the coverage check")
|
||||
}
|
||||
// The child-under-root branch relies on filepath.Separator, so use a
|
||||
// forward-slash Windows path (also accepted by Windows) so the
|
||||
// component boundary is verifiable on a POSIX CI runner too.
|
||||
if !HasPathPrefix(`c:/work/git/myrepo/pkg`, `C:/work/git/myrepo`) {
|
||||
t.Fatal("child under a drive-letter root must match despite case")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVolume(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{`c:\x`, `C:\x`},
|
||||
{`C:\x`, `C:\x`},
|
||||
{`\\srv\share`, `\\SRV\SHARE`},
|
||||
{`\\Srv\Share\dir`, `\\SRV\SHARE\dir`},
|
||||
{"/Users/me/project", "/Users/me/project"}, // no volume: no-op
|
||||
{"relative/path", "relative/path"}, // no volume: no-op
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := NormalizeVolume(c.in); got != c.want {
|
||||
t.Errorf("NormalizeVolume(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeVolume_Idempotent proves store-time volume normalization is
|
||||
// stable: applying it in the CLI (runTrack / install) and again in the
|
||||
// daemon's TrackRepoCtx / ReconcileRepoCtx never drifts the stored path.
|
||||
func TestNormalizeVolume_Idempotent(t *testing.T) {
|
||||
for _, in := range []string{`c:\x`, `C:\x`, `\\srv\share\dir`, "/Users/me/p", "relative/p", ""} {
|
||||
once := NormalizeVolume(in)
|
||||
if twice := NormalizeVolume(once); twice != once {
|
||||
t.Errorf("NormalizeVolume not idempotent for %q: once=%q twice=%q", in, once, twice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeNameLen(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"/Users/me", 0},
|
||||
{"relative", 0},
|
||||
{`c:\x`, 2},
|
||||
{`C:`, 2},
|
||||
{`\\srv\share`, len(`\\srv\share`)},
|
||||
{`\\srv\share\dir`, len(`\\srv\share`)},
|
||||
{`\\`, 0},
|
||||
{"", 0},
|
||||
{"a", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := volumeNameLen(c.in); got != c.want {
|
||||
t.Errorf("volumeNameLen(%q) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSamePathIdentity_DistinctDirsNotMerged(t *testing.T) {
|
||||
// Two genuinely distinct directories must never be merged, even
|
||||
// when the fold is forced case-insensitive.
|
||||
withCaseInsensitive(t, true)
|
||||
a := t.TempDir()
|
||||
b := t.TempDir()
|
||||
if SamePathIdentity(a, b) {
|
||||
t.Fatalf("distinct dirs %q and %q must not be identified", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSamePathIdentity_CaseVariantOfSameDir(t *testing.T) {
|
||||
// On this host's default FS, a case variant of a real directory
|
||||
// resolves to the same inode. Only assert the merge when the host
|
||||
// filesystem is actually case-insensitive (so the variant exists).
|
||||
withCaseInsensitive(t, true)
|
||||
dir := t.TempDir()
|
||||
base := filepath.Base(dir)
|
||||
variant := filepath.Join(filepath.Dir(dir), swapCase(base))
|
||||
if _, err := os.Stat(variant); err != nil {
|
||||
t.Skipf("host filesystem is case-sensitive; %q does not resolve", variant)
|
||||
}
|
||||
if !SamePathIdentity(dir, variant) {
|
||||
t.Fatalf("case variant %q of %q should identify as the same dir", variant, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSamePathIdentity_MissingPathTrustsFold(t *testing.T) {
|
||||
// When a path cannot be stat'd, the fold is trusted rather than
|
||||
// treating the pair as distinct.
|
||||
withCaseInsensitive(t, true)
|
||||
a := "/nonexistent/gortex/Repo"
|
||||
b := "/nonexistent/gortex/repo"
|
||||
if !SamePathIdentity(a, b) {
|
||||
t.Fatal("fold-equal missing paths should be identified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSamePathIdentity_ByteEqualFastPath(t *testing.T) {
|
||||
withCaseInsensitive(t, false)
|
||||
if !SamePathIdentity("/x/y", "/x/y") {
|
||||
t.Fatal("byte-equal fast path failed")
|
||||
}
|
||||
}
|
||||
|
||||
// swapCase toggles the case of ASCII letters so a case-variant directory
|
||||
// name can be constructed deterministically.
|
||||
func swapCase(s string) string {
|
||||
b := []byte(s)
|
||||
for i := range b {
|
||||
switch {
|
||||
case b[i] >= 'a' && b[i] <= 'z':
|
||||
b[i] -= 32
|
||||
case b[i] >= 'A' && b[i] <= 'Z':
|
||||
b[i] += 32
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package pathkey canonicalises filesystem paths to a single Unicode
|
||||
// form so that every subsystem that keys, compares, or deduplicates a
|
||||
// path agrees on its byte representation regardless of where the path
|
||||
// came from.
|
||||
//
|
||||
// The problem this solves is platform-dependent Unicode normalisation.
|
||||
// A file named "café.go" or "日本語.go" has no single byte encoding:
|
||||
//
|
||||
// - macOS APFS / HFS+ decompose filenames to NFD ("e" + combining
|
||||
// acute) — so filepath.WalkDir and the FSEvents watcher hand back
|
||||
// decomposed bytes.
|
||||
// - Linux filesystems preserve the bytes as written, which for a
|
||||
// file created from git or a Linux editor is almost always NFC
|
||||
// (precomposed "é").
|
||||
// - git itself never normalises: `git diff` emits paths exactly as
|
||||
// the bytes were committed, i.e. typically NFC, even on macOS.
|
||||
//
|
||||
// So the *same* file can present as two different byte sequences
|
||||
// within one process: the filesystem walk stores one form, the git
|
||||
// watcher reports another. A graph node keyed under one form is then
|
||||
// invisible to a lookup made with the other — lost symbols, missed
|
||||
// watcher events, snapshot-key misses that trigger a full re-index.
|
||||
//
|
||||
// The fix is to fold every path to one canonical form at every keying
|
||||
// boundary. NFC is chosen as the target: it is what git stores, what
|
||||
// the W3C and IETF recommend for interchange, and the form Linux
|
||||
// repositories already carry, so normalising costs nothing on the
|
||||
// common path and only repairs macOS's decomposed form.
|
||||
package pathkey
|
||||
|
||||
import "golang.org/x/text/unicode/norm"
|
||||
|
||||
// Normalize returns p folded to Unicode NFC. An all-ASCII path — the
|
||||
// overwhelmingly common case — is returned unchanged without invoking
|
||||
// the normaliser, so the helper is free on hot indexing paths. A path
|
||||
// already in NFC is likewise returned as-is by norm.NFC.String, which
|
||||
// allocates nothing when no rune needs recomposing.
|
||||
//
|
||||
// Normalize only touches Unicode composition; it does not clean,
|
||||
// resolve, or change the separator style of the path. Callers that
|
||||
// also want filepath.Clean / ToSlash semantics apply those separately
|
||||
// — the two concerns are deliberately kept independent.
|
||||
func Normalize(p string) string {
|
||||
if isASCII(p) {
|
||||
return p
|
||||
}
|
||||
return norm.NFC.String(p)
|
||||
}
|
||||
|
||||
// Equal reports whether two paths denote the same path once both are
|
||||
// folded to NFC. Use this instead of a raw == when either operand may
|
||||
// have come from a different platform or from git rather than from a
|
||||
// filesystem walk.
|
||||
func Equal(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return Normalize(a) == Normalize(b)
|
||||
}
|
||||
|
||||
// isASCII reports whether s contains only bytes < 0x80. Such a string
|
||||
// is identical in every Unicode normal form, so it can skip the
|
||||
// normaliser entirely. Inlined as a tight byte loop rather than
|
||||
// ranging runes to avoid UTF-8 decoding on the hot path.
|
||||
func isASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= 0x80 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package pathkey
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// All non-ASCII fixtures are spelled with explicit \u / \U escapes so
|
||||
// this source file stays pure-ASCII and the byte form of each fixture
|
||||
// is deterministic regardless of the editor encoding used to save it.
|
||||
//
|
||||
// - cafeNFC / cafeNFD: "cafe.go" with an accented e. NFC precomposes
|
||||
// it to U+00E9; NFD spells it as base "e" (U+0065) + U+0301
|
||||
// COMBINING ACUTE ACCENT. Same filename, different bytes — the
|
||||
// exact macOS-NFD vs git/Linux-NFC split this package reconciles.
|
||||
// - cjkName / cyrName: CJK ideographs and Cyrillic letters have no
|
||||
// canonical decomposition, so they are identical in every normal
|
||||
// form; included to prove non-Latin scripts pass through cleanly.
|
||||
// - emoji: an astral-plane code point, to prove rune-correctness.
|
||||
const (
|
||||
cafeNFC = "café.go" // precomposed e-acute
|
||||
cafeNFD = "café.go" // "e" + combining acute accent
|
||||
cjkName = "日本語.go" // CJK: "Japanese language"
|
||||
cyrName = "кириллица.go" // Cyrillic
|
||||
emoji = "\U0001f600.go" // grinning face
|
||||
)
|
||||
|
||||
func TestNormalize_FoldsNFDToNFC(t *testing.T) {
|
||||
// The decomposed input must come back as the precomposed form, so
|
||||
// a path observed in NFD (macOS) keys identically to the same
|
||||
// path observed in NFC (git / Linux).
|
||||
if cafeNFD == cafeNFC {
|
||||
t.Fatal("test fixture invalid: NFD and NFC forms are byte-identical")
|
||||
}
|
||||
got := Normalize(cafeNFD)
|
||||
if got != cafeNFC {
|
||||
t.Fatalf("Normalize(NFD) = %q, want NFC %q", got, cafeNFC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize_NFCIsStable(t *testing.T) {
|
||||
// An already-NFC path is returned unchanged — normalisation is
|
||||
// idempotent, so re-keying an NFC path never drifts.
|
||||
if got := Normalize(cafeNFC); got != cafeNFC {
|
||||
t.Fatalf("Normalize(NFC) = %q, want unchanged %q", got, cafeNFC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize_Idempotent(t *testing.T) {
|
||||
for _, in := range []string{cafeNFD, cafeNFC, cjkName, cyrName, emoji, "plain/ascii/path.go"} {
|
||||
once := Normalize(in)
|
||||
twice := Normalize(once)
|
||||
if once != twice {
|
||||
t.Errorf("Normalize not idempotent for %q: once=%q twice=%q", in, once, twice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize_ASCIIUnchanged(t *testing.T) {
|
||||
// Pure-ASCII paths — the overwhelmingly common case — must pass
|
||||
// through byte-identical (and via the isASCII fast path).
|
||||
for _, in := range []string{
|
||||
"main.go",
|
||||
"internal/indexer/watcher.go",
|
||||
"./pkg/util.go",
|
||||
"a/b/c/d/e.go",
|
||||
"",
|
||||
} {
|
||||
if got := Normalize(in); got != in {
|
||||
t.Errorf("Normalize(%q) = %q, want unchanged", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize_AlwaysReturnsNFC(t *testing.T) {
|
||||
// Whatever Normalize returns must itself be in NFC — that is the
|
||||
// contract every keying call site relies on.
|
||||
for _, in := range []string{cafeNFD, cafeNFC, cjkName, cyrName, emoji} {
|
||||
got := Normalize(in)
|
||||
if !norm.NFC.IsNormalString(got) {
|
||||
t.Errorf("Normalize(%q) = %q is not NFC-normalised", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize_PreservesSeparatorsAndContent(t *testing.T) {
|
||||
// Normalisation must only touch Unicode composition — it must not
|
||||
// drop, reorder, or rewrite path separators or other content.
|
||||
cjkDir := "src/" + cjkName // already NFC
|
||||
if got := Normalize(cjkDir); got != cjkDir {
|
||||
t.Fatalf("Normalize altered an already-NFC CJK path: got %q want %q", got, cjkDir)
|
||||
}
|
||||
// A decomposed component nested in a multi-segment path folds in
|
||||
// place without disturbing the surrounding segments.
|
||||
nested := "a/" + cafeNFD + "/b.go"
|
||||
want := "a/" + cafeNFC + "/b.go"
|
||||
if got := Normalize(nested); got != want {
|
||||
t.Fatalf("Normalize(%q) = %q, want %q", nested, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqual_NFCvsNFD(t *testing.T) {
|
||||
// The whole point: the two byte forms of one filename compare
|
||||
// equal once folded.
|
||||
if !Equal(cafeNFC, cafeNFD) {
|
||||
t.Fatalf("Equal(%q, %q) = false, want true", cafeNFC, cafeNFD)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqual_IdenticalShortCircuit(t *testing.T) {
|
||||
if !Equal(cjkName, cjkName) {
|
||||
t.Fatalf("Equal of identical strings returned false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqual_DistinctPathsNotEqual(t *testing.T) {
|
||||
if Equal(cjkName, cyrName) {
|
||||
t.Fatalf("Equal reported two genuinely different paths as equal")
|
||||
}
|
||||
if Equal("a.go", "b.go") {
|
||||
t.Fatalf("Equal reported a.go == b.go")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsASCII(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"", true},
|
||||
{"plain.go", true},
|
||||
{"internal/indexer/watcher.go", true},
|
||||
{cafeNFC, false},
|
||||
{cafeNFD, false},
|
||||
{cjkName, false},
|
||||
{cyrName, false},
|
||||
{emoji, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isASCII(c.in); got != c.want {
|
||||
t.Errorf("isASCII(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user