返回 DeepSeek-Reasonix
git_metadata_sandbox_test.go
根目录 / internal / sandbox / git_metadata_sandbox_test.go
1 //go:build !windows
2
3 package sandbox
4
5 import (
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "testing"
13 "time"
14 )
15
16 func requireConfinedGit(t *testing.T) {
17 t.Helper()
18 if !Available() {
19 t.Skip("no usable OS sandbox backend")
20 }
21 requireGit(t)
22 }
23
24 // runConfined runs command under the real OS sandbox for spec, in dir.
25 func runConfined(t *testing.T, spec Spec, dir, command string) (string, error) {
26 t.Helper()
27 argv, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "/bin/bash"}, command)
28 if !wrapped {
29 t.Fatal("expected a sandbox-wrapped command")
30 }
31 cmd := exec.Command(argv[0], argv[1:]...)
32 cmd.Dir = dir
33 cmd.Env = isolatedGitEnv()
34 out, err := cmd.CombinedOutput()
35 return string(out), err
36 }
37
38 func snapshotFiles(t *testing.T, paths ...string) map[string]string {
39 t.Helper()
40 out := map[string]string{}
41 for _, p := range paths {
42 data, err := os.ReadFile(p)
43 if err != nil {
44 out[p] = "<absent>"
45 continue
46 }
47 out[p] = string(data)
48 }
49 return out
50 }
51
52 func TestSandboxDeniesGitMetadataWrites(t *testing.T) {
53 requireConfinedGit(t)
54 root := realTempDir(t)
55 ws := filepath.Join(root, "ws")
56 newRepo(t, ws)
57 lib := filepath.Join(root, "lib")
58 newRepo(t, lib)
59 gitIn(t, ws, "-c", "protocol.file.allow=always", "submodule", "add", "-q", lib, "vendor/lib")
60 gitIn(t, ws, "commit", "-q", "-m", "submodule")
61 gitIn(t, ws, "worktree", "add", "-q", "-b", "linked", filepath.Join(ws, ".wt", "linked"))
62 git := filepath.Join(ws, ".git")
63 module := filepath.Join(git, "modules", "vendor", "lib")
64 wtMeta := filepath.Join(git, "worktrees", "linked")
65 spec := Spec{Mode: "enforce", WriteRoots: []string{ws}}
66
67 watched := []string{
68 filepath.Join(git, "config"), filepath.Join(git, "config.worktree"), filepath.Join(git, "hooks", "pre-commit"),
69 filepath.Join(wtMeta, "config"), filepath.Join(wtMeta, "config.worktree"),
70 filepath.Join(module, "config"), filepath.Join(module, "hooks", "post-checkout"), filepath.Join(ws, "vendor", "lib", ".git"),
71 }
72 before := snapshotFiles(t, watched...)
73 commands := []string{
74 "git config core.fsmonitor /bin/sh",
75 "printf '[core]\\n\\tfsmonitor = /bin/sh\\n' >> .git/config",
76 "printf '#!/bin/sh\\n' > .git/hooks/pre-commit",
77 "mv .git/hooks .git/hooks.moved",
78 "cp .git/config /tmp/x-$$ && mv -f /tmp/x-$$ .git/config",
79 "ln -s /tmp .git/hooks2 && mv -f .git/hooks2 .git/hooks",
80 "printf '[core]\\n' >> .git/modules/vendor/lib/config",
81 "printf '#!/bin/sh\\n' > .git/modules/vendor/lib/hooks/post-checkout",
82 "mv .git .git.moved",
83 "rm -rf .git",
84 }
85 // Seatbelt matches paths, so it also refuses creating an absent file;
86 // bubblewrap can only mount over a path that exists.
87 if runtime.GOOS == "darwin" {
88 commands = append([]string{
89 "printf '[core]\\n\\tfsmonitor = /bin/sh\\n' > .git/config.worktree",
90 "printf '[core]\\n' > .git/worktrees/linked/config",
91 "printf '[core]\\n' > .git/worktrees/linked/config.worktree",
92 }, commands...)
93 }
94 for _, command := range commands {
95 if out, err := runConfined(t, spec, ws, command); err == nil {
96 t.Errorf("%q must be denied, got success: %s", command, out)
97 }
98 }
99 if after := snapshotFiles(t, watched...); !mapsEqual(before, after) {
100 t.Fatalf("protected git metadata changed:\nbefore %v\nafter %v", before, after)
101 }
102 if info, err := os.Lstat(git); err != nil || !info.IsDir() {
103 t.Fatalf("the .git entry must survive rm -rf and mv: %v", err)
104 }
105 }
106
107 func TestSandboxDeniesRetargetingGitPointers(t *testing.T) {
108 requireConfinedGit(t)
109 root := realTempDir(t)
110 main := filepath.Join(root, "main")
111 newRepo(t, main)
112 linked := filepath.Join(root, "linked")
113 gitIn(t, main, "worktree", "add", "-q", "-b", "linked", linked)
114 sep := filepath.Join(root, "sep")
115 store := filepath.Join(root, "store.git")
116 if err := os.Mkdir(sep, 0o755); err != nil {
117 t.Fatal(err)
118 }
119 gitIn(t, sep, "init", "-q", "--separate-git-dir", store)
120 spec := Spec{Mode: "enforce", WriteRoots: []string{linked, sep, root}}
121
122 for _, tc := range []struct{ dir, command string }{
123 {linked, "printf 'gitdir: %s\\n' " + root + "/evil > .git"},
124 {linked, "rm .git"},
125 {linked, "printf '[core]\\n' >> " + filepath.Join(main, ".git", "config")},
126 {linked, "printf '/tmp\\n' > " + filepath.Join(main, ".git", "worktrees", "linked", "commondir")},
127 {sep, "printf 'gitdir: /tmp\\n' > .git"},
128 {sep, "printf '[core]\\n' >> " + filepath.Join(store, "config")},
129 {sep, "mv " + store + " " + store + ".moved && mkdir -p " + store},
130 {sep, "printf '#!/bin/sh\\n' > " + filepath.Join(store, "hooks", "pre-commit")},
131 } {
132 if out, err := runConfined(t, spec, tc.dir, tc.command); err == nil {
133 t.Errorf("in %s, %q must be denied, got success: %s", tc.dir, tc.command, out)
134 }
135 }
136 if out, err := runConfined(t, spec, linked, "git status --porcelain && git -C "+sep+" status --porcelain"); err != nil {
137 t.Fatalf("repositories must still resolve after the denied writes: %v: %s", err, out)
138 }
139 }
140
141 // Ordinary git work writes objects, refs, index, logs and lock files under
142 // .git, never its config or hooks, so it keeps working confined.
143 func TestSandboxKeepsOrdinaryGitWorking(t *testing.T) {
144 requireConfinedGit(t)
145 ws := filepath.Join(realTempDir(t), "ws")
146 newRepo(t, ws)
147 spec := Spec{Mode: "enforce", WriteRoots: []string{ws}}
148 for _, command := range []string{
149 "echo one > a.txt && git add a.txt && git commit -q -m one",
150 "git branch topic && git checkout -q -b feature",
151 "echo two >> a.txt && git commit -q -am two",
152 "git checkout -q main && git merge -q --no-edit feature",
153 "git checkout -q topic && echo t > t.txt && git add t.txt && git commit -q -m t && git rebase -q main",
154 "echo wip >> a.txt && git stash -q && git stash pop -q && git checkout -q -- a.txt",
155 "git tag -a v1 -m v1 && git tag light",
156 "git branch -D feature",
157 "git worktree add -q -b side .wt/side && git worktree remove .wt/side",
158 "git cherry-pick --no-edit main~1 || git cherry-pick --abort",
159 "git gc -q",
160 "mkdir fresh && cd fresh && git init -q && echo f > f && git add f && git commit -q -m fresh",
161 "touch . && chmod 755 . && touch .git",
162 } {
163 if out, err := runConfined(t, spec, ws, command); err != nil {
164 t.Fatalf("%q must keep working confined: %v: %s", command, err, out)
165 }
166 }
167 }
168
169 // These agent git operations write repository config or hooks and so fail
170 // confined. The error names the protected path, which is what lets the host
171 // attribute the denial.
172 func TestSandboxGitOperationsThatWriteMetadata(t *testing.T) {
173 requireConfinedGit(t)
174 ws := filepath.Join(realTempDir(t), "ws")
175 newRepo(t, ws)
176 gitIn(t, ws, "branch", "old")
177 gitIn(t, ws, "branch", "topic")
178 lib := filepath.Join(filepath.Dir(ws), "lib")
179 newRepo(t, lib)
180 gitIn(t, ws, "-c", "protocol.file.allow=always", "submodule", "add", "-q", lib, "vendor/lib")
181 gitIn(t, ws, "commit", "-q", "-m", "submodule")
182 gitIn(t, ws, "submodule", "deinit", "-q", "--all")
183 if err := os.RemoveAll(filepath.Join(ws, ".git", "modules")); err != nil {
184 t.Fatal(err)
185 }
186 spec := Spec{Mode: "enforce", WriteRoots: []string{ws}}
187 for _, command := range []string{
188 "git config user.name Someone",
189 "git config --unset core.bare",
190 "git remote add origin https://example.invalid/repo.git",
191 "git branch -m old renamed",
192 "git config core.hooksPath .githooks",
193 "printf '#!/bin/sh\\n' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit",
194 "git -c protocol.file.allow=always submodule update --init",
195 } {
196 out, err := runConfined(t, spec, ws, command)
197 if err == nil {
198 t.Errorf("%q must fail confined: %s", command, out)
199 continue
200 }
201 if !strings.Contains(out, ".git/config") && !strings.Contains(out, ".git/hooks/") {
202 t.Errorf("%q failure must name the protected path: %s", command, out)
203 }
204 }
205 // git reports the refused write but exits 0 and claims the upstream is set.
206 out, err := runConfined(t, spec, ws, "git branch --set-upstream-to=main topic")
207 if err != nil || !strings.Contains(out, ".git/config") {
208 t.Fatalf("set-upstream-to: %v: %s", err, out)
209 }
210 if out, err := runConfined(t, spec, ws, "git config branch.topic.remote"); err == nil {
211 t.Fatalf("upstream must not have been recorded: %s", out)
212 }
213 }
214
215 func mapsEqual(a, b map[string]string) bool {
216 if len(a) != len(b) {
217 return false
218 }
219 for k, v := range a {
220 if b[k] != v {
221 return false
222 }
223 }
224 return true
225 }
226
227 // Submodule gitdirs are covered by pattern on macOS, so one created or moved
228 // after the rules were written is covered too.
229 func TestSeatbeltCoversSubmoduleGitDirsByPattern(t *testing.T) {
230 requireConfinedGit(t)
231 if runtime.GOOS != "darwin" {
232 t.Skip("patterns are the Seatbelt backend's")
233 }
234 ws := filepath.Join(realTempDir(t), "ws")
235 newRepo(t, ws)
236 if err := os.MkdirAll(filepath.Join(ws, ".git", "modules", "old", "hooks"), 0o755); err != nil {
237 t.Fatal(err)
238 }
239 if err := os.WriteFile(filepath.Join(ws, ".git", "modules", "old", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
240 t.Fatal(err)
241 }
242 spec := Spec{Mode: "enforce", WriteRoots: []string{ws}}
243 for _, command := range []string{
244 "mkdir -p .git/modules/new/deeper && printf '[core]\\n' > .git/modules/new/deeper/config",
245 "mkdir -p .git/modules/new && printf '#!/bin/sh\\n' > .git/modules/new/hooks",
246 "printf '#!/bin/sh\\n' > .git/modules/old/hooks/post-checkout",
247 "mv .git/modules/old .git/modules/moved",
248 "ln -s " + ws + " .git/modules/planted",
249 "printf '[core]\\n' > .git/worktrees/x/config.worktree || (mkdir -p .git/worktrees/x && printf '[core]\\n' > .git/worktrees/x/config.worktree)",
250 } {
251 if out, err := runConfined(t, spec, ws, command); err == nil {
252 t.Errorf("%q must be denied, got success: %s", command, out)
253 }
254 }
255 if out, err := runConfined(t, spec, ws, "mkdir -p .git/modules/new/objects && echo x > .git/modules/new/objects/o && rm .git/modules/new/objects/o"); err != nil {
256 t.Fatalf("other submodule gitdir contents stay writable: %v: %s", err, out)
257 }
258 }
259
260 // The reviewer's case: a `.git` file reaching its gitdir through a symlink that
261 // lives in the workspace. Swapping the symlink must be refused.
262 func TestSeatbeltDeniesSwappingASymlinkOnThePointerPath(t *testing.T) {
263 requireConfinedGit(t)
264 if runtime.GOOS != "darwin" {
265 t.Skip("a mount cannot pin a symlink; the host pins GIT_DIR instead")
266 }
267 base := realTempDir(t)
268 root := filepath.Join(base, "ws")
269 code := filepath.Join(root, "code")
270 store := filepath.Join(root, "store")
271 if err := os.MkdirAll(code, 0o755); err != nil {
272 t.Fatal(err)
273 }
274 gitIn(t, code, "init", "-q", "-b", "main", "--separate-git-dir", store)
275 if err := os.Symlink("store", filepath.Join(root, "link")); err != nil {
276 t.Fatal(err)
277 }
278 if err := os.WriteFile(filepath.Join(code, ".git"), []byte("gitdir: ../link\n"), 0o644); err != nil {
279 t.Fatal(err)
280 }
281 spec := Spec{Mode: "enforce", WriteRoots: []string{code, root}}
282 out, err := runConfined(t, spec, code, "cd .. && mkdir evil && cp -R store/. evil/ && printf '[probe]\\n\\tx = 1\\n' >> evil/config && rm link && ln -s evil link")
283 if err == nil {
284 t.Fatalf("swapping the pointer's symlink must be denied: %s", out)
285 }
286 if target, err := os.Readlink(filepath.Join(root, "link")); err != nil || target != "store" {
287 t.Fatalf("link changed: %q %v", target, err)
288 }
289 }
290
291 // Rules must not grow with the number of submodule gitdirs: a repository, or a
292 // confined command planting HEAD files, would otherwise overflow the argument
293 // list and break every command.
294 func TestSandboxRuleSizeIsBoundedBySubmoduleCount(t *testing.T) {
295 requireConfinedGit(t)
296 size := func(ws string) int {
297 argv, _ := Command(Spec{Mode: "enforce", WriteRoots: []string{ws}}, Shell{Kind: ShellBash, Path: "/bin/bash"}, "true")
298 n := 0
299 for _, a := range argv {
300 n += len(a)
301 }
302 return n
303 }
304 ws := filepath.Join(realTempDir(t), "ws")
305 newRepo(t, ws)
306 plant := func(from, to int) {
307 for i := from; i < to; i++ {
308 m := filepath.Join(ws, ".git", "modules", fmt.Sprintf("g%d", i%20), fmt.Sprintf("m%d", i))
309 if err := os.MkdirAll(filepath.Join(m, "hooks"), 0o755); err != nil {
310 t.Fatal(err)
311 }
312 if err := os.WriteFile(filepath.Join(m, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
313 t.Fatal(err)
314 }
315 if err := os.WriteFile(filepath.Join(m, "config"), nil, 0o644); err != nil {
316 t.Fatal(err)
317 }
318 }
319 }
320 plant(0, 200)
321 atLimit := size(ws)
322 plant(200, 2000)
323 if after := size(ws); after > atLimit || after > 256<<10 {
324 t.Fatalf("sandbox arguments must stop growing past the limit: %d bytes at 200 gitdirs, %d at 2000", atLimit, after)
325 }
326 start := time.Now()
327 if out, err := runConfined(t, Spec{Mode: "enforce", WriteRoots: []string{ws}}, ws, "echo ok > f && git add f && git commit -q -m f"); err != nil {
328 t.Fatalf("commands keep running: %v: %s", err, out)
329 }
330 t.Logf("confined git commit with 2000 submodule gitdirs took %v", time.Since(start))
331 if out, err := runConfined(t, Spec{Mode: "enforce", WriteRoots: []string{ws}}, ws, "printf '[core]\\n' >> .git/modules/g1/m1/config"); err == nil {
332 t.Fatalf("a planted submodule config stays protected: %s", out)
333 }
334 }
335
336 // The host adds its own linked worktrees under the workspace's .git/worktrees.
337 // Rewriting such an entry's commondir would point the host's git at a config
338 // the sandbox wrote, so existing entries' commondir stays protected.
339 func TestSandboxProtectsHostLinkedWorktreeCommondir(t *testing.T) {
340 requireConfinedGit(t)
341 base := realTempDir(t)
342 ws := filepath.Join(base, "ws")
343 newRepo(t, ws)
344 for i, linked := range []string{filepath.Join(base, "managed", "cand"), filepath.Join(ws, ".reasonix", "wt", "cand2")} {
345 gitIn(t, ws, "worktree", "add", "-q", "--detach", linked)
346 id := filepath.Base(linked)
347 evil := filepath.Join(ws, fmt.Sprintf("evil%d", i))
348 command := "mkdir -p " + evil + " && cp -R .git/. " + evil + "/ && printf '[probe]\\n\\tx = 1\\n' >> " + evil + "/config && printf '" + evil + "\\n' > .git/worktrees/" + id + "/commondir"
349 if out, err := runConfined(t, Spec{Mode: "enforce", WriteRoots: []string{ws}}, ws, command); err == nil {
350 t.Fatalf("rewriting %s's commondir must be denied: %s", id, out)
351 }
352 cmd := exec.Command("git", "-C", linked, "config", "--get", "probe.x")
353 cmd.Env = isolatedGitEnv()
354 if out, _ := cmd.Output(); strings.TrimSpace(string(out)) != "" {
355 t.Fatalf("host git in %s reads a sandbox-written config", linked)
356 }
357 }
358 if out, err := runConfined(t, Spec{Mode: "enforce", WriteRoots: []string{ws}}, ws, "git worktree add -q -b fresh .wt/fresh && git worktree remove .wt/fresh"); err != nil {
359 t.Fatalf("new worktrees stay creatable: %v: %s", err, out)
360 }
361 }
362
363 // Refs and reflogs inside a submodule gitdir are not config: a branch or tag
364 // named config or hooks is ordinary git work.
365 func TestSandboxLeavesSubmoduleRefsNamedLikeMetadataWritable(t *testing.T) {
366 requireConfinedGit(t)
367 base := realTempDir(t)
368 ws := filepath.Join(base, "ws")
369 newRepo(t, ws)
370 lib := filepath.Join(base, "lib")
371 newRepo(t, lib)
372 gitIn(t, ws, "-c", "protocol.file.allow=always", "submodule", "add", "-q", lib, "vendor/lib")
373 gitIn(t, ws, "commit", "-q", "-m", "submodule")
374 spec := Spec{Mode: "enforce", WriteRoots: []string{ws}}
375 for _, command := range []string{
376 "git -C vendor/lib branch config",
377 "git -C vendor/lib branch hooks",
378 "git -C vendor/lib branch feature/hooks/x",
379 "git -C vendor/lib tag config",
380 "git -C vendor/lib branch feature/y && git -C vendor/lib branch -D feature/y",
381 "git -C vendor/lib checkout -q -b topic && git -C vendor/lib commit -q --allow-empty -m t && git -C vendor/lib checkout -q -",
382 "git -C vendor/lib gc -q --prune=now",
383 } {
384 if out, err := runConfined(t, spec, ws, command); err != nil {
385 t.Errorf("%q must keep working confined: %v: %s", command, err, out)
386 }
387 }
388 if out, err := runConfined(t, spec, ws, "printf '[core]\\n' >> .git/modules/vendor/lib/config"); err == nil {
389 t.Fatalf("the submodule config stays protected: %s", out)
390 }
391 }
392
392 lines GO