diff --git a/src/domain/javascript/javascriptSemanticAnalysis.spawn_option_literals.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.spawn_option_literals.test.ts new file mode 100644 index 0000000000..aea47c0d67 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.spawn_option_literals.test.ts @@ -0,0 +1,115 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * `node:child_process` validates a literal `stdio` string before it creates anything, so a + * rejected value throws from the call itself and no child exists. + * + * Measured on Node 24.22.1: `"pipe"`, `"overlapped"`, `"inherit"` and `"ignore"` start a + * child, while `"ipc"`, `"PIPE"`, `"pipe "`, `"pipeline"`, `"stream"` and `"stdio"` throw + * `ERR_INVALID_ARG_VALUE`. The set is exact and case-sensitive. `fork` appends `"ipc"` to a + * string and splits it, so an empty string becomes the lone element `"ipc"`, which `fork` + * refuses — `spawn(..., {stdio: ""})` still starts a child. + */ +const spawnsFor = (body: string) => + analyzeJavaScriptSemantics( + `import cp from "node:child_process";\nfunction g() { ${body}; }`, + ).childProcessSpawns; + +it.each([ + [`cp.spawn("x", [], { stdio: "bad" })`], + [`cp.spawn("x", [], { stdio: "PIPE" })`], + [`cp.spawn("x", [], { stdio: "pipe " })`], + [`cp.spawn("x", [], { stdio: "ipc" })`], + [`cp.spawn("x", [], { stdio: "pipeline" })`], + [`cp.spawn("x", [], { stdio: "stdio" })`], + [`cp.fork("x", [], { stdio: "bad" })`], + [`cp.fork("x", [], { stdio: "" })`], + ['cp.spawn("x", [], { stdio: `ipc` })'], +] as const)("emits no spawn for a rejected stdio string: %s", (call) => { + expect(spawnsFor(call)).toEqual([]); +}); + +it.each([ + [`cp.spawn("x", [], { stdio: "pipe" })`], + [`cp.spawn("x", [], { stdio: "overlapped" })`], + [`cp.spawn("x", [], { stdio: "inherit" })`], + [`cp.spawn("x", [], { stdio: "ignore" })`], + [`cp.spawn("x", [], { stdio: "" })`], + [`cp.fork("x", [], { stdio: "pipe" })`], + ['cp.spawn("x", [], { stdio: `pipe` })'], + // `exec` spreads its options and validates none of them, and async `execFile` does not + // forward `stdio` to its underlying spawn at all, so both return a real child. + [`cp.exec("x", { stdio: "bad" })`], + [`cp.execFile("x", [], { stdio: "bad" })`], +] as const)("keeps the spawn for an accepted stdio string: %s", (call) => { + expect(spawnsFor(call)).toHaveLength(1); +}); + +it.each([ + // A binding is not a literal, so this file leaves the answer to the runtime. + [`const s = "bad"; cp.spawn("x", [], { stdio: s })`], + [`cp.spawn("x", [], { stdio: ["pipe"] })`], +] as const)("leaves an undecided stdio undecided: %s", (call) => { + expect(spawnsFor(call)).toHaveLength(1); +}); + +// Invalid literal paths and argv0 values fail before child creation. Unknown +// constructor results are covered separately and must retain their candidates. +it.each([ + [`cp.spawn("x", [], { cwd: 5 })`], + [`cp.spawn("x", [], { cwd: true })`], + [`cp.spawn("x", [], { cwd: {} })`], + [`cp.spawn("x", [], { cwd: [] })`], + [`cp.spawn("x", [], { cwd: 0 })`], + [`cp.spawn("x", [], { argv0: 5 })`], + [`cp.fork("x", [], { cwd: 5 })`], + [`cp.exec("x", { cwd: 5 })`], + [`cp.execFile("x", [], { cwd: 5 })`], + [`cp.spawn("x", [], { cwd: 1n })`], + [`cp.spawn("x", [], { cwd: /x/ })`], + [`cp.spawn("x", [], { cwd: () => {} })`], + [`cp.spawn("x", [], { cwd: class {} })`], + [`cp.spawn("x", [], { argv0: 1n })`], +] as const)("emits no spawn for a non-string cwd or argv0: %s", (call) => { + expect(spawnsFor(call)).toEqual([]); +}); + +it.each([ + [`cp.spawn("x", [], { cwd: "/tmp" })`], + [`cp.spawn("x", [], { cwd: null })`], + [`cp.spawn("x", [], { cwd: "" })`], + [`cp.spawn("x", [], { argv0: "x" })`], + [`cp.spawn("x", [], { argv0: null })`], + // Node accepts any `env` value, so a non-object one is not a call-site failure. + [`cp.spawn("x", [], { env: 5 })`], + // A binding is not a literal, so this file leaves the answer to the runtime. + [`const c = "/tmp"; cp.spawn("x", [], { cwd: c })`], + // A no-substitution template is the same string to Node. + ['cp.spawn("x", [], { cwd: `/tmp` })'], + ['cp.spawn("x", [], { argv0: `x` })'], +] as const)("keeps the spawn for an accepted cwd or argv0: %s", (call) => { + expect(spawnsFor(call)).toHaveLength(1); +}); + +// Constructor names cannot establish the returned cwd value or its validity. +it.each([ + [`cp.spawn("x", [], { cwd: new Uint8Array([47, 116, 109, 112]) })`], + [`cp.spawn("x", [], { cwd: new Buffer("/tmp") })`], + [`cp.spawn("x", [], { cwd: new URL("file:///C:/tmp/") })`], + [`cp.spawn("x", [], { cwd: new globalThis.URL("file:///tmp/") })`], + [ + `class DirectoryURL { constructor() { return new URL("file:///tmp/"); } }; cp.spawn("x", [], { cwd: new DirectoryURL() })`, + ], + [`cp.spawn("x", [], { cwd: new Foo("/tmp") })`], +] as const)("keeps unresolved cwd constructor candidates: %s", (call) => { + expect(spawnsFor(call)).toHaveLength(1); +}); + +it.each([ + [`cp.spawn("x", [], { argv0: new Uint8Array([120]) })`], + [`cp.spawn("x", [], { argv0: new URL("file:///C:/t/") })`], +] as const)("emits no spawn for a constructor Node refuses: %s", (call) => { + expect(spawnsFor(call)).toEqual([]); +}); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 11731e05b1..9bf98aaccd 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -76,6 +76,8 @@ export const collectJavaScriptSemanticChildProcesses = ( const candidates = collectSpawns(program, state, callables).filter( (candidate) => !forkRejectsStdioArray(candidate, context) && + !rejectsStdioLiteral(candidate, context) && + !rejectsProcessOptionLiteral(candidate, context) && !rejectsOptionsShape(candidate, context, state), ); const childProcessSpawns = candidates.map((candidate) => @@ -449,6 +451,42 @@ const environmentSupplied = ( return staticJavaScriptTruthiness(value) !== false; }; +/** + * The `stdio` string values Node accepts for `spawn`, `execFile` and `fork`. + * + * Measured on Node 24.22.1: `"pipe"`, `"overlapped"`, `"inherit"` and `"ignore"` start a + * child; `"ipc"`, `"PIPE"`, `"pipe "`, `"pipeline"`, `"stream"` and `"stdio"` all throw + * `ERR_INVALID_ARG_VALUE` before anything is created. The set is exact and case-sensitive. + */ +const STREAM_STDIO_MODES = new Set([ + "pipe", + "overlapped", + "inherit", + "ignore", +] as const); + +/** + * Whether an exact string `stdio` makes the call throw before a child exists. + * + * `spawn` passes the string to `getValidStdio`, so every value outside + * `STREAM_STDIO_MODES` throws. `fork` sends the string through `stdioStringToArray`, which + * refuses `""` on its own — it does not split the string and it never appends `"ipc"`, so + * the rejection is that one explicit check rather than an IPC-count rule. Measured on Node + * 24.22.1: `spawn(..., {stdio: ""})` starts a child while `fork(..., {stdio: ""})` throws + * `ERR_INVALID_ARG_VALUE`. + * + * `exec` and `execFile` are excluded by the caller: async `execFile` does not forward + * `stdio` to its underlying `spawn` at all, so `execFile(..., {stdio: "bad"})` returns a + * real child, which `stdioMode` already records as `"default"`. + */ +const rejectsStdioString = ( + value: string, + method: JavaScriptSemanticChildProcessSpawn["method"], +): boolean => + (STREAM_STDIO_MODES as ReadonlySet).has(value) + ? false + : method === "fork" || value !== ""; + const stdioMode = ( options: t.Node | undefined, method: JavaScriptSemanticChildProcessSpawn["method"], @@ -474,6 +512,33 @@ const stdioMode = ( return "dynamic"; }; +/** + * Whether a `stdio` member is a string Node refuses at the call site. + * + * The value is read through the shared literal owner rather than the AST kind, because a + * no-substitution template literal is the same string to Node: measured on Node 24.22.1, + * `{stdio: \`ipc\`}` throws `ERR_INVALID_ARG_VALUE` while `{stdio: \`pipe\`}` starts a + * child. A non-string value is left to `stdioMode`, which already reports it as + * `"dynamic"`, so this filter only speaks about strings. + */ +const rejectsStdioLiteral = ( + candidate: SpawnCandidate, + context: ChildSpawnContext, +): boolean => { + // Async exec/execFile do not forward options.stdio to their underlying spawn. + if (candidate.method === "exec" || candidate.method === "execFile") + return false; + const { options } = spawnArguments(candidate, context); + if (options === undefined) return false; + const expression = unwrapJavaScriptExpression(options).node; + if (context.isNullish(expression)) return false; + const lookup = staticObjectProperty(expression, "stdio", context.isNullish); + if (lookup.status !== "explicit") return false; + const literal = readExactJavaScriptLiteral(lookup.value); + if (!literal.found || typeof literal.value !== "string") return false; + return rejectsStdioString(literal.value, candidate.method); +}; + /** * Node's `fork` requires an explicit `stdio` array to carry exactly one IPC * channel. An array that provably omits `"ipc"` throws @@ -510,6 +575,48 @@ const forkRejectsStdioArray = ( return ipcCount !== 1; }; +/** Only proven invalid cwd paths or argv0 strings establish call-site rejection. */ +const rejectsProcessOptionLiteral = ( + candidate: SpawnCandidate, + context: ChildSpawnContext, +): boolean => { + const { options } = spawnArguments(candidate, context); + if (options === undefined) return false; + const expression = unwrapJavaScriptExpression(options).node; + if (context.isNullish(expression)) return false; + for (const member of ["cwd", "argv0"] as const) { + const lookup = staticObjectProperty(expression, member, context.isNullish); + if (lookup.status !== "explicit") continue; + const value = unwrapJavaScriptExpression(lookup.value).node; + if (context.isNullish(value)) continue; + // The value is read through the shared literal owner, so a no-substitution template + // literal counts as the string Node receives: measured on Node 24.22.1, + // `{cwd: \`/tmp\`}` and `{argv0: \`x\`}` both start a child. + const literal = readExactJavaScriptLiteral(value); + if (literal.found) { + if (typeof literal.value === "string") continue; + return true; + } + if (t.isStringLiteral(value)) continue; + // A constructor can return a URL or another accepted path value. Its name + // cannot prove that cwd is invalid, including qualified and custom constructors. + if (member === "cwd" && t.isNewExpression(value)) continue; + if ( + t.isObjectExpression(value) || + t.isArrayExpression(value) || + t.isBooleanLiteral(value) || + t.isBigIntLiteral(value) || + t.isRegExpLiteral(value) || + t.isArrowFunctionExpression(value) || + t.isFunctionExpression(value) || + t.isClassExpression(value) || + t.isNewExpression(value) + ) + return true; + } + return false; +}; + /** * Node validates the options object before creating anything, so a literal * primitive or literal array in that slot throws ERR_INVALID_ARG_TYPE diff --git a/tests/acceptance/applications/javascriptConditionalBuiltinEffects.test.ts b/tests/acceptance/applications/javascriptConditionalBuiltinEffects.test.ts index f02493f407..5eaf7aafd6 100644 --- a/tests/acceptance/applications/javascriptConditionalBuiltinEffects.test.ts +++ b/tests/acceptance/applications/javascriptConditionalBuiltinEffects.test.ts @@ -144,3 +144,53 @@ cliTest.for(["CLI", "stdio MCP"] as const)( } }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves definite rejection and unknown builtin admission through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-builtin-admission-"); + const cases = [ + ["rejected", 'const child=cp.spawn("tool",[],{cwd:5});', false], + [ + "qualified", + 'const child=cp.spawn("tool",[],{cwd:new globalThis.URL("file:///tmp/")});', + true, + ], + [ + "unknown", + 'const child=cp.spawn("tool",[],{cwd:new DirectoryURL()});', + true, + ], + ] as const; + for (const [name, body] of cases) { + await writeFile( + join(root, name + ".mjs"), + 'import cp from "node:child_process";' + + "function entry(){" + + body + + "}", + ); + } + const { semantic_graph: graph } = await analyzeJavaScriptDirectory( + surface, + cli, + root, + ); + for (const [name, , retained] of cases) { + const nodes = graph.nodes.filter( + (node) => + node.identity.module_path === name + ".mjs" && + node.kind === "child-process", + ); + expect(nodes, name).toHaveLength(retained ? 1 : 0); + const ids = new Set(nodes.map((node) => node.node_id)); + const links = graph.relations.filter( + (relation) => + relation.relation === "spawns" && + (ids.has(relation.source_node_id) || + ids.has(relation.target_node_id)), + ); + expect(links, name).toHaveLength(retained ? 1 : 0); + } + }, +);