diff --git a/internal/pathsafe/identity.go b/internal/pathsafe/identity.go new file mode 100644 index 0000000..2b78357 --- /dev/null +++ b/internal/pathsafe/identity.go @@ -0,0 +1,62 @@ +package pathsafe + +import "os" + +// Identity is an immutable, read-only token capturing the filesystem facts of a +// single path at capture time: its mode type, size, and the stat identity used +// by os.SameFile. It is only constructed by FilesystemIdentity and is safe to +// compare across goroutines. +type Identity struct { + path string + info os.FileInfo +} + +// Path returns the path supplied when the identity was captured. +func (i Identity) Path() string { + return i.path +} + +// Mode returns the captured mode bits, or zero for a zero-value Identity. +func (i Identity) Mode() os.FileMode { + if i.info == nil { + return 0 + } + return i.info.Mode() +} + +// Size returns the captured size in bytes, or zero for a zero-value Identity. +func (i Identity) Size() int64 { + if i.info == nil { + return 0 + } + return i.info.Size() +} + +// IsDir reports whether the captured entry is a directory. +func (i Identity) IsDir() bool { + if i.info == nil { + return false + } + return i.info.IsDir() +} + +// FilesystemIdentity captures an immutable, read-only snapshot of path using +// os.Lstat, never following a final symlink. A missing or unreachable path +// returns a descriptive PathError wrapping the underlying cause. +func FilesystemIdentity(path string) (Identity, error) { + info, err := os.Lstat(path) + if err != nil { + return Identity{}, &PathError{Input: path, Reason: "stat identity", Cause: err} + } + return Identity{path: path, info: info}, nil +} + +// SameIdentity reports whether two identities name the same filesystem object +// using os.SameFile semantics. Two hard links to one inode compare equal; a +// zero-value Identity (for example after a capture failure) never matches. +func SameIdentity(a, b Identity) bool { + if a.info == nil || b.info == nil { + return false + } + return os.SameFile(a.info, b.info) +} diff --git a/internal/pathsafe/identity_test.go b/internal/pathsafe/identity_test.go new file mode 100644 index 0000000..db1908e --- /dev/null +++ b/internal/pathsafe/identity_test.go @@ -0,0 +1,116 @@ +package pathsafe + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFilesystemIdentity(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"same path matches itself", testSamePathIdentity}, + {"hard links share identity", testHardLinksShareIdentity}, + {"distinct files differ", testDistinctFilesDiffer}, + {"missing path errors", testMissingIdentityErrors}, + {"identity reports mode and size", testIdentityAccessors}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testSamePathIdentity(t *testing.T) { + path := writeFile(t, "target") + first, err := FilesystemIdentity(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, err := FilesystemIdentity(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !SameIdentity(first, second) { + t.Fatal("the same path must share filesystem identity") + } +} + +func testHardLinksShareIdentity(t *testing.T) { + original := writeFile(t, "original") + linked := filepath.Join(filepath.Dir(original), "linked") + if err := os.Link(original, linked); err != nil { + t.Skipf("hard links unsupported: %v", err) + } + first, err := FilesystemIdentity(original) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, err := FilesystemIdentity(linked) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !SameIdentity(first, second) { + t.Fatal("hard links must share filesystem identity") + } +} + +func testDistinctFilesDiffer(t *testing.T) { + a := writeFile(t, "first") + b := writeFile(t, "second") + first, err := FilesystemIdentity(a) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, err := FilesystemIdentity(b) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if SameIdentity(first, second) { + t.Fatal("distinct files must not share identity") + } +} + +func testMissingIdentityErrors(t *testing.T) { + if _, err := FilesystemIdentity(filepath.Join(t.TempDir(), "absent")); err == nil { + t.Fatal("missing path must return an error") + } +} + +func testIdentityAccessors(t *testing.T) { + path := writeFile(t, "target") + identity, err := FilesystemIdentity(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if identity.Path() != path { + t.Fatalf("Path = %q, want %q", identity.Path(), path) + } + if identity.IsDir() { + t.Fatal("captured regular file must not report IsDir") + } + if identity.Mode()&0o600 != 0o600 { + t.Fatalf("Mode = %v, expected 0600 bits", identity.Mode()) + } + if identity.Size() != 1 { + t.Fatalf("Size = %d, want 1", identity.Size()) + } + var zero Identity + if zero.IsDir() || zero.Size() != 0 || zero.Mode() != 0 { + t.Fatal("zero-value Identity must report empty defaults") + } + if SameIdentity(zero, identity) { + t.Fatal("zero-value Identity must never match") + } +} + +func writeFile(t *testing.T, name string) string { + t.Helper() + directory := t.TempDir() + path := filepath.Join(directory, name) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/pathsafe/protected.go b/internal/pathsafe/protected.go new file mode 100644 index 0000000..cf35d51 --- /dev/null +++ b/internal/pathsafe/protected.go @@ -0,0 +1,78 @@ +package pathsafe + +import ( + "path/filepath" + "slices" + "strings" +) + +// ProtectedTree reports whether target equals or descends into the protected +// tree, checking the relation in both directions (PLAN.md Sections 6.1 and +// 6.3). Each relation is evaluated twice: once with canonical native absolute +// path segments compared by string equality, and once with the portable +// NFC-plus-EqualFold segment equivalence. The trees collide when either +// comparison overlaps, so case-only or NFC/NFD aliases are rejected even on a +// case-sensitive host. No lowercase key is ever derived. +func ProtectedTree(target, protected string) bool { + targetSegments := segmentsOf(target) + protectedSegments := segmentsOf(protected) + return nativeOverlap(targetSegments, protectedSegments) || + portableOverlap(targetSegments, protectedSegments) +} + +// Equal reports whether two canonical absolute paths name the same native +// location by comparing their cleaned segment lists with string equality. It is +// the native building block of the overlap check. +func Equal(a, b string) bool { + return slices.Equal(segmentsOf(a), segmentsOf(b)) +} + +// Contains reports whether parent is a strict native ancestor of child by +// comparing their cleaned segment lists. +func Contains(parent, child string) bool { + return nativeAncestor(segmentsOf(parent), segmentsOf(child)) +} + +// nativeOverlap reports a collision when the two segment lists are equal or one +// is a strict native prefix of the other. +func nativeOverlap(target, protected []string) bool { + return slices.Equal(target, protected) || + nativeAncestor(protected, target) || + nativeAncestor(target, protected) +} + +// portableOverlap reports a collision when the two segment lists are portably +// equivalent or one is a strict portable prefix of the other. +func portableOverlap(target, protected []string) bool { + return PathsEquivalent(target, protected) || + IsParentEquivalent(protected, target) || + IsParentEquivalent(target, protected) +} + +// nativeAncestor reports whether parent is a strict native prefix of child: +// shorter in length and every leading segment equal by string comparison. +func nativeAncestor(parent, child []string) bool { + if len(parent) >= len(child) { + return false + } + for index, segment := range parent { + if child[index] != segment { + return false + } + } + return true +} + +// segmentsOf returns the cleaned native path segments of path, dropping the +// leading separator so two absolute paths compare by their real components. +func segmentsOf(path string) []string { + cleaned := filepath.Clean(path) + raw := strings.Split(cleaned, string(filepath.Separator)) + segments := make([]string, 0, len(raw)) + for _, part := range raw { + if part != "" { + segments = append(segments, part) + } + } + return segments +} diff --git a/internal/pathsafe/protected_test.go b/internal/pathsafe/protected_test.go new file mode 100644 index 0000000..2d3cc85 --- /dev/null +++ b/internal/pathsafe/protected_test.go @@ -0,0 +1,78 @@ +package pathsafe + +import "testing" + +func TestProtectedTree(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"descendant is protected", testDescendantProtected}, + {"ancestor direction is protected", testAncestorProtected}, + {"equal paths are protected", testEqualProtected}, + {"disjoint paths are not protected", testDisjointNotProtected}, + {"case-fold equivalent is protected", testCaseFoldProtected}, + {"nfc equivalent is protected", testNFCProtected}, + {"contains is strict ancestor", testContainsStrict}, + {"equal is native exact", testEqualNative}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testDescendantProtected(t *testing.T) { + if !ProtectedTree("/home/user/repo/.config", "/home/user/repo") { + t.Fatal("target descending into protected must be protected") + } +} + +func testAncestorProtected(t *testing.T) { + if !ProtectedTree("/home/user/repo", "/home/user/repo/.config") { + t.Fatal("reverse relation must also be protected") + } +} + +func testEqualProtected(t *testing.T) { + if !ProtectedTree("/home/user/repo", "/home/user/repo") { + t.Fatal("equal paths must be protected") + } +} + +func testDisjointNotProtected(t *testing.T) { + if ProtectedTree("/home/user/other", "/home/user/repo") { + t.Fatal("disjoint paths must not be protected") + } +} + +func testCaseFoldProtected(t *testing.T) { + if !ProtectedTree("/home/user/REPO/file", "/home/user/repo") { + t.Fatal("case-only segment difference must be protected via EqualFold") + } +} + +func testNFCProtected(t *testing.T) { + composed := "/home/user/" + "\u00e9" + "/file" + decomposed := "/home/user/" + "e\u0301" + if !ProtectedTree(composed, decomposed) { + t.Fatal("NFC/NFD-equivalent segments must be protected") + } +} + +func testContainsStrict(t *testing.T) { + if !Contains("/home/user/repo", "/home/user/repo/sub") { + t.Fatal("parent must contain strict child") + } + if Contains("/home/user/repo", "/home/user/repo") { + t.Fatal("contains must be strict, not equal") + } +} + +func testEqualNative(t *testing.T) { + if !Equal("/home/user/repo", "/home/user/repo") { + t.Fatal("equal paths must compare native equal") + } + if Equal("/home/user/repo", "/home/user/REPO") { + t.Fatal("native equal must be case-sensitive") + } +}