From ebaa639cf85cb26da47146564c4bfbde862ec1f0 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Sun, 11 Oct 2026 13:59:19 +0000 Subject: [PATCH] fix(javascript): prove Electron module namespace conversion --- .../javascript/electronModuleInterop.test.ts | 216 ++++++++++ .../javascriptElectronMemberWrites.ts | 98 ++++- .../javascriptModuleInterop.fixture.ts | 24 ++ .../javascript/javascriptModuleInterop.ts | 371 ++++++++++++++++++ .../javascript/javascriptSemanticAnalysis.ts | 44 ++- .../electronModuleInteropCli.test.ts | 88 +++++ 6 files changed, 822 insertions(+), 19 deletions(-) create mode 100644 src/domain/javascript/electronModuleInterop.test.ts create mode 100644 src/domain/javascript/javascriptModuleInterop.fixture.ts create mode 100644 src/domain/javascript/javascriptModuleInterop.ts create mode 100644 tests/acceptance/applications/electronModuleInteropCli.test.ts diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts new file mode 100644 index 0000000000..fc7b5ef793 --- /dev/null +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { ROLLDOWN_MODULE_INTEROP } from "./javascriptModuleInterop.fixture.js"; + +const WINDOW = + 'new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}});'; +const IMPORT = 'let electron = require("electron");'; +const WRAP = "electron = __toESM(electron, 1);"; + +describe("Electron module interop identity", () => { + it("preserves named APIs through the released Rolldown namespace conversion", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + ${IMPORT} ${WRAP} ${WINDOW} + new electron.BrowserWindow({webPreferences:{preload:runtimePath}}); + electron.ipcMain.handle("ping", () => "pong"); + electron.contextBridge.exposeInMainWorld("api", {ping:true}); + `); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + { preload_path: null }, + ]); + expect(analysis.electron.ipc).toMatchObject([ + { channel: "ping", side: "main" }, + ]); + expect(analysis.electron.context_bridge_apis).toMatchObject([ + { api_key: "api" }, + ]); + }); + + it("follows lexical identities when helper and parameter names change", () => { + const source = `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}` + .replaceAll("__toESM", "namespaceOf") + .replaceAll("__copyProps", "copyNamed") + .replaceAll("mod", "inputModule") + .replaceAll("isNodeMode", "nodeMode") + .replaceAll("target", "namespaceTarget") + .replaceAll("from", "sourceObject"); + expect( + analyzeJavaScriptStaticSource(source).electron.browser_windows, + ).toMatchObject([{ preload_path: "./preload.js" }]); + }); +}); + +describe("Electron module interop uncertainty", () => { + it.each([ + [ + "intrinsic changed through an unresolved mutator target", + `Object.defineProperty(getTarget(), "create", {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via dynamic apply", + `const args = [Object, "getOwnPropertyNames", {value:() => []}]; Object.defineProperty.apply(null, args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via spread call", + `const args = [Object, "getOwnPropertyNames", {value:() => []}]; Object.defineProperty.call(null, ...args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via Reflect.apply", + `const args = [Object, "getOwnPropertyNames", {value:() => []}]; Reflect.apply(Object.defineProperty, null, args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via spread Reflect.apply", + `const args = [null, [Object, "create", {value:customCreate}]]; Reflect.apply(Object.defineProperty, ...args); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via call", + `Object.defineProperty.call(null, Object, "getOwnPropertyNames", {value:() => []}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed via apply", + `Object.defineProperty.apply(null, [Object, "getOwnPropertyNames", {value:() => []}]); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with defineProperty", + `Object.defineProperty(Object, "getOwnPropertyNames", {value:() => []}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with Reflect.set", + `Reflect.set(Object, "getOwnPropertyNames", () => []); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with an aliased mutator", + `const change = Object.defineProperty; change(Object, "create", {value:customCreate}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with assign", + `Object.assign(Object, {getOwnPropertyNames:() => []}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "intrinsic changed with defineProperties", + `Object.defineProperties(Function.prototype, {bind:{value:customBind}}); ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten Object global", + `Object = customObject; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten intrinsic through an alias", + `const objectAlias = Object; objectAlias.create = customCreate; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten intrinsic through globalThis", + `globalThis.Object.create = customCreate; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "helper called before initialization", + `${IMPORT} ${WRAP} ${ROLLDOWN_MODULE_INTEROP}`, + ], + [ + "fake helper", + `${ROLLDOWN_MODULE_INTEROP}\n__toESM = (mod) => ({BrowserWindow:Fake}); ${IMPORT} ${WRAP}`, + ], + [ + "changed getter", + `${ROLLDOWN_MODULE_INTEROP.replace("from[k]", "to[k]")}\n${IMPORT} ${WRAP}`, + ], + [ + "replaced copy helper", + `${ROLLDOWN_MODULE_INTEROP}\n__copyProps = () => ({}); ${IMPORT} ${WRAP}`, + ], + [ + "replaced intrinsic alias", + `${ROLLDOWN_MODULE_INTEROP}\n__defProp = () => ({}); ${IMPORT} ${WRAP}`, + ], + [ + "shadowed Object", + `const Object = customObject; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten intrinsic", + `Object.create = customCreate; ${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP}`, + ], + [ + "overwritten alias call", + `${ROLLDOWN_MODULE_INTEROP}\n__hasOwnProp.call = customCall; ${IMPORT} ${WRAP}`, + ], + [ + "overwritten Function bind", + `${ROLLDOWN_MODULE_INTEROP}\nFunction.prototype.bind = customBind; ${IMPORT} ${WRAP}`, + ], + [ + "conditional conversion", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} if (flag) { ${WRAP} }`, + ], + [ + "compound assignment", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} electron ||= __toESM(electron, 1);`, + ], + [ + "other input", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} electron = __toESM(other, 1);`, + ], + [ + "later overwrite", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} electron = unrelated;`, + ], + [ + "overwritten constructor", + `${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} electron.BrowserWindow = Fake;`, + ], + ])("does not infer Electron identity after %s", (_name, setup) => { + expect( + analyzeJavaScriptStaticSource(`${setup}\n${WINDOW}`).electron + .browser_windows, + ).toEqual([]); + }); +}); + +describe("Electron module interop capture and scope", () => { + it("does not reject unrelated intrinsic property definitions", () => { + const analysis = analyzeJavaScriptStaticSource(` + Object.defineProperty(Object, "unrelated", {value:1}); + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); + + it("keeps opaque targets in a generic helper separate from outer module identity", () => { + const analysis = analyzeJavaScriptStaticSource(` + function genericAssign() { Object.assign.apply(null, arguments); } + ${ROLLDOWN_MODULE_INTEROP} ${IMPORT} ${WRAP} ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); + + it("rejects an opaque intrinsic mutation in the namespace's own callable", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + function openWindow(args) { + Object.defineProperty.apply(null, args); + ${IMPORT} ${WRAP} ${WINDOW} + } + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it("preserves captured intrinsics across later vendor polyfills and callable writes", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + Object.create = customCreate; + ${IMPORT} ${WRAP} + function runLoop(key) { globalThis[key] = this; } + function polyfill() { Object.create = customCreate; } + ${WINDOW} + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); + + it("keeps a shadowed namespace separate from the converted module", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + ${IMPORT} ${WRAP} ${WINDOW} + function decoy(electron) { ${WINDOW} } + `); + expect(analysis.electron.browser_windows).toHaveLength(1); + }); +}); diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index b8cd3839bd..11015dae3e 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -5,8 +5,8 @@ import { semanticMemberWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; +import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; /** Match Electron's supported module entry points. */ export const ELECTRON_MODULE = @@ -24,7 +24,7 @@ export const electronExportPath = ( /** Static identity recovery cannot establish the absence of Electron usage. */ export const ELECTRON_IDENTITY_LIMITATION = - "Electron API identity follows proven lexical module aliases. Reassigned bindings, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated."; + "Electron API identity follows proven lexical module aliases and structurally verified module-namespace interop. Other reassignments, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated."; /** Preserve exact property segments instead of flattening literal dotted keys. */ export const electronMemberPath = ( @@ -42,14 +42,89 @@ export const electronMemberPath = ( return { root, members: members.toReversed() }; }; -/** Index visible writes through proven Electron aliases, without executing them. */ -export function* collectElectronMemberWritesSteps( +/** Index member and binding writes through caller-proven lexical identities. */ +export function* collectJavaScriptIdentityWritesSteps( program: t.Program, - origin: (root: t.Node) => readonly string[] | undefined, -): Generator { - const writes = new SemanticPropertyPathCoverage(); + origin: ( + root: t.Node, + kind: "binding" | "member" | "opaque-call", + ) => readonly string[] | undefined, + intrinsic: (node: t.Node) => readonly string[] | undefined, + retain: (node: t.Node, path: readonly (string | null)[]) => void, +): Generator { + const objectMutators = new Set([ + "defineProperty", + "defineProperties", + "assign", + "setPrototypeOf", + ]); + const reflectMutators = new Set([ + "set", + "deleteProperty", + "defineProperty", + "setPrototypeOf", + ]); + const keyedMutators = new Set(["set", "deleteProperty", "defineProperty"]); yield* traverseJavaScriptAstSteps(program, { enter: (node) => { + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { + let callee = intrinsic(node.callee); + let arguments_ = node.arguments; + let applied: t.Node | undefined; + let usesApply = false; + if (callee?.join(".") === "intrinsic.Reflect.apply") { + usesApply = true; + const callable = arguments_[0]; + callee = t.isNode(callable) ? intrinsic(callable) : undefined; + applied = arguments_[2]; + } + if (callee?.at(-1) === "call") { + callee = callee.slice(0, -1); + arguments_ = arguments_.slice(1); + } else if (callee?.at(-1) === "apply") { + usesApply = true; + callee = callee.slice(0, -1); + applied = arguments_[1]; + } + if (usesApply) + arguments_ = + t.isArrayExpression(applied) && + applied.elements.every((element) => t.isExpression(element)) + ? applied.elements.filter((element): element is t.Expression => + t.isExpression(element), + ) + : []; + const method = callee?.at(-1); + const owner = callee?.length === 3 ? callee[1] : undefined; + if ( + (owner === "Object" && objectMutators.has(method ?? "")) || + (owner === "Reflect" && reflectMutators.has(method ?? "")) + ) { + const target = arguments_[0]; + // A known mutator with an opaque argument list can overwrite any + // captured intrinsic. Do not turn an unexamined target into proof. + if (target === undefined || t.isSpreadElement(target)) { + const scope = origin(node, "opaque-call"); + if (scope !== undefined) retain(node, scope); + } + if (t.isNode(target) && !t.isSpreadElement(target)) { + const { root, members } = electronMemberPath(target); + const exported = origin(root, "member"); + if (exported !== undefined) { + const key = keyedMutators.has(method ?? "") + ? [stringValue(arguments_[1]) ?? null] + : []; + retain(node, [...exported, ...members, ...key]); + } else if ( + !t.isObjectExpression(target) && + !t.isArrayExpression(target) + ) { + const scope = origin(node, "opaque-call"); + if (scope !== undefined) retain(node, scope); + } + } + } + } const target = t.isAssignmentExpression(node) ? node.left : t.isUpdateExpression(node) @@ -60,12 +135,15 @@ export function* collectElectronMemberWritesSteps( ? 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); - const exported = origin(root); - if (exported !== undefined) writes.retain([...exported, ...members]); + const exported = origin(root, "member"); + if (exported !== undefined) retain(node, [...exported, ...members]); } }, }); - return writes; } diff --git a/src/domain/javascript/javascriptModuleInterop.fixture.ts b/src/domain/javascript/javascriptModuleInterop.fixture.ts new file mode 100644 index 0000000000..1ed97af906 --- /dev/null +++ b/src/domain/javascript/javascriptModuleInterop.fixture.ts @@ -0,0 +1,24 @@ +// Rolldown runtime from the public T3 Code nightly 20261011.2967 main.cjs. +// The input is inert source: none of these helpers or Electron APIs is executed. +export const ROLLDOWN_MODULE_INTEROP = ` +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { + key = keys[i]; + if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { + get: ((k) => from[k]).bind(null, key), + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable + }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", { + value: mod, + enumerable: true +}) : target, mod)); +`; diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts new file mode 100644 index 0000000000..3e8f300c7f --- /dev/null +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -0,0 +1,371 @@ +import { parseExpression } from "@babel/parser"; +import * as t from "@babel/types"; + +import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; +import { + isUnshadowedGlobal, + resolveSemanticBindingState, + semanticScopeId, + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, +} from "./javascriptSemanticState.js"; +import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; +import { childNodes } from "./javascriptSemanticTraversal.js"; +import { electronMemberPath } from "./javascriptElectronMemberWrites.js"; +import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; + +// Match the complete Rolldown runtime operations, including its live getters. +// Names and formatting are irrelevant; lexical roles, operators and keys are not. +const TO_ESM = parseExpression(`(mod, isNodeMode, target) => ( + target = mod != null ? __create(__getProtoOf(mod)) : {}, + __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") + ? __defProp(target, "default", {value: mod, enumerable: true}) : target, mod) +)`); +const COPY_PROPS = parseExpression(`(to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") + for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { + key = keys[i]; + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { + get: ((k) => from[k]).bind(null, key), + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable + }); + } + return to; +}`); +const INTRINSICS: Readonly> = { + __create: ["Object", "create"], + __getProtoOf: ["Object", "getPrototypeOf"], + __defProp: ["Object", "defineProperty"], + __getOwnPropNames: ["Object", "getOwnPropertyNames"], + __getOwnPropDesc: ["Object", "getOwnPropertyDescriptor"], + __hasOwnProp: ["Object", "prototype", "hasOwnProperty"], +}; + +interface IntrinsicDependency { + readonly path: readonly string[]; + readonly capturedAt?: t.Node; +} + +interface InteropOrigin { + readonly origin: JavaScriptModuleOrigin; + readonly dependencies: readonly IntrinsicDependency[]; +} + +interface InteropHelper { + readonly dependencies: readonly IntrinsicDependency[]; + readonly readyAfter: number; +} + +/** Prove one inert namespace conversion without relaxing general assignment semantics. */ +export const createSemanticModuleInterop = ( + state: JavaScriptSemanticAnalysisState, +) => { + const helpers = new Map(); + const origins = new Map(); + const intrinsicBindings = new Map(); + const writes: { + readonly scope: string; + readonly offset: number; + readonly coverage: SemanticPropertyPathCoverage; + }[] = []; + const invalidatedBindings = new Map(); + const opaqueMutationPath = (node: t.Node): readonly string[] => { + let current: t.Node | undefined = node; + while (current !== undefined) { + if (t.isFunction(current)) + return ["opaque-intrinsic", semanticScopeId("function", current)]; + current = state.parentsByNode.get(current); + } + return ["intrinsic"]; + }; + + const intrinsicPath = (node: t.Node): readonly string[] | undefined => { + let current = node; + const visited = new Set(); + const pending: { id: string; suffixLength: number }[] = []; + let suffix: readonly string[] = []; + const finish = ( + path: readonly string[] | undefined, + ): readonly string[] | undefined => { + for (const binding of pending) + intrinsicBindings.set( + binding.id, + path === undefined + ? null + : path.slice(0, path.length - binding.suffixLength), + ); + return path; + }; + for (;;) { + const { root, members } = electronMemberPath(current); + if (members.some((member) => member === null)) return finish(undefined); + suffix = [...(members as readonly string[]), ...suffix]; + for (const global of ["Object", "Function", "Reflect"]) + if (isUnshadowedGlobal(root, state, global)) + return finish(["intrinsic", global, ...suffix]); + for (const global of ["globalThis", "global", "window", "self"]) + if (isUnshadowedGlobal(root, state, global)) + return finish(["intrinsic", ...suffix]); + if (!t.isIdentifier(root)) return finish(undefined); + const binding = resolveSemanticBindingState(state, root, root.name); + const cached = + binding === undefined + ? undefined + : intrinsicBindings.get(binding.bindingId); + if (cached !== undefined) + return finish(cached === null ? undefined : [...cached, ...suffix]); + const initializer = stableInitializer(binding, state); + if ( + binding === undefined || + initializer === undefined || + visited.has(binding.bindingId) + ) + return finish(undefined); + visited.add(binding.bindingId); + pending.push({ id: binding.bindingId, suffixLength: suffix.length }); + current = initializer; + } + }; + + const helperProof = ( + binding: JavaScriptSemanticBindingState, + ): InteropHelper | undefined => { + const cached = helpers.get(binding.bindingId); + if (cached !== undefined) return cached ?? undefined; + helpers.set(binding.bindingId, null); + const wrapper = stableInitializer(binding, state); + if (wrapper === undefined) return undefined; + const wrapperRoles = canonicalRoles(wrapper, TO_ESM, state); + const copyBinding = wrapperRoles?.get("__copyProps"); + const copy = stableInitializer(copyBinding, state); + const copyRoles = + copy === undefined ? undefined : canonicalRoles(copy, COPY_PROPS, state); + if ( + wrapperRoles === undefined || + copyRoles === undefined || + copy === undefined + ) + return undefined; + const dependencies: IntrinsicDependency[] = []; + for (const roles of [wrapperRoles, copyRoles]) + for (const [role, dependency] of roles) { + const expected = INTRINSICS[role]; + if (expected === undefined) continue; + const initializer = stableInitializer(dependency, state); + const path = + initializer === undefined ? undefined : intrinsicPath(initializer); + if ( + initializer === undefined || + path === undefined || + JSON.stringify(path) !== JSON.stringify(["intrinsic", ...expected]) + ) + return undefined; + dependencies.push({ path, capturedAt: initializer }); + if (role === "__hasOwnProp") + dependencies.push({ path: [...path, "call"] }); + } + dependencies.push( + { path: ["intrinsic", "Function", "prototype", "bind"] }, + { path: ["intrinsic", "Function", "prototype", "call"] }, + ); + const proof = { + dependencies, + readyAfter: Math.max( + wrapper.end ?? Infinity, + copy.end ?? Infinity, + ...dependencies.map(({ capturedAt }) => capturedAt?.end ?? -1), + ), + }; + helpers.set(binding.bindingId, proof); + return proof; + }; + + const origin = ( + binding: JavaScriptSemanticBindingState, + ): InteropOrigin | undefined => { + const [initial, converted] = binding.initializers; + if ( + binding.definitions.length !== 2 || + binding.definitions[0]?.kind !== "variable" || + binding.definitions[1]?.kind !== "assignment" || + binding.initializers.length !== 2 || + initial === undefined || + converted === undefined || + initial.projection.length !== 0 || + converted.projection.length !== 0 || + state.conditionalInitializers.has(initial.node) + ) + return undefined; + const call = converted.node; + const assignment = state.parentsByNode.get(call); + const statement = + assignment === undefined + ? undefined + : state.parentsByNode.get(assignment); + const declaration = state.parentsByNode.get(initial.node); + const declarationStatement = + declaration === undefined + ? undefined + : state.parentsByNode.get(declaration); + if ( + !t.isCallExpression(call) || + !t.isIdentifier(call.callee) || + !t.isAssignmentExpression(assignment, { operator: "=" }) || + assignment.right !== call || + !t.isIdentifier(assignment.left, { name: binding.name }) || + !t.isExpressionStatement(statement) || + !t.isVariableDeclarator(declaration) || + !t.isVariableDeclaration(declarationStatement) || + state.parentsByNode.get(statement) !== + state.parentsByNode.get(declarationStatement) || + (initial.node.start ?? Infinity) >= (call.start ?? -1) || + call.arguments.length !== 2 || + !t.isNumericLiteral(call.arguments[1], { value: 1 }) + ) + return undefined; + const input = call.arguments[0]; + if ( + !t.isIdentifier(input) || + resolveSemanticBindingState(state, input, input.name) !== binding + ) + return undefined; + const required = semanticRequireOrigin(initial.node, state)?.origin; + if (required === undefined || required.importedPath.length !== 0) + return undefined; + const helper = resolveSemanticBindingState( + state, + call.callee, + call.callee.name, + ); + const proof = helper === undefined ? undefined : helperProof(helper); + if (proof === undefined || proof.readyAfter >= (call.start ?? -1)) + return undefined; + const result = { + origin: required, + dependencies: proof.dependencies.map((dependency) => ({ + ...dependency, + capturedAt: dependency.capturedAt ?? call, + })), + }; + origins.set(binding.bindingId, result); + return result; + }; + return { + origin, + intrinsicPath, + opaqueMutationPath, + retainWrite: (node: t.Node, path: readonly (string | null)[]) => { + const coverage = new SemanticPropertyPathCoverage(); + coverage.retain(path[0] === "opaque-intrinsic" ? ["intrinsic"] : path); + writes.push({ + scope: opaqueMutationPath(node).join("."), + offset: node.start ?? Infinity, + coverage, + }); + }, + invalidated: (binding: JavaScriptSemanticBindingState): boolean => { + const cached = invalidatedBindings.get(binding.bindingId); + if (cached !== undefined) return cached; + const result = + origins + .get(binding.bindingId) + ?.dependencies.some(({ path, capturedAt }) => { + if (capturedAt === undefined) return true; + const scope = opaqueMutationPath(capturedAt).join("."); + // Captured function values survive later replacement of global + // properties. Other callable bodies are not execution evidence. + return writes.some( + (write) => + (write.scope === scope || write.scope === "intrinsic") && + write.offset < (capturedAt.start ?? -1) && + write.coverage.covers(path), + ); + }) ?? false; + invalidatedBindings.set(binding.bindingId, result); + return result; + }, + }; +}; + +const stableInitializer = ( + binding: JavaScriptSemanticBindingState | undefined, + state: JavaScriptSemanticAnalysisState, +): t.Node | 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 + : undefined; +}; + +/** Exact AST shape with a bijection of lexical roles, not identifier spelling. */ +const canonicalRoles = ( + actual: t.Node, + pattern: t.Node, + state: JavaScriptSemanticAnalysisState, +): ReadonlyMap | undefined => { + const roles = new Map(); + const used = new Map(); + const pending: { actual: t.Node; pattern: t.Node; parent: t.Node | null }[] = + [{ actual, pattern, parent: null }]; + while (pending.length !== 0) { + const pair = pending.pop(); + if (pair === undefined || pair.actual.type !== pair.pattern.type) + return undefined; + const { actual: node, pattern: expected, parent } = pair; + const literalKey = + parent !== null && + ((t.isMemberExpression(parent) && + !parent.computed && + parent.property === expected) || + (t.isObjectProperty(parent) && + !parent.computed && + parent.key === expected)); + if (t.isIdentifier(expected) && t.isIdentifier(node) && !literalKey) { + const binding = resolveSemanticBindingState(state, node, node.name); + if ( + binding === undefined || + (roles.has(expected.name) && roles.get(expected.name) !== binding) || + (used.has(binding.bindingId) && + used.get(binding.bindingId) !== expected.name) + ) + return undefined; + roles.set(expected.name, binding); + used.set(binding.bindingId, expected.name); + } + const visitorKeys = t.VISITOR_KEYS[expected.type] ?? []; + for (const [key, field] of Object.entries( + t.NODE_FIELDS[expected.type] ?? {}, + )) { + if ( + visitorKeys.includes(key) || + (t.isIdentifier(expected) && key === "name" && !literalKey) + ) + continue; + const fallback: unknown = Reflect.get(field, "default"); + if ( + JSON.stringify(Reflect.get(node, key) ?? fallback) !== + JSON.stringify(Reflect.get(expected, key) ?? fallback) + ) + return undefined; + } + const children = childNodes(node), + expectedChildren = childNodes(expected); + if (children.length !== expectedChildren.length) return undefined; + for (const [index, child] of children.entries()) { + const expectedChild = expectedChildren[index]; + if (expectedChild !== undefined) + pending.push({ + actual: child, + pattern: expectedChild, + parent: expected, + }); + } + } + return roles; +}; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 425cbef455..d654d75923 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -46,7 +46,7 @@ import { import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDerivedAnalysis.js"; import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js"; import { - collectElectronMemberWritesSteps, + collectJavaScriptIdentityWritesSteps, electronExportPath, electronMemberPath, ELECTRON_MODULE, @@ -61,6 +61,8 @@ import { } from "./javascriptSourceParser.js"; import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js"; +import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; +import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; interface BindPatternInput { readonly pattern: t.Node; @@ -371,6 +373,9 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( const facts = new Map(); const state = createState(file.program); yield* collectDefinitionsSteps(file.program, state); + const interop = createSemanticModuleInterop(state); + const writes = new SemanticPropertyPathCoverage(); + let collectedWrites = false; const exportsByBinding = new Map(); const rootExport = (root: t.Node): readonly string[] | undefined => { if (t.isIdentifier(root)) { @@ -379,20 +384,34 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( if (binding === undefined) return undefined; let exported = exportsByBinding.get(binding.bindingId); if (exported === undefined) { - exported = electronExport(binding, state); + exported = electronExport(binding, state, interop); exportsByBinding.set(binding.bindingId, exported); } - return exported ?? undefined; + return collectedWrites && interop.invalidated(binding) + ? undefined + : (exported ?? undefined); } const origin = semanticRequireOrigin(root, state)?.origin; return origin !== undefined && ELECTRON_MODULE.test(origin.specifier) ? electronExportPath(origin.importedPath) : undefined; }; - const writes = yield* collectElectronMemberWritesSteps( + yield* collectJavaScriptIdentityWritesSteps( file.program, - rootExport, + (root, kind) => { + if (kind === "opaque-call") return interop.opaqueMutationPath(root); + const exported = kind === "binding" ? undefined : rootExport(root); + return exported === undefined + ? interop.intrinsicPath(root) + : ["electron", ...exported]; + }, + interop.intrinsicPath, + (node, path) => { + if (path[0] === "electron") writes.retain(path); + else interop.retainWrite(node, path); + }, ); + collectedWrites = true; yield* traverseJavaScriptAstSteps(file.program, { enter: (node) => { if ( @@ -403,7 +422,10 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( return; const { root, members } = electronMemberPath(node.callee); const exported = rootExport(root); - if (exported !== undefined && !writes.covers([...exported, ...members])) + if ( + exported !== undefined && + !writes.covers(["electron", ...exported, ...members]) + ) facts.set(root.start ?? -1, exported); }, }); @@ -415,10 +437,14 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( const electronExport = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, + interop: ReturnType, ): readonly string[] | null => { - if (binding.definitions.some(({ kind }) => kind === "assignment")) - return null; - const directOrigin = bindingOrigin(binding, state); + const reassigned = binding.definitions.some( + ({ kind }) => kind === "assignment", + ); + const converted = reassigned ? interop.origin(binding) : undefined; + if (reassigned && converted === undefined) return null; + const directOrigin = converted?.origin ?? bindingOrigin(binding, state); const [initializer] = binding.initializers; if ( directOrigin === undefined && diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts new file mode 100644 index 0000000000..7bc31c5ba1 --- /dev/null +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -0,0 +1,88 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { ROLLDOWN_MODULE_INTEROP } from "../../../src/domain/javascript/javascriptModuleInterop.fixture.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains converted Electron windows and preload evidence through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-electron-interop-"); + await writeFile( + join(root, "package.json"), + JSON.stringify({ main: "main.cjs" }), + ); + await writeFile( + join(root, "preload.js"), + 'throw new Error("must stay inert");', + ); + const source = `${ROLLDOWN_MODULE_INTEROP} + let electron = require("electron"); + electron = __toESM(electron, 1); + new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}}); + new electron.BrowserWindow({webPreferences:{preload:runtimePath}}); + const direct = require("electron"); + new direct.BrowserWindow({}); + function decoy(electron) {new electron.BrowserWindow({});} + throw new Error("must not execute main"); + `; + await writeFile(join(root, "main.cjs"), source); + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const result = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(document).normalized_result; + expect(result.summary).toMatchObject({ + browser_windows: 3, + preload_entrypoints: 1, + }); + expect(result.statistics.parse_failures).toBe(0); + const windows = result.graph.nodes.filter( + ({ kind }) => kind === "browser-window", + ); + expect(windows).toHaveLength(3); + for (const window of windows) { + expect(window.observations[0]?.evidence.location).toMatchObject({ + available: true, + value: { kind: "source-range", source: "main.cjs" }, + }); + } + expect( + windows.flatMap((window) => + window.observations.map(({ properties }) => properties.web_preferences), + ), + ).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ + name: "preload", + value: { + status: "dynamic", + value: null, + expression: "runtimePath", + }, + }), + ]), + ]), + ); + }, +);