Files
reverse-engineering-anything/src/domain/javascript/javascriptSemanticResources.ts
T
吴佳浩Alben 172f248884 fix(javascript): emit no fs acquisition for a rejected literal mode (#8624)
* fix(javascript): emit no fs acquisition for a rejected literal mode

`fs.open`, `fs.openSync` and `fs/promises.open` validate the `mode` in argument 2 before
touching the filesystem, so a mode Node rejects throws synchronously and no descriptor
exists. The guard read only the flag, so a rejected mode kept its acquisition and any
linked release.

Measured on Node 24.22.1:

    fs.openSync(p, "r", -1)          -> THREW ERR_OUT_OF_RANGE
    fs.openSync(p, "r", 1.5)         -> THREW ERR_OUT_OF_RANGE
    fs.openSync(p, "r", NaN)         -> THREW ERR_OUT_OF_RANGE
    fs.openSync(p, "r", 1e21)        -> THREW ERR_OUT_OF_RANGE
    fs.openSync(p, "r", true)        -> THREW ERR_INVALID_ARG_TYPE
    fs.openSync(p, "r", 0n)          -> THREW ERR_INVALID_ARG_TYPE
    fs.openSync(p, "r", {})          -> THREW ERR_INVALID_ARG_TYPE
    fs.openSync(p, "r", "8")         -> THREW ERR_INVALID_ARG_VALUE
    fs.openSync(p, "r", "")          -> THREW ERR_INVALID_ARG_VALUE
    fs.openSync(p, "r", "0o644")     -> THREW ERR_INVALID_ARG_VALUE
    --- accepted ---
    fs.openSync(p, "r", 0o644)       -> ok
    fs.openSync(p, "r", 2147483647)  -> ok
    fs.openSync(p, "r", -0)          -> ok
    fs.openSync(p, "r", "0644")      -> ok
    fs.openSync(p, "r", null)        -> ok
    fs.openSync(p, "r", void 0)      -> ok

The guard decides only exactly-readable literals. A numeric mode is validated as an
unsigned 32-bit integer; a string mode must be a canonical octal digit string, padded
leading zeros allowed, and its value must stay inside uint32; `null`, `undefined` and
`void <expr>` default the mode and are kept, as is a computed value.

Three boundaries are deliberate:

- Values in `[2^31, 2^32-1]` **abort the Node process** rather than throwing
  (`Assertion failed: args[2]->IsInt32()`), so they stay undecided instead of being
  claimed as a synchronous throw.
- A spread before the mode slot shifts the runtime position.
- A unary chain is resolved through its innermost readable operand, and a nested
  boolean or string result is left undecided rather than applied as a number.

Regressions cover both directions. Full suite here: 5466 passed | 7 skipped.

* fix(javascript): remove the superseded string mode check

Review finding. `rejectsOpenModeLiteral` builds `octal` from a string literal or a
no-expression template and returns inside that branch for every value it matched. A
`StringLiteral` therefore never reaches the later `isStringLiteral` check, which cannot
run and is a second copy of the canonical-octal rule to maintain.

* test(javascript): cover the spread and boundary paths of the mode guard

Review feedback from @greptile-apps: the new mode tests skipped both paths the
guard deliberately leaves undecided. Two spread cases are added:

- `openSync(...args, "r", "0600")`, where a spread in the flag slot shifts the
  runtime position of the mode;
- `openSync(p, "r", ...modes)`, where a spread occupies the mode slot itself.

Both must keep the acquisition, because neither establishes the mode value. The
boundary cases were already present: `2147483647` and `2147483648` are keep
cases (`2147483648` aborts the process rather than throwing) and `4294967296` is
the reject case, alongside the octal `37777777777` keep and `40000000000` reject
pair.

127 cases pass in this file.
2026-11-06 15:14:03 +08:00

531 lines
20 KiB
TypeScript

import * as t from "@babel/types";
import {
BUILTIN_RESOURCE_SPECIFIERS,
BUILTIN_RESOURCE_METHODS,
BUILTIN_RESOURCE_RELEASE_METHODS,
} from "./javascriptBuiltinResourceMethods.js";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticResourceOperation,
} from "./javascriptSemanticIr.js";
import {
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import {
builtinDataEffectMethod,
dataEffectMemberCallee,
outerDataEffectBinding,
traverseDataEffects,
type DataEffectTraversalContext,
} from "./javascriptSemanticDataEffectHelpers.js";
import { FS_OPEN_FLAGS, range } from "./javascriptStaticAnalysisHelpers.js";
const RELEASE_METHODS = ["close", "destroy", "end"] as const;
/**
* Release methods the value from this acquisition actually has. An unlisted
* specifier/method pair admits none, so a call to it emits no release fact.
*/
const releaseMethodsFor = (
acquisition: ResourceCandidate,
): readonly ("close" | "destroy" | "end")[] => {
const key = `${acquisition.specifier}:${acquisition.method}`;
const table: Readonly<
Record<string, readonly ("close" | "destroy" | "end")[]>
> = BUILTIN_RESOURCE_RELEASE_METHODS;
return table[key] ?? [];
};
interface ResourceCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly specifier: string;
readonly method: JavaScriptSemanticResourceOperation["method"];
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
}
/** Recover built-in filesystem/network acquisition and local release handles. */
export const collectJavaScriptSemanticResources = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): JavaScriptSemanticResourceOperation[] => {
const context: DataEffectTraversalContext = {
state,
callableIds: new Set(callables.map(({ callableId }) => callableId)),
callableStack: [],
ancestors: [],
};
const acquisitions = collectAcquisitions(program, context).filter(
(candidate) =>
!socketPortInFirstArgument(candidate) ||
!rejectsSocketPortLiteral(candidate.node, context),
);
return [
...acquisitions.map(immutableAcquisition),
...collectReleases(program, context, acquisitions),
];
};
/**
* `fs.open`/`fs.openSync` validate the flag before touching the filesystem, so
* a flag Node rejects at the call site throws synchronously and no descriptor
* exists. Node accepts only a known flag string, a nullish value (which defaults
* to `"r"`), or a bitmask integer; everything else throws. A computed value may
* still be valid, so only an exactly readable literal is decided.
*/
const rejectsOpenFlagLiteral = (
node: t.CallExpression | t.OptionalCallExpression,
context: DataEffectTraversalContext,
): boolean => {
// A spread before the flag slot shifts the runtime position, so argument 1 is
// not provably the flag. `openSync(...args, "0600")` with
// `args = [path, "w"]` opens with mode `"0600"`, not with an invalid flag.
if (t.isSpreadElement(node.arguments[0])) return false;
const flag = node.arguments[1];
if (flag === undefined || t.isSpreadElement(flag)) return false;
const value = unwrapJavaScriptExpression(flag).node;
// A no-expression template literal is exactly readable, so it is the same flag
// string Node receives.
const flagText = t.isStringLiteral(value)
? value.value
: t.isTemplateLiteral(value) && value.expressions.length === 0
? value.quasis[0]?.value.cooked
: undefined;
if (flagText !== undefined && flagText !== null)
return !FS_OPEN_FLAGS.has(flagText);
// A non-integer or out-of-int32 value is not a usable bitmask. The sign alone
// does not decide: Node accepts some signed bitmasks such as `-2147483648` and
// rejects others such as `-1`, so an in-range negative is left undecided.
const bits = staticFlagBits(value, context);
if (bits !== undefined)
return (
bits !== null &&
(!Number.isInteger(bits) || bits > 2_147_483_647 || bits < -2_147_483_648)
);
// `NaN` and `Infinity` are unshadowed globals, and neither is a bitmask; a unary
// operator over one is the same non-finite number.
if (isNonFiniteNumber(value, context)) return true;
// BigInt, boolean, object and array literals all fail Node's flag check. A
// unary operator over a BigInt literal is a BigInt too.
return (
t.isBigIntLiteral(value) ||
t.isBooleanLiteral(value) ||
t.isRegExpLiteral(value) ||
t.isObjectExpression(value) ||
t.isArrayExpression(value) ||
(t.isUnaryExpression(value) &&
t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node))
);
};
/**
* The number a literal flag argument evaluates to, or `null` when it evaluates to
* `undefined`. Returns `undefined` when the value is not exactly readable.
*
* `void <expr>` is `undefined`, which Node accepts as the default read flag, so
* it must never be judged by its operand. `!`, `typeof` and `delete` yield a
* boolean or a string rather than a bitmask.
*/
const staticFlagBits = (
value: t.Node,
context: DataEffectTraversalContext,
depth = 0,
): number | null | undefined => {
if (t.isNumericLiteral(value)) return value.value;
if (!t.isUnaryExpression(value)) return undefined;
if (value.operator === "void") return null;
// `!` yields a boolean and `typeof` a string. Neither is a bitmask, so a caller
// that reads the flag or mode rejects it. This arm is only reachable from the
// top-level entry: `unaryOperandNumber` never returns one of these as a number.
if (
value.operator === "!" ||
value.operator === "typeof" ||
value.operator === "delete"
)
return Number.NaN;
// A unary chain resolves through its innermost readable operand, so `~-1` is `0`
// and `-(-1.5)` is `1.5`. The depth bound keeps a pathological chain from
// recursing without limit.
if (depth > 8) return undefined;
const operand = unwrapJavaScriptExpression(value.argument).node;
const number = unaryOperandNumber(operand, context, depth + 1);
if (number === undefined) return undefined;
if (value.operator === "-") return -number;
if (value.operator === "+") return number;
if (value.operator === "~") return ~number;
return undefined;
};
/**
* The number an operand of a unary operator evaluates to, or `undefined` when it is
* not exactly readable.
*
* This is separate from `staticFlagBits` because that function answers two different
* questions: a numeric bitmask, and "this is a boolean or a string rather than a
* bitmask". The second answer has no number, and a real `NaN` is a number, so they
* cannot share a return slot. A nested `!`, `typeof` or `delete` therefore stays
* undecided here: `+!0` is `1` at runtime, but deciding it needs the operand's
* truthiness rather than a bitmask reading.
*/
const unaryOperandNumber = (
value: t.Node,
context: DataEffectTraversalContext,
depth: number,
): number | undefined => {
if (depth > 8) return undefined;
if (t.isNumericLiteral(value)) return value.value;
if (t.isUnaryExpression(value)) {
if (
value.operator === "!" ||
value.operator === "typeof" ||
value.operator === "delete" ||
value.operator === "void"
)
return undefined;
const inner = unaryOperandNumber(
unwrapJavaScriptExpression(value.argument).node,
context,
depth + 1,
);
if (inner === undefined) return undefined;
if (value.operator === "-") return -inner;
if (value.operator === "+") return inner;
if (value.operator === "~") return ~inner;
return undefined;
}
return primitiveToNumber(value) ?? nonFiniteGlobalNumber(value, context);
};
/**
* The number an unshadowed `NaN` or `Infinity` evaluates to, or `undefined` when the
* operand names neither. A `~` over one is a finite int32, which is why the bitwise
* result must be computed rather than treated as non-finite.
*/
const nonFiniteGlobalNumber = (
value: t.Node,
context: DataEffectTraversalContext,
): number | undefined => {
if (
!t.isIdentifier(value) ||
(value.name !== "NaN" && value.name !== "Infinity") ||
!isUnmodifiedGlobalReference(value, context.state, value.name)
)
return undefined;
return value.name === "NaN" ? Number.NaN : Number.POSITIVE_INFINITY;
};
/**
* The number JavaScript's unary coercion produces for an exactly readable
* non-numeric primitive, or `undefined` when the operand is not one. `-true` is
* `-1` and `+"bad"` is `NaN`, so neither is a socket port or an fs flag.
*/
const primitiveToNumber = (value: t.Node): number | undefined => {
if (t.isStringLiteral(value)) return Number(value.value);
if (t.isBooleanLiteral(value)) return Number(value.value);
if (t.isNullLiteral(value)) return 0;
if (t.isTemplateLiteral(value) && value.expressions.length === 0) {
const cooked = value.quasis[0]?.value.cooked;
return cooked === undefined || cooked === null ? undefined : Number(cooked);
}
return undefined;
};
/**
* Whether an exactly readable expression is a non-finite number: an unshadowed
* `NaN` or `Infinity`, or a unary operator applied to one. `void` is excluded
* because it yields `undefined`, which both slots accept as the default.
*/
const isNonFiniteNumber = (
value: t.Node,
context: DataEffectTraversalContext,
): boolean => {
const unshadowed = (node: t.Node): boolean =>
t.isIdentifier(node) &&
(node.name === "NaN" || node.name === "Infinity") &&
isUnmodifiedGlobalReference(node, context.state, node.name);
if (unshadowed(value)) return true;
return (
t.isUnaryExpression(value) &&
value.operator !== "void" &&
unshadowed(unwrapJavaScriptExpression(value.argument).node)
);
};
/** `fs.open`, `fs.openSync` and `fs/promises.open` take the mode in slot 2. */
const rejectsOpenModeLiteral = (
node: t.CallExpression | t.OptionalCallExpression,
context: DataEffectTraversalContext,
): boolean => {
// A spread before the mode slot shifts the runtime position.
if (
t.isSpreadElement(node.arguments[0]) ||
t.isSpreadElement(node.arguments[1])
)
return false;
const mode = node.arguments[2];
if (mode === undefined || t.isSpreadElement(mode)) return false;
const value = unwrapJavaScriptExpression(mode).node;
// `null`, `undefined` and `void <expr>` all default the mode.
if (t.isNullLiteral(value)) return false;
// A no-expression template literal is exactly readable, so it is the same
// string Node would receive.
const octal = t.isStringLiteral(value)
? value.value
: t.isTemplateLiteral(value) && value.expressions.length === 0
? value.quasis[0]?.value.cooked
: undefined;
if (octal !== undefined && octal !== null) {
if (!/^[0-7]+$/u.test(octal)) return true;
// Leading zeros pad the text without changing the value, so the bound reads the
// significant digits. Eleven octal digits cover the whole uint32 range, so a
// longer significant run cannot fit and an eleven-digit one is exact.
const digits = octal.replace(/^0+/u, "");
return digits.length > 11 || Number.parseInt(digits, 8) > 4_294_967_295;
}
// A numeric mode is validated as an unsigned 32-bit integer.
const bits = staticFlagBits(value, context);
if (bits !== undefined) {
if (bits === null) return false;
// Values in `[2^31, 2^32-1]` abort the process rather than throwing, so
// they stay undecided instead of being claimed as a synchronous throw.
return !Number.isInteger(bits) || bits < 0 || bits > 4_294_967_295;
}
// `NaN` and `Infinity` are unshadowed globals, and neither is a mode; a unary
// operator over one is the same non-finite number.
if (isNonFiniteNumber(value, context)) return true;
// BigInt, boolean, object and array literals all fail Node's mode check. A
// unary operator over a BigInt literal is a BigInt too, and the range helpers
// above only price numeric operands.
return (
t.isBigIntLiteral(value) ||
t.isBooleanLiteral(value) ||
t.isRegExpLiteral(value) ||
t.isObjectExpression(value) ||
t.isArrayExpression(value) ||
(t.isUnaryExpression(value) &&
t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node))
);
};
/** `fs.open`, `fs.openSync` and `fs/promises.open` take the flag in slot 1. */
const openFlagInFirstArgument = (reference: AcquisitionReference): boolean =>
reference.method === "open" || reference.method === "openSync"
? reference.specifier === "fs" || reference.specifier === "fs/promises"
: false;
const collectAcquisitions = (
program: t.Program,
context: DataEffectTraversalContext,
): ResourceCandidate[] => {
const output: ResourceCandidate[] = [];
traverseDataEffects(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const reference = acquisitionMethod(node.callee, context.state);
if (reference === null) return;
if (openFlagInFirstArgument(reference)) {
if (rejectsOpenFlagLiteral(node, context)) return;
if (rejectsOpenModeLiteral(node, context)) return;
}
output.push({
node,
specifier: reference.specifier,
method: reference.method,
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerDataEffectBinding(node, context),
});
});
return output;
};
const collectReleases = (
program: t.Program,
context: DataEffectTraversalContext,
acquisitions: readonly ResourceCandidate[],
): JavaScriptSemanticResourceOperation[] => {
const byBinding = new Map<string, ResourceCandidate[]>();
for (const acquisition of acquisitions) {
if (acquisition.resultBindingId === null) continue;
const existing = byBinding.get(acquisition.resultBindingId) ?? [];
existing.push(acquisition);
byBinding.set(acquisition.resultBindingId, existing);
}
const output: JavaScriptSemanticResourceOperation[] = [];
traverseDataEffects(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const member = dataEffectMemberCallee(node);
if (member === null || !t.isIdentifier(member.object)) return;
const method = RELEASE_METHODS.find(
(candidate) =>
candidate ===
semanticStaticPropertyName(member.property, member.computed),
);
if (method === undefined) return;
const binding = resolveSemanticBindingState(
context.state,
member.object,
member.object.name,
);
const candidates =
binding === undefined ? [] : (byBinding.get(binding.bindingId) ?? []);
if (binding === undefined || candidates.length === 0) return;
const linked = candidates.filter((acquisition) =>
releaseMethodsFor(acquisition).includes(method),
);
// No linked acquisition exposes this release method, so the call is not a
// release of it at all: Node throws at the call site instead.
if (linked.length === 0) return;
output.push({
resourceId: `resource:release:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "release",
method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: binding.bindingId,
linkedResourceIds: linked.map(resourceCandidateId).sort(),
resolution:
linked.length === 1 &&
holdsOnlyAcquisitions(binding, linked, context.state)
? "complete"
: "partial",
});
});
return output;
};
/**
* A lexical handle identifies its acquisition only while every value written
* to the binding is that acquisition itself. Any replacement, even a
* conditional or later one, leaves the released resource a candidate. A
* nullish placeholder, as in `let h = null; try { h = open() } finally {
* h?.close() }`, releases nothing else: the call is skipped or throws.
*/
const holdsOnlyAcquisitions = (
binding: JavaScriptSemanticBindingState,
linked: readonly ResourceCandidate[],
state: JavaScriptSemanticAnalysisState,
): boolean =>
[...binding.initializers, ...binding.referenceInitializers].every(
({ node, projection }) => {
if (projection.length > 0) return false;
let value = unwrapJavaScriptExpression(node).node;
if (t.isAwaitExpression(value))
value = unwrapJavaScriptExpression(value.argument).node;
return (
isNullishPlaceholder(value, state) ||
linked.some((acquisition) => acquisition.node === value)
);
},
);
const isNullishPlaceholder = (
value: t.Node,
state: JavaScriptSemanticAnalysisState,
): boolean =>
t.isNullLiteral(value) ||
t.isUnaryExpression(value, { operator: "void" }) ||
isUnmodifiedGlobalReference(value, state, "undefined");
const immutableAcquisition = (
candidate: ResourceCandidate,
): JavaScriptSemanticResourceOperation => ({
resourceId: resourceCandidateId(candidate),
kind: "acquire",
method: candidate.method,
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
resultBindingId: candidate.resultBindingId,
linkedResourceIds: [],
resolution: "complete",
});
interface AcquisitionReference {
readonly specifier: string;
readonly method: JavaScriptSemanticResourceOperation["method"];
}
const acquisitionMethod = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
): AcquisitionReference | null => {
for (const specifier of BUILTIN_RESOURCE_SPECIFIERS) {
const method = builtinDataEffectMethod(callee, state, [specifier]);
if (
method !== null &&
(
BUILTIN_RESOURCE_METHODS[
specifier as keyof typeof BUILTIN_RESOURCE_METHODS
] as readonly string[]
).includes(method)
)
return {
specifier,
method: method as JavaScriptSemanticResourceOperation["method"],
};
}
return null;
};
const resourceCandidateId = (candidate: ResourceCandidate): string =>
`resource:acquire:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;
/**
* `net` and `tls` validate a numeric port before creating a socket, so a
* literal port outside `[0, 65535]`, a fractional, non-finite, BigInt, boolean
* or `null` first argument throws synchronously and no socket exists. A string
* is a Unix socket path and an object may carry a path, so only an exactly
* readable non-port literal is decided. `""` is a path with no length, which
* Node also rejects.
*
* `undefined` and an empty object throw `ERR_MISSING_ARGS`, but those are a
* missing option rather than a bad port, so they stay undecided here.
*/
const rejectsSocketPortLiteral = (
node: t.CallExpression | t.OptionalCallExpression,
context: DataEffectTraversalContext,
): boolean => {
const port = node.arguments[0];
if (port === undefined || t.isSpreadElement(port)) return false;
const value = unwrapJavaScriptExpression(port).node;
if (t.isStringLiteral(value)) return value.value.length === 0;
if (
t.isNullLiteral(value) ||
t.isBigIntLiteral(value) ||
t.isBooleanLiteral(value)
)
return true;
if (t.isNumericLiteral(value))
return (
!Number.isInteger(value.value) || value.value < 0 || value.value > 65535
);
// `NaN` and `Infinity` are unshadowed globals, and neither is a port.
if (
t.isIdentifier(value) &&
(value.name === "NaN" || value.name === "Infinity") &&
isUnmodifiedGlobalReference(value, context.state, value.name)
)
return true;
// `-1` and `-1.5` are unary expressions over numeric literals: always out of
// range because a port is never negative.
if (t.isUnaryExpression(value)) {
const operand = unwrapJavaScriptExpression(value.argument).node;
return (
t.isNumericLiteral(operand) &&
(value.operator === "-" || !Number.isInteger(operand.value))
);
}
return false;
};
/** Only `net`/`tls` sockets take a port in argument 0. */
const socketPortInFirstArgument = (
candidate: Pick<AcquisitionReference, "specifier">,
): boolean => candidate.specifier === "net" || candidate.specifier === "tls";