mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
709 lines
26 KiB
TypeScript
709 lines
26 KiB
TypeScript
import * as t from "@babel/types";
|
|
|
|
import type {
|
|
JavaScriptSemanticCallable,
|
|
JavaScriptSemanticProjectedResultBinding,
|
|
JavaScriptSemanticChildProcessInteraction,
|
|
JavaScriptSemanticChildProcessSpawn,
|
|
} from "./javascriptSemanticIr.js";
|
|
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
|
|
import {
|
|
createSemanticExpressionShapeResolver,
|
|
optionsAfterOptionalArgv,
|
|
type JavaScriptExpressionShapeResolver,
|
|
} from "./javascriptSemanticExpressionShapes.js";
|
|
import {
|
|
readExactJavaScriptLiteral,
|
|
semanticStaticPropertyName,
|
|
staticJavaScriptTruthiness,
|
|
staticObjectProperty,
|
|
unwrapJavaScriptExpression,
|
|
} from "./javascriptAstValues.js";
|
|
import {
|
|
resolveSemanticBindingState,
|
|
semanticStableInitializer,
|
|
type JavaScriptSemanticAnalysisState,
|
|
type JavaScriptSemanticBindingState,
|
|
} from "./javascriptSemanticState.js";
|
|
import {
|
|
isUnshadowedGlobal,
|
|
semanticBindingAtRead,
|
|
} from "./javascriptSemanticMutationInitializers.js";
|
|
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
|
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
|
|
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
|
import {
|
|
builtinDataEffectIdentity,
|
|
dataEffectLiteralString,
|
|
dataEffectMemberCallee,
|
|
assignedSemanticResultBindings,
|
|
scalarAssignedResultBinding,
|
|
semanticProjectedBindingFacts,
|
|
} from "./javascriptSemanticDataEffectHelpers.js";
|
|
|
|
const CHILD_PROCESS_METHODS = ["spawn", "exec", "execFile", "fork"] as const;
|
|
const CHILD_LISTENER_METHODS = ["on", "once", "addListener"] as const;
|
|
|
|
interface ChildProcessAnalysis {
|
|
readonly childProcessSpawns: readonly JavaScriptSemanticChildProcessSpawn[];
|
|
readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[];
|
|
}
|
|
|
|
type ChildSpawnContext = JavaScriptExpressionShapeResolver;
|
|
|
|
interface SpawnCandidate {
|
|
readonly builtinAuthority?: "candidate";
|
|
readonly node: t.CallExpression | t.OptionalCallExpression;
|
|
readonly method: JavaScriptSemanticChildProcessSpawn["method"];
|
|
readonly ownerCallableId: string | null;
|
|
readonly resultBindingId: string | null;
|
|
readonly projectedResultBindings: readonly JavaScriptSemanticProjectedResultBinding[];
|
|
}
|
|
|
|
interface ChildInteractionContext {
|
|
readonly state: JavaScriptSemanticAnalysisState;
|
|
readonly spawnByNode: WeakMap<t.Node, SpawnCandidate>;
|
|
readonly spawnsByBinding: ReadonlyMap<string, readonly SpawnCandidate[]>;
|
|
}
|
|
|
|
/** Recover explicit asynchronous node:child_process ownership candidates. */
|
|
export const collectJavaScriptSemanticChildProcesses = (
|
|
program: t.Program,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
callables: readonly JavaScriptSemanticCallable[],
|
|
): ChildProcessAnalysis => {
|
|
const context = createSemanticExpressionShapeResolver(state);
|
|
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) =>
|
|
immutableSpawn(candidate, context, state),
|
|
);
|
|
return {
|
|
childProcessSpawns,
|
|
childProcessInteractions: collectInteractions(
|
|
program,
|
|
state,
|
|
callables,
|
|
candidates,
|
|
),
|
|
};
|
|
};
|
|
|
|
const collectSpawns = (
|
|
program: t.Program,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
callables: readonly JavaScriptSemanticCallable[],
|
|
): SpawnCandidate[] => {
|
|
const output: SpawnCandidate[] = [];
|
|
const admittedCallables = new Set(
|
|
callables.map(({ callableId }) => callableId),
|
|
);
|
|
const callableStack = createSemanticCallableIdStack(admittedCallables);
|
|
const ancestors: t.Node[] = [];
|
|
traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, {
|
|
enter: (node, parent) => {
|
|
callableStack.enter(node, parent);
|
|
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
|
const identity = childProcessMethod(node.callee, state);
|
|
if (identity !== null) {
|
|
const { method, builtinAuthority } = identity;
|
|
const projectedResultBindings = assignedSemanticResultBindings(
|
|
node,
|
|
ancestors,
|
|
state,
|
|
);
|
|
output.push({
|
|
node,
|
|
method,
|
|
...(builtinAuthority === undefined ? {} : { builtinAuthority }),
|
|
ownerCallableId: callableStack.callables.at(-1) ?? null,
|
|
resultBindingId: scalarAssignedResultBinding(
|
|
projectedResultBindings,
|
|
),
|
|
projectedResultBindings: semanticProjectedBindingFacts(
|
|
projectedResultBindings.filter(
|
|
({ projectionPath }) => projectionPath.length > 0,
|
|
),
|
|
),
|
|
});
|
|
}
|
|
}
|
|
ancestors.push(node);
|
|
},
|
|
exit: (node) => {
|
|
ancestors.pop();
|
|
callableStack.exit(node);
|
|
},
|
|
});
|
|
return output;
|
|
};
|
|
|
|
const immutableSpawn = (
|
|
candidate: SpawnCandidate,
|
|
context: ChildSpawnContext,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): JavaScriptSemanticChildProcessSpawn => {
|
|
const { options, argvCount } = spawnArguments(candidate, context);
|
|
const command = dataEffectLiteralString(candidate.node.arguments[0]);
|
|
return {
|
|
...(candidate.builtinAuthority === undefined
|
|
? {}
|
|
: { builtinAuthority: candidate.builtinAuthority }),
|
|
processId: spawnId(candidate),
|
|
method: candidate.method,
|
|
location: range(candidate.node),
|
|
ownerCallableId: candidate.ownerCallableId,
|
|
resultBindingId: candidate.resultBindingId,
|
|
projectedResultBindings: candidate.projectedResultBindings,
|
|
command,
|
|
argvCount,
|
|
environmentSupplied: environmentSupplied(options, context, state),
|
|
stdioMode: stdioMode(options, candidate.method, context),
|
|
resolution: command === null ? "partial" : "complete",
|
|
};
|
|
};
|
|
|
|
const collectInteractions = (
|
|
program: t.Program,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
callables: readonly JavaScriptSemanticCallable[],
|
|
spawns: readonly SpawnCandidate[],
|
|
): JavaScriptSemanticChildProcessInteraction[] => {
|
|
const output: JavaScriptSemanticChildProcessInteraction[] = [];
|
|
const callableIds = new Set(callables.map(({ callableId }) => callableId));
|
|
const callableStack = createSemanticCallableIdStack(callableIds);
|
|
const spawnByNode = new WeakMap<t.Node, SpawnCandidate>();
|
|
const spawnsByBinding = new Map<string, SpawnCandidate[]>();
|
|
for (const spawn of spawns) {
|
|
spawnByNode.set(spawn.node, spawn);
|
|
const ids = [
|
|
...(spawn.resultBindingId === null ? [] : [spawn.resultBindingId]),
|
|
...spawn.projectedResultBindings.map(({ bindingId }) => bindingId),
|
|
];
|
|
for (const id of ids) {
|
|
const existing = spawnsByBinding.get(id) ?? [];
|
|
existing.push(spawn);
|
|
spawnsByBinding.set(id, existing);
|
|
}
|
|
}
|
|
const context: ChildInteractionContext = {
|
|
state,
|
|
spawnByNode,
|
|
spawnsByBinding,
|
|
};
|
|
traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, {
|
|
enter: (node, parent) => {
|
|
callableStack.enter(node, parent);
|
|
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
|
|
return;
|
|
const interaction = childInteraction(
|
|
node,
|
|
callableStack.callables.at(-1) ?? null,
|
|
context,
|
|
);
|
|
if (interaction === null) return;
|
|
output.push(interaction);
|
|
},
|
|
exit: (node) => callableStack.exit(node),
|
|
});
|
|
return output;
|
|
};
|
|
|
|
const childInteraction = (
|
|
node: t.CallExpression | t.OptionalCallExpression,
|
|
ownerCallableId: string | null,
|
|
context: ChildInteractionContext,
|
|
): JavaScriptSemanticChildProcessInteraction | null => {
|
|
const { state } = context;
|
|
const member = dataEffectMemberCallee(node);
|
|
if (member === null) return null;
|
|
const method = semanticStaticPropertyName(member.property, member.computed);
|
|
const classification = classifyInteraction(method, node.arguments[0]);
|
|
if (classification === null) return null;
|
|
const binding = objectBinding(member.object, state);
|
|
const { candidates: linked, complete } = resolveChildCandidates(
|
|
member.object,
|
|
context,
|
|
);
|
|
if (linked.length === 0) return null;
|
|
const { kind, interactionMethod, eventName } = classification;
|
|
const listener = node.arguments[1];
|
|
return {
|
|
...(linked.some((candidate) => candidate.builtinAuthority === "candidate")
|
|
? { builtinAuthority: "candidate" as const }
|
|
: {}),
|
|
interactionId: `child:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
|
|
kind,
|
|
method: interactionMethod,
|
|
location: range(node),
|
|
ownerCallableId,
|
|
processBindingId: binding?.bindingId ?? null,
|
|
linkedProcessIds: linked
|
|
.map((candidate) => spawnId(candidate))
|
|
.sort(compareUnicodeCodePoints),
|
|
eventName: kind === "listener" ? eventName : null,
|
|
signalName:
|
|
kind === "signal" ? killSignalName(node.arguments, state) : null,
|
|
listenerLocation:
|
|
kind === "listener" && t.isNode(listener) ? range(listener) : null,
|
|
resolution:
|
|
complete &&
|
|
linked.every((candidate) => candidate.builtinAuthority !== "candidate")
|
|
? "complete"
|
|
: "partial",
|
|
};
|
|
};
|
|
|
|
// Only an omitted argument or the unshadowed global undefined selects the
|
|
// SIGTERM default; an unresolved argument leaves the signal unknown.
|
|
const killSignalName = (
|
|
args: t.CallExpression["arguments"],
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): string | null => {
|
|
const [signal] = args;
|
|
if (signal === undefined) return "SIGTERM";
|
|
const expression = unwrapJavaScriptExpression(signal).node;
|
|
if (
|
|
t.isUnaryExpression(expression, { operator: "void" }) ||
|
|
isUnshadowedGlobal(expression, state, "undefined")
|
|
)
|
|
return "SIGTERM";
|
|
return dataEffectLiteralString(signal);
|
|
};
|
|
|
|
const classifyInteraction = (
|
|
method: string,
|
|
argument:
|
|
| t.Expression
|
|
| t.SpreadElement
|
|
| t.JSXNamespacedName
|
|
| t.ArgumentPlaceholder
|
|
| undefined,
|
|
): {
|
|
readonly kind: JavaScriptSemanticChildProcessInteraction["kind"];
|
|
readonly interactionMethod: JavaScriptSemanticChildProcessInteraction["method"];
|
|
readonly eventName: "exit" | "error" | null;
|
|
} | null => {
|
|
if (method === "kill")
|
|
return { kind: "signal", interactionMethod: "kill", eventName: null };
|
|
const listenerMethod = CHILD_LISTENER_METHODS.find(
|
|
(candidate) => candidate === method,
|
|
);
|
|
const eventName = dataEffectLiteralString(argument);
|
|
return listenerMethod !== undefined &&
|
|
(eventName === "exit" || eventName === "error")
|
|
? { kind: "listener", interactionMethod: listenerMethod, eventName }
|
|
: null;
|
|
};
|
|
|
|
const childProcessMethod = (
|
|
callee: t.Node,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): {
|
|
readonly method: JavaScriptSemanticChildProcessSpawn["method"];
|
|
readonly builtinAuthority?: "candidate";
|
|
} | null => {
|
|
const identity = builtinDataEffectIdentity(callee, state, [
|
|
"child_process",
|
|
"node:child_process",
|
|
]);
|
|
const method =
|
|
identity?.path.length === 1
|
|
? CHILD_PROCESS_METHODS.find(
|
|
(candidate) => candidate === identity.path[0],
|
|
)
|
|
: undefined;
|
|
return method === undefined
|
|
? null
|
|
: {
|
|
method,
|
|
...(identity?.builtinAuthority === undefined
|
|
? {}
|
|
: { builtinAuthority: identity.builtinAuthority }),
|
|
};
|
|
};
|
|
|
|
const resolveChildCandidates = (
|
|
node: t.Node,
|
|
context: ChildInteractionContext,
|
|
): {
|
|
readonly candidates: readonly SpawnCandidate[];
|
|
readonly complete: boolean;
|
|
} => {
|
|
const { state, spawnByNode, spawnsByBinding } = context;
|
|
const candidates = new Set<SpawnCandidate>();
|
|
const fallback = new Set<SpawnCandidate>();
|
|
const pending = [node];
|
|
const visited = new Set<t.Node>();
|
|
let complete = true;
|
|
while (pending.length > 0) {
|
|
const current = unwrapJavaScriptExpression(pending.pop()!).node;
|
|
if (visited.has(current)) {
|
|
complete = false;
|
|
continue;
|
|
}
|
|
visited.add(current);
|
|
// Spawn-result assignment already admits await as an identity wrapper.
|
|
if (t.isAwaitExpression(current)) {
|
|
pending.push(current.argument);
|
|
continue;
|
|
}
|
|
const direct = spawnByNode.get(current);
|
|
if (direct !== undefined) {
|
|
candidates.add(direct);
|
|
continue;
|
|
}
|
|
const binding = t.isIdentifier(current)
|
|
? resolveSemanticBindingState(state, current, current.name)
|
|
: undefined;
|
|
if (binding === undefined) {
|
|
complete = false;
|
|
continue;
|
|
}
|
|
for (const candidate of spawnsByBinding.get(binding.bindingId) ?? [])
|
|
fallback.add(candidate);
|
|
// An alias captures its source at its initializer read, not at the later
|
|
// interaction. The shared selector retains all sources across uncertain
|
|
// control flow, loops and writes in other activations.
|
|
const snapshot = semanticBindingAtRead(binding, current, state);
|
|
const selected = snapshot.binding;
|
|
const initializers = [
|
|
...selected.initializers,
|
|
...selected.referenceInitializers,
|
|
];
|
|
// Defaults and other reference sources do not exclude a caller-supplied
|
|
// value, even when they are the binding's only retained source.
|
|
if (initializers.length !== 1 || selected.referenceInitializers.length > 0)
|
|
complete = false;
|
|
const stable = semanticStableInitializer(binding, state);
|
|
for (const initializer of initializers) {
|
|
if (state.conditionallySkippedExpressions.has(initializer.node))
|
|
complete = false;
|
|
if (initializer.projection.length > 0) {
|
|
complete = false;
|
|
continue;
|
|
}
|
|
// A lone assignment can still be later or belong to another activation.
|
|
// Only initialized lexical declarations exclude an earlier undefined value
|
|
// without a same-body ordering proof.
|
|
if (
|
|
!(stable === initializer && !binding.declarationKinds.has("var")) &&
|
|
snapshot.status !== "ordered"
|
|
)
|
|
complete = false;
|
|
pending.push(initializer.node);
|
|
}
|
|
}
|
|
// Preserve historical candidates with explicit uncertainty when the selected
|
|
// value cannot be established; a binding inventory alone never proves identity.
|
|
if (!complete) for (const candidate of fallback) candidates.add(candidate);
|
|
return {
|
|
candidates: [...candidates],
|
|
complete: complete && candidates.size === 1,
|
|
};
|
|
};
|
|
const spawnArguments = (
|
|
candidate: SpawnCandidate,
|
|
context: ChildSpawnContext,
|
|
): {
|
|
readonly options: t.Node | undefined;
|
|
readonly argvCount: number | null;
|
|
} => {
|
|
const args = candidate.node.arguments;
|
|
if (t.isSpreadElement(args[0])) return { options: args[0], argvCount: null };
|
|
const second = args[1] && unwrapJavaScriptExpression(args[1]).node;
|
|
const argv =
|
|
candidate.method !== "exec" && t.isArrayExpression(second)
|
|
? second
|
|
: undefined;
|
|
// exec(command[, options][, callback]) has no argv slot.
|
|
const options =
|
|
candidate.method === "exec"
|
|
? second
|
|
: optionsAfterOptionalArgv(args, context);
|
|
return {
|
|
options,
|
|
argvCount:
|
|
argv !== undefined &&
|
|
!argv.elements.some((element) => t.isSpreadElement(element))
|
|
? argv.elements.length
|
|
: null,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Node replaces a falsy `options.env` with a copy of the parent environment, so
|
|
* a nullish, empty-string, zero or `false` value passes the whole environment
|
|
* through instead of supplying one.
|
|
*/
|
|
const environmentSupplied = (
|
|
options: t.Node | undefined,
|
|
context: ChildSpawnContext,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): boolean => {
|
|
const lookup = staticObjectProperty(options, "env", context.isNullish);
|
|
if (lookup.status !== "explicit") return false;
|
|
const value = unwrapJavaScriptExpression(lookup.value).node;
|
|
if (context.isNullish(value)) return false;
|
|
if (
|
|
t.isIdentifier(value, { name: "NaN" }) &&
|
|
isUnshadowedGlobal(value, state, "NaN")
|
|
)
|
|
return false;
|
|
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<string>).has(value)
|
|
? false
|
|
: method === "fork" || value !== "";
|
|
|
|
const stdioMode = (
|
|
options: t.Node | undefined,
|
|
method: JavaScriptSemanticChildProcessSpawn["method"],
|
|
context: ChildSpawnContext,
|
|
): string => {
|
|
// Async exec/execFile do not forward options.stdio to their underlying spawn.
|
|
if (method === "exec" || method === "execFile") return "default";
|
|
if (options === undefined) return "default";
|
|
const expression = unwrapJavaScriptExpression(options).node;
|
|
if (context.isNullish(expression)) return "default";
|
|
const lookup = staticObjectProperty(expression, "stdio", context.isNullish);
|
|
if (lookup.status === "missing")
|
|
return unsetStdioMode(expression, method, context);
|
|
if (lookup.status !== "explicit") return "dynamic";
|
|
const value = unwrapJavaScriptExpression(lookup.value).node;
|
|
if (context.isNullish(value))
|
|
return unsetStdioMode(expression, method, context);
|
|
if (t.isStringLiteral(value)) return value.value;
|
|
if (t.isArrayExpression(value)) return "array";
|
|
// fork ignores any other known stdio value and falls back to `silent`.
|
|
if (method === "fork" && staticJavaScriptTruthiness(value) !== undefined)
|
|
return unsetStdioMode(expression, method, context);
|
|
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
|
|
* ERR_CHILD_PROCESS_IPC_REQUIRED, and one that names `"ipc"` twice throws
|
|
* ERR_IPC_ONE_PIPE; both throws are synchronous, before any child process
|
|
* exists, so such a call is not a spawn at all. A string `stdio` is normalised
|
|
* with a single `"ipc"` appended, so only the array form can be wrong; a spread
|
|
* or unreadable element may still change the count and is therefore left alone.
|
|
*/
|
|
const forkRejectsStdioArray = (
|
|
candidate: SpawnCandidate,
|
|
context: ChildSpawnContext,
|
|
): boolean => {
|
|
if (candidate.method !== "fork") 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 value = unwrapJavaScriptExpression(lookup.value).node;
|
|
if (!t.isArrayExpression(value)) return false;
|
|
const literals = value.elements.map((element) =>
|
|
element === null || t.isSpreadElement(element)
|
|
? undefined
|
|
: readExactJavaScriptLiteral(element),
|
|
);
|
|
if (literals.some((literal) => literal === undefined || !literal.found))
|
|
return false;
|
|
const ipcCount = literals.filter(
|
|
(literal) =>
|
|
literal !== undefined && literal.found && literal.value === "ipc",
|
|
).length;
|
|
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
|
|
* synchronously and no child process exists. `spawn` also rejects `null`
|
|
* (`undefined` is its only exemption), while `fork` and `execFile` skip any
|
|
* nullish options, and `exec` spreads its options and validates none of them.
|
|
*/
|
|
const rejectsOptionsShape = (
|
|
candidate: SpawnCandidate,
|
|
context: ChildSpawnContext,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): boolean => {
|
|
if (candidate.method === "exec") return false;
|
|
const { options } = spawnArguments(candidate, context);
|
|
if (options === undefined) return false;
|
|
const value = unwrapJavaScriptExpression(options).node;
|
|
if (context.isArray(value)) return true;
|
|
const literal = readExactJavaScriptLiteral(value);
|
|
if (literal.found)
|
|
return !(candidate.method !== "spawn" && literal.value === null);
|
|
// An unshadowed `NaN` or `Infinity` is a number, and Node's options check
|
|
// rejects a number the same way it rejects any other primitive. `undefined`
|
|
// stays the one accepted global.
|
|
if (
|
|
t.isIdentifier(value) &&
|
|
(value.name === "NaN" || value.name === "Infinity") &&
|
|
isUnshadowedGlobal(value, state, value.name)
|
|
)
|
|
return true;
|
|
return isUnpricedPrimitiveLiteral(value);
|
|
};
|
|
|
|
/**
|
|
* 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`.
|
|
* All are primitive literals, so Node's options check rejects them the same way;
|
|
* read the shape directly here rather than widening the shared reader.
|
|
*
|
|
* `void <expr>` is the exception: it evaluates to `undefined`, which is the one
|
|
* options value Node accepts in place of an object, so it must not be dropped.
|
|
*/
|
|
const isUnpricedPrimitiveLiteral = (value: t.Node): boolean => {
|
|
if (t.isNumericLiteral(value) || t.isBigIntLiteral(value)) return true;
|
|
if (!t.isUnaryExpression(value)) return false;
|
|
if (value.operator === "void") return false;
|
|
// `!` and `~` always yield a primitive, whatever their operand.
|
|
if (value.operator === "!" || value.operator === "~") return true;
|
|
if (value.operator !== "+" && value.operator !== "-") return false;
|
|
const argument = unwrapJavaScriptExpression(value.argument).node;
|
|
return t.isNumericLiteral(argument) || t.isBigIntLiteral(argument);
|
|
};
|
|
|
|
/**
|
|
* Without a string or array `stdio`, Node's `fork` pipes the standard streams
|
|
* when `silent` is truthy and inherits them otherwise.
|
|
*/
|
|
const unsetStdioMode = (
|
|
options: t.Node,
|
|
method: JavaScriptSemanticChildProcessSpawn["method"],
|
|
context: ChildSpawnContext,
|
|
): string => {
|
|
if (method !== "fork") return "default";
|
|
const silent = staticObjectProperty(options, "silent", context.isNullish);
|
|
if (silent.status === "missing") return "default";
|
|
if (silent.status !== "explicit") return "dynamic";
|
|
const value = unwrapJavaScriptExpression(silent.value).node;
|
|
if (context.isNullish(value)) return "default";
|
|
const truthy = staticJavaScriptTruthiness(value);
|
|
if (truthy === undefined) return "dynamic";
|
|
return truthy ? "pipe" : "default";
|
|
};
|
|
|
|
const objectBinding = (
|
|
node: t.Expression | t.Super,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): JavaScriptSemanticBindingState | undefined =>
|
|
t.isIdentifier(node)
|
|
? resolveSemanticBindingState(state, node, node.name)
|
|
: undefined;
|
|
|
|
const spawnId = (candidate: SpawnCandidate): string =>
|
|
`child:spawn:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;
|