fix(ext/process): run shebang-less scripts via /bin/sh on ENOEXEC#34945
Conversation
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
left a comment
There was a problem hiding this comment.
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]>
Did this — summary of the comparison and confirmation the fix matches: libuv does no shell handling of its own. In glibc 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;
}
Net behavior, which matches the bug report:
Deno hits the macOS-style failure on Linux because Rust's This PR replicates glibc's
One intentional minor difference: we pass the resolved command path to |
Running a script that the kernel can't exec directly (e.g. a shell script without a
#!shebang line) viachild_process.execFileSync/spawnfailed on Linux withCommand failed/ENOEXEC, while it works in Node.js:Root cause
POSIX
execvp/posix_spawnpare specified to fall back to running a file that exec rejects withENOEXECthrough/bin/sh. macOS libc implements this fallback (verified with a C repro), soDeno.Command/execFileSyncalready worked there — the bug only reproduces on Linux, where glibc'sposix_spawnp(used by Rust'sstd::process::Command) does not fall back. Node.js gets the fallback for free because libuv spawns viaexecvp.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 inext/process—Deno.Command().outputSync(),Deno.Command().spawn(), and the node-compat spawn op — so sync and asyncspawn/spawnSync/exec/execFileall behave consistently.Notes:
/bin/sh); the shell is only the interpreter, exactly asexecvpdoes. No--allow-runbypass.EACCES, notENOEXEC), so they are not silently run through the shell — matching Node.Test
Added
tests/specs/node/exec_file_sync_no_shebang(unix-only) coveringexecFileSync,spawnSync, asyncexecFile/spawn, andDeno.Command.Closes #34919
Closes denoland/divybot#503