lookup.go

  1package fsext
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8
  9	"github.com/charmbracelet/crush/internal/home"
 10)
 11
 12// Lookup searches for a target files or directories starting from dir
 13// and walking up the directory tree until filesystem root is reached.
 14// It also checks the ownership of files to ensure that the search does
 15// not cross ownership boundaries. It skips ownership mismatches without
 16// errors.
 17// Returns full paths to fount targets.
 18// The search includes the starting directory itself.
 19func Lookup(dir string, targets ...string) ([]string, error) {
 20	if len(targets) == 0 {
 21		return nil, nil
 22	}
 23
 24	var found []string
 25
 26	err := traverseUp(dir, func(cwd string, owner int) error {
 27		for _, target := range targets {
 28			fpath := filepath.Join(cwd, target)
 29			err := probeEnt(fpath, owner)
 30
 31			// skip to the next file on permission denied
 32			if errors.Is(err, os.ErrNotExist) ||
 33				errors.Is(err, os.ErrPermission) {
 34				continue
 35			}
 36
 37			if err != nil {
 38				return fmt.Errorf("error probing file %s: %w", fpath, err)
 39			}
 40
 41			found = append(found, fpath)
 42		}
 43
 44		return nil
 45	})
 46	if err != nil {
 47		return nil, err
 48	}
 49
 50	return found, nil
 51}
 52
 53// LookupClosest searches for a target file or directory starting from dir
 54// and walking up the directory tree until found or root or home is reached.
 55// It also checks the ownership of files to ensure that the search does
 56// not cross ownership boundaries.
 57// Returns the full path to the target if found, empty string and false otherwise.
 58// The search includes the starting directory itself.
 59func LookupClosest(dir, target string) (string, bool) {
 60	var found string
 61
 62	err := traverseUp(dir, func(cwd string, owner int) error {
 63		fpath := filepath.Join(cwd, target)
 64
 65		err := probeEnt(fpath, owner)
 66		if errors.Is(err, os.ErrNotExist) {
 67			return nil
 68		}
 69
 70		if err != nil {
 71			return fmt.Errorf("error probing file %s: %w", fpath, err)
 72		}
 73
 74		if cwd == home.Dir() {
 75			return filepath.SkipAll
 76		}
 77
 78		found = fpath
 79		return filepath.SkipAll
 80	})
 81
 82	return found, err == nil && found != ""
 83}
 84
 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.
 96func 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.
126func 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
160// traverseUp walks up from given directory up until filesystem root reached.
161// It passes absolute path of current directory and staring directory owner ID
162// to callback function. It is up to user to check ownership.
163func traverseUp(dir string, walkFn func(dir string, owner int) error) error {
164	cwd, err := filepath.Abs(dir)
165	if err != nil {
166		return fmt.Errorf("cannot convert CWD to absolute path: %w", err)
167	}
168
169	owner, err := Owner(dir)
170	if err != nil {
171		return fmt.Errorf("cannot get ownership: %w", err)
172	}
173
174	for {
175		err := walkFn(cwd, owner)
176		if err == nil || errors.Is(err, filepath.SkipDir) {
177			parent := filepath.Dir(cwd)
178			if parent == cwd {
179				return nil
180			}
181
182			cwd = parent
183			continue
184		}
185
186		if errors.Is(err, filepath.SkipAll) {
187			return nil
188		}
189
190		return err
191	}
192}
193
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//
201// Boundary comparison is performed against symlink-resolved paths so
202// that callers passing logically equivalent paths (a symlinked /var vs
203// the underlying /private/var, for example) still terminate at the
204// expected directory.
205func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) error) error {
206	cwd, err := filepath.Abs(dir)
207	if err != nil {
208		return fmt.Errorf("cannot convert CWD to absolute path: %w", err)
209	}
210
211	stop := cwd
212	if stopDir != "" {
213		stop, err = filepath.Abs(stopDir)
214		if err != nil {
215			return fmt.Errorf("cannot convert stop dir to absolute path: %w", err)
216		}
217	}
218	canonStop := canonicalize(stop)
219
220	owner, err := Owner(dir)
221	if err != nil {
222		return fmt.Errorf("cannot get ownership: %w", err)
223	}
224
225	for {
226		err := walkFn(cwd, owner)
227		if err == nil || errors.Is(err, filepath.SkipDir) {
228			if canonicalize(cwd) == canonStop {
229				return nil
230			}
231
232			parent := filepath.Dir(cwd)
233			if parent == cwd {
234				return nil
235			}
236
237			cwd = parent
238			continue
239		}
240
241		if errors.Is(err, filepath.SkipAll) {
242			return nil
243		}
244
245		return err
246	}
247}
248
249// canonicalize resolves any symbolic links in path. If resolution fails
250// (typically because path does not exist yet) the original path is
251// returned cleaned, so callers can still perform stable equality checks.
252func canonicalize(path string) string {
253	if resolved, err := filepath.EvalSymlinks(path); err == nil {
254		return resolved
255	}
256	return filepath.Clean(path)
257}
258
259// probeEnt checks if entity at given path exists and belongs to given owner
260func probeEnt(fspath string, owner int) error {
261	_, err := os.Stat(fspath)
262	if err != nil {
263		return fmt.Errorf("cannot stat %s: %w", fspath, err)
264	}
265
266	// special case for ownership check bypass
267	if owner == -1 {
268		return nil
269	}
270
271	fowner, err := Owner(fspath)
272	if err != nil {
273		return fmt.Errorf("cannot get ownership for %s: %w", fspath, err)
274	}
275
276	if fowner != owner {
277		return os.ErrPermission
278	}
279
280	return nil
281}