Merge pull request #1391 from marnixpluim/fix/electron-import-aliases

fix(javascript): recognize Electron APIs through renamed bindings
This commit is contained in:
morluto authored and GitHub committed 2026-10-10 13:57:24 +08:00
commit 817efaca9a
7 files changed
+250 -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 = (
@@ -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<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;
// 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),
@@ -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,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<string> => {
return root;
};
const requireAliasFixtureDirectory = async (): Promise<string> => {
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<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"));