fix(ext/process): run shebang-less scripts via /bin/sh on ENOEXEC (#34945)
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:
```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/process` — `Deno.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
---------
Co-authored-by: divybot <divybot@users.noreply.github.com>
Co-authored-by: Divy Srivastava <me@littledivy.com>