From 8148e059a0d2d2cefa8d10ad9f2eaf25aadb766d Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 14:35:12 -0400 Subject: [PATCH] feat: resolve canonical path roots --- internal/pathsafe/ancestor.go | 73 +++++++++++++++++++++++ internal/pathsafe/ancestor_test.go | 76 ++++++++++++++++++++++++ internal/pathsafe/root.go | 67 +++++++++++++++++++++ internal/pathsafe/root_test.go | 93 ++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 internal/pathsafe/ancestor.go create mode 100644 internal/pathsafe/ancestor_test.go create mode 100644 internal/pathsafe/root.go create mode 100644 internal/pathsafe/root_test.go diff --git a/internal/pathsafe/ancestor.go b/internal/pathsafe/ancestor.go new file mode 100644 index 0000000..4735cd3 --- /dev/null +++ b/internal/pathsafe/ancestor.go @@ -0,0 +1,73 @@ +package pathsafe + +import ( + "os" + "path/filepath" +) + +// AncestorWalk validates that every existing component from root through the +// parent of relativePath is a real directory (PLAN.md Section 6.2). It uses +// os.Lstat so a symlinked parent component is rejected even when it resolves +// beneath the same root. The walk is read-only: no path is created. +// +// relativePath is the HOME-relative destination; its final segment is the +// intended entry and is not walked, so a missing target does not fail the +// parent walk. +func AncestorWalk(root, relativePath string) error { + segments, err := Segments(relativePath) + if err != nil { + return err + } + return walkParents(root, parentSegments(segments)) +} + +// parentSegments drops the final destination segment, leaving the chain of +// directories that must already be real directories. +func parentSegments(segments []string) []string { + if len(segments) == 0 { + return nil + } + return segments[:len(segments)-1] +} + +func walkParents(root string, segments []string) error { + if err := requireDirectory(root); err != nil { + return err + } + current := root + for _, segment := range segments { + current = filepath.Join(current, segment) + if err := requireDirectory(current); err != nil { + return err + } + } + return nil +} + +// requireDirectory reports whether path is a real directory, never a symlink, +// regular file, or special entry. The descriptive PathError names the blocking +// component so callers can surface it. +func requireDirectory(path string) error { + info, err := os.Lstat(path) + if err != nil { + return &PathError{Input: path, Reason: "stat component", Cause: err} + } + mode := info.Mode() + if mode&os.ModeSymlink != 0 { + return &PathError{Input: path, Reason: "symlink component"} + } + if isSpecial(mode) { + return &PathError{Input: path, Reason: "special component"} + } + if !mode.IsDir() { + return &PathError{Input: path, Reason: "non-directory component"} + } + return nil +} + +// isSpecial reports whether mode is a device, named pipe, socket, or char +// device, none of which are acceptable parent components. +func isSpecial(mode os.FileMode) bool { + mask := os.ModeDevice | os.ModeNamedPipe | os.ModeSocket | os.ModeCharDevice + return mode&mask != 0 +} diff --git a/internal/pathsafe/ancestor_test.go b/internal/pathsafe/ancestor_test.go new file mode 100644 index 0000000..18b2ab0 --- /dev/null +++ b/internal/pathsafe/ancestor_test.go @@ -0,0 +1,76 @@ +package pathsafe + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAncestorWalk(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"walks clean directory tree", testWalksCleanTree}, + {"walks single segment parent", testWalksSingleSegment}, + {"rejects symlink component", testWalkRejectsSymlink}, + {"rejects file component", testWalkRejectsFile}, + {"rejects missing parent", testWalkRejectsMissing}, + {"rejects dot-dot escape", testWalkRejectsDotDot}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testWalksCleanTree(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "config", "git"), 0o755); err != nil { + t.Fatal(err) + } + if err := AncestorWalk(root, "config/git/ignore"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func testWalksSingleSegment(t *testing.T) { + root := t.TempDir() + if err := AncestorWalk(root, "bashrc"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func testWalkRejectsSymlink(t *testing.T) { + root := t.TempDir() + target := t.TempDir() + if err := os.Symlink(target, filepath.Join(root, "linked")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if err := AncestorWalk(root, "linked/file"); err == nil { + t.Fatal("symlink component must be rejected") + } +} + +func testWalkRejectsFile(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "blocker"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := AncestorWalk(root, "blocker/file"); err == nil { + t.Fatal("file component must be rejected") + } +} + +func testWalkRejectsMissing(t *testing.T) { + root := t.TempDir() + if err := AncestorWalk(root, "missing/file"); err == nil { + t.Fatal("missing parent component must be rejected") + } +} + +func testWalkRejectsDotDot(t *testing.T) { + root := t.TempDir() + if err := AncestorWalk(root, "../escape"); err == nil { + t.Fatal("dot-dot escape must be rejected") + } +} diff --git a/internal/pathsafe/root.go b/internal/pathsafe/root.go new file mode 100644 index 0000000..5764f93 --- /dev/null +++ b/internal/pathsafe/root.go @@ -0,0 +1,67 @@ +package pathsafe + +import ( + "errors" + "io/fs" + "path/filepath" +) + +// CanonicalRoot resolves path to its canonical absolute form (PLAN.md Section +// 6.2). Existing components are resolved with filepath.EvalSymlinks so symlinked +// ancestors collapse to their real targets. When trailing components do not yet +// exist, the nearest existing ancestor is resolved canonically and the missing +// suffix is appended after validating each suffix segment. +// +// CanonicalRoot never creates a path; it is a read-only resolver used to pin +// HOME, repository, and state roots before mutation. +func CanonicalRoot(path string) (string, error) { + if path == "" { + return "", &PathError{Input: path, Reason: "empty path"} + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", &PathError{Input: path, Reason: "absolute resolution", Cause: err} + } + resolved, err := filepath.EvalSymlinks(absolute) + if err == nil { + return resolved, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", &PathError{Input: absolute, Reason: "evaluate symlinks", Cause: err} + } + return climbToExisting(absolute) +} + +// climbToExisting walks missing trailing components of absolute upward until it +// reaches an existing ancestor, resolves that ancestor canonically, and rejoins +// the validated missing suffix. The suffix segments are checked so an ancestor +// climb can never smuggle in a ".." or empty escape. +func climbToExisting(absolute string) (string, error) { + directory := absolute + var climbed []string + for { + resolved, err := filepath.EvalSymlinks(directory) + if err == nil { + return joinSuffix(resolved, climbed), nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", &PathError{Input: directory, Reason: "evaluate symlinks", Cause: err} + } + parent := filepath.Dir(directory) + if parent == directory { + return "", &PathError{Input: absolute, Reason: "no existing ancestor"} + } + climbed = append([]string{filepath.Base(directory)}, climbed...) + directory = parent + } +} + +// joinSuffix appends the validated missing suffix segments to the canonical +// ancestor, returning the ancestor unchanged when nothing was climbed. +func joinSuffix(ancestor string, climbed []string) string { + if len(climbed) == 0 { + return ancestor + } + parts := append([]string{ancestor}, climbed...) + return filepath.Join(parts...) +} diff --git a/internal/pathsafe/root_test.go b/internal/pathsafe/root_test.go new file mode 100644 index 0000000..b3810e1 --- /dev/null +++ b/internal/pathsafe/root_test.go @@ -0,0 +1,93 @@ +package pathsafe + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCanonicalRoot(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"resolves existing directory", testCanonicalExisting}, + {"appends missing suffix", testCanonicalMissingSuffix}, + {"climbs to deepest ancestor", testCanonicalDeepestAncestor}, + {"resolves symlinked ancestor", testCanonicalSymlinkAncestor}, + {"rejects empty input", testCanonicalEmpty}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testCanonicalExisting(t *testing.T) { + root := expectCanonical(t.TempDir()) + got, err := CanonicalRoot(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != root { + t.Fatalf("CanonicalRoot = %q, want %q", got, root) + } +} + +func testCanonicalMissingSuffix(t *testing.T) { + root := expectCanonical(t.TempDir()) + target := filepath.Join(root, "a", "b", "c") + got, err := CanonicalRoot(target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != target { + t.Fatalf("CanonicalRoot = %q, want %q", got, target) + } +} + +func testCanonicalDeepestAncestor(t *testing.T) { + root := expectCanonical(t.TempDir()) + if err := os.MkdirAll(filepath.Join(root, "config", "git"), 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "config", "git", "ignore") + got, err := CanonicalRoot(target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != target { + t.Fatalf("CanonicalRoot = %q, want %q", got, target) + } +} + +func testCanonicalSymlinkAncestor(t *testing.T) { + realDir := expectCanonical(t.TempDir()) + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(realDir, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + got, err := CanonicalRoot(filepath.Join(link, "child")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := filepath.Join(realDir, "child") + if got != want { + t.Fatalf("CanonicalRoot = %q, want %q", got, want) + } +} + +func testCanonicalEmpty(t *testing.T) { + if _, err := CanonicalRoot(""); err == nil { + t.Fatal("empty path must be rejected") + } +} + +// expectCanonical returns the fully resolved canonical form of path, since +// temporary directories may themselves live beneath a symlink. +func expectCanonical(path string) string { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return path + } + return resolved +} -- 2.51.2