返回 DeepSeek-Reasonix
git_metadata_test.go
根目录 / internal / sandbox / git_metadata_test.go
1 //go:build !windows
2
3 package sandbox
4
5 import (
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "slices"
12 "strings"
13 "testing"
14 )
15
16 func requireGit(t *testing.T) {
17 t.Helper()
18 if _, err := exec.LookPath("git"); err != nil {
19 t.Skip("git is unavailable")
20 }
21 }
22
23 // gitIn runs host git with no global or system configuration.
24 func gitIn(t *testing.T, dir string, args ...string) {
25 t.Helper()
26 cmd := exec.Command("git", args...)
27 cmd.Dir = dir
28 cmd.Env = isolatedGitEnv()
29 if out, err := cmd.CombinedOutput(); err != nil {
30 t.Fatalf("git %v: %v: %s", args, err, out)
31 }
32 }
33
34 func isolatedGitEnv() []string {
35 return append(os.Environ(), "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1",
36 "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@example.com", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@example.com")
37 }
38
39 func realTempDir(t *testing.T) string {
40 t.Helper()
41 dir, err := filepath.EvalSymlinks(t.TempDir())
42 if err != nil {
43 t.Fatal(err)
44 }
45 return dir
46 }
47
48 func newRepo(t *testing.T, dir string) {
49 t.Helper()
50 if err := os.MkdirAll(dir, 0o755); err != nil {
51 t.Fatal(err)
52 }
53 gitIn(t, dir, "init", "-q", "-b", "main")
54 gitIn(t, dir, "commit", "-q", "--allow-empty", "-m", "initial")
55 }
56
57 func metadataPaths(roots, writable []string) (paths, pins []string) {
58 meta := gitMetadataWithin(roots, writable)
59 all := meta.Paths
60 for _, common := range meta.Commons {
61 all = append(all, gitGroupPaths(common, gitGroupMaxEntries)...)
62 }
63 for _, p := range all {
64 switch {
65 case p.Pin:
66 pins = append(pins, p.Path)
67 case p.Tree:
68 paths = append(paths, p.Path+string(filepath.Separator))
69 default:
70 paths = append(paths, p.Path)
71 }
72 }
73 return paths, pins
74 }
75
76 func requirePaths(t *testing.T, got []string, want ...string) {
77 t.Helper()
78 for _, w := range want {
79 if !slices.Contains(got, w) {
80 t.Fatalf("missing %s in %v", w, got)
81 }
82 }
83 }
84
85 func TestGitMetadataOfOrdinaryRepository(t *testing.T) {
86 requireGit(t)
87 ws := filepath.Join(realTempDir(t), "ws")
88 newRepo(t, ws)
89 git := filepath.Join(ws, ".git")
90 paths, pins := metadataPaths([]string{ws}, []string{ws})
91 requirePaths(t, paths, filepath.Join(git, "config"), filepath.Join(git, "config.worktree"),
92 filepath.Join(git, "commondir"), filepath.Join(git, "hooks")+string(filepath.Separator))
93 requirePaths(t, pins, ws, git)
94 for _, p := range paths {
95 if strings.HasPrefix(p, filepath.Join(git, "objects")) || strings.HasPrefix(p, filepath.Join(git, "refs")) || p == filepath.Join(git, "HEAD") {
96 t.Fatalf("ordinary git state must stay writable: %s", p)
97 }
98 }
99 }
100
101 func TestGitMetadataDiscoversFromSubdirectory(t *testing.T) {
102 requireGit(t)
103 ws := filepath.Join(realTempDir(t), "ws")
104 newRepo(t, ws)
105 sub := filepath.Join(ws, "pkg", "sub")
106 if err := os.MkdirAll(sub, 0o755); err != nil {
107 t.Fatal(err)
108 }
109 paths, _ := metadataPaths([]string{sub}, []string{ws})
110 requirePaths(t, paths, filepath.Join(ws, ".git", "config"))
111 if paths, _ := metadataPaths([]string{sub}, []string{sub}); len(paths) != 0 {
112 t.Fatalf("metadata outside every writable directory needs no rule: %v", paths)
113 }
114 }
115
116 func TestGitMetadataFollowsGitFileToSeparateGitDir(t *testing.T) {
117 requireGit(t)
118 root := realTempDir(t)
119 ws := filepath.Join(root, "ws")
120 store := filepath.Join(root, "store.git")
121 if err := os.MkdirAll(ws, 0o755); err != nil {
122 t.Fatal(err)
123 }
124 gitIn(t, ws, "init", "-q", "--separate-git-dir", store)
125 paths, pins := metadataPaths([]string{ws}, []string{root})
126 requirePaths(t, paths, filepath.Join(ws, ".git"), filepath.Join(store, "config"), filepath.Join(store, "hooks")+string(filepath.Separator))
127 requirePaths(t, pins, store)
128 }
129
130 // A `.git` file names whatever git will read. Pointing it at a decoy moves the
131 // protection with it, and the pointer itself is protected, so there is no
132 // second, unprotected config git would read instead.
133 func TestGitMetadataProtectsWhatThePointerNames(t *testing.T) {
134 root := realTempDir(t)
135 ws := filepath.Join(root, "ws")
136 decoy := filepath.Join(root, "decoy")
137 for _, d := range []string{ws, decoy} {
138 if err := os.MkdirAll(d, 0o755); err != nil {
139 t.Fatal(err)
140 }
141 }
142 if err := os.WriteFile(filepath.Join(ws, ".git"), []byte("gitdir: ../decoy\n"), 0o644); err != nil {
143 t.Fatal(err)
144 }
145 paths, _ := metadataPaths([]string{ws}, []string{root})
146 requirePaths(t, paths, filepath.Join(ws, ".git"), filepath.Join(decoy, "config"), filepath.Join(decoy, "hooks")+string(filepath.Separator))
147 }
148
149 func TestGitMetadataResolvesSymlinkedGitDir(t *testing.T) {
150 requireGit(t)
151 root := realTempDir(t)
152 real := filepath.Join(root, "real")
153 newRepo(t, real)
154 ws := filepath.Join(root, "ws")
155 if err := os.Mkdir(ws, 0o755); err != nil {
156 t.Fatal(err)
157 }
158 if err := os.Symlink(filepath.Join(real, ".git"), filepath.Join(ws, ".git")); err != nil {
159 t.Fatal(err)
160 }
161 paths, pins := metadataPaths([]string{ws}, []string{root})
162 requirePaths(t, paths, filepath.Join(real, ".git", "config"))
163 requirePaths(t, pins, filepath.Join(ws, ".git"))
164 }
165
166 func TestGitMetadataOfLinkedWorktreeAndSubmodule(t *testing.T) {
167 requireGit(t)
168 root := realTempDir(t)
169 main := filepath.Join(root, "main")
170 newRepo(t, main)
171 lib := filepath.Join(root, "lib")
172 newRepo(t, lib)
173 gitIn(t, main, "-c", "protocol.file.allow=always", "submodule", "add", "-q", lib, "vendor/lib")
174 linked := filepath.Join(root, "linked")
175 gitIn(t, main, "worktree", "add", "-q", "-b", "linked", linked)
176 common := filepath.Join(main, ".git")
177 wtDir := filepath.Join(common, "worktrees", "linked")
178
179 paths, _ := metadataPaths([]string{linked}, []string{root})
180 requirePaths(t, paths,
181 filepath.Join(linked, ".git"),
182 filepath.Join(wtDir, "commondir"), filepath.Join(wtDir, "config.worktree"),
183 filepath.Join(common, "config"), filepath.Join(common, "hooks")+string(filepath.Separator),
184 filepath.Join(common, "modules", "vendor", "lib", "config"),
185 filepath.Join(common, "modules", "vendor", "lib", "hooks")+string(filepath.Separator))
186
187 paths, _ = metadataPaths([]string{main}, []string{main})
188 requirePaths(t, paths, filepath.Join(wtDir, "config"), filepath.Join(wtDir, "config.worktree"))
189 }
190
191 func TestGitMetadataWithoutRepository(t *testing.T) {
192 ws := realTempDir(t)
193 if paths, pins := metadataPaths([]string{ws}, []string{ws}); len(paths)+len(pins) != 0 {
194 t.Fatalf("no repository, no rules: %v %v", paths, pins)
195 }
196 if got := GitMetadataPaths(Spec{Mode: "enforce", ReadOnly: true, WriteRoots: []string{ws}}); len(got) != 0 {
197 t.Fatalf("read-only spec needs no git rules: %v", got)
198 }
199 }
200
201 func TestGitPointerRejectsMalformedFiles(t *testing.T) {
202 dir := realTempDir(t)
203 for name, body := range map[string]string{
204 "noprefix": "../elsewhere\n",
205 "empty": "gitdir: \n",
206 "large": "gitdir: " + strings.Repeat("a", gitFileMaxBytes) + "\n",
207 } {
208 path := filepath.Join(dir, name)
209 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
210 t.Fatal(err)
211 }
212 if got, ok := readGitPointer(path, "gitdir: "); ok {
213 t.Fatalf("%s: accepted %q", name, got)
214 }
215 }
216 ws := filepath.Join(dir, "ws")
217 if err := os.Mkdir(ws, 0o755); err != nil {
218 t.Fatal(err)
219 }
220 if err := os.WriteFile(filepath.Join(ws, ".git"), []byte("not a pointer\n"), 0o644); err != nil {
221 t.Fatal(err)
222 }
223 paths, _ := metadataPaths([]string{ws}, []string{ws})
224 if !slices.Equal(paths, []string{filepath.Join(ws, ".git")}) {
225 t.Fatalf("an unreadable pointer still pins itself: %v", paths)
226 }
227 }
228
229 // Every symlink on the way from a `.git` pointer to its gitdir is pinned:
230 // swapping one would redirect git without touching a protected path.
231 func TestGitMetadataPinsSymlinkComponentsOfThePointer(t *testing.T) {
232 requireGit(t)
233 base := realTempDir(t)
234 root := filepath.Join(base, "ws")
235 code := filepath.Join(root, "code")
236 store := filepath.Join(root, "store")
237 if err := os.MkdirAll(code, 0o755); err != nil {
238 t.Fatal(err)
239 }
240 gitIn(t, code, "init", "-q", "--separate-git-dir", store)
241 if err := os.Symlink("hop", filepath.Join(root, "link")); err != nil {
242 t.Fatal(err)
243 }
244 if err := os.Symlink("store", filepath.Join(root, "hop")); err != nil {
245 t.Fatal(err)
246 }
247 if err := os.WriteFile(filepath.Join(code, ".git"), []byte("gitdir: ../link\n"), 0o644); err != nil {
248 t.Fatal(err)
249 }
250 paths, pins := metadataPaths([]string{code}, []string{root})
251 requirePaths(t, pins, filepath.Join(root, "link"), filepath.Join(root, "hop"), store)
252 requirePaths(t, paths, filepath.Join(store, "config"))
253 }
254
255 func TestGitGroupPathsCollapseWhenOverBudget(t *testing.T) {
256 requireGit(t)
257 ws := filepath.Join(realTempDir(t), "ws")
258 newRepo(t, ws)
259 common := filepath.Join(ws, ".git")
260 plant := func(n int) {
261 for i := range n {
262 m := filepath.Join(common, "modules", fmt.Sprintf("g%d", i%10), fmt.Sprintf("m%d", i))
263 if err := os.MkdirAll(m, 0o755); err != nil {
264 t.Fatal(err)
265 }
266 if err := os.WriteFile(filepath.Join(m, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
267 t.Fatal(err)
268 }
269 }
270 }
271 plant(3)
272 configs := func(paths []gitProtectedPath) (n int, tree bool) {
273 for _, p := range paths {
274 if filepath.Base(p.Path) == "config" {
275 n++
276 }
277 tree = tree || (p.Tree && p.Path == filepath.Join(common, "modules"))
278 }
279 return n, tree
280 }
281 if n, tree := configs(gitGroupPaths(common, gitGroupMaxEntries)); n != 3 || tree {
282 t.Fatalf("each submodule gitdir is protected one by one: %d configs, tree %v", n, tree)
283 }
284 plant(gitGroupMaxEntries + 1)
285 if n, tree := configs(gitGroupPaths(common, gitGroupMaxEntries)); n != gitGroupMaxEntries || !tree {
286 t.Fatalf("past the limit the group is one read-only tree: %d configs, tree %v", n, tree)
287 }
288 if g := gitGroupsOf(common, gitGroupMaxMounts); g.ModulesOver {
289 t.Fatal("the mount budget is larger than the rule budget")
290 }
291 }
292
293 // A symlinked group directory or gitdir entry is never followed: binding
294 // through it would protect, or expose, whatever it names.
295 func TestGitGroupsDoNotFollowSymlinks(t *testing.T) {
296 requireGit(t)
297 base := realTempDir(t)
298 ws := filepath.Join(base, "ws")
299 newRepo(t, ws)
300 src := filepath.Join(ws, "src")
301 if err := os.MkdirAll(filepath.Join(src, "m"), 0o755); err != nil {
302 t.Fatal(err)
303 }
304 if err := os.WriteFile(filepath.Join(src, "m", "HEAD"), []byte("x\n"), 0o644); err != nil {
305 t.Fatal(err)
306 }
307 common := filepath.Join(ws, ".git")
308 if err := os.Symlink(src, filepath.Join(common, "modules")); err != nil {
309 t.Fatal(err)
310 }
311 if err := os.MkdirAll(filepath.Join(common, "worktrees"), 0o755); err != nil {
312 t.Fatal(err)
313 }
314 if err := os.Symlink(src, filepath.Join(common, "worktrees", "planted")); err != nil {
315 t.Fatal(err)
316 }
317 for _, p := range gitGroupPaths(common, gitGroupMaxEntries) {
318 if strings.HasPrefix(p.Path, src) || strings.Contains(p.Path, "planted") || strings.HasPrefix(p.Path, filepath.Join(common, "modules")) {
319 t.Fatalf("followed a symlink: %v", p)
320 }
321 }
322 }
323
324 func TestCheckGitMetadataRefusesAHardLinkedConfig(t *testing.T) {
325 requireGit(t)
326 ws := filepath.Join(realTempDir(t), "ws")
327 newRepo(t, ws)
328 spec := Spec{Mode: "enforce", WriteRoots: []string{ws}}
329 if err := CheckGitMetadata(spec); err != nil {
330 t.Fatalf("an ordinary repository is not refused: %v", err)
331 }
332 if err := os.Link(filepath.Join(ws, ".git", "config"), filepath.Join(ws, "cfg")); err != nil {
333 t.Fatal(err)
334 }
335 err := CheckGitMetadata(spec)
336 if !errors.Is(err, ErrGitMetadataLinked) || !strings.Contains(err.Error(), filepath.Join(ws, ".git", "config")) {
337 t.Fatalf("a hard-linked config must be refused with its identity: %v", err)
338 }
339 if err := os.Remove(filepath.Join(ws, "cfg")); err != nil {
340 t.Fatal(err)
341 }
342 hook := filepath.Join(ws, ".git", "hooks", "post-merge")
343 if err := os.WriteFile(hook, []byte("#!/bin/sh\n"), 0o755); err != nil {
344 t.Fatal(err)
345 }
346 if err := os.Link(hook, filepath.Join(ws, "hook")); err != nil {
347 t.Fatal(err)
348 }
349 if err := CheckGitMetadata(spec); !errors.Is(err, ErrGitMetadataLinked) {
350 t.Fatalf("a hard-linked hook must be refused: %v", err)
351 }
352 }
353
353 lines GO