返回 DeepSeek-Reasonix
gitcmd_test.go
根目录 / internal / gitcmd / gitcmd_test.go
1 package gitcmd
2
3 import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "runtime"
9 "slices"
10 "strings"
11 "testing"
12 "time"
13 )
14
15 func hasConfig(args []string, want string) bool {
16 for i := 0; i+1 < len(args); i++ {
17 if args[i] == "-c" && args[i+1] == want {
18 return true
19 }
20 }
21 return false
22 }
23
24 func TestArgsCarryBaselineConfig(t *testing.T) {
25 args := argsFor("linux", "/repo", nil, "status", "--porcelain=v1")
26 for _, want := range []string{"core.fsmonitor=false", "maintenance.auto=false"} {
27 if !hasConfig(args, want) {
28 t.Fatalf("args = %v, want -c %s", args, want)
29 }
30 }
31 if i := slices.Index(args, "-C"); i < 0 || args[i+1] != "/repo" {
32 t.Fatalf("args = %v, want -C /repo", args)
33 }
34 // Caller arguments stay last and in order.
35 if got := args[len(args)-2:]; got[0] != "status" || got[1] != "--porcelain=v1" {
36 t.Fatalf("trailing args = %v, want the caller's arguments last", got)
37 }
38 }
39
40 // Extra config may add to the baseline but must never replace it: a call site
41 // that wants its own preference still gets the hardening.
42 func TestArgsExtraConfigCannotDropBaseline(t *testing.T) {
43 args := argsFor("linux", "/repo", []string{"core.quotepath=false", ""}, "status")
44 if !hasConfig(args, "core.fsmonitor=false") {
45 t.Fatalf("args = %v, want the baseline retained alongside extra config", args)
46 }
47 if !hasConfig(args, "core.quotepath=false") {
48 t.Fatalf("args = %v, want the extra config applied", args)
49 }
50 base := slices.Index(args, "core.fsmonitor=false")
51 extra := slices.Index(args, "core.quotepath=false")
52 if base > extra {
53 t.Fatalf("args = %v, want baseline before extra config so the caller's value wins ties", args)
54 }
55 if slices.Contains(args, "") {
56 t.Fatalf("args = %v, want empty config entries dropped", args)
57 }
58 }
59
60 func TestArgsEnableLongPathsOnlyOnWindows(t *testing.T) {
61 if args := argsFor("windows", `C:\Users\test\repo`, nil, "status"); !hasConfig(args, "core.longpaths=true") {
62 t.Fatalf("windows args = %v, want core.longpaths=true", args)
63 }
64 if args := argsFor("linux", "/tmp/repo", nil, "status"); hasConfig(args, "core.longpaths=true") {
65 t.Fatalf("non-windows args = %v, must not override core.longpaths", args)
66 }
67 }
68
69 // diff is the one subcommand that can be pointed at an external program by
70 // repository configuration, so it carries the disabling flags — placed after
71 // the subcommand, never duplicated, and never added to other subcommands.
72 func TestDiffDisablesRepositoryConfiguredPrograms(t *testing.T) {
73 args := argsFor("linux", "/repo", nil, "diff", "--numstat", "HEAD", "--")
74 sub := slices.Index(args, "diff")
75 if sub < 0 {
76 t.Fatalf("args = %v, want the diff subcommand", args)
77 }
78 for _, flag := range []string{"--no-ext-diff", "--no-textconv"} {
79 i := slices.Index(args, flag)
80 if i < 0 {
81 t.Fatalf("args = %v, want %s", args, flag)
82 }
83 if i < sub {
84 t.Fatalf("args = %v, want %s after the subcommand", args, flag)
85 }
86 }
87 if got := args[len(args)-3:]; got[0] != "--numstat" || got[1] != "HEAD" || got[2] != "--" {
88 t.Fatalf("trailing args = %v, want the caller's diff arguments preserved in order", got)
89 }
90
91 explicit := argsFor("linux", "/repo", nil, "diff", "--no-ext-diff", "HEAD")
92 if n := strings.Count(strings.Join(explicit, " "), "--no-ext-diff"); n != 1 {
93 t.Fatalf("args = %v, want one --no-ext-diff when the caller already passed it", explicit)
94 }
95
96 if args := argsFor("linux", "/repo", nil, "status"); slices.Contains(args, "--no-ext-diff") {
97 t.Fatalf("status args = %v, must not carry diff-only flags", args)
98 }
99 }
100
101 func TestEnvDisablesPromptsAndKeepsSSHUsable(t *testing.T) {
102 env := Env()
103 if !slices.Contains(env, "GIT_OPTIONAL_LOCKS=0") || !slices.Contains(env, "GIT_TERMINAL_PROMPT=0") {
104 t.Fatalf("env = %v, want optional locks and terminal prompts disabled", env)
105 }
106 // An empty value is a *present* value to git: clearing these would break
107 // legitimate ssh remotes and external diff tooling rather than harden.
108 for _, banned := range []string{"GIT_SSH_COMMAND=", "GIT_EXTERNAL_DIFF="} {
109 if slices.Contains(env, banned) {
110 t.Fatalf("env = %v, must not set %q", env, banned)
111 }
112 }
113 }
114
115 // The invariant this package exists for: a repository's own config names a
116 // command in core.fsmonitor, and inspecting that repository must not run it.
117 // git executes fsmonitor during an index refresh, which a plain status does.
118 func TestRepositoryConfigCannotRunCommandsDuringInspection(t *testing.T) {
119 if runtime.GOOS == "windows" {
120 t.Skip("payload script is POSIX shell")
121 }
122 if _, err := exec.LookPath("git"); err != nil {
123 t.Skip("git not installed")
124 }
125
126 repo := t.TempDir()
127 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
128 defer cancel()
129
130 run := func(args ...string) {
131 t.Helper()
132 if out, err := Command(ctx, repo, args...).CombinedOutput(); err != nil {
133 t.Fatalf("git %v: %v: %s", args, err, out)
134 }
135 }
136 run("init", "--quiet")
137 run("config", "user.email", "test@example.com")
138 run("config", "user.name", "test")
139 if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("content\n"), 0o600); err != nil {
140 t.Fatal(err)
141 }
142 run("add", "file.txt")
143 run("commit", "--quiet", "-m", "initial")
144
145 // A repository whose config points fsmonitor at a command. Writing the
146 // marker is what an attacker's payload would do first.
147 marker := filepath.Join(t.TempDir(), "executed")
148 payload := filepath.Join(t.TempDir(), "payload.sh")
149 script := "#!/bin/sh\ntouch " + marker + "\nexit 1\n"
150 if err := os.WriteFile(payload, []byte(script), 0o700); err != nil {
151 t.Fatal(err)
152 }
153 run("config", "core.fsmonitor", payload)
154
155 // Dirty the tree so an index refresh has work to do, then inspect it the
156 // way the status readout does.
157 if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("changed\n"), 0o600); err != nil {
158 t.Fatal(err)
159 }
160 _, _ = Command(ctx, repo, "status", "--porcelain=v1").CombinedOutput()
161 _, _ = Command(ctx, repo, "diff", "--numstat", "HEAD", "--").CombinedOutput()
162 _, _ = Command(ctx, repo, "rev-parse", "--show-toplevel").CombinedOutput()
163
164 if _, err := os.Stat(marker); err == nil {
165 t.Fatal("repository config ran a command during inspection")
166 } else if !os.IsNotExist(err) {
167 t.Fatalf("stat marker: %v", err)
168 }
169 }
170
171 // The clean-filter residual from the advisory: a .gitattributes entry plus a
172 // filter.<driver>.clean command in the repository's local config makes
173 // `git diff` run that command to produce the "clean" working-tree side, and
174 // neither --no-ext-diff nor --no-textconv covers it. Diff invocations must
175 // neutralize every locally-defined driver while still rendering a correct
176 // diff (the emptied filter is an identity pass-through, not a content wipe).
177 func TestDiffDoesNotRunRepositoryCleanFilters(t *testing.T) {
178 if runtime.GOOS == "windows" {
179 t.Skip("payload script is POSIX shell")
180 }
181 if _, err := exec.LookPath("git"); err != nil {
182 t.Skip("git not installed")
183 }
184
185 repo := t.TempDir()
186 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
187 defer cancel()
188
189 run := func(args ...string) {
190 t.Helper()
191 if out, err := Command(ctx, repo, args...).CombinedOutput(); err != nil {
192 t.Fatalf("git %v: %v: %s", args, err, out)
193 }
194 }
195 run("init", "--quiet")
196 run("config", "user.email", "test@example.com")
197 run("config", "user.name", "test")
198
199 marker := filepath.Join(t.TempDir(), "executed")
200 payload := filepath.Join(t.TempDir(), "clean.sh")
201 script := "#!/bin/sh\ntouch " + marker + "\ncat\n"
202 if err := os.WriteFile(payload, []byte(script), 0o700); err != nil {
203 t.Fatal(err)
204 }
205 if err := os.WriteFile(filepath.Join(repo, ".gitattributes"), []byte("secret.bin filter=pwn\n"), 0o600); err != nil {
206 t.Fatal(err)
207 }
208 if err := os.WriteFile(filepath.Join(repo, "secret.bin"), []byte("secret\n"), 0o600); err != nil {
209 t.Fatal(err)
210 }
211 run("add", ".gitattributes", "secret.bin")
212 run("commit", "--quiet", "-m", "initial")
213 run("config", "filter.pwn.clean", payload)
214 run("config", "filter.pwn.process", payload)
215 run("config", "filter.pwn.required", "true")
216 if err := os.WriteFile(filepath.Join(repo, "secret.bin"), []byte("secret\nchanged\n"), 0o600); err != nil {
217 t.Fatal(err)
218 }
219
220 // The exact shape desktop/workspace_changes.go builds: -C inside args.
221 out, err := Command(ctx, "", "-C", repo, "diff", "--no-ext-diff", "--no-textconv", "--relative", "HEAD", "--", filepath.FromSlash("secret.bin")).CombinedOutput()
222 if err != nil {
223 t.Fatalf("diff failed: %v: %s", err, out)
224 }
225 if !strings.Contains(string(out), "changed") {
226 t.Fatalf("diff output lost the working-tree change (filter neutralization must pass content through):\n%s", out)
227 }
228 // And the shape internal/cli/gitstatus.go builds: dir parameter + diff.
229 if out, err = Command(ctx, repo, "diff", "--numstat", "HEAD", "--").CombinedOutput(); err != nil {
230 t.Fatalf("numstat diff failed: %v: %s", err, out)
231 }
232
233 if _, err := os.Stat(marker); err == nil {
234 t.Fatal("repository clean filter ran during a diff")
235 } else if !os.IsNotExist(err) {
236 t.Fatalf("stat marker: %v", err)
237 }
238 }
239
240 func TestFilterNeutralizingConfigOnlyForDiff(t *testing.T) {
241 repo := t.TempDir()
242 if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o700); err != nil {
243 t.Fatal(err)
244 }
245 config := `[core]
246 bare = false
247 [filter "lfs"]
248 clean = git-lfs clean -- %f
249 process = git-lfs filter-process
250 required = true
251 [filter "pwn"]
252 smudge = whatever
253 `
254 if err := os.WriteFile(filepath.Join(repo, ".git", "config"), []byte(config), 0o600); err != nil {
255 t.Fatal(err)
256 }
257
258 for _, want := range []string{
259 "filter.lfs.clean=", "filter.lfs.process=", "filter.lfs.required=false",
260 "filter.pwn.clean=", "filter.pwn.process=", "filter.pwn.required=false",
261 } {
262 args := argsFor("linux", repo, nil, "diff", "HEAD")
263 if !hasConfig(args, want) {
264 t.Fatalf("diff args = %v, want -c %s", args, want)
265 }
266 }
267
268 // The -C-inside-args form workspace_changes.go uses resolves the same repo.
269 args := argsFor("linux", "", nil, "-C", repo, "diff", "--no-ext-diff")
270 if !hasConfig(args, "filter.lfs.clean=") {
271 t.Fatalf("args with -C inside = %v, want filter.lfs.clean= override", args)
272 }
273
274 // Non-diff subcommands carry no filter overrides, and a repo without
275 // filter sections adds nothing even for diff.
276 for _, sub := range []string{"status", "rev-parse", "diff-tree"} {
277 if args := argsFor("linux", repo, nil, sub, "--porcelain=v1"); hasConfig(args, "filter.lfs.clean=") {
278 t.Fatalf("%s args = %v, must not carry diff-only filter overrides", sub, args)
279 }
280 }
281 clean := t.TempDir()
282 if err := os.MkdirAll(filepath.Join(clean, ".git"), 0o700); err != nil {
283 t.Fatal(err)
284 }
285 if err := os.WriteFile(filepath.Join(clean, ".git", "config"), []byte("[core]\n\tbare = false\n"), 0o600); err != nil {
286 t.Fatal(err)
287 }
288 if args := argsFor("linux", clean, nil, "diff"); slices.Contains(args, "filter.") {
289 t.Fatalf("filter-free repo diff args = %v, want no filter overrides", args)
290 }
291 }
292
293 // A linked worktree keeps its config next to the gitdir the .git file points
294 // at; the neutralization must follow the link.
295 func TestLocalFilterDriversFollowsWorktreeLink(t *testing.T) {
296 main := t.TempDir()
297 if err := os.MkdirAll(filepath.Join(main, ".git"), 0o700); err != nil {
298 t.Fatal(err)
299 }
300 wt := t.TempDir()
301 if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: "+filepath.Join(main, ".git")+"\n"), 0o600); err != nil {
302 t.Fatal(err)
303 }
304 if err := os.WriteFile(filepath.Join(main, ".git", "config"), []byte("[filter \"x\"]\n\tclean = cmd\n"), 0o600); err != nil {
305 t.Fatal(err)
306 }
307
308 if got := localFilterDrivers(wt); !slices.Equal(got, []string{"x"}) {
309 t.Fatalf("localFilterDrivers(worktree) = %v, want [x]", got)
310 }
311 args := argsFor("linux", wt, nil, "diff")
312 if !hasConfig(args, "filter.x.clean=") {
313 t.Fatalf("worktree diff args = %v, want filter.x.clean= override", args)
314 }
315 }
316
317 func TestGitSubcommandSkipsGlobalOptions(t *testing.T) {
318 for _, tt := range []struct {
319 args []string
320 sub string
321 cDir string
322 }{
323 {args: []string{"status"}, sub: "status"},
324 {args: []string{"diff", "HEAD"}, sub: "diff"},
325 {args: []string{"-C", "/repo", "diff"}, sub: "diff", cDir: "/repo"},
326 {args: []string{"-C/repo", "status"}, sub: "status", cDir: "/repo"},
327 {args: []string{"-c", "a=b", "-C", "/r", "log"}, sub: "log", cDir: "/r"},
328 {args: []string{"--no-pager", "status"}, sub: "status"},
329 {args: []string{}},
330 {args: []string{"-c", "a=b"}},
331 } {
332 sub, cDir := gitSubcommand(tt.args)
333 if sub != tt.sub || cDir != tt.cDir {
334 t.Fatalf("gitSubcommand(%v) = (%q, %q), want (%q, %q)", tt.args, sub, cDir, tt.sub, tt.cDir)
335 }
336 }
337 }
338
338 lines GO