diff --git a/scripts/build/buildToolRunner.mjs b/scripts/build/buildToolRunner.mjs new file mode 100644 index 0000000000..a6f22f9921 --- /dev/null +++ b/scripts/build/buildToolRunner.mjs @@ -0,0 +1,162 @@ +/** + * OmniRoute — cross-platform spawning of locally installed build tools. + * + * WHY: `node_modules/.bin/` (no extension) is a POSIX shell script. On + * Windows the executable shim is `.cmd`, so `execFileSync(join(ROOT, + * "node_modules", ".bin", "esbuild"), …)` dies with + * + * Error: spawnSync C:\…\node_modules\.bin\esbuild ENOENT + * + * and — because the `postbuild` hook runs after a SUCCESSFUL `next build` — the + * operator sees "✓ Compiled successfully" immediately followed by a failed + * `npm run build`, with a complete `.build/next/standalone` tree on disk. + * + * Switching to `.cmd` alone is not enough: since the CVE-2024-27980 + * hardening, Node >= 20 refuses to spawn a `.cmd`/`.bat` without a shell + * (EINVAL), and `shell: true` in turn disables argument escaping (DEP0190). + * + * So the preferred path avoids the shim entirely: read the tool's own `bin` + * entry from its package.json and run THAT with this Node binary — no shim, no + * shell, nothing to escape, identical behaviour on every platform. The `.bin` + * shim stays only as a last resort for a tool that is not resolvable inside the + * local dependency tree. + * + * These helpers were private to `scripts/build/prepublish.ts`, where the same + * Windows failure was already fixed; they live here so plain-`node` build + * scripts (`postbuild` → colocate-standalone.mjs) can share one implementation + * instead of re-learning the same lesson. `planBuildToolSpawn()` takes the + * platform as a parameter — like `resolveNextBuildEnv()` in + * build-next-isolated.mjs — so the Windows behaviour is unit-testable from CI's + * Linux runners. + */ +import { execFileSync } from "node:child_process"; +import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); + +/** + * Absolute path of a tool's own `bin` entry inside the local dependency tree, + * or `null` when the package (or the entry it advertises) is not there. + * + * @param {string} packageName Package that ships the tool, e.g. `"esbuild"`. + * @param {string} binName Key in that package's `bin` map, e.g. `"esbuild"`. + * @param {string} [root] Directory holding `node_modules` (defaults to repo root). + * @returns {string | null} + */ +export function resolveLocalBinEntry(packageName, binName, root = ROOT) { + try { + const packageJsonPath = join(root, "node_modules", packageName, "package.json"); + if (!existsSync(packageJsonPath)) return null; + const meta = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const relative = typeof meta.bin === "string" ? meta.bin : meta.bin?.[binName]; + if (!relative) return null; + const absolute = join(root, "node_modules", packageName, relative); + return existsSync(absolute) ? absolute : null; + } catch { + return null; + } +} + +/** + * Does this file start with an executable image's magic bytes? + * + * esbuild >= 0.25 ships `bin/esbuild` as the NATIVE platform executable on + * Linux/macOS (ELF / Mach-O) instead of a JS shim — handing that to + * `process.execPath` makes Node parse machine code as JavaScript and die with + * "SyntaxError: Invalid or unexpected token". Native entries must be executed + * directly; JS entries go through this Node binary. + * + * @param {string} entryPath + * @returns {boolean} + */ +export function isNativeExecutable(entryPath) { + try { + const fd = openSync(entryPath, "r"); + const head = Buffer.alloc(4); + readSync(fd, head, 0, 4, 0); + closeSync(fd); + return ( + (head[0] === 0x7f && head[1] === 0x45 && head[2] === 0x4c && head[3] === 0x46) || // ELF + head.readUInt32BE(0) === 0xfeedfacf || // Mach-O 64 + head.readUInt32BE(0) === 0xcffaedfe || // Mach-O 64 (LE on disk) + (head[0] === 0x4d && head[1] === 0x5a) // PE (Windows MZ) + ); + } catch { + return false; + } +} + +/** + * `cmd.exe` receives one flat command line, and Node does NOT escape arguments + * when `shell` is set, so anything holding whitespace has to be quoted here. + * Build arguments carry absolute paths, and `C:\Users\First Last\…` is an + * ordinary Windows home directory. + * + * @param {string} value + * @returns {string} + */ +function quoteForShell(value) { + if (!/\s/.test(value) || value.startsWith('"')) return value; + return `"${value}"`; +} + +/** + * Decide HOW to spawn a build tool. Pure: no filesystem access, no `process` + * inspection beyond `execPath`, platform injected — so a Linux test can assert + * the Windows plan. + * + * @param {object} input + * @param {string} input.binName Tool name as it appears in `node_modules/.bin`. + * @param {readonly string[]} input.args Arguments for the tool. + * @param {string | null} [input.entryPath] Result of {@link resolveLocalBinEntry}. + * @param {boolean} [input.entryIsNative] Result of {@link isNativeExecutable}. + * @param {string} [input.root] Directory holding `node_modules`. + * @param {string} [input.platform] `process.platform` value to plan for. + * @returns {{ file: string, args: string[], shell: boolean }} `file`/`args` are + * already shell-quoted when `shell` is true, and must be passed together. + */ +export function planBuildToolSpawn({ + binName, + args, + entryPath = null, + entryIsNative = false, + root = ROOT, + platform = process.platform, +}) { + // Preferred: the tool's own entry point, spawned with no shim and no shell. + if (entryPath) { + return entryIsNative + ? { file: entryPath, args: [...args], shell: false } + : { file: process.execPath, args: [entryPath, ...args], shell: false }; + } + + // Last resort: the `node_modules/.bin` shim. On Windows that means the `.cmd` + // variant, which Node only spawns through a shell (see the module header). + const isWindows = platform === "win32"; + const shim = join(root, "node_modules", ".bin", isWindows ? `${binName}.cmd` : binName); + return isWindows + ? { file: quoteForShell(shim), args: args.map(quoteForShell), shell: true } + : { file: shim, args: [...args], shell: false }; +} + +/** + * Run a locally installed build tool, synchronously, on any platform. + * + * @param {string} packageName Package that ships the tool, e.g. `"esbuild"`. + * @param {string} binName Key in that package's `bin` map, e.g. `"esbuild"`. + * @param {readonly string[]} args Arguments for the tool. + * @param {import("node:child_process").ExecFileSyncOptions} [options] Passed to `execFileSync`. + * @returns {void} + */ +export function runBuildTool(packageName, binName, args, options = {}) { + const entryPath = resolveLocalBinEntry(packageName, binName); + const plan = planBuildToolSpawn({ + binName, + args, + entryPath, + entryIsNative: entryPath ? isNativeExecutable(entryPath) : false, + }); + execFileSync(plan.file, plan.args, plan.shell ? { ...options, shell: true } : options); +} diff --git a/scripts/build/colocate-standalone.mjs b/scripts/build/colocate-standalone.mjs index bb108da47f..f8dca14a51 100644 --- a/scripts/build/colocate-standalone.mjs +++ b/scripts/build/colocate-standalone.mjs @@ -18,8 +18,8 @@ */ import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { execFileSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { runBuildTool } from "./buildToolRunner.mjs"; import { computeDependencyClosure } from "./colocateOptionals.mjs"; const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); @@ -89,8 +89,12 @@ function main() { const callLogWorkerDest = join(STANDALONE, CALL_LOG_WORKER_REL); mkdirSync(dirname(callLogWorkerDest), { recursive: true }); - execFileSync( - join(ROOT, "node_modules", ".bin", "esbuild"), + // Never spawn `node_modules/.bin/esbuild` directly: that extensionless path is + // a POSIX shell script and does not exist on Windows (ENOENT), which failed + // `npm run build` right after a successful `next build`. See buildToolRunner.mjs. + runBuildTool( + "esbuild", + "esbuild", [ CALL_LOG_WORKER_SRC, "--bundle", @@ -120,8 +124,9 @@ function main() { if (!existsSync(workerDest)) { mkdirSync(dirname(workerDest), { recursive: true }); try { - execFileSync( - join(ROOT, "node_modules", ".bin", "esbuild"), + runBuildTool( + "esbuild", + "esbuild", [ join( ROOT, diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index b1e398deb0..d29ec32560 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -22,14 +22,12 @@ import { readdirSync, statSync, chmodSync, - openSync, - readSync, - closeSync, } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { assembleStandalone } from "./assembleStandalone.mjs"; +import { isNativeExecutable, resolveLocalBinEntry } from "./buildToolRunner.mjs"; import { resolveBundledNpmEntry } from "./resolveNpmEntry.ts"; import { APP_STAGING_ALLOWED_EXACT_PATHS, @@ -51,52 +49,15 @@ const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx"; // // `shell: true` would fix the spawn but disables argument escaping (DEP0190), so it // is only the last resort. Preferred order: run the tool's own JS entry point with -// this Node binary — no shim, no shell, nothing to escape. -function resolveLocalBinEntry(packageName: string, binName: string): string | null { - try { - const packageJsonPath = join(ROOT, "node_modules", packageName, "package.json"); - if (!existsSync(packageJsonPath)) return null; - const meta = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { - bin?: string | Record; - }; - const relative = typeof meta.bin === "string" ? meta.bin : meta.bin?.[binName]; - if (!relative) return null; - const absolute = join(ROOT, "node_modules", packageName, relative); - return existsSync(absolute) ? absolute : null; - } catch { - return null; - } -} +// this Node binary — no shim, no shell, nothing to escape. `resolveLocalBinEntry()` +// and `isNativeExecutable()` implement that resolution and now live in +// buildToolRunner.mjs, shared with the plain-`node` build scripts. /** * Runs a build tool without ever touching a `.cmd` shim. `packageName` is where the * tool lives in the local dependency tree; when it is not installed there the call * falls back to the Node-resolved `npx` entry point, and only then to the shim. */ -/** - * esbuild ≥0.25 ships its `bin/esbuild` as the NATIVE platform executable on - * Linux/macOS (ELF / Mach-O) instead of a JS shim — running it through - * `process.execPath` makes Node parse machine code as JavaScript and crash with - * "SyntaxError: Invalid or unexpected token". Sniff the magic bytes and exec - * native entries directly; JS entries keep going through this Node binary. - */ -function isNativeExecutable(entryPath: string): boolean { - try { - const fd = openSync(entryPath, "r"); - const head = Buffer.alloc(4); - readSync(fd, head, 0, 4, 0); - closeSync(fd); - return ( - (head[0] === 0x7f && head[1] === 0x45 && head[2] === 0x4c && head[3] === 0x46) || // ELF - head.readUInt32BE(0) === 0xfeedfacf || // Mach-O 64 - head.readUInt32BE(0) === 0xcffaedfe || // Mach-O 64 (LE on disk) - (head[0] === 0x4d && head[1] === 0x5a) // PE (Windows MZ) - ); - } catch { - return false; - } -} - function runBuildTool( packageName: string, binName: string, diff --git a/tests/unit/build/build-tool-runner-win-shim.test.ts b/tests/unit/build/build-tool-runner-win-shim.test.ts new file mode 100644 index 0000000000..ffc406bc4c --- /dev/null +++ b/tests/unit/build/build-tool-runner-win-shim.test.ts @@ -0,0 +1,208 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { join, sep } from "node:path"; +import { tmpdir } from "node:os"; + +import { + isNativeExecutable, + planBuildToolSpawn, + resolveLocalBinEntry, + runBuildTool, +} from "../../../scripts/build/buildToolRunner.mjs"; + +/** + * Regression coverage for the Windows `postbuild` crash. + * + * `colocate-standalone.mjs` spawned `node_modules/.bin/esbuild` — an + * extensionless POSIX shell script that does not exist on Windows. `npm run + * build` therefore died with + * + * Error: spawnSync C:\…\node_modules\.bin\esbuild ENOENT + * + * immediately AFTER `next build` reported "✓ Compiled successfully", leaving a + * complete `.build/next/standalone` tree next to a failed build. + * + * The platform is injected into `planBuildToolSpawn()` (same seam as + * `resolveNextBuildEnv()` in build-next-isolated.mjs) so the Windows decisions + * are asserted from CI's Linux runners. + */ + +test("planBuildToolSpawn prefers the tool's own JS entry over any .bin shim", () => { + const plan = planBuildToolSpawn({ + binName: "esbuild", + args: ["in.ts", "--outfile=out.js"], + entryPath: "/repo/node_modules/esbuild/bin/esbuild", + entryIsNative: false, + platform: "win32", + }); + + assert.equal(plan.file, process.execPath, "a JS entry runs on this Node binary"); + assert.deepEqual(plan.args, [ + "/repo/node_modules/esbuild/bin/esbuild", + "in.ts", + "--outfile=out.js", + ]); + assert.equal(plan.shell, false, "no shell means no argument-escaping hazard (DEP0190)"); +}); + +test("planBuildToolSpawn execs a NATIVE entry directly instead of feeding it to Node", () => { + // esbuild >= 0.25 ships bin/esbuild as an ELF/Mach-O binary on Linux/macOS; + // handing that to process.execPath crashes with "Invalid or unexpected token". + const plan = planBuildToolSpawn({ + binName: "esbuild", + args: ["in.ts"], + entryPath: "/repo/node_modules/esbuild/bin/esbuild", + entryIsNative: true, + platform: "linux", + }); + + assert.equal(plan.file, "/repo/node_modules/esbuild/bin/esbuild"); + assert.deepEqual(plan.args, ["in.ts"]); + assert.equal(plan.shell, false); +}); + +test("planBuildToolSpawn falls back to the .cmd shim (with a shell) on win32", () => { + const plan = planBuildToolSpawn({ + binName: "esbuild", + args: ["in.ts"], + entryPath: null, + root: "C:\\repo", + platform: "win32", + }); + + assert.ok(plan.file.endsWith("esbuild.cmd"), `expected a .cmd shim, got ${plan.file}`); + // Node >= 20 refuses to spawn a .cmd without a shell (CVE-2024-27980 hardening). + assert.equal(plan.shell, true, "a .cmd only spawns through a shell"); +}); + +test("planBuildToolSpawn falls back to the extensionless shim (no shell) elsewhere", () => { + const plan = planBuildToolSpawn({ + binName: "esbuild", + args: ["in.ts"], + entryPath: null, + root: "/repo", + platform: "linux", + }); + + assert.equal(plan.file, join("/repo", "node_modules", ".bin", "esbuild")); + assert.ok(!plan.file.endsWith(".cmd"), "no .cmd suffix off Windows"); + assert.equal(plan.shell, false); +}); + +test("planBuildToolSpawn quotes whitespace paths when it has to use a shell", () => { + // `C:\Users\First Last\…` is an ordinary Windows home directory, and Node does + // not escape arguments once `shell` is set. + const plan = planBuildToolSpawn({ + binName: "esbuild", + args: ["--outfile=C:\\Users\\First Last\\out.js", "--bundle"], + entryPath: null, + root: "C:\\Users\\First Last\\repo", + platform: "win32", + }); + + assert.ok(plan.file.startsWith('"') && plan.file.endsWith('"'), "shim path is quoted"); + assert.equal(plan.args[0], '"--outfile=C:\\Users\\First Last\\out.js"'); + assert.equal(plan.args[1], "--bundle", "arguments without whitespace are left alone"); +}); + +test("resolveLocalBinEntry reads the package's own bin map, never node_modules/.bin", () => { + const root = mkdtempSync(join(tmpdir(), "bin-entry-")); + try { + const pkgDir = join(root, "node_modules", "esbuild"); + mkdirSync(join(pkgDir, "bin"), { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ bin: { esbuild: "bin/esbuild" } }) + ); + writeFileSync(join(pkgDir, "bin", "esbuild"), "#!/usr/bin/env node\n"); + + const entry = resolveLocalBinEntry("esbuild", "esbuild", root); + assert.equal(entry, join(pkgDir, "bin", "esbuild")); + assert.ok( + !entry.includes(`${sep}.bin${sep}`), + "the resolved entry must bypass the platform-specific .bin shim" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("resolveLocalBinEntry returns null for a missing package or a missing entry", () => { + const root = mkdtempSync(join(tmpdir(), "bin-entry-missing-")); + try { + assert.equal(resolveLocalBinEntry("nope", "nope", root), null); + + const pkgDir = join(root, "node_modules", "esbuild"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ bin: { esbuild: "bin/esbuild" } }) + ); + assert.equal( + resolveLocalBinEntry("esbuild", "esbuild", root), + null, + "an advertised entry that is not on disk must not be spawned" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("isNativeExecutable distinguishes an executable image from a JS shim", () => { + const root = mkdtempSync(join(tmpdir(), "native-sniff-")); + try { + const shim = join(root, "shim.js"); + const elf = join(root, "elf.bin"); + const pe = join(root, "pe.exe"); + writeFileSync(shim, "#!/usr/bin/env node\nconsole.log(1);\n"); + writeFileSync(elf, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02])); + writeFileSync(pe, Buffer.from([0x4d, 0x5a, 0x90, 0x00])); + + assert.equal(isNativeExecutable(shim), false); + assert.equal(isNativeExecutable(elf), true); + assert.equal(isNativeExecutable(pe), true); + assert.equal(isNativeExecutable(join(root, "absent")), false, "a missing file is not native"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("runBuildTool actually runs esbuild from this repo's dependency tree", () => { + // End-to-end on whatever platform the suite runs on: the bug was a spawn + // failure, so the only conclusive assertion is a real spawn. + const out = mkdtempSync(join(tmpdir(), "esbuild-spawn-")); + try { + const src = join(out, "worker.ts"); + const dest = join(out, "worker.js"); + writeFileSync(src, "export const answer: number = 42;\n"); + + runBuildTool( + "esbuild", + "esbuild", + [src, "--bundle", "--platform=node", "--format=esm", `--outfile=${dest}`], + { stdio: "pipe" } + ); + + assert.match(readFileSync(dest, "utf8"), /42/, "esbuild produced the bundle"); + } finally { + rmSync(out, { recursive: true, force: true }); + } +}); + +test("colocate-standalone.mjs never spawns the node_modules/.bin shim again", () => { + const source = readFileSync( + new URL("../../../scripts/build/colocate-standalone.mjs", import.meta.url), + "utf8" + ); + + assert.ok( + !/\.bin["'\s,]+["']esbuild/.test(source), + "the postbuild hook must not reference node_modules/.bin/esbuild — that path is Windows-fatal" + ); + assert.match( + source, + /runBuildTool\(/, + "esbuild is spawned through the shared cross-platform runner" + ); +});