mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
fix(javascript): recognize Electron APIs through renamed bindings
Electron recognition matched callee spellings, so a bundler rename such as
esbuild's `import { ipcMain as ipcMain2 } from "electron"`, or a destructured
`const { contextBridge: bridge } = require("electron")`, hid every IPC
handler, BrowserWindow, contextBridge and utility process behind it.
Resolve each call and construction root lexically to its Electron import or
require origin, and classify the callee under that export's name. Shadowed
locals keep their own meaning.
On Hermes Desktop v0.21.6, whose main process is one esbuild ESM bundle,
recognized main IPC handlers rise from 1 to 204, BrowserWindow constructions
from 0 to 11, and paired renderer transmissions from 1 to 201.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPY2GmufMNGQKoEgRUx1pd
This commit is contained in:
7 files changed
+186
-20
No files matched your search
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
semanticVariableScope,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { collectSemanticMemberMutations } from "./javascriptSemanticMemberMutations.js";
|
||||
import {
|
||||
collectSemanticReturns,
|
||||
@@ -282,6 +283,84 @@ 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<number, string> => {
|
||||
const facts = new Map<number, string>();
|
||||
const state = createState(file.program);
|
||||
collectDefinitions(file.program, state);
|
||||
const exportsByBinding = new Map<string, string | null>();
|
||||
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;
|
||||
if (binding.mutable || binding.initializers.length !== 1) 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),
|
||||
|
||||
@@ -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<number, JavaScriptOpenReceiverFact>,
|
||||
): 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<number, JavaScriptOpenReceiverFact>,
|
||||
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<number, JavaScriptOpenReceiverFact>,
|
||||
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);
|
||||
|
||||
@@ -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<number, string>;
|
||||
}
|
||||
|
||||
/** Candidate import, require, worker, or service-worker reference. */
|
||||
|
||||
@@ -89,6 +89,33 @@ 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("returns a tagged cancellation without executing application code", async () => {
|
||||
const root = await fixtureDirectory();
|
||||
const controller = new AbortController();
|
||||
@@ -299,6 +326,45 @@ const fixtureDirectory = async (): Promise<string> => {
|
||||
return root;
|
||||
};
|
||||
|
||||
const renamedBindingFixtureDirectory = async (): Promise<string> => {
|
||||
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<string> => {
|
||||
const root = await createTestTempDirectory("rea-electron-computed-members-");
|
||||
await mkdir(join(root, "native"));
|
||||
|
||||
Reference in new issue
Block a user