fix(javascript): retain Electron switch conditions (#7910)

* fix(javascript): retain Electron switch conditions

* fix(javascript): bound switch path traversal
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 07:56:15 +08:00
1 parent 086abf3996
commit 844bb46351
4 files changed
+259 -1

No files matched your search

@@ -560,6 +560,8 @@ alternative object. A traced invocation whose key, API object or world stays
unresolved or guarded keeps the call's own unresolved observation.
Unknown spreads, reassigned bindings, accessors,
dynamic channels and unsupported syntax do not become exact implementations.
Literal switch cases retain their selection conditions. Possible fallthrough
remains partial; dynamic case expressions remain unknown.
This pass neither executes the bundle nor proves that runtime guards pass.
An ambiguous or unresolved handler retains its channel registration but does
not acquire a path to one arbitrarily selected callback. Unknown factory
@@ -1,6 +1,104 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it("retains switch selection on a direct bridge exposure", () => {
const result = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcRenderer } = require("electron");
switch (process.platform) {
case "win32":
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
}
`);
expect(result.electron.call_paths?.[0]?.conditions).toEqual([
expect.objectContaining({
expression: '((process.platform) === ("win32"))',
}),
]);
});
it("retains a conservative selection for stacked and fallthrough cases", () => {
const result = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcRenderer } = require("electron");
switch (platform) {
case "first": work();
case "second":
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
}
`);
expect(result.electron.call_paths?.[0]?.conditions).toEqual([
expect.objectContaining({
expression: '((platform) === ("first")) || ((platform) === ("second"))',
}),
]);
expect(result.parse_status).toBe("partial");
});
it("includes the default selection when an earlier default can fall through", () => {
const result = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcRenderer } = require("electron");
switch (platform) {
default: work();
case "second":
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
}
`);
expect(result.electron.call_paths?.[0]?.conditions).toEqual([
expect.objectContaining({
expression: '((platform) === ("second")) || ((platform) !== ("second"))',
}),
]);
expect(result.parse_status).toBe("partial");
});
it("retains switch conditions in a traced bridge wrapper", () => {
const result = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("api", {
ping() { switch (process.platform) { case "win32": return ipcRenderer.invoke("ping"); } }
});
`);
expect(result.electron.call_paths?.[0]?.conditions).toEqual([
expect.objectContaining({
expression: '((process.platform) === ("win32"))',
}),
]);
});
it.each(["direct", "wrapped"] as const)(
"retains the selected case near the end of a switch on a %s path",
(kind) => {
const exposure = `contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });`;
const cases = Array.from(
{ length: 8 },
(_, index) => `case ${index}:`,
).join("\n");
const selected = `${cases}\ncase 8: ${kind === "direct" ? exposure : 'return ipcRenderer.invoke("ping");'}`;
const body =
kind === "direct"
? `switch (platform) { ${selected} }`
: `contextBridge.exposeInMainWorld("api", { ping() { switch (platform) { ${selected} } } });`;
const result = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcRenderer } = require("electron");
${body}
`);
expect(
result.electron.call_paths?.[0]?.conditions[0]?.expression,
).toContain("((platform) === (8))");
},
);
it("does not invent a selection predicate for dynamic case expressions", () => {
const result = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcRenderer } = require("electron");
switch (platform) {
case mutatePlatform():
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
}
`);
expect(result.electron.call_paths?.[0]?.conditions).toEqual([]);
expect(result.parse_status).toBe("partial");
});
it("retains the continuing predicate after a break to an enclosing label", () => {
const result = analyzeJavaScriptStaticSource(`const e=require("electron");
guard: {
@@ -302,6 +302,92 @@ export function* collectElectronWrappedCallPathsSteps(
],
};
};
/** A case can be reached by its own selection or by fallthrough from an earlier case. */
const switchCaseContext = (
node: t.SwitchStatement,
index: number,
context: PathContext,
): PathContext => {
const text = (part: t.Node): string | undefined =>
part.start == null || part.end == null || part.end - part.start > 512
? undefined
: source.slice(part.start, part.end);
const discriminant = text(node.discriminant);
if (discriminant === undefined) {
accumulator.unknownFindings += 1;
return context;
}
const choices: string[] = [];
let expressionBytes = 0;
let defaultBefore = false;
const compare = (test: t.Expression, operator: "===" | "!==") => {
// Dynamic case expressions can mutate the evaluated discriminant; do
// not turn a second source evaluation into an invented predicate.
if (
!t.isStringLiteral(test) &&
!t.isNumericLiteral(test) &&
!t.isBooleanLiteral(test) &&
!t.isNullLiteral(test) &&
!t.isBigIntLiteral(test)
)
return undefined;
const label = text(test);
return label === undefined
? undefined
: `(${discriminant}) ${operator} (${label})`;
};
const append = (output: string[], item: string): boolean => {
expressionBytes += item.length + 6;
if (expressionBytes > 512) return false;
output.push(item);
return true;
};
let fallthrough = false;
for (let i = 0; i <= index; i += 1) {
if (--remainingVisits < 0) return context;
const branch = node.cases[i];
if (!branch) continue;
if (i < index && branch.consequent.length > 0) fallthrough = true;
if (branch.test == null) {
defaultBefore = true;
continue;
}
const match = compare(branch.test, "===");
if (match === undefined || !append(choices, match)) {
accumulator.unknownFindings += 1;
return context;
}
}
if (defaultBefore) {
const unmatched: string[] = [];
for (const branch of node.cases) {
if (--remainingVisits < 0) return context;
if (branch.test == null) continue;
const mismatch = compare(branch.test, "!==");
if (mismatch === undefined || !append(unmatched, mismatch)) {
accumulator.unknownFindings += 1;
return context;
}
}
choices.push(unmatched.length === 0 ? "true" : unmatched.join(" && "));
}
// Earlier case bodies may terminate or change state before reaching this
// case. The selection disjunction is necessary but not a complete path.
if (fallthrough) accumulator.unknownFindings += 1;
const expression = choices.map((choice) => `(${choice})`).join(" || ");
if (expression.length > 512) {
accumulator.unknownFindings += 1;
return context;
}
if (expression === "") return context;
return {
...context,
conditions: [
...context.conditions,
{ expression, location: range(node) },
],
};
};
const sequentialFallthrough = (
fact: ElectronStatementFallthrough | null,
env: SymbolicEnvironment,
@@ -1172,6 +1258,7 @@ export function* collectElectronWrappedCallPathsSteps(
let next = context;
for (const statement of node.body) {
walk(statement, env, next);
if (remainingVisits < 0) return;
const fallthrough = sequentialFallthrough(
prefixes.statement(statement, node) ?? null,
env,
@@ -1218,9 +1305,27 @@ export function* collectElectronWrappedCallPathsSteps(
);
return;
}
if (t.isSwitchStatement(node)) {
walk(node.discriminant, env, context);
for (let index = 0; index < node.cases.length; index += 1) {
if (remainingVisits < 0) return;
const branch = node.cases[index]!;
if (branch.test) walk(branch.test, env, context);
if (remainingVisits < 0) return;
const branchContext = switchCaseContext(node, index, context);
for (const statement of branch.consequent) {
if (remainingVisits < 0) return;
walk(statement, env, branchContext);
}
}
return;
}
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
call(node, env, context);
for (const child of childNodes(node)) walk(child, env, context);
for (const child of childNodes(node)) {
if (remainingVisits < 0) return;
walk(child, env, context);
}
}
let prefixVisits = 0;
function* seed(
@@ -1257,6 +1362,18 @@ export function* collectElectronWrappedCallPathsSteps(
if (truth !== undefined && truth !== positive) return;
if (truth === undefined)
context = condition(context, parent.test, positive);
} else if (t.isSwitchCase(parent) && child !== parent.test) {
const owner = state.parentsByNode.get(parent);
if (owner && t.isSwitchStatement(owner)) {
let index = 0;
while (index < owner.cases.length && owner.cases[index] !== parent) {
if (--remainingVisits < 0) return;
index += 1;
}
if (index < owner.cases.length)
context = switchCaseContext(owner, index, context);
else accumulator.unknownFindings += 1;
} else accumulator.unknownFindings += 1;
} else if (t.isLogicalExpression(parent) && child === parent.right) {
const right = symbolicLogicalRight(
parent.operator,
@@ -0,0 +1,41 @@
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"retains a switch selection on Electron bridge paths through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-electron-switch-");
await writeFixtureFiles(root, {
"package.json": JSON.stringify({
name: "switch",
version: "1",
main: "main.js",
}),
"main.js": `const { BrowserWindow, ipcMain } = require("electron");
ipcMain.handle("ping", () => "pong");
new BrowserWindow({ webPreferences: { preload: "preload.js" } });`,
"preload.js": `const { contextBridge, ipcRenderer } = require("electron");
switch (process.platform) {
case "win32":
contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });
}`,
});
const result = await analyzeJavaScriptDirectory(surface, cli, root);
const edges = result.graph.edges.filter(
(edge) =>
edge.evidence.extractor.operation === "trace-electron-wrapper-path",
);
expect(edges.length).toBeGreaterThan(0);
for (const edge of edges)
expect(edge.properties.conditions).toEqual(
expect.arrayContaining([
expect.objectContaining({
expression: '((process.platform) === ("win32"))',
}),
]),
);
},
);