diff --git a/internal/fsext/lookup.go b/internal/fsext/lookup.go index 098426571c69521a5978a2c2e0a4178b51b0aae6..e9d43f791bb5259dc437f6749310e741d56271cc 100644 --- a/internal/fsext/lookup.go +++ b/internal/fsext/lookup.go @@ -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) diff --git a/internal/fsext/lookup_test.go b/internal/fsext/lookup_test.go index 97c167f37d8ebcf4d19124367955874e7f816b67..f4e445c179459dc0deed85708014a6690d4a008d 100644 --- a/internal/fsext/lookup_test.go +++ b/internal/fsext/lookup_test.go @@ -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()