diff --git a/src/domain/javascript/javascriptAstValues.test.ts b/src/domain/javascript/javascriptAstValues.test.ts index 8ff1052439f..e24d0ee5787 100644 --- a/src/domain/javascript/javascriptAstValues.test.ts +++ b/src/domain/javascript/javascriptAstValues.test.ts @@ -2,6 +2,7 @@ import * as t from "@babel/types"; import { expect, it } from "vitest"; import { + javaScriptExpressionResultIsUsed, semanticMemberChain, semanticStaticPropertyKey, staticObjectProperty, @@ -10,6 +11,102 @@ import { staticObjectPropertyNames, } from "./javascriptAstValues.js"; +it("keeps missing and mismatched result-parent edges conservative", () => { + const call = t.callExpression(t.identifier("invoke"), []); + const other = t.numericLiteral(0); + const parents = new WeakMap(); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(true); + for (const parent of [ + t.expressionStatement(other), + t.unaryExpression("void", other), + t.sequenceExpression([other]), + t.parenthesizedExpression(other), + t.tsAsExpression(other, t.tsUnknownKeyword()), + t.conditionalExpression(other, other, other), + t.logicalExpression("&&", other, other), + t.forStatement(other, null, other, t.emptyStatement()), + ]) { + parents.set(call, parent); + if (t.isExpression(parent)) + parents.set(parent, t.expressionStatement(parent)); + expect(javaScriptExpressionResultIsUsed(call, parents), parent.type).toBe( + true, + ); + } +}); + +it("follows an explicit grouping wrapper through a final comma operand", () => { + const call = t.callExpression(t.identifier("invoke"), []); + const wrapper = t.parenthesizedExpression(call); + const sequence = t.sequenceExpression([t.numericLiteral(0), wrapper]); + const parents = new WeakMap([ + [call, wrapper], + [wrapper, sequence], + ]); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(true); + parents.set(sequence, t.expressionStatement(sequence)); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(false); + parents.set(sequence, t.callExpression(t.identifier("consume"), [sequence])); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(true); + parents.delete(sequence); + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(true); +}); + +it.each([128, 256, 512])( + "indexes comma membership once for %i sibling calls per parent map", + (count) => { + const calls = Array.from({ length: count }, () => + t.callExpression(t.identifier("invoke"), []), + ); + const sequence = t.sequenceExpression(calls); + let memberReads = 0; + sequence.expressions = new Proxy(sequence.expressions, { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/u.test(key)) memberReads += 1; + return Reflect.get(target, key, receiver); + }, + }); + const parentMap = () => + new WeakMap([ + ...calls.map((call): [t.Node, t.Node] => [call, sequence]), + [sequence, t.expressionStatement(sequence)], + ]); + const parents = parentMap(); + for (const call of calls) + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(false); + expect(memberReads).toBeLessThanOrEqual(count * 2); + const firstReads = memberReads; + for (const call of calls) + expect(javaScriptExpressionResultIsUsed(call, parents)).toBe(false); + expect(memberReads - firstReads).toBeLessThanOrEqual(count); + const repeatedReads = memberReads; + const independentParents = parentMap(); + for (const call of calls) + expect(javaScriptExpressionResultIsUsed(call, independentParents)).toBe( + false, + ); + // A different analysis owns its own membership, even for the same AST. + expect(memberReads - repeatedReads).toBe(firstReads); + }, +); + +it("keeps sequence membership isolated and rejects mismatches after indexing", () => { + const first = t.callExpression(t.identifier("first"), []); + const second = t.callExpression(t.identifier("second"), []); + const firstSequence = t.sequenceExpression([first, t.numericLiteral(0)]); + const secondSequence = t.sequenceExpression([second, t.numericLiteral(0)]); + const parents = new WeakMap([ + [first, firstSequence], + [second, secondSequence], + ]); + expect(javaScriptExpressionResultIsUsed(first, parents)).toBe(false); + expect(javaScriptExpressionResultIsUsed(second, parents)).toBe(false); + parents.set(first, secondSequence); + expect(javaScriptExpressionResultIsUsed(first, parents)).toBe(true); + parents.delete(first); + expect(javaScriptExpressionResultIsUsed(first, parents)).toBe(true); +}); + it("keeps string spread indices separate from named descriptor fields", () => { for (const text of ["", "x", "😀", "x".repeat(1_000_000)]) { const original = t.objectProperty( diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 2f26a7ecbaf..ef49cf3f2a4 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -50,6 +50,65 @@ export const javaScriptExpressionWrapperTarget = ( ? node.expression : undefined; +// Parent facts and sequence children belong to one immutable parsed tree. Retain +// only child membership; result use still follows the current outer parent edges. +const sequenceMembersByParents = new WeakMap< + Pick, "get">, + WeakMap> +>(); + +/** Follow only transparent result edges to an explicit value-discarding context. */ +export const javaScriptExpressionResultIsUsed = ( + node: t.Node, + parents: Pick, "get">, +): boolean => { + let child = node; + for ( + let parent = parents.get(child); + parent !== undefined; + parent = parents.get(child) + ) { + if (javaScriptExpressionWrapperTarget(parent) === child) { + child = parent; + continue; + } + if ( + (t.isConditionalExpression(parent) && + (parent.consequent === child || parent.alternate === child)) || + (t.isLogicalExpression(parent) && parent.right === child) + ) { + child = parent; + continue; + } + if (t.isSequenceExpression(parent)) { + let sequences = sequenceMembersByParents.get(parents); + if (sequences === undefined) { + sequences = new WeakMap(); + sequenceMembersByParents.set(parents, sequences); + } + let members = sequences.get(parent); + if (members === undefined) { + members = new Set(parent.expressions); + sequences.set(parent, members); + } + if (!members.has(child)) return true; + if (parent.expressions.at(-1) !== child) return false; + child = parent; + continue; + } + if (t.isExpressionStatement(parent)) return parent.expression !== child; + if (t.isUnaryExpression(parent, { operator: "void" })) + return parent.argument !== child; + // for/of and for/in heads use their expressions. A C-style for initializer + // or update discards the value, but a variable initializer still stores it. + return !( + t.isForStatement(parent) && + (parent.init === child || parent.update === child) + ); + } + return true; +}; + /** * Remove wrappers that affect TypeScript or Flow checking but not runtime * expression identity. The returned depth lets bounded evaluators account for diff --git a/src/domain/javascript/javascriptNetworkRequests.ts b/src/domain/javascript/javascriptNetworkRequests.ts index a2b4cfc8ec8..12830edd57b 100644 --- a/src/domain/javascript/javascriptNetworkRequests.ts +++ b/src/domain/javascript/javascriptNetworkRequests.ts @@ -3,16 +3,20 @@ import { semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; -import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; import { - resolveSemanticBindingState, - semanticStableInitializer, - type JavaScriptBindingIndex, - type JavaScriptSemanticAnalysisState, + semanticConstructedReceiver, + semanticPrimitiveArguments, +} from "./javascriptSemanticIntrinsicReceivers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import * as t from "@babel/types"; -import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { + isUnmodifiedGlobalReference, + semanticAmbientPathMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; export const isFetchCall = ( node: t.Node, @@ -40,6 +44,7 @@ const isXhrOpenRequest = ( node: t.Node, bindings: JavaScriptSemanticAnalysisState, index?: Pick, + purpose: "request" | "receiver" = "request", ): boolean | undefined => { if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return false; @@ -49,60 +54,58 @@ const isXhrOpenRequest = ( semanticStaticPropertyName(callee.property, callee.computed) !== "open" ) return false; + // Native open can dispatch readystatechange. Its receiver is nonescaping + // only when no handler or prior opaque operation can have configured it. + // Primitive arguments also avoid user code during WebIDL string conversion. if ( - ["XMLHttpRequest", "Object"].some((owner) => - ["open", "__proto__"].some( - (member) => - bindings.ambientWrites.covers([ - "global", - owner, - "prototype", - member, - ]) || - bindings.ambientEscapes.covers([ - "global", - owner, - "prototype", - member, - ]), + purpose === "receiver" && + !semanticPrimitiveArguments(node.arguments, bindings) + ) + return false; + const prototypeOwners = + purpose === "receiver" + ? ["XMLHttpRequest", "XMLHttpRequestEventTarget", "EventTarget", "Object"] + : ["XMLHttpRequest", "Object"]; + if ( + prototypeOwners.some((owner) => + (purpose === "receiver" + ? ["open", "__proto__", "onreadystatechange"] + : ["open", "__proto__"] + ).some((member) => + semanticAmbientPathMayHaveChangedAt( + purpose === "receiver" ? node : callee, + bindings, + ["global", owner, "prototype", member], + node, + ), ), ) ) return false; - let receiver = unwrapJavaScriptExpression(callee.object).node; - const seen = new Set(); - while (t.isIdentifier(receiver)) { - if ( - index !== undefined && - (index.referenceMayHaveChangedAt(node, receiver, ["open"]) || - index.referenceMayHaveChangedAt(node, receiver, ["__proto__"])) - ) - return false; - const binding = resolveSemanticBindingState( - bindings, - receiver, - receiver.name, - ); - if (binding === undefined || seen.has(binding.bindingId)) return false; - seen.add(binding.bindingId); - const initializer = semanticStableInitializer(binding, bindings); - if ( - initializer === undefined || - initializer.projection.length !== 0 || - !semanticBindingAtRead(binding, receiver, bindings).hasDefiniteInitializer - ) - return false; - receiver = unwrapJavaScriptExpression(initializer.node).node; - } + const receiver = semanticConstructedReceiver(callee.object, node, bindings, { + paths: purpose === "receiver" ? [[]] : [["open"], ["__proto__"]], + index, + }); if ( - !t.isNewExpression(receiver) || - !isUnmodifiedGlobalReference(receiver.callee, bindings, "XMLHttpRequest") || + receiver === undefined || + !isUnmodifiedGlobalReference( + receiver.construction.callee, + bindings, + "XMLHttpRequest", + ) || semanticOpaqueGlobalMayHaveChanged(callee, bindings) ) return false; - return index === undefined && seen.size > 0 ? undefined : true; + return index === undefined && receiver.viaBinding ? undefined : true; }; +/** A pristine native open is safe for the mutation producer, not every XHR call. */ +export const isIntrinsicNonEscapingXhrOpen = ( + node: t.Node, + bindings: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => isXhrOpenRequest(node, bindings, index, "receiver"); + /** Resolve once through the shared mutation index when lexical proof needs it. */ export const createXhrOpenRequestQuery = (index: JavaScriptBindingIndex): ((node: t.Node) => boolean) => diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index 5044197dcce..7d8396f7031 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -13,6 +13,9 @@ import { import { createGlobalPathReader, createGlobalReferenceReader, + retainSemanticAmbientEffect, + semanticAmbientPathMayHaveChangedAt, + semanticAmbientObjectMayHaveChangedAt, } from "./javascriptSemanticAmbientWrites.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; @@ -387,3 +390,251 @@ it.each([ expect(statuses).toEqual(expected); }, ); + +it.each([ + [ + "an intact method", + "const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');", + false, + ], + [ + "a current function-local invocation", + "function run(){const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');}", + false, + ], + [ + "an earlier constructor escape", + "const xhr=new XMLHttpRequest();mutate(xhr.constructor);xhr.open('GET','/xhr');", + true, + ], + [ + "a later constructor escape", + "const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(xhr.constructor);", + false, + ], + [ + "an escaped method value", + "const xhr=new XMLHttpRequest();mutate(XMLHttpRequest.prototype.open);xhr.open('GET','/xhr');", + false, + ], + [ + "a selected method before argument effects", + "const xhr=new XMLHttpRequest();xhr.open('GET',(mutate(xhr.constructor),'/xhr'));", + false, + ], + [ + "a later effect in a possibly repeated activation", + "function run(){const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(xhr.constructor);}", + true, + ], + [ + "an unlocated ambient escape", + "function request(){window.fetch('/other');}fetch=replacement;const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(xhr.constructor);", + true, + ], +])("retains timed ambient path effects for %s", (_label, source, changed) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected ambient-effect fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const results: boolean[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.property, { name: "open" }) + ) + results.push( + semanticAmbientPathMayHaveChangedAt( + node.callee, + index.bindings, + ["global", "XMLHttpRequest", "prototype", "open"], + node, + ), + ); + }, + }); + expect(results).toEqual([changed]); +}); + +it("shares matching ambient records across reads instead of scanning unrelated effects again", () => { + const work = (count: number): number => { + const source = + Array.from( + { length: count }, + (_value, index) => + "new XMLHttpRequest().open('GET','/xhr');globalThis.unrelated" + + index + + "=replacement;", + ).join("") + "XMLHttpRequest.prototype.open=replacement;"; + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected ambient work fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + // Producer proofs can already warm this path. Grow facts once so the + // measured reads include one cold lookup followed by shared warm lookups. + retainSemanticAmbientEffect( + index.bindings, + "write", + ["global", "addedAfterIndex"], + parsed.program, + ); + let reads = 0; + for (const effect of [ + ...index.bindings.ambientWriteEffects, + ...index.bindings.ambientEscapeEffects, + ]) { + const path = effect.path; + Object.defineProperty(effect, "path", { + configurable: true, + get: () => { + reads += 1; + return path; + }, + }); + } + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.property, { name: "open" }) + ) + semanticAmbientPathMayHaveChangedAt( + node.callee, + index.bindings, + ["global", "XMLHttpRequest", "prototype", "open"], + node, + ); + }, + }); + return reads; + }; + const smaller = work(32); + expect(smaller).toBeGreaterThan(0); + expect(work(64)).toBeLessThan(3 * smaller); +}); + +it.each(["write", "escape"] as const)( + "refreshes cached ambient records when %s facts grow without caching read answers", + (kind) => { + const parsed = parseJavaScriptSource( + "before();inspect();after();inspect();", + ); + if (parsed === null) + throw new Error("Expected growing ambient-effect fixture"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + const calls: t.CallExpression[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node)) calls.push(node); + }, + }); + const [before, first, after, second] = calls; + if (!before || !first || !after || !second) + throw new Error("Expected four call sites"); + const path = ["global", "XMLHttpRequest", "prototype", "open"]; + retainSemanticAmbientEffect(state, "write", path, after); + expect(semanticAmbientPathMayHaveChangedAt(first.callee, state, path)).toBe( + false, + ); + expect( + semanticAmbientPathMayHaveChangedAt(second.callee, state, path), + ).toBe(true); + retainSemanticAmbientEffect( + state, + kind, + kind === "write" ? path : ["global", "XMLHttpRequest"], + before, + ); + expect(semanticAmbientPathMayHaveChangedAt(first.callee, state, path)).toBe( + true, + ); + }, +); + +it("keeps invocation exclusions outside the shared ambient record cache", () => { + const parsed = parseJavaScriptSource("function run(){invoke();}"); + if (parsed === null) throw new Error("Expected invocation-effect fixture"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + let invocation: t.CallExpression | undefined; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node)) invocation = node; + }, + }); + if (invocation === undefined) throw new Error("Expected invocation"); + const path = ["global", "XMLHttpRequest", "prototype", "open"]; + retainSemanticAmbientEffect( + state, + "escape", + ["global", "XMLHttpRequest"], + invocation, + ); + expect( + semanticAmbientPathMayHaveChangedAt( + invocation.callee, + state, + path, + invocation, + ), + ).toBe(false); + expect( + semanticAmbientPathMayHaveChangedAt(invocation.callee, state, path), + ).toBe(true); +}); + +it.each([ + ["Array.prototype[0] = replacement; inspect();", true], + ["Array.prototype[key] = replacement; inspect();", true], + ["Array.prototype[0].value = replacement; inspect();", true], + ["configure(Array.prototype[0]); inspect();", true], + ["configure(Array.prototype); inspect();", true], + ["configure(Array); inspect();", true], + ["inspect(); Array.prototype[0] = replacement;", false], + ["function patch() { Array.prototype[0] = replacement; } inspect();", true], + ["Map.prototype[0] = replacement; inspect();", false], +])("queries timed ambient subtree overlap: %s", (source, changed) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected ambient subtree fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const results: boolean[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "inspect" }) + ) { + // A path query must not cache a subtree query's matching records or vice versa. + semanticAmbientPathMayHaveChangedAt( + node, + index.bindings, + ["global", "Array", "prototype"], + node, + ); + results.push( + semanticAmbientObjectMayHaveChangedAt( + node, + index.bindings, + ["global", "Array", "prototype"], + node, + ), + ); + } + }, + }); + expect(results).toEqual([changed]); +}); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index e8c5e87b56a..dd82af3a930 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -71,6 +71,97 @@ const ambientEffectCovers = ( semanticPropertyPathKeyMatches(key, target[index]!), ); +interface AmbientPathEffectCache { + readonly writes: number; + readonly escapes: number; + readonly byPath: Map< + string, + JavaScriptSemanticAnalysisState["ambientWriteEffects"] + >; +} + +const ambientPathEffectCaches = new WeakMap< + JavaScriptSemanticAnalysisState, + AmbientPathEffectCache +>(); + +const semanticAmbientEffectsMayHaveChangedAt = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, + path: readonly string[], + scope: "path" | "subtree", + invocation?: t.Node, +): boolean => { + if ( + scope === "path" && + !state.ambientWrites.covers(path) && + !state.ambientEscapes.covers(path) + ) + return false; + let cache = ambientPathEffectCaches.get(state); + if ( + cache?.writes !== state.ambientWriteEffects.length || + cache?.escapes !== state.ambientEscapeEffects.length + ) { + cache = { + writes: state.ambientWriteEffects.length, + escapes: state.ambientEscapeEffects.length, + byPath: new Map(), + }; + ambientPathEffectCaches.set(state, cache); + } + const key = JSON.stringify([scope, path]); + let effects = cache.byPath.get(key); + if (effects === undefined) { + const matches = ( + effect: JavaScriptSemanticAnalysisState["ambientWriteEffects"][number], + ): boolean => + scope === "path" + ? ambientEffectCovers(effect, path) + : effect.path + .slice(0, path.length) + .every((key, index) => + semanticPropertyPathKeyMatches(key, path[index]!), + ); + effects = [ + ...state.ambientWriteEffects.filter(matches), + ...state.ambientEscapeEffects.filter(matches), + ]; + cache.byPath.set(key, effects); + } + // Only matching records are shared. Their timing belongs to each read. + return effects.some( + (effect) => + // A receiver is exposed by the invocation after its method was selected. + (invocation === undefined || effect.node !== invocation) && + semanticEffectTiming(read, effect.node, state) !== "after", + ); +}; + +/** Query retained ambient effects at a selected read, preserving unknown timing. */ +export const semanticAmbientPathMayHaveChangedAt = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, + path: readonly string[], + invocation?: t.Node, +): boolean => + semanticAmbientEffectsMayHaveChangedAt(read, state, path, "path", invocation); + +/** Check an object's whole surface, including descendant accessors and aliases. */ +export const semanticAmbientObjectMayHaveChangedAt = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, + path: readonly string[], + invocation?: t.Node, +): boolean => + semanticAmbientEffectsMayHaveChangedAt( + read, + state, + path, + "subtree", + invocation, + ); + const retainedAmbientEffects = new WeakMap< JavaScriptSemanticAnalysisState, Map> @@ -607,8 +698,15 @@ export function* indexJavaScriptAmbientWritesSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, observeOpaqueInvocation?: (node: t.Node) => void, - observeWriteSite?: (node: t.Node) => void, + options: { + readonly observeWriteSite?: (node: t.Node) => void; + readonly nativeInspectionReceiver?: ( + node: t.CallExpression | t.OptionalCallExpression, + effectsCollected?: boolean, + ) => boolean | undefined; + } = {}, ): Generator { + const { observeWriteSite, nativeInspectionReceiver } = options; const ambientReads = createAmbientPathResolver(state, false); const browserReceiverMethods = new Set(); const storedSites = new WeakSet(); @@ -619,6 +717,7 @@ export function* indexJavaScriptAmbientWritesSteps( node: t.Node; target: t.Node; browserReceiver: boolean; + nativeInspection?: boolean; projection?: AmbientProjection; }[] = []; const stores = new WeakMap< @@ -687,8 +786,12 @@ export function* indexJavaScriptAmbientWritesSteps( (node, invocation) => { observeOpaqueInvocation?.(node); // Escaping objects can change contents without replacing their identity. - const retainEscape = (target: t.Node, browserReceiver = false) => { - escapes.push({ target, node, browserReceiver }); + const retainEscape = ( + target: t.Node, + browserReceiver = false, + nativeInspection = false, + ) => { + escapes.push({ target, node, browserReceiver, nativeInspection }); }; for (const argument of t.isTaggedTemplateExpression(node) ? node.quasi.expressions @@ -706,18 +809,27 @@ export function* indexJavaScriptAmbientWritesSteps( ["open", "fetch"].includes(invocation.path[1] ?? "") ? invocation.path[1] : undefined; + const inspectionReceiver = + nativeInspectionReceiver !== undefined && + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + nativeInspectionReceiver(node) !== false; if ( !t.isNewExpression(node) && (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && - !( - invocation?.kind === "known" && - ["Object", "Reflect"].includes(invocation.path[1] ?? "") - ) + (inspectionReceiver || + !( + invocation?.kind === "known" && + ["Object", "Reflect"].includes(invocation.path[1] ?? "") + )) ) { if (browserMethod !== undefined) browserReceiverMethods.add(browserMethod); - retainEscape(callee.object, browserMethod !== undefined); + retainEscape( + callee.object, + browserMethod !== undefined, + inspectionReceiver, + ); } }, ); @@ -810,7 +922,12 @@ export function* indexJavaScriptAmbientWritesSteps( // if no write or opaque escape can replace its native member before this // invocation; ambiguous execution order remains conservative. const resolverReceivers: typeof escapes = []; + const inspectionReceivers = new Set<(typeof escapes)[number]>(); for (const effect of escapes) { + if (effect.nativeInspection) { + inspectionReceivers.add(effect); + continue; + } const { node, target } = effect; const callee = t.isCallExpression(node) || t.isOptionalCallExpression(node) @@ -887,7 +1004,26 @@ export function* indexJavaScriptAmbientWritesSteps( state.ambientWrites.covers(["global", method]), ) ) - state.ambientEscapes.retain(["global", null]); + retainSemanticAmbientEffect(state, "escape", ["global"], undefined); + // Do not prove a receiver during traversal: a later-declared activation can + // escape its callable first. Settle ordinary effects, then monotonically + // replay failed candidates until no remaining native proof can be revoked. + while (inspectionReceivers.size > 0) { + const effectsBefore = state.ambientEscapeEffects.length; + let examined = 0; + for (const effect of inspectionReceivers) { + if (examined++ % 64 === 0) yield; + const { node } = effect; + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + nativeInspectionReceiver?.(node, true) === true + ) + continue; + inspectionReceivers.delete(effect); + retainEscapeEffect(effect); + } + if (state.ambientEscapeEffects.length === effectsBefore) break; + } } export type GlobalReferenceRead = diff --git a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts index 0f80b47dcaf..14756e19013 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts @@ -13,9 +13,25 @@ describe("JavaScript semantic call result use", () => { 'consume("unique")!;', 'consume("unique") as unknown;', '(consume("unique") as unknown)!;', + 'consume("unique") satisfies unknown;', + 'void consume("unique");', + 'const value = void consume("unique");', + 'const value = (consume("unique"), 0);', + '(0, consume("unique"));', + 'void (0, consume("unique"));', + 'const value = ((0, consume("unique")), 1);', + 'void ((0, consume("unique")) as unknown)!;', + 'flag ? consume("unique") : 0;', + 'flag ? 0 : consume("unique");', + 'flag && consume("unique");', + 'flag || consume("unique");', + 'flag ?? consume("unique");', + 'void ((flag ? (0, consume("unique")) : 0) as unknown)!;', + 'const value = ((flag && consume("unique")), 0);', ])("does not treat %s as a used call result", (source) => { const ir = analyzeJavaScriptSemantics(source); expect(ir.callSites[0]?.resultUsed).toBe(false); + expect(ir.callResultFlows).toEqual([]); }); it("treats an asserted call initializer as a used result", () => { @@ -23,12 +39,62 @@ describe("JavaScript semantic call result use", () => { 'const value = consume("unique")!;', ); expect(used.callSites[0]?.resultUsed).toBe(true); + expect(used.callResultFlows).toMatchObject([ + { + callSiteId: used.callSites[0]?.callSiteId, + bindingId: topLevelBinding(used, "value").bindingId, + }, + ]); + }); + + it.each([ + 'const value = (0, consume("unique"));', + 'void store(consume("unique"));', + 'void (holder.value = consume("unique"));', + 'void consume("unique").value;', + 'void +consume("unique");', + 'function run() { return consume("unique"); }', + 'const run = () => consume("unique");', + 'async function run() { void await consume("unique"); }', + 'function* run() { void (yield consume("unique")); }', + 'for (; consume("unique");) {}', + 'for (const value of consume("unique")) {}', + 'for (const value in consume("unique")) {}', + 'consume("unique") ? 0 : 1;', + 'consume("unique") && 0;', + 'const value = flag ? consume("unique") : 0;', + 'const value = flag && consume("unique");', + 'void (holder.value = flag ? consume("unique") : 0);', + 'void store(flag && consume("unique"));', + 'function run() { return flag ? consume("unique") : 0; }', + 'async function run() { void await (flag && consume("unique")); }', + 'function* run() { void (yield (flag ? 0 : consume("unique"))); }', + ])("preserves a consumed or unsupported result context: %s", (source) => { + const ir = analyzeJavaScriptSemantics(source); + const call = ir.callSites.find((site) => + site.arguments.some((argument) => argument.literal === "unique"), + ); + expect(call?.resultUsed).toBe(true); }); + it.each([ + 'const value = flag ? consume("unique") : 0;', + 'const value = flag && consume("unique");', + ])( + "does not invent a direct assigned-result flow through alternatives: %s", + (source) => { + const ir = analyzeJavaScriptSemantics(source); + expect(ir.callSites[0]?.resultUsed).toBe(true); + expect(ir.callResultFlows).toEqual([]); + }, + ); + it.each([ 'for (notify("token"); running;) {}', 'for (; running; notify("token")) {}', 'for (notify("token")!; running;) {}', + 'for ((0, notify("token")); running;) {}', + 'for (; running; (0, notify("token")!)) {}', ])("does not treat %s as a used call result", (source) => { const ir = analyzeJavaScriptSemantics(source); const call = ir.callSites.find((site) => diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts index 8897a08e467..83449360572 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_event_listener.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { analyzeCommonJsSemanticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; /** * A listener must be a function, and Node throws `ERR_INVALID_ARG_TYPE` before @@ -27,6 +28,19 @@ const eventsFor = (call: string) => eventName, })); +const eventsForSource = ( + source: string, + analyze = analyzeJavaScriptSemantics, +) => + analyze(source).eventOperations.map(({ kind, method, eventName }) => ({ + kind, + method, + eventName, + })); + +const eventsForCommonJsSource = (source: string) => + eventsForSource(source, analyzeCommonJsSemanticFixture); + it.each([ ["a missing on listener", `e.on("x")`], ["a missing once listener", `e.once("x")`], @@ -74,6 +88,1309 @@ it.each([ ]); }); +/** + * A listener method called without a listener argument passes `undefined` to + * Node's `checkListener`, which throws before anything is registered or + * removed. The argument position is empty, so no function can arrive there. + * + * Measured on Node 22.23.3: + * + * emitter.on() -> THREW ERR_INVALID_ARG_TYPE + * emitter.on("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.once("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.addListener("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.prependListener("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.prependOnceListener("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.removeListener("x") -> THREW ERR_INVALID_ARG_TYPE + * emitter.off("x") / emitter.off() -> THREW ERR_INVALID_ARG_TYPE + * + * A spread still reaches the position through the call, so it is untouched. + */ +it.each([ + ["a missing listener", `e.on("x")`], + ["a missing once listener", `e.once("x")`], + ["a missing addListener listener", `e.addListener("x")`], + ["a missing prependListener listener", `e.prependListener("x")`], + ["a missing prependOnceListener listener", `e.prependOnceListener("x")`], + ["a missing removeListener listener", `e.removeListener("x")`], + ["a missing off listener", `e.off("x")`], + ["no arguments at all", `e.on()`], + ["no off arguments at all", `e.off()`], +] as const)("emits no event operation for %s", (_name, call) => { + expect(eventsFor(call)).toEqual([]); +}); + +it("keeps the event operation for a spread call with no fixed listener", () => { + expect(eventsFor(`e.on(...pairs)`)).toEqual([ + { + kind: expect.any(String) as unknown as string, + method: "on", + eventName: null, + }, + ]); +}); + +it.each([ + [ + "a named import", + `import { EventEmitter } from "node:events";`, + "EventEmitter", + ], + ["a default import", `import EE from "node:events";`, "EE"], +] as const)( + "drops a missing listener on a receiver built from %s", + (_name, header, constructor) => { + expect( + eventsForSource(`${header} +const e = new ${constructor}(); +e.on("x");`), + ).toEqual([]); + }, +); + +/** Unknown hosts retain candidates; only the CommonJS wrapper proves require. */ +it.each([ + [ + "a destructured require", + `const { EventEmitter } = require("events");`, + "EventEmitter", + ], + [ + "a member require", + `const events = require("node:events");`, + "events.EventEmitter", + ], +] as const)( + "requires CommonJS authority to reject a missing listener from %s", + (_name, header, constructor) => { + const source = `${header} +const e = new (${constructor})(); +e.on("x");`; + expect(eventsForSource(source)).toContainEqual({ + kind: "register", + method: "on", + eventName: "x", + }); + expect(eventsForCommonJsSource(source)).toEqual([]); + }, +); + +it("keeps an ESM receiver when a candidate loader may replace its prototype", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +require("node:events").EventEmitter.prototype.on = replacement; +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it("keeps a missing listener when the receiver was reassigned", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e = other; +e.on("x");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "x" }); +}); + +/** + * `checkListener` is Node's own contract. An EventEmitter-style receiver that + * this module does not construct from `events.EventEmitter` may define a + * one-argument form: Socket.IO's `socket.off("x")` removes every listener for + * the event, so the operation must survive as an unresolved candidate. + */ +it("keeps a one-argument removal on a receiver with no proven Node emitter", () => { + expect( + eventsForSource(`function g(socket) { socket.off("x"); }`), + ).toContainEqual({ kind: "remove", method: "off", eventName: "x" }); +}); + +/** + * A subclass may default the listener position, which makes `bus.on("ready")` + * a real registration even though the module never proves Node's rejection. + * Measured on Node 22.23.3: this class stores `"ready"` and does not throw. + */ +it("keeps the registration of a subclass that defaults its listener", () => { + const events = eventsForSource( + `import { EventEmitter } from "node:events"; +class Bus extends EventEmitter { + on(name, fn = () => {}) { return super.on(name, fn); } +} +const bus = new Bus(); +bus.on("ready");`, + ); + expect(events).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it.each([ + ["extra.EventEmitter", "extra", "{ EventEmitter: Bus }"], + ["extra.deep.EventEmitter", "extra", "{ deep: { EventEmitter: Bus } }"], + ["default.EventEmitter", "default", "{ EventEmitter: Bus }"], +] as const)( + "keeps custom nested events exports as candidates: %s", + (selected, changed, replacement) => { + for (const listener of ["", ", () => {}"]) { + expect( + eventsForSource(`import A from "events"; +import B from "node:events"; +import { Bus } from "./bus.js"; +A.${changed} = ${replacement}; +const e = new B.${selected}(); +e.on("ready"${listener});`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); + } + }, +); + +it("still rejects a canonical class through a namespace default export", () => { + expect( + eventsForSource(`import * as events from "node:events"; +const e = new events.default.EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + +/** + * A member write replaces the method the native contract belongs to. Measured + * on Node 22.23.3: `e.on = (name, fn = () => {}) => ...` stores `"ready"`, so + * `checkListener` never runs and the one-argument call is a real registration. + */ +it("keeps the registration of a receiver whose method was replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on = (name, fn = () => {}) => EventEmitter.prototype.on.call(e, name, fn); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Every instance shares the constructor's prototype, so replacing the method + * there replaces it for this receiver too. + */ +it("keeps the registration of a receiver whose prototype was replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +EventEmitter.prototype.on = function (name, fn = () => {}) { return this; }; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * The proof is keyed on the selected method, so a write to an unrelated member + * must not weaken it: `tag` never answers `on`. + */ +it("still rejects a missing listener beside an unrelated member write", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.tag = 1; +e.on("ready");`), + ).toEqual([]); +}); + +/** + * A namespace import constructs the class through a member expression, so the + * constructor has no binding of its own and the prototype write is recorded + * against the module instead. Measured on Node 22.22.2: rewriting `on` to + * delegate to the `addListener` captured before the rewrite stores `"ready"` + * for a one-argument call. Delegating to `this.addListener` instead overflows + * the stack, because Node defines `addListener` as the same function as `on`. + */ +it("keeps the registration of a receiver built from a namespace member", () => { + expect( + eventsForSource(`import * as events from "node:events"; +const original = events.EventEmitter.prototype.addListener; +const e = new events.EventEmitter(); +events.EventEmitter.prototype.on = function (name, fn = () => {}) { return original.call(this, name, fn); }; +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** A member require reaches the same class through the same member expression. */ +it("keeps the registration of a receiver built from a member require", () => { + expect( + eventsForCommonJsSource(`const events = require("node:events"); +const original = events.EventEmitter.prototype.addListener; +const e = new events.EventEmitter(); +events.EventEmitter.prototype.on = function (name, fn = () => {}) { return original.call(this, name, fn); }; +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Handing the instance to another call lets that call replace the method. + * Measured on Node 22.22.2: `configure(e)` replaces `on` with a form that + * defaults the listener, so `e.on("ready")` registers. + */ +it("keeps the registration of a receiver that was passed to a call", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +function configure(target) { + target.on = (name, fn = () => {}) => target.addListener(name, fn); +} +configure(e); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it.each([ + ["inline", "", "process.getBuiltinModule('events').EventEmitter"], + [ + "qualified", + "", + "globalThis.process.getBuiltinModule('node:events').EventEmitter", + ], + [ + "bound alias", + "const events = process.getBuiltinModule('events'); const alias = events;", + "alias.EventEmitter", + ], + [ + "destructured global getter", + "const { getBuiltinModule: get } = process;", + "get('events').EventEmitter", + ], + [ + "extracted global getter", + "const get = process.getBuiltinModule;", + "get('events').EventEmitter", + ], + [ + "qualified global getter", + "const get = globalThis.process.getBuiltinModule;", + "get('events').EventEmitter", + ], + [ + "global getter alias", + "const get0 = process.getBuiltinModule; const get = get0;", + "get('events').EventEmitter", + ], + [ + "process object alias", + "const host = process;", + "host.getBuiltinModule('events').EventEmitter", + ], + [ + "stored global getter", + "const holder = { get: process.getBuiltinModule };", + "holder.get('events').EventEmitter", + ], + [ + "imported process", + "import process from 'node:process';", + "process.getBuiltinModule('events').EventEmitter", + ], + [ + "imported getter", + "import { getBuiltinModule } from 'node:process';", + "getBuiltinModule('events').EventEmitter", + ], + [ + "extracted imported getter", + "import * as process from 'node:process'; const get = process.getBuiltinModule;", + "get('events').EventEmitter", + ], +] as const)( + "orders shared prototype changes through %s builtin acquisition", + (_name, setup, constructor) => { + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +${setup} +const e = new EventEmitter(); +${before ? `${constructor}.prototype.on = replacement;` : ""} +e.on("ready"); +${before ? "" : `${constructor}.prototype.on = replacement;`}`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); + +it("retains a prototype exposed through builtin acquisition", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +configure(process.getBuiltinModule('events').EventEmitter.prototype); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it("keeps builtin acquisition effects whose callback timing is unknown", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +function patch() { process.getBuiltinModule('events').EventEmitter.prototype.on = replacement; } +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it("does not grant native constructor proof from effect-only builtin acquisition", () => { + expect( + eventsForSource(`const events = process.getBuiltinModule('events'); +const e = new events.EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it.each([ + ["inline named", "", "(await import('node:events')).EventEmitter"], + ["inline default", "", "(await import('events')).default"], + [ + "conditional namespace", + "", + "(await (flag ? import('events') : import('node:events'))).EventEmitter", + ], + [ + "sequence namespace", + "", + "(await (sideEffect(), import('events'))).EventEmitter", + ], + [ + "assignment namespace", + "let namespacePromise;", + "(await (namespacePromise = import('events'))).EventEmitter", + ], + ["logical namespace", "", "(await (other || import('events'))).EventEmitter"], + [ + "bound namespace", + "const events = await import('events');", + "events.EventEmitter", + ], + [ + "destructured named", + "const { EventEmitter: EE } = await import('node:events');", + "EE", + ], + [ + "bound alias", + "const events = await import('events'); const alias = events;", + "alias.EventEmitter", + ], +] as const)( + "orders shared prototype changes through %s dynamic import", + (_name, setup, constructor) => { + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +${setup} +const e = new EventEmitter(); +${before ? `${constructor}.prototype.on = replacement;` : ""} +e.on("ready"); +${before ? "" : `${constructor}.prototype.on = replacement;`}`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); + +it("retains a prototype exposed through awaited import", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +configure((await import('events')).EventEmitter.prototype); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it.each([ + "flag ? import('events') : import('node:events')", + "sideEffect(), import('events')", + "promise = import('events')", + "other || import('events')", +])("orders escapes through awaited branch namespaces: %s", (acquisition) => { + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +let promise; +const e = new EventEmitter(); +${before ? `configure((await (${acquisition})).EventEmitter.prototype);` : ""} +e.on("ready"); +${before ? "" : `configure((await (${acquisition})).EventEmitter.prototype);`}`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); +}); + +it("keeps dynamic import effects whose callback timing is unknown", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +async function patch() { (await import('events')).EventEmitter.prototype.on = replacement; } +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +// These callback/Promise routes retain uncertainty without proving a module origin. +it.each([ + "await import('events').then(ns => { ns.EventEmitter.prototype.on = replacement; });", + "await import('events').then(({ default: EE }) => { EE.prototype.on = replacement; });", + "const promise = import('events'); (await promise).EventEmitter.prototype.on = replacement;", + "const [ns] = await Promise.all([import('events')]); ns.EventEmitter.prototype.on = replacement;", +])("preserves uncertain dynamic acquisition effects: %s", (mutation) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +${mutation} +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +// These Promise aliases remain conservative without a shared-module origin fact. +it.each([ + "const pending = import('node:events'); const ns = await pending;", + "const pending = import('node:events'); const alias = pending; const ns = await alias;", + "const { pending } = { pending: import('node:events') }; const ns = await pending;", + "const [pending] = [import('node:events')]; const ns = await pending;", + "const pending = flag ? import('events') : import('node:events'); const ns = await pending;", +])( + "preserves unknown initializer mutations through Promise aliases: %s", + (setup) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { initialize } from "./initialize.js"; +${setup} +ns.EventEmitter.init = initialize; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); + }, +); + +it("does not grant native constructor proof from effect-only dynamic import", () => { + expect( + eventsForSource(`const events = await import('events'); +const e = new events.EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * The added checks must not withdraw the proof from an untouched namespace + * receiver, or the guard would never apply to the member-expression forms. + */ +it("still rejects a missing listener on an untouched namespace receiver", () => { + expect( + eventsForSource(`import * as events from "node:events"; +const e = new events.EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + +/** + * A call that runs after the registration cannot change whether that + * registration threw. Measured on Node 22.22.2: `e.on("x")` throws + * `ERR_INVALID_ARG_TYPE` before `configure(e)` or `e.emit("done")` run, so a + * later escape is not evidence about this call. + */ +it("still rejects a missing listener registered before the receiver escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on("ready"); +function configure(target) { + target.on = (name, fn = () => {}) => target.addListener(name, fn); +} +configure(e); +e.emit("done");`), + ).not.toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * `prototype[key]` records an unknown property name, which still covers the + * method this call selects. Measured on Node 22.22.2: after that write + * `e.on("ready")` stores one listener. + */ +it("keeps the registration of a receiver whose method is replaced by key", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const original = EventEmitter.prototype.addListener; +const key = "on"; +EventEmitter.prototype[key] = function (name, fn = () => {}) { return original.call(this, name, fn); }; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * A write through an inline require belongs to the module rather than to a + * binding. Measured on Node 22.22.2: it replaces `on` for every instance, so + * `e.on("ready")` stores one listener. + */ +it("keeps the registration of a receiver after an inline require write", () => { + expect( + eventsForCommonJsSource(`const original = require("node:events").EventEmitter.prototype.addListener; +require("node:events").EventEmitter.prototype.on = function (name, fn = () => {}) { return original.call(this, name, fn); }; +const { EventEmitter } = require("node:events"); +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Handing the prototype object itself to a call lets that call install a + * method. Measured on Node 22.22.2: after `configure(EventEmitter.prototype)` + * installs a defaulting wrapper, `e.on("ready")` stores one listener. + */ +it("keeps the registration after the prototype object escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +function configure(proto) { + const saved = proto.on; + proto.on = function (name, fn = () => {}) { return saved.call(this, name, fn); }; +} +configure(EventEmitter.prototype); +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Replacing the receiver's own prototype changes which method answers the call. + * Measured on Node 22.22.2: with `e.__proto__` set to an object that defaults + * the listener, `e.on("ready")` does not throw. + */ +it("keeps the registration after the receiver prototype is replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +const saved = EventEmitter.prototype.on; +e.__proto__ = { on(name, fn = () => {}) { return saved.call(this, name, fn); } }; +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Handing the class itself to a call opens the same route as handing out its + * prototype. Measured on Node 22.22.2: after `configure(EventEmitter)` installs + * a defaulting wrapper, `e.on("ready")` does not throw. + */ +it("keeps the registration after the class object escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +function configure(Ctor) { + Ctor.prototype.on = function (name, fn = () => {}) { + return EventEmitter.prototype.addListener.call(this, name, fn); + }; +} +const e = new EventEmitter(); +configure(EventEmitter); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Node runs `EventEmitter.init` while constructing, so a replaced hook can give + * every new instance its own method. Measured on Node 22.22.2: `e.on("ready")` + * does not throw after `init` is replaced with a wrapper that installs one. + */ +it("keeps the registration after the init hook is replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const savedInit = EventEmitter.init; +EventEmitter.init = function (opts) { + savedInit.call(this, opts); + this.on = function (name, fn = () => {}) { + return EventEmitter.prototype.addListener.call(this, name, fn); + }; +}; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Node invokes `EventEmitter.init.call(this, opts)`. The initializer function is + * mutable: an external configurator can replace its `.call` slot with a wrapper + * that installs an own defaulting listener method. Measured on Node 24.22.1: + * `e.on("ready")` then registers one listener without throwing. + */ +it.each([ + [ + "a named import", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + "EventEmitter.init", + ], + [ + "a namespace", + 'import * as events from "node:events";', + "events.EventEmitter", + "events.EventEmitter.init", + ], + [ + "an initializer alias", + 'import { EventEmitter } from "node:events"; const init = EventEmitter.init;', + "EventEmitter", + "init", + ], + [ + "a required module", + 'const events = require("node:events");', + "events.EventEmitter", + "events.EventEmitter.init", + true, + ], + [ + "an inline require", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + 'require("events").EventEmitter.init', + true, + ], + [ + "a named initializer import", + 'import { EventEmitter, init } from "node:events";', + "EventEmitter", + "init", + ], + [ + "a named initializer alias", + 'import { EventEmitter, init } from "node:events"; const initialize = init;', + "EventEmitter", + "initialize", + ], + [ + "a namespace initializer", + 'import * as events from "node:events";', + "events.EventEmitter", + "events.init", + ], + [ + "a member required initializer", + 'const EventEmitter = require("node:events"); const init = require("events").init;', + "EventEmitter", + "init", + true, + ], +] as const)( + "checks initializer escape timing through %s", + (_name, header, constructor, initializer, commonJs: boolean = false) => { + const events = commonJs ? eventsForCommonJsSource : eventsForSource; + const source = (before: boolean) => `${header} +import { configure } from "./configure.js"; +${before ? `configure(${initializer});` : ""} +const e = new ${constructor}(); +${before ? "" : `configure(${initializer});`} +e.on("ready");`; + expect(events(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(events(source(false))).toEqual([]); + }, +); + +it("keeps the registration after a destructured required initializer escapes", () => { + expect( + eventsForCommonJsSource(`const { EventEmitter, init } = require("node:events"); +import { configure } from "./configure.js"; +configure(init); +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * The inherited call slot is mutable, and init.prototype.constructor leads back + * to init itself. Existing prototype/escape facts must retain both surfaces. + */ +it.each([ + "EventEmitter.init.__proto__", + "Reflect.getPrototypeOf(EventEmitter.init)", + "EventEmitter.init.prototype", + "init.__proto__", + "Reflect.getPrototypeOf(init)", + "init.prototype", +])( + "keeps the registration when the initializer is reachable through %s", + (surface) => { + expect( + eventsForSource(`import { EventEmitter, init } from "node:events"; +import { configure } from "./configure.js"; +configure(${surface}); +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); + }, +); + +it.each(["EventEmitter.init", "init"])( + "still rejects a missing listener when only %s.call escapes", + (initializer) => { + expect( + eventsForSource(`import { EventEmitter, init } from "node:events"; +import { configure } from "./configure.js"; +configure(${initializer}.call); +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); + }, +); + +it.each(["init.call = replacement;", "init.__proto__ = replacement;"])( + "checks named initializer mutation timing for %s", + (mutation) => { + const source = ( + before: boolean, + ) => `import { EventEmitter, init } from "node:events"; +${before ? mutation : ""} +const e = new EventEmitter(); +${before ? "" : mutation} +e.on("ready");`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); + +it("still rejects a missing listener beside an unrelated imported initializer escape", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { init } from "./unrelated.js"; +import { configure } from "./configure.js"; +configure(init); +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + +it("keeps the registration after an initializer alias call slot is replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const init = EventEmitter.init; +init.call = replacement; +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * Two specifiers name the same mutable module, so a replacement installed + * through one import answers the other. Measured on Node 22.22.2: `e.on("ready")` + * does not throw after the class is replaced through the first import. + */ +it("keeps the registration after the class is replaced through another import", () => { + expect( + eventsForSource(`import first from "events"; +import second from "node:events"; +first.EventEmitter = class extends first.EventEmitter { + on(name, fn = () => {}) { return super.on(name, fn); } +}; +const e = new second.EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** + * An `init` property on an unrelated object cannot reshape the class. Measured + * on Node 22.22.2: `e.on("x")` still throws after `config.init = true`. + */ +it("still rejects a missing listener beside an unrelated init write", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const config = {}; +config.init = true; +const e = new EventEmitter(); +e.on("x");`), + ).toEqual([]); +}); + +/** + * Only a hook installed before the construction can shape the instance, so a + * later class-level write leaves this receiver on the native method. Measured + * on Node 22.22.2: `e.on("x")` still throws after `EventEmitter.init` is + * replaced on an already-built receiver. + */ +it("still rejects a missing listener when the init hook changes later", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +EventEmitter.init = function () {}; +e.on("x");`), + ).toEqual([]); +}); + +/** + * A direct `eval` can rewrite the receiver with code no write record describes. + * Measured on Node 22.22.2: `e.on("ready")` registers one listener after the + * evaluated replacement installs a defaulting method. + */ +it("keeps the registration when eval may replace the method", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +eval("e.on = (name, fn = () => {}) => e.addListener(name, fn)"); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +/** The imported helper hides its writes, so only the shared escape proves uncertainty. */ +it.each([ + [ + "the class", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + "EventEmitter", + ], + [ + "the namespace", + 'import * as events from "node:events";', + "events.EventEmitter", + "events", + ], + [ + "a class alias", + 'import { EventEmitter } from "node:events"; const EE = EventEmitter;', + "EE", + "EE", + ], + [ + "another import", + 'import first from "events"; import second from "node:events";', + "second.EventEmitter", + "first", + ], + [ + "a required module", + 'const events = require("node:events");', + "events.EventEmitter", + "events", + true, + ], + [ + "an inline require", + "", + 'require("node:events").EventEmitter', + 'require("events")', + true, + ], +] as const)( + "keeps the registration when %s escapes after construction", + (_name, header, constructor, escaped, commonJs: boolean = false) => { + const events = commonJs ? eventsForCommonJsSource : eventsForSource; + expect( + events(`import { configure } from "./configure.js"; +${header} +const e = new (${constructor})(); +configure(${escaped}); +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + +it.each([ + [ + "the class", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + "EventEmitter", + ], + [ + "the namespace", + 'import * as events from "node:events";', + "events.EventEmitter", + "events", + ], + [ + "another import", + 'import first from "events"; import second from "node:events";', + "second.EventEmitter", + "first", + ], +] as const)( + "still rejects a missing listener before %s escapes", + (_name, header, constructor, escaped) => { + expect( + eventsForSource(`import { configure } from "./configure.js"; +${header} +const e = new (${constructor})(); +e.on("ready"); +configure(${escaped});`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + +it("still rejects a missing listener when only the prototype method value escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +configure(EventEmitter.prototype.on); +const e = new EventEmitter(); +e.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects a missing listener when only a saved method value escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const saved = EventEmitter.prototype.on; +configure(saved); +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + +it.each([ + "configure(EventEmitter)", + 'eval("EventEmitter.prototype.on = replacement")', +])( + "still rejects when %s runs after method selection in an argument", + (effect) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +e.on((${effect}, "ready"));`), + ).toEqual([]); + }, +); + +it.each(["before", "after"] as const)( + "keeps the registration when the prototype constructor escapes %s construction", + (timing) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +${timing === "before" ? "configure(EventEmitter.prototype.constructor);" : ""} +const e = new EventEmitter(); +${timing === "after" ? "configure(EventEmitter.prototype.constructor);" : ""} +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + +it("keeps the registration after an alias of the prototype constructor escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const Ctor = EventEmitter.prototype.constructor; +const e = new EventEmitter(); +configure(Ctor); +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("keeps the registration after a computed prototype constructor escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const key = "prototype"; +const e = new EventEmitter(); +configure(EventEmitter[key].constructor); +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +/** Shared prototype changes may outlive the activation that made them. */ +it("keeps a registration when a class escape may run in an earlier invocation", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +function run(change) { + const e = new EventEmitter(); + if (!change) e.on("ready"); + configure(EventEmitter); +} +run(true); +run(false);`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it.each([ + [ + "a named import", + 'import { EventEmitter } from "node:events";', + "EventEmitter", + ], + ["a local class alias", 'import { EventEmitter } from "node:events";', "EE"], + [ + "a required module", + 'const events = require("node:events");', + "events.EventEmitter", + true, + ], + ["an inline require", "", 'require("node:events").EventEmitter', true], +] as const)( + "keeps a nested receiver when module eval may change %s", + (_name, header, constructor, commonJs: boolean = false) => { + const events = commonJs ? eventsForCommonJsSource : eventsForSource; + expect( + events(`${header} +const original = (${constructor === "EE" ? "EventEmitter" : constructor}).prototype.on; +eval(${JSON.stringify(`(${constructor === "EE" ? "EventEmitter" : constructor}).prototype.on = function(name, fn = () => {}) { return original.call(this, name, fn); }`)}); +function run() { + ${constructor === "EE" ? "const EE = EventEmitter;" : ""} + const e = new (${constructor})(); + e.on("ready"); +} +run();`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + +it("keeps a registration when outer eval may run in an earlier invocation", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const original = EventEmitter.prototype.on; +function run(change) { + if (change) eval("EventEmitter.prototype.on = function(name, fn = () => {}) { return original.call(this, name, fn); }"); + function listen() { + const e = new EventEmitter(); + e.on("ready"); + } + if (!change) listen(); +} +run(true); +run(false);`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects a missing listener before a top-level eval runs", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on("ready"); +eval("EventEmitter.prototype.on = replacement");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects a missing listener beside a shadowed eval call", () => { + expect( + eventsForCommonJsSource(`const { EventEmitter } = require("node:events"); +function run(eval) { + const e = new EventEmitter(); + eval("ordinary callback"); + e.on("ready"); +}`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("keeps a registration when indirect eval may reacquire the shared module", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const indirect = eval; +indirect("process.getBuiltinModule('events').EventEmitter.prototype.on = replacement"); +function run() { + const e = new EventEmitter(); + e.on("ready"); +}`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects a missing listener before a top-level indirect eval", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const indirect = eval; +const e = new EventEmitter(); +e.on("ready"); +indirect("process.getBuiltinModule('events').EventEmitter.prototype.on = replacement");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it.each([ + "configure(e.constructor);", + "const Ctor = e.constructor; configure(Ctor);", + 'const key = "constructor"; configure(e[key]);', + "configure(e.__proto__.constructor);", +])("keeps a registration when the receiver exposes its class: %s", (effect) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +${effect} +e.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it.each([ + "configure(e.constructor);", + "const Ctor = e.constructor; configure(Ctor);", +])("still rejects before the receiver exposes its class: %s", (effect) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +e.on("ready"); +${effect}`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects when only a method reached through the receiver class escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const e = new EventEmitter(); +configure(e.constructor.prototype.on); +e.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("keeps another receiver's registration when a whole instance exposes the shared class", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const first = new EventEmitter(); +const second = new EventEmitter(); +configure(first); +second.on("ready");`), + ).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects before another instance exposes the shared class", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const first = new EventEmitter(); +const second = new EventEmitter(); +second.on("ready"); +configure(first);`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects when only another receiver's method value escapes", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const first = new EventEmitter(); +const second = new EventEmitter(); +configure(first.on); +second.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it("still rejects after an instance constructor slot is replaced", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.constructor = Other; +e.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); +}); + +it.each(["const copy = { ...first };", "const { tag, ...copy } = first;"])( + "still rejects when a shallow copy excludes inherited class references: %s", + (copy) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +const first = new EventEmitter(); +const second = new EventEmitter(); +${copy} +configure(copy); +second.on("ready");`), + ).not.toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + }, +); + /** * `emit` passes its remaining arguments to listeners that already exist, so it * does not validate them the way a registration does. @@ -120,3 +1437,153 @@ it.each([ { kind: "register", method, eventName: null }, ]); }); + +it.each([ + ["direct write", "Function.prototype.call = wrapper;"], + ["qualified write", "globalThis.Function.prototype.call = wrapper;"], + [ + "prototype alias", + "const proto = Function.prototype; proto.call = wrapper;", + ], + ["prototype escape", "configure(Function.prototype);"], + ["unknown property", "Function.prototype[key] = wrapper;"], +])( + "orders the initializer's inherited call dependency: %s", + (_name, mutation) => { + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +import { wrapper, configure } from "./patch.js"; +${before ? mutation : ""} +const e = new EventEmitter(); +${before ? "" : mutation} +e.on("ready");`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); + +it("keeps unknown timing on the initializer's inherited call dependency", () => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { wrapper } from "./patch.js"; +function patch() { Function.prototype.call = wrapper; } +const e = new EventEmitter(); +e.on("ready");`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); +}); + +it.each([ + "configure(Function.prototype.call);", + "Function.prototype.tag = wrapper;", + "function patch(Function) { Function.prototype.call = wrapper; }", +])( + "preserves native proof without an inherited call-slot change: %s", + (mutation) => { + expect( + eventsForSource(`import { EventEmitter } from "node:events"; +import { wrapper, configure } from "./patch.js"; +${mutation} +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); + }, +); + +it.each([ + "configure(EventEmitter.init.prototype.constructor);", + "configure(init.prototype.constructor);", + "configure(EventEmitter.init.prototype.constructor.prototype.constructor);", + "const initialize = init.prototype.constructor; configure(initialize);", + "init.prototype.constructor.call = wrapper;", + "init.prototype.constructor.prototype.constructor.call = wrapper;", +])("orders initializer back-reference effects: %s", (mutation) => { + const source = ( + before: boolean, + ) => `import { EventEmitter, init } from "node:events"; +import { configure, wrapper } from "./patch.js"; +${before ? mutation : ""} +const e = new EventEmitter(); +${before ? "" : mutation} +e.on("ready");`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); +}); + +it.each([ + "configure(EventEmitter.init.prototype.constructor.call);", + "configure(init.prototype.constructor.call);", + "init.prototype.constructor.tag = wrapper;", + "init.prototype.constructor = wrapper;", + "init.prototype.constructor.prototype.constructor = wrapper;", + "delete init.prototype.constructor;", + "delete init.prototype.constructor.prototype.constructor;", +])("preserves unrelated initializer back-reference values: %s", (mutation) => { + expect( + eventsForSource(`import { EventEmitter, init } from "node:events"; +import { configure, wrapper } from "./patch.js"; +${mutation} +const e = new EventEmitter(); +e.on("ready");`), + ).toEqual([]); +}); + +it.each([ + ["namespace", 'import * as ns from "events";', "ns.init"], + ["default", 'import ns from "events";', "ns.init"], + ["builtin getter", "", 'process.getBuiltinModule("events").init'], + ["awaited namespace", "", '(await import("events")).init'], + ["awaited default", "", '(await import("events")).default.init'], +])( + "orders existing %s initializer back-reference facts", + (_name, header, initializer) => { + const effect = `configure(${initializer}.prototype.constructor);`; + const source = ( + before: boolean, + ) => `import { EventEmitter } from "node:events"; +import { configure } from "./configure.js"; +${header} +${before ? effect : ""} +const e = new EventEmitter(); +${before ? "" : effect} +e.on("ready");`; + expect(eventsForSource(source(true))).toContainEqual({ + kind: "register", + method: "on", + eventName: "ready", + }); + expect(eventsForSource(source(false))).toEqual([]); + }, +); + +it.each([ + "process.getBuiltinModule.call(null, 'events')", + "process.getBuiltinModule.apply(null, ['events'])", + "Reflect.apply(process.getBuiltinModule, null, ['events'])", + "process.getBuiltinModule.bind(null, 'events')()", + "process.getBuiltinModule.bind(null)('events')", +])( + "retains native-listener uncertainty after normalized getter effects: %s", + (getter) => { + expect( + eventsForSource(`import {EventEmitter} from 'node:events'; + import {replacement} from './patch.js'; + ${getter}.EventEmitter.prototype.on = replacement; + const e = new EventEmitter(); e.on('ready');`), + ).toContainEqual({ kind: "register", method: "on", eventName: "ready" }); + expect( + eventsForSource(`import {EventEmitter} from 'node:events'; + import {replacement} from './patch.js'; + const e = new EventEmitter(); e.on('ready'); + ${getter}.EventEmitter.prototype.on = replacement;`), + ).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index f9abd62983b..903cead21b9 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -80,6 +80,7 @@ import { semanticBundledRequireOriginCandidate, } from "./javascriptBundledRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; +import { createSemanticArgumentInspectionQuery } from "./javascriptSemanticArgumentInspections.js"; import { semanticBindingAtRead, semanticEffectTiming, @@ -220,7 +221,12 @@ function* indexSemanticAmbientWritesSteps( createAmbientPathResolver, createSemanticModuleInterop, }), - (node) => observePossibleCommonJsDelegateWrite(node, state), + { + observeWriteSite: (node) => + observePossibleCommonJsDelegateWrite(node, state), + nativeInspectionReceiver: + createSemanticArgumentInspectionQuery(state).receiver, + }, ); if (state.opaqueEffects.globalSites.length > 0) clearSemanticValueCaches(state); @@ -1185,7 +1191,7 @@ const createState = ( }; return { precision: resolveJavaScriptSemanticPrecision(precision), - arrayIterationUnknown: false, + arrayIterationEffects: [], memberMutationFactsComplete: false, program, opaqueEffects: { diff --git a/src/domain/javascript/javascriptSemanticArgumentInspections.test.ts b/src/domain/javascript/javascriptSemanticArgumentInspections.test.ts new file mode 100644 index 00000000000..4b1470ad3b5 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArgumentInspections.test.ts @@ -0,0 +1,448 @@ +import { runInNewContext } from "node:vm"; +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { createSemanticArgumentInspectionQuery } from "./javascriptSemanticArgumentInspections.js"; +import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; +import type { JavaScriptSemanticPrecisionInput } from "./javascriptSemanticPrecision.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const METHODS = [ + "Array.isArray", + "Object.keys", + "Object.values", + "Object.entries", + "Object.getOwnPropertyNames", + "Object.getOwnPropertySymbols", + "Object.getOwnPropertyDescriptors", + "Reflect.ownKeys", +]; +const capture = "const urls=['/api'];const [url]=urls;fetch(url);"; +const endpoints = ( + source: string, + precision?: JavaScriptSemanticPrecisionInput, +) => + analyzeJavaScriptStaticSource(source, precision).endpoints.map( + ({ value }) => value, + ); + +const fixture = ( + source: string, + mutations = true, + precision?: JavaScriptSemanticPrecisionInput, +) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected native-inspection fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps( + parsed.program, + undefined, + precision, + ), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + const calls: (t.CallExpression | t.OptionalCallExpression)[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) + calls.push(node); + }, + }); + if (mutations) completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const query = createSemanticArgumentInspectionQuery(index.bindings); + return { index, calls, query }; +}; + +const proof = (source: string) => { + const { index, calls, query } = fixture(source); + const call = calls.at(-1)!; + return query.argument(call, call.arguments[0]!, index); +}; + +it.each(METHODS)("preserves unrelated iteration after native %s", (method) => { + for (const allocation of [ + "new Object()", + "new Array(3)", + "new Array('a', 'b')", + ]) { + for (const source of [ + `${method}(${allocation});`, + `const value=${allocation};const alias=value;void ${method}(alias);`, + ]) { + expect(proof(source)).toBe(true); + expect(endpoints(source + capture)).toEqual(["/api"]); + } + } +}); + +it.each([ + "Array.isArray([]);", + "Object.keys({});", + "new Array(3);", + "const value=new Array(3);globalThis.Array.isArray(value);", + "const value=new Object();Object?.['keys']?.(value);", + "const a=new Array(3);Array.isArray(a);Array.isArray(a);", + "const o=new Object();Object.keys(o);Object.values(o);Object.entries(o);", + "const a=new Array(3);const o=new Object();Array.isArray(a);Object.keys(o);Reflect.ownKeys(a);Object.getOwnPropertyDescriptors(o);", + "const o=new Object();Object.keys(o, 0);", + "const a=new Array(3);const result=Array.isArray(a);consume(result);", + "const o=new Object();Object.keys(o);o.x=1;", + "Array.isArray(new Array(3));Array.isArray=patch;", + "Object.keys(new Object());consume(Object);", + "Object.keys(new Object());Object=Replacement;", +])("retains ordinary controls and read-site ordering: %s", (source) => { + expect(endpoints(source + capture)).toEqual(["/api"]); +}); + +it.each([ + "Array.isArray=patch;Array.isArray(new Array(3));", + "Object.keys=patch;Object.keys(new Object());", + "globalThis.Array.isArray=patch;Array.isArray(new Array(3));", + "consume(Array);Array.isArray(new Array(3));", + "consume(Object);Object.keys(new Object());", + "consume(globalThis);Array.isArray(new Array(3));", + "eval(code);Array.isArray(new Array(3));", + "const a=new Array(3);const alias=a;alias.x=1;Array.isArray(a);", + "const a=new Array(3);consume(a);Array.isArray(a);", + "const o=new Object();Object.setPrototypeOf(o, custom);Object.keys(o);", + "Object.prototype[0]=patch;Object.keys(new Object());", + "Array.prototype[0]=patch;Object.values(new Array(3));", + "const a=new Array(3);Array.isArray(a);consume(a);Array.isArray(a);", + "const o=new Object();Object.keys(o);Object.keys(o,(Object.prototype[0]=patch));", + "const o=new Object();Object.keys(o,(consume(o),0));", + "Object.keys(new Object(),eval(code));", + "Object.keys(new Object(),...args);", + "Array.isArray(...[new Array(3)]);", + "function inspect(){Array.isArray(new Array(3));}function change(){consume(Array);}", + "function inspect(){Object.keys(new Object());}function change(){Object.keys=patch;}", + "function inspect(){Object.keys(new Object());}function change(){consume(new Object());}", +])("keeps prior, unordered and argument-evaluation effects: %s", (source) => { + expect(endpoints(source + capture)).not.toContain("/api"); +}); + +it.each([ + "function inspect(Array){Array.isArray(new Array(3));}", + "Array=Replacement;Array.isArray(new Array(3));", + "Object=Replacement;Object.keys(new Object());", + "Object.keys(new Object(proxy));", + "Object.keys(new Object(null));", + "Object.keys(new Proxy(new Object(), handler));", + "Object.keys(new Array({}));", + "const o={get x(){return value}};Object.values(o);", + "const o={get x(){return value}};Object.keys(o);", + "Object.keys(unknown);", + "Object.keys({});", + "const o=Object();Object.keys(o);", + "let o;if(flag)o=new Object();Object.keys(o);", + "Object.keys(o);const o=new Object();", + "const {o}={o:new Object()};Object.keys(o);", + "const a=b;const b=a;Object.keys(a);", + "Array.isArray.call(null,new Array(3));", + "const check=Array.isArray;check(new Array(3));", + "const check=Array.isArray.bind(null);check(new Array(3));", + "Reflect.apply(Array.isArray,null,[new Array(3)]);", + "Object.getPrototypeOf(new Object());", + "Object.is(new Object());", + "Number.isFinite(new Array(3));", + "const result=Object.keys(new Object());", + "const result=Object.getOwnPropertyDescriptors(new Object());", +])("does not add shape, conversion or invocation authority: %s", (source) => { + expect(proof(source)).toBe(false); +}); + +it("defers both producers and retains source sites through repeated collection", () => { + const source = "const a=new Array(3);Array.isArray(a);Array.isArray(a);"; + const { index, calls, query } = fixture(source, false); + for (const call of calls) { + expect(query.receiver(call)).toBeUndefined(); + expect(query.argument(call, call.arguments[0]!)).toBeUndefined(); + } + expect(index.bindings.ambientEscapeEffects).toEqual([]); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + for (const call of calls) + expect(query.argument(call, call.arguments[0]!, index)).toBe(true); + const effects = [...index.bindings.ambientEscapeEffects]; + completeSemanticSteps( + collectSemanticMemberMutationsSteps( + index.bindings.program, + index.bindings, + index.referenceMayHaveChangedAt, + ), + ); + expect(index.bindings.ambientEscapeEffects).toEqual(effects); + expect(index.bindings.arrayIterationEffects).toEqual([]); +}); + +it("rechecks earlier ambient candidates after a later failed native receiver", () => { + const source = + "function read(){Object.values(new Object());}function inspect(){Object.keys(new Object());}Object.keys=patch;"; + const { index, calls, query } = fixture(source, false); + for (const call of calls) { + expect(query.receiver(call, true)).toBe(false); + expect( + index.bindings.ambientEscapeEffects.some(({ node }) => node === call), + ).toBe(true); + } +}); + +it("rechecks earlier member candidates after a later failed argument", () => { + const source = + "function first(){Array.isArray(new Array(3));}function second(){Object.keys(new Object(proxy));}"; + const { index, calls, query } = fixture(source); + const first = calls[0]!; + expect(query.argument(first, first.arguments[0]!, index)).toBe(false); + expect(index.bindings.arrayIterationEffects).toContain(first); +}); + +it.each(METHODS.filter((method) => method !== "Array.isArray"))( + "retains returned-container escapes from %s", + (method) => { + for (const source of [ + `consume(${method}(new Object()));`, + `const result=${method}(new Object());consume(result);`, + `void (holder.result=${method}(new Object()));`, + `${method}(new Object()).length;`, + `${method}(new Object())&&0;`, + `const result=(0,${method}(new Object()));`, + ]) + expect(endpoints(source + capture)).not.toContain("/api"); + }, +); + +it.each(METHODS.filter((method) => method !== "Array.isArray"))( + "retains return and await argument escapes from %s at their call sites", + (method) => { + for (const source of [ + `function read(){const value=new Object();return ${method}(value);}`, + `async function read(){const value=new Object();return ${method}(value);}`, + `async function read(){const value=new Object();await ${method}(value);}`, + `async function read(){const value=new Object();return await ${method}(value);}`, + `async function read(){const value=new Object();void(await ${method}(value));}`, + ]) { + const { index, calls, query } = fixture(source); + const call = calls[0]!; + const argument = call.arguments[0]!; + // The native callable still keeps its receiver, but retaining/awaiting + // its returned container cannot grant an argument escape exemption. + expect(query.receiver(call, true)).toBe(true); + expect(query.argument(call, argument, index)).toBe(false); + expect(index.bindingForReferenceAt(argument)?.escapedPaths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ node: call, path: [] }), + ]), + ); + expect(index.bindings.arrayIterationEffects).toContain(call); + } + }, +); + +it.each([ + "function read(){const value=new Array(3);return Array.isArray(value);}", + "async function read(){const value=new Array(3);return Array.isArray(value);}", + "async function read(){const value=new Array(3);await Array.isArray(value);}", + "async function read(){const value=new Array(3);return await Array.isArray(value);}", +])( + "permits the primitive inspection result in retained contexts: %s", + (source) => { + const { index, calls, query } = fixture(source); + const call = calls[0]!; + const argument = call.arguments[0]!; + expect(query.argument(call, argument, index)).toBe(true); + expect(index.bindingForReferenceAt(argument)?.escapedPaths).toEqual([]); + expect(index.bindings.arrayIterationEffects).toEqual([]); + }, +); + +it.each(METHODS.filter((method) => method !== "Array.isArray"))( + "uses the shared discarded-result owner for %s", + (method) => { + for (const source of [ + `void ${method}(new Object());`, + `(${method}(new Object()),0);`, + `const value=(${method}(new Object()),0);`, + `flag?${method}(new Object()):0;`, + `flag?0:${method}(new Object());`, + `flag&&${method}(new Object());`, + `flag||${method}(new Object());`, + ]) { + expect(proof(source)).toBe(true); + expect(endpoints(source + capture)).toEqual(["/api"]); + } + }, +); + +it("does not mistake primitive result slots for an unreachable prototype", () => { + const source = "consume(Object.keys(new Object()));" + capture; + const fetched: string[] = []; + runInNewContext( + "function consume(value){value.__proto__[Symbol.iterator]=function*(){yield '/changed'}};" + + source, + { fetch: (value: string) => fetched.push(value) }, + { timeout: 1000 }, + ); + expect(fetched).toEqual(["/changed"]); + expect(endpoints(source)).not.toContain("/api"); +}); + +it("keeps native getter, Proxy and conversion boundaries distinct", () => { + const events: Record = runInNewContext( + `(() => { + let hits=[]; + const value={get x(){hits.push('getter');return 1},[Symbol.toPrimitive](){hits.push('conversion');return 1}}; + const proxy=new Proxy(value,{ + ownKeys(target){hits.push('ownKeys');return Reflect.ownKeys(target)}, + getOwnPropertyDescriptor(target,key){hits.push('descriptor');return Reflect.getOwnPropertyDescriptor(target,key)} + }); + const result={}; + for(const [name,operation,target] of [['array',Array.isArray,proxy],['keys',Object.keys,value],['proxy',Object.keys,proxy],['values',Object.values,value],['number',Number,value]]){ + hits=[];operation(target);result[name]=hits.slice(); + } + return result; + })()`, + {}, + { timeout: 1000 }, + ); + expect(events).toEqual({ + array: [], + keys: [], + proxy: ["ownKeys", "descriptor"], + values: ["getter"], + number: ["conversion"], + }); + for (const operation of [ + "const value={get x(){Array.prototype[Symbol.iterator]=patch}};Object.values(value);", + "const value=new Proxy({}, {ownKeys(){Array.prototype[Symbol.iterator]=patch;return []}});Object.keys(value);", + "Number({valueOf(){Array.prototype[Symbol.iterator]=patch;return 1}});", + ]) + expect(endpoints(operation + capture)).not.toContain("/api"); +}); + +it("keeps an exhausted primitive proof unknown", () => { + const source = + "const size=flag?1:2;const a=new Array(size);Array.isArray(a);"; + expect(endpoints(source + capture)).toEqual(["/api"]); + const { index, calls, query } = fixture(source, true, { + primitive_candidates: 1, + }); + const call = calls.at(-1)!; + expect(query.argument(call, call.arguments[0]!, index)).toBe(false); + expect( + endpoints(source + capture, { primitive_candidates: 1 }), + ).not.toContain("/api"); +}); + +it.each(["Array.isArray", "Reflect.ownKeys"])( + "preserves native Map lookup after %s of a proven empty Map", + (method) => { + expect(runInNewContext(`${method}(new Map())`)).toEqual( + method === "Array.isArray" ? false : [], + ); + for (const bound of [false, true]) { + for (const used of method === "Array.isArray" ? [false, true] : [false]) { + const source = `${bound ? "const map = new Map();" : ""} + ${used ? "const result = " : ""}${method}(${bound ? "map" : "new Map()"});`; + const tail = "const records = new Map(); records.get('/cache');"; + expect(proof(source), source).toBe(true); + expect(() => runInNewContext(source + tail)).not.toThrow(); + expect(endpoints(source + tail), source).toEqual([]); + } + } + }, +); + +it.each(["Array.isArray", "Reflect.ownKeys"])( + "keeps empty Map inspection authority bounded for %s", + (method) => { + const namespace = method.split(".")[0]; + for (const source of [ + `function run(Map) { ${method}(new Map()); }`, + `Map = Replacement; ${method}(new Map());`, + `globalThis.Map = Replacement; ${method}(new globalThis.Map());`, + `${namespace} = replacement; ${method}(new Map());`, + `${method} = replacement; ${method}(new Map());`, + `const map = new Map(); map.x = 1; ${method}(map);`, + `const map = new Map(); const alias = map; alias.x = 1; ${method}(map);`, + `const map = new Map(); consume(map); ${method}(map);`, + `const map = new Map(); Object.setPrototypeOf(map, custom); ${method}(map);`, + `Map.prototype.x = 1; ${method}(new Map());`, + `Object.prototype.x = 1; ${method}(new Map());`, + `delete Map.prototype.get; ${method}(new Map());`, + `Object.defineProperty(Map.prototype, Symbol.toStringTag, {get() { patch(); }}); ${method}(new Map());`, + `function later() { Map.prototype.x = 1; } ${method}(new Map());`, + `eval(code); ${method}(new Map());`, + `${method}(new Map([]));`, + `${method}(new Map(undefined));`, + `${method}(new Map(...args));`, + `${method}(...[new Map()]);`, + `${method}(new Set());`, + `${method}(new Date());`, + `const inspect = ${method}; inspect(new Map());`, + `${method}.call(null, new Map());`, + `${method}.apply(null, [new Map()]);`, + `const inspect = ${method}.bind(null); inspect(new Map());`, + ]) + expect(proof(source), source).toBe(false); + for (const effect of [ + "Map.prototype.x = 1", + "Object.prototype.x = 1", + "eval(code)", + "consume(map)", + "map.x = 1", + ]) { + const source = `const map = new Map(); ${method}(map, (${effect}, 0));`; + expect(proof(source), source).toBe(false); + } + const source = `${method}(new Map());`; + const { calls, query } = fixture(source, false); + expect(query.argument(calls[0]!, calls[0]!.arguments[0]!)).toBeUndefined(); + }, +); + +it.each([ + "const result = Reflect.ownKeys(new Map());", + "consume(Reflect.ownKeys(new Map()));", + "Reflect.ownKeys(new Map()) && 0;", + "const result = flag ? Reflect.ownKeys(new Map()) : 0;", +])("retains container-result uncertainty for empty Map: %s", (source) => { + const { index, calls, query } = fixture(source); + const call = calls.find( + (call) => + t.isMemberExpression(call.callee) && + t.isIdentifier(call.callee.object, { name: "Reflect" }), + )!; + expect(query.argument(call, call.arguments[0]!, index)).toBe(false); +}); + +it.each(METHODS.filter((method) => method.startsWith("Object.")))( + "preserves the existing empty Map argument boundary for %s", + (method) => { + for (const used of [false, true]) { + const source = `${used ? "const result = " : ""}${method}(new Map());`; + expect(proof(source)).toBe(false); + expect( + endpoints(source + "const records = new Map(); records.get('/cache');"), + ).toEqual(["/cache"]); + } + }, +); + +it.each(["Array.isArray", "Reflect.ownKeys"])( + "retains the selected %s callee before later arguments", + (method) => { + const source = `const map = new Map(); ${method}(map, (${method} = replacement, 0));`; + expect(proof(source)).toBe(true); + expect(() => + runInNewContext(source, { + replacement() { + throw new Error("Wrong callee"); + }, + }), + ).not.toThrow(); + }, +); diff --git a/src/domain/javascript/javascriptSemanticArgumentInspections.ts b/src/domain/javascript/javascriptSemanticArgumentInspections.ts new file mode 100644 index 00000000000..ebb8922d810 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArgumentInspections.ts @@ -0,0 +1,152 @@ +import * as t from "@babel/types"; + +import { javaScriptExpressionResultIsUsed } from "./javascriptAstValues.js"; +import { + createGlobalReferenceReader, + semanticAmbientObjectMayHaveChangedAt, + semanticAmbientPathMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; +import { globalReferenceNamespace } from "./javascriptSemanticGlobalReference.js"; +import { + semanticConstructedReceiver, + semanticPrimitiveArguments, + semanticPristineEmptyMapConstruction, +} from "./javascriptSemanticIntrinsicReceivers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; + +type InspectionCall = t.CallExpression | t.OptionalCallExpression; +type GlobalPath = readonly [string, ...string[]]; + +interface Inspection { + readonly path: GlobalPath; + readonly primitiveResult?: true; + readonly emptyMapArgument?: true; +} + +const INSPECTIONS: readonly Inspection[] = [ + { path: ["Array", "isArray"], primitiveResult: true, emptyMapArgument: true }, + { path: ["Object", "keys"] }, + { path: ["Object", "values"] }, + { path: ["Object", "entries"] }, + { path: ["Object", "getOwnPropertyNames"] }, + { path: ["Object", "getOwnPropertySymbols"] }, + { path: ["Object", "getOwnPropertyDescriptors"] }, + { path: ["Reflect", "ownKeys"], emptyMapArgument: true }, +]; + +/** One selected-callee authority for native inspection receivers and arguments. */ +export const createSemanticArgumentInspectionQuery = ( + state: JavaScriptSemanticAnalysisState, +) => { + const readers = new Map( + [ + ...INSPECTIONS.map(({ path }) => path), + ["Array"] as const, + ["Object"] as const, + ["Map"] as const, + ].map((path) => [path.join("."), createGlobalReferenceReader(state, path)]), + ); + const candidates = new WeakMap(); + const candidate = (node: InspectionCall): Inspection | undefined => { + const cached = candidates.get(node); + if (cached !== undefined) return cached ?? undefined; + const inspection = INSPECTIONS.find( + ({ path }) => + globalReferenceNamespace(node.callee, state, path) !== undefined, + ); + candidates.set(node, inspection ?? null); + return inspection; + }; + const unchanged = ( + callee: t.Node, + invocation: t.Node, + path: GlobalPath, + ): boolean => + readers.get(path.join("."))?.(callee).status === "unmodified" && + !semanticAmbientPathMayHaveChangedAt( + callee, + state, + ["global", ...path], + invocation, + ) && + !semanticOpaqueGlobalMayHaveChanged(callee, state); + + /** Undefined holds a candidate until all ordinary effects have been gathered. */ + const receiver = ( + node: InspectionCall, + effectsCollected = false, + ): boolean | undefined => { + const inspection = candidate(node); + if (inspection === undefined) return false; + return effectsCollected + ? unchanged(node.callee, node, inspection.path) + : undefined; + }; + + return { + receiver, + argument: ( + node: InspectionCall, + argument: t.Node, + index?: Pick, + ): boolean | undefined => { + // Spread evaluation and additional arguments retain their ordinary effects. + if ( + node.arguments[0] !== argument || + node.arguments.some((item) => t.isSpreadElement(item)) + ) + return false; + const native = receiver(node, index !== undefined); + if (native !== true) return native; + // A returned container can expose shared prototypes even when all its + // own slots are primitive. Until its identity is retained, only the + // boolean Array.isArray result may be consumed without escaping a source. + if ( + candidate(node)?.primitiveResult !== true && + javaScriptExpressionResultIsUsed(node, state.parentsByNode) + ) + return false; + const source = semanticConstructedReceiver(argument, node, state, { + paths: [[]], + index, + }); + if (source === undefined) return false; + const construction = source.construction; + if ( + candidate(node)?.emptyMapArgument === true && + unchanged(construction.callee, construction, ["Map"]) + ) + return semanticPristineEmptyMapConstruction(construction, node, state); + const owner = unchanged(construction.callee, construction, ["Object"]) + ? "Object" + : unchanged(construction.callee, construction, ["Array"]) + ? "Array" + : undefined; + if ( + owner === undefined || + (owner === "Object" + ? construction.arguments.length !== 0 + : !semanticPrimitiveArguments(construction.arguments, state)) + ) + return false; + // The existing pristine-construction proof excludes Proxy sources, own + // mutations, retained object slots, and inherited hooks. In particular, + // values/entries may invoke getters outside this deliberately narrow proof. + return ( + !(owner === "Array" ? ["Array", "Object"] : ["Object"]).some( + (prototype) => + semanticAmbientObjectMayHaveChangedAt( + node, + state, + ["global", prototype, "prototype"], + node, + ), + ) && !semanticOpaqueGlobalMayHaveChanged(node, state) + ); + }, + }; +}; diff --git a/src/domain/javascript/javascriptSemanticArrayIterationTiming.test.ts b/src/domain/javascript/javascriptSemanticArrayIterationTiming.test.ts new file mode 100644 index 00000000000..aa1cb23c3d5 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArrayIterationTiming.test.ts @@ -0,0 +1,414 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; +import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticKnownArrayIteration, + semanticKnownArrayIterationAt, + semanticReferenceIterationUnknown, +} from "./javascriptSemanticReferenceSources.js"; +import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js"; + +const indexFor = (source: string, collect = true) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected iteration timing fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + if (collect) completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const calls: t.CallExpression[] = []; + const spreads: t.SpreadElement[] = []; + const captures: t.Node[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node)) calls.push(node); + if (t.isSpreadElement(node)) spreads.push(node); + if (t.isForOfStatement(node)) captures.push(node.right); + if ( + t.isVariableDeclarator(node) && + t.isArrayPattern(node.id) && + node.init != null + ) + captures.push(node.init); + }, + }); + return { index, calls, spreads, captures }; +}; + +const endpointValues = (source: string) => + analyzeJavaScriptStaticSource(source).endpoints.map(({ value }) => value); +const allocation = "const urls=['/api'];"; +const capture = "const [url]=urls;"; +const fetchCaptured = "fetch(url);"; +const emptyArray: JavaScriptSemanticValue = { + status: "array", + items: [], + unknownItems: false, + omittedItems: 0, +}; + +it.each([ + "consume(new Array(3));", + "consume(Array.prototype);", + "consume(Object.prototype);", + "consume([].__proto__);", + "consume([].constructor.prototype);", + "Array.prototype[Symbol.iterator]=patch;", + "Object.prototype[0]=patch;", + "Array.prototype.constructor.prototype[Symbol.iterator]=patch;", +])("orders prototype effects at the actual capture: %s", (effect) => { + expect(endpointValues(allocation + capture + effect + fetchCaptured)).toEqual( + ["/api"], + ); + expect( + endpointValues(allocation + effect + capture + fetchCaptured), + ).not.toContain("/api"); + expect( + endpointValues(effect + allocation + capture + fetchCaptured), + ).not.toContain("/api"); +}); + +it.each([ + "function mutate(){Array.prototype[Symbol.iterator]=patch;}", + "function mutate(){consume(new Array(3));}", + "if(flag){Array.prototype[Symbol.iterator]=patch;}", + "with(scope){Array.prototype[Symbol.iterator]=patch;}", + "const Array=local;with(scope){consume(Array.prototype);}", +])("retains preceding or unordered effects: %s", (effect) => { + expect( + endpointValues(allocation + effect + capture + fetchCaptured), + ).not.toContain("/api"); +}); + +it.each([ + "Array=other;", + "globalThis.Array=other;", + "consume(Array);", + "Array.prototype.fill=patch;", + "Array.prototype.constructor.prototype.fill=patch;", + "consume(Array.prototype.fill);", + "const {...copy}=Array;consume(copy);", + "consume({...Array});", + "const Array={prototype:{}};consume(Array.prototype);", + "function mutate(Array){Array.prototype[Symbol.iterator]=patch;}", + "[].__proto__=patch;", + "const globalThis={Array:{prototype:{}}};consume(globalThis.Array.prototype);", +])("does not widen the original prototype-effect domain: %s", (effect) => { + expect(endpointValues(effect + allocation + capture + fetchCaptured)).toEqual( + ["/api"], + ); +}); + +it.each([ + "consume(Array[key]);", + "Array.prototype[key]=patch;", + "Array.prototype[0]=patch;", + "Array.prototype.__proto__=patch;", + "Array.prototype.constructor.prototype.constructor.prototype[0]=patch;", + "consume({...Array.prototype});", + "const {...copy}=Array.prototype;consume(copy);", + "consume({...([].__proto__)});", + "consume({...([].constructor.prototype)});", +])( + "retains unknown, numeric, inherited, and copied prototype effects: %s", + (effect) => { + expect( + endpointValues(effect + allocation + capture + fetchCaptured), + ).not.toContain("/api"); + }, +); + +it("keeps own iterator writes ordered around destructuring capture", () => { + const effect = "urls[Symbol.iterator]=patch;"; + expect(endpointValues(allocation + capture + effect + fetchCaptured)).toEqual( + ["/api"], + ); + expect( + endpointValues(allocation + effect + capture + fetchCaptured), + ).not.toContain("/api"); +}); + +it.each([ + ["consume(...urls,(Array.prototype[Symbol.iterator]=patch,0));", true], + ["consume((Array.prototype[Symbol.iterator]=patch,0),...urls);", false], + ["consume(...urls);Array.prototype[Symbol.iterator]=patch;", true], + ["Array.prototype[Symbol.iterator]=patch;consume(...urls);", false], +] as const)("uses the original spread operand: %s", (body, known) => { + const { index, spreads } = indexFor(allocation + body); + expect(spreads).toHaveLength(1); + expect( + semanticKnownArrayIterationAt(spreads[0]!.argument, index.bindings), + ).toBe(known); +}); + +it("orders one prototype effect independently between two argument reads", () => { + const { index, spreads } = indexFor( + "const left=['/left'];const right=['/right'];consume(...left,(Array.prototype[Symbol.iterator]=patch,0),...right);", + ); + expect( + spreads.map(({ argument }) => + semanticKnownArrayIterationAt(argument, index.bindings), + ), + ).toEqual([true, false]); +}); + +it.each(["const [url]=urls;", "const [...rest]=urls;"])( + "checks actual iteration after allocation for %s", + (read) => { + for (const before of [false, true]) { + const effect = "consume(new Array(3));"; + const { index, captures } = indexFor( + allocation + (before ? effect + read : read + effect), + ); + expect(captures).toHaveLength(1); + expect( + semanticReferenceIterationUnknown( + { iterationSources: [{ node: captures[0]!, projection: [] }] }, + index.bindings, + ), + ).toBe(before); + } + }, +); + +it("does not treat unknown array items as an iterator mutation, including empty arrays", () => { + const { index } = indexFor("const value=[];"); + const read = index.bindings.program.body[0]!; + const unknownItems: JavaScriptSemanticValue = { + ...emptyArray, + status: "array", + items: [], + unknownItems: true, + omittedItems: null, + }; + expect(semanticKnownArrayIteration(emptyArray, index.bindings, read)).toBe( + true, + ); + expect(semanticKnownArrayIteration(unknownItems, index.bindings, read)).toBe( + true, + ); + index.bindings.arrayIterationEffects.push(undefined); + expect(semanticKnownArrayIteration(emptyArray, index.bindings, read)).toBe( + false, + ); + expect(semanticKnownArrayIteration(unknownItems, index.bindings, read)).toBe( + false, + ); +}); + +it.each([ + "(flag?[]:[]).__proto__", + "(flag&&[]).__proto__", + "(0,[]).constructor.prototype", + "{nested:(flag?[]:[]).__proto__}", +])( + "replays cached literal prototype leaves with each original call site: %s", + (result) => { + const { index, calls } = indexFor( + `function source(){return ${result};} consume(source());consume(source());`, + ); + const mutations = calls.filter((call) => + t.isIdentifier(call.callee, { name: "source" }), + ); + expect(mutations).toHaveLength(2); + expect(index.bindings.arrayIterationEffects).toEqual(mutations); + const original = [...index.bindings.arrayIterationEffects]; + completeSemanticSteps( + collectSemanticMemberMutationsSteps( + index.bindings.program, + index.bindings, + index.referenceMayHaveChangedAt, + ), + ); + expect(index.bindings.arrayIterationEffects).toEqual(original); + }, +); + +it("invalidates returned-reference results when iteration effects are discovered", () => { + const { index, calls } = indexFor( + "consume(new Array(3));function first(){return a;}function second(){return b;}const values=[first,second];const selected=[...values][0];selected();", + false, + ); + const invocation = calls.find((call) => + t.isIdentifier(call.callee, { name: "selected" }), + ); + if (invocation === undefined) throw new Error("Expected selected invocation"); + const returnedReferences = createSemanticReturnedReferences(index.bindings); + const names = () => + [...returnedReferences(invocation.callee, invocation).callables].map( + (node) => (t.isFunctionDeclaration(node) ? node.id?.name : undefined), + ); + expect(names()).toEqual(["first"]); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + expect(index.bindings.arrayIterationEffects.length).toBeGreaterThan(0); + expect(names()).toEqual(["first", "second"]); +}); + +it("checks a copied callable at its spread capture rather than its array initializer", () => { + const source = + "function first(){return a;}function second(){return b;}const values=[first,second];"; + for (const before of [false, true]) { + const effect = "consume(new Array(3));"; + const copy = "const selected=[...values][0];"; + const { index, calls } = indexFor( + source + (before ? effect + copy : copy + effect) + "selected();", + ); + const invocation = calls.find((call) => + t.isIdentifier(call.callee, { name: "selected" }), + ); + if (invocation === undefined) + throw new Error("Expected selected invocation"); + const result = createSemanticReturnedReferences(index.bindings)( + invocation.callee, + invocation, + ); + expect( + [...result.callables].map((node) => + t.isFunctionDeclaration(node) ? node.id?.name : undefined, + ), + ).toEqual(before ? ["first", "second"] : ["first"]); + } +}); + +it.each([ + ["for(const url of urls){}", false], + ["for(const url of urls){}consume(new Array(3));", false], + ["consume(new Array(3));for(const url of urls){}", true], +] as const)( + "orders effects around top-level for-of entry: %s", + (body, unknown) => { + const { index, captures } = indexFor(allocation + body); + expect( + semanticReferenceIterationUnknown( + { iterationSources: [{ node: captures[0]!, projection: [] }] }, + index.bindings, + ), + ).toBe(unknown); + }, +); + +it("orders shared iteration facts independently for two reads of the same operand", () => { + const { index, spreads } = indexFor( + allocation + + "consume(...urls,(Array.prototype[Symbol.iterator]=patch,0),...urls);", + ); + // Isolate the iteration owner: the general value owner conservatively loses + // this binding's shape when the second spread also exposes its root. + expect( + spreads.map(({ argument }) => + semanticKnownArrayIteration(emptyArray, index.bindings, argument), + ), + ).toEqual([true, false]); +}); + +it("invalidates a warm returned-reference cache on the iteration revision alone", () => { + const { index, calls } = indexFor( + "function first(){return a;}function second(){return b;}const values=[first,second];const selected=[...values][0];selected();", + ); + const invocation = calls.find((call) => + t.isIdentifier(call.callee, { name: "selected" }), + ); + if (invocation === undefined) throw new Error("Expected selected invocation"); + const returnedReferences = createSemanticReturnedReferences(index.bindings); + const names = () => + [...returnedReferences(invocation.callee, invocation).callables].map( + (node) => (t.isFunctionDeclaration(node) ? node.id?.name : undefined), + ); + const revisions = () => [ + index.bindings.ambientWriteEffects.length, + index.bindings.ambientEscapeEffects.length, + ...[...index.bindings.bindingsById.values()].flatMap((binding) => [ + binding.memberWrites.length, + binding.escapedPaths.length, + ]), + ]; + expect(names()).toEqual(["first"]); + const unchanged = revisions(); + index.bindings.arrayIterationEffects.push(undefined); + expect(revisions()).toEqual(unchanged); + expect(names()).toEqual(["first", "second"]); +}); + +it.each([ + ["['/api']", "source[0]"], + ["[{url:'/api'}]", "source[0].url"], +])( + "does not escape an earlier for-of source after a later prototype effect: %s", + (value, selected) => { + const create = `const source=${value};`; + const loop = "for(const item of source){}"; + const effect = "consume(new Array(3));"; + expect( + endpointValues(create + loop + `fetch(${selected});` + effect), + ).toEqual(["/api"]); + expect( + endpointValues(create + effect + loop + `fetch(${selected});`), + ).not.toContain("/api"); + }, +); + +it.each([ + "for(const item of (consume(new Array(3)),source)){}", + "for(const item of (Array.prototype[Symbol.iterator]=patch,source)){}", +])("retains effects inside the for-of iterable expression: %s", (loop) => { + const { index, captures } = indexFor("const source=['/api'];" + loop); + expect(index.bindings.arrayIterationEffects.length).toBeGreaterThan(0); + expect( + index.bindings.arrayIterationEffects.every( + (effect) => + semanticEffectTiming(captures[0]!, effect, index.bindings) !== "after", + ), + ).toBe(true); + expect( + semanticKnownArrayIteration(emptyArray, index.bindings, captures[0]!), + ).toBe(false); +}); + +it.each([ + "for(const read of source){}", + "for(const item of source){read;}", + "while(flag){for(const item of read){}}", + "for(;;){for(const item of read){}}", + "function run(){for(const item of read){}}", + "for(const key in read){}", + "class Example{value=read;}", +])( + "does not extend the for-of entry proof to repeated or delayed reads: %s", + (body) => { + const { index, calls } = indexFor( + "const source=['/api'];" + body + "consume(new Array(3));", + ); + let read: t.Identifier | undefined; + traverseJavaScriptAst(index.bindings.program, { + enter(node) { + if (t.isIdentifier(node, { name: "read" })) read = node; + }, + }); + const effect = calls.find((node) => + t.isIdentifier(node.callee, { name: "consume" }), + ); + if (read === undefined || effect === undefined) + throw new Error("Expected read and effect"); + expect(semanticEffectTiming(read, effect, index.bindings)).toBe("unknown"); + }, +); + +it("keeps missing source ownership and missing effects unordered", () => { + const { index, calls } = indexFor("consume(new Array(3));"); + expect( + semanticEffectTiming(t.identifier("unowned"), calls[0], index.bindings), + ).toBe("unknown"); + expect( + semanticEffectTiming(index.bindings.program, undefined, index.bindings), + ).toBe("unknown"); +}); diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.test.ts b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts new file mode 100644 index 00000000000..87fbca19999 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArrayOperations.test.ts @@ -0,0 +1,361 @@ +import { runInNewContext } from "node:vm"; + +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { isIntrinsicNonEscapingArrayCall } from "./javascriptSemanticArrayOperations.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; + +const proofFor = (source: string, method = "fill") => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected array-call fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const results: (boolean | undefined)[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.property, { name: method }) + ) + results.push( + isIntrinsicNonEscapingArrayCall(node, index.bindings, index), + ); + }, + }); + return results; +}; + +it.each([ + "fill(0)", + "copyWithin(0, 1)", + "reverse()", + "push(0)", + "pop()", + "shift()", + "unshift(0)", + "includes(0)", + "indexOf(0)", + "lastIndexOf(0)", + "at(0)", + "join(',')", + "toString()", + "keys()", + "values()", + "entries()", +])("preserves unrelated array iteration after discarded native %s", (call) => { + const source = `const a = new Array(3); a.${call}; const urls = ['/api']; const [url] = urls; fetch(url);`; + expect(proofFor(source, call.slice(0, call.indexOf("(")))).toEqual([true]); + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { value: "/api" }, + ]); +}); + +it.each([ + "void new Array(3).fill(0);", + "const unused = (new Array(3).fill(0), 1);", + "(0, new Array(3).fill(0));", + "void (0, new Array(3).fill(0));", + "const unused = ((0, new Array(3).fill(0)), 1);", + "(new Array(3).fill(0) as unknown)!;", + "for (new Array(3).fill(0); false;) {}", + "for (; false; (0, new Array(3).fill(0))) {}", + "flag ? new Array(3).fill(0) : 0;", + "flag ? 0 : new Array(3).fill(0);", + "flag && new Array(3).fill(0);", + "flag || new Array(3).fill(0);", + "flag ?? new Array(3).fill(0);", + "void ((flag ? (0, new Array(3).fill(0)) : 0) as unknown)!;", + "const unused = ((flag && new Array(3).fill(0)), 0);", +])( + "preserves unrelated destructuring after a discarded Array result: %s", + (source) => { + expect(proofFor(source)).toEqual([true]); + expect( + analyzeJavaScriptStaticSource( + `${source} const urls = ['/api']; const [url] = urls; fetch(url);`, + ).endpoints.map(({ value }) => value), + ).toEqual(["/api"]); + }, +); + +it.each([ + "new Array(3).fill(0);", + "const a = new globalThis.Array(3); const b = a; b.fill(0);", + "const a = new Array('/old', '/other'); a.fill('/new');", + "const a = new Array(3); a.fill(0); a.fill = patch;", + "const a = new Array(3); a.fill(0); Array.prototype[0] = replacement;", + "Map.prototype[0] = replacement; const a = new Array(3); a.fill(0);", +])("proves only prior effects relevant to a pristine array: %s", (source) => { + expect(proofFor(source)).toEqual([true]); +}); + +it.each([ + "Array.prototype.fill = patch; const a = new Array(3); a.fill(0);", + "Array.prototype[0] = replacement; const a = new Array(3); a.fill(0);", + "Object.prototype[0] = replacement; const a = new Array(3); a.fill(0);", + "Array.prototype[key] = replacement; const a = new Array(3); a.fill(0);", + "Object.defineProperty(Array.prototype, '0', {set(value) { patch(this); }}); const a = new Array(3); a.fill(0);", + "Object.defineProperty(Object.prototype, '0', {set(value) { patch(this); }}); const a = new Array(3); a.fill(0);", + "consume(Array.prototype); const a = new Array(3); a.fill(0);", + "consume(Object.prototype); const a = new Array(3); a.fill(0);", + "Object.setPrototypeOf(Array.prototype, custom); const a = new Array(3); a.fill(0);", + "const a = new Array(3); Object.setPrototypeOf(a, custom); a.fill(0);", + "const a = new Array(3); Object.defineProperty(a, '0', {set(value) { patch(this); }}); a.fill(0);", + "const a = new Array(3); const b = a; b[0] = value; a.fill(0);", + "const a = new Array(3); consume(a); a.fill(0);", + "const a = new Array(3); a.fill = patch; a.fill(0);", + "const a = new Array(3); a.fill({}, 0);", + "const a = new Array(3); a.fill(0, {valueOf() { patch(a); return 0; }});", + "const a = new Array(3); a.fill(...args);", + "const a = new Array(3); a.fill(value);", + "const a = new Array(value); a.fill(0);", + "const a = new Array(...args); a.fill(0);", + "const a = new Array(3); consume(a.fill(0));", + "const a = []; a.fill(0);", + "const a = new Array(3); a.fill(0, (Array.prototype[0] = replacement, 0));", + "const a = new Array(3); a.fill(0, (Object.prototype[0] = replacement, 0));", + "const a = new Array(3); a.fill(0, (eval(code), 0));", + "const a = new Array(3); a.fill(0, (Object.setPrototypeOf(a, custom) ? 0 : 0));", + "const a = new Array(3); a.fill(0, ((Array.prototype[0] = replacement) ? 0 : 0));", + "const a = new Array(3); a.fill(0, ((Object.prototype[0] = replacement) ? 0 : 0));", + "const a = new Array(3); a.fill(0, (eval(code) ? 0 : 0));", + "function patch() { Array.prototype[0] = replacement; } const a = new Array(3); a.fill(0);", + "function run(Array) { const a = new Array(3); a.fill(0); }", + "function run(a) { a.fill(0); }", + "void new Array(3).fill(0, (Array.prototype[0] = replacement, 0));", + "void new Array(3).fill(0, { valueOf() { return 0; } });", + "void new Array(3).fill(...args);", + "function run(Array) { void new Array(3).fill(0); }", + "void consume(new Array(3).fill(0));", + "void (holder.value = new Array(3).fill(0));", + "const value = (0, new Array(3).fill(0));", + "new Array(3).fill(0) ? 0 : 1;", + "new Array(3).fill(0) && 0;", + "const value = flag ? new Array(3).fill(0) : 0;", + "void consume(flag && new Array(3).fill(0));", +])("retains receiver uncertainty outside the pristine proof: %s", (source) => { + expect(proofFor(source)).toEqual([false]); +}); + +it.each(["sort()", "splice(0)", "slice(0)", "concat(0)", "forEach(callback)"])( + "does not admit additional callback or species contracts: %s", + (call) => { + expect( + proofFor( + `const a = new Array(3); a.${call};`, + call.slice(0, call.indexOf("(")), + ), + ).toEqual([false]); + }, +); + +it("does not restore stale literal-array slots after a native mutation", () => { + expect( + analyzeJavaScriptStaticSource( + "const a = ['/old']; a.fill('/new'); fetch(a[0]);", + ).endpoints.map(({ value }) => value), + ).not.toContain("/old"); +}); + +it("reconsiders earlier pristine proofs after a deferred receiver escapes", () => { + expect( + proofFor(`function run() { + const a = new Array(3); a.fill(0); + const b = new Array(3); b[0] = 1; b.fill(0); + }`), + ).toEqual([false, false]); +}); + +it.each([ + "includes(0)", + "indexOf(0)", + "lastIndexOf(0)", + "at(0)", + "join(',')", + "toString()", + "push(0)", + "pop()", + "shift()", + "unshift(0)", +])("permits only proven primitive results to be consumed: %s", (call) => { + const method = call.slice(0, call.indexOf("(")); + for (const use of [`const result = a.${call};`, `consume(a.${call});`]) { + const source = `const a = new Array(3); ${use} const urls=['/api']; const [url]=urls; fetch(url);`; + expect(proofFor(source, method)).toEqual([true]); + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { value: "/api" }, + ]); + } +}); + +it.each([ + "fill(0)", + "copyWithin(0, 1)", + "reverse()", + "keys()", + "values()", + "entries()", +])("keeps retained receiver results discarded-only: %s", (call) => { + expect( + proofFor( + `const a = new Array(3); consume(a.${call});`, + call.slice(0, call.indexOf("(")), + ), + ).toEqual([false]); +}); + +it.each([ + "new Array(3).includes(0);", + "const a = new globalThis.Array('a', 'b'); const b = a; b.includes('a');", + "const a = new Array(3); a.includes(0); Array.prototype[0] = replacement;", +])( + "retains pristine Array read certainty without new shape facts: %s", + (source) => { + expect(proofFor(source, "includes")).toEqual([true]); + }, +); + +it.each([ + [ + "includes", + "Object.defineProperty(Array.prototype, '0', {get() { patch(); return 0; }}); const a = new Array(3); a.includes(0);", + ], + [ + "includes", + "Object.defineProperty(Object.prototype, '0', {get() { patch(); return 0; }}); const a = new Array(3); a.includes(0);", + ], + ["includes", "const a = new Array(3); a[0] = object; a.includes(0);"], + [ + "includes", + "const a = new Array(3); a.includes(0, {valueOf() {patch(a); return 0;}});", + ], + ["includes", "const a = new Array(3); a.includes(0, (eval(code) ? 0 : 0));"], + [ + "includes", + "const a = new Array(3); a.includes(0, ((Array.prototype[0] = replacement) ? 0 : 0));", + ], + ["join", "const a = new Array(object, 0); a.join(',');"], + [ + "join", + "const a = new Array(3); a.join({toString() {patch(a); return ',';}});", + ], + [ + "toString", + "Array.prototype.join = replacement; const a = new Array(3); a.toString();", + ], + ["toString", "const a = new Array(3); a.join = replacement; a.toString();"], + ["includes", "const a = [0]; a.includes(0);"], + ["values", "const a = new Array(3); consume(a); a.values();"], +])("does not exempt a read with observable hooks: %s", (method, source) => { + expect(proofFor(source, method)).toEqual([false]); +}); + +it.each([ + "toLocaleString()", + "slice()", + "concat()", + "flat()", + "map(callback)", + "filter(callback)", + "find(callback)", + "some(callback)", + "every(callback)", + "reduce(callback)", + "sort()", + "toSorted()", + "toReversed()", + "toSpliced(0)", + "with(0, 1)", +])( + "does not add callback, element-prototype or copying contracts: %s", + (call) => { + expect( + proofFor( + `const a = new Array(1, 2); a.${call};`, + call.slice(0, call.indexOf("(")), + ), + ).toEqual([false]); + }, +); + +it.each([ + "new Array(3).includes(0);", + "const a = new Array(3); a.includes(0);", +])( + "defers even direct pristine reads until propagated effects converge: %s", + (source) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected read fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const results: (boolean | undefined)[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node)) + results.push(isIntrinsicNonEscapingArrayCall(node, index.bindings)); + }, + }); + expect(results).toEqual([undefined]); + }, +); + +it("does not exempt locale conversion through mutable element prototypes", () => { + expect( + proofFor( + "Number.prototype.toLocaleString = patch; const a = new Array(1, 2); a.toLocaleString();", + "toLocaleString", + ), + ).toEqual([false]); +}); + +it.each([ + "const a = new Array(object, 0); const result = a.pop();", + "const a = new Array(3); a[0] = object; const result = a.at(0);", + "const a = new Array(3); a.fill(object); const result = a.shift();", + "const a = new Array(3); consume(a); const result = a.pop();", + "Object.defineProperty(Array.prototype, '0', {get() { return object; }}); const a = new Array(3); const result = a.at(0);", + "const a = new Array(3); const result = a.includes(0, (eval(code) ? 0 : 0));", + "const a = new Array(3); const result = a.includes(0, ((Array.prototype[0] = object) ? 0 : 0));", +])( + "does not certify consumed results beyond the existing primitive/pristine facts: %s", + (source) => { + const method = /result = a\.(\w+)/u.exec(source)?.[1]; + expect(proofFor(source, method)).toEqual([false]); + }, +); + +it.each(["at(0)", "pop()", "shift()"])( + "keeps primitive results after admitted slot mutations: %s", + (call) => { + const method = call.slice(0, call.indexOf("(")); + const source = `const a = new Array(3); a.fill('/first'); a.copyWithin(1, 0); a.reverse(); a.push('/last'); a.unshift('/start'); const result = a.${call}; const urls=['/api']; const [url]=urls; fetch(url);`; + expect(proofFor(source, method).at(-1)).toBe(true); + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { value: "/api" }, + ]); + }, +); + +it("does not treat Array's locale override as an inherited Object method", () => { + const observed: string[] = []; + const source = `Number.prototype.toLocaleString = function() { + fetch('/locale-hook'); return 'number'; + }; new Array(1, 2).toLocaleString();`; + runInNewContext(source, { fetch: (value: string) => observed.push(value) }); + expect(observed).toEqual(["/locale-hook", "/locale-hook"]); + expect(proofFor(source, "toLocaleString")).toEqual([false]); +}); diff --git a/src/domain/javascript/javascriptSemanticArrayOperations.ts b/src/domain/javascript/javascriptSemanticArrayOperations.ts new file mode 100644 index 00000000000..d3dabada5dc --- /dev/null +++ b/src/domain/javascript/javascriptSemanticArrayOperations.ts @@ -0,0 +1,72 @@ +import * as t from "@babel/types"; + +import { + javaScriptExpressionResultIsUsed, + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + semanticConstructedReceiver, + semanticPrimitiveArguments, + semanticPristineArrayConstruction, +} from "./javascriptSemanticIntrinsicReceivers.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; + +// Primitive-only construction and prior admitted mutations cannot add object slots. +const PRISTINE_ARRAY_PRIMITIVE_RESULTS = new Set([ + "includes", + "indexOf", + "lastIndexOf", + "at", + "join", + "toString", + "push", + "pop", + "shift", + "unshift", +]); +const PRISTINE_ARRAY_RETAINED_RESULTS = new Set([ + "fill", + "copyWithin", + "reverse", + "keys", + "values", + "entries", +]); + +/** These native operations have no callbacks on pristine arrays with primitive arguments. */ +export const isIntrinsicNonEscapingArrayCall = ( + node: t.CallExpression | t.OptionalCallExpression, + state: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => { + const callee = unwrapJavaScriptExpression(node.callee).node; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return false; + const method = + semanticStaticPropertyName(callee.property, callee.computed) ?? ""; + if ( + !PRISTINE_ARRAY_PRIMITIVE_RESULTS.has(method) && + (!PRISTINE_ARRAY_RETAINED_RESULTS.has(method) || + // Receiver-returning methods and iterators retain the array itself. + javaScriptExpressionResultIsUsed(node, state.parentsByNode)) + ) + return false; + if (!semanticPrimitiveArguments(node.arguments, state)) return false; + const receiver = semanticConstructedReceiver(callee.object, node, state, { + paths: [[]], + index, + }); + // Literal arrays retain element values. Skipping their mutation without a + // value update would restore stale slots; new Array already evaluates unknown. + if ( + receiver === undefined || + !semanticPristineArrayConstruction(receiver.construction, node, state) + ) + return false; + // Even direct constructions wait for propagated effects and convergence. + return index === undefined ? undefined : true; +}; diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.test.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.test.ts new file mode 100644 index 00000000000..1628afbac7f --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.test.ts @@ -0,0 +1,95 @@ +import { expect, it } from "vitest"; + +import { + analyzeJavaScriptSemantics, + indexJavaScriptBindingsWithReceiversSteps, +} from "./javascriptSemanticAnalysis.js"; +import { collectJavaScriptSemanticAsyncEffects } from "./javascriptSemanticAsyncEffects.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +const indexed = (source: string) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected event-effect fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return { program: parsed.program, state: index.bindings }; +}; + +it("shares binding scans across missing-listener calls", () => { + const work = (count: number): number => { + // Imports are hoisted, but unrelated declarations may precede them in source. + const source = `${Array.from({ length: count }, (_, i) => `const unrelated${String(i)} = ${String(i)};`).join("\n")} +import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +${Array.from({ length: count }, (_, i) => `e.on("event${String(i)}");`).join("\n")}`; + const { program, state } = indexed(source); + const bindings = state.bindingsById.values.bind(state.bindingsById); + let reads = 0; + Object.defineProperty(state.bindingsById, "values", { + value: function* () { + for (const binding of bindings()) { + reads += 1; + yield binding; + } + }, + }); + const { eventOperations } = collectJavaScriptSemanticAsyncEffects( + program, + state, + [], + ); + // Preserve the earlier-invocation escape uncertainty for subsequent calls. + expect(eventOperations.map(({ eventName }) => eventName)).toEqual( + Array.from({ length: count - 1 }, (_, i) => `event${String(i + 1)}`), + ); + return reads; + }; + expect(work(64)).toBeLessThan(3 * work(32)); +}); + +it("checks shared prototype timing separately at each call", () => { + const result = + analyzeJavaScriptSemantics(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on("before"); +EventEmitter.prototype.on = replacement; +e.on("after");`); + expect(result.eventOperations.map(({ eventName }) => eventName)).toEqual([ + "after", + ]); +}); + +it("checks initialization effects against each receiver construction", () => { + const result = + analyzeJavaScriptSemantics(`import { EventEmitter } from "node:events"; +const before = new EventEmitter(); +EventEmitter.init = replacement; +const after = new EventEmitter(); +before.on("native"); +after.on("changed");`); + expect(result.eventOperations.map(({ eventName }) => eventName)).toEqual([ + "changed", + ]); +}); + +it("rebuilds shared effects for a later collection on the same state", () => { + const { program, state } = + indexed(`import { EventEmitter } from "node:events"; +const e = new EventEmitter(); +e.on("ready");`); + expect( + collectJavaScriptSemanticAsyncEffects(program, state, []).eventOperations, + ).toEqual([]); + const emitter = [...state.bindingsById.values()].find( + ({ name }) => name === "EventEmitter", + ); + if (emitter === undefined) throw new Error("Expected EventEmitter binding"); + emitter.memberWrites.push({ path: ["prototype", "on"], node: undefined }); + expect( + collectJavaScriptSemanticAsyncEffects(program, state, []).eventOperations, + ).toMatchObject([{ method: "on", eventName: "ready" }]); +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 4e5802e8c43..824b4631664 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -15,14 +15,29 @@ import { import { resolveSemanticBindingState, semanticResolutionBlocked, + semanticStableInitializer, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticBindingAtRead, + semanticTopLevelEffectFollowsCapture, +} from "./javascriptSemanticMutationInitializers.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; -import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; -import { semanticOpaqueNameMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import { + isUnmodifiedGlobalReference, + semanticAmbientPathMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; +import { + semanticOpaqueNameMayHaveChanged, + semanticSharedModuleMayHaveOpaqueEffect, +} from "./javascriptSemanticOpaqueEffects.js"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; +import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js"; import { + affectsBuiltinPath, + builtinDataEffectCallablePath, builtinDataEffectIdentity, dataEffectArgumentBindingId, dataEffectLiteralString, @@ -119,6 +134,11 @@ export const collectJavaScriptSemanticAsyncEffects = ( callables: readonly JavaScriptSemanticCallable[], ): AsyncEffectAnalysis => { const events: JavaScriptSemanticEventOperation[] = []; + // Mutation facts are complete before this collection. Share only matching + // records within this pass; every read still decides its own timing. + let emitterEffects: EventEmitterSharedEffects | undefined; + const sharedEmitterEffects = (): EventEmitterSharedEffects => + (emitterEffects ??= indexEventEmitterSharedEffects(state)); const timers: TimerCandidate[] = []; const callableIds = new Set(callables.map(({ callableId }) => callableId)); const callableStack = createSemanticCallableIdStack(callableIds); @@ -128,7 +148,13 @@ export const collectJavaScriptSemanticAsyncEffects = ( callableStack.enter(node, parent); if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { const ownerCallableId = callableStack.callables.at(-1) ?? null; - collectEvent(node, ownerCallableId, state, events); + collectEvent( + node, + ownerCallableId, + state, + events, + sharedEmitterEffects, + ); collectTimer({ node, ownerCallableId, @@ -183,11 +209,458 @@ export const rejectsSemanticEventListener = ( ); }; +/** + * Node's `events` module, whose `EventEmitter` validates every listener + * argument before it registers or removes anything. + */ +const NODE_EVENTS_SPECIFIERS = ["events", "node:events"] as const; + +/** + * Report whether a value is `new .EventEmitter()`. Named and default + * imports, destructured requires, and member requires all denote that class. + * The construction is returned so the caller reads `callee` from the unwrapped + * `NewExpression` rather than from a wrapper around it. + */ +const nodeEventEmitterConstruction = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): t.NewExpression | null => { + const value = unwrapJavaScriptExpression(node).node; + if (!t.isNewExpression(value)) return null; + const selected = builtinDataEffectCallablePath( + value.callee, + state, + NODE_EVENTS_SPECIFIERS, + ); + if (selected === null) return null; + // An empty selected path is the module's default export, which `events` + // defines as `EventEmitter`. + // A suffix alone cannot prove the class: user-defined nested exports may + // end in EventEmitter while selecting an unrelated implementation. + return selected.length === 0 || + (selected.length === 1 && selected[0] === "EventEmitter") + ? value + : null; +}; + +/** + * Resolve the identifier a construction reads the class through: the class + * itself for `new EventEmitter()`, and the module namespace for + * `new events.EventEmitter()`. + */ +const constructionRootBinding = ( + callee: t.Expression | t.Super, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticBindingState | undefined => { + let node: t.Node = unwrapJavaScriptExpression(callee).node; + while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + node = unwrapJavaScriptExpression(node.object).node; + return t.isIdentifier(node) + ? resolveSemanticBindingState(state, node, node.name) + : undefined; +}; + +/** + * Report whether a binding can name the events module or its class, which is + * what makes a member write on it reach the class this construction used. An + * imported or required binding carries the module origin; a local alias such as + * `const EE = EventEmitter` is followed through its initializer. + */ +const eventsClassPath = (path: JavaScriptSemanticPropertyPath): boolean => { + for (let index = 0; index < path.length; index += 1) { + const key = path[index]; + // An unknown key may select the class again; do not prove absence from it. + if (key === null) return true; + if (key === "EventEmitter" || key === "default") continue; + // The standard back-reference returns from the prototype to this class. + if ( + key === "prototype" && + index + 1 < path.length && + (path[index + 1] === "constructor" || path[index + 1] === null) + ) { + index += 1; + continue; + } + return false; + } + return true; +}; + +/** A function's default prototype.constructor pair can reach the function again. */ +const constructorBackReferenceEnd = ( + path: JavaScriptSemanticPropertyPath, + start = 0, + limit = path.length, +): number => { + let end = start; + while ( + end + 1 < limit && + path[end] === "prototype" && + path[end + 1] === "constructor" + ) + end += 2; + return end; +}; + +/** The initializer object owns the `.call` slot Node reads during allocation. */ +const eventsInitializerPath = ( + path: JavaScriptSemanticPropertyPath, +): boolean => { + const initializer = path.indexOf("init"); + return ( + initializer >= 0 && + eventsClassPath(path.slice(0, initializer)) && + constructorBackReferenceEnd(path, initializer + 1) === path.length + ); +}; + +const reachesEventsClassSurface = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): boolean => + binding.directOrigins.some( + ({ specifier, importedPath }) => + eventsClassPath(importedPath) && + NODE_EVENTS_SPECIFIERS.some( + (candidate) => + candidate.replace(/^node:/u, "") === specifier.replace(/^node:/u, ""), + ), + ) || + binding.initializers.some(({ node }) => { + const path = builtinDataEffectCallablePath( + node, + state, + NODE_EVENTS_SPECIFIERS, + ); + return path !== null && eventsClassPath(path); + }); + +type EventEmitterEffect = t.Node | undefined; + +interface EventEmitterClassEffects { + readonly writes: readonly EventEmitterEffect[]; + readonly initializerEscapes: readonly EventEmitterEffect[]; + readonly escapes: readonly EventEmitterEffect[]; +} + +interface EventEmitterSharedEffects extends EventEmitterClassEffects { + readonly otherRoots: ReadonlyMap; + readonly prototypeWrites: ReadonlyMap; + readonly prototypeEscapes: readonly EventEmitterEffect[]; +} + +/** Unknown property keys may select any part of the construction surface. */ +const targetsEventsClass = (path: JavaScriptSemanticPropertyPath): boolean => + path.some( + (key) => + key === null || + key === "EventEmitter" || + key === "init" || + key === "prototype", + ); + +/** Index shared matching records once, without caching a call's validity. */ +const indexEventEmitterSharedEffects = ( + state: JavaScriptSemanticAnalysisState, +): EventEmitterSharedEffects => { + const writes: EventEmitterEffect[] = []; + const initializerEscapes: EventEmitterEffect[] = []; + const escapes: EventEmitterEffect[] = []; + const otherRoots = new Map(); + const prototypeWrites = new Map(); + const prototypeEscapes: EventEmitterEffect[] = []; + const retainPrototypeWrite = ( + path: JavaScriptSemanticPropertyPath, + node: EventEmitterEffect, + ): void => { + for (const method of EVENT_LISTENER_METHODS) { + if (!affectsPrototypeMethod(path, method)) continue; + const matching = prototypeWrites.get(method) ?? []; + matching.push(node); + prototypeWrites.set(method, matching); + } + }; + for (const binding of state.bindingsById.values()) { + const classWrites = binding.memberWrites + .filter(({ path }) => targetsEventsClass(path)) + .map(({ node }) => node); + const classEscapes = binding.escapedPaths + .filter(({ path }) => eventsClassPath(path)) + .map(({ node }) => node); + const classInitializerEscapes = binding.escapedPaths + .filter(({ path }) => eventsInitializerPath(path)) + .map(({ node }) => node); + if ( + classWrites.length > 0 || + classEscapes.length > 0 || + classInitializerEscapes.length > 0 + ) { + if (reachesEventsClassSurface(binding, state)) { + for (const node of classWrites) writes.push(node); + for (const node of classEscapes) escapes.push(node); + for (const node of classInitializerEscapes) + initializerEscapes.push(node); + } else { + // A proven construction's root remains relevant even when the general + // class-origin predicate cannot establish that binding's surface. + otherRoots.set(binding.bindingId, { + writes: classWrites, + initializerEscapes: classInitializerEscapes, + escapes: classEscapes, + }); + } + } + // A named initializer import owns its own records, rather than paths on a + // class binding. Consume the provenance owner's retained origins, including + // candidate origins: uncertainty can withdraw this proof, never create it. + const initializerWrites = binding.memberWrites.filter(({ path }) => { + // A write replaces its last slot, not the value previously stored there. + const selected = path.slice( + constructorBackReferenceEnd(path, 0, path.length - 1), + ); + return ( + affectsBuiltinPath(selected, ["call"]) || selected.at(0) === "__proto__" + ); + }); + const initializerObjectEscapes = binding.escapedPaths.filter( + ({ path }) => constructorBackReferenceEnd(path) === path.length, + ); + if ( + (initializerWrites.length > 0 || initializerObjectEscapes.length > 0) && + evaluateSemanticProvenance(binding, state).origins.some( + ({ specifier, importedPath }) => + eventsInitializerPath(importedPath) && + NODE_EVENTS_SPECIFIERS.some( + (candidate) => + candidate.replace(/^node:/u, "") === + specifier.replace(/^node:/u, ""), + ), + ) + ) { + for (const { node } of initializerWrites) writes.push(node); + for (const { node } of initializerObjectEscapes) + initializerEscapes.push(node); + } + for (const { path, node } of binding.memberWrites) + retainPrototypeWrite(path, node); + for (const { path, node } of binding.escapedPaths) + if (escapesPrototypeObject(path)) prototypeEscapes.push(node); + } + for (const specifier of NODE_EVENTS_SPECIFIERS) + for (const { kind, path, node } of state.requiredModuleEffects.get( + specifier, + ) ?? []) { + if (kind === "escape") { + if (eventsClassPath(path)) escapes.push(node); + if (eventsInitializerPath(path)) initializerEscapes.push(node); + if (escapesPrototypeObject(path)) prototypeEscapes.push(node); + } else { + if (targetsEventsClass(path)) writes.push(node); + retainPrototypeWrite(path, node); + } + } + return { + writes, + initializerEscapes, + escapes, + otherRoots, + prototypeWrites, + prototypeEscapes, + }; +}; + +/** + * Construction-only writes and initializer escapes matter before allocation. + * An escaped initializer can gain an own `.call` wrapper without replacement. + * Class and namespace escapes can replace the shared prototype until the + * listener call. + */ +const eventsClassSurfaceTouched = ( + effects: EventEmitterSharedEffects, + construction: t.NewExpression, + read: t.Node, + state: JavaScriptSemanticAnalysisState, + invocation: t.Node, +): boolean => { + const precedes = (read: t.Node, effect: EventEmitterEffect): boolean => + effect !== invocation && + !semanticTopLevelEffectFollowsCapture(read, effect, state.parentsByNode); + const touched = (selected: EventEmitterClassEffects): boolean => + selected.escapes.some((node) => precedes(read, node)) || + selected.writes.some((node) => precedes(construction, node)) || + selected.initializerEscapes.some((node) => precedes(construction, node)); + if (touched(effects)) return true; + const root = constructionRootBinding(construction.callee, state); + const rootEffects = + root === undefined ? undefined : effects.otherRoots.get(root.bindingId); + return rootEffects !== undefined && touched(rootEffects); +}; + +/** + * Report whether a recorded path can select, or replace, `prototype.`. + * The path is recorded against whichever object owns the write, so it carries + * the owner's own prefix (`EventEmitter.prototype.on`, `events.EventEmitter + * .prototype.on`) and only its tail names the slot. An unknown property key + * such as `prototype[key]` covers the method as well, so the tail is compared + * with the shared builtin-path rule instead of by exact keys. + */ +const affectsPrototypeMethod = ( + path: JavaScriptSemanticPropertyPath, + method: string, +): boolean => { + for (let index = 0; index < path.length; index += 1) + if (affectsBuiltinPath(path.slice(index), ["prototype", method])) + return true; + return false; +}; + +/** + * Report whether any object in this module writes a `prototype.` slot. + * A receiver cannot be held to the native contract once the method that answers + * the call may have been replaced on a shared prototype. The write is recorded + * on whichever binding owns the object, so a scan covers every spelling of it: + * the class itself, a namespace import, a member require, or an alias of any of + * them. A write through an inline `require(...)` has no binding at all and is + * recorded against the module, so those records are scanned too. A file that + * reassigns `prototype.` for an unrelated class loses the proof as + * well, which keeps the guard narrow instead of guessing which definition + * answers the call. + */ +const writesPrototypeMethod = ( + effects: EventEmitterSharedEffects, + method: string, + read: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => + (effects.prototypeWrites.get(method) ?? []).some( + (write) => + !semanticTopLevelEffectFollowsCapture(read, write, state.parentsByNode), + ); + +/** + * Report whether an escape hands out the prototype object itself. Reading a + * method and passing the function value out (`proto.on`) cannot change the + * prototype, but passing the object (`configure(proto)`) lets the call install + * a replacement method on it. + */ +const escapesPrototypeObject = ( + path: JavaScriptSemanticPropertyPath, +): boolean => + path.length > 0 && + (path.at(-1) === "prototype" || + path.at(-1) === "__proto__" || + path.at(-1) === null); + +/** + * Report whether the shared prototype may have gained a method through a call. + * The prototype escapes exactly like an instance does, and the callee can + * install a defaulting wrapper on it, so the class no longer answers with the + * method it defines. + */ +const prototypeObjectEscaped = ( + effects: EventEmitterSharedEffects, + read: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => + effects.prototypeEscapes.some( + (escape) => + !semanticTopLevelEffectFollowsCapture(read, escape, state.parentsByNode), + ); + +/** + * Report whether the receiver is a value this module constructs from Node's + * `EventEmitter` and still calls the native listener method. Only that class is + * bound by `checkListener`; other EventEmitter-style APIs define one-argument + * forms, such as Socket.IO's `socket.off("x")`, which removes every listener + * for the event. Any receiver whose selected method may have been replaced + * keeps its operation as an unresolved candidate. + */ +const nodeEventEmitterReceiver = ( + node: t.Expression | t.Super, + method: string, + state: JavaScriptSemanticAnalysisState, + call: t.Node, + sharedEffects: () => EventEmitterSharedEffects, +): boolean => { + if (!t.isIdentifier(node)) return false; + const binding = resolveSemanticBindingState(state, node, node.name); + if (binding === undefined) return false; + const initializer = semanticStableInitializer(binding, state); + if (initializer === undefined || initializer.projection.length !== 0) + return false; + const construction = nodeEventEmitterConstruction(initializer.node, state); + if (construction === null) return false; + // Node allocates through EventEmitter.init.call(this, opts). The inherited + // call slot is shared by functions, so its ambient effects belong to the + // allocation even when no events binding was written or exposed. + if ( + semanticAmbientPathMayHaveChangedAt(construction, state, [ + "global", + "Function", + "prototype", + "call", + ]) + ) + return false; + // Reaching the class is not the same as reaching the method: the class object + // and the `init` hook Node runs while constructing can both be reshaped, so a + // construction whose surface was touched keeps its operation. + const effects = sharedEffects(); + if (eventsClassSurfaceTouched(effects, construction, node, state, call)) + return false; + // The receiver is activation-local, but the events module and its prototype + // are shared: opaque code in an outer activation can replace their methods + // even when this receiver does not exist yet. Use the opaque-effect owner's + // shared-identity query rather than interpreting eval text here. + if (semanticSharedModuleMayHaveOpaqueEffect(node, state)) return false; + // Constructing the class proves where the receiver came from, not which + // definition answers the call. Passing the instance to another call lets that + // call replace the method first, so the proof no longer holds. Reading + // `emitter.on` in order to call it passes the function value rather than the + // instance, and the collector records the operation's own call as one of + // these escapes, so that call is not evidence against the receiver. A call + // that runs after this one cannot change whether it threw, so it is not + // evidence either. + if ( + binding.escapedPaths.some( + (escape) => + escape.path.length === 0 && + escape.node !== call && + !semanticTopLevelEffectFollowsCapture( + call, + escape.node, + state.parentsByNode, + ), + ) + ) + return false; + // A member write replaces the selected method, so it no longer keeps the + // native contract. An unrelated write such as `emitter.tag = 1` leaves the + // proof intact. Replacing the receiver's own prototype selects a different + // method as well, and is recorded as a `__proto__` write. + if ( + binding.memberWrites.some( + ({ path, node: write }) => + (affectsBuiltinPath(path, [method]) || path.at(0) === "__proto__") && + !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), + ) + ) + return false; + // Every instance shares the constructor's prototype, so a write there + // replaces the method for this receiver as well, and so does handing that + // prototype to a call that may install the method itself. + return ( + !writesPrototypeMethod(effects, method, node, state) && + !prototypeObjectEscaped(effects, node, state) + ); +}; + const collectEvent = ( node: t.CallExpression | t.OptionalCallExpression, ownerCallableId: string | null, state: JavaScriptSemanticAnalysisState, output: JavaScriptSemanticEventOperation[], + sharedEffects: () => EventEmitterSharedEffects, ): void => { const member = dataEffectMemberCallee(node); if (member === null) return; @@ -199,15 +672,26 @@ const collectEvent = ( if (method === undefined) return; const kind = eventKind(method); const eventArgument = node.arguments[0]; - const listenerArgument = EVENT_LISTENER_METHODS.includes(method) - ? node.arguments[1] - : undefined; + const listenerMethod = EVENT_LISTENER_METHODS.includes(method); + const listenerArgument = listenerMethod ? node.arguments[1] : undefined; + // A listener method called without a listener argument passes `undefined` to + // Node's `checkListener`, which throws before it registers or removes + // anything. Only a spread argument can still reach that position, and only a + // receiver proven to be Node's `EventEmitter` is held to that contract. + if ( + listenerMethod && + listenerArgument === undefined && + !node.arguments.some((argument) => t.isSpreadElement(argument)) && + nodeEventEmitterReceiver(member.object, method, state, node, sharedEffects) + ) + return; // A leading spread shifts the runtime argument positions, so argument 1 is not // provably the listener: `e.on(...["x", fn], 1)` registers `fn` and ignores the // trailing `1`. if ( EVENT_LISTENER_METHODS.includes(method) && !t.isSpreadElement(eventArgument) && + listenerArgument !== undefined && rejectsSemanticEventListener(listenerArgument) ) return; diff --git a/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts new file mode 100644 index 00000000000..16a59aebc93 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticBuiltinModuleEffects.test.ts @@ -0,0 +1,529 @@ +import { expect, it } from "vitest"; + +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { + semanticModuleAcquisitionEffectOrigin, + semanticRequireOriginCandidate, +} from "./javascriptSemanticRequireOrigin.js"; +import { builtinDataEffectCallablePath } from "./javascriptSemanticDataEffectHelpers.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; + +const indexed = (source: string, commonJs = false) => { + const parsed = parseJavaScriptSource( + source, + commonJs ? "fixture.cts" : undefined, + ); + if (parsed === null) + throw new Error("Expected builtin-module effect fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps( + parsed.program, + commonJs ? "commonjs-wrapper" : undefined, + ), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return index.bindings; +}; + +it.each([ + "process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "globalThis.process['getBuiltinModule']('node:events').EventEmitter.prototype.on = replacement;", + "(process?.getBuiltinModule?.('events')).EventEmitter.prototype.on = replacement;", + "const events = process.getBuiltinModule('events'); events.EventEmitter.prototype.on = replacement;", + "const events = process.getBuiltinModule('events'); const alias = events; alias.EventEmitter.prototype.on = replacement;", + "const { getBuiltinModule: get } = process; get('events').EventEmitter.prototype.on = replacement;", + "const get = process.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement;", + "const get = globalThis.process.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement;", + "const get0 = process.getBuiltinModule; const get = get0; get('events').EventEmitter.prototype.on = replacement;", + "const host = process; host.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "const holder = { get: process.getBuiltinModule }; holder.get('events').EventEmitter.prototype.on = replacement;", + "const get = process.getBuiltinModule; process.getBuiltinModule = other; get('events').EventEmitter.prototype.on = replacement;", + + "import process from 'node:process'; process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "import { getBuiltinModule } from 'node:process'; getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "import * as process from 'node:process'; const get = process.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement;", + "import { getBuiltinModule as get } from 'node:process'; const alias = get; alias('events').EventEmitter.prototype.on = replacement;", +])( + "retains builtin acquisition writes as candidate module effects: %s", + (source) => { + const state = indexed(source); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it("does not promote the effect-only acquisition into require provenance", () => { + const state = indexed("const events = process.getBuiltinModule('events');"); + const binding = [...state.bindingsById.values()].find( + ({ name }) => name === "events", + ); + const expression = binding?.initializers[0]?.node; + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toMatchObject({ + specifier: "events", + importedPath: [], + loaderAuthority: "candidate", + }); + expect(semanticRequireOriginCandidate(expression, state)).toBeUndefined(); +}); + +it.each([ + "function run(process) { process.getBuiltinModule('events').EventEmitter.prototype.on = replacement; }", + "process = other; process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "const process = other; process.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "other.getBuiltinModule('events').EventEmitter.prototype.on = replacement;", + "const process = other; const { getBuiltinModule: get } = process; get('events').EventEmitter.prototype.on = replacement;", + "function run(process) { const get = process.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement; }", + "let get = process.getBuiltinModule; get = other; get('events').EventEmitter.prototype.on = replacement;", + "const get = other.getBuiltinModule; get('events').EventEmitter.prototype.on = replacement;", + + "process.getBuiltinModule(specifier).EventEmitter.prototype.on = replacement;", +])( + "does not invent module identity outside supported acquisition facts: %s", + (source) => { + expect(indexed(source).requiredModuleEffects.get("events") ?? []).toEqual( + [], + ); + }, +); + +it.each([ + "(await import('events')).EventEmitter.prototype.on = replacement;", + "const events = await import('node:events'); events.EventEmitter.prototype.on = replacement;", + "const { EventEmitter: EE } = await import('events'); EE.prototype.on = replacement;", + "const events = await import('events'); const alias = events; alias.EventEmitter.prototype.on = replacement;", +])( + "retains awaited import writes as candidate module effects: %s", + (source) => { + expect(indexed(source).requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it("retains a dynamic default export without promoting its origin to require", () => { + const state = indexed( + "const events = await import('events'); events.default.prototype.on = replacement;", + ); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["default", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + const binding = [...state.bindingsById.values()].find( + ({ name }) => name === "events", + ); + const expression = binding?.initializers[0]?.node; + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toMatchObject({ + specifier: "events", + resolutionMode: "import", + loaderAuthority: "candidate", + }); + expect(semanticRequireOriginCandidate(expression, state)).toBeUndefined(); +}); + +it.each([ + "import('events').EventEmitter.prototype.on = replacement;", + "(await import(specifier)).EventEmitter.prototype.on = replacement;", + "(await other('events')).EventEmitter.prototype.on = replacement;", +])("does not invent an awaited namespace from %s", (source) => { + expect(indexed(source).requiredModuleEffects.get("events") ?? []).toEqual([]); +}); + +it.each([ + "(await (flag ? import('events') : import('node:events'))).EventEmitter.prototype.on = replacement;", + "(await (sideEffect(), import('events'))).EventEmitter.prototype.on = replacement;", + "(await (promise = import('events'))).EventEmitter.prototype.on = replacement;", + "(await (other || import('events'))).EventEmitter.prototype.on = replacement;", +])("retains awaited branch value acquisition effects: %s", (source) => { + expect( + indexed( + `import { EventEmitter } from 'events'; ${source}`, + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); +}); + +it.each([ + "(flag ? import('events') : import('node:events')).EventEmitter.prototype.on = replacement;", + "(await (import('events'), other)).EventEmitter.prototype.on = replacement;", + "(await import('events').selected).EventEmitter.prototype.on = replacement;", + "(await { pending: import('events') }).pending.EventEmitter.prototype.on = replacement;", + "(await [import('events')])[0].EventEmitter.prototype.on = replacement;", + "(await { pending: flag ? import('events') : import('node:events') }).pending.EventEmitter.prototype.on = replacement;", +])( + "does not apply await to unrelated values or Promise children: %s", + (source) => { + expect( + indexed( + `import { EventEmitter } from 'events'; ${source}`, + ).requiredModuleEffects.get("events") ?? [], + ).toEqual([]); + }, +); + +it.each([ + "flag ? import('events') : import('node:events')", + "sideEffect(), import('events')", + "promise = import('events')", + "other || import('events')", +])("retains escaped awaited branch namespaces: %s", (acquisition) => { + const state = indexed( + `import { EventEmitter } from 'events'; configure((await (${acquisition})).EventEmitter.prototype);`, + ); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "escape", + path: ["EventEmitter", "prototype"], + loaderAuthority: "candidate", + }), + ); +}); + +const eventConstructorPaths = (state: ReturnType) => { + const paths: (readonly string[] | null)[] = []; + traverseJavaScriptAst(state.program, { + enter(node) { + if (node.type === "NewExpression") + paths.push( + builtinDataEffectCallablePath(node.callee, state, [ + "events", + "node:events", + ]), + ); + }, + }); + return paths; +}; + +// CommonJS TypeScript fixtures retain inert imports with explicit wrapper authority. + +it.each([ + [ + "default events to node:events", + `import A from "events"; import B from "node:events";`, + ], + [ + "default node:events to events", + `import A from "node:events"; import B from "events";`, + ], + [ + "default to require", + `import A from "events"; const B = require("node:events");`, + ], + [ + "require to default", + `const A = require("node:events"); import B from "events";`, + ], + [ + "default node:events to require", + `import A from "node:events"; const B = require("events");`, + ], + [ + "require events to default", + `const A = require("events"); import B from "node:events";`, + ], +])("owns shared constructor-slot writes from %s", (_name, header) => { + const state = indexed( + `${header} +const before = new B.EventEmitter(); +const Ctor = B.EventEmitter; +const object = B; +A.EventEmitter = replacement; +const saved = new Ctor(); +const after = new B.EventEmitter(); +const throughSavedObject = new object.EventEmitter();`, + header.includes("require("), + ); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter"], + }), + ); + expect(eventConstructorPaths(state)).toEqual([ + ["EventEmitter"], + ["EventEmitter"], + null, + null, + ]); +}); + +it("keeps named and namespace export snapshots separate from mutable default slots", () => { + const state = indexed(`import A from "events"; +import B, { EventEmitter as Ctor } from "node:events"; +import * as ns from "node:events"; +A.EventEmitter = replacement; +const named = new Ctor(); +const namespace = new ns.EventEmitter(); +const mutable = new B.EventEmitter();`); + expect(eventConstructorPaths(state)).toEqual([ + ["EventEmitter"], + ["EventEmitter"], + null, + ]); +}); + +it("does not share replacement of an immutable namespace export slot", () => { + const state = + indexed(`import * as ns from "events"; import B from "node:events"; +ns.EventEmitter = replacement; +const e = new B.EventEmitter();`); + expect(state.requiredModuleEffects.get("events") ?? []).toEqual([]); + expect(eventConstructorPaths(state)).toEqual([["EventEmitter"]]); +}); + +it.each([ + ["unrelated property", "A.extra = replacement;", ["EventEmitter"]], + ["unknown property", "A[key] = replacement;", null], + ["containing object escape", "configure(A);", null], + ["class value escape", "configure(A.EventEmitter);", ["EventEmitter"]], +] as const)( + "uses shared path coverage for an events %s", + (_name, mutation, path) => { + const state = indexed(`import A from "events"; import B from "node:events"; +${mutation} +const e = new B.EventEmitter();`); + expect(eventConstructorPaths(state)).toEqual([path]); + }, +); + +it.each([ + "process.getBuiltinModule('events')", + "(await import('events'))", + "(await import('node:events'))", +])( + "does not promote %s into a proven constructor after sharing default effects", + (acquisition) => { + const state = indexed(`import A from "events"; +const acquired = ${acquisition}; +const before = new acquired.EventEmitter(); +A.EventEmitter = replacement; +const after = new acquired.EventEmitter();`); + expect(eventConstructorPaths(state)).toEqual([null, null]); + }, +); + +it.each([ + "process.getBuiltinModule.call(null, 'events')", + "process.getBuiltinModule.apply(null, ['events'])", + "Reflect.apply(process.getBuiltinModule, null, ['events'])", + "Function.prototype.call.call(process.getBuiltinModule, null, 'events')", + "process.getBuiltinModule.call.call(process.getBuiltinModule, null, 'events')", + "process.getBuiltinModule.bind(null, 'events')()", + "process.getBuiltinModule.bind(null)('events')", + "process.getBuiltinModule.bind(null).bind(null, 'events')()", + "(process.getBuiltinModule?.call(null, 'events'))", + "globalThis.process.getBuiltinModule.apply(null, ['node:events'])", +])("normalizes effective builtin-getter arguments: %s", (getter) => { + const source = `${getter}.EventEmitter.prototype.on = replacement;`; + const state = indexed(source); + expect(state.requiredModuleEffects.get("events")).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); +}); + +it.each([ + "const get = process.getBuiltinModule; get.call(null, 'events')", + "const { getBuiltinModule: get } = process; get.apply(null, ['events'])", + "const host = process; host.getBuiltinModule.call(null, 'events')", + "const holder = { get: process.getBuiltinModule }; holder.get.call(null, 'events')", + "const get = process.getBuiltinModule.bind(null, 'events'); const alias = get; alias()", + "const host = process; const get = host.getBuiltinModule.bind(null); get('events')", + "import process from 'node:process'; process.getBuiltinModule.call(null, 'events')", + "import { getBuiltinModule as get } from 'process'; get.call(null, 'events')", + "import * as process from 'node:process'; process.getBuiltinModule.apply(null, ['events'])", + "import { getBuiltinModule as get } from 'process'; const alias = get; Reflect.apply(alias, null, ['events'])", +])( + "uses existing getter provenance without a separate alias walker: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it.each([ + "process.getBuiltinModule.call.call(other, null, 'events')", + "(0, process.getBuiltinModule.call)(null, 'events')", + "const call = process.getBuiltinModule.call; call(null, 'events')", + "process.getBuiltinModule.call(null, specifier)", + "process.getBuiltinModule.apply(null, args)", + "process.getBuiltinModule.apply(null, [, 'events'])", + "process.getBuiltinModule.call(...args)", + "process.getBuiltinModule.bind(null, specifier)()", + "process.getBuiltinModule.call = other; process.getBuiltinModule.call(null, 'events')", + "Function.prototype.apply = other; process.getBuiltinModule.apply(null, ['events'])", + "Reflect.apply = other; Reflect.apply(process.getBuiltinModule, null, ['events'])", + "process.getBuiltinModule.bind = other; process.getBuiltinModule.bind(null, 'events')()", + "import process from 'process'; process.getBuiltinModule.call = other; process.getBuiltinModule.call(null, 'events')", + "const process = other; process.getBuiltinModule.call(null, 'events')", + "const Reflect = other; Reflect.apply(process.getBuiltinModule, null, ['events'])", +])( + "does not guess getter arguments through unknown invocation facts: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events") ?? [], + ).toEqual([]); + }, +); + +it.each([ + "process.getBuiltinModule.call(null, 'events')", + "process.getBuiltinModule.bind(null, 'events')()", +])( + "does not promote normalized getter calls to loader authority: %s", + (getter) => { + const state = indexed(`const events = ${getter};`); + const expression = [...state.bindingsById.values()].find( + ({ name }) => name === "events", + )?.initializers[0]?.node; + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toMatchObject({ specifier: "events", loaderAuthority: "candidate" }); + expect(semanticRequireOriginCandidate(expression, state)).toBeUndefined(); + }, +); + +it.each([ + "import {getBuiltinModule as get} from 'process'; get.bind(null, 'events')()", + "const holder = { get: process.getBuiltinModule }; holder.get.bind(null, 'events')()", +])( + "does not invent bound-capture facts for unsupported sources: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events") ?? [], + ).toEqual([]); + }, +); + +it("preserves candidate-only direct acquisition after a getter replacement", () => { + expect( + indexed( + "process.getBuiltinModule = replacement; process.getBuiltinModule('events').EventEmitter.prototype.on = other;", + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); +}); + +it("invalidates the getter helper reader when a shared process effect is indexed later", () => { + const state = indexed( + "import process from 'process'; const events = process.getBuiltinModule.call(null, 'events');", + ); + const expression = [...state.bindingsById.values()].find( + ({ name }) => name === "events", + )?.initializers[0]?.node; + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toBeDefined(); + state.requiredModuleEffects.set("process", [ + ...(state.requiredModuleEffects.get("process") ?? []), + { kind: "write", path: ["getBuiltinModule", "call"], node: undefined }, + ]); + expect( + semanticModuleAcquisitionEffectOrigin(expression, state), + ).toBeUndefined(); +}); + +it.each([ + "process.getBuiltinModule('fs');", + "process.getBuiltinModule.call(null, 'fs');", + "import process from 'process'; process.getBuiltinModule.call(null, 'fs');", + "const get = process.getBuiltinModule.bind(null); get('fs');", +])( + "does not treat a prior getter invocation as a helper replacement: %s", + (prefix) => { + expect( + indexed( + `${prefix} process.getBuiltinModule.call(null, 'events').EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + kind: "write", + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it.each([ + "const get = process.getBuiltinModule; process.getBuiltinModule = other; get.call(null, 'events')", + "process.getBuiltinModule = other; process.getBuiltinModule.call(null, 'events')", + "const host = process; process = other; host.getBuiltinModule.call(null, 'events')", +])( + "retains a possible getter without requiring an unchanged parent slot: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events"), + ).toContainEqual( + expect.objectContaining({ + path: ["EventEmitter", "prototype", "on"], + loaderAuthority: "candidate", + }), + ); + }, +); + +it.each([ + "globalThis.process = other; globalThis.process.getBuiltinModule.call(null, 'events')", + "globalThis.process = other; globalThis.process.getBuiltinModule.bind(null, 'events')()", + "globalThis.process.getBuiltinModule.call = other; globalThis.process.getBuiltinModule.call(null, 'events')", + "process.getBuiltinModule[key] = other; process.getBuiltinModule.call(null, 'events')", + "globalThis[key].getBuiltinModule.call = other; globalThis.process.getBuiltinModule.call(null, 'events')", +])( + "does not mistake a fallback intrinsic path for a candidate getter: %s", + (getter) => { + expect( + indexed( + `${getter}.EventEmitter.prototype.on = replacement;`, + ).requiredModuleEffects.get("events") ?? [], + ).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 1442a921fcc..c4b57340f57 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -12,7 +12,7 @@ import type { import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; import { createSemanticCallableStack } from "./javascriptSemanticCallableStack.js"; import { - javaScriptExpressionWrapperTarget, + javaScriptExpressionResultIsUsed, readExactJavaScriptLiteral, semanticStaticPropertyKey, unwrapJavaScriptExpression, @@ -107,7 +107,7 @@ export const collectJavaScriptSemanticCalls = ( t.isOptionalCallExpression(node) || t.isNewExpression(node) ) - collectCallSite(node, parent, owner, context); + collectCallSite(node, owner, context); context.ancestors.push(node); }, exit: (node) => { @@ -125,35 +125,8 @@ export const collectJavaScriptSemanticCalls = ( }; }; -/** TypeScript and grouping wrappers do not consume a call result. */ -const callResultIsUsed = ( - call: t.Node, - parent: t.Node | null, - ancestors: readonly t.Node[], -): boolean => { - let current = parent; - let child = call; - let index = ancestors.length - 1; - while ( - current !== null && - javaScriptExpressionWrapperTarget(current) !== undefined - ) { - child = current; - index -= 1; - current = index >= 0 ? (ancestors[index] ?? null) : null; - } - if (t.isExpressionStatement(current)) return false; - // for/of and for/in heads use their expressions. A C-style for initializer - // or update discards the value. - return !( - t.isForStatement(current) && - (current.init === child || current.update === child) - ); -}; - const collectCallSite = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, - parent: t.Node | null, owner: JavaScriptSemanticCallable | undefined, context: CallCollectionContext, ): void => { @@ -173,7 +146,7 @@ const collectCallSite = ( callerCallableId: owner?.callableId ?? null, location: range(node), calleeLocation: range(node.callee), - resultUsed: callResultIsUsed(node, parent, context.ancestors), + resultUsed: javaScriptExpressionResultIsUsed(node, state.parentsByNode), resolution: resolution.callableIds.length === 1 && resolution.complete ? "exact" diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.test.ts b/src/domain/javascript/javascriptSemanticChildProcesses.test.ts index 2f1ef4acf7f..12e9017e958 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.test.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.test.ts @@ -22,7 +22,21 @@ it.each( ); expect(ir.childProcessSpawns).toHaveLength(1); expect(ir.childProcessInteractions).toEqual([]); - expect(ir.eventOperations).toEqual([]); + // Spawn ownership does not prove that the selected listener method is native. + // A missing argument keeps a generic candidate, not a known registration. + if (listener === "") { + expect(ir.eventOperations).toMatchObject([ + { + kind: "register", + method, + eventName: "exit", + listenerBindingId: null, + listenerLocation: null, + }, + ]); + } else { + expect(ir.eventOperations).toEqual([]); + } }, ); diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index 88f0e0a8a80..cc45e4e5df6 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -711,7 +711,7 @@ const builtinProvenanceOrigin = ( : undefined; }; -const affectsBuiltinPath = ( +export const affectsBuiltinPath = ( path: JavaScriptSemanticPropertyPath, selected: readonly string[], ): boolean => diff --git a/src/domain/javascript/javascriptSemanticIntrinsicReceivers.ts b/src/domain/javascript/javascriptSemanticIntrinsicReceivers.ts new file mode 100644 index 00000000000..f498c87b97d --- /dev/null +++ b/src/domain/javascript/javascriptSemanticIntrinsicReceivers.ts @@ -0,0 +1,112 @@ +import * as t from "@babel/types"; + +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; +import { + isUnmodifiedGlobalReference, + semanticAmbientObjectMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import { semanticPrimitiveCandidates } from "./javascriptSemanticPrimitives.js"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import { + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptBindingIndex, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; + +/** Resolve only stable, initialized identifier chains to one construction. */ +export const semanticConstructedReceiver = ( + expression: t.Node, + invocation: t.Node, + state: JavaScriptSemanticAnalysisState, + { + paths, + index, + }: { + readonly paths: readonly JavaScriptSemanticPropertyPath[]; + readonly index: + | Pick + | undefined; + }, +): + | { readonly construction: t.NewExpression; readonly viaBinding: boolean } + | undefined => { + let receiver = unwrapJavaScriptExpression(expression).node; + const seen = new Set(); + while (t.isIdentifier(receiver)) { + if ( + index !== undefined && + paths.some((path) => + index.referenceMayHaveChangedAt(invocation, receiver, path), + ) + ) + return undefined; + const binding = resolveSemanticBindingState(state, receiver, receiver.name); + if (binding === undefined || seen.has(binding.bindingId)) return undefined; + seen.add(binding.bindingId); + const initializer = semanticStableInitializer(binding, state); + if ( + initializer === undefined || + initializer.projection.length !== 0 || + !semanticBindingAtRead(binding, receiver, state).hasDefiniteInitializer + ) + return undefined; + receiver = unwrapJavaScriptExpression(initializer.node).node; + } + return t.isNewExpression(receiver) + ? { construction: receiver, viaBinding: seen.size > 0 } + : undefined; +}; + +/** Primitive candidates exclude coercion hooks; evaluating them can still have effects. */ +export const semanticPrimitiveArguments = ( + arguments_: t.CallExpression["arguments"], + state: JavaScriptSemanticAnalysisState, +): boolean => + arguments_.every( + (argument) => + !t.isSpreadElement(argument) && + semanticPrimitiveCandidates( + evaluateSemanticExpression(argument, state), + ) !== null, + ); + +/** Fresh primitive arrays cannot retain object slots or select modified prototype hooks. */ +export const semanticPristineArrayConstruction = ( + construction: t.NewExpression, + invocation: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => + isUnmodifiedGlobalReference(construction.callee, state, "Array") && + semanticPrimitiveArguments(construction.arguments, state) && + // Include argument evaluation and inherited numeric accessors through holes. + !["Array", "Object"].some((owner) => + semanticAmbientObjectMayHaveChangedAt( + invocation, + state, + ["global", owner, "prototype"], + invocation, + ), + ) && + !semanticOpaqueGlobalMayHaveChanged(invocation, state); + +/** Empty native Maps have no iterable hooks or retained entry objects. */ +export const semanticPristineEmptyMapConstruction = ( + construction: t.NewExpression, + invocation: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => + isUnmodifiedGlobalReference(construction.callee, state, "Map") && + construction.arguments.length === 0 && + !["Map", "Object"].some((owner) => + semanticAmbientObjectMayHaveChangedAt( + invocation, + state, + ["global", owner, "prototype"], + invocation, + ), + ) && + !semanticOpaqueGlobalMayHaveChanged(invocation, state); diff --git a/src/domain/javascript/javascriptSemanticMapOperations.ts b/src/domain/javascript/javascriptSemanticMapOperations.ts index ce559fe3ba9..8761cbcae7d 100644 --- a/src/domain/javascript/javascriptSemanticMapOperations.ts +++ b/src/domain/javascript/javascriptSemanticMapOperations.ts @@ -1,73 +1,98 @@ import * as t from "@babel/types"; import { + javaScriptExpressionResultIsUsed, semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; -import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { - resolveSemanticBindingState, - semanticStableInitializer, - type JavaScriptBindingIndex, - type JavaScriptSemanticAnalysisState, + isUnmodifiedGlobalReference, + semanticAmbientPathMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import { semanticConstructedReceiver } from "./javascriptSemanticIntrinsicReceivers.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; /** Prove an unchanged Map operation; undefined requests shared member-mutation facts. */ -export const isIntrinsicMapLookup = ( +const isIntrinsicMapOperation = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, bindings: JavaScriptSemanticAnalysisState, + purpose: "lookup" | "receiver", index?: Pick, ): boolean | undefined => { const callee = unwrapJavaScriptExpression(node.callee).node; if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return false; const method = semanticStaticPropertyName(callee.property, callee.computed); - if (method !== "get" && method !== "delete") return false; - const prototypeMemberMayHaveChanged = (member: string): boolean => - ["Map", "Object"].some((owner) => { - const path = ["global", owner, "prototype", member]; - return ( - bindings.ambientWrites.covers(path) || - bindings.ambientEscapes.covers(path) - ); - }); - if (prototypeMemberMayHaveChanged(method)) return false; - let receiver = unwrapJavaScriptExpression(callee.object).node; - const seen = new Set(); - while (t.isIdentifier(receiver)) { - if ( - index !== undefined && - (index.referenceMayHaveChangedAt(node, receiver, [method]) || - index.referenceMayHaveChangedAt(node, receiver, ["__proto__"])) - ) - return false; - const binding = resolveSemanticBindingState( - bindings, - receiver, - receiver.name, + if ( + method !== "get" && + method !== "delete" && + (purpose !== "receiver" || + (method !== "has" && + method !== "clear" && + // set returns this, and iterators retain entries. Until their result + // identities are retained, only discarded results prove no escape. + ((method !== "set" && + method !== "keys" && + method !== "values" && + method !== "entries") || + javaScriptExpressionResultIsUsed(node, bindings.parentsByNode)))) + ) + return false; + const prototypeMemberMayHaveChanged = ( + member: string, + read: t.Node, + invocation: t.Node, + ): boolean => + ["Map", "Object"].some((owner) => + semanticAmbientPathMayHaveChangedAt( + read, + bindings, + ["global", owner, "prototype", member], + invocation, + ), ); - if (binding === undefined || seen.has(binding.bindingId)) return false; - seen.add(binding.bindingId); - const initializer = semanticStableInitializer(binding, bindings); - if ( - initializer === undefined || - initializer.projection.length !== 0 || - !semanticBindingAtRead(binding, receiver, bindings).hasDefiniteInitializer - ) - return false; - receiver = unwrapJavaScriptExpression(initializer.node).node; - } + if (prototypeMemberMayHaveChanged(method, callee, node)) return false; + const receiver = semanticConstructedReceiver(callee.object, node, bindings, { + paths: [[method], ["__proto__"]], + index, + }); if ( - !t.isNewExpression(receiver) || - !isUnmodifiedGlobalReference(receiver.callee, bindings, "Map") + receiver === undefined || + !isUnmodifiedGlobalReference(receiver.construction.callee, bindings, "Map") ) return false; // Iterable construction reads and invokes the instance's set hook. A changed // adder can install its own get/delete before the constructed value is read. - if (receiver.arguments.length > 0 && prototypeMemberMayHaveChanged("set")) + // The adder is selected after arguments evaluate, not at the callee read. + if ( + receiver.construction.arguments.length > 0 && + prototypeMemberMayHaveChanged( + "set", + receiver.construction, + receiver.construction, + ) + ) return false; if (semanticOpaqueGlobalMayHaveChanged(callee, bindings)) return false; - return index === undefined && seen.size > 0 ? undefined : true; + return index === undefined && receiver.viaBinding ? undefined : true; }; + +/** Endpoint filtering applies only to the route-like get/delete methods. */ +export const isIntrinsicMapLookup = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + bindings: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => + isIntrinsicMapOperation(node, bindings, "lookup", index); + +/** Other callback-free native methods also leave the shared class untouched. */ +export const isIntrinsicNonEscapingMapCall = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + bindings: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => + isIntrinsicMapOperation(node, bindings, "receiver", index); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index b4bf18206f4..605865b6d1c 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -5,13 +5,18 @@ import { createGlobalPathReader, retainSemanticAmbientEffect, } from "./javascriptSemanticAmbientWrites.js"; -import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; +import { isIntrinsicNonEscapingArrayCall } from "./javascriptSemanticArrayOperations.js"; +import { isIntrinsicNonEscapingMapCall } from "./javascriptSemanticMapOperations.js"; +import { isIntrinsicNonEscapingObjectCall } from "./javascriptSemanticObjectOperations.js"; +import { createSemanticArgumentInspectionQuery } from "./javascriptSemanticArgumentInspections.js"; +import { isIntrinsicNonEscapingXhrOpen } from "./javascriptNetworkRequests.js"; import { isIntrinsicTypedArrayConstruction, isTypedArrayNumericIndex, } from "./javascriptSemanticTypedArrays.js"; import { + semanticModuleAcquisitionEffectOrigin, semanticGlobalProcessOrigin, semanticGlobalProcessPathOrigin, semanticOriginalGlobalProcessAt, @@ -123,6 +128,20 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => { ); }; +/** A literal's inherited reference can expose the native Array prototype. */ +const arrayLiteralIterationMayChange = ( + node: t.Node, + path: PropertyPath, + effect: ValueEffect, +): boolean => + t.isArrayExpression(node) && + ((path[0] === "__proto__" && + (effect === "escape" || path.length > 1) && + arrayPrototypeIterationMayChange(path.slice(1))) || + (path[0] === "constructor" && + path[1] === "prototype" && + arrayPrototypeIterationMayChange(path.slice(2)))); + /** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */ /** Local methods use the same slots, prototype shadowing and array indices as aliases. */ @@ -487,9 +506,29 @@ export function* collectSemanticMemberMutationsSteps( state: JavaScriptSemanticAnalysisState, referenceMayHaveChangedAt: JavaScriptBindingIndex["referenceMayHaveChangedAt"], ): Generator { - const deferredMapReceivers = new Set< + const inspection = createSemanticArgumentInspectionQuery(state); + const intrinsicReceiverCall = ( + node: t.CallExpression | t.OptionalCallExpression, + index?: Pick, + ): boolean | undefined => { + const inspected = inspection.receiver(node, index !== undefined); + if (inspected !== false) return inspected; + const map = isIntrinsicNonEscapingMapCall(node, state, index); + if (map !== false) return map; + const xhr = isIntrinsicNonEscapingXhrOpen(node, state, index); + if (xhr !== false) return xhr; + const array = isIntrinsicNonEscapingArrayCall(node, state, index); + return array === false + ? isIntrinsicNonEscapingObjectCall(node, state, index) + : array; + }; + const deferredIntrinsicReceivers = new Set< t.CallExpression | t.OptionalCallExpression >(); + const deferredInspectionArguments = new Map< + t.CallExpression | t.OptionalCallExpression, + t.Node + >(); // The binding pass already retained callable nodes. Ordinary data-only code // must not pay an alias walk for every member read or assignment. const accessorKeys = { @@ -629,6 +668,14 @@ export function* collectSemanticMemberMutationsSteps( const observablePaths = observableMutationPaths(state); const recordedRequiredEffects = new Set(); let recordedEffectCount = 0; + const iterationEffects = new Set(state.arrayIterationEffects); + const recordArrayIterationEffect = (mutation: t.Node | undefined): void => { + if (iterationEffects.has(mutation)) return; + iterationEffects.add(mutation); + state.arrayIterationEffects.push(mutation); + recordedEffectCount += 1; + clearSemanticValueCaches(state); + }; const recordRequiredEffect = ( specifier: string, path: PropertyPath, @@ -737,7 +784,6 @@ export function* collectSemanticMemberMutationsSteps( recordRequiredEffect("node:process", path.slice(2), "write", node); const selections = new Map(); const coveringPaths = new Map(); - state.arrayIterationUnknown = false; // Definition collection finishes before this phase, so a scope's bindings no // longer change and one node resolves to one binding. Repeated visits of the // same node otherwise repeat the same scope walk. @@ -891,6 +937,8 @@ export function* collectSemanticMemberMutationsSteps( readonly bindings: ReadonlySet; readonly originAt: t.Node | undefined; readonly definiteReceiver: boolean; + /** Only value-preserving branches may carry an await to an acquisition. */ + readonly awaitedModuleAcquisition?: boolean; readonly exactKey?: string; readonly joined?: { readonly key: string; @@ -1024,14 +1072,22 @@ export function* collectSemanticMemberMutationsSteps( ) continue; const expression = unwrapJavaScriptExpression(current.node).node; - const required = semanticRequireOriginCandidate(expression, state, true); - if (required !== undefined) { + if (arrayLiteralIterationMayChange(expression, current.path, effect)) + recordArrayIterationEffect(mutation); + const moduleEffectOrigin = + semanticRequireOriginCandidate(expression, state, true)?.origin ?? + semanticModuleAcquisitionEffectOrigin( + expression, + state, + current.awaitedModuleAcquisition, + ); + if (moduleEffectOrigin !== undefined) { recordRequiredEffect( - required.origin.specifier, - [...required.origin.importedPath, ...current.path], + moduleEffectOrigin.specifier, + [...moduleEffectOrigin.importedPath, ...current.path], effect, mutation, - required.origin.loaderAuthority, + moduleEffectOrigin.loaderAuthority, ); continue; } @@ -1084,7 +1140,7 @@ export function* collectSemanticMemberMutationsSteps( (current.path[1] === "prototype" || current.path[1] === null) && arrayPrototypeIterationMayChange(current.path.slice(2))) ) - state.arrayIterationUnknown = true; + recordArrayIterationEffect(mutation); if (lookup?.kind !== "uncertain-binding") continue; binding = lookup.binding; } @@ -1465,8 +1521,17 @@ export function* collectSemanticMemberMutationsSteps( ]), }); } else { + // Escape flattening must not discard the await before its value branch + // reaches a direct acquisition. Other escapes keep the shared cache. + const awaitedModuleAcquisition = + t.isAwaitExpression(expression) || + (current.awaitedModuleAcquisition === true && + (t.isConditionalExpression(expression) || + t.isLogicalExpression(expression) || + t.isSequenceExpression(expression) || + t.isAssignmentExpression(expression))); const references = - effect === "escape" + effect === "escape" && !awaitedModuleAcquisition ? escapedReferenceLeaves( expression, current.path, @@ -1521,6 +1586,11 @@ export function* collectSemanticMemberMutationsSteps( else enqueue({ ...value, + // This await consumes the branch value, not a property, alias, + // container child, or callback result. Those paths drop it. + ...(awaitedModuleAcquisition + ? { awaitedModuleAcquisition: true } + : {}), bindings: current.bindings, definiteReceiver: current.definiteReceiver && @@ -1572,16 +1642,6 @@ export function* collectSemanticMemberMutationsSteps( effect: ValueEffect, widenedGetterResult = false, ): readonly ReferencedValue[] { - if ( - t.isArrayExpression(expression) && - ((path[0] === "__proto__" && - (effect === "escape" || path.length > 1) && - arrayPrototypeIterationMayChange(path.slice(1))) || - (path[0] === "constructor" && - path[1] === "prototype" && - arrayPrototypeIterationMayChange(path.slice(2)))) - ) - state.arrayIterationUnknown = true; const references = referencedValues( expression, path, @@ -1633,7 +1693,8 @@ export function* collectSemanticMemberMutationsSteps( if ( value.iterableFallbackPath !== undefined || t.isMemberExpression(node) || - t.isOptionalMemberExpression(node) + t.isOptionalMemberExpression(node) || + arrayLiteralIterationMayChange(node, value.path, "escape") ) { leaves.push(value); return true; @@ -1846,12 +1907,19 @@ export function* collectSemanticMemberMutationsSteps( mutation: node, originAt: node, }); - else markEscaped(argument, node); + else { + const inspected = t.isNewExpression(node) + ? false + : inspection.argument(node, argument); + if (inspected === false) markEscaped(argument, node); + else if (!t.isNewExpression(node)) + deferredInspectionArguments.set(node, argument); + } // Constructing a member does not pass its container as `this`. if (!t.isNewExpression(node)) { - const mapLookup = isIntrinsicMapLookup(node, state); - if (mapLookup === undefined) deferredMapReceivers.add(node); - else if (!mapLookup) markReceiver(node.callee, node); + const intrinsicCall = intrinsicReceiverCall(node); + if (intrinsicCall === undefined) deferredIntrinsicReceivers.add(node); + else if (!intrinsicCall) markReceiver(node.callee, node); } } else if (t.isTaggedTemplateExpression(node)) { for (const source of returnedReferences(node.tag).references) @@ -1867,11 +1935,11 @@ export function* collectSemanticMemberMutationsSteps( while ( pendingReferences.length > 0 || iterableReferences.length > 0 || - deferredMapReceivers.size > 0 + deferredIntrinsicReceivers.size > 0 || + deferredInspectionArguments.size > 0 ) { const effectsBefore = recordedEffectCount; const iterablesBefore = iterableReferences.length; - const iterationBefore: boolean = state.arrayIterationUnknown; for (const [index, reference] of pendingReferences.splice(0).entries()) { if (index % 64 === 0) yield; const { initializer } = reference; @@ -1951,10 +2019,18 @@ export function* collectSemanticMemberMutationsSteps( continue; } } - const slot = semanticSelectedReferenceSlot(reference, state); + const slot = semanticSelectedReferenceSlot( + reference, + state, + reference.node, + ); // Custom iteration can yield the iterable itself as well as any child. // Treat its root as escaped, including writes through yielded references. - const knownArray = semanticKnownArrayIteration(slot?.value, state); + const knownArray = semanticKnownArrayIteration( + slot?.value, + state, + reference.node, + ); if (knownArray && reference.iterationOnly) continue; markValue( reference.node, @@ -1970,24 +2046,31 @@ export function* collectSemanticMemberMutationsSteps( reference, ); } - // Native Map lookup methods do not expose their receiver to user code. + let inspectionIndex = 0; + for (const [node, argument] of deferredInspectionArguments) { + if (inspectionIndex++ % 64 === 0) yield; + if ( + inspection.argument(node, argument, { referenceMayHaveChangedAt }) === + true + ) + continue; + deferredInspectionArguments.delete(node); + markEscaped(argument, node); + } + // Proven native Map and pristine XHR/Array/Object calls do not expose receivers. // Settle genuine effects first, then replay every unproven receiver escape. // Keep proven sites until convergence: later propagated effects can revoke them. let receiverIndex = 0; - for (const node of deferredMapReceivers) { + for (const node of deferredIntrinsicReceivers) { if (receiverIndex++ % 64 === 0) yield; - if ( - isIntrinsicMapLookup(node, state, { referenceMayHaveChangedAt }) === - true - ) + if (intrinsicReceiverCall(node, { referenceMayHaveChangedAt }) === true) continue; - deferredMapReceivers.delete(node); + deferredIntrinsicReceivers.delete(node); markReceiver(node.callee, node); } if ( recordedEffectCount === effectsBefore && - iterableReferences.length === iterablesBefore && - state.arrayIterationUnknown === iterationBefore + iterableReferences.length === iterablesBefore ) break; } @@ -2123,13 +2206,33 @@ const constructedReferenceValues = ( const classes = t.isClass(node) ? [node] : semanticClassCandidates(node.callee, state); - return classes.flatMap((owner) => + const references: ReferencedValue[] = classes.flatMap((owner) => semanticClassPropertyReferences( { owner, kind: t.isClass(node) ? "static" : "instance" }, [key, ...remaining], state, ), ); + // A constructed receiver may expose its constructor or prototype even when + // the class is imported and has no local AST. Retain these as inherited + // may-references, not exact value provenance, with the original effect site. + // Replacing the instance slot itself was excluded above. + if ( + t.isNewExpression(node) && + // Copied-slot selections contain own enumerable values, never inherited + // constructor/prototype links. A null key is an ordinary unknown read. + (key === null || typeof key !== "object") + ) { + if (semanticPropertyPathKeyMatches(key, "constructor")) + references.push({ node: node.callee, path: remaining, inherited: true }); + if (semanticPropertyPathKeyMatches(key, "__proto__")) + references.push({ + node: node.callee, + path: ["prototype", ...remaining], + inherited: true, + }); + } + return references; }; const branchReferenceValues = ( @@ -2270,6 +2373,7 @@ const observableMutationPaths = ( const hasParsedRequireOrigin = binding.initializers.some( ({ node }) => semanticRequireOriginCandidate(node, state) !== undefined || + semanticModuleAcquisitionEffectOrigin(node, state) !== undefined || hasSemanticCreateRequireFactoryOrigin(node, state), ); if ( diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index cdc464c2003..14f411c3c8b 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -165,7 +165,7 @@ const positionPrecedes = ( left.declaratorIndex >= 0 && left.declaratorIndex < right.declaratorIndex)); -/** Statement position of a node evaluated once by the program, outside loops and functions. */ +/** Statement position of a node evaluated once by the program, outside repeated execution. */ const computeTopLevelStatementPosition = ( node: t.Node | undefined, parents: WeakMap, @@ -178,7 +178,8 @@ const computeTopLevelStatementPosition = ( (t.isFunction(node) && (child === undefined || !semanticMethodDefinitionExpression(node, child))) || - t.isLoop(node) + // The iterable is selected once on entry; an outer loop still repeats it. + (t.isLoop(node) && !(t.isForOfStatement(node) && child === node.right)) ) return null; if (delayedInstanceFieldValue(node, child)) return null; diff --git a/src/domain/javascript/javascriptSemanticObjectOperations.test.ts b/src/domain/javascript/javascriptSemanticObjectOperations.test.ts new file mode 100644 index 00000000000..c336d2ace68 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticObjectOperations.test.ts @@ -0,0 +1,525 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { + createXhrOpenRequestQuery, + isIntrinsicNonEscapingXhrOpen, +} from "./javascriptNetworkRequests.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { isIntrinsicNonEscapingArrayCall } from "./javascriptSemanticArrayOperations.js"; +import { semanticConstructedReceiver } from "./javascriptSemanticIntrinsicReceivers.js"; +import { + isIntrinsicMapLookup, + isIntrinsicNonEscapingMapCall, +} from "./javascriptSemanticMapOperations.js"; +import { isIntrinsicNonEscapingObjectCall } from "./javascriptSemanticObjectOperations.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { semanticStaticPropertyName } from "./javascriptAstValues.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; + +type ReceiverProof = ( + node: t.CallExpression | t.OptionalCallExpression, + state: JavaScriptSemanticAnalysisState, + index?: Pick, +) => boolean | undefined; + +const fixture = (source: string, method: string, mutations = true) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected intrinsic-receiver fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + if (mutations) completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const calls: (t.CallExpression | t.OptionalCallExpression)[] = []; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + (t.isMemberExpression(node.callee) || + t.isOptionalMemberExpression(node.callee)) && + semanticStaticPropertyName( + node.callee.property, + node.callee.computed, + ) === method + ) + calls.push(node); + }, + }); + return { index, calls }; +}; + +const proofFor = ( + source: string, + method = "hasOwnProperty", + proof: ReceiverProof = isIntrinsicNonEscapingObjectCall, +) => { + const { index, calls } = fixture(source, method); + return calls.map((call) => proof(call, index.bindings, index)); +}; + +it.each([ + "hasOwnProperty('x')", + "propertyIsEnumerable('x')", + "valueOf()", + "isPrototypeOf(0)", + "toString()", + "toLocaleString()", +])( + "preserves unrelated array iteration after pristine native Object.%s", + (call) => { + for (const receiver of ["new Object()", "o"]) { + const source = `${receiver === "o" ? "const o = new Object();" : ""} + ${receiver}.${call}; const urls = ['/api']; const [url] = urls; fetch(url);`; + expect(proofFor(source, call.slice(0, call.indexOf("(")))).toEqual([ + true, + ]); + expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([ + { value: "/api" }, + ]); + } + }, +); + +it.each([ + "new globalThis.Object().hasOwnProperty('x');", + "const o = new Object(); const a = o; const b = a; b.hasOwnProperty('x');", + "const o = new Object(); o?.['hasOwnProperty']?.('x');", + "const o = new Object(); const result = o.hasOwnProperty('x');", + "const o = new Object(); o.hasOwnProperty(flag ? 'x' : 'y');", + "const o = new Object(); o.hasOwnProperty();", + "const o = new Object(); o.hasOwnProperty('x'); o.hasOwnProperty = patch;", + "const o = new Object(); o.hasOwnProperty('x'); o[key] = replacement;", + "const o = new Object(); o.hasOwnProperty('x'); Object.prototype[0] = replacement;", + "const o = new Object(); o.hasOwnProperty('x'); Object.prototype[Symbol.toStringTag] = 'Changed';", + "Map.prototype[0] = replacement; const o = new Object(); o.hasOwnProperty('x');", +])("proves only prior effects relevant to an empty Object: %s", (source) => { + expect(proofFor(source)).toEqual([true]); +}); + +it.each([ + "void new Object().valueOf();", + "const unused = (new Object().valueOf(), 1);", + "(0, new Object().valueOf());", + "void (0, new Object().valueOf());", + "const unused = ((0, new Object().valueOf()), 1);", + "(new Object().valueOf() as unknown)!;", + "for (new Object().valueOf(); false;) {}", + "for (; false; (0, new Object().valueOf())) {}", + "flag ? new Object().valueOf() : 0;", + "flag ? 0 : new Object().valueOf();", + "flag && new Object().valueOf();", + "flag || new Object().valueOf();", + "flag ?? new Object().valueOf();", + "void ((flag ? (0, new Object().valueOf()) : 0) as unknown)!;", + "const unused = ((flag && new Object().valueOf()), 0);", +])( + "preserves unrelated destructuring after a discarded Object result: %s", + (source) => { + expect(proofFor(source, "valueOf")).toEqual([true]); + expect( + analyzeJavaScriptStaticSource( + `${source} const urls = ['/api']; const [url] = urls; fetch(url);`, + ).endpoints.map(({ value }) => value), + ).toEqual(["/api"]); + }, +); + +it.each([ + "function run(Object) { new Object().hasOwnProperty('x'); }", + "Object = Replacement; new Object().hasOwnProperty('x');", + "globalThis.Object = Replacement; new globalThis.Object().hasOwnProperty('x');", + "new Object(proxy).hasOwnProperty('x');", + "new Object('boxed').hasOwnProperty('x');", + "new Object(null).hasOwnProperty('x');", + "new Object(...args).hasOwnProperty('x');", + "new Proxy(new Object(), handler).hasOwnProperty('x');", + "const o = Object(); o.hasOwnProperty('x');", + "const o = {}; o.hasOwnProperty('x');", + "const o = make(); o.hasOwnProperty('x');", + "const o = new Object(); o.x = 1; o.hasOwnProperty('x');", + "const o = new Object(); o[key] = replacement; o.hasOwnProperty('x');", + "const o = new Object(); const alias = o; alias.x = 1; o.hasOwnProperty('x');", + "const o = new Object(); consume(o); o.hasOwnProperty('x');", + "const o = new Object(); o.hasOwnProperty = patch; o.hasOwnProperty('x');", + "const o = new Object(); Object.setPrototypeOf(o, custom); o.hasOwnProperty('x');", + "const o = new Object(); Object.defineProperty(o, 'x', {get() { patch(this); }}); o.hasOwnProperty('x');", + "Object.prototype.hasOwnProperty = patch; new Object().hasOwnProperty('x');", + "Object.prototype[0] = replacement; new Object().hasOwnProperty('x');", + "Object.prototype[key] = replacement; new Object().hasOwnProperty('x');", + "consume(Object.prototype); new Object().hasOwnProperty('x');", + "Object.setPrototypeOf(Object.prototype, custom); new Object().hasOwnProperty('x');", + "function patch() { Object.prototype.x = replacement; } new Object().hasOwnProperty('x');", + "new Object().hasOwnProperty({toString() { patch(Object.prototype); return 'x'; }});", + "new Object().hasOwnProperty(proxy);", + "new Object().hasOwnProperty(...args);", + "new Object().hasOwnProperty(unknown);", + "eval(code); new Object().hasOwnProperty('x');", +])("retains receiver uncertainty outside the pristine proof: %s", (source) => { + expect(proofFor(source)).toEqual([false]); +}); + +it.each([ + "((o.x = 1) ? 'x' : 'y')", + "(Object.setPrototypeOf(o, custom) ? 'x' : 'y')", + "((Object.prototype.x = replacement) ? 'x' : 'y')", + "(eval(code) ? 'x' : 'y')", + "(consume(o) ? 'x' : 'y')", +])("includes effects while evaluating primitive arguments: %s", (argument) => { + expect( + proofFor(`const o = new Object(); o.hasOwnProperty(${argument});`), + ).toEqual([false]); +}); + +it.each([ + [ + "Object.prototype[Symbol.toStringTag] = 'Changed'; new Object().toString();", + "toString", + ], + [ + "const o = new Object(); o[Symbol.toStringTag] = 'Changed'; o.toString();", + "toString", + ], + [ + "Object.defineProperty(Object.prototype, Symbol.toStringTag, {get() { patch(this); }}); new Object().toString();", + "toString", + ], + [ + "const o = new Object(); o.toString = patch; o.toLocaleString();", + "toLocaleString", + ], + [ + "Object.prototype.toString = patch; new Object().toLocaleString();", + "toLocaleString", + ], + ["new Object().isPrototypeOf(new Proxy({}, handler));", "isPrototypeOf"], + ["const o = new Object(); consume(o.valueOf());", "valueOf"], + ["const result = new Object().valueOf();", "valueOf"], + ["void consume(new Object().valueOf());", "valueOf"], + ["void (holder.value = new Object().valueOf());", "valueOf"], + ["const result = (0, new Object().valueOf());", "valueOf"], + ["Object.prototype.valueOf = patch; void new Object().valueOf();", "valueOf"], + [ + "void new Object().valueOf((Object.prototype.x = replacement, 0));", + "valueOf", + ], + ["void new Object().valueOf(...args);", "valueOf"], + ["function run(Object) { void new Object().valueOf(); }", "valueOf"], + ["new Object().valueOf() ? 0 : 1;", "valueOf"], + ["new Object().valueOf() && 0;", "valueOf"], + ["const result = flag ? new Object().valueOf() : 0;", "valueOf"], + ["void consume(flag && new Object().valueOf());", "valueOf"], +] as const)( + "excludes coercion, tag, locale and returned-receiver hooks: %s", + (source, method) => { + expect(proofFor(source, method)).toEqual([false]); + }, +); + +it.each([ + "propertyIsEnumerable('x')", + "isPrototypeOf()", + "toString()", + "toLocaleString()", +])("permits consumed primitive results: %s", (call) => { + const method = call.slice(0, call.indexOf("(")); + expect( + proofFor(`const o = new Object(); const result = o.${call};`, method), + ).toEqual([true]); +}); + +it.each([ + "__defineGetter__('x', callback)", + "__lookupGetter__('x')", + "unknown()", +])("does not admit additional Object method contracts: %s", (call) => { + expect( + proofFor(`new Object().${call};`, call.slice(0, call.indexOf("("))), + ).toEqual([false]); +}); + +it.each([ + [ + "new Object().hasOwnProperty('x');", + "hasOwnProperty", + isIntrinsicNonEscapingObjectCall, + undefined, + ], + ["new Array(3).fill(0);", "fill", isIntrinsicNonEscapingArrayCall, undefined], + ["new Map().get('x');", "get", isIntrinsicNonEscapingMapCall, true], + [ + "new XMLHttpRequest().open('GET', '/api');", + "open", + isIntrinsicNonEscapingXhrOpen, + true, + ], + [ + "const o = new Object(); o.hasOwnProperty('x');", + "hasOwnProperty", + isIntrinsicNonEscapingObjectCall, + undefined, + ], + [ + "const o = new Array(3); o.fill(0);", + "fill", + isIntrinsicNonEscapingArrayCall, + undefined, + ], + [ + "const o = new Map(); o.get('x');", + "get", + isIntrinsicNonEscapingMapCall, + undefined, + ], + [ + "const o = new XMLHttpRequest(); o.open('GET', '/api');", + "open", + isIntrinsicNonEscapingXhrOpen, + undefined, + ], +] as const)( + "preserves each consumer's deferred-proof contract: %s", + (source, method, proof, initial) => { + const { index, calls } = fixture(source, method, false); + expect(calls).toHaveLength(1); + expect(proof(calls[0]!, index.bindings)).toBe(initial); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + expect(proof(calls[0]!, index.bindings, index)).toBe(true); + }, +); + +it.each([ + "o.hasOwnProperty('x'); const o = new Object();", + "const a = o; const o = new Object(); a.hasOwnProperty('x');", + "let o; if (flag) o = new Object(); o.hasOwnProperty('x');", + "let o = new Object(); o = other; o.hasOwnProperty('x');", + "const {o} = {o: new Object()}; o.hasOwnProperty('x');", + "const a = b; const b = a; a.hasOwnProperty('x');", + "function run(o) { o.hasOwnProperty('x'); }", +])( + "rejects unstable, projected, cyclic or uninitialized receiver chains: %s", + (source) => { + const { index, calls } = fixture(source, "hasOwnProperty"); + const call = calls[0]!; + if (!t.isMemberExpression(call.callee)) + throw new Error("Expected member call"); + expect( + semanticConstructedReceiver(call.callee.object, call, index.bindings, { + paths: [[]], + index, + }), + ).toBeUndefined(); + expect(isIntrinsicNonEscapingObjectCall(call, index.bindings, index)).toBe( + false, + ); + }, +); + +it("keeps Map member paths narrower than the whole receiver", () => { + const source = "const o = new Map(); o.unrelated = 1; o.get('x');"; + expect(proofFor(source, "get", isIntrinsicNonEscapingMapCall)).toEqual([ + true, + ]); + expect(proofFor(source, "get", isIntrinsicMapLookup)).toEqual([true]); +}); + +it("keeps XHR request identity separate from the nonescaping receiver proof", () => { + const { index, calls } = fixture( + "const o = new XMLHttpRequest(); o.onreadystatechange = callback; o.open('GET', '/api');", + "open", + ); + expect(isIntrinsicNonEscapingXhrOpen(calls[0]!, index.bindings, index)).toBe( + false, + ); + expect(createXhrOpenRequestQuery(index)(calls[0]!)).toBe(true); +}); + +it.each([ + "new Object().hasOwnProperty('x');", + "const a = new Object(); a.hasOwnProperty('x');", +])( + "reconsiders pristine proofs after deferred effects reach Object.prototype: %s", + (first) => { + expect( + proofFor( + `function run() { ${first} const b = new Object(); b.x = 1; b.hasOwnProperty('x'); }`, + ), + ).toEqual([false, false]); + }, +); + +it.each(["Array", "Map"])( + "uses Object method contracts on pristine %s receivers", + (owner) => { + const construction = owner === "Array" ? "new Array(3)" : "new Map()"; + const calls = [ + "hasOwnProperty('0')", + "propertyIsEnumerable('0')", + "isPrototypeOf(null)", + ...(owner === "Map" ? ["toString()", "toLocaleString()"] : []), + "valueOf()", + ]; + for (const call of calls) { + const method = call.slice(0, call.indexOf("(")); + for (const bound of [false, true]) { + for (const used of method === "valueOf" ? [false] : [false, true]) { + const source = `${bound ? `const receiver = ${construction};` : ""} + ${used ? "const result = " : ""}${bound ? "receiver" : construction}.${call};`; + expect(proofFor(source, method), source).toEqual([true]); + const tail = + owner === "Array" + ? "const urls = ['/api']; const [url] = urls; fetch(url);" + : "const records = new Map(); records.get('/cache');"; + expect( + analyzeJavaScriptStaticSource(source + tail).endpoints.map( + ({ value }) => value, + ), + source, + ).toEqual(owner === "Array" ? ["/api"] : []); + } + } + } + }, +); + +it.each(["Array", "Map"])( + "retains pristine %s receiver proof boundaries", + (owner) => { + const construction = owner === "Array" ? "new Array(3)" : "new Map()"; + const rejected = [ + `function run(${owner}) { new ${owner}().hasOwnProperty('x'); }`, + `${owner} = Replacement; ${construction}.hasOwnProperty('x');`, + `globalThis.${owner} = Replacement; new globalThis.${owner}().hasOwnProperty('x');`, + `new ${owner}(unknown).hasOwnProperty('x');`, + `new ${owner}(...args).hasOwnProperty('x');`, + `new ${owner}({}).hasOwnProperty('x');`, + `const o = ${construction}; o.x = 1; o.hasOwnProperty('x');`, + `const o = ${construction}; o.hasOwnProperty = patch; o.hasOwnProperty('x');`, + `const o = ${construction}; const alias = o; alias.x = 1; o.hasOwnProperty('x');`, + `const o = ${construction}; consume(o); o.hasOwnProperty('x');`, + `const o = ${construction}; Object.setPrototypeOf(o, custom); o.hasOwnProperty('x');`, + `${owner}.prototype.hasOwnProperty = patch; ${construction}.hasOwnProperty('x');`, + `Object.prototype.hasOwnProperty = patch; ${construction}.hasOwnProperty('x');`, + `${owner}.prototype[0] = patch; ${construction}.hasOwnProperty('x');`, + `${construction}.hasOwnProperty({toString() { patch(); return 'x'; }});`, + `${construction}.hasOwnProperty(...args);`, + `${construction}.hasOwnProperty(unknown);`, + `eval(code); ${construction}.hasOwnProperty('x');`, + `const o = ${construction}; o.hasOwnProperty((o.x = 1, 'x'));`, + `${construction}.hasOwnProperty((${owner}.prototype.x = 1, 'x'));`, + `${construction}.hasOwnProperty((Object.prototype.x = 1, 'x'));`, + `${construction}.hasOwnProperty((eval(code), 'x'));`, + ]; + for (const source of rejected) + expect(proofFor(source), source).toEqual([false]); + for (const context of [ + `const retained = ${construction}.valueOf();`, + `consume(${construction}.valueOf());`, + `${construction}.valueOf() && 0;`, + `const retained = flag ? ${construction}.valueOf() : 0;`, + ]) + expect(proofFor(context, "valueOf"), context).toEqual([false]); + for (const context of [ + `void ${construction}.valueOf();`, + `const unused = (${construction}.valueOf(), 0);`, + `flag ? ${construction}.valueOf() : 0;`, + `flag && ${construction}.valueOf();`, + ]) + expect(proofFor(context, "valueOf"), context).toEqual([true]); + const source = `${construction}.hasOwnProperty('x');`; + const { index, calls } = fixture(source, "hasOwnProperty", false); + expect( + isIntrinsicNonEscapingObjectCall(calls[0]!, index.bindings), + ).toBeUndefined(); + }, +); + +it.each([ + ["new Array(3).toString();", "toString"], + ["new Array(3).toLocaleString();", "toLocaleString"], + [ + "Number.prototype.toLocaleString = function() { patch(); }; new Array(1, 2).toLocaleString();", + "toLocaleString", + ], + ["new Map([]).hasOwnProperty('x');", "hasOwnProperty"], + ["new Set().hasOwnProperty('x');", "hasOwnProperty"], + ["new Date().hasOwnProperty('x');", "hasOwnProperty"], + ["new Map().hasOwnProperty.call(new Map(), 'x');", "call"], + [ + "Map.prototype[Symbol.toStringTag] = 'Changed'; new Map().toString();", + "toString", + ], + [ + "Object.defineProperty(Map.prototype, Symbol.toStringTag, {get() { patch(); }}); new Map().toString();", + "toString", + ], + [ + "Map.prototype.toString = patch; new Map().toLocaleString();", + "toLocaleString", + ], + [ + "const o = new Map(); o.toString = patch; o.toLocaleString();", + "toLocaleString", + ], +] as const)( + "does not widen inherited Object dispatch: %s", + (source, method) => { + expect(proofFor(source, method)).toEqual([false]); + }, +); + +it.each(["Array", "Map"])( + "orders %s inherited method effects at invocation", + (owner) => { + const construction = owner === "Array" ? "new Array(3)" : "new Map()"; + for (const later of [ + "o.x = 1;", + `${owner}.prototype.hasOwnProperty = patch;`, + "Object.prototype.hasOwnProperty = patch;", + ]) { + expect( + proofFor(`const o = ${construction}; o.hasOwnProperty('x'); ${later}`), + ).toEqual([true]); + } + expect( + proofFor(`const o = ${construction}; function patch() { + ${owner}.prototype.hasOwnProperty = replacement; + } o.hasOwnProperty('x');`), + ).toEqual([false]); + }, +); + +it.each([ + ["Map.prototype.hasOwnProperty = replacement;", "hasOwnProperty('x')"], + ["delete Map.prototype.hasOwnProperty;", "hasOwnProperty('x')"], + ["delete Object.prototype.hasOwnProperty;", "hasOwnProperty('x')"], + ["Object.prototype.toString = replacement;", "toLocaleString()"], + ["delete Object.prototype.toString;", "toString()"], + ["delete Map.prototype[Symbol.toStringTag];", "toString()"], + ["Object.prototype[Symbol.toStringTag] = 'Changed';", "toString()"], + [ + "Object.defineProperty(Object.prototype, Symbol.toStringTag, {get() { patch(); }});", + "toLocaleString()", + ], + ["Object.setPrototypeOf(Map.prototype, custom);", "toString()"], + ["const o = new Map(); o[Symbol.toStringTag] = 'Changed';", "toString()"], +] as const)( + "requires Map's own and inherited method surfaces: %s", + (prefix, call) => { + const receiver = prefix.startsWith("const o") ? "o" : "new Map()"; + expect( + proofFor( + `${prefix} ${receiver}.${call};`, + call.slice(0, call.indexOf("(")), + ), + ).toEqual([false]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticObjectOperations.ts b/src/domain/javascript/javascriptSemanticObjectOperations.ts new file mode 100644 index 00000000000..2996116ca9c --- /dev/null +++ b/src/domain/javascript/javascriptSemanticObjectOperations.ts @@ -0,0 +1,99 @@ +import * as t from "@babel/types"; + +import { + javaScriptExpressionResultIsUsed, + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + isUnmodifiedGlobalReference, + semanticAmbientObjectMayHaveChangedAt, +} from "./javascriptSemanticAmbientWrites.js"; +import { + semanticConstructedReceiver, + semanticPrimitiveArguments, + semanticPristineArrayConstruction, + semanticPristineEmptyMapConstruction, +} from "./javascriptSemanticIntrinsicReceivers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import type { + JavaScriptBindingIndex, + JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; + +const PRISTINE_OBJECT_METHODS = new Set([ + "hasOwnProperty", + "propertyIsEnumerable", + "isPrototypeOf", + "toString", + "toLocaleString", + "valueOf", +]); + +/** Object prototype methods on proven fresh receivers cannot expose shared hooks. */ +export const isIntrinsicNonEscapingObjectCall = ( + node: t.CallExpression | t.OptionalCallExpression, + state: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => { + const callee = unwrapJavaScriptExpression(node.callee).node; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return false; + const method = semanticStaticPropertyName(callee.property, callee.computed); + if ( + !PRISTINE_OBJECT_METHODS.has(method ?? "") || + // valueOf returns the receiver; no returned-reference proof is added here. + (method === "valueOf" && + javaScriptExpressionResultIsUsed(node, state.parentsByNode)) || + // Property keys can coerce, and isPrototypeOf can walk a Proxy argument. + !semanticPrimitiveArguments(node.arguments, state) + ) + return false; + const receiver = semanticConstructedReceiver(callee.object, node, state, { + paths: [[]], + index, + }); + if (receiver === undefined) return false; + if ( + isUnmodifiedGlobalReference(receiver.construction.callee, state, "Array") + ) { + // Arrays override both string methods; their contracts belong to Array operations. + if ( + method === "toString" || + method === "toLocaleString" || + !semanticPristineArrayConstruction(receiver.construction, node, state) + ) + return false; + return index === undefined ? undefined : true; + } + if (isUnmodifiedGlobalReference(receiver.construction.callee, state, "Map")) { + if ( + !semanticPristineEmptyMapConstruction(receiver.construction, node, state) + ) + return false; + return index === undefined ? undefined : true; + } + if ( + !isUnmodifiedGlobalReference( + receiver.construction.callee, + state, + "Object", + ) || + receiver.construction.arguments.length !== 0 + ) + return false; + // Include argument evaluation and the whole prototype surface: toString + // reads Symbol.toStringTag, and toLocaleString invokes this.toString. + if ( + semanticAmbientObjectMayHaveChangedAt( + node, + state, + ["global", "Object", "prototype"], + node, + ) || + semanticOpaqueGlobalMayHaveChanged(node, state) + ) + return false; + // Direct constructions also wait for propagated effects and convergence. + return index === undefined ? undefined : true; +}; diff --git a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts index b4905f7bc23..fd31bfe799b 100644 --- a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts +++ b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts @@ -123,6 +123,27 @@ export const semanticOpaqueGlobalMayHaveChanged = ( (effect) => semanticEffectTiming(read, effect, state) !== "after", ); +/** + * A shared module object may be reached again in another activation, including + * through an inline require with no binding. Opaque code can mutate that object + * without an explicit write record. Only provably later effects are excluded; + * fresh-local capture rules do not apply to the shared identity. + */ +export const semanticSharedModuleMayHaveOpaqueEffect = ( + read: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (semanticOpaqueGlobalMayHaveChanged(read, state)) return true; + for (const sites of state.opaqueEffects.byScope.values()) + if ( + sites.some( + (effect) => semanticEffectTiming(read, effect, state) !== "after", + ) + ) + return true; + return false; +}; + /** Rebinding requires lexical visibility. Object references can remain reachable * through aliases even where the original name is shadowed or out of block scope. * Without a disjointness proof, consider their enclosing activation and closures. diff --git a/src/domain/javascript/javascriptSemanticReferenceSources.ts b/src/domain/javascript/javascriptSemanticReferenceSources.ts index 3a97d3a1570..f2f7c99ef0b 100644 --- a/src/domain/javascript/javascriptSemanticReferenceSources.ts +++ b/src/domain/javascript/javascriptSemanticReferenceSources.ts @@ -42,14 +42,17 @@ interface ReferenceSource { readonly iterationSources?: readonly ReferenceSource[]; } -/** Native array iteration preserves numeric slots only while its iterator is intact. */ +/** Native array iteration preserves numeric slots only while its iterator is intact at the read. */ export const semanticKnownArrayIteration = ( value: JavaScriptSemanticValue | undefined, state: JavaScriptSemanticAnalysisState, + read: t.Node, ): boolean => - !state.arrayIterationUnknown && value?.status === "array" && - value.items.every((item) => semanticArrayIndex(item.name) !== null); + value.items.every((item) => semanticArrayIndex(item.name) !== null) && + !state.arrayIterationEffects.some( + (effect) => semanticEffectTiming(read, effect, state) !== "after", + ); /** Invocation spreads use the same native-iteration proof at their source read. */ export const semanticKnownArrayIterationAt = ( @@ -59,6 +62,7 @@ export const semanticKnownArrayIterationAt = ( semanticKnownArrayIteration( evaluateSemanticExpression(node, state, node), state, + node, ); /** Numeric destructuring projections do not select slots of custom iterators. */ @@ -84,7 +88,7 @@ const selectedReferenceIterationKnown = ( // inventory can prove every possible nested iterable without treating // a computed object property or custom iterator as an own-index read. if ( - !semanticKnownArrayIteration(value, state) || + !semanticKnownArrayIteration(value, state, source.node) || value.status !== "array" || value.unknownItems ) @@ -101,7 +105,9 @@ const selectedReferenceIterationKnown = ( } values = [...selected]; } - return values.every((value) => semanticKnownArrayIteration(value, state)); + return values.every((value) => + semanticKnownArrayIteration(value, state, source.node), + ); }; /** Exclude references whose source slot or destructuring fallback is absent. */ diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 5a2dccebaf9..9d804861c10 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -8,6 +8,7 @@ import { } from "./javascriptSemanticProjectedSources.js"; import * as t from "@babel/types"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; +import { evaluateSemanticExpressionProvenance } from "./javascriptSemanticValues.js"; import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js"; import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; @@ -61,6 +62,7 @@ import { type JavaScriptRequireOrigin, } from "./javascriptSemanticRequireOriginSlot.js"; import { + createAmbientPathResolver, isUnmodifiedGlobalReference, isUnmodifiedImportMetaUrl, } from "./javascriptSemanticAmbientWrites.js"; @@ -690,10 +692,10 @@ const loaderInvocationInterops = new WeakMap< } >(); -const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => { - const prior = loaderInvocationInterops.get(state); - if (prior?.writes === state.ambientWriteEffects.length) return prior.interop; - const interop = createSemanticModuleInterop(state); +const retainAmbientInvocationWrites = ( + state: JavaScriptSemanticAnalysisState, + interop: ReturnType, +): void => { for (const effect of state.ambientWriteEffects) if (effect.path[0] === "global") interop.retainWrite(effect.node ?? state.program, [ @@ -704,6 +706,13 @@ const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => { key === null || typeof key === "object" ? null : String(key), ), ]); +}; + +const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => { + const prior = loaderInvocationInterops.get(state); + if (prior?.writes === state.ambientWriteEffects.length) return prior.interop; + const interop = createSemanticModuleInterop(state); + retainAmbientInvocationWrites(state, interop); loaderInvocationInterops.set(state, { writes: state.ambientWriteEffects.length, interop, @@ -1716,6 +1725,199 @@ export const semanticGlobalProcessPathOrigin = ( }; }; +const moduleAcquisitionAmbientReaders = new WeakMap< + JavaScriptSemanticAnalysisState, + ReturnType +>(); + +/** Candidate acquisition effects consume existing module and ambient selections. */ +const moduleAcquisitionGetterPath = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, +): readonly string[] | undefined => { + const getterPath = (path: readonly (string | null)[]) => + path[0] === "getBuiltinModule" && + path + .slice(1) + .every( + (member) => + member === "call" || member === "apply" || member === "bind", + ) + ? ["candidate-module", "process", ...(path as readonly string[])] + : undefined; + const chain = semanticMemberChain(callee); + if (chain?.root !== undefined && chain.root !== null) { + const origin = + semanticGlobalProcessOrigin(chain.root, state) ?? + semanticGlobalProcessPathOrigin(callee, state); + if (origin !== undefined) { + const path = getterPath( + origin.importedPath.length === 0 ? chain.members : origin.importedPath, + ); + if (path !== undefined) return path; + } + } + for (const origin of evaluateSemanticExpressionProvenance(callee, state) + .origins) { + if (origin.specifier.replace(/^node:/u, "") !== "process") continue; + const path = getterPath( + origin.resolutionMode !== "require" && + origin.importedPath[0] === "default" + ? origin.importedPath.slice(1) + : origin.importedPath, + ); + if (path !== undefined) return path; + } + const root = chain?.root; + if ( + !t.isIdentifier(root) || + resolveSemanticBindingState(state, root, root.name) === undefined + ) + return undefined; + let read = moduleAcquisitionAmbientReaders.get(state); + if (read === undefined) { + // Possible-effect mode selects aliases at each read without caching across + // mutation-index updates. It grants no native loader authority. + read = createAmbientPathResolver(state, true); + moduleAcquisitionAmbientReaders.set(state, read); + } + for (const path of read(callee, callee).paths) { + if (path[0] !== "global" || path[1] !== "process") continue; + const selected = getterPath(path.slice(2)); + if (selected !== undefined) return selected; + } + return undefined; +}; + +const moduleAcquisitionInvocationInterops = new WeakMap< + JavaScriptSemanticAnalysisState, + { + readonly writes: number; + readonly processEffects: number; + readonly interop: ReturnType; + } +>(); + +/** Normalize generic calls and existing bound captures at the invocation owner. */ +const moduleAcquisitionInvocationInterop = ( + state: JavaScriptSemanticAnalysisState, +) => { + const effects = state.requiredModuleEffects.get("process") ?? []; + const prior = moduleAcquisitionInvocationInterops.get(state); + if (prior?.writes === state.ambientWriteEffects.length) { + let helperWriteAdded = false; + for (let index = prior.processEffects; index < effects.length; index += 1) + if (effects[index]?.kind === "write") { + helperWriteAdded = true; + break; + } + // Ordinary calls append escapes. Do not repeatedly rescan their history or + // rebuild the invocation reader when no helper-write facts were added. + if (!helperWriteAdded) { + moduleAcquisitionInvocationInterops.set(state, { + ...prior, + processEffects: effects.length, + }); + return prior.interop; + } + } + const intrinsic = loaderInvocationInterop(state); + const interop = createSemanticModuleInterop(state, { + intrinsicPath: (node) => + moduleAcquisitionGetterPath(node, state) ?? intrinsic.intrinsicPath(node), + }); + retainAmbientInvocationWrites(state, interop); + // Replacing the candidate getter does not disprove its possible result. + // Only writes below that function affect generic helper argument mapping; + // otherwise even an earlier captured getter would lose its candidate path. + const retainGetterHelperWrite = ( + node: t.Node | undefined, + path: readonly unknown[], + ) => { + if (path.length < 2) return; + interop.retainWrite(node ?? state.program, [ + "candidate-module", + "process", + ...path.map((key) => + key === null || typeof key === "object" ? null : String(key), + ), + ]); + }; + for (const effect of state.ambientWriteEffects) + if ( + effect.path[0] === "global" && + effect.path[1] !== undefined && + semanticPropertyPathKeyMatches(effect.path[1], "process") + ) + retainGetterHelperWrite(effect.node, effect.path.slice(2)); + // Imported/default process objects share these existing mutation records. + // Invocation escapes are not helper replacements; retain actual writes only. + for (const effect of effects) + if (effect.kind === "write") + retainGetterHelperWrite(effect.node, effect.path); + moduleAcquisitionInvocationInterops.set(state, { + writes: state.ambientWriteEffects.length, + processEffects: effects.length, + interop, + }); + return interop; +}; + +/** + * Retain possible shared-module effects from inline module acquisitions. + * This reader is mutation-only: it must not grant loader or constructor proof. + */ +export const semanticModuleAcquisitionEffectOrigin = ( + node: t.Node | null | undefined, + state: JavaScriptSemanticAnalysisState, + awaitedDirectValue = false, +): JavaScriptModuleOrigin | undefined => { + const chain = semanticMemberChain(node); + if (chain === undefined) return undefined; + const call = chain.root; + const acquisition = t.isAwaitExpression(call) + ? unwrapJavaScriptExpression(call.argument).node + : awaitedDirectValue && chain.members.length === 0 + ? call + : undefined; + if (t.isImportExpression(acquisition)) { + const specifier = stringValue(acquisition.source); + return specifier === undefined + ? undefined + : { + specifier, + importedPath: chain.members, + resolutionMode: "import", + loaderAuthority: "candidate", + }; + } + // An unawaited import is a Promise, not the acquired module namespace. + if (!t.isCallExpression(call) && !t.isOptionalCallExpression(call)) + return undefined; + // A direct candidate getter never promised an unchanged native function. + // Keep that mutation-only contract; generic helpers additionally need their + // invocation semantics to determine which arguments reach the candidate. + const directPath = moduleAcquisitionGetterPath(call.callee, state); + const invocation = + directPath?.length === 3 + ? { kind: "known", path: directPath, arguments: call.arguments } + : moduleAcquisitionInvocationInterop(state).intrinsicInvocation(call); + if ( + invocation?.kind !== "known" || + invocation.path.join(".") !== "candidate-module.process.getBuiltinModule" + ) + return undefined; + const specifier = stringValue(invocation.arguments?.[0]); + return specifier === undefined + ? undefined + : { + specifier, + importedPath: chain.members, + resolutionMode: "require", + loaderAuthority: "candidate", + }; +}; + /** Check one unknown-host wrapper candidate at its source-ordered read. */ const isConditionalRequireAt = ( root: t.Identifier, @@ -1853,6 +2055,7 @@ const MUTABLE_BUILTIN_DEFAULTS = new Set([ "http", "https", "child_process", + "events", "timers", "process", "path", diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index 6062bb972cb..cc8f0072b1d 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -96,7 +96,7 @@ export const createSemanticReturnedReferences = ( { readonly result: SemanticReturnedReferenceResult; readonly invocation: t.Node | undefined; - readonly iterationUnknown: boolean; + readonly iterationEffects: number; readonly ambientWrites: number; readonly dependencies: readonly { readonly binding: JavaScriptSemanticBindingState; @@ -110,7 +110,7 @@ export const createSemanticReturnedReferences = ( if ( existing !== undefined && existing.invocation === invocation && - existing.iterationUnknown === state.arrayIterationUnknown && + existing.iterationEffects === state.arrayIterationEffects.length && existing.ambientWrites === state.ambientWriteEffects.length && existing.dependencies.every( ({ binding, writes, escapes }) => @@ -551,6 +551,7 @@ export const createSemanticReturnedReferences = ( source.capturedSelectionAt, )?.value, state, + source.capturedSelectionAt, ) ) projection = [null, ...source.path.slice(1)]; @@ -736,7 +737,7 @@ export const createSemanticReturnedReferences = ( results.set(callee, { result, invocation, - iterationUnknown: state.arrayIterationUnknown, + iterationEffects: state.arrayIterationEffects.length, ambientWrites: state.ambientWriteEffects.length, dependencies: [...dependencies].map(([binding, counts]) => ({ binding, diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index a7d2c008c4d..ae609044b91 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -151,7 +151,8 @@ export type SemanticPrototypeOwner = (typeof semanticPrototypeOwners)[number]; /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { readonly precision: JavaScriptSemanticPrecision; - arrayIterationUnknown: boolean; + /** Append-only sites classified by the native-array iteration effect owner. */ + readonly arrayIterationEffects: (t.Node | undefined)[]; /** Receiver isolation requires the completed member-effect inventory. */ memberMutationFactsComplete: boolean; readonly program: t.Program; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index f0ba4e2cf30..46ee1402824 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -114,6 +114,278 @@ it.each([ "Object.getPrototypeOf = () => Map.prototype; Object.getPrototypeOf({}).get = fetch; new Map().get('/accounts');", true, ], + [ + "const records = new Map(); records.get('/accounts'); const other = new Map(); consume(other);", + false, + ], + [ + "const records = new Map(); records.delete('/accounts'); const other = new Map(); consume(other);", + false, + ], + [ + "const records = new Map(); const other = new Map(); consume(other); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); consume(other); records.delete('/accounts');", + true, + ], + [ + "const records = new Map(); records.get('/accounts'); Map.prototype.get = fetch;", + false, + ], + [ + "const records = new Map(); Map.prototype.get = fetch; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); records.get('/accounts'); consume(Map.prototype);", + false, + ], + [ + "const records = new Map(); consume(Map.prototype); records.get('/accounts');", + true, + ], + [ + "const records = new Map([['x', 1]]); Map.prototype.set = replacement; records.get('/accounts');", + false, + ], + [ + "Map.prototype.set = replacement; const records = new Map([['x', 1]]); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); function later() { consume(Map.prototype); } records.get('/accounts');", + true, + ], + [ + "const records = new Map((Map.prototype.set = function () { this.get = fetch; return this; }, [['x', 1]])); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().set('x', 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); void new Map().set('x', 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const unused = (new Map().set('x', 1), 0); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); (0, new Map().set('x', 1)); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); void (0, new Map().set('x', 1)); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const unused = ((0, new Map().set('x', 1)), 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); (new Map().set('x', 1) as unknown)!; records.get('/accounts');", + false, + ], + [ + "const records = new Map(); for (new Map().set('x', 1); false;) {} records.get('/accounts');", + false, + ], + [ + "const records = new Map(); for (; false; (0, new Map().set('x', 1))) {} records.get('/accounts');", + false, + ], + [ + "const records = new Map(); void new Map().keys(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); flag ? new Map().set('x', 1) : 0; records.get('/accounts');", + false, + ], + [ + "const records = new Map(); flag ? 0 : new Map().set('x', 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); flag && new Map().set('x', 1); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); void ((flag ? (0, new Map().set('x', 1)) : 0) as unknown)!; records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const unused = ((flag && new Map().set('x', 1)), 0); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); new Map().set('x', 1) ? 0 : 1; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().set('x', 1) && 0; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const result = flag ? new Map().set('x', 1) : 0; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); void consume(flag && new Map().set('x', 1)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); void consume(new Map().set('x', 1)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const result = (0, new Map().set('x', 1)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); Map.prototype.set = patch; void new Map().set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); void new Map().set(records, 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); void new Map().set(...expose(records)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); other.set('x', 1); records.delete('/accounts');", + false, + ], + [ + "const records = new Map(); new Map().has('x'); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); consume(other.has('x')); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); new Map().clear(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); consume(other.clear()); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); Map.prototype.set = patch; new Map().set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); Map.prototype.has = patch; new Map().has('x'); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); Map.prototype.clear = patch; new Map().clear(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); other.set = patch; other.set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(Map.prototype); new Map().set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); eval('Map.prototype.set = patch'); new Map().set('x', 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(new Map().set('x', 1)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); const result = other.set('x', 1); consume(result); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().set('x', 1).constructor.prototype.get = fetch; records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map([['x', 1]]).forEach((value, key, map) => { map.constructor.prototype.get = fetch; }); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().set(records, 1); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().set(...expose(records)); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().keys(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); other.keys(); records.delete('/accounts');", + false, + ], + [ + "const records = new Map(); Map.prototype.keys = patch; new Map().keys(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); other.keys = patch; other.keys(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(new Map().keys()); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().values(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); other.values(); records.delete('/accounts');", + false, + ], + [ + "const records = new Map(); Map.prototype.values = patch; new Map().values(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); other.values = patch; other.values(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(new Map().values()); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); new Map().entries(); records.get('/accounts');", + false, + ], + [ + "const records = new Map(); const other = new Map(); other.entries(); records.delete('/accounts');", + false, + ], + [ + "const records = new Map(); Map.prototype.entries = patch; new Map().entries(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); const other = new Map(); other.entries = patch; other.entries(); records.get('/accounts');", + true, + ], + [ + "const records = new Map(); consume(new Map().entries()); records.get('/accounts');", + true, + ], ["client.get('/accounts');", true], ["eval('Map.prototype.get = fetch'); new Map().get('/accounts');", true], ] as const)( @@ -2245,3 +2517,143 @@ it.each([ ); }, ); + +it.each([ + [ + "an unchanged function-local receiver", + "function run(){const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');}", + true, + ], + [ + "an earlier constructor escape", + "const xhr=new XMLHttpRequest();mutate(xhr.constructor);xhr.open('GET','/xhr');", + false, + ], + [ + "an earlier cross-instance escape", + "const other=new XMLHttpRequest();const xhr=new XMLHttpRequest();mutate(other);xhr.open('GET','/xhr');", + false, + ], + [ + "a later constructor escape", + "const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(xhr.constructor);", + true, + ], + [ + "a later cross-instance escape", + "const other=new XMLHttpRequest();const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(other);", + true, + ], + [ + "an escaped method value", + "const xhr=new XMLHttpRequest();mutate(XMLHttpRequest.prototype.open);xhr.open('GET','/xhr');", + true, + ], + [ + "a shared escape in a possibly repeated activation", + "const other=new XMLHttpRequest();function run(){const xhr=new XMLHttpRequest();xhr.open('GET','/xhr');mutate(other);}", + false, + ], +])("orders XHR method selection against %s", (_label, source, observed) => { + const result = analyzeWebBundleFixture(inspection(source)); + expect(result.observations.endpoints.map(({ value }) => value)).toEqual( + observed ? ["/xhr"] : [], + ); + expect( + result.inferences + .filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value), + ).toEqual(observed ? [] : ["/xhr"]); +}); + +it.each([ + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open('GET', '/a');", + false, + ], + [ + "const b = new XMLHttpRequest(); new XMLHttpRequest().open('GET', '/a');", + false, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); const alias = a; alias.open('GET', '/a');", + false, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); const method = 'GET'; a.open(method, '/a', true); a.open('GET', '/again');", + false, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open('GET', '/a'); a.onreadystatechange = handler;", + false, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.onreadystatechange = handler; a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.addEventListener('readystatechange', handler); a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.send(); a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open('GET', { toString() { a.onreadystatechange = handler; return '/a'; } });", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open('GET', unknownUrl);", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open(...args);", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); a.open = custom; a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); XMLHttpRequest.prototype.open = custom; a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); XMLHttpRequest.prototype.onreadystatechange = handler; a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); configure(XMLHttpRequest.prototype); a.open('GET', '/a');", + true, + ], + [ + "const a = new XMLHttpRequest(), b = new XMLHttpRequest(); eval('a.onreadystatechange = handler'); a.open('GET', '/a');", + true, + ], +] as const)( + "keeps pristine XHR calls separate from potentially configured receivers: %s", + (setup, candidate) => { + const source = `${setup} b.open('GET', '/b');`; + const result = analyzeWebBundleFixture(inspection(source)); + expect( + result.observations.endpoints + .filter(({ value }) => value === "/b") + .map(({ value }) => value), + ).toEqual(candidate ? [] : ["/b"]); + expect( + result.inferences + .filter( + ({ kind, value }) => kind === "endpoint_candidate" && value === "/b", + ) + .map(({ value }) => value), + ).toEqual(candidate ? ["/b"] : []); + const endpoint = analyzeJavaScriptStaticSource(source).endpoints.find( + ({ value }) => value === "/b", + ); + expect(endpoint?.interpretation ?? null).toBe( + candidate ? "candidate" : null, + ); + expect(endpoint).toBeDefined(); + }, +);