Merge pull request #1391 from marnixpluim/fix/electron-import-aliases
fix(javascript): recognize Electron APIs through renamed bindings
This commit is contained in:
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"));
|
||||
|
||||
Reference in new issue
Block a user