mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 13:26:10 +00:00
* fix(javascript): emit no socket for a literal out-of-range port
`net` and `tls` validate a numeric port before creating a socket, so a literal
port Node rejects throws synchronously and no socket exists — but the analysis
reported an `acquire` operation.
Measured on Node 24.22.1:
net.connect(0) / 80 / 65535 -> Socket created
net.connect(65536) -> THREW ERR_SOCKET_BAD_PORT
net.connect(-1) / 1.5 -> THREW ERR_SOCKET_BAD_PORT
net.connect(NaN) / Infinity -> THREW ERR_SOCKET_BAD_PORT
net.connect(1e400) -> THREW ERR_SOCKET_BAD_PORT
net.connect(0n) / true / false -> THREW ERR_INVALID_ARG_TYPE
net.connect(null) -> THREW ERR_INVALID_ARG_TYPE
net.connect("") -> THREW ERR_SOCKET_BAD_PORT
net.connect("/tmp/x.sock") -> Socket created (Unix socket path)
net.connect({ port: 80 }) -> Socket created
net.connect(undefined) / {} / [] -> THREW ERR_MISSING_ARGS (undecided)
--- tls mirrors net ---
tls.connect(80) -> Socket created
tls.connect(65536) / -1 / NaN -> THREW ERR_SOCKET_BAD_PORT
A string is a Unix socket path and an object may carry a path, so only an
exactly readable non-port literal is decided. `undefined` and an empty object
throw `ERR_MISSING_ARGS`, which is a missing option rather than a bad port, so
they stay undecided. `NaN` and `Infinity` are identifiers rather than literals
and are decided only through `isUnmodifiedGlobalReference`.
* chore: retrigger title and readiness checks after the title was corrected
* fix(javascript): place the port check away from the fs flag check
`#8125` adds an fs flag guard at the top of `collectAcquisitions` and this branch
added a socket port guard in the same spot, so the two conflicted on every
rebase. The port check now filters the completed candidate list from
`collectJavaScriptSemanticResources` and its helpers sit at the end of the file,
which cannot collide with the fs flag guard.
* fix(javascript): drop the redundant in-loop port guard
The caller now filters the completed candidate list, so the loop guard became
dead code. Removing it also keeps this branch from editing the same lines as the
fs flag guard in #8125.
280 lines
9.7 KiB
TypeScript
280 lines
9.7 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 { 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),
|
|
];
|
|
};
|
|
|
|
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;
|
|
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";
|