From 701e550fbb7f94ef29002cd92b6c18d8ad5a883d Mon Sep 17 00:00:00 2001 From: bbrodriges Date: Mon, 22 Sep 2025 22:45:39 +0300 Subject: [PATCH] feat(config): search`crush.json` recursively up from the working directory (#898) --- internal/config/load.go | 33 ++- internal/fsext/lookup.go | 141 ++++++++++ internal/fsext/lookup_test.go | 483 ++++++++++++++++++++++++++++++++ internal/fsext/owner_windows.go | 6 + internal/fsext/parent.go | 60 ---- 5 files changed, 655 insertions(+), 68 deletions(-) create mode 100644 internal/fsext/lookup.go create mode 100644 internal/fsext/lookup_test.go delete mode 100644 internal/fsext/parent.go diff --git a/internal/config/load.go b/internal/config/load.go index 9ac5411f0b1697ce96453c72a01defe219c19a37..06ece6467177a443e7b6af20754976e901f642e9 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -41,13 +41,8 @@ func LoadReader(fd io.Reader) (*Config, error) { // Load loads the configuration from the default paths. func Load(workingDir, dataDir string, debug bool) (*Config, error) { - // uses default config paths - configPaths := []string{ - globalConfig(), - GlobalConfigData(), - filepath.Join(workingDir, fmt.Sprintf("%s.json", appName)), - filepath.Join(workingDir, fmt.Sprintf(".%s.json", appName)), - } + configPaths := lookupConfigs(workingDir) + cfg, err := loadFromConfigPaths(configPaths) if err != nil { 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) { if dataDir != "" { c.Options.DataDirectory = dataDir } else if c.Options.DataDirectory == "" { - if path, ok := fsext.SearchParent(workingDir, defaultDataDirectory); ok { + if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok { c.Options.DataDirectory = path } else { c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory) @@ -514,6 +509,28 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro return nil } +// lookupConfigs searches config files recursively from CWD up to FS root +func lookupConfigs(cwd string) []string { + // prepend default config paths + configPaths := []string{ + globalConfig(), + GlobalConfigData(), + } + + configNames := []string{appName + ".json", "." + appName + ".json"} + + foundConfigs, err := fsext.Lookup(cwd, configNames...) + if err != nil { + // returns at least default configs + return configPaths + } + + // reverse order so last config has more priority + slices.Reverse(foundConfigs) + + return append(configPaths, foundConfigs...) +} + func loadFromConfigPaths(configPaths []string) (*Config, error) { var configs []io.Reader diff --git a/internal/fsext/lookup.go b/internal/fsext/lookup.go new file mode 100644 index 0000000000000000000000000000000000000000..098426571c69521a5978a2c2e0a4178b51b0aae6 --- /dev/null +++ b/internal/fsext/lookup.go @@ -0,0 +1,141 @@ +package fsext + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/crush/internal/home" +) + +// Lookup searches for a target files or directories starting from dir +// and walking up the directory tree until filesystem root is reached. +// It also checks the ownership of files to ensure that the search does +// not cross ownership boundaries. It skips ownership mismatches without +// errors. +// Returns full paths to fount targets. +// The search includes the starting directory itself. +func Lookup(dir string, targets ...string) ([]string, error) { + if len(targets) == 0 { + return nil, nil + } + + var found []string + + err := traverseUp(dir, func(cwd string, owner int) error { + for _, target := range targets { + fpath := filepath.Join(cwd, target) + err := probeEnt(fpath, owner) + + // skip to the next file on permission denied + if errors.Is(err, os.ErrNotExist) || + errors.Is(err, os.ErrPermission) { + continue + } + + if err != nil { + return fmt.Errorf("error probing file %s: %w", fpath, err) + } + + found = append(found, fpath) + } + + return nil + }) + if err != nil { + return nil, err + } + + return found, nil +} + +// LookupClosest searches for a target file or directory starting from dir +// and walking up the directory tree until found or root or home is reached. +// It also checks the ownership of files to ensure that the search does +// not cross ownership boundaries. +// Returns the full path to the target if found, empty string and false otherwise. +// The search includes the starting directory itself. +func LookupClosest(dir, target string) (string, bool) { + var found string + + err := traverseUp(dir, func(cwd string, owner int) error { + fpath := filepath.Join(cwd, target) + + err := probeEnt(fpath, owner) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + if err != nil { + return fmt.Errorf("error probing file %s: %w", fpath, err) + } + + if cwd == home.Dir() { + return filepath.SkipAll + } + + found = fpath + return filepath.SkipAll + }) + + return found, err == nil && found != "" +} + +// traverseUp walks up from given directory up until filesystem root reached. +// It passes absolute path of current directory and staring directory owner ID +// to callback function. It is up to user to check ownership. +func traverseUp(dir string, walkFn func(dir string, owner int) error) error { + cwd, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("cannot convert CWD to absolute path: %w", err) + } + + owner, err := Owner(dir) + if err != nil { + return fmt.Errorf("cannot get ownership: %w", err) + } + + for { + err := walkFn(cwd, owner) + if err == nil || errors.Is(err, filepath.SkipDir) { + parent := filepath.Dir(cwd) + if parent == cwd { + return nil + } + + cwd = parent + continue + } + + if errors.Is(err, filepath.SkipAll) { + return nil + } + + return err + } +} + +// probeEnt checks if entity at given path exists and belongs to given owner +func probeEnt(fspath string, owner int) error { + _, err := os.Stat(fspath) + if err != nil { + return fmt.Errorf("cannot stat %s: %w", fspath, err) + } + + // special case for ownership check bypass + if owner == -1 { + return nil + } + + fowner, err := Owner(fspath) + if err != nil { + return fmt.Errorf("cannot get ownership for %s: %w", fspath, err) + } + + if fowner != owner { + return os.ErrPermission + } + + return nil +} diff --git a/internal/fsext/lookup_test.go b/internal/fsext/lookup_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b7604331673aad0d65d34e046901bc9eae722195 --- /dev/null +++ b/internal/fsext/lookup_test.go @@ -0,0 +1,483 @@ +package fsext + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/charmbracelet/crush/internal/home" + "github.com/stretchr/testify/require" +) + +func TestLookupClosest(t *testing.T) { + tempDir := t.TempDir() + + // Change to temp directory + oldWd, _ := os.Getwd() + err := os.Chdir(tempDir) + require.NoError(t, err) + + t.Cleanup(func() { + os.Chdir(oldWd) + }) + + t.Run("target found in starting directory", func(t *testing.T) { + testDir := t.TempDir() + + // Create target file in current directory + targetFile := filepath.Join(testDir, "target.txt") + err := os.WriteFile(targetFile, []byte("test"), 0o644) + require.NoError(t, err) + + foundPath, found := LookupClosest(testDir, "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) + + t.Run("target found in parent directory", func(t *testing.T) { + testDir := t.TempDir() + + // Create subdirectory + subDir := filepath.Join(testDir, "subdir") + err := os.Mkdir(subDir, 0o755) + require.NoError(t, err) + + // Create target file in parent directory + targetFile := filepath.Join(testDir, "target.txt") + err = os.WriteFile(targetFile, []byte("test"), 0o644) + require.NoError(t, err) + + foundPath, found := LookupClosest(subDir, "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) + + t.Run("target found in grandparent directory", func(t *testing.T) { + testDir := t.TempDir() + + // Create nested subdirectories + subDir := filepath.Join(testDir, "subdir") + err := os.Mkdir(subDir, 0o755) + require.NoError(t, err) + + subSubDir := filepath.Join(subDir, "subsubdir") + err = os.Mkdir(subSubDir, 0o755) + require.NoError(t, err) + + // Create target file in grandparent directory + targetFile := filepath.Join(testDir, "target.txt") + err = os.WriteFile(targetFile, []byte("test"), 0o644) + require.NoError(t, err) + + foundPath, found := LookupClosest(subSubDir, "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) + + t.Run("target not found", func(t *testing.T) { + testDir := t.TempDir() + + foundPath, found := LookupClosest(testDir, "nonexistent.txt") + require.False(t, found) + require.Empty(t, foundPath) + }) + + t.Run("target directory found", func(t *testing.T) { + testDir := t.TempDir() + + // Create target directory in current directory + targetDir := filepath.Join(testDir, "targetdir") + err := os.Mkdir(targetDir, 0o755) + require.NoError(t, err) + + foundPath, found := LookupClosest(testDir, "targetdir") + require.True(t, found) + require.Equal(t, targetDir, foundPath) + }) + + t.Run("stops at home directory", func(t *testing.T) { + // This test is limited as we can't easily create files above home directory + // but we can test the behavior by searching from home directory itself + homeDir := home.Dir() + + // Search for a file that doesn't exist from home directory + foundPath, found := LookupClosest(homeDir, "nonexistent_file_12345.txt") + require.False(t, found) + require.Empty(t, foundPath) + }) + + t.Run("invalid starting directory", func(t *testing.T) { + foundPath, found := LookupClosest("/invalid/path/that/does/not/exist", "target.txt") + require.False(t, found) + require.Empty(t, foundPath) + }) + + t.Run("relative path handling", func(t *testing.T) { + testDir := t.TempDir() + + // Change to test directory + oldWd, _ := os.Getwd() + err := os.Chdir(testDir) + require.NoError(t, err) + defer os.Chdir(oldWd) + + // Create target file in current directory + err = os.WriteFile("target.txt", []byte("test"), 0o644) + require.NoError(t, err) + + // Search using relative path + foundPath, found := LookupClosest(".", "target.txt") + require.True(t, found) + + // Resolve symlinks to handle macOS /private/var vs /var discrepancy + expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt")) + require.NoError(t, err) + actualPath, err := filepath.EvalSymlinks(foundPath) + require.NoError(t, err) + require.Equal(t, expectedPath, actualPath) + }) +} + +func TestLookupClosestWithOwnership(t *testing.T) { + // Note: Testing ownership boundaries is difficult in a cross-platform way + // without creating complex directory structures with different owners. + // This test focuses on the basic functionality when ownership checks pass. + + tempDir := t.TempDir() + + // Change to temp directory + oldWd, _ := os.Getwd() + err := os.Chdir(tempDir) + require.NoError(t, err) + + t.Cleanup(func() { + os.Chdir(oldWd) + }) + + t.Run("search respects same ownership", func(t *testing.T) { + testDir := t.TempDir() + + // Create subdirectory structure + subDir := filepath.Join(testDir, "subdir") + err := os.Mkdir(subDir, 0o755) + require.NoError(t, err) + + // Create target file in parent directory + targetFile := filepath.Join(testDir, "target.txt") + err = os.WriteFile(targetFile, []byte("test"), 0o644) + require.NoError(t, err) + + // Search should find the target assuming same ownership + foundPath, found := LookupClosest(subDir, "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) +} + +func TestLookup(t *testing.T) { + tempDir := t.TempDir() + + // Change to temp directory + oldWd, _ := os.Getwd() + err := os.Chdir(tempDir) + require.NoError(t, err) + + t.Cleanup(func() { + os.Chdir(oldWd) + }) + + t.Run("no targets returns empty slice", func(t *testing.T) { + testDir := t.TempDir() + + found, err := Lookup(testDir) + require.NoError(t, err) + require.Empty(t, found) + }) + + t.Run("single target found in starting directory", func(t *testing.T) { + testDir := t.TempDir() + + // Create target file in current directory + targetFile := filepath.Join(testDir, "target.txt") + err := os.WriteFile(targetFile, []byte("test"), 0o644) + require.NoError(t, err) + + found, err := Lookup(testDir, "target.txt") + require.NoError(t, err) + require.Len(t, found, 1) + require.Equal(t, targetFile, found[0]) + }) + + t.Run("multiple targets found in starting directory", func(t *testing.T) { + testDir := t.TempDir() + + // Create multiple target files in current directory + targetFile1 := filepath.Join(testDir, "target1.txt") + targetFile2 := filepath.Join(testDir, "target2.txt") + targetFile3 := filepath.Join(testDir, "target3.txt") + + err := os.WriteFile(targetFile1, []byte("test1"), 0o644) + require.NoError(t, err) + err = os.WriteFile(targetFile2, []byte("test2"), 0o644) + require.NoError(t, err) + err = os.WriteFile(targetFile3, []byte("test3"), 0o644) + require.NoError(t, err) + + found, err := Lookup(testDir, "target1.txt", "target2.txt", "target3.txt") + require.NoError(t, err) + require.Len(t, found, 3) + require.Contains(t, found, targetFile1) + require.Contains(t, found, targetFile2) + require.Contains(t, found, targetFile3) + }) + + t.Run("targets found in parent directories", func(t *testing.T) { + testDir := t.TempDir() + + // Create subdirectory + subDir := filepath.Join(testDir, "subdir") + err := os.Mkdir(subDir, 0o755) + require.NoError(t, err) + + // Create target files in parent directory + targetFile1 := filepath.Join(testDir, "target1.txt") + targetFile2 := filepath.Join(testDir, "target2.txt") + err = os.WriteFile(targetFile1, []byte("test1"), 0o644) + require.NoError(t, err) + err = os.WriteFile(targetFile2, []byte("test2"), 0o644) + require.NoError(t, err) + + found, err := Lookup(subDir, "target1.txt", "target2.txt") + require.NoError(t, err) + require.Len(t, found, 2) + require.Contains(t, found, targetFile1) + require.Contains(t, found, targetFile2) + }) + + t.Run("targets found across multiple directory levels", func(t *testing.T) { + testDir := t.TempDir() + + // Create nested subdirectories + subDir := filepath.Join(testDir, "subdir") + err := os.Mkdir(subDir, 0o755) + require.NoError(t, err) + + subSubDir := filepath.Join(subDir, "subsubdir") + err = os.Mkdir(subSubDir, 0o755) + require.NoError(t, err) + + // Create target files at different levels + targetFile1 := filepath.Join(testDir, "target1.txt") + targetFile2 := filepath.Join(subDir, "target2.txt") + targetFile3 := filepath.Join(subSubDir, "target3.txt") + + err = os.WriteFile(targetFile1, []byte("test1"), 0o644) + require.NoError(t, err) + err = os.WriteFile(targetFile2, []byte("test2"), 0o644) + require.NoError(t, err) + err = os.WriteFile(targetFile3, []byte("test3"), 0o644) + require.NoError(t, err) + + found, err := Lookup(subSubDir, "target1.txt", "target2.txt", "target3.txt") + require.NoError(t, err) + require.Len(t, found, 3) + require.Contains(t, found, targetFile1) + require.Contains(t, found, targetFile2) + require.Contains(t, found, targetFile3) + }) + + t.Run("some targets not found", func(t *testing.T) { + testDir := t.TempDir() + + // Create only some target files + targetFile1 := filepath.Join(testDir, "target1.txt") + targetFile2 := filepath.Join(testDir, "target2.txt") + + err := os.WriteFile(targetFile1, []byte("test1"), 0o644) + require.NoError(t, err) + err = os.WriteFile(targetFile2, []byte("test2"), 0o644) + require.NoError(t, err) + + // Search for existing and non-existing targets + found, err := Lookup(testDir, "target1.txt", "nonexistent.txt", "target2.txt", "another_nonexistent.txt") + require.NoError(t, err) + require.Len(t, found, 2) + require.Contains(t, found, targetFile1) + require.Contains(t, found, targetFile2) + }) + + t.Run("no targets found", func(t *testing.T) { + testDir := t.TempDir() + + found, err := Lookup(testDir, "nonexistent1.txt", "nonexistent2.txt", "nonexistent3.txt") + require.NoError(t, err) + require.Empty(t, found) + }) + + t.Run("target directories found", func(t *testing.T) { + testDir := t.TempDir() + + // Create target directories + targetDir1 := filepath.Join(testDir, "targetdir1") + targetDir2 := filepath.Join(testDir, "targetdir2") + err := os.Mkdir(targetDir1, 0o755) + require.NoError(t, err) + err = os.Mkdir(targetDir2, 0o755) + require.NoError(t, err) + + found, err := Lookup(testDir, "targetdir1", "targetdir2") + require.NoError(t, err) + require.Len(t, found, 2) + require.Contains(t, found, targetDir1) + require.Contains(t, found, targetDir2) + }) + + t.Run("mixed files and directories", func(t *testing.T) { + testDir := t.TempDir() + + // Create target files and directories + targetFile := filepath.Join(testDir, "target.txt") + targetDir := filepath.Join(testDir, "targetdir") + err := os.WriteFile(targetFile, []byte("test"), 0o644) + require.NoError(t, err) + err = os.Mkdir(targetDir, 0o755) + require.NoError(t, err) + + found, err := Lookup(testDir, "target.txt", "targetdir") + require.NoError(t, err) + require.Len(t, found, 2) + require.Contains(t, found, targetFile) + require.Contains(t, found, targetDir) + }) + + t.Run("invalid starting directory", func(t *testing.T) { + found, err := Lookup("/invalid/path/that/does/not/exist", "target.txt") + require.Error(t, err) + require.Empty(t, found) + }) + + t.Run("relative path handling", func(t *testing.T) { + testDir := t.TempDir() + + // Change to test directory + oldWd, _ := os.Getwd() + err := os.Chdir(testDir) + require.NoError(t, err) + + t.Cleanup(func() { + os.Chdir(oldWd) + }) + + // Create target files in current directory + err = os.WriteFile("target1.txt", []byte("test1"), 0o644) + require.NoError(t, err) + err = os.WriteFile("target2.txt", []byte("test2"), 0o644) + require.NoError(t, err) + + // Search using relative path + found, err := Lookup(".", "target1.txt", "target2.txt") + require.NoError(t, err) + require.Len(t, found, 2) + + // Resolve symlinks to handle macOS /private/var vs /var discrepancy + expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt")) + require.NoError(t, err) + expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt")) + require.NoError(t, err) + + // Check that found paths match expected paths (order may vary) + foundEvalSymlinks := make([]string, len(found)) + for i, path := range found { + evalPath, err := filepath.EvalSymlinks(path) + require.NoError(t, err) + foundEvalSymlinks[i] = evalPath + } + + require.Contains(t, foundEvalSymlinks, expectedPath1) + require.Contains(t, foundEvalSymlinks, expectedPath2) + }) +} + +func TestProbeEnt(t *testing.T) { + t.Run("existing file with correct owner", func(t *testing.T) { + tempDir := t.TempDir() + + // Create test file + testFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFile, []byte("test"), 0o644) + require.NoError(t, err) + + // Get owner of temp directory + owner, err := Owner(tempDir) + require.NoError(t, err) + + // Test probeEnt with correct owner + err = probeEnt(testFile, owner) + require.NoError(t, err) + }) + + t.Run("existing directory with correct owner", func(t *testing.T) { + tempDir := t.TempDir() + + // Create test directory + testDir := filepath.Join(tempDir, "testdir") + err := os.Mkdir(testDir, 0o755) + require.NoError(t, err) + + // Get owner of temp directory + owner, err := Owner(tempDir) + require.NoError(t, err) + + // Test probeEnt with correct owner + err = probeEnt(testDir, owner) + require.NoError(t, err) + }) + + t.Run("nonexistent file", func(t *testing.T) { + tempDir := t.TempDir() + + nonexistentFile := filepath.Join(tempDir, "nonexistent.txt") + owner, err := Owner(tempDir) + require.NoError(t, err) + + err = probeEnt(nonexistentFile, owner) + require.Error(t, err) + require.True(t, errors.Is(err, os.ErrNotExist)) + }) + + t.Run("nonexistent file in nonexistent directory", func(t *testing.T) { + nonexistentFile := "/this/directory/does/not/exists/nonexistent.txt" + + err := probeEnt(nonexistentFile, -1) + require.Error(t, err) + require.True(t, errors.Is(err, os.ErrNotExist)) + }) + + t.Run("ownership bypass with -1", func(t *testing.T) { + tempDir := t.TempDir() + + // Create test file + testFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFile, []byte("test"), 0o644) + require.NoError(t, err) + + // Test probeEnt with -1 (bypass ownership check) + err = probeEnt(testFile, -1) + require.NoError(t, err) + }) + + t.Run("ownership mismatch returns permission error", func(t *testing.T) { + tempDir := t.TempDir() + + // Create test file + testFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFile, []byte("test"), 0o644) + require.NoError(t, err) + + // Test probeEnt with different owner (use 9999 which is unlikely to be the actual owner) + err = probeEnt(testFile, 9999) + require.Error(t, err) + require.True(t, errors.Is(err, os.ErrPermission)) + }) +} diff --git a/internal/fsext/owner_windows.go b/internal/fsext/owner_windows.go index 107cda009b5fc152cba3200271c7145ff3227a39..41f9091c3e75e8f187984a8e1ddb7a7aa72c9dab 100644 --- a/internal/fsext/owner_windows.go +++ b/internal/fsext/owner_windows.go @@ -2,8 +2,14 @@ package fsext +import "os" + // Owner retrieves the user ID of the owner of the file or directory at the // specified path. func Owner(path string) (int, error) { + _, err := os.Stat(path) + if err != nil { + return 0, err + } return -1, nil } diff --git a/internal/fsext/parent.go b/internal/fsext/parent.go deleted file mode 100644 index bd3193610a79cbc80b5bb2c1d75be32a819f34f5..0000000000000000000000000000000000000000 --- a/internal/fsext/parent.go +++ /dev/null @@ -1,60 +0,0 @@ -package fsext - -import ( - "errors" - "os" - "path/filepath" - - "github.com/charmbracelet/crush/internal/home" -) - -// SearchParent searches for a target file or directory starting from dir -// and walking up the directory tree until found or root or home is reached. -// It also checks the ownership of directories to ensure that the search does -// not cross ownership boundaries. -// Returns the full path to the target if found, empty string and false otherwise. -// The search includes the starting directory itself. -func SearchParent(dir, target string) (string, bool) { - absDir, err := filepath.Abs(dir) - if err != nil { - return "", false - } - - path := filepath.Join(absDir, target) - if _, err := os.Stat(path); err == nil { - return path, true - } else if !errors.Is(err, os.ErrNotExist) { - return "", false - } - - previousParent := absDir - previousOwner, err := Owner(previousParent) - if err != nil { - return "", false - } - - for { - parent := filepath.Dir(previousParent) - if parent == previousParent || parent == home.Dir() { - return "", false - } - - parentOwner, err := Owner(parent) - if err != nil { - return "", false - } - if parentOwner != previousOwner { - return "", false - } - - path := filepath.Join(parent, target) - if _, err := os.Stat(path); err == nil { - return path, true - } else if !errors.Is(err, os.ErrNotExist) { - return "", false - } - - previousParent = parent - previousOwner = parentOwner - } -}