| 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 |