返回 DeepSeek-Reasonix
project_scope_compat_test.go
根目录 / internal / config / project_scope_compat_test.go
1 package config
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "runtime"
8 "slices"
9 "strings"
10 "testing"
11 )
12
13 func TestExistingProjectGrantsCoverDeclarationsAndDoNotResurrect(t *testing.T) {
14 out := t.TempDir()
15 rule := "Bash=echo exact $(value)"
16 _, root := loadScoped(t, "", "[permissions]\nallow = "+renderStringArray([]string{rule})+"\n[sandbox]\nallow_write = ["+tomlQuote(filepath.Join(out, "child"))+"]\n")
17 store := NewProjectGrantStore(ReasonixHomeDir())
18 if err := store.Update(root, func(g ProjectGrant) (ProjectGrant, error) {
19 g.Allow = []string{rule}
20 g.AllowWrite = []string{out}
21 return g, nil
22 }); err != nil {
23 t.Fatal(err)
24 }
25 cfg, err := LoadForRootReadOnly(root)
26 if err != nil {
27 t.Fatal(err)
28 }
29 if len(cfg.IgnoredProjectSettings()) != 0 || !slices.Equal(cfg.Permissions.Allow, []string{rule}) || !slices.Equal(cfg.Sandbox.AllowWrite, []string{out}) {
30 t.Fatalf("effective=%+v diagnostics=%+v", cfg.Sandbox, cfg.IgnoredProjectSettings())
31 }
32 other := t.TempDir()
33 otherCfg, err := LoadForRootReadOnly(other)
34 if err != nil || len(otherCfg.Permissions.Allow) != 0 {
35 t.Fatal("grant crossed workspace boundary", err)
36 }
37 if err := store.Update(root, func(g ProjectGrant) (ProjectGrant, error) { return ProjectGrant{}, nil }); err != nil {
38 t.Fatal(err)
39 }
40 cfg, err = LoadForRootReadOnly(root)
41 if err != nil || len(cfg.Permissions.Allow) != 0 || len(cfg.Sandbox.AllowWrite) != 0 || len(cfg.IgnoredProjectSettings()) != 2 {
42 t.Fatalf("revoked grant restored: %v %+v", err, cfg.IgnoredProjectSettings())
43 }
44 }
45
46 func TestEquivalentProjectRootUsesExistingUserValue(t *testing.T) {
47 parent := t.TempDir()
48 root := filepath.Join(parent, "project")
49 if err := os.Mkdir(root, 0700); err != nil {
50 t.Fatal(err)
51 }
52 home := t.TempDir()
53 t.Setenv("REASONIX_HOME", home)
54 userPath := filepath.Clean(parent)
55 projectPath := parent + string(filepath.Separator) + "."
56 if runtime.GOOS == "windows" {
57 projectPath = filepath.ToSlash(parent)
58 }
59 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[sandbox]\nworkspace_root = "+tomlQuote(userPath)+"\n"), 0600); err != nil {
60 t.Fatal(err)
61 }
62 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[sandbox]\nworkspace_root = "+tomlQuote(projectPath)+"\n"), 0600); err != nil {
63 t.Fatal(err)
64 }
65 cfg, err := LoadForRootReadOnly(root)
66 if err != nil {
67 t.Fatal(err)
68 }
69 if len(cfg.IgnoredProjectSettings()) != 0 || cfg.Sandbox.WorkspaceRoot != userPath {
70 t.Fatalf("root=%q diagnostics=%+v", cfg.Sandbox.WorkspaceRoot, cfg.IgnoredProjectSettings())
71 }
72 }
73
74 func TestProjectPathIdentityAndBoundary(t *testing.T) {
75 parent := t.TempDir()
76 root, sibling := filepath.Join(parent, "repo"), filepath.Join(parent, "repo-other")
77 for _, p := range []string{root, sibling} {
78 if err := os.Mkdir(p, 0700); err != nil {
79 t.Fatal(err)
80 }
81 }
82 c := Default()
83 if c.authorizedPath(root, []string{root}, sibling) {
84 t.Fatal("string prefix granted sibling")
85 }
86 if !c.authorizedPath(root, []string{root}, filepath.Join(root, "missing", "child")) {
87 t.Fatal("missing tail lost its authorized ancestor")
88 }
89 link := filepath.Join(root, "link")
90 if err := os.Symlink(sibling, link); err != nil {
91 t.Skipf("symlink unavailable: %v", err)
92 }
93 if c.authorizedPath(root, []string{root}, filepath.Join(link, "new")) {
94 t.Fatal("external link granted by project root")
95 }
96 if !c.authorizedPath(root, []string{sibling}, filepath.Join(link, "new")) {
97 t.Fatal("existing external grant not recognized")
98 }
99 upper := filepath.Join(parent, "REPO")
100 if err := os.Mkdir(upper, 0700); err == nil {
101 if c.equivalentConfigPath(root, root, upper) {
102 t.Fatal("distinct case-sensitive directories conflated")
103 }
104 } else if os.IsExist(err) && !c.equivalentConfigPath(root, root, upper) {
105 t.Fatal("case-insensitive directory identity lost")
106 }
107 }
108
109 func TestProjectPermissionCoverageRetainsRuleSemantics(t *testing.T) {
110 for _, tc := range []struct {
111 name, global, project string
112 pending bool
113 }{
114 {"exact", "Bash=echo one", "Bash=echo one", false},
115 {"glob", "Bash(go test:*)", "Bash=go test ./...", false},
116 {"tool", "Read", "Read(src/**)", false},
117 {"no_broadening", "Bash=echo one", "Bash", true},
118 {"literal_star", "Bash=echo *", "Bash=echo secret", true},
119 } {
120 t.Run(tc.name, func(t *testing.T) {
121 cfg, _ := loadScoped(t, "[permissions]\nmode='ask'\nallow="+renderStringArray([]string{tc.global})+"\ndeny=['Bash(rm:*)']\nask=['Write']\n", "[permissions]\nallow="+renderStringArray([]string{tc.project})+"\n")
122 if len(cfg.IgnoredProjectSettings()) > 0 != tc.pending {
123 t.Fatalf("diagnostics=%+v", cfg.IgnoredProjectSettings())
124 }
125 if !slices.Equal(cfg.Permissions.Allow, []string{tc.global}) || !slices.Contains(cfg.Permissions.Ask, "Write") || !slices.Contains(cfg.Permissions.Deny, "Bash(rm:*)") || cfg.Permissions.Mode != "ask" {
126 t.Fatalf("permission semantics changed: %+v", cfg.Permissions)
127 }
128 })
129 }
130 }
131
132 func TestExistingWorkspaceRootCoversExternalDeclaration(t *testing.T) {
133 root := t.TempDir()
134 cfg, _ := loadScoped(t, "[sandbox]\nworkspace_root="+tomlQuote(root)+"\n", "[sandbox]\nallow_write=["+tomlQuote(filepath.Join(root, "child"))+"]\n")
135 if len(cfg.IgnoredProjectSettings()) != 0 || len(cfg.Sandbox.AllowWrite) != 0 {
136 t.Fatalf("existing root not recognized: %+v", cfg.IgnoredProjectSettings())
137 }
138 // Declaring an unapproved workspace root must not bootstrap authorization.
139 cfg, _ = loadScoped(t, "", "[sandbox]\nworkspace_root="+tomlQuote(root)+"\nallow_write=["+tomlQuote(filepath.Join(root, "child"))+"]\n")
140 if len(cfg.IgnoredProjectSettings()) != 2 || len(cfg.Sandbox.AllowWrite) != 0 {
141 t.Fatal("project root granted its own write declaration")
142 }
143 }
144 func TestLegacyProjectDeclarationsAreNotGrantsOrLoadFailures(t *testing.T) {
145 rules := make([]string, 39)
146 for i := range rules {
147 rules[i] = fmt.Sprintf("Bash=echo synthetic-command-%d", i)
148 }
149 project := "# preserved comment\n[model_roles]\nanswerer = 'unchanged'\n[permissions]\nallow = " + renderStringArray(rules) + "\n"
150 cfg, root := loadScoped(t, "[permissions]\nmode='ask'\ndeny=['Bash(rm:*)']\n", project)
151 file := filepath.Join(root, "reasonix.toml")
152 before, _ := os.Stat(file)
153 if cfg.HasLoadWarnings() || len(cfg.Permissions.Allow) != 0 || len(cfg.IgnoredProjectSettings()) != 39 {
154 t.Fatalf("warnings=%v permissions=%+v ignored=%v", cfg.LoadWarnings(), cfg.Permissions, cfg.IgnoredProjectSettings())
155 }
156 for range 2 {
157 if _, err := LoadForRootReadOnly(root); err != nil {
158 t.Fatal(err)
159 }
160 }
161 after, _ := os.Stat(file)
162 content, _ := os.ReadFile(file)
163 if string(content) != project || !before.ModTime().Equal(after.ModTime()) {
164 t.Fatal("read-only loading rewrote project file")
165 }
166 }
167 func TestCorruptProjectGrantsRemainLoadWarnings(t *testing.T) {
168 _, root := loadScoped(t, "", "")
169 path := NewProjectGrantStore(ReasonixHomeDir()).Path()
170 if err := os.WriteFile(path, []byte("{broken"), 0600); err != nil {
171 t.Fatal(err)
172 }
173 cfg, err := LoadForRootReadOnly(root)
174 if err != nil {
175 t.Fatal(err)
176 }
177 if !cfg.HasLoadWarnings() || !strings.Contains(strings.Join(cfg.LoadWarnings(), "\n"), path) {
178 t.Fatalf("warnings=%v", cfg.LoadWarnings())
179 }
180 }
181 func TestProjectUnknownPresetWarningKeepsItsOrigin(t *testing.T) {
182 cfg, root := loadScoped(t, "[desktop]\ndefault_tool_approval_mode='workspace-write'\n", "[desktop]\ndefault_tool_approval_mode='unknown-project-preset'\n")
183 warnings := strings.Join(cfg.LoadWarnings(), "\n")
184 if !strings.Contains(warnings, "project config sets desktop.default_tool_approval_mode") || !strings.Contains(warnings, "unknown-project-preset") || strings.Contains(warnings, filepath.Join(ReasonixHomeDir(), "config.toml")) {
185 t.Fatalf("root=%s warnings=%s", root, warnings)
186 }
187 }
188
188 lines GO