| 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 |