diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index f29e7cf8f4..a44baf89a7 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -12,6 +12,7 @@ import type { import { boundedExpression, collectContextBridgeMembers, + electronCalleeName, electronStaticValue, objectProperty, } from "./electronStaticAnalysisValues.js"; @@ -19,7 +20,6 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js"; import { argumentNode, - calleeName, range, staticPath, staticPathResolutionContext, @@ -42,7 +42,7 @@ const inspectBrowserWindow = ( node: t.NewExpression, context: JavaScriptFindingContext, ): void => { - const name = calleeName(node.callee); + const name = electronCalleeName(node.callee, context); if (name !== "BrowserWindow" && !name.endsWith(".BrowserWindow")) return; const options = argumentNode(node.arguments[0]); const collected = collectWindowOptions(context.source, options); @@ -165,7 +165,7 @@ const inspectContextBridge = ( node: t.CallExpression, context: JavaScriptFindingContext, ): void => { - const name = calleeName(node.callee); + const name = electronCalleeName(node.callee, context); const main = name === "contextBridge.exposeInMainWorld" || name.endsWith(".contextBridge.exposeInMainWorld"); @@ -213,7 +213,7 @@ const inspectUtilityProcess = ( node: t.CallExpression, context: JavaScriptFindingContext, ): void => { - const name = calleeName(node.callee); + const name = electronCalleeName(node.callee, context); if (name !== "utilityProcess.fork" && !name.endsWith(".utilityProcess.fork")) return; const moduleNode = argumentNode(node.arguments[0]); diff --git a/src/domain/javascript/electronStaticAnalysisIpc.ts b/src/domain/javascript/electronStaticAnalysisIpc.ts index 9efc2670b8..a560ea1844 100644 --- a/src/domain/javascript/electronStaticAnalysisIpc.ts +++ b/src/domain/javascript/electronStaticAnalysisIpc.ts @@ -7,6 +7,7 @@ import type { } from "./electronStaticAnalysisTypes.js"; import { boundedExpression, + electronCalleeName, electronStaticValue, handlerKind, } from "./electronStaticAnalysisValues.js"; @@ -36,7 +37,7 @@ const inspectIpcCall = ( node: t.CallExpression, context: JavaScriptFindingContext, ): void => { - const name = calleeName(node.callee); + const name = electronCalleeName(node.callee, context); const descriptor = ipcDescriptor(name); if (descriptor === undefined) return; const channelNode = argumentNode(node.arguments[0]); diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 2a800e6bbd..c2bb6e76a2 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -6,7 +6,26 @@ import { readExactJavaScriptLiteral, semanticStaticPropertyName, } from "./javascriptAstValues.js"; -import { sourceSlice } from "./javascriptStaticAnalysisHelpers.js"; +import { calleeName, sourceSlice } from "./javascriptStaticAnalysisHelpers.js"; +import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; + +/** Name a callee, spelling a root renamed from Electron as its export. */ +export const electronCalleeName = ( + callee: t.Node, + context: JavaScriptFindingContext, +): string => { + const name = calleeName(callee); + let root = callee; + while (t.isMemberExpression(root) || t.isOptionalMemberExpression(root)) { + if (!t.isNode(root.object)) return name; + root = root.object; + } + if (!t.isIdentifier(root)) return name; + const exported = context.electronBindings?.get(root.start ?? -1); + return exported === undefined + ? name + : `${exported}${name.slice(root.name.length)}`; +}; /** Preserve one literal value or the exact inert expression. */ export const electronStaticValue = ( diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 452d29074b..4b5ad0f723 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -32,6 +32,7 @@ import { semanticVariableScope, } from "./javascriptSemanticState.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; import { collectSemanticMemberMutations } from "./javascriptSemanticMemberMutations.js"; import { collectSemanticReturns, @@ -285,6 +286,92 @@ const resolveGlobalAliasFact = ( return openGlobalFact(name); }; +const ELECTRON_MODULE = /^electron(?:\/(?:common|main|renderer|utility))?$/u; + +/** + * Electron export paths for call and construction roots bound to Electron + * under another name, such as esbuild's `import { ipcMain as ipcMain2 }`. + * Keys are root identifier offsets; roots spelled as their export are omitted. + */ +export const classifyParsedJavaScriptElectronBindings = ( + file: ParsedJavaScriptSource, +): ReadonlyMap => { + const facts = new Map(); + const state = createState(file.program); + collectDefinitions(file.program, state); + const exportsByBinding = new Map(); + traverseJavaScriptAst(file.program, { + enter: (node) => { + if ( + !t.isCallExpression(node) && + !t.isOptionalCallExpression(node) && + !t.isNewExpression(node) + ) + return; + const root = calleeRoot(node.callee); + if (root === undefined) return; + const binding = resolveSemanticBindingState(state, root, root.name); + if (binding === undefined) return; + let exported = exportsByBinding.get(binding.bindingId); + if (exported === undefined) { + exported = electronExport(binding, state); + exportsByBinding.set(binding.bindingId, exported); + } + if (exported !== null && exported !== root.name) + facts.set(root.start ?? -1, exported); + }, + }); + return facts; +}; + +const calleeRoot = (node: t.Node): t.Identifier | undefined => { + while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + if (!t.isNode(node.object)) return undefined; + node = node.object; + } + return t.isIdentifier(node) ? node : undefined; +}; + +// Read import and require origins directly; general value evaluation is +// unnecessary here and costly across large vendor bundles. +const electronExport = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): string | null => { + const origin = bindingOrigin(binding, state); + return origin !== undefined && + ELECTRON_MODULE.test(origin.specifier) && + origin.importedPath.length > 0 + ? origin.importedPath.join(".") + : null; +}; + +const bindingOrigin = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): JavaScriptModuleOrigin | undefined => { + if (binding.directOrigins.length > 0) + return binding.directOrigins.length === 1 + ? binding.directOrigins[0] + : undefined; + const [initializer] = binding.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") || + state.conditionalInitializers.has(initializer.node) + ) + return undefined; + const origin = semanticRequireOrigin(initializer.node, state); + const projection = initializer.projection; + return origin === undefined || + !projection.every((key): key is string => typeof key === "string") + ? undefined + : { ...origin, importedPath: [...origin.importedPath, ...projection] }; +}; + const createState = (program: t.Program): JavaScriptSemanticAnalysisState => { const root: JavaScriptSemanticScopeState = { scopeId: semanticScopeId("program", program), diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index af20eaa926..524af811eb 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -25,9 +25,11 @@ import { finalizeLocatedFindings } from "./javascriptStaticAnalysisFindings.js"; import { createJavaScriptAnalysisAccumulator, type JavaScriptAnalysisAccumulator as AnalysisAccumulator, + type JavaScriptFindingContext, } from "./javascriptStaticAnalysisState.js"; import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js"; import { + classifyParsedJavaScriptElectronBindings, classifyParsedJavaScriptOpenReceivers, type JavaScriptOpenReceiverFact, } from "./javascriptSemanticAnalysis.js"; @@ -57,21 +59,24 @@ export const analyzeParsedJavaScriptStaticSource = ( openReceiverFacts?: ReadonlyMap, ): JavaScriptStaticAnalysis => { const accumulator = createJavaScriptAnalysisAccumulator(source.length); - traverseStaticSource(source, file, accumulator, openReceiverFacts); + traverseStaticSource(file, { + source, + accumulator, + ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), + electronBindings: classifyParsedJavaScriptElectronBindings(file), + }); addSourceMapDirectives(source, file.comments ?? [], accumulator); return finalizeStaticAnalysis(source, file, accumulator); }; const traverseStaticSource = ( - source: string, file: ParsedJavaScriptSource, - accumulator: AnalysisAccumulator, - openReceiverFacts?: ReadonlyMap, + findings: JavaScriptFindingContext, ): void => { traverseJavaScriptAst(file, { enter: (node) => { - accumulator.visitedNodes += 1; - inspectNode(source, node, accumulator, openReceiverFacts); + findings.accumulator.visitedNodes += 1; + inspectNode(node, findings); return undefined; }, }); @@ -156,16 +161,10 @@ const finalizeStaticAnalysis = ( }; const inspectNode = ( - source: string, node: t.Node, - accumulator: AnalysisAccumulator, - openReceiverFacts?: ReadonlyMap, + findings: JavaScriptFindingContext, ): void => { - const findings = { - source, - accumulator, - ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), - }; + const { source, accumulator } = findings; inspectElectronStaticNode(node, findings); if (t.isCallExpression(node)) { inspectBundlerRegistration(source, node, accumulator); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 23de6952bf..47e25bccdb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -65,6 +65,8 @@ export interface JavaScriptFindingContext { | "local-indexed-db" | "local-cache-storage" >; + /** Electron export paths for renamed call roots, by identifier offset. */ + readonly electronBindings?: ReadonlyMap; } /** Candidate import, require, worker, or service-worker reference. */ diff --git a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts index 3484f98a24..dd7bd8809e 100644 --- a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts +++ b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts @@ -89,6 +89,45 @@ describe("static Electron application analysis", () => { expect(requestedMembers).toEqual(["*"]); }); + it("maps Electron boundaries through bundler-renamed bindings", async () => { + const root = await renamedBindingFixtureDirectory(); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const graph = parseJavaScriptApplicationGraph(result.graph); + const handledChannels = graph.nodes.flatMap(({ kind, observations }) => + kind === "ipc-handler" + ? observations.flatMap(({ properties }) => + properties.side === "main" ? [properties.channel] : [], + ) + : [], + ); + + expect(result.electron_summary).toMatchObject({ + browser_windows: 1, + context_bridge_apis: 1, + exposed_api_members: 2, + ipc: { + main_handlers: 2, + paired_renderer_transmissions: 2, + unpaired_literal_renderer_transmissions: 0, + }, + utility_processes: 1, + }); + expect(handledChannels.sort()).toEqual(["rea:read", "rea:write"]); + }); + + it("maps unchanged var and let require aliases while excluding reassigned bindings", async () => { + const root = await requireAliasFixtureDirectory(); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + + expect(result.electron_summary.ipc).toMatchObject({ + main_handlers: 2, + paired_renderer_transmissions: 2, + unpaired_literal_renderer_transmissions: 0, + }); + }); + it("returns a tagged cancellation without executing application code", async () => { const root = await fixtureDirectory(); const controller = new AbortController(); @@ -299,6 +338,89 @@ const fixtureDirectory = async (): Promise => { return root; }; +const requireAliasFixtureDirectory = async (): Promise => { + const root = await createTestTempDirectory("rea-electron-require-aliases-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "rea-electron-require-aliases", + main: "main.cjs", + }), + ), + writeFile( + join(root, "main.cjs"), + String.raw` +var { ipcMain: main } = require("electron"); +let listener = require("electron/main").ipcMain; +main.handle("rea:read", async () => "value"); +listener.on("rea:write", () => undefined); + +let replaced = require("electron").ipcMain; +replaced = { handle() {} }; +replaced.handle("rea:reassigned", () => undefined); + +var assignedLater; +assignedLater.handle("rea:before-assignment", () => undefined); +assignedLater = require("electron").ipcMain; + +if (globalThis.registerIpc) { + var conditional = require("electron").ipcMain; +} +conditional.handle("rea:conditional", () => undefined); +`, + ), + writeFile( + join(root, "preload.cjs"), + String.raw` +const { ipcRenderer } = require("electron"); +ipcRenderer.invoke("rea:read"); +ipcRenderer.send("rea:write", "value"); +`, + ), + ]); + return root; +}; + +const renamedBindingFixtureDirectory = async (): Promise => { + const root = await createTestTempDirectory("rea-electron-renamed-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ name: "rea-electron-renamed", main: "main.mjs" }), + ), + // Bundlers rename colliding imports, e.g. esbuild's ipcMain2. + writeFile( + join(root, "main.mjs"), + String.raw` +import { BrowserWindow as BrowserWindow2, ipcMain as ipcMain2 } from "electron"; +import { ipcMain as ipcMain3, utilityProcess as utility } from "electron/main"; + +new BrowserWindow2({ webPreferences: { preload: "./preload.cjs" } }); +ipcMain2.handle("rea:read", async () => "value"); +ipcMain3.on("rea:write", () => undefined); +utility.fork("./worker.js"); +function register(ipcMain2) { + ipcMain2.handle("rea:shadowed", async () => "local"); +} +export { register }; +`, + ), + writeFile( + join(root, "preload.cjs"), + String.raw` +const { contextBridge: bridge, ipcRenderer: ipc } = require("electron"); +bridge.exposeInMainWorld("reaApi", { + read: () => ipc.invoke("rea:read"), + write: (value) => ipc.send("rea:write", value), +}); +`, + ), + writeFile(join(root, "worker.js"), "module.exports = {};"), + ]); + return root; +}; + const computedMemberFixtureDirectory = async (): Promise => { const root = await createTestTempDirectory("rea-electron-computed-members-"); await mkdir(join(root, "native"));