on main help sign in
Merge pull request #2885 from charmbracelet/harden-directory-bounds
3497b74Christian Rocha
internal/config/config.go+11−7
@@ -259,13 +259,17 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
259}
259}
260
260
261type Options struct {
261type Options struct {
262− ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
262+ ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
263− SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
263+ SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
264− TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
264+ TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
265− Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
265+ Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
266− DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
266+ DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
267− DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"`
267+ DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"`
268+ // DataDirectory is where Crush keeps per-project state such as
269+ // the SQLite database and workspace overrides. Relative paths are
270+ // resolved against the working directory; absolute paths are used
271+ // verbatim. After defaulting the stored value is always absolute.
268− DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
272+ DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data. Relative paths are resolved against the working directory; absolute paths are used as-is.,default=.crush,example=.crush"`
269 DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"`
273 DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"`
270 DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
274 DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
271 DisableDefaultProviders bool `json:"disable_default_providers,omitempty" jsonschema:"description=Ignore all default/embedded providers. When enabled\\, providers must be fully specified in the config file with base_url\\, models\\, and api_key - no merging with defaults occurs,default=false"`
275 DisableDefaultProviders bool `json:"disable_default_providers,omitempty" jsonschema:"description=Ignore all default/embedded providers. When enabled\\, providers must be fully specified in the config file with base_url\\, models\\, and api_key - no merging with defaults occurs,default=false"`
internal/config/load.go+50−3
@@ -424,7 +424,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
424 if dataDir != "" {
424 if dataDir != "" {
425 c.Options.DataDirectory = dataDir
425 c.Options.DataDirectory = dataDir
426 } else if c.Options.DataDirectory == "" {
426 } else if c.Options.DataDirectory == "" {
427− if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok {
427+ if path, ok := fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory); ok {
428 c.Options.DataDirectory = path
428 c.Options.DataDirectory = path
429 } else {
429 } else {
430 c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
430 c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
@@ -702,7 +702,12 @@ func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provid
702 return nil
702 return nil
703}
703}
704
704
705−// lookupConfigs searches config files recursively from CWD up to FS root
705+// lookupConfigs searches config files starting at cwd and walking up
706+// through the current project. The upward walk stops at the git
707+// working tree root when one can be detected, otherwise at cwd itself,
708+// so an unrelated crush.json placed above the project is never picked
709+// up. Global user-level config locations are always included
710+// regardless of the boundary.
706func lookupConfigs(cwd string) []string {
711func lookupConfigs(cwd string) []string {
707 // prepend default config paths
712 // prepend default config paths
708 configPaths := []string{
713 configPaths := []string{
@@ -712,7 +717,7 @@ func lookupConfigs(cwd string) []string {
712
717
713 configNames := []string{appName + ".json", "." + appName + ".json"}
718 configNames := []string{appName + ".json", "." + appName + ".json"}
714
719
715− foundConfigs, err := fsext.Lookup(cwd, configNames...)
720+ foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...)
716 if err != nil {
721 if err != nil {
717 // returns at least default configs
722 // returns at least default configs
718 return configPaths
723 return configPaths
@@ -873,6 +878,48 @@ func isInsideWorktree() bool {
873 return err == nil && strings.TrimSpace(string(bts)) == "true"
878 return err == nil && strings.TrimSpace(string(bts)) == "true"
874}
879}
875
880
881+// worktreeRoot returns the absolute path of the git working tree root for
882+// dir, or the empty string if dir is not inside a working tree (bare
883+// repositories, missing git binary, plain directories, or any other
884+// failure mode). Linked worktrees and submodules each report their own
885+// top-level, which is what callers want when bounding lookups.
886+func worktreeRoot(dir string) string {
887+ cmd := exec.CommandContext(
888+ context.Background(),
889+ "git", "rev-parse", "--show-toplevel",
890+ )
891+ cmd.Dir = dir
892+ out, err := cmd.Output()
893+ if err != nil {
894+ return ""
895+ }
896+ root := strings.TrimSpace(string(out))
897+ if root == "" {
898+ return ""
899+ }
900+ abs, err := filepath.Abs(root)
901+ if err != nil {
902+ return ""
903+ }
904+ return abs
905+}
906+
907+// projectBoundary returns the directory at which an upward configuration
908+// search rooted at dir should stop. It is the git working tree root when
909+// one can be detected, otherwise dir itself. Returning dir as a
910+// fallback keeps Crush from silently adopting state files placed above
911+// the current project.
912+func projectBoundary(dir string) string {
913+ if root := worktreeRoot(dir); root != "" {
914+ return root
915+ }
916+ abs, err := filepath.Abs(dir)
917+ if err != nil {
918+ return dir
919+ }
920+ return abs
921+}
922+
876// GlobalSkillsDirs returns the default directories for Agent Skills.
923// GlobalSkillsDirs returns the default directories for Agent Skills.
877// Skills in these directories are auto-discovered and their files can be read
924// Skills in these directories are auto-discovered and their files can be read
878// without permission prompts.
925// without permission prompts.
internal/config/load_test.go+185
@@ -4,6 +4,7 @@ import (
4 "io"
4 "io"
5 "log/slog"
5 "log/slog"
6 "os"
6 "os"
7+ "os/exec"
7 "path/filepath"
8 "path/filepath"
8 "testing"
9 "testing"
9
10
@@ -36,6 +37,98 @@ func TestConfig_LoadFromBytes(t *testing.T) {
36 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
37 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
37}
38}
38
39
40+func TestLookupConfigs_BoundedByProject(t *testing.T) {
41+ // Force GlobalConfig and GlobalConfigData to point at locations we
42+ // control so they can be present in the result without polluting
43+ // the developer's real config.
44+ globalDir := t.TempDir()
45+ t.Setenv("CRUSH_GLOBAL_CONFIG", globalDir)
46+ t.Setenv("CRUSH_GLOBAL_DATA", globalDir)
47+
48+ t.Run("does not pick up crush.json above non-git project", func(t *testing.T) {
49+ parent := t.TempDir()
50+
51+ // crush.json above the project must not be adopted.
52+ require.NoError(t, os.WriteFile(
53+ filepath.Join(parent, "crush.json"),
54+ []byte(`{}`),
55+ 0o644,
56+ ))
57+
58+ project := filepath.Join(parent, "project")
59+ require.NoError(t, os.Mkdir(project, 0o755))
60+
61+ got := lookupConfigs(project)
62+ for _, p := range got {
63+ require.NotEqual(t, filepath.Join(parent, "crush.json"), p)
64+ }
65+ })
66+
67+ t.Run("does not climb out of git worktree to find crush.json", func(t *testing.T) {
68+ if _, err := exec.LookPath("git"); err != nil {
69+ t.Skip("git not available")
70+ }
71+
72+ parent := t.TempDir()
73+
74+ require.NoError(t, os.WriteFile(
75+ filepath.Join(parent, "crush.json"),
76+ []byte(`{}`),
77+ 0o644,
78+ ))
79+
80+ worktree := filepath.Join(parent, "worktree")
81+ require.NoError(t, os.Mkdir(worktree, 0o755))
82+ gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
83+ gitInit.Dir = worktree
84+ require.NoError(t, gitInit.Run())
85+
86+ got := lookupConfigs(worktree)
87+ strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, "crush.json"))
88+ require.NoError(t, err)
89+ for _, p := range got {
90+ pEval, err := filepath.EvalSymlinks(p)
91+ if err != nil {
92+ continue
93+ }
94+ require.NotEqual(t, strayEval, pEval, "must not adopt parent crush.json")
95+ }
96+ })
97+
98+ t.Run("picks up crush.json inside the project", func(t *testing.T) {
99+ project := t.TempDir()
100+ local := filepath.Join(project, "crush.json")
101+ require.NoError(t, os.WriteFile(local, []byte(`{}`), 0o644))
102+
103+ got := lookupConfigs(project)
104+
105+ localEval, err := filepath.EvalSymlinks(local)
106+ require.NoError(t, err)
107+ var foundLocal bool
108+ for _, p := range got {
109+ pEval, err := filepath.EvalSymlinks(p)
110+ if err != nil {
111+ continue
112+ }
113+ if pEval == localEval {
114+ foundLocal = true
115+ break
116+ }
117+ }
118+ require.True(t, foundLocal, "expected project crush.json to be in lookup result: %v", got)
119+ })
120+
121+ t.Run("global config is always included regardless of boundary", func(t *testing.T) {
122+ project := t.TempDir()
123+
124+ got := lookupConfigs(project)
125+ // Global config and global data path are always prepended,
126+ // even when no project file exists.
127+ require.Contains(t, got, GlobalConfig())
128+ require.Contains(t, got, GlobalConfigData())
129+ })
130+}
131+
39func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
132func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
40 t.Parallel()
133 t.Parallel()
41
134
@@ -111,6 +204,98 @@ func TestConfig_setDefaults(t *testing.T) {
111
204
112 require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
205 require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
113 })
206 })
207+
208+ t.Run("preserves absolute configured data directory", func(t *testing.T) {
209+ // Use a platform-appropriate absolute path so the test runs
210+ // the same way on POSIX and Windows.
211+ absDir := filepath.Join(t.TempDir(), "data")
212+ cfg := &Config{Options: &Options{DataDirectory: absDir}}
213+
214+ cfg.setDefaults(filepath.Join(t.TempDir(), "worktree"), "")
215+
216+ require.Equal(t, absDir, cfg.Options.DataDirectory)
217+ })
218+
219+ t.Run("workspace merge re-entry keeps an absolute data directory", func(t *testing.T) {
220+ // Simulate the load and reload paths: defaults are applied
221+ // twice with the data directory potentially carried through
222+ // from an earlier merge as a relative string.
223+ workingDir := filepath.Join(t.TempDir(), "worktree")
224+ cfg := &Config{}
225+ cfg.setDefaults(workingDir, "")
226+
227+ // Workspace JSON sets data_directory to a relative value; the
228+ // merge replaces the struct, then setDefaults runs again.
229+ cfg.Options.DataDirectory = "./state"
230+ cfg.setDefaults(workingDir, "")
231+
232+ require.True(t, filepath.IsAbs(cfg.Options.DataDirectory),
233+ "data directory must remain absolute after re-merge, got %q",
234+ cfg.Options.DataDirectory)
235+ require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
236+ })
237+
238+ t.Run("does not adopt .crush from a parent project", func(t *testing.T) {
239+ parent := t.TempDir()
240+
241+ // .crush in the parent: it should not be reused by the child
242+ // because there is no git context joining them.
243+ require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
244+
245+ child := filepath.Join(parent, "child")
246+ require.NoError(t, os.Mkdir(child, 0o755))
247+
248+ cfg := &Config{}
249+ cfg.setDefaults(child, "")
250+
251+ require.Equal(t,
252+ filepath.Clean(filepath.Join(child, defaultDataDirectory)),
253+ filepath.Clean(cfg.Options.DataDirectory),
254+ )
255+ })
256+
257+ t.Run("does not climb out of git worktree to find .crush", func(t *testing.T) {
258+ if _, err := exec.LookPath("git"); err != nil {
259+ t.Skip("git not available")
260+ }
261+
262+ parent := t.TempDir()
263+
264+ // Stray .crush above the worktree root.
265+ require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
266+
267+ worktree := filepath.Join(parent, "worktree")
268+ require.NoError(t, os.Mkdir(worktree, 0o755))
269+
270+ sub := filepath.Join(worktree, "pkg")
271+ require.NoError(t, os.Mkdir(sub, 0o755))
272+
273+ // Make worktree a real git repo so the boundary detection
274+ // resolves to it, mirroring what happens with linked worktrees
275+ // in real usage.
276+ gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
277+ gitInit.Dir = worktree
278+ require.NoError(t, gitInit.Run())
279+
280+ cfg := &Config{}
281+ cfg.setDefaults(sub, "")
282+
283+ // Resolve symlinks because TempDir on macOS sits under /var
284+ // which is a symlink to /private/var. The data directory has
285+ // not been created yet, so resolve its parent and join.
286+ gotDir, gotName := filepath.Split(cfg.Options.DataDirectory)
287+ gotEvalDir, err := filepath.EvalSymlinks(filepath.Clean(gotDir))
288+ require.NoError(t, err)
289+ gotEval := filepath.Join(gotEvalDir, gotName)
290+
291+ strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, defaultDataDirectory))
292+ require.NoError(t, err)
293+ require.NotEqual(t, strayEval, gotEval, "must not adopt parent .crush")
294+
295+ subEval, err := filepath.EvalSymlinks(sub)
296+ require.NoError(t, err)
297+ require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval)
298+ })
114}
299}
115
300
116func TestConfig_configureProviders(t *testing.T) {
301func TestConfig_configureProviders(t *testing.T) {
internal/fsext/lookup.go+140
@@ -82,6 +82,81 @@ func LookupClosest(dir, target string) (string, bool) {
82 return found, err == nil && found != ""
82 return found, err == nil && found != ""
83}
83}
84
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.
96+func 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.
126+func 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+
85// traverseUp walks up from given directory up until filesystem root reached.
160// traverseUp walks up from given directory up until filesystem root reached.
86// It passes absolute path of current directory and staring directory owner ID
161// It passes absolute path of current directory and staring directory owner ID
87// to callback function. It is up to user to check ownership.
162// to callback function. It is up to user to check ownership.
@@ -116,6 +191,71 @@ func traverseUp(dir string, walkFn func(dir string, owner int) error) error {
116 }
191 }
117}
192}
118
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.
205+func 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.
252+func canonicalize(path string) string {
253+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
254+ return resolved
255+ }
256+ return filepath.Clean(path)
257+}
258+
119// probeEnt checks if entity at given path exists and belongs to given owner
259// probeEnt checks if entity at given path exists and belongs to given owner
120func probeEnt(fspath string, owner int) error {
260func probeEnt(fspath string, owner int) error {
121 _, err := os.Stat(fspath)
261 _, err := os.Stat(fspath)
internal/fsext/lookup_test.go+125
@@ -353,6 +353,131 @@ func TestLookup(t *testing.T) {
353 })
353 })
354}
354}
355
355
356+func TestLookupClosestBounded(t *testing.T) {
357+ t.Run("found in starting directory", func(t *testing.T) {
358+ testDir := t.TempDir()
359+
360+ targetFile := filepath.Join(testDir, "target.txt")
361+ require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
362+
363+ foundPath, found := LookupClosestBounded(testDir, testDir, "target.txt")
364+ require.True(t, found)
365+ require.Equal(t, targetFile, foundPath)
366+ })
367+
368+ t.Run("found at boundary directory", func(t *testing.T) {
369+ boundary := t.TempDir()
370+
371+ subDir := filepath.Join(boundary, "subdir")
372+ require.NoError(t, os.Mkdir(subDir, 0o755))
373+
374+ targetFile := filepath.Join(boundary, "target.txt")
375+ require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
376+
377+ foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt")
378+ require.True(t, found)
379+ require.Equal(t, targetFile, foundPath)
380+ })
381+
382+ t.Run("does not climb past boundary", func(t *testing.T) {
383+ parent := t.TempDir()
384+
385+ // Target lives above the boundary.
386+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644))
387+
388+ boundary := filepath.Join(parent, "project")
389+ require.NoError(t, os.Mkdir(boundary, 0o755))
390+
391+ subDir := filepath.Join(boundary, "subdir")
392+ require.NoError(t, os.Mkdir(subDir, 0o755))
393+
394+ foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt")
395+ require.False(t, found)
396+ require.Empty(t, foundPath)
397+ })
398+
399+ t.Run("empty boundary searches only starting directory", func(t *testing.T) {
400+ parent := t.TempDir()
401+
402+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644))
403+
404+ subDir := filepath.Join(parent, "subdir")
405+ require.NoError(t, os.Mkdir(subDir, 0o755))
406+
407+ foundPath, found := LookupClosestBounded(subDir, "", "target.txt")
408+ require.False(t, found)
409+ require.Empty(t, foundPath)
410+ })
411+
412+ t.Run("empty boundary still finds in starting directory", func(t *testing.T) {
413+ testDir := t.TempDir()
414+
415+ targetFile := filepath.Join(testDir, "target.txt")
416+ require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
417+
418+ foundPath, found := LookupClosestBounded(testDir, "", "target.txt")
419+ require.True(t, found)
420+ require.Equal(t, targetFile, foundPath)
421+ })
422+}
423+
424+func TestLookupBounded(t *testing.T) {
425+ t.Run("returns matches at and below boundary", func(t *testing.T) {
426+ boundary := t.TempDir()
427+
428+ subDir := filepath.Join(boundary, "subdir")
429+ require.NoError(t, os.Mkdir(subDir, 0o755))
430+
431+ atBoundary := filepath.Join(boundary, "target.txt")
432+ atSub := filepath.Join(subDir, "target.txt")
433+ require.NoError(t, os.WriteFile(atBoundary, []byte("a"), 0o644))
434+ require.NoError(t, os.WriteFile(atSub, []byte("b"), 0o644))
435+
436+ found, err := LookupBounded(subDir, boundary, "target.txt")
437+ require.NoError(t, err)
438+ require.Len(t, found, 2)
439+ require.Contains(t, found, atBoundary)
440+ require.Contains(t, found, atSub)
441+ })
442+
443+ t.Run("ignores matches above boundary", func(t *testing.T) {
444+ parent := t.TempDir()
445+
446+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644))
447+
448+ boundary := filepath.Join(parent, "project")
449+ require.NoError(t, os.Mkdir(boundary, 0o755))
450+
451+ subDir := filepath.Join(boundary, "subdir")
452+ require.NoError(t, os.Mkdir(subDir, 0o755))
453+
454+ // Target lives only above the boundary.
455+ found, err := LookupBounded(subDir, boundary, "target.txt")
456+ require.NoError(t, err)
457+ require.Empty(t, found)
458+ })
459+
460+ t.Run("empty boundary searches only starting directory", func(t *testing.T) {
461+ parent := t.TempDir()
462+
463+ require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644))
464+
465+ subDir := filepath.Join(parent, "subdir")
466+ require.NoError(t, os.Mkdir(subDir, 0o755))
467+
468+ found, err := LookupBounded(subDir, "", "target.txt")
469+ require.NoError(t, err)
470+ require.Empty(t, found)
471+ })
472+
473+ t.Run("no targets returns nil", func(t *testing.T) {
474+ dir := t.TempDir()
475+ found, err := LookupBounded(dir, dir)
476+ require.NoError(t, err)
477+ require.Empty(t, found)
478+ })
479+}
480+
356func TestProbeEnt(t *testing.T) {
481func TestProbeEnt(t *testing.T) {
357 t.Run("existing file with correct owner", func(t *testing.T) {
482 t.Run("existing file with correct owner", func(t *testing.T) {
358 tempDir := t.TempDir()
483 tempDir := t.TempDir()
internal/swagger/docs.go+1−1
@@ -2939,7 +2939,7 @@ const docTemplate = `{
2939 }
2939 }
2940 },
2940 },
2941 "data_directory": {
2941 "data_directory": {
2942− "description": "Relative to the cwd",
2942+ "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.",
2943 "type": "string"
2943 "type": "string"
2944 },
2944 },
2945 "debug": {
2945 "debug": {
internal/swagger/swagger.json+1−1
@@ -2932,7 +2932,7 @@
2932 }
2932 }
2933 },
2933 },
2934 "data_directory": {
2934 "data_directory": {
2935− "description": "Relative to the cwd",
2935+ "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.",
2936 "type": "string"
2936 "type": "string"
2937 },
2937 },
2938 "debug": {
2938 "debug": {
internal/swagger/swagger.yaml+5−1
@@ -287,7 +287,11 @@ definitions:
287 type: string
287 type: string
288 type: array
288 type: array
289 data_directory:
289 data_directory:
290− description: Relative to the cwd
290+ description: |-
291+ DataDirectory is where Crush keeps per-project state such as the SQLite
292+ database and workspace overrides. Relative paths are resolved against
293+ the working directory; absolute paths are used verbatim. After
294+ defaulting the stored value is always absolute.
291 type: string
295 type: string
292 debug:
296 debug:
293 type: boolean
297 type: boolean
schema.json+1−1
@@ -423,7 +423,7 @@
423 },
423 },
424 "data_directory": {
424 "data_directory": {
425 "type": "string",
425 "type": "string",
426− "description": "Directory for storing application data (relative to working directory)",
426+ "description": "Directory for storing application data. Relative paths are resolved against the working directory; absolute paths are used as-is.",
427 "default": ".crush",
427 "default": ".crush",
428 "examples": [
428 "examples": [
429 ".crush"
429 ".crush"