@@ -82,6 +82,81 @@ func LookupClosest(dir, target string) (string, bool) {
return found, err == nil && found != ""
}
+// LookupClosestBounded behaves like LookupClosest but constrains the
+// upward search to stopDir. The walk inspects dir, then each ancestor up
+// to and including stopDir, then terminates regardless of whether the
+// target was found. Use this when the caller wants to avoid adopting
+// matches from outside a project boundary (for example a sibling
+// worktree or a parent project).
+//
+// If stopDir is empty, only dir itself is searched. If stopDir is not an
+// ancestor of dir, the walk still terminates at the filesystem root.
+// The $HOME and ownership safeguards from LookupClosest are preserved
+// as outer bounds.
+func LookupClosestBounded(dir, stopDir, target string) (string, bool) {
+ var found string
+
+ err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error {
+ fpath := filepath.Join(cwd, target)
+
+ err := probeEnt(fpath, owner)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+
+ if err != nil {
+ return fmt.Errorf("error probing file %s: %w", fpath, err)
+ }
+
+ if cwd == home.Dir() {
+ return filepath.SkipAll
+ }
+
+ found = fpath
+ return filepath.SkipAll
+ })
+
+ return found, err == nil && found != ""
+}
+
+// LookupBounded behaves like Lookup but constrains the upward search to
+// stopDir. The walk inspects dir, then each ancestor up to and including
+// stopDir, then terminates. If stopDir is empty, only dir itself is
+// searched.
+func LookupBounded(dir, stopDir string, targets ...string) ([]string, error) {
+ if len(targets) == 0 {
+ return nil, nil
+ }
+
+ var found []string
+
+ err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error {
+ for _, target := range targets {
+ fpath := filepath.Join(cwd, target)
+ err := probeEnt(fpath, owner)
+
+ // skip to the next file on permission denied
+ if errors.Is(err, os.ErrNotExist) ||
+ errors.Is(err, os.ErrPermission) {
+ continue
+ }
+
+ if err != nil {
+ return fmt.Errorf("error probing file %s: %w", fpath, err)
+ }
+
+ found = append(found, fpath)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return found, nil
+}
+
// traverseUp walks up from given directory up until filesystem root reached.
// It passes absolute path of current directory and staring directory owner ID
// to callback function. It is up to user to check ownership.
@@ -116,6 +191,55 @@ func traverseUp(dir string, walkFn func(dir string, owner int) error) error {
}
}
+// traverseUpBounded walks up from dir, visiting each ancestor up to and
+// including stopDir, then terminates. If stopDir is empty, only dir
+// itself is visited; callers that want an unbounded walk should use
+// traverseUp instead. If stopDir is set but is not an ancestor of dir
+// the walk still stops at the filesystem root, so callers cannot
+// accidentally produce an infinite walk by passing a sibling path.
+func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) error) error {
+ cwd, err := filepath.Abs(dir)
+ if err != nil {
+ return fmt.Errorf("cannot convert CWD to absolute path: %w", err)
+ }
+
+ stop := cwd
+ if stopDir != "" {
+ stop, err = filepath.Abs(stopDir)
+ if err != nil {
+ return fmt.Errorf("cannot convert stop dir to absolute path: %w", err)
+ }
+ }
+
+ owner, err := Owner(dir)
+ if err != nil {
+ return fmt.Errorf("cannot get ownership: %w", err)
+ }
+
+ for {
+ err := walkFn(cwd, owner)
+ if err == nil || errors.Is(err, filepath.SkipDir) {
+ if cwd == stop {
+ return nil
+ }
+
+ parent := filepath.Dir(cwd)
+ if parent == cwd {
+ return nil
+ }
+
+ cwd = parent
+ continue
+ }
+
+ if errors.Is(err, filepath.SkipAll) {
+ return nil
+ }
+
+ return err
+ }
+}
+
// probeEnt checks if entity at given path exists and belongs to given owner
func probeEnt(fspath string, owner int) error {
_, err := os.Stat(fspath)
@@ -353,6 +353,131 @@ func TestLookup(t *testing.T) {
})
}
+func TestLookupClosestBounded(t *testing.T) {
+ t.Run("found in starting directory", func(t *testing.T) {
+ testDir := t.TempDir()
+
+ targetFile := filepath.Join(testDir, "target.txt")
+ require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
+
+ foundPath, found := LookupClosestBounded(testDir, testDir, "target.txt")
+ require.True(t, found)
+ require.Equal(t, targetFile, foundPath)
+ })
+
+ t.Run("found at boundary directory", func(t *testing.T) {
+ boundary := t.TempDir()
+
+ subDir := filepath.Join(boundary, "subdir")
+ require.NoError(t, os.Mkdir(subDir, 0o755))
+
+ targetFile := filepath.Join(boundary, "target.txt")
+ require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
+
+ foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt")
+ require.True(t, found)
+ require.Equal(t, targetFile, foundPath)
+ })
+
+ t.Run("does not climb past boundary", func(t *testing.T) {
+ parent := t.TempDir()
+
+ // Target lives above the boundary.
+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644))
+
+ boundary := filepath.Join(parent, "project")
+ require.NoError(t, os.Mkdir(boundary, 0o755))
+
+ subDir := filepath.Join(boundary, "subdir")
+ require.NoError(t, os.Mkdir(subDir, 0o755))
+
+ foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt")
+ require.False(t, found)
+ require.Empty(t, foundPath)
+ })
+
+ t.Run("empty boundary searches only starting directory", func(t *testing.T) {
+ parent := t.TempDir()
+
+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644))
+
+ subDir := filepath.Join(parent, "subdir")
+ require.NoError(t, os.Mkdir(subDir, 0o755))
+
+ foundPath, found := LookupClosestBounded(subDir, "", "target.txt")
+ require.False(t, found)
+ require.Empty(t, foundPath)
+ })
+
+ t.Run("empty boundary still finds in starting directory", func(t *testing.T) {
+ testDir := t.TempDir()
+
+ targetFile := filepath.Join(testDir, "target.txt")
+ require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
+
+ foundPath, found := LookupClosestBounded(testDir, "", "target.txt")
+ require.True(t, found)
+ require.Equal(t, targetFile, foundPath)
+ })
+}
+
+func TestLookupBounded(t *testing.T) {
+ t.Run("returns matches at and below boundary", func(t *testing.T) {
+ boundary := t.TempDir()
+
+ subDir := filepath.Join(boundary, "subdir")
+ require.NoError(t, os.Mkdir(subDir, 0o755))
+
+ atBoundary := filepath.Join(boundary, "target.txt")
+ atSub := filepath.Join(subDir, "target.txt")
+ require.NoError(t, os.WriteFile(atBoundary, []byte("a"), 0o644))
+ require.NoError(t, os.WriteFile(atSub, []byte("b"), 0o644))
+
+ found, err := LookupBounded(subDir, boundary, "target.txt")
+ require.NoError(t, err)
+ require.Len(t, found, 2)
+ require.Contains(t, found, atBoundary)
+ require.Contains(t, found, atSub)
+ })
+
+ t.Run("ignores matches above boundary", func(t *testing.T) {
+ parent := t.TempDir()
+
+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644))
+
+ boundary := filepath.Join(parent, "project")
+ require.NoError(t, os.Mkdir(boundary, 0o755))
+
+ subDir := filepath.Join(boundary, "subdir")
+ require.NoError(t, os.Mkdir(subDir, 0o755))
+
+ // Target lives only above the boundary.
+ found, err := LookupBounded(subDir, boundary, "target.txt")
+ require.NoError(t, err)
+ require.Empty(t, found)
+ })
+
+ t.Run("empty boundary searches only starting directory", func(t *testing.T) {
+ parent := t.TempDir()
+
+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644))
+
+ subDir := filepath.Join(parent, "subdir")
+ require.NoError(t, os.Mkdir(subDir, 0o755))
+
+ found, err := LookupBounded(subDir, "", "target.txt")
+ require.NoError(t, err)
+ require.Empty(t, found)
+ })
+
+ t.Run("no targets returns nil", func(t *testing.T) {
+ dir := t.TempDir()
+ found, err := LookupBounded(dir, dir)
+ require.NoError(t, err)
+ require.Empty(t, found)
+ })
+}
+
func TestProbeEnt(t *testing.T) {
t.Run("existing file with correct owner", func(t *testing.T) {
tempDir := t.TempDir()