diff --git a/src/domain/javascript/electronCallPathBlockPrefixes.ts b/src/domain/javascript/electronCallPathBlockPrefixes.ts index 8df1bc04e4..aebf687aac 100644 --- a/src/domain/javascript/electronCallPathBlockPrefixes.ts +++ b/src/domain/javascript/electronCallPathBlockPrefixes.ts @@ -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; 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(); + readonly #blocks = new WeakMap(); 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(); 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); diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 7b318c733c..9d6a47ee3f 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -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"); diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index 81c8d2287e..2eba39ebc4 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -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) {