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.
200func 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
243// probeEnt checks if entity at given path exists and belongs to given owner
244func probeEnt(fspath string, owner int) error {
245 _, err := os.Stat(fspath)
246 if err != nil {
247 return fmt.Errorf("cannot stat %s: %w", fspath, err)
248 }
249
250 // special case for ownership check bypass
251 if owner == -1 {
252 return nil
253 }
254
255 fowner, err := Owner(fspath)
256 if err != nil {
257 return fmt.Errorf("cannot get ownership for %s: %w", fspath, err)
258 }
259
260 if fowner != owner {
261 return os.ErrPermission
262 }
263
264 return nil
265}