@@ -0,0 +1,594 @@
1+package shell
2+
3+import (
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.
20+func 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.
31+func 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.
39+func 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.
81+func 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+
180+func 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.
189+func 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:].
212+func 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).
232+func 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.
254+func 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.
273+func 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.
287+func 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).
310+func 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.
337+func 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.
359+func 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.
384+func 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.
399+func 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.
428+func 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.
463+func 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.
492+func 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.
529+func 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.
562+func 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+}