diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 08b6202c13..638cf8fbf6 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -335,6 +335,10 @@ 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. +Known Object/Reflect mutators remain recognizable through literal destructuring +of intrinsic objects. Stable identifier chains to object/array literals keep those +targets separate from intrinsic objects when initialized before use; opaque, +reassigned or not-yet-initialized targets remain unresolved. Direct and destructuring assignment, loop, update, and delete targets are checked through namespace aliases; unrelated member writes do not discard observed API identity. This static check does diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index 06d3be51f7..653668d009 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -233,6 +233,48 @@ describe("Electron module interop pattern writes", () => { }); describe("Electron module interop capture and scope", () => { + it.each([ + 'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});', + "const objectAlias = Object; const {assign:change} = objectAlias; change(Object,{create:customCreate});", + 'const {set:change} = Reflect; change(Object,"create",customCreate);', + 'const {Object:{defineProperty:change}} = globalThis; change(Object,"create",{value:customCreate});', + ])("rejects a projected intrinsic mutator alias: %s", (write) => { + const source = `${write} ${ROLLDOWN_MODULE_INTEROP} + ${IMPORT} ${WRAP} ${WINDOW} + `.replaceAll("__toESM", "namespaceOf"); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it.each([ + "const metadata = {};", + "const metadata = [];", + "const initial = {}; const metadata = initial;", + ])( + "keeps a fresh mutator target separate from intrinsic identity: %s", + (setup) => { + const analysis = analyzeJavaScriptStaticSource(` + ${setup} Object.defineProperty(metadata,"x",{value:1}); + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }, + ); + + it.each([ + "const metadata = getMetadata();", + "let metadata = {}; metadata = Object;", + "Object.defineProperty(metadata,'x',{value:1}); const metadata = {};", + "const metadata = {intrinsic:Object}; Object.defineProperty(metadata.intrinsic,'create',{value:customCreate});", + ])("keeps an unproven mutator target unresolved: %s", (setup) => { + const analysis = analyzeJavaScriptStaticSource(` + ${setup} Object.defineProperty(metadata,"x",{value:1}); + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); + it("does not reject unrelated intrinsic property definitions", () => { const analysis = analyzeJavaScriptStaticSource(` Object.defineProperty(Object, "unrelated", {value:1}); diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 6264e769e2..c5439a20a8 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -50,6 +50,7 @@ export function* collectJavaScriptIdentityWritesSteps( kind: "binding" | "member" | "opaque-call", ) => readonly string[] | undefined, intrinsic: (node: t.Node) => readonly string[] | undefined, + freshObject: (node: t.Node) => boolean, retain: (node: t.Node, path: readonly (string | null)[]) => void, ): Generator { const objectMutators = new Set([ @@ -115,10 +116,7 @@ export function* collectJavaScriptIdentityWritesSteps( ? [stringValue(arguments_[1]) ?? null] : []; retain(node, [...exported, ...members, ...key]); - } else if ( - !t.isObjectExpression(target) && - !t.isArrayExpression(target) - ) { + } else if (!freshObject(target)) { const scope = origin(node, "opaque-call"); if (scope !== undefined) retain(node, scope); } diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index b24da41a12..73c13c4127 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -16,6 +16,7 @@ import { } from "./javascriptSemanticTraversal.js"; import { electronMemberPath } from "./javascriptElectronMemberWrites.js"; import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; // Match the complete Rolldown runtime operations, including its live getters. // Names and formatting are irrelevant; lexical roles, operators and keys are not. @@ -121,15 +122,39 @@ export const createSemanticModuleInterop = ( : intrinsicBindings.get(binding.bindingId); if (cached !== undefined) return finish(cached === null ? undefined : [...cached, ...suffix]); - const initializer = stableInitializer(binding, state); + const initializer = stableBindingInitializer(binding, state); if ( binding === undefined || initializer === undefined || + initializer.projection.some((segment) => segment === null) || visited.has(binding.bindingId) ) return finish(undefined); visited.add(binding.bindingId); pending.push({ id: binding.bindingId, suffixLength: suffix.length }); + suffix = [...initializer.projection.map(String), ...suffix]; + current = initializer.node; + } + }; + + const isFreshObject = (node: t.Node): boolean => { + const visited = new Set(); + let current = node; + for (;;) { + current = unwrapJavaScriptExpression(current).node; + if (t.isObjectExpression(current) || t.isArrayExpression(current)) + return true; + if (!t.isIdentifier(current)) return false; + const binding = resolveSemanticBindingState(state, current, current.name); + const initializer = stableInitializer(binding, state); + if ( + binding === undefined || + initializer === undefined || + (initializer.end ?? Infinity) >= (current.start ?? -1) || + visited.has(binding.bindingId) + ) + return false; + visited.add(binding.bindingId); current = initializer; } }; @@ -330,6 +355,7 @@ export const createSemanticModuleInterop = ( origin, callOrigin, intrinsicPath, + isFreshObject, opaqueMutationPath, retainWrite: (node: t.Node, path: readonly (string | null)[]) => { const coverage = new SemanticPropertyPathCoverage(); @@ -388,14 +414,21 @@ const stableInitializer = ( binding: JavaScriptSemanticBindingState | undefined, state: JavaScriptSemanticAnalysisState, ): t.Node | undefined => { + const initializer = stableBindingInitializer(binding, state); + return initializer?.projection.length === 0 ? initializer.node : undefined; +}; + +const stableBindingInitializer = ( + binding: JavaScriptSemanticBindingState | undefined, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticBindingState["initializers"][number] | undefined => { const [initializer] = binding?.initializers ?? []; return binding !== undefined && binding.definitions.length === 1 && binding.initializers.length === 1 && initializer !== undefined && - initializer.projection.length === 0 && !state.conditionalInitializers.has(initializer.node) - ? initializer.node + ? initializer : undefined; }; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 8cfa440205..50342f6295 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -413,6 +413,7 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( : ["electron", ...exported]; }, interop.intrinsicPath, + interop.isFreshObject, (node, path) => { if (path[0] === "electron") writes.retain(path); else interop.retainWrite(node, path); diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index 8104cde05f..758b5ef115 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -22,16 +22,25 @@ 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); + for (const [name, mutation] of [ + ["poisoned.cjs", "({Object} = replacements);"], + [ + "projected.cjs", + 'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});', + ], + ] as const) + await writeFile( + join(root, name), + `${mutation} ${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} + ); + const source = `const metadata = {}; + Object.defineProperty(metadata,"x",{value:1}); + ${ROLLDOWN_MODULE_INTEROP} let electron = require("electron"); electron = __toESM(electron, 1); new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}}); @@ -48,6 +57,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( new fake.BrowserWindow({}); function decoy(electron) {new electron.BrowserWindow({});} require("./poisoned.cjs"); + require("./projected.cjs"); throw new Error("must not execute main"); `; await writeFile(join(root, "main.cjs"), source); @@ -74,7 +84,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); + expect(result.statistics.parsed_javascript_files).toBe(4); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", );