diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 2ac0072053..396352c5b2 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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 diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index f166f14f63..429fadf97a 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -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: { diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index faba9aa8f5..93967c5434 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -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, diff --git a/tests/acceptance/applications/electronSwitchConditionsCli.test.ts b/tests/acceptance/applications/electronSwitchConditionsCli.test.ts new file mode 100644 index 0000000000..a5b9e61529 --- /dev/null +++ b/tests/acceptance/applications/electronSwitchConditionsCli.test.ts @@ -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"))', + }), + ]), + ); + }, +);