Skip to content

fix(ext/process): run shebang-less scripts via /bin/sh on ENOEXEC#34945

Merged
littledivy merged 3 commits into
mainfrom
orch/divybot-503
Jun 6, 2026
Merged

fix(ext/process): run shebang-less scripts via /bin/sh on ENOEXEC#34945
littledivy merged 3 commits into
mainfrom
orch/divybot-503

Conversation

@divybot

@divybot divybot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Running a script that the kernel can't exec directly (e.g. a shell script without a #! shebang line) via child_process.execFileSync/spawn failed on Linux with Command failed/ENOEXEC, while it works in Node.js:

import { execFileSync } from "node:child_process";
// helpers/script.sh contains: echo "$1 $1"   (with NO shebang line)
execFileSync("./helpers/script.sh", ["hello"], { encoding: "utf8" });
// Node: "hello hello"  —  Deno (Linux): Error: Command failed

Root cause

POSIX execvp/posix_spawnp are specified to fall back to running a file that exec rejects with ENOEXEC through /bin/sh. macOS libc implements this fallback (verified with a C repro), so Deno.Command/execFileSync already worked there — the bug only reproduces on Linux, where glibc's posix_spawnp (used by Rust's std::process::Command) does not fall back. Node.js gets the fallback for free because libuv spawns via execvp.

Fix

When a spawn fails with ENOEXEC, retry the command as an argument to /bin/sh, matching Node.js/libuv. This is applied at all three spawn paths in ext/processDeno.Command().outputSync(), Deno.Command().spawn(), and the node-compat spawn op — so sync and async spawn/spawnSync/exec/execFile all behave consistently.

Notes:

  • Permissions are still checked against the original command (not /bin/sh); the shell is only the interpreter, exactly as execvp does. No --allow-run bypass.
  • Files that are not executable still fail with a permission error (EACCES, not ENOEXEC), so they are not silently run through the shell — matching Node.
  • Windows is unaffected.

Test

Added tests/specs/node/exec_file_sync_no_shebang (unix-only) covering execFileSync, spawnSync, async execFile/spawn, and Deno.Command.

Closes #34919

Closes denoland/divybot#503

divybot and others added 2 commits June 6, 2026 01:35
Running a script that the kernel can't exec directly (e.g. a shell script
without a `#!` shebang line) via `child_process.execFileSync`/`spawn` failed on
Linux with "Command failed"/`ENOEXEC`, while it works in Node.js.

POSIX `execvp`/`posix_spawnp` are specified to fall back to running such files
through `/bin/sh`. macOS libc implements this fallback (so Deno already worked
there), but glibc's `posix_spawnp` — used by Rust's `std::process::Command` on
Linux — does not. Node.js gets the fallback for free because libuv spawns via
`execvp`.

This makes Deno perform the fallback explicitly: when a spawn fails with
`ENOEXEC`, retry the command as an argument to `/bin/sh`, matching Node.js and
libuv. Applied to all three spawn paths (`Deno.Command().outputSync()`,
`Deno.Command().spawn()`, and the node-compat spawn op) so sync and async
`spawn`/`spawnSync`/`exec`/`execFile` all behave consistently. Files that are
not executable still fail with a permission error (`EACCES`, not `ENOEXEC`),
matching Node.

Closes #34919

Co-Authored-By: Divy Srivastava <[email protected]>
…back

Strengthen the ENOEXEC shell-fallback regression test to also verify that a
file without the execute bit is NOT silently run through /bin/sh: the fallback
must only trigger on ENOEXEC (executable but unrecognized format), while a
non-executable file fails with EACCES, matching Node.js. This guards the
fallback against being too eager.

Co-Authored-By: Divy Srivastava <[email protected]>

@littledivy littledivy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compare against Node.jss/libuv internals

…llback

Clarify that the /bin/sh ENOEXEC fallback faithfully replicates glibc's
`execvp` (`maybe_script_execute`): on ENOEXEC it re-execs `/bin/sh` with argv
`[sh, file, args...]`, dropping the original argv[0]. libuv/Node.js inherit
this behavior from `execvp`; Rust's `posix_spawn` on Linux does not, which is
why Deno replicates it explicitly.

Co-Authored-By: Divy Srivastava <[email protected]>
@divybot

divybot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor Author

Compare against Node.js/libuv internals

Did this — summary of the comparison and confirmation the fix matches:

libuv does no shell handling of its own. In uv__process_child_init it just calls execvp(options->file, options->args) (or posix_spawn on the eligible fast path) and writes back errno on failure — there is no /bin/sh fallback in libuv. So the fallback is entirely libc's.

glibc execvp (__execvpemaybe_script_execute) does fall back, including for slash-containing paths:

if (strchr (file, '/') != NULL) {
  __execve (file, argv, envp);
  if (errno == ENOEXEC && exec_script)
    maybe_script_execute (file, argv, envp);   // exec_script = true for execvp
  return -1;
}

maybe_script_execute re-execs with new_argv = [_PATH_BSHELL ("/bin/sh"), file, argv[1], argv[2], ...] — i.e. the original argv[0] is dropped and /bin/sh runs file with argv[1..].

Net behavior, which matches the bug report:

  • Linux: libuv's fork+execvp path hits glibc's fallback → ./script.sh runs via /bin/sh → works (what the reporter sees).
  • macOS: libuv uses posix_spawn, which has no fallback → Node fails with ENOEXEC there (confirmed with a local C repro: posix_spawn returns ENOEXEC, while posix_spawnp/execvp print hello hello).

Deno hits the macOS-style failure on Linux because Rust's std::process::Command uses posix_spawnp, and glibc's posix_spawnp (unlike macOS's) does not implement the script fallback.

This PR replicates glibc's maybe_script_execute exactly:

  • fallback triggers only on ENOEXEC (not EACCES — a non-executable file still fails like Node; covered by the added test);
  • shell is /bin/sh
  • argv is [/bin/sh, cmd, ...args] with the original argv0 dropped (matching maybe_script_execute);
  • applied to sync + async, Deno.Command and the node-compat op.

One intentional minor difference: we pass the resolved command path to /bin/sh (so $0 is absolute) rather than the original relative string, which is more robust across cwd changes and matches glibc's behavior for PATH-resolved names.

@littledivy
littledivy enabled auto-merge (squash) June 6, 2026 03:51
@littledivy
littledivy merged commit ef1460d into main Jun 6, 2026
136 checks passed
@littledivy
littledivy deleted the orch/divybot-503 branch June 6, 2026 03:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Command failed when running execFileSync from node:child_process

2 participants