返回 DeepSeek-Reasonix
bash_test.go
根目录 / internal / shellparse / bash_test.go
1 package shellparse
2
3 import (
4 "errors"
5 "reflect"
6 "strings"
7 "testing"
8 )
9
10 func TestStaticFields(t *testing.T) {
11 tests := []struct {
12 name string
13 command string
14 want []string
15 malformed bool
16 }{
17 {
18 name: "plain command",
19 command: "git status --short",
20 want: []string{"git", "status", "--short"},
21 },
22 {
23 name: "quoted static fields",
24 command: `grep 'a|b' "file name.txt"`,
25 want: []string{"grep", "a|b", "file name.txt"},
26 },
27 {
28 name: "escaped static field",
29 command: `find . -name scratch \-delete`,
30 want: []string{"find", ".", "-name", "scratch", "-delete"},
31 },
32 {
33 name: "redirect is shell syntax",
34 command: "git log >/dev/null",
35 malformed: true,
36 },
37 {
38 name: "control operator is shell syntax",
39 command: "git status && rm -rf /tmp/x",
40 malformed: true,
41 },
42 {
43 name: "parameter expansion is shell syntax",
44 command: "git diff $REV",
45 malformed: true,
46 },
47 {
48 name: "command substitution is shell syntax",
49 command: "echo $(touch out)",
50 malformed: true,
51 },
52 {
53 name: "assignment prefix is shell syntax",
54 command: "GIT_EXTERNAL_DIFF=cat git diff",
55 malformed: true,
56 },
57 {
58 name: "parse failure is malformed",
59 command: "echo 'unterminated",
60 malformed: true,
61 },
62 }
63 for _, tt := range tests {
64 t.Run(tt.name, func(t *testing.T) {
65 got, malformed := StaticFields(tt.command)
66 if tt.malformed {
67 if malformed == "" {
68 t.Fatalf("StaticFields(%q) malformed = empty, want error", tt.command)
69 }
70 return
71 }
72 if malformed != "" {
73 t.Fatalf("StaticFields(%q) malformed = %q", tt.command, malformed)
74 }
75 if !reflect.DeepEqual(got, tt.want) {
76 t.Fatalf("StaticFields(%q) = %#v, want %#v", tt.command, got, tt.want)
77 }
78 })
79 }
80 }
81
82 func TestContainsUnquotedGlob(t *testing.T) {
83 tests := []struct {
84 command string
85 want bool
86 }{
87 {command: "rg TODO *.go", want: true},
88 {command: "rg TODO file?.go", want: true},
89 {command: "rg TODO [ab].go", want: true},
90 {command: `rg TODO "*.go"`},
91 {command: `rg TODO '*.go'`},
92 {command: `rg TODO \*.go`},
93 {command: "git status --short"},
94 }
95 for _, tt := range tests {
96 if got := ContainsUnquotedGlob(tt.command); got != tt.want {
97 t.Errorf("ContainsUnquotedGlob(%q) = %v, want %v", tt.command, got, tt.want)
98 }
99 }
100 }
101
102 func TestAnalyzeApprovalFeaturesMarksNonStaticArgumentsAsExpansion(t *testing.T) {
103 tests := []struct {
104 command string
105 want bool
106 }{
107 {command: "printf '%s\\n' {a,b}", want: true},
108 {command: "printf '%s\\n' @(a|b)", want: true},
109 {command: `printf '%s\\n' "{a,b}"`},
110 {command: `printf '%s\\n' \{a,b\}`},
111 }
112 for _, tt := range tests {
113 command := tt.command
114 features, ok := AnalyzeApprovalFeatures(command)
115 if !ok {
116 t.Fatalf("AnalyzeApprovalFeatures(%q) failed", command)
117 }
118 if features.Expansion != tt.want {
119 t.Errorf("AnalyzeApprovalFeatures(%q) expansion = %v, want %v", command, features.Expansion, tt.want)
120 }
121 }
122 }
123
124 func TestParseStaticCommandPolicy(t *testing.T) {
125 got, err := ParseStaticCommand(`FOO=bar MESSAGE='hello world' go test ./...`, StaticCommandPolicy{AllowEnvAssignments: true})
126 if err != nil {
127 t.Fatalf("ParseStaticCommand env assignment: %v", err)
128 }
129 if !reflect.DeepEqual(got.Env, []string{"FOO=bar", "MESSAGE=hello world"}) {
130 t.Fatalf("Env = %#v", got.Env)
131 }
132 if !reflect.DeepEqual(got.Argv, []string{"go", "test", "./..."}) {
133 t.Fatalf("Argv = %#v", got.Argv)
134 }
135
136 _, err = ParseStaticCommand(`FOO=bar go test`, StaticCommandPolicy{})
137 assertStaticRejectReason(t, err, StaticRejectAssignment)
138
139 _, err = ParseStaticCommand(`FOO=$(whoami) go test`, StaticCommandPolicy{AllowEnvAssignments: true})
140 assertStaticRejectReason(t, err, StaticRejectExpansion)
141
142 _, err = ParseStaticCommand(`go test ./... >out.txt`, StaticCommandPolicy{AllowEnvAssignments: true})
143 assertStaticRejectReason(t, err, StaticRejectRedirection)
144
145 got, err = ParseStaticCommand(`go test ./... 2>&1`, StaticCommandPolicy{AllowStderrToStdout: true})
146 if err != nil {
147 t.Fatalf("ParseStaticCommand stderr merge: %v", err)
148 }
149 if !got.MergeStderr {
150 t.Fatalf("MergeStderr = false, want true")
151 }
152 if !reflect.DeepEqual(got.Argv, []string{"go", "test", "./..."}) {
153 t.Fatalf("Argv with stderr merge = %#v", got.Argv)
154 }
155
156 _, err = ParseStaticCommand(`go test ./... 2>err.txt`, StaticCommandPolicy{AllowStderrToStdout: true})
157 assertStaticRejectReason(t, err, StaticRejectRedirection)
158
159 if _, malformed := StaticFields(`FOO=bar go test`); malformed != "shell control syntax" {
160 t.Fatalf("StaticFields assignment malformed = %q", malformed)
161 }
162 if _, malformed := StaticFields(`go test 2>&1`); malformed != "shell control syntax" {
163 t.Fatalf("StaticFields redirection malformed = %q", malformed)
164 }
165 }
166
167 func assertStaticRejectReason(t *testing.T, err error, want StaticRejectReason) {
168 t.Helper()
169 var reject *StaticRejectError
170 if !errors.As(err, &reject) {
171 t.Fatalf("error = %v (%T), want StaticRejectError", err, err)
172 }
173 if reject.Reason != want {
174 t.Fatalf("reason = %q, want %q (err=%v)", reject.Reason, want, err)
175 }
176 }
177
178 func TestContainsShellSyntax(t *testing.T) {
179 for _, command := range []string{
180 "git status && rm -rf /",
181 "cat a | tee b",
182 "git status > out.txt",
183 "echo $(rm x)",
184 "echo $HOME",
185 "echo `whoami`",
186 "sleep 1 &",
187 } {
188 if !ContainsShellSyntax(command) {
189 t.Fatalf("ContainsShellSyntax(%q) = false, want true", command)
190 }
191 }
192 for _, command := range []string{
193 "git status",
194 `grep 'a|b' file`,
195 `printf "%s\n" "a && b"`,
196 `find . -name scratch \-delete`,
197 } {
198 if ContainsShellSyntax(command) {
199 t.Fatalf("ContainsShellSyntax(%q) = true, want false", command)
200 }
201 }
202 }
203
204 func TestSplitTopLevel(t *testing.T) {
205 tests := []struct {
206 name string
207 command string
208 want []string
209 wantSplit bool
210 wantOK bool
211 }{
212 {
213 name: "atomic command",
214 command: "git status",
215 want: []string{"git status"},
216 wantOK: true,
217 },
218 {
219 name: "and chain",
220 command: `git add . && git commit -m "wip" && git push`,
221 want: []string{"git add .", `git commit -m "wip"`, "git push"},
222 wantSplit: true,
223 wantOK: true,
224 },
225 {
226 name: "semicolon chain",
227 command: "cd /tmp; ls -la",
228 want: []string{"cd /tmp", "ls -la"},
229 wantSplit: true,
230 wantOK: true,
231 },
232 {
233 name: "pipe",
234 command: "git log --oneline | head -20",
235 want: []string{"git log --oneline", "head -20"},
236 wantSplit: true,
237 wantOK: true,
238 },
239 {
240 name: "background",
241 command: "sleep 1 & echo done",
242 want: []string{"sleep 1", "echo done"},
243 wantSplit: true,
244 wantOK: true,
245 },
246 {
247 name: "operator inside quotes stays in segment",
248 command: `echo 'a && b' && ls`,
249 want: []string{`echo 'a && b'`, "ls"},
250 wantSplit: true,
251 wantOK: true,
252 },
253 {
254 name: "command substitution is opaque",
255 command: `echo $(git rev-parse HEAD; date) && ls`,
256 want: []string{`echo $(git rev-parse HEAD; date)`, "ls"},
257 wantSplit: true,
258 wantOK: true,
259 },
260 {
261 name: "process substitution is opaque",
262 command: "diff <(git log -1 | head) <(git show HEAD | head) && ls",
263 want: []string{
264 "diff <(git log -1 | head) <(git show HEAD | head)",
265 "ls",
266 },
267 wantSplit: true,
268 wantOK: true,
269 },
270 {
271 name: "comments are skipped",
272 command: "# comment\nshuf -i 1-30 -n 10 | sort -rn",
273 want: []string{
274 "shuf -i 1-30 -n 10",
275 "sort -rn",
276 },
277 wantSplit: true,
278 wantOK: true,
279 },
280 {
281 name: "heredoc fails closed",
282 command: "cat <<EOF && ls\nline1\nEOF",
283 wantOK: false,
284 },
285 {
286 name: "compound statement fails closed",
287 command: "if true; then ls; fi && pwd",
288 wantOK: false,
289 },
290 }
291 for _, tt := range tests {
292 t.Run(tt.name, func(t *testing.T) {
293 got, split, ok := SplitTopLevel(tt.command)
294 if ok != tt.wantOK {
295 t.Fatalf("ok = %v, want %v (segments=%#v)", ok, tt.wantOK, got)
296 }
297 if !ok {
298 return
299 }
300 if split != tt.wantSplit {
301 t.Fatalf("split = %v, want %v", split, tt.wantSplit)
302 }
303 if !reflect.DeepEqual(got, tt.want) {
304 t.Fatalf("segments = %#v, want %#v", got, tt.want)
305 }
306 })
307 }
308 }
309
310 func TestHasHereDoc(t *testing.T) {
311 file, err := ParseBash(strings.Join([]string{
312 "cat <<'EOF'",
313 "nohup sleep 60 >/dev/null 2>&1 &",
314 "EOF",
315 }, "\n"))
316 if err != nil {
317 t.Fatalf("ParseBash heredoc: %v", err)
318 }
319 if !HasHereDoc(file) {
320 t.Fatal("HasHereDoc = false, want true")
321 }
322 }
323
323 lines GO