diff --git a/src/domain/javascript/javascriptSemanticAnalysis.child_process_callbacks.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.child_process_callbacks.test.ts new file mode 100644 index 0000000000..8684465805 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.child_process_callbacks.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const spawns = (source: string) => + analyzeJavaScriptSemantics(source).childProcessSpawns; + +it.each(["spawn", "fork"])("rejects function-valued %s options", (method) => { + expect( + spawns(` + import { ${method} } from "node:child_process"; + ${method}("worker.js", () => {}); + ${method}("worker.js", [], function options() {}); + ${method}("worker.js", [], class Options {}); + `), + ).toEqual([]); +}); + +it("rejects a literal exec callback after explicit options", () => { + expect( + spawns(` + import { exec } from "node:child_process"; + exec("node --version", {}, false); + exec("node --version", null, {}); + `), + ).toEqual([]); +}); + +it("selects execFile callbacks with and without an argv array", () => { + expect( + spawns(` + import { execFile } from "node:child_process"; + execFile("node", {}, { cwd: "." }); + execFile("node", [], {}, false); + execFile("node", null, {}, "callback"); + `), + ).toEqual([]); +}); + +it("keeps callback overloads and nullish callbacks", () => { + expect( + spawns(` + import { exec, execFile } from "node:child_process"; + exec("node --version", () => {}, false); + exec("node --version", {}, null); + execFile("node", () => {}, { cwd: false }); + execFile("node", [], () => {}, false); + execFile("node", {}, null); + execFile("node", [], {}, undefined); + `), + ).toHaveLength(6); +}); + +it("leaves uncertain callback slots and spread positions undecided", () => { + expect( + spawns(` + import { exec, execFile, spawn } from "node:child_process"; + function run(options, callback, args) { + exec("node --version", options, false); + exec("node --version", {}, callback); + execFile("node", options, false); + execFile("node", [], options, false); + execFile("node", ...args, {}, false); + spawn("node", ...args, () => {}); + } + `), + ).toHaveLength(6); +}); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 38d0f04bc2..c85ec82b56 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -79,7 +79,8 @@ export const collectJavaScriptSemanticChildProcesses = ( !forkRejectsStdioArray(candidate, context) && !rejectsStdioLiteral(candidate, context) && !rejectsProcessOptionLiteral(candidate, context) && - !rejectsOptionsShape(candidate, context, state), + !rejectsOptionsShape(candidate, context, state) && + !rejectsCallback(candidate, context), ); const childProcessSpawns = candidates.map((candidate) => immutableSpawn(candidate, context, state), @@ -647,6 +648,13 @@ const rejectsOptionsShape = ( if (options === undefined) return false; const value = unwrapJavaScriptExpression(options).node; if (context.isArray(value)) return true; + if ( + candidate.method !== "execFile" && + (t.isFunctionExpression(value) || + t.isArrowFunctionExpression(value) || + t.isClassExpression(value)) + ) + return true; const literal = readExactJavaScriptLiteral(value); if (literal.found) return !(candidate.method !== "spawn" && literal.value === null); @@ -662,6 +670,52 @@ const rejectsOptionsShape = ( return isUnpricedPrimitiveLiteral(value); }; +/** Select only callback slots whose overload is established by source syntax. */ +const rejectsCallback = ( + candidate: SpawnCandidate, + context: ChildSpawnContext, +): boolean => { + if (candidate.method !== "exec" && candidate.method !== "execFile") + return false; + const args = candidate.node.arguments; + if (args.slice(0, 4).some((argument) => t.isSpreadElement(argument))) + return false; + const second = args[1] && unwrapJavaScriptExpression(args[1]).node; + const third = args[2] && unwrapJavaScriptExpression(args[2]).node; + let callback: t.Node | undefined; + if (candidate.method === "exec") { + // A function in slot 1 replaces slot 2, even when slot 2 is not callable. + if ( + second !== undefined && + !context.isNullish(second) && + !rejectsSemanticEventListener(second) + ) + return false; + callback = third; + } else if (t.isObjectExpression(second) || t.isRegExpLiteral(second)) { + callback = third; + } else if ( + second === undefined || + context.isArray(second) || + context.isNullish(second) + ) { + // With argv present, a third-slot function replaces the fourth callback. + if ( + third !== undefined && + !context.isNullish(third) && + !rejectsSemanticEventListener(third) + ) + return false; + callback = args[3]; + } else { + return false; + } + return ( + callback !== undefined && + !context.isNullish(callback) && + rejectsSemanticEventListener(callback) + ); +}; /** * The exact-value reader deliberately refuses to price a BigInt, a non-finite * numeric literal such as `1e400`, and a negated numeric literal such as `-0`.