1package shell
2
3import (
4 "bytes"
5 "crypto/rand"
6 "encoding/hex"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "reflect"
11 "runtime"
12 "strings"
13 "testing"
14)
15
16// writeScript is a small helper that drops a file with the given contents
17// and executable mode into dir. Tests that need exec semantics rely on the
18// 0o755 mode on Unix; Windows ignores file modes but doesn't need them
19// because dispatch decides what to do from file contents, not permissions.
20func writeScript(t *testing.T, dir, name, contents string) string {
21 t.Helper()
22 path := filepath.Join(dir, name)
23 if err := os.WriteFile(path, []byte(contents), 0o755); err != nil {
24 t.Fatalf("write %s: %v", name, err)
25 }
26 return filepath.ToSlash(path)
27}
28
29// randSuffix returns a short random hex string, used to build
30// intentionally-unique paths that won't collide with anything on disk.
31func randSuffix() string {
32 var b [4]byte
33 _, _ = rand.Read(b[:])
34 return hex.EncodeToString(b[:])
35}
36
37// TestIsPathPrefixed covers the classification rules used by the dispatch
38// handler to decide whether argv[0] is a file reference.
39func TestIsPathPrefixed(t *testing.T) {
40 cases := []struct {
41 in string
42 want bool
43 }{
44 {"./foo.sh", true},
45 {"../foo.sh", true},
46 {"/usr/bin/foo", true},
47 {"foo", false},
48 {"foo.sh", false},
49 {"jq", false},
50 {"", false},
51 }
52 for _, c := range cases {
53 if got := isPathPrefixed(c.in); got != c.want {
54 t.Errorf("isPathPrefixed(%q) = %v, want %v", c.in, got, c.want)
55 }
56 }
57
58 if runtime.GOOS == "windows" {
59 winCases := []struct {
60 in string
61 want bool
62 }{
63 {`C:\foo\bar.exe`, true},
64 {`C:/foo/bar.exe`, true},
65 {`c:\foo`, true},
66 {`Z:/x`, true},
67 {`C:`, false}, // just a drive, no path.
68 {`\\server\share`, true},
69 }
70 for _, c := range winCases {
71 if got := isPathPrefixed(c.in); got != c.want {
72 t.Errorf("isPathPrefixed(%q) = %v, want %v", c.in, got, c.want)
73 }
74 }
75 }
76}
77
78// TestParseShebang covers the shebang grammar: literal paths, env,
79// env -S, kernel single-arg semantics, CRLF tolerance, and every
80// enumerated error case.
81func TestParseShebang(t *testing.T) {
82 type want struct {
83 interp string
84 args []string
85 errSub string // substring expected in error message (empty → no error)
86 }
87 cases := []struct {
88 name string
89 in string
90 want want
91 }{
92 {
93 name: "literal-no-args",
94 in: "#!/bin/bash\necho body\n",
95 want: want{interp: "/bin/bash"},
96 },
97 {
98 name: "literal-kernel-single-arg",
99 in: "#!/bin/bash -x -y\n",
100 want: want{interp: "/bin/bash", args: []string{"-x -y"}},
101 },
102 {
103 name: "env-basic",
104 in: "#!/usr/bin/env bash\n",
105 want: want{interp: "bash"},
106 },
107 {
108 name: "env-kernel-single-arg",
109 in: "#!/usr/bin/env bash -x\n",
110 want: want{interp: "bash", args: []string{"-x"}},
111 },
112 {
113 name: "env-dash-S-splits",
114 in: "#!/usr/bin/env -S bash -x\n",
115 want: want{interp: "bash", args: []string{"-x"}},
116 },
117 {
118 name: "env-dash-S-multi-args",
119 in: "#!/usr/bin/env -S bash -x --noprofile\n",
120 want: want{interp: "bash", args: []string{"-x", "--noprofile"}},
121 },
122 {
123 name: "leading-space",
124 in: "#! /usr/bin/env bash\n",
125 want: want{interp: "bash"},
126 },
127 {
128 name: "crlf",
129 in: "#!/bin/bash\r\n",
130 want: want{interp: "/bin/bash"},
131 },
132 {
133 name: "bare-env-name",
134 in: "#!env bash\n",
135 want: want{interp: "bash"},
136 },
137 {
138 name: "empty-after-hashbang",
139 in: "#!\n",
140 want: want{errSub: "empty shebang"},
141 },
142 {
143 name: "env-alone",
144 in: "#!/usr/bin/env\n",
145 want: want{errSub: "missing program name"},
146 },
147 {
148 name: "env-dash-S-alone",
149 in: "#!/usr/bin/env -S\n",
150 want: want{errSub: "env -S requires a program"},
151 },
152 {
153 name: "env-unknown-flag",
154 in: "#!/usr/bin/env -x bash\n",
155 want: want{errSub: "unsupported env flag"},
156 },
157 }
158 for _, c := range cases {
159 t.Run(c.name, func(t *testing.T) {
160 sb, err := parseShebang([]byte(c.in))
161 if c.want.errSub != "" {
162 if err == nil || !strings.Contains(err.Error(), c.want.errSub) {
163 t.Fatalf("expected error containing %q, got: %v", c.want.errSub, err)
164 }
165 return
166 }
167 if err != nil {
168 t.Fatalf("unexpected error: %v", err)
169 }
170 if sb.interpreter != c.want.interp {
171 t.Errorf("interpreter = %q, want %q", sb.interpreter, c.want.interp)
172 }
173 if !equalStringSlice(sb.args, c.want.args) {
174 t.Errorf("args = %v, want %v", sb.args, c.want.args)
175 }
176 })
177 }
178}
179
180func equalStringSlice(a, b []string) bool {
181 if len(a) == 0 && len(b) == 0 {
182 return true
183 }
184 return reflect.DeepEqual(a, b)
185}
186
187// TestIsBinary covers the NUL-byte and magic-byte classification used to
188// keep compiled executables off the in-process shell-source path.
189func TestIsBinary(t *testing.T) {
190 cases := []struct {
191 name string
192 in []byte
193 want bool
194 }{
195 {"shell", []byte("echo hi\n"), false},
196 {"nul", []byte("hello\x00world"), true},
197 {"elf", []byte{0x7F, 'E', 'L', 'F', 0x02, 0x01}, true},
198 {"mz", []byte("MZ\x90\x00"), true},
199 {"macho-64-le", []byte{0xCF, 0xFA, 0xED, 0xFE}, true},
200 {"short-non-binary", []byte("a"), false},
201 }
202 for _, c := range cases {
203 if got := isBinary(c.in); got != c.want {
204 t.Errorf("%s: isBinary = %v, want %v", c.name, got, c.want)
205 }
206 }
207}
208
209// TestDispatch_ShellSourceNoShebang exercises the in-process shell-source
210// branch: a file without a shebang runs via a nested runner and sees
211// positional params from argv[1:].
212func TestDispatch_ShellSourceNoShebang(t *testing.T) {
213 dir := t.TempDir()
214 script := writeScript(t, dir, "args.sh", `echo "$1 $2"`)
215
216 var stdout bytes.Buffer
217 err := Run(t.Context(), RunOptions{
218 Command: script + " alpha beta",
219 Cwd: dir,
220 Stdout: &stdout,
221 })
222 if err != nil {
223 t.Fatalf("Run returned error: %v", err)
224 }
225 if got := stdout.String(); got != "alpha beta\n" {
226 t.Fatalf("stdout = %q, want %q", got, "alpha beta\n")
227 }
228}
229
230// TestDispatch_EmptyFile confirms a zero-byte script runs as empty shell
231// source (exit 0, no output).
232func TestDispatch_EmptyFile(t *testing.T) {
233 dir := t.TempDir()
234 script := writeScript(t, dir, "empty.sh", "")
235
236 var stdout, stderr bytes.Buffer
237 err := Run(t.Context(), RunOptions{
238 Command: script,
239 Cwd: dir,
240 Stdout: &stdout,
241 Stderr: &stderr,
242 })
243 if err != nil {
244 t.Fatalf("Run returned error: %v (stderr=%q)", err, stderr.String())
245 }
246 if stdout.Len() != 0 || stderr.Len() != 0 {
247 t.Fatalf("expected empty output, got stdout=%q stderr=%q", stdout.String(), stderr.String())
248 }
249}
250
251// TestDispatch_ShellSourceComposesWithPipe confirms the dispatch handler
252// plays nicely with mvdan's pipeline logic: a shell-source script on the
253// left feeds the jq builtin on the right.
254func TestDispatch_ShellSourceComposesWithPipe(t *testing.T) {
255 dir := t.TempDir()
256 script := writeScript(t, dir, "emit.sh", `printf '"value"'`)
257
258 var stdout bytes.Buffer
259 err := Run(t.Context(), RunOptions{
260 Command: script + ` | jq -r .`,
261 Cwd: dir,
262 Stdout: &stdout,
263 })
264 if err != nil {
265 t.Fatalf("Run returned error: %v", err)
266 }
267 if got := stdout.String(); got != "value\n" {
268 t.Fatalf("stdout = %q, want %q", got, "value\n")
269 }
270}
271
272// TestDispatch_MissingFile returns a clean error for a non-existent path.
273func TestDispatch_MissingFile(t *testing.T) {
274 dir := t.TempDir()
275 missing := filepath.Join(dir, "nope.sh")
276 err := Run(t.Context(), RunOptions{
277 Command: missing,
278 Cwd: dir,
279 })
280 if err == nil {
281 t.Fatal("expected error for missing script, got nil")
282 }
283}
284
285// TestDispatch_DirectoryNotFile surfaces a distinct error when the path
286// resolves to a directory.
287func TestDispatch_DirectoryNotFile(t *testing.T) {
288 dir := t.TempDir()
289 subDir := filepath.Join(dir, "adir")
290 if err := os.MkdirAll(subDir, 0o755); err != nil {
291 t.Fatalf("mkdir: %v", err)
292 }
293
294 var stderr bytes.Buffer
295 err := Run(t.Context(), RunOptions{
296 Command: "./adir",
297 Cwd: dir,
298 Stderr: &stderr,
299 })
300 if err == nil {
301 t.Fatal("expected error when invoking a directory, got nil")
302 }
303 if !strings.Contains(err.Error(), "is a directory") {
304 t.Fatalf("expected 'is a directory' in error, got: %v", err)
305 }
306}
307
308// TestDispatch_BashShebang runs a #!/bin/bash script via os/exec. Skipped
309// if bash isn't available (rare in CI, but keep the test robust).
310func TestDispatch_BashShebang(t *testing.T) {
311 bash, err := exec.LookPath("bash")
312 if err != nil {
313 t.Skipf("bash not in PATH: %v", err)
314 }
315 _ = bash
316
317 dir := t.TempDir()
318 script := writeScript(t, dir, "bash-echo.sh", "#!/usr/bin/env bash\necho bashout\n")
319
320 var stdout, stderr bytes.Buffer
321 err = Run(t.Context(), RunOptions{
322 Command: script,
323 Cwd: dir,
324 Stdout: &stdout,
325 Stderr: &stderr,
326 })
327 if err != nil {
328 t.Fatalf("Run returned error: %v (stderr=%q)", err, stderr.String())
329 }
330 if got := stdout.String(); got != "bashout\n" {
331 t.Fatalf("stdout = %q, want %q", got, "bashout\n")
332 }
333}
334
335// TestDispatch_ShebangPassesExitCode maps interpreter exit codes through to
336// interp.ExitStatus so the caller can inspect them with ExitCode.
337func TestDispatch_ShebangPassesExitCode(t *testing.T) {
338 if _, err := exec.LookPath("bash"); err != nil {
339 t.Skipf("bash not in PATH: %v", err)
340 }
341 dir := t.TempDir()
342 script := writeScript(t, dir, "fail.sh", "#!/usr/bin/env bash\nexit 5\n")
343
344 err := Run(t.Context(), RunOptions{
345 Command: script,
346 Cwd: dir,
347 })
348 if err == nil {
349 t.Fatal("expected non-nil error from exit 5")
350 }
351 if code := ExitCode(err); code != 5 {
352 t.Fatalf("ExitCode = %d, want 5", code)
353 }
354}
355
356// TestDispatch_MissingInterpreter surfaces a clear error (and non-zero
357// exit) when the shebang points to a binary that doesn't exist and has
358// no PATH fallback.
359func TestDispatch_MissingInterpreter(t *testing.T) {
360 dir := t.TempDir()
361 script := writeScript(t, dir, "bad.sh", "#!/no/such/interpreter-"+randSuffix()+"\n:\n")
362
363 var stderr bytes.Buffer
364 err := Run(t.Context(), RunOptions{
365 Command: script,
366 Cwd: dir,
367 Stderr: &stderr,
368 })
369 if err == nil {
370 t.Fatal("expected error for missing interpreter, got nil")
371 }
372 if ExitCode(err) == 0 {
373 t.Fatalf("expected non-zero exit code, got 0")
374 }
375 if !strings.Contains(stderr.String(), "not found") {
376 t.Fatalf("expected 'not found' in stderr, got: %q", stderr.String())
377 }
378}
379
380// TestDispatch_BarePathNotHandled confirms the handler ignores
381// non-path-prefixed argv[0] entirely: a benign bare `true` command must
382// not try to open a file in cwd. If dispatch were (incorrectly) firing
383// on bare commands, this test would see probeFile's ENOENT.
384func TestDispatch_BarePathNotHandled(t *testing.T) {
385 dir := t.TempDir()
386 err := Run(t.Context(), RunOptions{
387 Command: "true",
388 Cwd: dir,
389 })
390 if err != nil {
391 t.Fatalf("bare `true` should not trigger dispatch: %v", err)
392 }
393}
394
395// TestDispatch_ProbeWindowClassifiesByHead confirms that classification is
396// done on the first probeWindow bytes even when the file is much larger;
397// a file whose head is shell source but whose tail contains NUL bytes is
398// classified as shell source, not binary.
399func TestDispatch_ProbeWindowClassifiesByHead(t *testing.T) {
400 dir := t.TempDir()
401 head := "echo prefix\n"
402 // Pad past probeWindow, then append some NULs.
403 padding := strings.Repeat(" ", probeWindow)
404 contents := head + padding + "\x00\x00\x00"
405 script := writeScript(t, dir, "long.sh", contents)
406
407 var stdout bytes.Buffer
408 err := Run(t.Context(), RunOptions{
409 Command: script,
410 Cwd: dir,
411 Stdout: &stdout,
412 })
413 if err != nil {
414 t.Fatalf("Run returned error: %v", err)
415 }
416 if got := stdout.String(); !strings.HasPrefix(got, "prefix\n") {
417 t.Fatalf("stdout = %q, want prefix %q", got, "prefix\n")
418 }
419}
420
421// TestDispatch_BinaryPassthroughExecutes copies a real binary from PATH
422// into a tempdir, invokes it via a path-prefixed argv[0], and verifies it
423// ran — i.e. the binary branch correctly returns through `next` to the
424// default exec handler. We use whichever of `true`/`echo` is available on
425// PATH so the test works on any Unix-y system; it skips on Windows where
426// the stock binaries don't share names and the Go test binary approach
427// is heavier than this test deserves.
428func TestDispatch_BinaryPassthroughExecutes(t *testing.T) {
429 if runtime.GOOS == "windows" {
430 t.Skip("relies on a Unix-style PATH binary")
431 }
432 src, err := exec.LookPath("true")
433 if err != nil {
434 t.Skipf("no `true` binary on PATH: %v", err)
435 }
436 data, err := os.ReadFile(src)
437 if err != nil {
438 t.Fatalf("read %s: %v", src, err)
439 }
440 dir := t.TempDir()
441 dst := filepath.Join(dir, "copied-true")
442 if err := os.WriteFile(dst, data, 0o755); err != nil {
443 t.Fatalf("write %s: %v", dst, err)
444 }
445
446 runErr := Run(t.Context(), RunOptions{
447 Command: dst,
448 Cwd: dir,
449 // Default handler needs PATH to resolve dynamic linker / loader
450 // helpers on some systems; inherit the process env so the copy
451 // can actually start.
452 Env: os.Environ(),
453 })
454 if runErr != nil {
455 t.Fatalf("expected copy of /bin/true to exit 0, got: %v", runErr)
456 }
457}
458
459// TestDispatch_UnreadableFile confirms an EACCES on the script surfaces
460// as a clean error rather than a silent fallback or a mis-classified
461// shell-source attempt. POSIX-only: Windows doesn't have the same
462// permission model and running as root would bypass the check anyway.
463func TestDispatch_UnreadableFile(t *testing.T) {
464 if runtime.GOOS == "windows" {
465 t.Skip("POSIX permission model")
466 }
467 if os.Geteuid() == 0 {
468 t.Skip("root bypasses file mode permission checks")
469 }
470 dir := t.TempDir()
471 script := writeScript(t, dir, "unreadable.sh", "echo nope\n")
472 if err := os.Chmod(script, 0o000); err != nil {
473 t.Fatalf("chmod: %v", err)
474 }
475 t.Cleanup(func() { _ = os.Chmod(script, 0o644) })
476
477 err := Run(t.Context(), RunOptions{
478 Command: script,
479 Cwd: dir,
480 })
481 if err == nil {
482 t.Fatal("expected permission error, got nil")
483 }
484 if !strings.Contains(err.Error(), "permission") {
485 t.Fatalf("expected 'permission' in error, got: %v", err)
486 }
487}
488
489// TestDispatch_SymlinkLoop confirms that an ELOOP-returning path surfaces
490// cleanly. POSIX-only: creating symlinks reliably on Windows requires
491// elevated privileges or developer mode, and neither is guaranteed in CI.
492func TestDispatch_SymlinkLoop(t *testing.T) {
493 if runtime.GOOS == "windows" {
494 t.Skip("symlink creation requires special privileges on Windows")
495 }
496 dir := t.TempDir()
497 a := filepath.Join(dir, "a")
498 b := filepath.Join(dir, "b")
499 if err := os.Symlink(b, a); err != nil {
500 t.Fatalf("symlink a→b: %v", err)
501 }
502 if err := os.Symlink(a, b); err != nil {
503 t.Fatalf("symlink b→a: %v", err)
504 }
505
506 err := Run(t.Context(), RunOptions{
507 Command: a,
508 Cwd: dir,
509 })
510 if err == nil {
511 t.Fatal("expected loop error, got nil")
512 }
513 // The exact error varies by OS; any of these message fragments is
514 // acceptable evidence that the loop was detected.
515 msg := err.Error()
516 if !strings.Contains(msg, "too many") &&
517 !strings.Contains(msg, "loop") &&
518 !strings.Contains(msg, "level") {
519 t.Fatalf("expected symlink-loop-ish error, got: %v", err)
520 }
521}
522
523// TestResolveInterpreter_PermissiveFallback confirms the key portability
524// behavior: a literal shebang path that doesn't exist falls back to a
525// PATH-lookup on its basename. This is what makes #!/bin/bash work on a
526// Windows box where bash.exe lives somewhere else on PATH. We construct a
527// fake PATH in a tempdir rather than depending on what the host has
528// installed so the test is deterministic everywhere.
529func TestResolveInterpreter_PermissiveFallback(t *testing.T) {
530 if runtime.GOOS == "windows" {
531 // exec.LookPath on Windows requires a recognized extension
532 // (.exe/.bat/.cmd). Producing one of those without a compiler
533 // run is more ceremony than this smoke test deserves; the
534 // logic under test is exercised by the Unix run.
535 t.Skip("Windows PATH lookup requires an extension-matched binary")
536 }
537 dir := t.TempDir()
538 fake := filepath.Join(dir, "bash")
539 if err := os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
540 t.Fatalf("write fake bash: %v", err)
541 }
542 t.Setenv("PATH", dir)
543
544 // Basename must match the fake we planted on PATH; the directory
545 // prefix must not exist so the literal stat fails.
546 missingDir := filepath.Join(dir, "definitely-not-here-"+randSuffix())
547 resolved, err := resolveInterpreter(filepath.Join(missingDir, "bash"))
548 if err != nil {
549 t.Fatalf("expected fallback to succeed, got: %v", err)
550 }
551 if resolved != fake {
552 t.Fatalf("resolved = %q, want %q", resolved, fake)
553 }
554}
555
556// TestResolveInterpreter_NonENOENTErrorsSurface guards against silently
557// falling back to PATH when stat fails for a reason other than the file
558// being missing. With a directory at the shebang path, os.Stat succeeds
559// (no fallback needed), but with an EACCES'd file it fails with a non-
560// ENOENT error that must be surfaced — otherwise we'd silently resolve a
561// different binary off PATH and hide the real problem.
562func TestResolveInterpreter_NonENOENTErrorsSurface(t *testing.T) {
563 if runtime.GOOS == "windows" {
564 t.Skip("POSIX permission model")
565 }
566 if os.Geteuid() == 0 {
567 t.Skip("root bypasses dir mode permission checks")
568 }
569 dir := t.TempDir()
570 // Put a candidate interpreter inside an unreadable/untraversable dir.
571 inner := filepath.Join(dir, "private")
572 if err := os.Mkdir(inner, 0o755); err != nil {
573 t.Fatalf("mkdir: %v", err)
574 }
575 interp := filepath.Join(inner, "bash")
576 if err := os.WriteFile(interp, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
577 t.Fatalf("write interpreter: %v", err)
578 }
579 // Drop search permission on inner so os.Stat(interp) returns EACCES.
580 if err := os.Chmod(inner, 0o000); err != nil {
581 t.Fatalf("chmod: %v", err)
582 }
583 t.Cleanup(func() { _ = os.Chmod(inner, 0o755) })
584
585 _, err := resolveInterpreter(interp)
586 if err == nil {
587 t.Fatal("expected error for unreadable interpreter, got nil")
588 }
589 // Must NOT have silently fallen back — the returned path shouldn't
590 // be a valid resolution; either way, the error has to surface.
591 if !strings.Contains(err.Error(), "permission") {
592 t.Fatalf("expected permission-denied error to surface, got: %v", err)
593 }
594}