package shellparse import ( "errors" "reflect" "strings" "testing" ) func TestStaticFields(t *testing.T) { tests := []struct { name string command string want []string malformed bool }{ { name: "plain command", command: "git status --short", want: []string{"git", "status", "--short"}, }, { name: "quoted static fields", command: `grep 'a|b' "file name.txt"`, want: []string{"grep", "a|b", "file name.txt"}, }, { name: "escaped static field", command: `find . -name scratch \-delete`, want: []string{"find", ".", "-name", "scratch", "-delete"}, }, { name: "redirect is shell syntax", command: "git log >/dev/null", malformed: true, }, { name: "control operator is shell syntax", command: "git status && rm -rf /tmp/x", malformed: true, }, { name: "parameter expansion is shell syntax", command: "git diff $REV", malformed: true, }, { name: "command substitution is shell syntax", command: "echo $(touch out)", malformed: true, }, { name: "assignment prefix is shell syntax", command: "GIT_EXTERNAL_DIFF=cat git diff", malformed: true, }, { name: "parse failure is malformed", command: "echo 'unterminated", malformed: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, malformed := StaticFields(tt.command) if tt.malformed { if malformed == "" { t.Fatalf("StaticFields(%q) malformed = empty, want error", tt.command) } return } if malformed != "" { t.Fatalf("StaticFields(%q) malformed = %q", tt.command, malformed) } if !reflect.DeepEqual(got, tt.want) { t.Fatalf("StaticFields(%q) = %#v, want %#v", tt.command, got, tt.want) } }) } } func TestParseStaticCommandPolicy(t *testing.T) { got, err := ParseStaticCommand(`FOO=bar MESSAGE='hello world' go test ./...`, StaticCommandPolicy{AllowEnvAssignments: true}) if err != nil { t.Fatalf("ParseStaticCommand env assignment: %v", err) } if !reflect.DeepEqual(got.Env, []string{"FOO=bar", "MESSAGE=hello world"}) { t.Fatalf("Env = %#v", got.Env) } if !reflect.DeepEqual(got.Argv, []string{"go", "test", "./..."}) { t.Fatalf("Argv = %#v", got.Argv) } _, err = ParseStaticCommand(`FOO=bar go test`, StaticCommandPolicy{}) assertStaticRejectReason(t, err, StaticRejectAssignment) _, err = ParseStaticCommand(`FOO=$(whoami) go test`, StaticCommandPolicy{AllowEnvAssignments: true}) assertStaticRejectReason(t, err, StaticRejectExpansion) _, err = ParseStaticCommand(`go test ./... >out.txt`, StaticCommandPolicy{AllowEnvAssignments: true}) assertStaticRejectReason(t, err, StaticRejectRedirection) got, err = ParseStaticCommand(`go test ./... 2>&1`, StaticCommandPolicy{AllowStderrToStdout: true}) if err != nil { t.Fatalf("ParseStaticCommand stderr merge: %v", err) } if !got.MergeStderr { t.Fatalf("MergeStderr = false, want true") } if !reflect.DeepEqual(got.Argv, []string{"go", "test", "./..."}) { t.Fatalf("Argv with stderr merge = %#v", got.Argv) } _, err = ParseStaticCommand(`go test ./... 2>err.txt`, StaticCommandPolicy{AllowStderrToStdout: true}) assertStaticRejectReason(t, err, StaticRejectRedirection) if _, malformed := StaticFields(`FOO=bar go test`); malformed != "shell control syntax" { t.Fatalf("StaticFields assignment malformed = %q", malformed) } if _, malformed := StaticFields(`go test 2>&1`); malformed != "shell control syntax" { t.Fatalf("StaticFields redirection malformed = %q", malformed) } } func assertStaticRejectReason(t *testing.T, err error, want StaticRejectReason) { t.Helper() var reject *StaticRejectError if !errors.As(err, &reject) { t.Fatalf("error = %v (%T), want StaticRejectError", err, err) } if reject.Reason != want { t.Fatalf("reason = %q, want %q (err=%v)", reject.Reason, want, err) } } func TestContainsShellSyntax(t *testing.T) { for _, command := range []string{ "git status && rm -rf /", "cat a | tee b", "git status > out.txt", "echo $(rm x)", "echo $HOME", "echo `whoami`", "sleep 1 &", } { if !ContainsShellSyntax(command) { t.Fatalf("ContainsShellSyntax(%q) = false, want true", command) } } for _, command := range []string{ "git status", `grep 'a|b' file`, `printf "%s\n" "a && b"`, `find . -name scratch \-delete`, } { if ContainsShellSyntax(command) { t.Fatalf("ContainsShellSyntax(%q) = true, want false", command) } } } func TestSplitTopLevel(t *testing.T) { tests := []struct { name string command string want []string wantSplit bool wantOK bool }{ { name: "atomic command", command: "git status", want: []string{"git status"}, wantOK: true, }, { name: "and chain", command: `git add . && git commit -m "wip" && git push`, want: []string{"git add .", `git commit -m "wip"`, "git push"}, wantSplit: true, wantOK: true, }, { name: "semicolon chain", command: "cd /tmp; ls -la", want: []string{"cd /tmp", "ls -la"}, wantSplit: true, wantOK: true, }, { name: "pipe", command: "git log --oneline | head -20", want: []string{"git log --oneline", "head -20"}, wantSplit: true, wantOK: true, }, { name: "background", command: "sleep 1 & echo done", want: []string{"sleep 1", "echo done"}, wantSplit: true, wantOK: true, }, { name: "operator inside quotes stays in segment", command: `echo 'a && b' && ls`, want: []string{`echo 'a && b'`, "ls"}, wantSplit: true, wantOK: true, }, { name: "command substitution is opaque", command: `echo $(git rev-parse HEAD; date) && ls`, want: []string{`echo $(git rev-parse HEAD; date)`, "ls"}, wantSplit: true, wantOK: true, }, { name: "process substitution is opaque", command: "diff <(git log -1 | head) <(git show HEAD | head) && ls", want: []string{ "diff <(git log -1 | head) <(git show HEAD | head)", "ls", }, wantSplit: true, wantOK: true, }, { name: "comments are skipped", command: "# comment\nshuf -i 1-30 -n 10 | sort -rn", want: []string{ "shuf -i 1-30 -n 10", "sort -rn", }, wantSplit: true, wantOK: true, }, { name: "heredoc fails closed", command: "cat </dev/null 2>&1 &", "EOF", }, "\n")) if err != nil { t.Fatalf("ParseBash heredoc: %v", err) } if !HasHereDoc(file) { t.Fatal("HasHereDoc = false, want true") } }