返回 DeepSeek-Reasonix
execution_contract_test.go
根目录 / internal / hook / execution_contract_test.go
1 package hook
2
3 import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "os"
9 "os/exec"
10 "reflect"
11 "runtime"
12 "strings"
13 "testing"
14 "time"
15 "unicode/utf16"
16 "unicode/utf8"
17
18 "reasonix/internal/sandbox"
19 )
20
21 func TestHookExecHelperProcess(t *testing.T) {
22 if os.Getenv("REASONIX_HOOK_EXEC_HELPER") != "1" {
23 return
24 }
25 for i, arg := range os.Args {
26 if arg != "--" {
27 continue
28 }
29 if err := json.NewEncoder(os.Stdout).Encode(os.Args[i+1:]); err != nil {
30 os.Exit(2)
31 }
32 os.Exit(0)
33 }
34 os.Exit(3)
35 }
36
37 func TestExecFormPreservesLiteralArgumentsEndToEnd(t *testing.T) {
38 executable, err := os.Executable()
39 if err != nil {
40 t.Fatal(err)
41 }
42 want := []string{
43 "",
44 " leading and trailing ",
45 "$HOME",
46 "%PATH%",
47 "!DELAYED!",
48 `a && b | c > out`,
49 `double"quote`,
50 "single'quote",
51 `C:\Program Files\Reasonix\hook.cmd`,
52 "第一行\n第二行",
53 "emoji-🧪",
54 }
55 args := append([]string{"-test.run=^TestHookExecHelperProcess$", "--"}, want...)
56 result := DefaultSpawner(context.Background(), SpawnInput{
57 Command: executable,
58 Args: args,
59 Mode: ExecutionExec,
60 Env: map[string]string{"REASONIX_HOOK_EXEC_HELPER": "1"},
61 Timeout: realSpawnTimeout,
62 })
63 if result.ExitCode != 0 || result.SpawnErr != nil {
64 t.Fatalf("exec-form helper failed: %+v", result)
65 }
66 var got []string
67 if err := json.Unmarshal([]byte(result.Stdout), &got); err != nil {
68 t.Fatalf("decode helper output %q: %v", result.Stdout, err)
69 }
70 if !reflect.DeepEqual(got, want) {
71 t.Fatalf("literal argv changed:\n got %#v\nwant %#v", got, want)
72 }
73 }
74
75 func TestSpawnCommandExecutionContractMatrix(t *testing.T) {
76 executable, err := os.Executable()
77 if err != nil {
78 t.Fatal(err)
79 }
80 literalArgs := []string{"", "$VALUE", "a && b", `nested"quote`}
81 cmd, err := spawnCommand(context.Background(), executable, ExecutionExec, "bash", literalArgs, RuntimeOptions{})
82 if err != nil {
83 t.Fatal(err)
84 }
85 if !reflect.DeepEqual(cmd.Args[1:], literalArgs) {
86 t.Fatalf("exec argv = %#v, want %#v", cmd.Args[1:], literalArgs)
87 }
88
89 if _, err := spawnCommand(context.Background(), "ignored", ExecutionMode("future"), "", nil, RuntimeOptions{}); err == nil ||
90 !strings.Contains(err.Error(), "unsupported hook execution mode") {
91 t.Fatalf("unknown execution mode error = %v", err)
92 }
93 if _, err := spawnCommand(context.Background(), "ignored", ExecutionShell, "fish", nil, RuntimeOptions{}); err == nil ||
94 !strings.Contains(err.Error(), "unsupported hook shell") {
95 t.Fatalf("unknown shell error = %v", err)
96 }
97 if runtime.GOOS != "windows" {
98 if _, err := spawnCommand(context.Background(), "echo ok", ExecutionShell, "cmd", nil, RuntimeOptions{}); err == nil ||
99 !strings.Contains(err.Error(), "only available on Windows") {
100 t.Fatalf("non-Windows cmd error = %v", err)
101 }
102 }
103 }
104
105 func TestShellSelectionBuildsExactInterpreterArgv(t *testing.T) {
106 if runtime.GOOS == "windows" {
107 t.Skip("Windows interpreter selection has native runtime tests")
108 }
109 script := `printf '%s' "a && b"`
110 tests := []struct {
111 name string
112 preferred string
113 wantPath string
114 }{
115 {name: "default", preferred: "", wantPath: "sh"},
116 {name: "auto", preferred: "auto", wantPath: "sh"},
117 {name: "bash", preferred: "bash", wantPath: "bash"},
118 }
119 for _, tt := range tests {
120 t.Run(tt.name, func(t *testing.T) {
121 cmd, err := spawnShellCommand(context.Background(), script, tt.preferred, RuntimeOptions{})
122 if err != nil {
123 t.Fatal(err)
124 }
125 if got := cmd.Args; len(got) != 3 || got[0] != tt.wantPath || got[1] != "-c" || got[2] != script {
126 t.Fatalf("shell argv = %#v, want [%q -c <exact script>]", got, tt.wantPath)
127 }
128 })
129 }
130
131 if _, err := exec.LookPath("pwsh"); err != nil {
132 if _, err := spawnShellCommand(context.Background(), script, "pwsh", RuntimeOptions{}); err == nil ||
133 !strings.Contains(err.Error(), "no usable PowerShell") {
134 t.Fatalf("missing pwsh error = %v", err)
135 }
136 }
137 }
138
139 func TestRawShellCommandPreservesScriptForResolvedShells(t *testing.T) {
140 if runtime.GOOS == "windows" {
141 t.Skip("uses POSIX executable paths for deterministic argv inspection")
142 }
143 script := `printf '%s' '"nested" && literal'`
144 bashCmd, err := rawShellCommand(context.Background(), sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/sh"}, script)
145 if err != nil {
146 t.Fatal(err)
147 }
148 if got, want := bashCmd.Args, []string{"/bin/sh", "-c", script}; !reflect.DeepEqual(got, want) {
149 t.Fatalf("raw Bash argv = %#v, want %#v", got, want)
150 }
151
152 powerShellScript := `$value = "a && 'b'"; Write-Output $value`
153 powerShellCmd, err := rawShellCommand(context.Background(), sandbox.Shell{
154 Kind: sandbox.ShellPowerShell,
155 Path: "/bin/sh",
156 }, powerShellScript)
157 if err != nil {
158 t.Fatal(err)
159 }
160 decoded, err := decodePowerShellCommandForTest(powerShellCmd.Args[4])
161 if err != nil {
162 t.Fatal(err)
163 }
164 if want := sandbox.PowerShellUTF8Script(powerShellScript); decoded != want {
165 t.Fatalf("PowerShell script = %q, want %q", decoded, want)
166 }
167 }
168
169 func TestResolvedHookShellPathAcceptsExecutableAndRejectsMissing(t *testing.T) {
170 if runtime.GOOS == "windows" {
171 t.Skip("uses POSIX executable paths")
172 }
173 got, err := resolvedHookShellPath(sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/sh"})
174 if err != nil || got != "/bin/sh" {
175 t.Fatalf("resolved /bin/sh = %q, %v", got, err)
176 }
177 if _, err := resolvedHookShellPath(sandbox.Shell{Kind: sandbox.ShellBash, Path: "/definitely/missing/reasonix-hook-shell"}); err == nil {
178 t.Fatal("missing absolute shell unexpectedly resolved")
179 }
180 }
181
182 func TestBashShellFormComplexCommandMatrix(t *testing.T) {
183 if runtime.GOOS == "windows" {
184 t.Skip("Windows shell-form coverage lives in windows_batch_test.go")
185 }
186 tests := []struct {
187 name string
188 command string
189 stdin string
190 env map[string]string
191 want string
192 }{
193 {
194 name: "operators inside literal quotes",
195 command: `printf '%s' 'a && b | c > out'`,
196 want: `a && b | c > out`,
197 },
198 {
199 name: "nested quotes and variable expansion",
200 command: `value='single "double"'; printf '%s:%s' "$HOOK_VALUE" "$value"`,
201 env: map[string]string{"HOOK_VALUE": "expanded"},
202 want: `expanded:single "double"`,
203 },
204 {
205 name: "pipeline",
206 command: `printf 'left\nright\n' | tail -n 1`,
207 want: "right",
208 },
209 {
210 name: "subshell and chaining",
211 command: `(printf one; printf two) && printf three`,
212 want: "onetwothree",
213 },
214 {
215 name: "command substitution",
216 command: `printf '<%s>' "$(printf nested)"`,
217 want: "<nested>",
218 },
219 {
220 name: "stdin",
221 command: `IFS= read -r value; printf '%s' "$value"`,
222 stdin: `payload "quoted" && literal`,
223 want: `payload "quoted" && literal`,
224 },
225 }
226 for _, tt := range tests {
227 t.Run(tt.name, func(t *testing.T) {
228 result := DefaultSpawner(context.Background(), SpawnInput{
229 Command: tt.command,
230 Mode: ExecutionShell,
231 Shell: "bash",
232 Env: tt.env,
233 Stdin: tt.stdin,
234 Timeout: realSpawnTimeout,
235 })
236 if result.ExitCode != 0 || result.SpawnErr != nil || result.Stdout != tt.want {
237 t.Fatalf("shell-form result = %+v, want stdout %q", result, tt.want)
238 }
239 })
240 }
241 }
242
243 func TestShellFormHonorsExitStderrAndTimeout(t *testing.T) {
244 if runtime.GOOS == "windows" {
245 t.Skip("uses Bash")
246 }
247 failed := DefaultSpawner(context.Background(), SpawnInput{
248 Command: `printf 'problem' >&2; exit 7`,
249 Mode: ExecutionShell,
250 Shell: "bash",
251 Timeout: realSpawnTimeout,
252 })
253 if failed.ExitCode != 7 || failed.Stderr != "problem" || failed.SpawnErr != nil {
254 t.Fatalf("shell failure result = %+v", failed)
255 }
256
257 timedOut := DefaultSpawner(context.Background(), SpawnInput{
258 Command: "sleep 5",
259 Mode: ExecutionShell,
260 Shell: "bash",
261 Timeout: 50 * time.Millisecond,
262 })
263 if !timedOut.TimedOut || timedOut.ExitCode != -1 {
264 t.Fatalf("shell timeout result = %+v", timedOut)
265 }
266 }
267
268 func decodePowerShellCommandForTest(encoded string) (string, error) {
269 raw, err := base64.StdEncoding.DecodeString(encoded)
270 if err != nil {
271 return "", err
272 }
273 if len(raw)%2 != 0 {
274 return "", &oddUTF16LengthError{length: len(raw)}
275 }
276 units := make([]uint16, len(raw)/2)
277 for i := range units {
278 units[i] = uint16(raw[i*2]) | uint16(raw[i*2+1])<<8
279 }
280 return string(utf16.Decode(units)), nil
281 }
282
283 type oddUTF16LengthError struct {
284 length int
285 }
286
287 func (e *oddUTF16LengthError) Error() string {
288 return fmt.Sprintf("odd UTF-16LE byte length %d", e.length)
289 }
290
291 func FuzzPowerShellCommandEncodingRoundTrip(f *testing.F) {
292 for _, seed := range []string{
293 "",
294 `Write-Output "a && 'b'"`,
295 `$value = "C:\Program Files\Reasonix"; $value`,
296 "第一行\n第二行",
297 "Write-Output '🧪'",
298 "`$literal; $(Write-Output nested)",
299 } {
300 f.Add(seed)
301 }
302 f.Fuzz(func(t *testing.T, script string) {
303 if !utf8.ValidString(script) {
304 t.Skip()
305 }
306 cmd := powerShellCommand(context.Background(), "powershell", script)
307 got, err := decodePowerShellCommandForTest(cmd.Args[4])
308 if err != nil {
309 t.Fatal(err)
310 }
311 want := sandbox.PowerShellUTF8Script(script)
312 if got != want {
313 t.Fatalf("PowerShell script changed:\n got %q\nwant %q", got, want)
314 }
315 })
316 }
317
317 lines GO