fix(electron): honor ordered switch-case completion in call paths (#10057)

fix(electron): honor switch-case completion in wrapper paths
This commit is contained in:
morluto authored and GitHub committed 2026-11-11 17:14:16 +08:00
1 parent 827b808248
commit 4073cfc932
3 files changed
+116 -10

No files matched your search

@@ -45,6 +45,9 @@ interface PrefixEntry {
readonly fact: ElectronStatementFallthrough;
}
/** Both braced blocks and switch cases own ordered statement sequences. */
type StatementList = t.BlockStatement | t.SwitchCase;
interface BlockIndex {
readonly positions: WeakMap<t.Node, number>;
readonly entries: readonly PrefixEntry[];
@@ -57,7 +60,7 @@ interface BlockIndex {
* to the invocation's symbolic environment and can establish unknown coverage.
*/
export class ElectronCallPathBlockPrefixes {
readonly #blocks = new WeakMap<t.BlockStatement, BlockIndex>();
readonly #blocks = new WeakMap<StatementList, BlockIndex>();
readonly #facts = new WeakMap<
t.Statement,
ElectronStatementFallthrough | null
@@ -120,7 +123,7 @@ export class ElectronCallPathBlockPrefixes {
if (depth >= 128) return UNKNOWN;
if (!this.admitVisit()) return undefined;
if (t.isReturnStatement(node) || t.isThrowStatement(node)) return EXIT;
if (t.isBreakStatement(node))
if (t.isBreakStatement(node) || t.isContinueStatement(node))
return {
fallthrough: false,
exits: false,
@@ -162,13 +165,15 @@ export class ElectronCallPathBlockPrefixes {
private exits(
node: t.Node | null | undefined,
block: t.BlockStatement,
block: StatementList,
): boolean | undefined {
const result = this.completion(node);
if (result === undefined || result.fallthrough || result.uncertain)
return result === undefined ? undefined : false;
for (const label of result.breaks) {
if (!label || !this.parents) return false;
// A switch-local break or enclosing-loop continue ends this case.
if (!label && t.isSwitchCase(block)) continue;
if (!this.parents) return false;
let matched = false;
for (
let parent = this.parents.get(block), n = 0;
@@ -177,7 +182,11 @@ export class ElectronCallPathBlockPrefixes {
) {
if (!this.admitVisit()) return undefined;
if (t.isFunction(parent)) break;
if (t.isLabeledStatement(parent) && parent.label.name === label) {
if (
label
? t.isLabeledStatement(parent) && parent.label.name === label
: t.isLoop(parent) || t.isSwitchStatement(parent)
) {
matched = true;
break;
}
@@ -189,7 +198,7 @@ export class ElectronCallPathBlockPrefixes {
statement(
statement: t.Statement,
block: t.BlockStatement,
block: StatementList,
): ElectronStatementFallthrough | null | undefined {
const cached = this.#facts.get(statement);
if (cached !== undefined) return cached;
@@ -218,7 +227,7 @@ export class ElectronCallPathBlockPrefixes {
}
*before(
block: t.BlockStatement,
block: StatementList,
child: t.Node,
): Generator<
void,
@@ -229,7 +238,8 @@ export class ElectronCallPathBlockPrefixes {
if (index === undefined) {
const positions = new WeakMap<t.Node, number>();
const entries: PrefixEntry[] = [];
for (const [position, statement] of block.body.entries()) {
const statements = t.isSwitchCase(block) ? block.consequent : block.body;
for (const [position, statement] of statements.entries()) {
if (!this.admitVisit()) return undefined;
positions.set(statement, position);
const fact = this.statement(statement, block);
@@ -1,3 +1,4 @@
import { Script } from "node:vm";
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
@@ -16,6 +17,90 @@ it("retains switch selection on a direct bridge exposure", () => {
]);
});
it.each(
["return", "throw new Error('stop')", "break", "continue"].flatMap(
(completion) =>
[false, true].flatMap((wrapped) =>
[false, true].map((nested) => ({ completion, wrapped, nested })),
),
),
)(
"excludes switch-case calls after $completion (wrapped=$wrapped, nested=$nested)",
({ completion, wrapped, nested }) => {
const exposure = (channel: string) =>
`e.contextBridge.exposeInMainWorld("${channel}", {send:()=>e.ipcRenderer.send("${channel}")});`;
const body = `for (const item of [0]) { switch (mode) {
case "off": ${nested ? "{" : ""} ${completion}; ${wrapped ? 'e.ipcRenderer.send("dead");' : exposure("dead")} ${nested ? "}" : ""}
default: ${wrapped ? 'e.ipcRenderer.send("live");' : exposure("live")}
} }`;
const source = `const e=require("electron");
${wrapped ? `e.contextBridge.exposeInMainWorld("api", {run(){${body}}});` : `function install(){${body}}`}
`;
expect(() => new Script(source)).not.toThrow();
const result = analyzeJavaScriptStaticSource(source);
expect(
result.electron.call_paths?.some(({ channel }) => channel === "dead"),
).toBe(false);
expect(
result.electron.call_paths?.some(({ channel }) => channel === "live"),
).toBe(true);
// Unreachable syntax remains observed; only a derived invocation path is removed.
expect(result.electron.ipc.some(({ channel }) => channel === "dead")).toBe(
true,
);
},
);
it.each([false, true])(
"retains an earlier case-local return guard (wrapped=%s)",
(wrapped) => {
const target = wrapped
? 'e.ipcRenderer.send("guarded");'
: 'e.contextBridge.exposeInMainWorld("api", {send:()=>e.ipcRenderer.send("guarded")});';
const body = `switch(mode){case "selected": if(blocked)return; ${target}}`;
const source = `const e=require("electron");
${wrapped ? `e.contextBridge.exposeInMainWorld("api",{run(){${body}}});` : `function install(){${body}}`}
`;
expect(() => new Script(source)).not.toThrow();
const result = analyzeJavaScriptStaticSource(source);
const paths =
result.electron.call_paths?.filter(
({ channel }) => channel === "guarded",
) ?? [];
expect(paths.length).toBeGreaterThan(0);
expect(
paths.every(({ conditions }) =>
conditions.some(({ expression }) => expression === "!(blocked)"),
),
).toBe(true);
},
);
it.each(
[
"while (true) { break; }",
"switch (inner) { case 0: break; }",
"for (const nested of [0]) { continue; }",
"local: { break local; }",
].flatMap((inner) => [false, true].map((wrapped) => ({ inner, wrapped }))),
)(
"retains outer-case continuation after $inner (wrapped=$wrapped)",
({ inner, wrapped }) => {
const target = wrapped
? 'e.ipcRenderer.send("live");'
: 'e.contextBridge.exposeInMainWorld("api", {send:()=>e.ipcRenderer.send("live")});';
const body = `switch (mode) { case "selected": ${inner} ${target} }`;
const source = `const e=require("electron");
${wrapped ? `e.contextBridge.exposeInMainWorld("api", {run(){${body}}});` : `function install(){${body}}`}
`;
expect(() => new Script(source)).not.toThrow();
const result = analyzeJavaScriptStaticSource(source);
expect(
result.electron.call_paths?.some(({ channel }) => channel === "live"),
).toBe(true);
},
);
it("retains a conservative selection for stacked and fallthrough cases", () => {
const result = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcRenderer } = require("electron");
@@ -1458,10 +1458,17 @@ export function* collectElectronWrappedCallPathsSteps(
const branch = node.cases[index]!;
if (branch.test) walk(branch.test, env, context);
if (remainingVisits < 0) return;
const branchContext = switchCaseContext(node, index, context);
let branchContext = switchCaseContext(node, index, context);
for (const statement of branch.consequent) {
if (remainingVisits < 0) return;
walk(statement, env, branchContext);
const fallthrough = sequentialFallthrough(
prefixes.statement(statement, branch) ?? null,
env,
branchContext,
);
if (!fallthrough.continues) break;
branchContext = fallthrough.context;
}
}
return;
@@ -1532,7 +1539,11 @@ export function* collectElectronWrappedCallPathsSteps(
parent.left,
parent.operator === "??" ? "nullish" : parent.operator === "&&",
);
} else if (t.isBlockStatement(parent)) {
}
if (
t.isBlockStatement(parent) ||
(t.isSwitchCase(parent) && child !== parent.test)
) {
const prefix = yield* prefixes.before(parent, child);
if (remainingVisits < 0) return;
if (prefix !== undefined) {