返回 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, "rev-parse", "--show-toplevel")
26 for _, want := range []string{"core.fsmonitor=", "maintenance.auto=false", "gc.auto=0", "core.hooksPath=" + os.DevNull, "log.showSignature=false", "merge.verifySignatures=false", "diff.submodule=short", "safe.bareRepository=explicit"} {
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] != "rev-parse" || got[1] != "--show-toplevel" {
36 t.Fatalf("trailing args = %v, want the caller's arguments last", got)
37 }
38 }
39
40 // A user's own submodule.recurse=true never carries a host checkout, merge or
41 // reset into a submodule; other subcommands leave that choice alone.
42 func TestArgsPinSubmoduleRecurseForTreeUpdates(t *testing.T) {
43 for _, sub := range []string{"checkout", "switch", "restore", "reset", "merge", "read-tree"} {
44 if args := argsFor("linux", "/repo", nil, sub); !hasConfig(args, "submodule.recurse=false") {
45 t.Fatalf("%s args = %v, want submodule.recurse=false", sub, args)
46 }
47 }
48 if args := argsFor("linux", "/repo", nil, "status"); hasConfig(args, "submodule.recurse=false") {
49 t.Fatalf("status args = %v, want the user's submodule.recurse left alone", args)
50 }
51 }
52
53 // Extra config may add to the baseline but must never replace it: a call site
54 // that wants its own preference still gets the hardening.
55 func TestArgsExtraConfigCannotDropBaseline(t *testing.T) {
56 args := argsFor("linux", "/repo", []string{"core.quotepath=false", ""}, "status")
57 if !hasConfig(args, "core.fsmonitor=") {
58 t.Fatalf("args = %v, want the baseline retained alongside extra config", args)
59 }
60 if !hasConfig(args, "core.quotepath=false") {
61 t.Fatalf("args = %v, want the extra config applied", args)
62 }
63 base := slices.Index(args, "core.fsmonitor=")
64 extra := slices.Index(args, "core.quotepath=false")
65 if base > extra {
66 t.Fatalf("args = %v, want baseline before extra config so the caller's value wins ties", args)
67 }
68 if slices.Contains(args, "") {
69 t.Fatalf("args = %v, want empty config entries dropped", args)
70 }
71 }
72
73 func TestArgsEnableLongPathsOnlyOnWindows(t *testing.T) {
74 if args := argsFor("windows", `C:\Users\test\repo`, nil, "status"); !hasConfig(args, "core.longpaths=true") {
75 t.Fatalf("windows args = %v, want core.longpaths=true", args)
76 }
77 if args := argsFor("linux", "/tmp/repo", nil, "status"); hasConfig(args, "core.longpaths=true") {
78 t.Fatalf("non-windows args = %v, must not override core.longpaths", args)
79 }
80 }
81
82 // The disabling flags go right after the subcommand — found past any global
83 // options the caller put in args — and are never duplicated.
84 func TestSubcommandFlagsDisableRepositoryConfiguredPrograms(t *testing.T) {
85 for _, tt := range []struct {
86 args []string
87 want []string
88 not []string
89 }{
90 {[]string{"diff", "--numstat", "HEAD", "--"}, []string{"diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=dirty", "--numstat", "HEAD", "--"}, nil},
91 {[]string{"-C", "/r", "diff", "HEAD"}, []string{"-C", "/r", "diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=dirty", "HEAD"}, nil},
92 {[]string{"-c", "user.name=x", "log", "-p"}, []string{"-c", "user.name=x", "log", "--no-ext-diff", "--no-textconv", "-p"}, []string{"--ignore-submodules=dirty"}},
93 {[]string{"--git-dir=/g", "show", "HEAD"}, []string{"--git-dir=/g", "show", "--no-ext-diff", "--no-textconv", "HEAD"}, nil},
94 {[]string{"-C", "/r", "status", "--porcelain=v1"}, []string{"-C", "/r", "status", "--ignore-submodules=dirty", "--porcelain=v1"}, []string{"--no-ext-diff"}},
95 {[]string{"diff", "--no-ext-diff", "--ignore-submodules=all"}, []string{"diff", "--no-textconv", "--no-ext-diff", "--ignore-submodules=all"}, nil},
96 {[]string{"rev-parse", "--show-toplevel"}, []string{"rev-parse", "--show-toplevel"}, nil},
97 {[]string{"--version"}, []string{"--version"}, nil},
98 } {
99 got := hardenSubcommand(tt.args)
100 if !slices.Equal(got, tt.want) {
101 t.Fatalf("hardenSubcommand(%v) = %v, want %v", tt.args, got, tt.want)
102 }
103 }
104 }
105
106 func TestDetachedRunsOutsideAnyRepository(t *testing.T) {
107 requirePOSIXGit(t)
108 f := newRepoFixture(t, "", map[string]string{"f.txt": "x\n"})
109 t.Chdir(f.dir)
110 cmd := exec.Command("git", "rev-parse", "--git-dir")
111 cmd.Env = append(os.Environ(), "GIT_DIR="+filepath.Join(f.dir, ".git"))
112 cleanup, err := Detached(cmd)
113 if err != nil {
114 t.Fatal(err)
115 }
116 defer cleanup()
117 if out, err := cmd.CombinedOutput(); err == nil {
118 t.Fatalf("rev-parse in a detached command found a repository: %s", out)
119 }
120 if slices.Contains(cmd.Env, "GIT_DIR="+filepath.Join(f.dir, ".git")) {
121 t.Fatalf("env = %v, want GIT_DIR dropped", cmd.Env)
122 }
123 }
124
125 func TestEnvDisablesPromptsAndKeepsSSHUsable(t *testing.T) {
126 env := Env()
127 if !slices.Contains(env, "GIT_OPTIONAL_LOCKS=0") || !slices.Contains(env, "GIT_TERMINAL_PROMPT=0") || !slices.Contains(env, "GIT_NO_LAZY_FETCH=1") {
128 t.Fatalf("env = %v, want optional locks and terminal prompts disabled", env)
129 }
130 // An empty value is a *present* value to git: clearing these would break
131 // legitimate ssh remotes and external diff tooling rather than harden.
132 for _, banned := range []string{"GIT_SSH_COMMAND=", "GIT_EXTERNAL_DIFF="} {
133 if slices.Contains(env, banned) {
134 t.Fatalf("env = %v, must not set %q", env, banned)
135 }
136 }
137 }
138
139 // The invariant this package exists for: a repository's own config names a
140 // command in core.fsmonitor, and inspecting that repository must not run it.
141 // git executes fsmonitor during an index refresh, which a plain status does.
142 func TestRepositoryConfigCannotRunCommandsDuringInspection(t *testing.T) {
143 if runtime.GOOS == "windows" {
144 t.Skip("payload script is POSIX shell")
145 }
146 if _, err := exec.LookPath("git"); err != nil {
147 t.Skip("git not installed")
148 }
149
150 repo := t.TempDir()
151 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
152 defer cancel()
153
154 run := func(args ...string) {
155 t.Helper()
156 if out, err := Command(ctx, repo, args...).CombinedOutput(); err != nil {
157 t.Fatalf("git %v: %v: %s", args, err, out)
158 }
159 }
160 run("init", "--quiet")
161 run("config", "user.email", "test@example.com")
162 run("config", "user.name", "test")
163 if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("content\n"), 0o600); err != nil {
164 t.Fatal(err)
165 }
166 run("add", "file.txt")
167 run("commit", "--quiet", "-m", "initial")
168
169 // A repository whose config points fsmonitor at a command. Writing the
170 // marker is what an attacker's payload would do first.
171 marker := filepath.Join(t.TempDir(), "executed")
172 payload := filepath.Join(t.TempDir(), "payload.sh")
173 script := "#!/bin/sh\ntouch " + marker + "\nexit 1\n"
174 if err := os.WriteFile(payload, []byte(script), 0o700); err != nil {
175 t.Fatal(err)
176 }
177 run("config", "core.fsmonitor", payload)
178
179 // Dirty the tree so an index refresh has work to do, then inspect it the
180 // way the status readout does.
181 if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("changed\n"), 0o600); err != nil {
182 t.Fatal(err)
183 }
184 _, _ = Command(ctx, repo, "status", "--porcelain=v1").CombinedOutput()
185 _, _ = Command(ctx, repo, "diff", "--numstat", "HEAD", "--").CombinedOutput()
186 _, _ = Command(ctx, repo, "rev-parse", "--show-toplevel").CombinedOutput()
187
188 if _, err := os.Stat(marker); err == nil {
189 t.Fatal("repository config ran a command during inspection")
190 } else if !os.IsNotExist(err) {
191 t.Fatalf("stat marker: %v", err)
192 }
193 }
194
195 // The clean-filter residual from the advisory: a .gitattributes entry plus a
196 // filter.<driver>.clean command in the repository's local config makes
197 // `git diff` run that command to produce the "clean" working-tree side, and
198 // neither --no-ext-diff nor --no-textconv covers it. Diff invocations must
199 // neutralize every locally-defined driver while still rendering a correct
200 // diff (the emptied filter is an identity pass-through, not a content wipe).
201 func TestDiffDoesNotRunRepositoryCleanFilters(t *testing.T) {
202 if runtime.GOOS == "windows" {
203 t.Skip("payload script is POSIX shell")
204 }
205 if _, err := exec.LookPath("git"); err != nil {
206 t.Skip("git not installed")
207 }
208
209 repo := t.TempDir()
210 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
211 defer cancel()
212
213 run := func(args ...string) {
214 t.Helper()
215 if out, err := Command(ctx, repo, args...).CombinedOutput(); err != nil {
216 t.Fatalf("git %v: %v: %s", args, err, out)
217 }
218 }
219 run("init", "--quiet")
220 run("config", "user.email", "test@example.com")
221 run("config", "user.name", "test")
222
223 marker := filepath.Join(t.TempDir(), "executed")
224 payload := filepath.Join(t.TempDir(), "clean.sh")
225 script := "#!/bin/sh\ntouch " + marker + "\ncat\n"
226 if err := os.WriteFile(payload, []byte(script), 0o700); err != nil {
227 t.Fatal(err)
228 }
229 if err := os.WriteFile(filepath.Join(repo, ".gitattributes"), []byte("secret.bin filter=pwn\n"), 0o600); err != nil {
230 t.Fatal(err)
231 }
232 if err := os.WriteFile(filepath.Join(repo, "secret.bin"), []byte("secret\n"), 0o600); err != nil {
233 t.Fatal(err)
234 }
235 run("add", ".gitattributes", "secret.bin")
236 run("commit", "--quiet", "-m", "initial")
237 run("config", "filter.pwn.clean", payload)
238 run("config", "filter.pwn.process", payload)
239 run("config", "filter.pwn.required", "true")
240 if err := os.WriteFile(filepath.Join(repo, "secret.bin"), []byte("secret\nchanged\n"), 0o600); err != nil {
241 t.Fatal(err)
242 }
243
244 // The exact shape desktop/workspace_changes.go builds: -C inside args.
245 out, err := Command(ctx, "", "-C", repo, "diff", "--no-ext-diff", "--no-textconv", "--relative", "HEAD", "--", filepath.FromSlash("secret.bin")).CombinedOutput()
246 if err != nil {
247 t.Fatalf("diff failed: %v: %s", err, out)
248 }
249 if !strings.Contains(string(out), "changed") {
250 t.Fatalf("diff output lost the working-tree change (filter neutralization must pass content through):\n%s", out)
251 }
252 // And the shape internal/cli/gitstatus.go builds: dir parameter + diff.
253 if out, err = Command(ctx, repo, "diff", "--numstat", "HEAD", "--").CombinedOutput(); err != nil {
254 t.Fatalf("numstat diff failed: %v: %s", err, out)
255 }
256
257 if _, err := os.Stat(marker); err == nil {
258 t.Fatal("repository clean filter ran during a diff")
259 } else if !os.IsNotExist(err) {
260 t.Fatalf("stat marker: %v", err)
261 }
262 }
263
263 lines GO