fix(electron): scope IPC pairing to application ownership

This commit is contained in:
morluto
2026-10-27 08:55:34 +08:00
parent 781eb1b28a
commit aa253bd13f
8 changed files with 260 additions and 80 deletions
@@ -43,49 +43,72 @@ export const collectElectronIpcRecords = (
)
.sort((left, right) => compareUnicodeCodePoints(left.key, right.key));
/** Match only exact literal channels and retain ambiguous matches separately. */
export const classifyElectronIpcPairings = (
records: readonly ElectronIpcRecord[],
): readonly ElectronIpcPairingState[] => {
const handlers = records.filter(isMainHandler);
return records.filter(isPairableRendererTransmission).map((transmission) => {
const candidates =
transmission.finding.channel === null
? []
: handlers.filter(
(handler) =>
(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[0]?.finding.receiver === undefined
? "paired"
: candidates.length > 0
? "ambiguous"
: "unpaired",
};
});
};
/** One interpretation shared by summary and every graph pairing consumer. */
export interface ElectronIpcContext {
readonly records: readonly ElectronIpcRecord[];
readonly owners: ReadonlyMap<string, string | null>;
readonly states: readonly ElectronIpcPairingState[];
readonly pairings: readonly ElectronIpcPairing[];
}
/** Return only pairings supported by one unique literal main handler. */
export const unambiguousElectronIpcPairings = (
records: readonly ElectronIpcRecord[],
): readonly ElectronIpcPairing[] =>
classifyElectronIpcPairings(records).flatMap((state) => {
/** Equal channel text only establishes a pairing within one proven application owner. */
export const createElectronIpcContext = (
analysis: JavaScriptArtifactAnalysis,
applicationOwner: (file: JavaScriptArtifactFile) => string | null,
): ElectronIpcContext => {
const records = collectElectronIpcRecords(analysis);
const owners = new Map(
records.map((record) => [record.key, applicationOwner(record.file)]),
);
const handlers = records.filter(isMainHandler);
const states: ElectronIpcPairingState[] = records
.filter(isPairableRendererTransmission)
.map((transmission) => {
const owner = owners.get(transmission.key);
const matching =
transmission.finding.channel === null
? []
: handlers.filter(
(handler) =>
(handler.finding.channel === transmission.finding.channel ||
(handler.finding.receiver !== undefined &&
handler.finding.channel === null)) &&
compatibleIpcModes(transmission.finding, handler.finding),
);
const candidates = matching.filter(
(handler) => owners.get(handler.key) === owner,
);
// Unknown ownership can conceal competing dispatch contexts. Different owners
// do not prove runtime isolation, so retain ambiguity rather than absence.
const uncertain =
owner == null ||
matching.some((handler) => owners.get(handler.key) == null);
return {
transmission,
candidates,
status:
!uncertain &&
candidates.length === 1 &&
candidates[0]?.finding.receiver === undefined
? "paired"
: uncertain || matching.length > 0
? "ambiguous"
: "unpaired",
};
});
const pairings = states.flatMap((state) => {
const handler = state.candidates[0];
return state.status === "paired" && handler !== undefined
? [{ transmission: state.transmission, handler }]
: [];
});
return { records, owners, states, pairings };
};
/** Summarize static Electron findings without treating gaps as absence. */
export const summarizeElectronBoundaries = (
analysis: JavaScriptArtifactAnalysis,
ipcContext: ElectronIpcContext,
directoryPaths?: ReadonlySet<string>,
): ElectronBoundarySummary => {
const files = new Map(analysis.files.map(({ file }) => [file.path, file]));
@@ -113,8 +136,8 @@ export const summarizeElectronBoundaries = (
finding,
})),
);
const ipc = collectElectronIpcRecords(analysis);
const pairing = classifyElectronIpcPairings(ipc);
const ipc = ipcContext.records;
const pairing = ipcContext.states;
const literalChannels = new Set(
ipc.flatMap(({ finding }) =>
finding.channel === null ? [] : [finding.channel],
@@ -1,3 +1,4 @@
import type { ElectronIpcContext } from "./ElectronBoundaryAnalysis.js";
import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js";
import { addElectronNativeBoundaries } from "./ElectronBoundaryGraphNative.js";
@@ -7,9 +8,10 @@ import { addElectronCallPaths } from "./ElectronBoundaryGraphCallPaths.js";
/** Add every static Electron process and security-boundary fact to the JAG. */
export const addElectronBoundaries = (
context: JavaScriptArtifactGraphContext,
ipc: ElectronIpcContext,
): void => {
addElectronWindowBoundaries(context);
addElectronIpcBoundaries(context);
addElectronIpcBoundaries(context, ipc);
addElectronNativeBoundaries(context);
addElectronCallPaths(context);
addElectronCallPaths(context, ipc);
};
@@ -3,10 +3,9 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicat
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import {
collectElectronIpcRecords,
unambiguousElectronIpcPairings,
type ElectronIpcRecord,
import type {
ElectronIpcContext,
ElectronIpcRecord,
} from "./ElectronBoundaryAnalysis.js";
import {
addElectronInferenceEdge,
@@ -30,13 +29,14 @@ interface ProjectedPath {
/** Retain context-specific wrapper paths, then join only unique IPC records. */
export const addElectronCallPaths = (
context: JavaScriptArtifactGraphContext,
ipc: ElectronIpcContext,
): void => {
const projected: ProjectedPath[] = [];
for (const { file, javascript } of context.analysis.files) {
if (javascript === null) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const path of javascript.electron.call_paths ?? []) {
const pathId = sha256Text(JSON.stringify(path));
const pathId = sha256Text(JSON.stringify([file.path, path]));
const nodes = path.steps.map((step, index) =>
context.accumulator.addNode({
kind: "javascript-expression",
@@ -137,9 +137,7 @@ export const addElectronCallPaths = (
projected.push({ file, path, coverage, nodes });
}
}
for (const { transmission, handler } of unambiguousElectronIpcPairings(
collectElectronIpcRecords(context.analysis),
)) {
for (const { transmission, handler } of ipc.pairings) {
const sources = projected.filter(
(p) => p.path.entry_kind === "bridge-member" && matches(p, transmission),
);
@@ -15,10 +15,9 @@ import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
collectElectronIpcRecords,
unambiguousElectronIpcPairings,
type ElectronIpcRecord,
import type {
ElectronIpcContext,
ElectronIpcRecord,
} from "./ElectronBoundaryAnalysis.js";
import {
addElectronAstEdge,
@@ -30,6 +29,7 @@ import {
interface IpcGraphState {
readonly context: JavaScriptArtifactGraphContext;
readonly records: readonly ElectronIpcRecord[];
readonly ipc: ElectronIpcContext;
readonly channels: Map<string, ApplicationNode>;
readonly handlers: Map<string, ApplicationNode>;
readonly sources: Map<string, ApplicationNode>;
@@ -54,10 +54,12 @@ interface ValidationObservationInput {
/** Project IPC syntax, handler locations, validations, and exact pairings. */
export const addElectronIpcBoundaries = (
context: JavaScriptArtifactGraphContext,
ipc: ElectronIpcContext,
): void => {
const state: IpcGraphState = {
context,
records: collectElectronIpcRecords(context.analysis),
records: ipc.records,
ipc,
channels: new Map(),
handlers: new Map(),
sources: new Map(),
@@ -82,7 +84,12 @@ const addIpcRecord = (
);
if (source === undefined) return;
state.sources.set(record.key, source);
const channel = addChannelNode(context, record, coverage);
const channel = addChannelNode(
context,
record,
coverage,
state.ipc.owners.get(record.key) ?? null,
);
state.channels.set(record.key, channel);
if (record.finding.mode === "listen" || record.finding.mode === "handle") {
const handler = addHandlerNode({
@@ -117,6 +124,7 @@ const addChannelNode = (
context: JavaScriptArtifactGraphContext,
record: ElectronIpcRecord,
coverage: JavaScriptArtifactGraphCoverage,
owner: string | null,
): ApplicationNode => {
const { finding, file } = record;
const channelKey =
@@ -125,7 +133,10 @@ const addChannelNode = (
: `literal:${finding.channel}`;
const identityKey =
finding.receiver === undefined
? channelKey
? JSON.stringify([
owner === null ? ["unknown", file.path] : ["application", owner],
channelKey,
])
: JSON.stringify([
file.path,
file.sha256,
@@ -204,7 +215,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
const handlerKey = `${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`;
const identityKey =
finding.receiver === undefined
? handlerKey
? JSON.stringify([file.path, handlerKey])
: JSON.stringify([
file.path,
finding.module_runtime ?? null,
@@ -280,7 +291,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
};
const addLiteralPairings = (state: IpcGraphState): void => {
for (const pairing of unambiguousElectronIpcPairings(state.records)) {
for (const pairing of state.ipc.pairings) {
const source = state.sources.get(pairing.transmission.key);
const handler = state.handlers.get(pairing.handler.key);
const coverage = coverageFor(state.context, pairing.transmission.file);
@@ -1,3 +1,4 @@
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { ELECTRON_IDENTITY_LIMITATION } from "../../domain/javascript/javascriptElectronMemberWrites.js";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import {
@@ -13,6 +14,7 @@ import {
selfReferenceOmissions,
dependencyManifest,
applicationContainerIdentity,
applicationRootPath,
type JavaScriptArtifactGraphContext,
} from "./JavaScriptArtifactGraphContext.js";
import {
@@ -45,8 +47,9 @@ import {
import { addJavaScriptBundlerNodes } from "./JavaScriptArtifactGraphBundlers.js";
import { addElectronBoundaries } from "./ElectronBoundaryGraph.js";
import {
classifyElectronIpcPairings,
collectElectronIpcRecords,
createElectronIpcContext,
summarizeElectronBoundaries,
type ElectronIpcContext,
} from "./ElectronBoundaryAnalysis.js";
/**
@@ -58,21 +61,33 @@ export function* buildImmutableJavaScriptArtifactGraphSteps(
snapshot: ArtifactInventorySnapshot,
fileSet: JavaScriptArtifactFileSet,
analysis: JavaScriptArtifactAnalysis,
): Generator<void, JavaScriptApplicationGraph> {
): Generator<
void,
{
readonly graph: JavaScriptApplicationGraph;
readonly electronSummary: ElectronBoundarySummary;
}
> {
const input = yield* buildJavaScriptArtifactGraphInputSteps(
snapshot,
fileSet,
analysis,
);
// The input was built here and is not retained, so it can be transferred.
return yield* sealTransferredJavaScriptApplicationGraphSteps(input);
return {
graph: yield* sealTransferredJavaScriptApplicationGraphSteps(input.graph),
electronSummary: input.electronSummary,
};
}
function* buildJavaScriptArtifactGraphInputSteps(
snapshot: ArtifactInventorySnapshot,
fileSet: JavaScriptArtifactFileSet,
analysis: JavaScriptArtifactAnalysis,
): Generator<void, unknown> {
): Generator<
void,
{ readonly graph: unknown; readonly electronSummary: ElectronBoundarySummary }
> {
const accumulator = new JavaScriptArtifactGraphAccumulator();
const root = createJavaScriptArtifactRootNode(accumulator, snapshot);
const parsedPackages = new Set(
@@ -183,7 +198,10 @@ function* buildJavaScriptArtifactGraphInputSteps(
yield* addJavaScriptModuleRelationshipsSteps(projectionContext);
const findingLimitations =
yield* addJavaScriptStaticFindingsSteps(projectionContext);
addElectronBoundaries(projectionContext);
const ipc = createElectronIpcContext(analysis, (file) =>
applicationRootPath(projectionContext, file),
);
addElectronBoundaries(projectionContext, ipc);
yield;
addJavaScriptHtmlRoles(projectionContext);
yield;
@@ -191,19 +209,31 @@ function* buildJavaScriptArtifactGraphInputSteps(
yield;
const coverage = graphCoverage(context);
return {
schema: "JavaScriptApplicationGraph",
root_node_ids:
packageRoots.length === 0
? [root.node_id]
: packageRoots.map(({ node_id: id }) => id),
nodes: accumulator.nodes(),
edges: accumulator.edges(),
coverage,
limitations: [
...bundlerLimitations,
...findingLimitations,
...graphLimitations(context, coverage.status, relationshipOmissions),
],
electronSummary: summarizeElectronBoundaries(
analysis,
ipc,
context.directoryPaths,
),
graph: {
schema: "JavaScriptApplicationGraph",
root_node_ids:
packageRoots.length === 0
? [root.node_id]
: packageRoots.map(({ node_id: id }) => id),
nodes: accumulator.nodes(),
edges: accumulator.edges(),
coverage,
limitations: [
...bundlerLimitations,
...findingLimitations,
...graphLimitations(
context,
coverage.status,
relationshipOmissions,
ipc,
),
],
},
};
}
@@ -295,9 +325,10 @@ const graphLimitations = (
context: JavaScriptArtifactGraphContext,
coverage: "complete" | "partial" | "unknown" | "unavailable",
relationshipOmissions: JavaScriptModuleRelationshipOmissions,
ipcContext: ElectronIpcContext,
): string[] => {
const ipc = collectElectronIpcRecords(context.analysis);
const pairings = classifyElectronIpcPairings(ipc);
const ipc = ipcContext.records;
const pairings = ipcContext.states;
const electronFindings = context.analysis.files.reduce(
(count, { javascript }) =>
count +
@@ -348,7 +379,7 @@ const graphLimitations = (
: []),
...(pairings.some(({ status }) => status === "ambiguous")
? [
"Some literal IPC channels have multiple compatible main handlers; ambiguous calls are not paired to any handler.",
"Some IPC transmissions have competing handlers or unresolved application ownership; ambiguous calls are not paired to any handler.",
]
: []),
...(context.analysis.files.some(
@@ -46,7 +46,6 @@ import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScri
import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js";
import { scanCanonicalArtifactInventoryInScope } from "../../artifacts/inventory/scanCanonical.js";
import { createJavaScriptNextAppContext } from "./JavaScriptNextAppContext.js";
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import type { ProgressReporter } from "../ProgressReporter.js";
import {
@@ -281,7 +280,7 @@ export const reconstructJavaScriptArtifact = async (
"seal_javascript_application_graph",
"Sealing the validated application graph",
);
const graph = await completeJavaScriptAnalysisSteps(
const { graph, electronSummary } = await completeJavaScriptAnalysisSteps(
applicationGraphSteps,
);
await reportCompletedPhase(
@@ -366,7 +365,7 @@ export const reconstructJavaScriptArtifact = async (
: { source_selection: files.source_selection }),
graph,
semantic_graph: semanticGraph,
electron_summary: summarizeElectronBoundaries(analysis, directoryPaths),
electron_summary: electronSummary,
statistics: {
relevant_files:
files.files.length + files.unavailable_relevant_files.length,
@@ -112,3 +112,119 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
).toEqual([]);
},
);
cliTest.for(["CLI", "stdio MCP"] as const)(
"pairs literal IPC within proven application owners through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-application-ipc-");
await writeFixtureFiles(root, {
"a/package.json": JSON.stringify({ name: "a", main: "main.cjs" }),
"a/main.cjs": `const { ipcMain, clipboard } = require("electron");
ipcMain.handle("shared", (event, value) => clipboard.writeText(value));`,
"a/preload.cjs": `const { ipcRenderer, contextBridge } = require("electron");
contextBridge.exposeInMainWorld("api", { read: (value) => ipcRenderer.invoke("shared", value) });`,
"b/package.json": JSON.stringify({ name: "b", main: "main.cjs" }),
"b/main.cjs": `const { ipcMain, clipboard } = require("electron");
ipcMain.handle("shared", (event, value) => clipboard.writeText(value));`,
"b/preload.cjs": `const { ipcRenderer, contextBridge } = require("electron");
contextBridge.exposeInMainWorld("api", { read: (value) => ipcRenderer.invoke("shared", value) });`,
});
const application = await analyzeJavaScriptDirectory(surface, cli, root);
expect(application.summary.ipc).toMatchObject({
operations: 4,
paired_renderer_transmissions: 2,
ambiguous_renderer_transmissions: 0,
});
const pairs = application.graph.edges.filter(
({ evidence }) =>
evidence.extractor.operation === "pair-ipc-literal-channel",
);
const joins = application.graph.edges.filter(
({ evidence }) =>
evidence.extractor.operation === "join-electron-wrapper-paths",
);
expect(joins).toHaveLength(2);
expect(
new Set(joins.map(({ target_node_id }) => target_node_id)).size,
).toBe(2);
const nodes = new Map(
application.graph.nodes.map((node) => [node.node_id, node]),
);
for (const join of joins) {
const target = nodes.get(join.target_node_id);
expect(target?.observations).toEqual(
expect.arrayContaining([
expect.objectContaining({
evidence: expect.objectContaining({
location: expect.objectContaining({
available: true,
value: expect.objectContaining({
source: join.properties.handler_path,
}),
}),
}),
}),
]),
);
}
expect(pairs).toHaveLength(2);
expect(
new Set(pairs.map(({ target_node_id }) => target_node_id)).size,
).toBe(2);
expect(
application.graph.nodes.filter(({ kind }) => kind === "ipc-handler"),
).toHaveLength(2);
for (const edge of pairs) {
expect(edge.evidence.location.available).toBe(true);
const location = edge.evidence.location;
if (!location.available || location.value.kind !== "source-range")
throw new Error("IPC pairing omitted its authored source location");
expect(edge.properties.handler_path).toBe(
location.value.source.startsWith("a/") ? "a/main.cjs" : "b/main.cjs",
);
}
expect(
application.graph.nodes.filter(({ kind }) => kind === "ipc-channel"),
).toHaveLength(2);
},
);
cliTest.for(["CLI", "stdio MCP"] as const)(
"retains unknown IPC applicability across distinct or shared application owners through %s",
async (surface, { cli }) => {
for (const sharedSource of [false, true]) {
const root = await createTestTempDirectory(
"rea-unknown-application-ipc-",
);
await writeFixtureFiles(root, {
"a/package.json": JSON.stringify({ name: "a", main: "main.cjs" }),
"a/main.cjs": sharedSource
? `require("../shared.cjs");`
: `module.exports = {};`,
"b/package.json": JSON.stringify({ name: "b", main: "main.cjs" }),
"b/main.cjs": `const { ipcMain } = require("electron");
${sharedSource ? 'require("../shared.cjs");' : ""}
ipcMain.handle("shared", () => "result");`,
[sharedSource ? "shared.cjs" : "a/preload.cjs"]:
`const { ipcRenderer } = require("electron"); ipcRenderer.invoke("shared");`,
});
const application = await analyzeJavaScriptDirectory(surface, cli, root);
expect(application.summary.ipc).toMatchObject({
operations: 2,
paired_renderer_transmissions: 0,
ambiguous_renderer_transmissions: 1,
unpaired_literal_renderer_transmissions: 0,
});
expect(
application.graph.edges.filter(({ evidence }) =>
["pair-ipc-literal-channel", "join-electron-wrapper-paths"].includes(
evidence.extractor.operation,
),
),
).toEqual([]);
expect(
application.graph.nodes.filter(({ kind }) => kind === "ipc-handler"),
).toHaveLength(1);
}
},
);
+1 -1
View File
@@ -8,4 +8,4 @@ export const immutableArtifactGraph = (
): Promise<JavaScriptApplicationGraph> =>
completeJavaScriptAnalysisSteps(
buildImmutableJavaScriptArtifactGraphSteps(...args),
);
).then(({ graph }) => graph);