on main help sign in
feat(config): search`crush.json` recursively up from the working directory (#898)
701e550bbrodriges
internal/config/load.go+25−8
@@ -41,13 +41,8 @@ func LoadReader(fd io.Reader) (*Config, error) {
41
41
42// Load loads the configuration from the default paths.
42// Load loads the configuration from the default paths.
43func Load(workingDir, dataDir string, debug bool) (*Config, error) {
43func Load(workingDir, dataDir string, debug bool) (*Config, error) {
44− // uses default config paths
45− configPaths := []string{
44+ configPaths := lookupConfigs(workingDir)
46− globalConfig(),
45+
47− GlobalConfigData(),
48− filepath.Join(workingDir, fmt.Sprintf("%s.json", appName)),
49− filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)),
50− }
51 cfg, err := loadFromConfigPaths(configPaths)
46 cfg, err := loadFromConfigPaths(configPaths)
52 if err != nil {
47 if err != nil {
53 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
48 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
@@ -316,7 +311,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
316 if dataDir != "" {
311 if dataDir != "" {
317 c.Options.DataDirectory = dataDir
312 c.Options.DataDirectory = dataDir
318 } else if c.Options.DataDirectory == "" {
313 } else if c.Options.DataDirectory == "" {
319− if path, ok := fsext.SearchParent(workingDir, defaultDataDirectory); ok {
314+ if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok {
320 c.Options.DataDirectory = path
315 c.Options.DataDirectory = path
321 } else {
316 } else {
322 c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
317 c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
@@ -514,6 +509,28 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
514 return nil
509 return nil
515}
510}
516
511
512+// lookupConfigs searches config files recursively from CWD up to FS root
513+func lookupConfigs(cwd string) []string {
514+ // prepend default config paths
515+ configPaths := []string{
516+ globalConfig(),
517+ GlobalConfigData(),
518+ }
519+
520+ configNames := []string{appName + ".json", "." + appName + ".json"}
521+
522+ foundConfigs, err := fsext.Lookup(cwd, configNames...)
523+ if err != nil {
524+ // returns at least default configs
525+ return configPaths
526+ }
527+
528+ // reverse order so last config has more priority
529+ slices.Reverse(foundConfigs)
530+
531+ return append(configPaths, foundConfigs...)
532+}
533+
517func loadFromConfigPaths(configPaths []string) (*Config, error) {
534func loadFromConfigPaths(configPaths []string) (*Config, error) {
518 var configs []io.Reader
535 var configs []io.Reader
519
536
internal/fsext/lookup.gonew file+141
@@ -0,0 +1,141 @@
1+package fsext
2+
3+import (
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.
19+func 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.
59+func 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+// traverseUp walks up from given directory up until filesystem root reached.
86+// It passes absolute path of current directory and staring directory owner ID
87+// to callback function. It is up to user to check ownership.
88+func traverseUp(dir string, walkFn func(dir string, owner int) error) error {
89+ cwd, err := filepath.Abs(dir)
90+ if err != nil {
91+ return fmt.Errorf("cannot convert CWD to absolute path: %w", err)
92+ }
93+
94+ owner, err := Owner(dir)
95+ if err != nil {
96+ return fmt.Errorf("cannot get ownership: %w", err)
97+ }
98+
99+ for {
100+ err := walkFn(cwd, owner)
101+ if err == nil || errors.Is(err, filepath.SkipDir) {
102+ parent := filepath.Dir(cwd)
103+ if parent == cwd {
104+ return nil
105+ }
106+
107+ cwd = parent
108+ continue
109+ }
110+
111+ if errors.Is(err, filepath.SkipAll) {
112+ return nil
113+ }
114+
115+ return err
116+ }
117+}
118+
119+// probeEnt checks if entity at given path exists and belongs to given owner
120+func probeEnt(fspath string, owner int) error {
121+ _, err := os.Stat(fspath)
122+ if err != nil {
123+ return fmt.Errorf("cannot stat %s: %w", fspath, err)
124+ }
125+
126+ // special case for ownership check bypass
127+ if owner == -1 {
128+ return nil
129+ }
130+
131+ fowner, err := Owner(fspath)
132+ if err != nil {
133+ return fmt.Errorf("cannot get ownership for %s: %w", fspath, err)
134+ }
135+
136+ if fowner != owner {
137+ return os.ErrPermission
138+ }
139+
140+ return nil
141+}
internal/fsext/lookup_test.gonew file+483
@@ -0,0 +1,483 @@
1+package fsext
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/charmbracelet/crush/internal/home"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestLookupClosest(t *testing.T) {
14+ tempDir := t.TempDir()
15+
16+ // Change to temp directory
17+ oldWd, _ := os.Getwd()
18+ err := os.Chdir(tempDir)
19+ require.NoError(t, err)
20+
21+ t.Cleanup(func() {
22+ os.Chdir(oldWd)
23+ })
24+
25+ t.Run("target found in starting directory", func(t *testing.T) {
26+ testDir := t.TempDir()
27+
28+ // Create target file in current directory
29+ targetFile := filepath.Join(testDir, "target.txt")
30+ err := os.WriteFile(targetFile, []byte("test"), 0o644)
31+ require.NoError(t, err)
32+
33+ foundPath, found := LookupClosest(testDir, "target.txt")
34+ require.True(t, found)
35+ require.Equal(t, targetFile, foundPath)
36+ })
37+
38+ t.Run("target found in parent directory", func(t *testing.T) {
39+ testDir := t.TempDir()
40+
41+ // Create subdirectory
42+ subDir := filepath.Join(testDir, "subdir")
43+ err := os.Mkdir(subDir, 0o755)
44+ require.NoError(t, err)
45+
46+ // Create target file in parent directory
47+ targetFile := filepath.Join(testDir, "target.txt")
48+ err = os.WriteFile(targetFile, []byte("test"), 0o644)
49+ require.NoError(t, err)
50+
51+ foundPath, found := LookupClosest(subDir, "target.txt")
52+ require.True(t, found)
53+ require.Equal(t, targetFile, foundPath)
54+ })
55+
56+ t.Run("target found in grandparent directory", func(t *testing.T) {
57+ testDir := t.TempDir()
58+
59+ // Create nested subdirectories
60+ subDir := filepath.Join(testDir, "subdir")
61+ err := os.Mkdir(subDir, 0o755)
62+ require.NoError(t, err)
63+
64+ subSubDir := filepath.Join(subDir, "subsubdir")
65+ err = os.Mkdir(subSubDir, 0o755)
66+ require.NoError(t, err)
67+
68+ // Create target file in grandparent directory
69+ targetFile := filepath.Join(testDir, "target.txt")
70+ err = os.WriteFile(targetFile, []byte("test"), 0o644)
71+ require.NoError(t, err)
72+
73+ foundPath, found := LookupClosest(subSubDir, "target.txt")
74+ require.True(t, found)
75+ require.Equal(t, targetFile, foundPath)
76+ })
77+
78+ t.Run("target not found", func(t *testing.T) {
79+ testDir := t.TempDir()
80+
81+ foundPath, found := LookupClosest(testDir, "nonexistent.txt")
82+ require.False(t, found)
83+ require.Empty(t, foundPath)
84+ })
85+
86+ t.Run("target directory found", func(t *testing.T) {
87+ testDir := t.TempDir()
88+
89+ // Create target directory in current directory
90+ targetDir := filepath.Join(testDir, "targetdir")
91+ err := os.Mkdir(targetDir, 0o755)
92+ require.NoError(t, err)
93+
94+ foundPath, found := LookupClosest(testDir, "targetdir")
95+ require.True(t, found)
96+ require.Equal(t, targetDir, foundPath)
97+ })
98+
99+ t.Run("stops at home directory", func(t *testing.T) {
100+ // This test is limited as we can't easily create files above home directory
101+ // but we can test the behavior by searching from home directory itself
102+ homeDir := home.Dir()
103+
104+ // Search for a file that doesn't exist from home directory
105+ foundPath, found := LookupClosest(homeDir, "nonexistent_file_12345.txt")
106+ require.False(t, found)
107+ require.Empty(t, foundPath)
108+ })
109+
110+ t.Run("invalid starting directory", func(t *testing.T) {
111+ foundPath, found := LookupClosest("/invalid/path/that/does/not/exist", "target.txt")
112+ require.False(t, found)
113+ require.Empty(t, foundPath)
114+ })
115+
116+ t.Run("relative path handling", func(t *testing.T) {
117+ testDir := t.TempDir()
118+
119+ // Change to test directory
120+ oldWd, _ := os.Getwd()
121+ err := os.Chdir(testDir)
122+ require.NoError(t, err)
123+ defer os.Chdir(oldWd)
124+
125+ // Create target file in current directory
126+ err = os.WriteFile("target.txt", []byte("test"), 0o644)
127+ require.NoError(t, err)
128+
129+ // Search using relative path
130+ foundPath, found := LookupClosest(".", "target.txt")
131+ require.True(t, found)
132+
133+ // Resolve symlinks to handle macOS /private/var vs /var discrepancy
134+ expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt"))
135+ require.NoError(t, err)
136+ actualPath, err := filepath.EvalSymlinks(foundPath)
137+ require.NoError(t, err)
138+ require.Equal(t, expectedPath, actualPath)
139+ })
140+}
141+
142+func TestLookupClosestWithOwnership(t *testing.T) {
143+ // Note: Testing ownership boundaries is difficult in a cross-platform way
144+ // without creating complex directory structures with different owners.
145+ // This test focuses on the basic functionality when ownership checks pass.
146+
147+ tempDir := t.TempDir()
148+
149+ // Change to temp directory
150+ oldWd, _ := os.Getwd()
151+ err := os.Chdir(tempDir)
152+ require.NoError(t, err)
153+
154+ t.Cleanup(func() {
155+ os.Chdir(oldWd)
156+ })
157+
158+ t.Run("search respects same ownership", func(t *testing.T) {
159+ testDir := t.TempDir()
160+
161+ // Create subdirectory structure
162+ subDir := filepath.Join(testDir, "subdir")
163+ err := os.Mkdir(subDir, 0o755)
164+ require.NoError(t, err)
165+
166+ // Create target file in parent directory
167+ targetFile := filepath.Join(testDir, "target.txt")
168+ err = os.WriteFile(targetFile, []byte("test"), 0o644)
169+ require.NoError(t, err)
170+
171+ // Search should find the target assuming same ownership
172+ foundPath, found := LookupClosest(subDir, "target.txt")
173+ require.True(t, found)
174+ require.Equal(t, targetFile, foundPath)
175+ })
176+}
177+
178+func TestLookup(t *testing.T) {
179+ tempDir := t.TempDir()
180+
181+ // Change to temp directory
182+ oldWd, _ := os.Getwd()
183+ err := os.Chdir(tempDir)
184+ require.NoError(t, err)
185+
186+ t.Cleanup(func() {
187+ os.Chdir(oldWd)
188+ })
189+
190+ t.Run("no targets returns empty slice", func(t *testing.T) {
191+ testDir := t.TempDir()
192+
193+ found, err := Lookup(testDir)
194+ require.NoError(t, err)
195+ require.Empty(t, found)
196+ })
197+
198+ t.Run("single target found in starting directory", func(t *testing.T) {
199+ testDir := t.TempDir()
200+
201+ // Create target file in current directory
202+ targetFile := filepath.Join(testDir, "target.txt")
203+ err := os.WriteFile(targetFile, []byte("test"), 0o644)
204+ require.NoError(t, err)
205+
206+ found, err := Lookup(testDir, "target.txt")
207+ require.NoError(t, err)
208+ require.Len(t, found, 1)
209+ require.Equal(t, targetFile, found[0])
210+ })
211+
212+ t.Run("multiple targets found in starting directory", func(t *testing.T) {
213+ testDir := t.TempDir()
214+
215+ // Create multiple target files in current directory
216+ targetFile1 := filepath.Join(testDir, "target1.txt")
217+ targetFile2 := filepath.Join(testDir, "target2.txt")
218+ targetFile3 := filepath.Join(testDir, "target3.txt")
219+
220+ err := os.WriteFile(targetFile1, []byte("test1"), 0o644)
221+ require.NoError(t, err)
222+ err = os.WriteFile(targetFile2, []byte("test2"), 0o644)
223+ require.NoError(t, err)
224+ err = os.WriteFile(targetFile3, []byte("test3"), 0o644)
225+ require.NoError(t, err)
226+
227+ found, err := Lookup(testDir, "target1.txt", "target2.txt", "target3.txt")
228+ require.NoError(t, err)
229+ require.Len(t, found, 3)
230+ require.Contains(t, found, targetFile1)
231+ require.Contains(t, found, targetFile2)
232+ require.Contains(t, found, targetFile3)
233+ })
234+
235+ t.Run("targets found in parent directories", func(t *testing.T) {
236+ testDir := t.TempDir()
237+
238+ // Create subdirectory
239+ subDir := filepath.Join(testDir, "subdir")
240+ err := os.Mkdir(subDir, 0o755)
241+ require.NoError(t, err)
242+
243+ // Create target files in parent directory
244+ targetFile1 := filepath.Join(testDir, "target1.txt")
245+ targetFile2 := filepath.Join(testDir, "target2.txt")
246+ err = os.WriteFile(targetFile1, []byte("test1"), 0o644)
247+ require.NoError(t, err)
248+ err = os.WriteFile(targetFile2, []byte("test2"), 0o644)
249+ require.NoError(t, err)
250+
251+ found, err := Lookup(subDir, "target1.txt", "target2.txt")
252+ require.NoError(t, err)
253+ require.Len(t, found, 2)
254+ require.Contains(t, found, targetFile1)
255+ require.Contains(t, found, targetFile2)
256+ })
257+
258+ t.Run("targets found across multiple directory levels", func(t *testing.T) {
259+ testDir := t.TempDir()
260+
261+ // Create nested subdirectories
262+ subDir := filepath.Join(testDir, "subdir")
263+ err := os.Mkdir(subDir, 0o755)
264+ require.NoError(t, err)
265+
266+ subSubDir := filepath.Join(subDir, "subsubdir")
267+ err = os.Mkdir(subSubDir, 0o755)
268+ require.NoError(t, err)
269+
270+ // Create target files at different levels
271+ targetFile1 := filepath.Join(testDir, "target1.txt")
272+ targetFile2 := filepath.Join(subDir, "target2.txt")
273+ targetFile3 := filepath.Join(subSubDir, "target3.txt")
274+
275+ err = os.WriteFile(targetFile1, []byte("test1"), 0o644)
276+ require.NoError(t, err)
277+ err = os.WriteFile(targetFile2, []byte("test2"), 0o644)
278+ require.NoError(t, err)
279+ err = os.WriteFile(targetFile3, []byte("test3"), 0o644)
280+ require.NoError(t, err)
281+
282+ found, err := Lookup(subSubDir, "target1.txt", "target2.txt", "target3.txt")
283+ require.NoError(t, err)
284+ require.Len(t, found, 3)
285+ require.Contains(t, found, targetFile1)
286+ require.Contains(t, found, targetFile2)
287+ require.Contains(t, found, targetFile3)
288+ })
289+
290+ t.Run("some targets not found", func(t *testing.T) {
291+ testDir := t.TempDir()
292+
293+ // Create only some target files
294+ targetFile1 := filepath.Join(testDir, "target1.txt")
295+ targetFile2 := filepath.Join(testDir, "target2.txt")
296+
297+ err := os.WriteFile(targetFile1, []byte("test1"), 0o644)
298+ require.NoError(t, err)
299+ err = os.WriteFile(targetFile2, []byte("test2"), 0o644)
300+ require.NoError(t, err)
301+
302+ // Search for existing and non-existing targets
303+ found, err := Lookup(testDir, "target1.txt", "nonexistent.txt", "target2.txt", "another_nonexistent.txt")
304+ require.NoError(t, err)
305+ require.Len(t, found, 2)
306+ require.Contains(t, found, targetFile1)
307+ require.Contains(t, found, targetFile2)
308+ })
309+
310+ t.Run("no targets found", func(t *testing.T) {
311+ testDir := t.TempDir()
312+
313+ found, err := Lookup(testDir, "nonexistent1.txt", "nonexistent2.txt", "nonexistent3.txt")
314+ require.NoError(t, err)
315+ require.Empty(t, found)
316+ })
317+
318+ t.Run("target directories found", func(t *testing.T) {
319+ testDir := t.TempDir()
320+
321+ // Create target directories
322+ targetDir1 := filepath.Join(testDir, "targetdir1")
323+ targetDir2 := filepath.Join(testDir, "targetdir2")
324+ err := os.Mkdir(targetDir1, 0o755)
325+ require.NoError(t, err)
326+ err = os.Mkdir(targetDir2, 0o755)
327+ require.NoError(t, err)
328+
329+ found, err := Lookup(testDir, "targetdir1", "targetdir2")
330+ require.NoError(t, err)
331+ require.Len(t, found, 2)
332+ require.Contains(t, found, targetDir1)
333+ require.Contains(t, found, targetDir2)
334+ })
335+
336+ t.Run("mixed files and directories", func(t *testing.T) {
337+ testDir := t.TempDir()
338+
339+ // Create target files and directories
340+ targetFile := filepath.Join(testDir, "target.txt")
341+ targetDir := filepath.Join(testDir, "targetdir")
342+ err := os.WriteFile(targetFile, []byte("test"), 0o644)
343+ require.NoError(t, err)
344+ err = os.Mkdir(targetDir, 0o755)
345+ require.NoError(t, err)
346+
347+ found, err := Lookup(testDir, "target.txt", "targetdir")
348+ require.NoError(t, err)
349+ require.Len(t, found, 2)
350+ require.Contains(t, found, targetFile)
351+ require.Contains(t, found, targetDir)
352+ })
353+
354+ t.Run("invalid starting directory", func(t *testing.T) {
355+ found, err := Lookup("/invalid/path/that/does/not/exist", "target.txt")
356+ require.Error(t, err)
357+ require.Empty(t, found)
358+ })
359+
360+ t.Run("relative path handling", func(t *testing.T) {
361+ testDir := t.TempDir()
362+
363+ // Change to test directory
364+ oldWd, _ := os.Getwd()
365+ err := os.Chdir(testDir)
366+ require.NoError(t, err)
367+
368+ t.Cleanup(func() {
369+ os.Chdir(oldWd)
370+ })
371+
372+ // Create target files in current directory
373+ err = os.WriteFile("target1.txt", []byte("test1"), 0o644)
374+ require.NoError(t, err)
375+ err = os.WriteFile("target2.txt", []byte("test2"), 0o644)
376+ require.NoError(t, err)
377+
378+ // Search using relative path
379+ found, err := Lookup(".", "target1.txt", "target2.txt")
380+ require.NoError(t, err)
381+ require.Len(t, found, 2)
382+
383+ // Resolve symlinks to handle macOS /private/var vs /var discrepancy
384+ expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt"))
385+ require.NoError(t, err)
386+ expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt"))
387+ require.NoError(t, err)
388+
389+ // Check that found paths match expected paths (order may vary)
390+ foundEvalSymlinks := make([]string, len(found))
391+ for i, path := range found {
392+ evalPath, err := filepath.EvalSymlinks(path)
393+ require.NoError(t, err)
394+ foundEvalSymlinks[i] = evalPath
395+ }
396+
397+ require.Contains(t, foundEvalSymlinks, expectedPath1)
398+ require.Contains(t, foundEvalSymlinks, expectedPath2)
399+ })
400+}
401+
402+func TestProbeEnt(t *testing.T) {
403+ t.Run("existing file with correct owner", func(t *testing.T) {
404+ tempDir := t.TempDir()
405+
406+ // Create test file
407+ testFile := filepath.Join(tempDir, "test.txt")
408+ err := os.WriteFile(testFile, []byte("test"), 0o644)
409+ require.NoError(t, err)
410+
411+ // Get owner of temp directory
412+ owner, err := Owner(tempDir)
413+ require.NoError(t, err)
414+
415+ // Test probeEnt with correct owner
416+ err = probeEnt(testFile, owner)
417+ require.NoError(t, err)
418+ })
419+
420+ t.Run("existing directory with correct owner", func(t *testing.T) {
421+ tempDir := t.TempDir()
422+
423+ // Create test directory
424+ testDir := filepath.Join(tempDir, "testdir")
425+ err := os.Mkdir(testDir, 0o755)
426+ require.NoError(t, err)
427+
428+ // Get owner of temp directory
429+ owner, err := Owner(tempDir)
430+ require.NoError(t, err)
431+
432+ // Test probeEnt with correct owner
433+ err = probeEnt(testDir, owner)
434+ require.NoError(t, err)
435+ })
436+
437+ t.Run("nonexistent file", func(t *testing.T) {
438+ tempDir := t.TempDir()
439+
440+ nonexistentFile := filepath.Join(tempDir, "nonexistent.txt")
441+ owner, err := Owner(tempDir)
442+ require.NoError(t, err)
443+
444+ err = probeEnt(nonexistentFile, owner)
445+ require.Error(t, err)
446+ require.True(t, errors.Is(err, os.ErrNotExist))
447+ })
448+
449+ t.Run("nonexistent file in nonexistent directory", func(t *testing.T) {
450+ nonexistentFile := "/this/directory/does/not/exists/nonexistent.txt"
451+
452+ err := probeEnt(nonexistentFile, -1)
453+ require.Error(t, err)
454+ require.True(t, errors.Is(err, os.ErrNotExist))
455+ })
456+
457+ t.Run("ownership bypass with -1", func(t *testing.T) {
458+ tempDir := t.TempDir()
459+
460+ // Create test file
461+ testFile := filepath.Join(tempDir, "test.txt")
462+ err := os.WriteFile(testFile, []byte("test"), 0o644)
463+ require.NoError(t, err)
464+
465+ // Test probeEnt with -1 (bypass ownership check)
466+ err = probeEnt(testFile, -1)
467+ require.NoError(t, err)
468+ })
469+
470+ t.Run("ownership mismatch returns permission error", func(t *testing.T) {
471+ tempDir := t.TempDir()
472+
473+ // Create test file
474+ testFile := filepath.Join(tempDir, "test.txt")
475+ err := os.WriteFile(testFile, []byte("test"), 0o644)
476+ require.NoError(t, err)
477+
478+ // Test probeEnt with different owner (use 9999 which is unlikely to be the actual owner)
479+ err = probeEnt(testFile, 9999)
480+ require.Error(t, err)
481+ require.True(t, errors.Is(err, os.ErrPermission))
482+ })
483+}
internal/fsext/owner_windows.go+6
@@ -2,8 +2,14 @@
2
2
3package fsext
3package fsext
4
4
5+import "os"
6+
5// Owner retrieves the user ID of the owner of the file or directory at the
7// Owner retrieves the user ID of the owner of the file or directory at the
6// specified path.
8// specified path.
7func Owner(path string) (int, error) {
9func Owner(path string) (int, error) {
10+ _, err := os.Stat(path)
11+ if err != nil {
12+ return 0, err
13+ }
8 return -1, nil
14 return -1, nil
9}
15}
internal/fsext/parent.godeleted−60
@@ -1,60 +0,0 @@
1−package fsext
2−
3−import (
4− "errors"
5− "os"
6− "path/filepath"
7−
8− "github.com/charmbracelet/crush/internal/home"
9−)
10−
11−// SearchParent searches for a target file or directory starting from dir
12−// and walking up the directory tree until found or root or home is reached.
13−// It also checks the ownership of directories to ensure that the search does
14−// not cross ownership boundaries.
15−// Returns the full path to the target if found, empty string and false otherwise.
16−// The search includes the starting directory itself.
17−func SearchParent(dir, target string) (string, bool) {
18− absDir, err := filepath.Abs(dir)
19− if err != nil {
20− return "", false
21− }
22−
23− path := filepath.Join(absDir, target)
24− if _, err := os.Stat(path); err == nil {
25− return path, true
26− } else if !errors.Is(err, os.ErrNotExist) {
27− return "", false
28− }
29−
30− previousParent := absDir
31− previousOwner, err := Owner(previousParent)
32− if err != nil {
33− return "", false
34− }
35−
36− for {
37− parent := filepath.Dir(previousParent)
38− if parent == previousParent || parent == home.Dir() {
39− return "", false
40− }
41−
42− parentOwner, err := Owner(parent)
43− if err != nil {
44− return "", false
45− }
46− if parentOwner != previousOwner {
47− return "", false
48− }
49−
50− path := filepath.Join(parent, target)
51− if _, err := os.Stat(path); err == nil {
52− return path, true
53− } else if !errors.Is(err, os.ErrNotExist) {
54− return "", false
55− }
56−
57− previousParent = parent
58− previousOwner = parentOwner
59− }
60−}