返回 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
170 lines GO