diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index 7b7b5c4bc6..d5d6ab5962 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -54,16 +54,18 @@ export const classifyElectronIpcPairings = ( ? [] : handlers.filter( (handler) => - handler.finding.channel === transmission.finding.channel && + (handler.finding.channel === transmission.finding.channel || + (handler.finding.receiver !== undefined && + handler.finding.channel === null)) && compatibleIpcModes(transmission.finding, handler.finding), ); return { transmission, candidates, status: - candidates.length === 1 + candidates.length === 1 && candidates[0]?.finding.receiver === undefined ? "paired" - : candidates.length > 1 + : candidates.length > 0 ? "ambiguous" : "unpaired", }; diff --git a/src/application/javascript/ElectronBoundaryGraphIpc.ts b/src/application/javascript/ElectronBoundaryGraphIpc.ts index 3cc383914d..23b694ffdb 100644 --- a/src/application/javascript/ElectronBoundaryGraphIpc.ts +++ b/src/application/javascript/ElectronBoundaryGraphIpc.ts @@ -119,10 +119,22 @@ const addChannelNode = ( coverage: JavaScriptArtifactGraphCoverage, ): ApplicationNode => { const { finding, file } = record; - const identityKey = + const channelKey = finding.channel === null ? `dynamic:${record.key}` : `literal:${finding.channel}`; + const identityKey = + finding.receiver === undefined + ? channelKey + : JSON.stringify([ + file.path, + file.sha256, + finding.module_runtime ?? null, + finding.module_key, + finding.receiver.kind, + finding.receiver.owner_location, + channelKey, + ]); return context.accumulator.addNode({ kind: "ipc-channel", identity: @@ -141,6 +153,9 @@ const addChannelNode = ( { label: finding.channel ?? finding.channel_expression, properties: { + ...(finding.receiver === undefined + ? {} + : { receiver: finding.receiver }), side: finding.side, operation: finding.operation, mode: finding.mode, @@ -159,16 +174,23 @@ const addChannelNode = ( range: finding.location, operation: "observe-ipc-channel-expression", coverage, - limitations: - finding.resolution_basis === "wrapper-substitution" - ? [ + limitations: [ + ...(finding.receiver === undefined + ? [] + : [ + "The receiver is a source-level allocation, not a runtime frame identity; registration and dispatch remain unverified.", + ]), + ...(finding.resolution_basis !== "wrapper-substitution" + ? [] + : [ "The channel is inferred through lexical wrapper substitution; runtime registration and dispatch remain unverified.", - ] - : finding.channel === null - ? [ - "The channel expression is dynamic; its runtime value and pairings remain unknown.", - ] - : [], + ]), + ...(finding.channel !== null + ? [] + : [ + "The channel expression is dynamic; its runtime value and pairings remain unknown.", + ]), + ], }), }, ], @@ -179,17 +201,31 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => { const { context, record, source, channel, coverage } = input; const { finding, file } = record; const handlerRange = finding.handler_location ?? finding.location; + const handlerKey = `${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`; + const identityKey = + finding.receiver === undefined + ? handlerKey + : JSON.stringify([ + file.path, + finding.module_runtime ?? null, + finding.module_key, + finding.receiver, + handlerKey, + ]); const handler = context.accumulator.addNode({ kind: "ipc-handler", identity: artifactLocalIdentity( file.sha256, "electron-ipc-handler", - `${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`, + identityKey, ), observations: [ { label: finding.channel, properties: { + ...(finding.receiver === undefined + ? {} + : { receiver: finding.receiver }), side: finding.side, operation: finding.operation, mode: finding.mode, diff --git a/src/domain/javascript/electronStaticAnalysisIpc.ts b/src/domain/javascript/electronStaticAnalysisIpc.ts index 5cebf0cc92..43bcdbc383 100644 --- a/src/domain/javascript/electronStaticAnalysisIpc.ts +++ b/src/domain/javascript/electronStaticAnalysisIpc.ts @@ -3,6 +3,7 @@ import * as t from "@babel/types"; import type { ElectronIpcDescriptor, ElectronIpcFinding, + ElectronIpcReceiver, ElectronSenderValidationFinding, } from "./electronStaticAnalysisTypes.js"; import { @@ -37,7 +38,10 @@ const inspectIpcCall = ( node: t.CallExpression | t.OptionalCallExpression, context: JavaScriptFindingContext, ): void => { - const descriptor = ipcDescriptor(electronCalleePath(node.callee, context)); + const scoped = scopedIpcDescriptor(node, context); + const descriptor = + scoped?.descriptor ?? + ipcDescriptor(electronCalleePath(node.callee, context)); if (descriptor === undefined) return; const channelNode = argumentNode(node.arguments[0]); const channelValue = electronStaticValue(context.source, channelNode); @@ -88,6 +92,7 @@ const inspectIpcCall = ( context.accumulator.unknownFindings += 1; const finding: ElectronIpcFinding = { ...findingState, + ...(scoped === undefined ? {} : { receiver: scoped.receiver }), ...channelReference, module_key: null, location: range(node), @@ -100,6 +105,48 @@ const inspectIpcCall = ( }); }; +const scopedIpcDescriptor = ( + node: t.CallExpression | t.OptionalCallExpression, + context: JavaScriptFindingContext, +): + | { + readonly descriptor: ElectronIpcDescriptor; + readonly receiver: ElectronIpcReceiver; + } + | undefined => { + if ( + !t.isMemberExpression(node.callee) && + !t.isOptionalMemberExpression(node.callee) + ) + return undefined; + const operation = semanticStaticPropertyName( + node.callee.property, + node.callee.computed, + ); + const descriptor = ipcDescriptor(["ipcMain", operation]); + if (descriptor === undefined) return undefined; + const receiver = context.browserWindowReceiver?.(node.callee.object, node, [ + operation, + ]); + if (receiver === undefined) return undefined; + const path = receiver.path; + const kind = + path.length === 3 && path[0] === "webContents" && path[1] === "ipc" + ? "web-contents" + : path.length === 4 && + path[0] === "webContents" && + path[1] === "mainFrame" && + path[2] === "ipc" + ? "main-frame" + : undefined; + return kind === undefined + ? undefined + : { + descriptor, + receiver: { kind, owner_location: range(receiver.owner) }, + }; +}; + const inspectValidationComparison = ( node: t.BinaryExpression, context: JavaScriptFindingContext, diff --git a/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts b/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts new file mode 100644 index 0000000000..0cefe731c2 --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts @@ -0,0 +1,120 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + ["webContents", "window.webContents.ipc"], + ["main frame", "window.webContents.mainFrame.ipc"], +] as const)( + "retains a proven %s IPC handler beside the global control", + (_, receiver) => { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow, ipcMain } = require("electron"); + const window = new BrowserWindow(); + ipcMain.handle("global-control", () => "global"); + ${receiver}.handle("scoped-channel", () => "scoped"); + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toHaveLength(1); + expect(analysis.electron.ipc).toContainEqual( + expect.objectContaining({ + side: "main", + operation: "handle", + channel: "global-control", + handler_kind: "inline-function", + }), + ); + expect(analysis.electron.ipc).toContainEqual( + expect.objectContaining({ + side: "main", + operation: "handle", + channel: "scoped-channel", + handler_kind: "inline-function", + }), + ); + }, +); + +it("does not label lookalike or shadowed receivers as Electron IPC", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow, ipcMain } = require("electron"); + ipcMain.handle("global-control", () => "global"); + const fake = { webContents: { ipc: { handle() {} } } }; + fake.webContents.ipc.handle("fake", () => {}); + function shadow(BrowserWindow) { + const window = new BrowserWindow(); + window.webContents.ipc.handle("shadowed", () => {}); + } + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.ipc.map(({ channel }) => channel)).toEqual([ + "global-control", + ]); +}); + +it.each([ + [ + 'const contents = window.webContents; contents.ipc.handle("same", () => {});', + "web-contents", + ], + [ + 'const frame = window.webContents.mainFrame; frame.ipc.handle("same", () => {});', + "main-frame", + ], + [ + 'const contents = window.webContents; const frame = contents.mainFrame; const ipc = frame.ipc; ipc.handle("same", () => {});', + "main-frame", + ], +])("retains a proven scoped alias: %s", (call, kind) => { + const findings = + analyzeJavaScriptStaticSource(`const { BrowserWindow } = require("electron"); + const window = new BrowserWindow(); ${call}`).electron.ipc; + expect(findings).toHaveLength(1); + expect(findings[0]?.receiver).toEqual({ + kind, + owner_location: { + start: { line: 2, column: 19 }, + end: { line: 2, column: 38 }, + }, + }); +}); + +it("keeps the shared owner's conservative omission after an aliased receiver call", () => { + const findings = + analyzeJavaScriptStaticSource(`const { BrowserWindow } = require("electron"); + const window = new BrowserWindow(); + const ipc = window.webContents.mainFrame.ipc; + ipc.handle("first", () => {}); + window.webContents.mainFrame.ipc.on("after-escape", () => {});`).electron + .ipc; + expect(findings.map(({ channel }) => channel)).toEqual(["first"]); +}); + +it.each([ + "window.webContents = replacement;", + "window.webContents.ipc = replacement;", + "window.webContents.ipc.handle = replacement;", + "const alias = window; alias.webContents.ipc.handle = replacement;", + "const contents = window.webContents; contents.ipc = replacement;", + "BrowserWindow.prototype.webContents = replacement;", +])("omits a mutated receiver: %s", (mutation) => { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow } = require("electron"); + const window = new BrowserWindow(); + ${mutation} + window.webContents.ipc.handle("changed", () => {}); + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.ipc).toEqual([]); +}); + +it.each(["webContents.ipc", "webContents.mainFrame.ipc"])( + "does not split one dotted member key into receiver segments: %s", + (key) => { + expect( + analyzeJavaScriptStaticSource(`const {BrowserWindow} = require("electron"); + const window = new BrowserWindow(); window[${JSON.stringify(key)}].handle("fake", () => {});`) + .electron.ipc, + ).toEqual([]); + }, +); diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index b0e4aca045..7cba97065b 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -114,7 +114,14 @@ type ElectronIpcHandler = readonly handler_location: JavaScriptSourceRange; }; +export interface ElectronIpcReceiver { + readonly kind: "web-contents" | "main-frame"; + /** Constructor syntax allocation, never a runtime frame identifier. */ + readonly owner_location: JavaScriptSourceRange; +} + interface ElectronIpcFindingContext { + readonly receiver?: ElectronIpcReceiver; /** Absent on direct syntax observations; wrappers require inferred evidence. */ readonly resolution_basis?: "wrapper-substitution"; readonly module_key: string | null; diff --git a/src/domain/javascript/electronStaticReceiverAdmission.test.ts b/src/domain/javascript/electronStaticReceiverAdmission.test.ts new file mode 100644 index 0000000000..5d44d4cc93 --- /dev/null +++ b/src/domain/javascript/electronStaticReceiverAdmission.test.ts @@ -0,0 +1,80 @@ +import { expect, it } from "vitest"; + +import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js"; +import { resolveElectronBrowserWindowReceiver } from "./electronStaticRenderer.js"; +import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; +import { + indexJavaScriptBindingsWithReceiversSteps, + indexJavaScriptElectronIdentitySteps, +} from "./javascriptSemanticAnalysis.js"; +import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; +import { + completeSemanticSteps as complete, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { + createJavaScriptAnalysisAccumulator, + type JavaScriptFindingContext, +} from "./javascriptStaticAnalysisState.js"; + +const inspect = (source: string) => { + const file = parseJavaScriptSource(source); + if (file === null) throw new Error("Fixture must parse"); + const index = complete( + indexJavaScriptBindingsWithReceiversSteps(file.program), + ); + complete(index.indexAmbientWritesSteps()); + const identity = complete( + indexJavaScriptElectronIdentitySteps(file.program, index.bindings), + ); + let mutationIndexCalls = 0; + const original = index.indexMemberMutationFactsSteps.bind(index); + index.indexMemberMutationFactsSteps = function* () { + mutationIndexCalls += 1; + yield* original(); + }; + const context: JavaScriptFindingContext = { + source, + accumulator: createJavaScriptAnalysisAccumulator(source.length), + vitePreloadDependencyTable: null, + electronBindings: identity.bindings, + electronExportIntact: identity.intactExport, + rpcScan: createJavaScriptRpcScan( + source, + index.receivers.rpcClientRoots, + index, + ), + expressionShapes: createSemanticExpressionShapeResolver(index.bindings), + browserWindowReceiver: (node, site, suffix) => + resolveElectronBrowserWindowReceiver(node, site, suffix, context, index), + }; + traverseJavaScriptAst(file, { + enter: (node) => inspectElectronIpcNode(node, context), + }); + return { findings: context.accumulator.ipc, mutationIndexCalls }; +}; + +it.each([ + [ + "ordinary EventEmitter", + `import {EventEmitter} from "node:events"; + const emitter = new EventEmitter(); emitter.on("event", listener);`, + ], + [ + "unsupported BrowserWindow path", + `const {BrowserWindow} = require("electron"); + const window = new BrowserWindow(); window.on("ready", listener);`, + ], +])("does not request whole-file mutation facts for %s", (_, source) => { + const result = inspect(source); + expect(result.findings).toEqual([]); + expect(result.mutationIndexCalls).toBe(0); +}); + +it("still checks mutations for a source-proven scoped Electron receiver", () => { + const result = inspect(`const {BrowserWindow} = require("electron"); + const window = new BrowserWindow(); window.webContents.ipc.handle("channel", () => {});`); + expect(result.findings).toHaveLength(1); + expect(result.mutationIndexCalls).toBeGreaterThan(0); +}); diff --git a/src/domain/javascript/electronStaticRenderer.ts b/src/domain/javascript/electronStaticRenderer.ts index 86c4128327..578dff96b6 100644 --- a/src/domain/javascript/electronStaticRenderer.ts +++ b/src/domain/javascript/electronStaticRenderer.ts @@ -25,20 +25,51 @@ export const isElectronRendererLoad = ( return false; const method = semanticStaticPropertyKey(callee.property, callee.computed); if (method !== "loadFile" && method !== "loadURL") return false; - let receiver = unwrapJavaScriptExpression(callee.object).node; - const path: string[] = [method]; - if ( - t.isMemberExpression(receiver) && - semanticStaticPropertyKey(receiver.property, receiver.computed) === - "webContents" - ) { - receiver = unwrapJavaScriptExpression(receiver.object).node; - path.unshift("webContents"); - } + const receiver = resolveElectronBrowserWindowReceiver( + callee.object, + site, + [method], + context, + index, + ); + return ( + receiver !== undefined && + (receiver.path.length === 1 || + (receiver.path.length === 2 && receiver.path[0] === "webContents")) + ); +}; + +/** Source allocation identity, not a claim about runtime window/frame identity. */ +export const resolveElectronBrowserWindowReceiver = ( + node: t.Node, + site: t.Node, + suffix: readonly string[], + context: JavaScriptFindingContext, + index: JavaScriptBindingIndex, +): + | { readonly owner: t.NewExpression; readonly path: readonly string[] } + | undefined => { + let receiver = unwrapJavaScriptExpression(node).node; + const path = [...suffix]; const seen = new Set(); - while (t.isIdentifier(receiver)) { + const aliases: { receiver: t.Identifier; path: readonly string[] }[] = []; + for (;;) { + if ( + t.isMemberExpression(receiver) || + t.isOptionalMemberExpression(receiver) + ) { + const member = semanticStaticPropertyKey( + receiver.property, + receiver.computed, + ); + if (member === null) return undefined; + path.unshift(member); + receiver = unwrapJavaScriptExpression(receiver.object).node; + continue; + } + if (!t.isIdentifier(receiver)) break; const binding = index.bindingForReferenceAt(receiver); - if (binding === undefined || seen.has(binding.bindingId)) return false; + if (binding === undefined || seen.has(binding.bindingId)) return undefined; seen.add(binding.bindingId); const initializer = semanticStableInitializer(binding, index.bindings); if ( @@ -47,21 +78,45 @@ export const isElectronRendererLoad = ( !semanticBindingAtRead(binding, receiver, index.bindings.parentsByNode) .hasDefiniteInitializer ) - return false; - completeSemanticSteps(index.indexMemberMutationFactsSteps()); - if (index.referenceMayHaveChangedAt(site, receiver, path)) return false; + return undefined; + aliases.push({ receiver, path: [...path] }); receiver = unwrapJavaScriptExpression(initializer.node).node; } - if (!t.isNewExpression(receiver)) return false; + if (!t.isNewExpression(receiver)) return undefined; const constructor = electronCalleePath(receiver.callee, context); - return ( - constructor?.length === 1 && - constructor[0] === "BrowserWindow" && + if ( + constructor?.length !== 1 || + constructor[0] !== "BrowserWindow" || + !supportedReceiverPath(path) + ) + return undefined; + if ( context.electronExportIntact?.([ "electron", "BrowserWindow", "prototype", ...path, - ]) === true + ]) !== true + ) + return undefined; + // Only source-proven supported receivers justify the whole-file mutation pass. + // Keep every alias's consumed path and check at the original invocation site. + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + for (const alias of aliases) + if (index.referenceMayHaveChangedAt(site, alias.receiver, alias.path)) + return undefined; + return { owner: receiver, path }; +}; + +const supportedReceiverPath = (path: readonly string[]): boolean => { + const operation = path.at(-1); + if (operation === "loadFile" || operation === "loadURL") + return ( + path.length === 1 || (path.length === 2 && path[0] === "webContents") + ); + return ( + path[0] === "webContents" && + ((path.length === 3 && path[1] === "ipc") || + (path.length === 4 && path[1] === "mainFrame" && path[2] === "ipc")) ); }; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 530c00605c..2ecf74191e 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -14,7 +14,10 @@ import { traverseJavaScriptAstSteps, } from "./javascriptSemanticTraversal.js"; -import { isElectronRendererLoad } from "./electronStaticRenderer.js"; +import { + isElectronRendererLoad, + resolveElectronBrowserWindowReceiver, +} from "./electronStaticRenderer.js"; import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js"; import { inspectElectronStaticNode } from "./electronStaticAnalysis.js"; import { @@ -109,6 +112,8 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( ); const findings: JavaScriptFindingContext = { pathIntrinsics: createStaticPathIntrinsicQuery(index), + browserWindowReceiver: (node, site, suffix) => + resolveElectronBrowserWindowReceiver(node, site, suffix, findings, index), rendererLoad: (callee, site) => isElectronRendererLoad(callee, site, findings, index), source, diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 505258501a..edbe450d71 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -101,6 +101,13 @@ export interface JavaScriptFindingContext { /** Native path/URL identities established by shared lexical and mutation owners. */ readonly pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery; readonly electronExportIntact?: (path: readonly string[]) => boolean; + readonly browserWindowReceiver?: ( + node: t.Node, + site: t.Node, + suffix: readonly string[], + ) => + | { readonly owner: t.NewExpression; readonly path: readonly string[] } + | undefined; readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean; readonly rpcScan: JavaScriptRpcScan; /** Shared bounded argv/nullish argument shapes for optional-slot signatures. */ diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts index c4601eadf6..13a528c2f6 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts @@ -151,3 +151,32 @@ it("preserves partial request targets through the strict worker schema", () => { const serialized = JSON.parse(JSON.stringify(facts)) as unknown; expect(javaScriptWorkerStaticAnalysisSchema.parse(serialized)).toEqual(facts); }); + +it("preserves scoped IPC receiver provenance and rejects malformed scope metadata", () => { + const facts = + analyzeJavaScriptStaticSource(`const {BrowserWindow} = require("electron"); + const window = new BrowserWindow(); window.webContents.mainFrame.ipc.handle("scoped", () => {});`); + expect(facts.electron.ipc).toHaveLength(1); + expect(facts.electron.ipc[0]?.receiver?.kind).toBe("main-frame"); + expect( + javaScriptWorkerStaticAnalysisSchema.parse( + JSON.parse(JSON.stringify(facts)) as unknown, + ), + ).toEqual(facts); + const finding = facts.electron.ipc[0]!; + for (const receiver of [ + { kind: "global", owner_location: finding.receiver?.owner_location }, + { kind: "main-frame" }, + { ...finding.receiver, runtime_frame_id: 42 }, + ]) { + expect( + javaScriptWorkerStaticAnalysisSchema.safeParse({ + ...facts, + electron: { + ...facts.electron, + ipc: [{ ...finding, receiver }], + }, + }).success, + ).toBe(false); + } +}); diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index a647147225..88825153c6 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -67,10 +67,17 @@ const locatedHandler = { ]), handler_location: range, }; +const ipcReceiver = z + .strictObject({ + kind: z.enum(["web-contents", "main-frame"]), + owner_location: range, + }) + .exactOptional(); const ipcChannel = (shape: Shape) => z.union([ z.strictObject({ ...findingContext, + receiver: ipcReceiver, invocation_location: range.optional(), resolution_basis: z.literal("wrapper-substitution").optional(), ...shape, @@ -79,6 +86,7 @@ const ipcChannel = (shape: Shape) => }), z.strictObject({ ...findingContext, + receiver: ipcReceiver, invocation_location: range.optional(), resolution_basis: z.literal("wrapper-substitution").optional(), ...shape, diff --git a/tests/boundary/cli/electronScopedIpc.test.ts b/tests/boundary/cli/electronScopedIpc.test.ts new file mode 100644 index 0000000000..78721ce5d4 --- /dev/null +++ b/tests/boundary/cli/electronScopedIpc.test.ts @@ -0,0 +1,114 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves scoped IPC allocations without global dispatch pairing through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-scoped-ipc-"); + const copy = `const {BrowserWindow}=require("electron"); const window=new BrowserWindow(); window.webContents.ipc.handle("copied",()=>{});`; + await writeFixtureFiles(root, { + "copy-one.cjs": copy, + "copy-two.cjs": copy, + "main.cjs": `const { BrowserWindow, ipcMain } = require("electron"); + const first = new BrowserWindow(); + const second = new BrowserWindow(); + first.webContents.mainFrame.ipc.handle("shared", () => "first"); + second.webContents.mainFrame.ipc.handle("shared", () => "second"); + ipcMain.handle("shared", () => "global"); + const dynamicWindow = new BrowserWindow(); + dynamicWindow.webContents.ipc.handle(dynamicChannel, () => "dynamic"); + ipcMain.handle("dynamic-shadow", () => "global"); + const fake = { webContents: { ipc: { handle() {} } } }; + fake.webContents.ipc.handle("fake", () => {});`, + "preload.cjs": `const { ipcRenderer } = require("electron"); + ipcRenderer.invoke("shared"); + ipcRenderer.invoke("dynamic-shadow");`, + }); + const application = await analyzeJavaScriptDirectory(surface, cli, root); + expect(application.statistics.parse_failures).toBe(0); + expect(application.summary.ipc).toMatchObject({ + operations: 9, + main_handlers: 7, + renderer_transmissions: 2, + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 2, + }); + const channels = application.graph.nodes.filter( + ({ kind }) => kind === "ipc-channel", + ); + const copies = application.graph.nodes.filter( + ({ kind, observations }) => + kind === "ipc-handler" && + observations.some(({ properties }) => properties.channel === "copied"), + ); + expect(copies).toHaveLength(2); + expect( + channels.filter(({ observations }) => + observations.some(({ properties }) => properties.channel === "copied"), + ), + ).toHaveLength(2); + const shared = channels.filter(({ observations }) => + observations.some(({ properties }) => properties.channel === "shared"), + ); + expect(shared).toHaveLength(3); + const scoped = shared.filter(({ observations }) => + observations.some(({ properties }) => properties.receiver !== undefined), + ); + expect(scoped).toHaveLength(2); + const owners = scoped.flatMap(({ observations }) => + observations.map(({ properties }) => properties.receiver), + ); + expect(new Set(owners.map((owner) => JSON.stringify(owner))).size).toBe(2); + expect( + channels.some(({ observations }) => + observations.some(({ properties }) => properties.channel === "fake"), + ), + ).toBe(false); + expect( + application.graph.edges.filter( + ({ evidence }) => + evidence.extractor.operation === "pair-ipc-literal-channel", + ), + ).toEqual([]); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "does not uniquely pair a renderer with exactly one scoped literal handler through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-single-scoped-ipc-"); + await writeFixtureFiles(root, { + "main.cjs": `const { BrowserWindow } = require("electron"); + const window = new BrowserWindow(); + window.webContents.mainFrame.ipc.handle("only-scoped", () => "result");`, + "preload.cjs": `const { ipcRenderer } = require("electron"); + ipcRenderer.invoke("only-scoped");`, + }); + const application = await analyzeJavaScriptDirectory(surface, cli, root); + expect(application.summary.ipc).toMatchObject({ + operations: 2, + main_handlers: 1, + renderer_transmissions: 1, + dynamic_channel_operations: 0, + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + }); + const handlers = application.graph.nodes.filter( + ({ kind }) => kind === "ipc-handler", + ); + expect(handlers).toHaveLength(1); + expect(handlers[0]?.observations[0]?.properties.receiver).toMatchObject({ + kind: "main-frame", + }); + expect( + application.graph.edges.filter( + ({ evidence }) => + evidence.extractor.operation === "pair-ipc-literal-channel", + ), + ).toEqual([]); + }, +);