diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 34ce40344..08b6202c1 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -330,7 +330,9 @@ helper simply returns its module parameter. Recognition uses structure rather than emitted names; other helper bodies and ambiguous runtime fallbacks remain unresolved. Captured function identities survive later global-property replacement. Visible intrinsic writes before capture or conversion are checked -in their owning callable scope. Arbitrary cross-call side effects are not +in their owning callable scope, including identifier leaves in nested/default/rest +destructuring and loop targets. Property keys and shadowed local bindings do not +overwrite the global intrinsic identity. Arbitrary cross-call side effects are not evaluated. Other reassignments, dynamic selections, and visibly overwritten namespace members are unresolved. Direct and destructuring assignment, loop, update, and delete targets are checked diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index fa9a85672..06d3be51f 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -194,6 +194,44 @@ describe("Electron module interop helper results", () => { }); }); +describe("Electron module interop pattern writes", () => { + it.each([ + "({Object} = replacements);", + "[Function] = replacements;", + "({nested:{Object}} = replacements);", + "({replacement:Object = customObject} = replacements);", + "({...Object} = replacements);", + "[...Function] = replacements;", + "for ({Object} of replacements) {}", + "for ([Function] in replacements) {}", + ])("rejects an intrinsic replaced through %s", (write) => { + const analysis = analyzeJavaScriptStaticSource(` + ${write} ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it.each([ + "({Object:unrelated} = replacements);", + "function decoy(Object) {({Object} = replacements);}", + ])("preserves intrinsic identity for an unrelated target: %s", (write) => { + const analysis = analyzeJavaScriptStaticSource(` + ${write} ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); + + it("preserves captured operations after later property destructuring", () => { + const analysis = analyzeJavaScriptStaticSource(` + ${ROLLDOWN_MODULE_INTEROP} ({create:Object.create} = replacements); + ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); +}); + describe("Electron module interop capture and scope", () => { it("does not reject unrelated intrinsic property definitions", () => { const analysis = analyzeJavaScriptStaticSource(` diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index f89eabb05..cb3a533fe 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -86,16 +86,20 @@ export const semanticObjectPatternKeys = ( return key === null ? [] : [key]; }); -/** Visit member write targets in direct and destructuring assignment forms. */ -export function* semanticMemberWriteTargets( +/** Visit binding and member leaves in direct and destructuring write targets. */ +export function* semanticWriteTargets( root: t.Node, -): Generator { +): Generator { const pending = [root]; while (pending.length > 0) { const next = pending.pop(); if (next === undefined) break; const node = unwrapJavaScriptExpression(next).node; - if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + if ( + t.isIdentifier(node) || + t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) + ) yield node; else if (t.isRestElement(node)) pending.push(node.argument); else if (t.isAssignmentPattern(node)) pending.push(node.left); diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 11015dae3..6264e769e 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -2,7 +2,7 @@ import * as t from "@babel/types"; import { semanticStaticPropertyKey, - semanticMemberWriteTargets, + semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; @@ -135,12 +135,13 @@ export function* collectJavaScriptIdentityWritesSteps( ? node.left : undefined; if (target === undefined) return; - if (t.isIdentifier(target)) { - const exported = origin(target, "binding"); - if (exported !== undefined) retain(node, exported); - } - for (const member of semanticMemberWriteTargets(target)) { - const { root, members } = electronMemberPath(member); + for (const leaf of semanticWriteTargets(target)) { + if (t.isIdentifier(leaf)) { + const exported = origin(leaf, "binding"); + if (exported !== undefined) retain(node, exported); + continue; + } + const { root, members } = electronMemberPath(leaf); const exported = origin(root, "member"); if (exported !== undefined) retain(node, [...exported, ...members]); } diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 2aef92460..8cfa44020 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -18,6 +18,7 @@ import { import { semanticObjectPatternKeys, semanticStaticPropertyKey, + semanticWriteTargets, } from "./javascriptAstValues.js"; import type { JavaScriptSemanticAnalysisState, @@ -634,10 +635,11 @@ function* collectDefinitionsSteps( for (const [index, { body, scope, parameters }] of functionBodies.entries()) { if (index % 64 === 0) yield; for (const parameter of parameters) - for (const identifier of assignedPatternIdentifiers( + for (const identifier of semanticWriteTargets( t.isTSParameterProperty(parameter) ? parameter.parameter : parameter, )) - copyParameterToBody({ identifier, body, scope, state }); + if (t.isIdentifier(identifier)) + copyParameterToBody({ identifier, body, scope, state }); } } @@ -785,16 +787,18 @@ const bindAssignment = ( state, ); else - for (const identifier of assignedPatternIdentifiers(node.left)) - addAssignment(identifier, node, scope, state); + for (const identifier of semanticWriteTargets(node.left)) + if (t.isIdentifier(identifier)) + addAssignment(identifier, node, scope, state); } else if (t.isUpdateExpression(node) && t.isIdentifier(node.argument)) addAssignment(node.argument, node, scope, state); else if (t.isForOfStatement(node) || t.isForInStatement(node)) for (const pattern of t.isVariableDeclaration(node.left) ? node.left.declarations.map(({ id }) => id) : [node.left]) { - for (const identifier of assignedPatternIdentifiers(pattern)) - addAssignment(identifier, node, scope, state); + for (const identifier of semanticWriteTargets(pattern)) + if (t.isIdentifier(identifier)) + addAssignment(identifier, node, scope, state); if (t.isForOfStatement(node)) // Keep the loop value unknown, but preserve references yielded into // each bound slot so writes/escapes can reach their iterable origins. @@ -809,30 +813,6 @@ const bindAssignment = ( } }; -const assignedPatternIdentifiers = ( - pattern: t.Node, -): readonly t.Identifier[] => { - const identifiers: t.Identifier[] = []; - const pending = [pattern]; - while (pending.length > 0) { - const node = pending.pop(); - if (node === undefined) continue; - if (t.isIdentifier(node)) identifiers.push(node); - else if (t.isRestElement(node)) pending.push(node.argument); - else if (t.isAssignmentPattern(node)) pending.push(node.left); - else if (t.isArrayPattern(node)) { - for (const element of node.elements) - if (element !== null) pending.push(element); - } else if (t.isObjectPattern(node)) { - for (const property of node.properties) - pending.push( - t.isRestElement(property) ? property.argument : property.value, - ); - } - } - return identifiers; -}; - const nestedScope = ( node: t.Node, parent: t.Node | null, diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 62df4c38c..35f3a0ca9 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -33,7 +33,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; import { semanticStaticPropertyKey, - semanticMemberWriteTargets, + semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -401,14 +401,15 @@ export function* collectSemanticMemberMutationsSteps( markEscaped(expression.object, mutation); }; const markTarget = (node: t.Node, mutation: t.Node): void => { - for (const target of semanticMemberWriteTargets(node)) - markValue( - target.object, - [semanticStaticPropertyKey(target.property, target.computed)], - new Set(), - "write", - mutation, - ); + for (const target of semanticWriteTargets(node)) + if (!t.isIdentifier(target)) + markValue( + target.object, + [semanticStaticPropertyKey(target.property, target.computed)], + new Set(), + "write", + mutation, + ); }; yield* traverseJavaScriptAstSteps(program, { enter: (node, parent) => { diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index 1e72a18b5..8104cde05 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -22,6 +22,15 @@ cliTest.for(["CLI", "stdio MCP"] as const)( join(root, "preload.js"), 'throw new Error("must stay inert");', ); + await writeFile( + join(root, "poisoned.cjs"), + `({Object} = replacements); + ${ROLLDOWN_MODULE_INTEROP} + let poisoned = require("electron"); + poisoned = __toESM(poisoned, 1); + new poisoned.BrowserWindow({}); + throw new Error("must not execute poisoned source");`, + ); const source = `${ROLLDOWN_MODULE_INTEROP} let electron = require("electron"); electron = __toESM(electron, 1); @@ -38,6 +47,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( const fake = __importStar(require("electron")); new fake.BrowserWindow({}); function decoy(electron) {new electron.BrowserWindow({});} + require("./poisoned.cjs"); throw new Error("must not execute main"); `; await writeFile(join(root, "main.cjs"), source); @@ -64,6 +74,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( preload_entrypoints: 1, }); expect(result.statistics.parse_failures).toBe(0); + expect(result.statistics.parsed_javascript_files).toBe(3); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", );