dispatch_test.go

  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}