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}