on main help sign in
feat(fsext): stop upward lookup at a boundary directory

Add variants of the upward filesystem search that stop at a caller-supplied boundary directory instead of walking all the way up to the filesystem root or to $HOME. Callers that want to avoid adopting matches from outside their project can now express that boundary explicitly. Existing callers and behavior are unchanged. Co-Authored-By: Charm Crush <crush@charm.land>

commit79b2d6197e51d4c02de41e8d0e6f1caf589d0974 authorChristian Rocha <christian@rocha.is> dateTue 12 May 2026, 09:19 (5 months ago) parent7b34700
2 files changed+249
internal/fsext/lookup.go+124 internal/fsext/lookup_test.go+125
internal/fsext/lookup.go+124
@@ -82,6 +82,81 @@ func LookupClosest(dir, target string) (string, bool) {
8282 return found, err == nil && found != ""
8383}
8484
85+// LookupClosestBounded behaves like LookupClosest but constrains the
86+// upward search to stopDir. The walk inspects dir, then each ancestor up
87+// to and including stopDir, then terminates regardless of whether the
88+// target was found. Use this when the caller wants to avoid adopting
89+// matches from outside a project boundary (for example a sibling
90+// worktree or a parent project).
91+//
92+// If stopDir is empty, only dir itself is searched. If stopDir is not an
93+// ancestor of dir, the walk still terminates at the filesystem root.
94+// The $HOME and ownership safeguards from LookupClosest are preserved
95+// as outer bounds.
96+func LookupClosestBounded(dir, stopDir, target string) (string, bool) {
97+ var found string
98+
99+ err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error {
100+ fpath := filepath.Join(cwd, target)
101+
102+ err := probeEnt(fpath, owner)
103+ if errors.Is(err, os.ErrNotExist) {
104+ return nil
105+ }
106+
107+ if err != nil {
108+ return fmt.Errorf("error probing file %s: %w", fpath, err)
109+ }
110+
111+ if cwd == home.Dir() {
112+ return filepath.SkipAll
113+ }
114+
115+ found = fpath
116+ return filepath.SkipAll
117+ })
118+
119+ return found, err == nil && found != ""
120+}
121+
122+// LookupBounded behaves like Lookup but constrains the upward search to
123+// stopDir. The walk inspects dir, then each ancestor up to and including
124+// stopDir, then terminates. If stopDir is empty, only dir itself is
125+// searched.
126+func LookupBounded(dir, stopDir string, targets ...string) ([]string, error) {
127+ if len(targets) == 0 {
128+ return nil, nil
129+ }
130+
131+ var found []string
132+
133+ err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error {
134+ for _, target := range targets {
135+ fpath := filepath.Join(cwd, target)
136+ err := probeEnt(fpath, owner)
137+
138+ // skip to the next file on permission denied
139+ if errors.Is(err, os.ErrNotExist) ||
140+ errors.Is(err, os.ErrPermission) {
141+ continue
142+ }
143+
144+ if err != nil {
145+ return fmt.Errorf("error probing file %s: %w", fpath, err)
146+ }
147+
148+ found = append(found, fpath)
149+ }
150+
151+ return nil
152+ })
153+ if err != nil {
154+ return nil, err
155+ }
156+
157+ return found, nil
158+}
159+
85160// traverseUp walks up from given directory up until filesystem root reached.
86161// It passes absolute path of current directory and staring directory owner ID
87162// 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 {
116191 }
117192}
118193
194+// traverseUpBounded walks up from dir, visiting each ancestor up to and
195+// including stopDir, then terminates. If stopDir is empty, only dir
196+// itself is visited; callers that want an unbounded walk should use
197+// traverseUp instead. If stopDir is set but is not an ancestor of dir
198+// the walk still stops at the filesystem root, so callers cannot
199+// accidentally produce an infinite walk by passing a sibling path.
200+func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) error) error {
201+ cwd, err := filepath.Abs(dir)
202+ if err != nil {
203+ return fmt.Errorf("cannot convert CWD to absolute path: %w", err)
204+ }
205+
206+ stop := cwd
207+ if stopDir != "" {
208+ stop, err = filepath.Abs(stopDir)
209+ if err != nil {
210+ return fmt.Errorf("cannot convert stop dir to absolute path: %w", err)
211+ }
212+ }
213+
214+ owner, err := Owner(dir)
215+ if err != nil {
216+ return fmt.Errorf("cannot get ownership: %w", err)
217+ }
218+
219+ for {
220+ err := walkFn(cwd, owner)
221+ if err == nil || errors.Is(err, filepath.SkipDir) {
222+ if cwd == stop {
223+ return nil
224+ }
225+
226+ parent := filepath.Dir(cwd)
227+ if parent == cwd {
228+ return nil
229+ }
230+
231+ cwd = parent
232+ continue
233+ }
234+
235+ if errors.Is(err, filepath.SkipAll) {
236+ return nil
237+ }
238+
239+ return err
240+ }
241+}
242+
119243// probeEnt checks if entity at given path exists and belongs to given owner
120244func probeEnt(fspath string, owner int) error {
121245 _, err := os.Stat(fspath)
internal/fsext/lookup_test.go+125
@@ -353,6 +353,131 @@ func TestLookup(t *testing.T) {
353353 })
354354}
355355
356+func TestLookupClosestBounded(t *testing.T) {
357+ t.Run("found in starting directory", func(t *testing.T) {
358+ testDir := t.TempDir()
359+
360+ targetFile := filepath.Join(testDir, "target.txt")
361+ require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
362+
363+ foundPath, found := LookupClosestBounded(testDir, testDir, "target.txt")
364+ require.True(t, found)
365+ require.Equal(t, targetFile, foundPath)
366+ })
367+
368+ t.Run("found at boundary directory", func(t *testing.T) {
369+ boundary := t.TempDir()
370+
371+ subDir := filepath.Join(boundary, "subdir")
372+ require.NoError(t, os.Mkdir(subDir, 0o755))
373+
374+ targetFile := filepath.Join(boundary, "target.txt")
375+ require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
376+
377+ foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt")
378+ require.True(t, found)
379+ require.Equal(t, targetFile, foundPath)
380+ })
381+
382+ t.Run("does not climb past boundary", func(t *testing.T) {
383+ parent := t.TempDir()
384+
385+ // Target lives above the boundary.
386+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644))
387+
388+ boundary := filepath.Join(parent, "project")
389+ require.NoError(t, os.Mkdir(boundary, 0o755))
390+
391+ subDir := filepath.Join(boundary, "subdir")
392+ require.NoError(t, os.Mkdir(subDir, 0o755))
393+
394+ foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt")
395+ require.False(t, found)
396+ require.Empty(t, foundPath)
397+ })
398+
399+ t.Run("empty boundary searches only starting directory", func(t *testing.T) {
400+ parent := t.TempDir()
401+
402+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644))
403+
404+ subDir := filepath.Join(parent, "subdir")
405+ require.NoError(t, os.Mkdir(subDir, 0o755))
406+
407+ foundPath, found := LookupClosestBounded(subDir, "", "target.txt")
408+ require.False(t, found)
409+ require.Empty(t, foundPath)
410+ })
411+
412+ t.Run("empty boundary still finds in starting directory", func(t *testing.T) {
413+ testDir := t.TempDir()
414+
415+ targetFile := filepath.Join(testDir, "target.txt")
416+ require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
417+
418+ foundPath, found := LookupClosestBounded(testDir, "", "target.txt")
419+ require.True(t, found)
420+ require.Equal(t, targetFile, foundPath)
421+ })
422+}
423+
424+func TestLookupBounded(t *testing.T) {
425+ t.Run("returns matches at and below boundary", func(t *testing.T) {
426+ boundary := t.TempDir()
427+
428+ subDir := filepath.Join(boundary, "subdir")
429+ require.NoError(t, os.Mkdir(subDir, 0o755))
430+
431+ atBoundary := filepath.Join(boundary, "target.txt")
432+ atSub := filepath.Join(subDir, "target.txt")
433+ require.NoError(t, os.WriteFile(atBoundary, []byte("a"), 0o644))
434+ require.NoError(t, os.WriteFile(atSub, []byte("b"), 0o644))
435+
436+ found, err := LookupBounded(subDir, boundary, "target.txt")
437+ require.NoError(t, err)
438+ require.Len(t, found, 2)
439+ require.Contains(t, found, atBoundary)
440+ require.Contains(t, found, atSub)
441+ })
442+
443+ t.Run("ignores matches above boundary", func(t *testing.T) {
444+ parent := t.TempDir()
445+
446+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644))
447+
448+ boundary := filepath.Join(parent, "project")
449+ require.NoError(t, os.Mkdir(boundary, 0o755))
450+
451+ subDir := filepath.Join(boundary, "subdir")
452+ require.NoError(t, os.Mkdir(subDir, 0o755))
453+
454+ // Target lives only above the boundary.
455+ found, err := LookupBounded(subDir, boundary, "target.txt")
456+ require.NoError(t, err)
457+ require.Empty(t, found)
458+ })
459+
460+ t.Run("empty boundary searches only starting directory", func(t *testing.T) {
461+ parent := t.TempDir()
462+
463+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644))
464+
465+ subDir := filepath.Join(parent, "subdir")
466+ require.NoError(t, os.Mkdir(subDir, 0o755))
467+
468+ found, err := LookupBounded(subDir, "", "target.txt")
469+ require.NoError(t, err)
470+ require.Empty(t, found)
471+ })
472+
473+ t.Run("no targets returns nil", func(t *testing.T) {
474+ dir := t.TempDir()
475+ found, err := LookupBounded(dir, dir)
476+ require.NoError(t, err)
477+ require.Empty(t, found)
478+ })
479+}
480+
356481func TestProbeEnt(t *testing.T) {
357482 t.Run("existing file with correct owner", func(t *testing.T) {
358483 tempDir := t.TempDir()