diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 1c61520ea8..82e2cef99a 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -323,8 +323,15 @@ substitute version-dependent Electron defaults for omitted `webPreferences`. Dynamic option objects, bridge keys, API objects, and IPC channel expressions remain unknown and make coverage partial. -Electron API identity follows proven lexical module aliases. Reassigned bindings, -dynamic selections, and visibly overwritten namespace members are unresolved. +Electron API identity follows proven lexical module aliases. Bundled CommonJS +namespace imports are also followed through the interop helpers that bundlers +and compilers emit (`__toESM`, `__importStar`, `_interopRequireWildcard`), +including the `electron = __toESM(electron)` rewrap that Rolldown emits. This +step is inferred: a helper is recognized by its emitted name and a single +program-scope function definition, and its body is not evaluated, so a local +function that reuses one of these names is trusted to pass the module through. +Other reassigned bindings, dynamic selections, and visibly overwritten namespace +members are 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/electronStaticAnalysisIdentity.test.ts b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts index 78e4b314ca..99a3805e88 100644 --- a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts +++ b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts @@ -73,4 +73,51 @@ describe("Electron API identity", () => { }, ]); }); + + it("follows bundler namespace interop helpers without accepting other writes", () => { + const analysis = analyzeJavaScriptStaticSource(` + var __toESM = (mod) => mod; + var __importStar = (this && this.__importStar) || ((mod) => mod); + let electron = require("electron"); + electron = __toESM(electron); + new electron.BrowserWindow({ webPreferences: { preload: "./rolldown.js" } }); + var import_electron = __toESM(require("electron")); + new import_electron.BrowserWindow({ webPreferences: { preload: "./esbuild.js" } }); + function _interopRequireWildcard(mod) { return mod; } + const Electron = __importStar(require("electron")); + new Electron.BrowserWindow({ webPreferences: { preload: "./tsc.js" } }); + + let replaced = require("electron"); + replaced = __toESM(require("./fake")); + new replaced.BrowserWindow({ webPreferences: { preload: "./replaced.js" } }); + let rewritten = require("electron"); + rewritten = __toESM(rewritten); + rewritten = fake; + new rewritten.BrowserWindow({ webPreferences: { preload: "./rewritten.js" } }); + const babel = _interopRequireWildcard(require("electron")); + new babel.BrowserWindow({ webPreferences: { preload: "./babel.js" } }); + function nested() { + const __toESM = (mod) => mod; + const scoped = __toESM(require("electron")); + new scoped.BrowserWindow({ webPreferences: { preload: "./nested.js" } }); + } + `); + + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["./rolldown.js", "./esbuild.js", "./tsc.js", "./babel.js"]); + }); + + it("does not trust unbound helper names or non-function helper values", () => { + const analysis = analyzeJavaScriptStaticSource(` + const __toESM = makeLookalike(); + const electron = __toESM(require("electron")); + new electron.BrowserWindow({}); + let unbound = require("electron"); + unbound = __importStar(unbound); + new unbound.BrowserWindow({}); + `); + + expect(analysis.electron.browser_windows).toEqual([]); + }); }); diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index b8cd3839bd..696bb68aaa 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -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, including results of namespace interop helpers (__toESM, __importStar, _interopRequireWildcard) recognized by name and a single program-scope function definition; helper bodies are not evaluated. Other 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."; /** Preserve exact property segments instead of flattening literal dotted keys. */ export const electronMemberPath = ( diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 425cbef455..c4d8153c14 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -37,7 +37,10 @@ import { completeSemanticSteps, traverseJavaScriptAstSteps, } from "./javascriptSemanticTraversal.js"; -import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; +import { + semanticNamespaceInteropOperand, + semanticRequireOrigin, +} from "./javascriptSemanticRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; import { collectSemanticReturns, @@ -384,7 +387,10 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( } return exported ?? undefined; } - const origin = semanticRequireOrigin(root, state)?.origin; + const origin = semanticRequireOrigin( + semanticNamespaceInteropOperand(root, state) ?? root, + state, + )?.origin; return origin !== undefined && ELECTRON_MODULE.test(origin.specifier) ? electronExportPath(origin.importedPath) : undefined; @@ -416,14 +422,15 @@ const electronExport = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, ): readonly string[] | null => { - if (binding.definitions.some(({ kind }) => kind === "assignment")) - return null; - const directOrigin = bindingOrigin(binding, state); - const [initializer] = binding.initializers; + const initializers = namespacePreservingInitializers(binding, state); + if (initializers === undefined) return null; + const directOrigin = bindingOrigin(binding, initializers, state); + const [initializer] = initializers; if ( directOrigin === undefined && (initializer === undefined || - binding.initializers.length !== 1 || + initializers.length !== 1 || + initializers.length !== binding.initializers.length || state.conditionalInitializers.has(initializer.node)) ) return null; @@ -441,25 +448,66 @@ const electronExport = ( : null; }; +/** + * Return the initializers that can change which module a binding names. + * Bundlers emit `name = __toESM(name)` after a CommonJS require; rewrapping + * the binding's own value keeps every member, so it is not a reassignment. + */ +const namespacePreservingInitializers = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticBindingState["initializers"] | undefined => { + const initializers = binding.initializers.filter( + ({ node }) => !rewrapsOwnNamespace(binding, node, state), + ); + const rewraps = binding.initializers.length - initializers.length; + const assignments = binding.definitions.filter( + ({ kind }) => kind === "assignment", + ).length; + return assignments === rewraps ? initializers : undefined; +}; + +const rewrapsOwnNamespace = ( + binding: JavaScriptSemanticBindingState, + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const assignment = state.parentsByNode.get(node); + const operand = semanticNamespaceInteropOperand(node, state); + return ( + t.isAssignmentExpression(assignment) && + assignment.operator === "=" && + assignment.right === node && + t.isIdentifier(assignment.left, { name: binding.name }) && + t.isIdentifier(operand, { name: binding.name }) && + !semanticResolutionBlocked(state, operand, operand.name) && + resolveSemanticBindingState(state, operand, operand.name) === binding + ); +}; + const bindingOrigin = ( binding: JavaScriptSemanticBindingState, + initializers: JavaScriptSemanticBindingState["initializers"], state: JavaScriptSemanticAnalysisState, ): JavaScriptModuleOrigin | undefined => { if (binding.directOrigins.length > 0) return binding.directOrigins.length === 1 ? binding.directOrigins[0] : undefined; - const [initializer] = binding.initializers; + const [initializer] = initializers; // A var/let declaration permits writes without proving one occurred. // Require one unconditional initializer and no actual binding writes. if ( initializer === undefined || - binding.initializers.length !== 1 || - binding.definitions.some(({ kind }) => kind === "assignment") || + initializers.length !== 1 || state.conditionalInitializers.has(initializer.node) ) return undefined; - const origin = semanticRequireOrigin(initializer.node, state)?.origin; + const origin = semanticRequireOrigin( + semanticNamespaceInteropOperand(initializer.node, state) ?? + initializer.node, + state, + )?.origin; const projection = initializer.projection; return origin === undefined || !projection.every((key): key is string => typeof key === "string") diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 00f0805ba1..ec9ec9ae63 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -5,6 +5,7 @@ import { resolveSemanticBindingState, semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { semanticStaticPropertyKey, @@ -58,3 +59,73 @@ export const semanticRequireOrigin = ( call: current, }; }; + +// esbuild/Rolldown, TypeScript, and Babel emit these helpers for namespace +// imports of CommonJS modules; each returns an object exposing every own +// member of its operand, so member paths through the result are unchanged. +// Helper bodies are not evaluated: recognition trusts a single program-scope +// function definition under one of these emitted names. +const NAMESPACE_INTEROP_HELPERS = new Set([ + "__importStar", + "__toESM", + "_interopRequireWildcard", +]); + +/** Return the module operand of a locally declared namespace interop helper. */ +export const semanticNamespaceInteropOperand = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): t.Expression | undefined => { + const call = unwrapJavaScriptExpression(node).node; + if ( + !t.isCallExpression(call) || + !t.isIdentifier(call.callee) || + !NAMESPACE_INTEROP_HELPERS.has(call.callee.name) || + semanticResolutionBlocked(state, call.callee, call.callee.name) + ) + return undefined; + const helper = resolveSemanticBindingState( + state, + call.callee, + call.callee.name, + ); + const [operand] = call.arguments; + return helper !== undefined && + emittedInteropHelper(helper, state) && + t.isExpression(operand) + ? operand + : undefined; +}; + +// Emitters declare each helper once at program scope as a function, an +// arrow/function expression, or `(this && this.name) || `. +const emittedInteropHelper = ( + helper: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const [initializer] = helper.initializers; + if ( + initializer === undefined || + helper.initializers.length !== 1 || + helper.definitions.length !== 1 || + (helper.kind !== "function" && helper.kind !== "variable") || + state.scopesById.get(helper.scopeId)?.kind !== "program" + ) + return false; + const value = unwrapJavaScriptExpression(initializer.node).node; + const emitted = + t.isLogicalExpression(value, { operator: "||" }) && + t.isLogicalExpression(unwrapJavaScriptExpression(value.left).node, { + operator: "&&", + }) + ? unwrapJavaScriptExpression(value.right).node + : value; + return ( + t.isFunctionDeclaration(emitted) || + t.isFunctionExpression(emitted) || + t.isArrowFunctionExpression(emitted) || + (t.isCallExpression(emitted) && + (t.isFunctionExpression(emitted.callee) || + t.isArrowFunctionExpression(emitted.callee))) + ); +};