| 1 | package gitcmd |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "runtime" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | // Host git never runs a program named by the repository's own configuration. |
| 16 | // Each probe below runs the payload under stock git; each asserts the marker |
| 17 | // stays absent and the output still reports the change. |
| 18 | |
| 19 | type repoFixture struct { |
| 20 | t *testing.T |
| 21 | dir string |
| 22 | marker string |
| 23 | ctx context.Context |
| 24 | } |
| 25 | |
| 26 | func requirePOSIXGit(t *testing.T) { |
| 27 | t.Helper() |
| 28 | if runtime.GOOS == "windows" { |
| 29 | t.Skip("payload script is POSIX shell") |
| 30 | } |
| 31 | if _, err := exec.LookPath("git"); err != nil { |
| 32 | t.Skip("git not installed") |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // newRepoFixture commits files (path -> content) with the given |
| 37 | // .gitattributes, using plain git: the fixture is setup, not the subject. |
| 38 | func newRepoFixture(t *testing.T, attributes string, files map[string]string) *repoFixture { |
| 39 | t.Helper() |
| 40 | requirePOSIXGit(t) |
| 41 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| 42 | t.Cleanup(cancel) |
| 43 | f := &repoFixture{t: t, dir: t.TempDir(), marker: filepath.Join(t.TempDir(), "executed"), ctx: ctx} |
| 44 | f.plain("init", "--quiet", "-b", "main") |
| 45 | f.plain("config", "user.email", "test@example.com") |
| 46 | f.plain("config", "user.name", "test") |
| 47 | f.plain("config", "commit.gpgsign", "false") |
| 48 | if attributes != "" { |
| 49 | f.write(".gitattributes", attributes) |
| 50 | } |
| 51 | for path, content := range files { |
| 52 | f.write(path, content) |
| 53 | } |
| 54 | f.plain("add", "-A") |
| 55 | f.plain("commit", "--quiet", "-m", "initial") |
| 56 | return f |
| 57 | } |
| 58 | |
| 59 | func (f *repoFixture) plain(args ...string) string { |
| 60 | f.t.Helper() |
| 61 | cmd := exec.CommandContext(f.ctx, "git", append([]string{"-C", f.dir}, args...)...) |
| 62 | out, err := cmd.CombinedOutput() |
| 63 | if err != nil { |
| 64 | f.t.Fatalf("setup git %v: %v: %s", args, err, out) |
| 65 | } |
| 66 | return string(out) |
| 67 | } |
| 68 | |
| 69 | func (f *repoFixture) write(rel, content string) { |
| 70 | f.t.Helper() |
| 71 | path := filepath.Join(f.dir, filepath.FromSlash(rel)) |
| 72 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 73 | f.t.Fatal(err) |
| 74 | } |
| 75 | if err := os.WriteFile(path, []byte(content), 0o644); err != nil { |
| 76 | f.t.Fatal(err) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func (f *repoFixture) appendConfig(rel, text string) { |
| 81 | f.t.Helper() |
| 82 | path := filepath.Join(f.dir, ".git", filepath.FromSlash(rel)) |
| 83 | fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) |
| 84 | if err != nil { |
| 85 | f.t.Fatal(err) |
| 86 | } |
| 87 | defer fh.Close() |
| 88 | if _, err := fh.WriteString(text); err != nil { |
| 89 | f.t.Fatal(err) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // payload writes a script that records its run and passes stdin through, so a |
| 94 | // run that is not stopped still produces plausible git output. |
| 95 | func (f *repoFixture) payload() string { |
| 96 | f.t.Helper() |
| 97 | path := filepath.Join(f.t.TempDir(), "payload.sh") |
| 98 | script := "#!/bin/sh\necho ran >> '" + f.marker + "'\ncat\n" |
| 99 | if err := os.WriteFile(path, []byte(script), 0o755); err != nil { |
| 100 | f.t.Fatal(err) |
| 101 | } |
| 102 | return path |
| 103 | } |
| 104 | |
| 105 | // makeStatDirty rewrites rel with same-length content and an old mtime, which |
| 106 | // forces git to re-hash the file — through its filter — to decide the answer. |
| 107 | func (f *repoFixture) makeStatDirty(rel, content string) { |
| 108 | f.t.Helper() |
| 109 | f.write(rel, content) |
| 110 | old := time.Now().Add(-48 * time.Hour) |
| 111 | if err := os.Chtimes(filepath.Join(f.dir, rel), old, old); err != nil { |
| 112 | f.t.Fatal(err) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func (f *repoFixture) run(args ...string) (string, error) { |
| 117 | f.t.Helper() |
| 118 | out, err := Command(f.ctx, "", append([]string{"-C", f.dir}, args...)...).CombinedOutput() |
| 119 | return string(out), err |
| 120 | } |
| 121 | |
| 122 | func (f *repoFixture) mustRun(args ...string) string { |
| 123 | f.t.Helper() |
| 124 | out, err := f.run(args...) |
| 125 | if err != nil { |
| 126 | f.t.Fatalf("host git %v: %v: %s", args, err, out) |
| 127 | } |
| 128 | return out |
| 129 | } |
| 130 | |
| 131 | func (f *repoFixture) assertNotExecuted() { |
| 132 | f.t.Helper() |
| 133 | if data, err := os.ReadFile(f.marker); err == nil { |
| 134 | f.t.Fatalf("host git ran a repository-configured program (%d runs)", strings.Count(string(data), "ran")) |
| 135 | } else if !os.IsNotExist(err) { |
| 136 | f.t.Fatalf("stat marker: %v", err) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestHostStatusAndDiffDoNotRunRepositoryFilters(t *testing.T) { |
| 141 | for _, tt := range []struct { |
| 142 | name string |
| 143 | config func(f *repoFixture, payload string) |
| 144 | }{ |
| 145 | {"subsection", func(f *repoFixture, p string) { |
| 146 | f.appendConfig("config", "[filter \"pwn\"]\n\tclean = "+p+"\n\tprocess = "+p+"\n\trequired = true\n") |
| 147 | }}, |
| 148 | {"legacy dotted section", func(f *repoFixture, p string) { |
| 149 | f.appendConfig("config", "[filter.pwn]\n\tclean = "+p+"\n") |
| 150 | }}, |
| 151 | {"included file", func(f *repoFixture, p string) { |
| 152 | f.appendConfig("extra.cfg", "[filter \"pwn\"]\n\tclean = "+p+"\n") |
| 153 | f.appendConfig("config", "[include]\n\tpath = extra.cfg\n") |
| 154 | }}, |
| 155 | {"worktree scope", func(f *repoFixture, p string) { |
| 156 | f.plain("config", "extensions.worktreeConfig", "true") |
| 157 | f.appendConfig("config.worktree", "[filter \"pwn\"]\n\tclean = "+p+"\n") |
| 158 | }}, |
| 159 | } { |
| 160 | t.Run(tt.name, func(t *testing.T) { |
| 161 | f := newRepoFixture(t, "f.txt filter=pwn\n", map[string]string{"f.txt": "hello\n", "sub/g.txt": "g\n"}) |
| 162 | tt.config(f, f.payload()) |
| 163 | f.makeStatDirty("f.txt", "hellx\n") |
| 164 | |
| 165 | if out := f.mustRun("status", "--porcelain=v1"); !strings.Contains(out, " M f.txt") { |
| 166 | t.Fatalf("status = %q, want the same-size edit reported", out) |
| 167 | } |
| 168 | if out := f.mustRun("diff", "--numstat", "HEAD", "--"); !strings.Contains(out, "1\t1\tf.txt") { |
| 169 | t.Fatalf("numstat = %q, want 1/1 for f.txt", out) |
| 170 | } |
| 171 | if out, err := Command(f.ctx, f.dir, "diff", "HEAD", "--", "f.txt").CombinedOutput(); err != nil || !strings.Contains(string(out), "+hellx") { |
| 172 | t.Fatalf("diff = %q, %v; want the working-tree line", out, err) |
| 173 | } |
| 174 | if out, err := Command(f.ctx, filepath.Join(f.dir, "sub"), "status", "--porcelain=v1").CombinedOutput(); err != nil || !strings.Contains(string(out), "f.txt") { |
| 175 | t.Fatalf("status from a subdirectory = %q, %v", out, err) |
| 176 | } |
| 177 | f.mustRun("ls-files", "--modified") |
| 178 | f.assertNotExecuted() |
| 179 | }) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | func TestHostHistoryReadsDoNotRunDiffDriversOrSignatureVerifiers(t *testing.T) { |
| 184 | f := newRepoFixture(t, "f.txt diff=tc\n", map[string]string{"f.txt": "one\n"}) |
| 185 | p := f.payload() |
| 186 | f.appendConfig("config", "[diff \"tc\"]\n\ttextconv = "+p+"\n\tcommand = "+p+"\n[diff]\n\texternal = "+p+ |
| 187 | "\n[log]\n\tshowSignature = true\n[gpg]\n\tprogram = "+p+"\n[gpg \"ssh\"]\n\tprogram = "+p+"\n") |
| 188 | f.write("f.txt", "two\n") |
| 189 | f.plain("-c", "diff.external=", "commit", "--quiet", "-am", "second") |
| 190 | signCommitHeader(f) |
| 191 | |
| 192 | for _, args := range [][]string{ |
| 193 | {"log", "-p", "-1"}, |
| 194 | {"show", "HEAD"}, |
| 195 | {"diff", "HEAD~1", "HEAD"}, |
| 196 | } { |
| 197 | if out := f.mustRun(args...); !strings.Contains(out, "+two") { |
| 198 | t.Fatalf("git %v = %q, want the committed change", args, out) |
| 199 | } |
| 200 | } |
| 201 | f.mustRun("log", "--format=%H %s", "-1") |
| 202 | f.assertNotExecuted() |
| 203 | } |
| 204 | |
| 205 | // signCommitHeader rewrites HEAD to carry a gpgsig header, the input that makes |
| 206 | // log.showSignature call the configured verifier. |
| 207 | func signCommitHeader(f *repoFixture) { |
| 208 | f.t.Helper() |
| 209 | raw := f.plain("cat-file", "commit", "HEAD") |
| 210 | header, body, _ := strings.Cut(raw, "\n\n") |
| 211 | signed := header + "\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n AAAA\n -----END PGP SIGNATURE-----\n\n" + body |
| 212 | cmd := exec.CommandContext(f.ctx, "git", "-C", f.dir, "hash-object", "-t", "commit", "-w", "--stdin") |
| 213 | cmd.Stdin = strings.NewReader(signed) |
| 214 | out, err := cmd.Output() |
| 215 | if err != nil { |
| 216 | f.t.Fatalf("hash signed commit: %v", err) |
| 217 | } |
| 218 | f.plain("update-ref", "HEAD", strings.TrimSpace(string(out))) |
| 219 | } |
| 220 | |
| 221 | func TestHostSuperprojectInspectionDoesNotRunSubmoduleDrivers(t *testing.T) { |
| 222 | sub := newRepoFixture(t, "s.txt filter=pwn\n", map[string]string{"s.txt": "hello\n"}) |
| 223 | f := newRepoFixture(t, "", map[string]string{"top.txt": "top\n"}) |
| 224 | f.plain("-c", "protocol.file.allow=always", "submodule", "--quiet", "add", sub.dir, "sm") |
| 225 | f.plain("commit", "--quiet", "-m", "add submodule") |
| 226 | f.appendConfig("modules/sm/config", "[filter \"pwn\"]\n\tclean = "+f.payload()+"\n") |
| 227 | f.makeStatDirty("sm/s.txt", "hellx\n") |
| 228 | f.write("top.txt", "changed\n") |
| 229 | |
| 230 | if out := f.mustRun("status", "--porcelain=v1"); !strings.Contains(out, " M top.txt") { |
| 231 | t.Fatalf("status = %q, want the superproject change", out) |
| 232 | } |
| 233 | f.mustRun("diff", "--numstat", "HEAD", "--") |
| 234 | f.assertNotExecuted() |
| 235 | } |
| 236 | |
| 237 | func TestHostRepositoryMutationsDoNotRunRepositoryPrograms(t *testing.T) { |
| 238 | f := newRepoFixture(t, "f.txt filter=pwn merge=pwn\n", map[string]string{"f.txt": "a\nb\nc\n"}) |
| 239 | f.plain("checkout", "--quiet", "-b", "side") |
| 240 | f.write("f.txt", "a\nb\nX\n") |
| 241 | f.plain("commit", "--quiet", "-am", "side") |
| 242 | f.plain("checkout", "--quiet", "main") |
| 243 | f.write("f.txt", "Y\nb\nc\n") |
| 244 | f.plain("commit", "--quiet", "-am", "main") |
| 245 | p := f.payload() |
| 246 | hooks := filepath.Join(f.dir, ".git", "hooks") |
| 247 | if err := os.MkdirAll(hooks, 0o755); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | for _, hook := range []string{"post-checkout", "reference-transaction", "post-merge", "pre-merge-commit", "pre-commit", "pre-auto-gc"} { |
| 251 | if err := os.Symlink(p, filepath.Join(hooks, hook)); err != nil { |
| 252 | t.Fatal(err) |
| 253 | } |
| 254 | } |
| 255 | f.appendConfig("config", "[filter \"pwn\"]\n\tclean = "+p+"\n\tsmudge = "+p+"\n\tprocess = "+p+ |
| 256 | "\n[merge \"pwn\"]\n\tdriver = "+p+" %O %A %B\n") |
| 257 | |
| 258 | linked := filepath.Join(t.TempDir(), "linked") |
| 259 | f.mustRun("worktree", "add", "--quiet", "--detach", linked, "HEAD") |
| 260 | if data, err := os.ReadFile(filepath.Join(linked, "f.txt")); err != nil || string(data) != "Y\nb\nc\n" { |
| 261 | t.Fatalf("worktree checkout = %q, %v; want the committed bytes", data, err) |
| 262 | } |
| 263 | f.write("new.txt", "new\n") |
| 264 | f.mustRun("add", "-A") |
| 265 | cmd := Command(f.ctx, f.dir, "hash-object", "--stdin-paths") |
| 266 | cmd.Stdin = strings.NewReader("f.txt\n") |
| 267 | if out, err := cmd.CombinedOutput(); err != nil { |
| 268 | t.Fatalf("hash-object: %v: %s", err, out) |
| 269 | } |
| 270 | f.mustRun("commit", "--quiet", "-m", "add new") |
| 271 | _, _ = f.run("merge", "--no-edit", "side") |
| 272 | _, _ = f.run("merge", "--abort") |
| 273 | f.mustRun("update-ref", "refs/heads/probe", "HEAD") |
| 274 | f.assertNotExecuted() |
| 275 | } |
| 276 | |
| 277 | // A driver name git's -c syntax cannot address must stop the invocation rather |
| 278 | // than let it run with that driver live. |
| 279 | func TestUnaddressableDriverNameFailsClosed(t *testing.T) { |
| 280 | f := newRepoFixture(t, "f.txt filter=a=b\n", map[string]string{"f.txt": "hello\n"}) |
| 281 | f.appendConfig("config", "[filter \"a=b\"]\n\tclean = "+f.payload()+"\n") |
| 282 | f.makeStatDirty("f.txt", "hellx\n") |
| 283 | |
| 284 | _, err := f.run("status", "--porcelain=v1") |
| 285 | if !errors.Is(err, ErrRepositoryDrivers) { |
| 286 | t.Fatalf("status err = %v, want ErrRepositoryDrivers", err) |
| 287 | } |
| 288 | // rev-parse converts no content, so it runs without the listing and still |
| 289 | // starts no driver. |
| 290 | f.mustRun("rev-parse", "HEAD") |
| 291 | f.assertNotExecuted() |
| 292 | } |
| 293 | |
| 294 | func TestRepositoryWithoutDriversAddsNoOverrides(t *testing.T) { |
| 295 | f := newRepoFixture(t, "", map[string]string{"f.txt": "hello\n"}) |
| 296 | cmd := Command(f.ctx, f.dir, "status", "--porcelain=v1") |
| 297 | for _, arg := range cmd.Args { |
| 298 | // The baseline pins merge.verifySignatures etc.; only per-driver |
| 299 | // overrides are what a driver-free repository must not add. |
| 300 | if strings.HasSuffix(arg, ".clean=") || strings.HasSuffix(arg, ".process=") || strings.HasSuffix(arg, ".driver=") { |
| 301 | t.Fatalf("args = %v, want no driver overrides for a driver-free repository", cmd.Args) |
| 302 | } |
| 303 | } |
| 304 | if cmd.Err != nil { |
| 305 | t.Fatalf("cmd.Err = %v", cmd.Err) |
| 306 | } |
| 307 | } |
| 308 |