diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index a9f436a826..1ececa18af 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -73,8 +73,36 @@ describe("Electron module interop identity", () => { }); }); +const BOUND_INTRINSIC_MUTATIONS: [string, string][] = [ + [ + "intrinsic changed through aliased prebound arguments", + `const objectAlias = Object; const key = "create"; const define = Object.defineProperty; const mutate = define.bind(null, objectAlias, key); mutate({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed through nested prebinding", + `const first = Object.defineProperty.bind(null, Object); const second = first.bind(null, "create"); second({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "a replaced bound mutator bind method is unknown", + `const first = Object.defineProperty.bind(null, Object); first.bind = customBind; const second = first.bind(null, "create"); second({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed through a computed prebound call", + `const mutate = Object.defineProperty.bind(null, Object, "create"); mutate["call"](null, {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed through a computed prebound apply", + `const mutate = Object.defineProperty.bind(null, Object, "create"); mutate["apply"](null, [{value:customCreate}]); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "a replaced bind intrinsic makes a prebound mutator unknown", + `Function.prototype.bind = customBind; const mutate = Object.defineProperty.bind(null, Object, "create"); mutate({value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], +]; + describe("Electron module interop uncertainty", () => { it.each([ + ...BOUND_INTRINSIC_MUTATIONS, [ "intrinsic changed through an unresolved mutator target", `Object.defineProperty(getTarget(), "create", {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, @@ -547,3 +575,14 @@ describe("Electron module interop bound mutators", () => { ).toHaveLength(1); }); }); + +it("keeps unrelated exports after an aliased prebound key write", () => { + const analysis = analyzeJavaScriptStaticSource(` + const electron = require("electron"); + const key = "Unrelated"; + const mutate = Object.defineProperty.bind(null, electron, key); + mutate({value: custom}); + new electron.BrowserWindow({}); + `); + expect(analysis.electron.browser_windows).toHaveLength(1); +}); diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index a265b94fa4..ef3e7f22a6 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -6,7 +6,6 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; -import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; /** Match Electron's supported module entry points. */ export const ELECTRON_MODULE = @@ -60,6 +59,7 @@ export function* collectJavaScriptIdentityWritesSteps( intrinsicInvocation: ( node: t.CallExpression | t.OptionalCallExpression, ) => JavaScriptIntrinsicInvocation | undefined, + staticString: (node: t.Node | undefined) => string | undefined, freshObject: (node: t.Node) => boolean, retain: (node: t.Node, path: readonly (string | null)[]) => void, ): Generator { @@ -100,7 +100,7 @@ export function* collectJavaScriptIdentityWritesSteps( const exported = origin(root, "member"); if (exported !== undefined) { const key = keyedMutators.has(method ?? "") - ? [stringValue(arguments_?.[1]) ?? null] + ? [staticString(arguments_?.[1]) ?? null] : []; retain(node, [...exported, ...members, ...key]); } else if (!freshObject(target)) { diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index 32bab79f7c..f8eb6d2fdc 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -37,6 +37,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( node: t.CallExpression | t.OptionalCallExpression, state: JavaScriptSemanticAnalysisState, intrinsicPath: (node: t.Node) => readonly string[] | undefined, + boundIntrinsic: (node: t.Node) => JavaScriptIntrinsicInvocation | undefined, ): JavaScriptIntrinsicInvocation | undefined => { const visited = new Set(); let callee: t.Node = node.callee; @@ -70,23 +71,15 @@ export const resolveJavaScriptIntrinsicInvocation = ( continue; } } - if (t.isCallExpression(callee) || t.isOptionalCallExpression(callee)) { - const binder: t.Node = unwrapJavaScriptExpression(callee.callee).node; - if ( - (!t.isMemberExpression(binder) && - !t.isOptionalMemberExpression(binder)) || - semanticStaticPropertyKey(binder.property, binder.computed) !== "bind" - ) - return undefined; - const prefix = callee.arguments.slice(1); - arguments_ = t.isSpreadElement(callee.arguments[0]) - ? undefined - : prefix.length === 0 - ? arguments_ - : [...prefix, ...(arguments_ ?? [])]; - callee = binder.object; - continue; - } + const bound = boundIntrinsic(callee); + if (bound !== undefined) + return { + path: bound.path, + arguments: + bound.arguments === undefined + ? undefined + : [...bound.arguments, ...(arguments_ ?? [])], + }; if (!t.isIdentifier(callee)) return undefined; const binding = resolveSemanticBindingState(state, callee, callee.name); const initializer = semanticStableInitializer(binding, state); diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index 542fbbd392..bee2f43186 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -15,7 +15,10 @@ import { childNodes, javaScriptAstChildKeys, } from "./javascriptSemanticTraversal.js"; -import { electronMemberPath } from "./javascriptElectronMemberWrites.js"; +import { + electronMemberPath, + type JavaScriptIntrinsicInvocation, +} from "./javascriptElectronMemberWrites.js"; import { resolveJavaScriptIntrinsicInvocation } from "./javascriptIntrinsicInvocation.js"; import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; import { @@ -173,6 +176,109 @@ export const createSemanticModuleInterop = ( } }; + const staticString = (node: t.Node | undefined): string | undefined => { + if (node === undefined) return undefined; + const visited = new Set(); + let current = node; + for (;;) { + const literal = readExactJavaScriptLiteral(current); + if (literal.found) + return typeof literal.value === "string" ? literal.value : undefined; + const expression = unwrapJavaScriptExpression(current).node; + if (!t.isIdentifier(expression)) return undefined; + const binding = resolveSemanticBindingState( + state, + expression, + expression.name, + ); + const initializer = semanticStableInitializer(binding, state); + if ( + binding === undefined || + initializer === undefined || + initializer.projection.length !== 0 || + (initializer.node.end ?? Infinity) >= (expression.start ?? -1) || + visited.has(binding.bindingId) + ) + return undefined; + visited.add(binding.bindingId); + current = initializer.node; + } + }; + + const boundIntrinsic = ( + node: t.Node, + ): JavaScriptIntrinsicInvocation | undefined => { + const visited = new Set(); + const argumentGroups: t.CallExpression["arguments"][] = []; + const bindReads: t.Node[] = []; + let opaqueArguments = false; + let current = node; + for (;;) { + current = unwrapJavaScriptExpression(current).node; + if (t.isIdentifier(current)) { + const binding = resolveSemanticBindingState( + state, + current, + current.name, + ); + const initializer = semanticStableInitializer(binding, state); + if ( + binding === undefined || + initializer === undefined || + initializer.projection.length !== 0 || + (initializer.node.end ?? Infinity) >= (current.start ?? -1) || + visited.has(binding.bindingId) + ) + return undefined; + visited.add(binding.bindingId); + current = initializer.node; + continue; + } + if (!t.isCallExpression(current)) return undefined; + const { root: bindTarget, members } = electronMemberPath(current.callee); + if (members.at(-1) !== "bind") return undefined; + const captured = capturedIntrinsic(current.callee); + const terminal = captured?.path.at(-1) === "bind"; + // Only a direct .bind can continue a previously bound callable. Keeping + // intervening members would require a separate proof of their identity. + if (!terminal && members.length !== 1) return undefined; + bindReads.push(captured?.capturedAt ?? current.callee); + const thisArgument = current.arguments[0]; + const ownArguments = current.arguments.slice(1); + opaqueArguments ||= + thisArgument !== undefined && + !readExactJavaScriptLiteral(thisArgument).found; + opaqueArguments ||= ownArguments.some((argument) => + t.isSpreadElement(argument), + ); + argumentGroups.push(ownArguments); + if (!terminal || captured === undefined) { + current = bindTarget; + continue; + } + const path = captured.path.slice(0, -1); + const boundPath = [...path, "bind"]; + const functionBind = ["intrinsic", "Function", "prototype", "bind"]; + opaqueArguments ||= bindReads.some((capture) => { + const scope = opaqueMutationPath(capture).join("."); + return writes.some( + (write) => + (write.scope === scope || write.scope === "intrinsic") && + write.offset < (capture.start ?? -1) && + (write.coverage.covers(boundPath) || + write.coverage.covers(functionBind)), + ); + }); + return { + path, + // Rebinding appends arguments to the earlier bound argument list. + arguments: opaqueArguments + ? undefined + : argumentGroups.toReversed().flat(), + }; + } + }; + const isFreshObject = (node: t.Node): boolean => { const visited = new Set(); let current = node; @@ -443,7 +549,14 @@ export const createSemanticModuleInterop = ( callOrigin, intrinsicPath, intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) => - resolveJavaScriptIntrinsicInvocation(node, state, intrinsicPath), + resolveJavaScriptIntrinsicInvocation( + node, + state, + intrinsicPath, + boundIntrinsic, + ), + boundIntrinsic, + staticString, isFreshObject, opaqueMutationPath, retainWrite: (node: t.Node, path: readonly (string | null)[]) => { diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 6c07340cd2..517ed58364 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -407,10 +407,14 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( if (kind === "opaque-call") return interop.opaqueMutationPath(root); const exported = kind === "binding" ? undefined : rootExport(root); return exported === undefined - ? interop.intrinsicPath(root) + ? (interop.intrinsicPath(root) ?? + (kind === "member" + ? interop.boundIntrinsic(root)?.path + : undefined)) : ["electron", ...exported]; }, interop.intrinsicInvocation, + interop.staticString, interop.isFreshObject, (node, path) => { if (path[0] === "electron") writes.retain(path);