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 > = 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(); 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, ): boolean => candidate.specifier === "net" || candidate.specifier === "tls";