mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
test(ipc): measure what crosses the bridge, and make the leak census run
Three claims around the boundary were stronger than the evidence for them. The rethrow was described as preserving identity, properties and stack. It does, inside preload — but the renderer receives the rejection through contextBridge, which copies it. Measured on the real binary under the same webPreferences as createMainWindow: the copy is a plain Error, its prototype and own properties are gone, one object rejected twice arrives as two, and the stack is regenerated from the message. Nothing is lost by narrowing in place that the bridge would not have dropped anyway, because an invoke rejection reaches the boundary already flattened. The comment now says that, and ipc-invoke-boundary-bridge.electron.test.ts asserts the renderer's view. The ratchet had a second bypass: computed access with string-literal keys, which the scan's own string-blanking erased before matching. It passed 3/3 green with a live escape in the tree. Two bypasses is a shape problem, not a spelling problem, so the arms are now labelled for what they are — the preload scan and the new main-world exposure assertion are fences, the raw-bridge scan is a tripwire and says so. window.electron remains a real open door that no source scan can close; the note records that not opening it is the only thing that would. The 115-surface figure could not be reconstructed from the repository. The census now exists and runs: 131 expressions across 84 modules on a stated axis, with a planted leak as a positive control and a stripped variant plus a non-preload variant as negative controls.
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import { resolveElectronProbeLaunch } from '../main/browser/electron-probe-display-launch'
|
||||
|
||||
/**
|
||||
* What a renderer consumer actually receives, measured across a real `contextBridge`.
|
||||
*
|
||||
* The boundary rethrows the object `ipcRenderer.invoke` rejected with, so inside preload the
|
||||
* rejection keeps its identity, its properties and its original stack. None of that reaches the
|
||||
* renderer: `contextBridge` copies the value, and a copy is a fresh plain `Error`. Unit tests that
|
||||
* stop at the preload side cannot see this, which is why the guarantee was overstated — so this
|
||||
* file drives the real binary and asserts the renderer's view, including the parts that are lost.
|
||||
*
|
||||
* The identity and own-property assertions describe the bridge, not the strip, and would hold with
|
||||
* the boundary deleted. They are here to keep the *claim* honest, not to pin the fix; the strip is
|
||||
* pinned by the message and stack assertions, which the `unstripped` control moves.
|
||||
*/
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
type ErrorView = {
|
||||
isError: boolean
|
||||
ctorName: string
|
||||
name: string
|
||||
message: string
|
||||
ownKeys: string[]
|
||||
stackFirstLine: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
type FixtureResult = {
|
||||
preloadStripped: ErrorView
|
||||
preloadCarriesOwnProperties: ErrorView
|
||||
rendererStripped: ErrorView
|
||||
rendererUnstripped: ErrorView
|
||||
rendererCarriesOwnProperties: ErrorView
|
||||
sameObjectAcrossTwoRejections: boolean
|
||||
mutatingOneCopyLeaksToTheOther: boolean
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
/** `sandbox: true` matches `createMainWindow`, and a sandboxed preload may only require `electron`. */
|
||||
function preloadEntry(boundaryPath: string): string {
|
||||
return `
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { invoke } from ${JSON.stringify(boundaryPath)}
|
||||
|
||||
const view = (e) => ({
|
||||
isError: e instanceof Error,
|
||||
ctorName: (e && e.constructor && e.constructor.name) || '',
|
||||
name: (e && e.name) || '',
|
||||
message: (e && e.message) || '',
|
||||
ownKeys: e && typeof e === 'object' ? Object.getOwnPropertyNames(e).sort() : [],
|
||||
stackFirstLine: e && e.stack ? String(e.stack).split('\\n')[0] : '',
|
||||
code: e && e.code
|
||||
})
|
||||
|
||||
const record = {}
|
||||
|
||||
// The real boundary, on a channel whose handler rejects with a readable reason.
|
||||
const stripped = invoke('probe:reject').catch((rejection) => {
|
||||
record.preloadStripped = view(rejection)
|
||||
throw rejection
|
||||
})
|
||||
|
||||
// Control: the same handler reached without the boundary, so the envelope is still on the message.
|
||||
const unstripped = ipcRenderer.invoke('probe:reject-control')
|
||||
|
||||
// A preload-constructed error carrying own properties, to characterise the bridge itself.
|
||||
class BoundaryProbeError extends Error {
|
||||
constructor(message) {
|
||||
super(message)
|
||||
this.name = 'BoundaryProbeError'
|
||||
this.code = 'E_PROBE'
|
||||
}
|
||||
}
|
||||
const carrier = new BoundaryProbeError('carries own properties')
|
||||
record.preloadCarriesOwnProperties = view(carrier)
|
||||
|
||||
// One object, rejected twice: the renderer sees two copies or one reference.
|
||||
const shared = new Error('rejected twice')
|
||||
|
||||
contextBridge.exposeInMainWorld('probe', {
|
||||
stripped: () => stripped,
|
||||
unstripped: () => unstripped,
|
||||
carrier: () => Promise.reject(carrier),
|
||||
sharedFirst: () => Promise.reject(shared),
|
||||
sharedSecond: () => Promise.reject(shared),
|
||||
record: () => record
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
const RENDERER_PROBE = `
|
||||
const view = (e) => ({
|
||||
isError: e instanceof Error,
|
||||
ctorName: (e && e.constructor && e.constructor.name) || '',
|
||||
name: (e && e.name) || '',
|
||||
message: (e && e.message) || '',
|
||||
ownKeys: e && typeof e === 'object' ? Object.getOwnPropertyNames(e).sort() : [],
|
||||
stackFirstLine: e && e.stack ? String(e.stack).split('\\n')[0] : '',
|
||||
code: e && e.code
|
||||
})
|
||||
const rejection = async (call) => {
|
||||
try {
|
||||
await call()
|
||||
} catch (caught) {
|
||||
return caught
|
||||
}
|
||||
throw new Error('expected a rejection')
|
||||
}
|
||||
window.__probe = async () => {
|
||||
const stripped = await rejection(() => window.probe.stripped())
|
||||
const unstripped = await rejection(() => window.probe.unstripped())
|
||||
const carrier = await rejection(() => window.probe.carrier())
|
||||
const first = await rejection(() => window.probe.sharedFirst())
|
||||
const second = await rejection(() => window.probe.sharedSecond())
|
||||
first.message = 'mutated in the renderer'
|
||||
return {
|
||||
...window.probe.record(),
|
||||
rendererStripped: view(stripped),
|
||||
rendererUnstripped: view(unstripped),
|
||||
rendererCarriesOwnProperties: view(carrier),
|
||||
sameObjectAcrossTwoRejections: first === second,
|
||||
mutatingOneCopyLeaksToTheOther: second.message === 'mutated in the renderer'
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function fixtureMain(paths: { htmlPath: string; preloadPath: string; resultPath: string }): string {
|
||||
return `
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
|
||||
class HandlerError extends Error {
|
||||
constructor(message) {
|
||||
super(message)
|
||||
this.name = 'HandlerError'
|
||||
this.code = 'E_HANDLER'
|
||||
}
|
||||
}
|
||||
const reject = () => {
|
||||
throw new HandlerError('Host key verification failed')
|
||||
}
|
||||
ipcMain.handle('probe:reject', reject)
|
||||
ipcMain.handle('probe:reject-control', reject)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(${JSON.stringify(paths.resultPath)}, JSON.stringify({ error: 'fixture timeout' }))
|
||||
process.exit(1)
|
||||
}, 30000)
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: ${JSON.stringify(paths.preloadPath)},
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
await window.loadFile(${JSON.stringify(paths.htmlPath)})
|
||||
const result = await window.webContents.executeJavaScript('window.__probe()')
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(${JSON.stringify(paths.resultPath)}, JSON.stringify(result))
|
||||
app.exit(0)
|
||||
} catch (error) {
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(
|
||||
${JSON.stringify(paths.resultPath)},
|
||||
JSON.stringify({ error: String(error && error.stack ? error.stack : error) })
|
||||
)
|
||||
app.exit(1)
|
||||
}
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
async function runFixture(): Promise<FixtureResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-ipc-boundary-bridge-'))
|
||||
fixtureRoots.push(root)
|
||||
const preloadSource = join(root, 'preload-entry.ts')
|
||||
const htmlPath = join(root, 'index.html')
|
||||
const mainPath = join(root, 'main.cjs')
|
||||
const resultPath = join(root, 'result.json')
|
||||
|
||||
writeFileSync(preloadSource, preloadEntry(join(process.cwd(), 'src/preload/ipc-invoke-boundary')))
|
||||
writeFileSync(htmlPath, `<!doctype html><body><script>${RENDERER_PROBE}</script></body>`)
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
// The fixture asserts on a constructor name, which minification would rewrite.
|
||||
minify: false,
|
||||
lib: {
|
||||
entry: preloadSource,
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'preload.cjs',
|
||||
name: 'OrcaIpcInvokeBoundaryFixture'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron'] }
|
||||
}
|
||||
})
|
||||
writeFileSync(
|
||||
mainPath,
|
||||
fixtureMain({ htmlPath, preloadPath: join(root, 'preload.cjs'), resultPath })
|
||||
)
|
||||
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [mainPath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 60_000 })
|
||||
const rawResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${rawResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return JSON.parse(rawResult) as FixtureResult
|
||||
}
|
||||
|
||||
describe('the stripped rejection as a renderer consumer receives it', () => {
|
||||
it('arrives as an Error carrying the reason, with the envelope only in the preload-side stack', async () => {
|
||||
const result = await runFixture()
|
||||
|
||||
// What the renderer can rely on.
|
||||
expect(result.rendererStripped.isError).toBe(true)
|
||||
expect(result.rendererStripped.message).toBe('Host key verification failed')
|
||||
|
||||
// The control proves the message and stack assertions above move when the strip does not run.
|
||||
expect(result.rendererUnstripped.message).toBe(
|
||||
"Error invoking remote method 'probe:reject-control': HandlerError: Host key verification failed"
|
||||
)
|
||||
expect(result.rendererUnstripped.stackFirstLine).toContain('Error invoking remote method')
|
||||
|
||||
// The renderer's stack is regenerated from the message, so it echoes the reason, not the
|
||||
// envelope. The wrapped form survives on the preload side, which is where it is logged.
|
||||
expect(result.rendererStripped.stackFirstLine).toBe('Error: Host key verification failed')
|
||||
expect(result.preloadStripped.stackFirstLine).toContain('Error invoking remote method')
|
||||
})
|
||||
|
||||
it('is a copy: prototype, own properties and object identity do not cross the bridge', async () => {
|
||||
const result = await runFixture()
|
||||
|
||||
// Preload holds a subclass with an own `code`; the renderer receives neither.
|
||||
expect(result.preloadCarriesOwnProperties.ctorName).toBe('BoundaryProbeError')
|
||||
expect(result.preloadCarriesOwnProperties.code).toBe('E_PROBE')
|
||||
expect(result.preloadCarriesOwnProperties.ownKeys).toContain('code')
|
||||
|
||||
expect(result.rendererCarriesOwnProperties.isError).toBe(true)
|
||||
expect(result.rendererCarriesOwnProperties.ctorName).toBe('Error')
|
||||
expect(result.rendererCarriesOwnProperties.name).toBe('Error')
|
||||
expect(result.rendererCarriesOwnProperties.code).toBeUndefined()
|
||||
expect(result.rendererCarriesOwnProperties.ownKeys).toEqual(['message', 'stack'])
|
||||
|
||||
// One preload object, rejected twice, arrives as two unrelated renderer objects.
|
||||
expect(result.sameObjectAcrossTwoRejections).toBe(false)
|
||||
expect(result.mutatingOneCopyLeaksToTheOther).toBe(false)
|
||||
|
||||
// An IPC rejection has nothing else to lose: it reaches the boundary already flattened.
|
||||
expect(result.preloadStripped.ctorName).toBe('Error')
|
||||
expect(result.preloadStripped.ownKeys).toEqual(['message', 'stack'])
|
||||
})
|
||||
})
|
||||
@@ -11,9 +11,33 @@ import { describe, expect, it } from 'vitest'
|
||||
* 731 call sites through one wrapper fixed them at once — this test is what stops the 732nd from
|
||||
* being written outside it.
|
||||
*
|
||||
* The `electronAPI` bridge is covered too, because `contextBridge.exposeInMainWorld('electron', …)`
|
||||
* hands the renderer a raw `ipcRenderer` whose `invoke` never reaches the wrapper. Nothing uses that
|
||||
* door today; the point of a ratchet is that it stays shut before something does.
|
||||
* ## What each assertion is worth
|
||||
*
|
||||
* Two of the three arms below are text scans, and a text scan cannot enumerate the ways JavaScript
|
||||
* spells a member access. Two separate bypasses have already been demonstrated against this file:
|
||||
* a cast with an aliased receiver, which a `window`-anchored pattern missed, and a computed access
|
||||
* whose keys are string literals (`w['electron']['ipcRenderer']['invoke']`), which the scan's own
|
||||
* string-blanking step erased before matching. The second one passed 3/3 green with a live escape
|
||||
* in the tree. Patching a third spelling would not change the shape: `'ipc' + 'Renderer'` walks
|
||||
* past any regex, and so does any key read from a variable.
|
||||
*
|
||||
* So the arms are labelled for what they are, not for what would be reassuring:
|
||||
*
|
||||
* - `stays behind the boundary` is a **fence**. `ipcRenderer` is only importable in preload, and
|
||||
* preload is three modules; a text scan is proportionate there and there is nowhere to hide.
|
||||
* - `the main world gets exactly these globals` is a **fence**, and the load-bearing one. Under
|
||||
* context isolation `exposeInMainWorld` is the only way to put anything in the renderer's world,
|
||||
* so the doors are enumerable, they all live in one file, and widening the set is a one-line diff
|
||||
* in the module a reviewer reads most closely.
|
||||
* - `is not reached through the raw bridge` is a **tripwire**, and is documented as one. It catches
|
||||
* somebody reaching for `window.electron.ipcRenderer` without thinking. It does not survive
|
||||
* somebody who means it, and it must not be read as though it does.
|
||||
*
|
||||
* The residue this cannot close: `window.electron` really is a live door to a raw `ipcRenderer`,
|
||||
* and no scan of renderer source will hold it shut. The only thing that closes it is not opening
|
||||
* it — nothing in the tree reads `window.electron` today, so the exposure could be dropped. That is
|
||||
* a change to the app's global surface rather than to this fix, so it is recorded here as the
|
||||
* standing recommendation and not smuggled in.
|
||||
*/
|
||||
const BOUNDARY_MODULE = 'src/preload/ipc-invoke-boundary.ts'
|
||||
const SRC_ROOT = resolve(__dirname, '..')
|
||||
@@ -29,23 +53,35 @@ const IGNORED_DIRECTORIES = new Set([
|
||||
|
||||
/** Whitespace and newlines are legal between the receiver and the call, and one call site used them. */
|
||||
const RAW_INVOKE = /ipcRenderer\s*\.\s*invoke\s*\(/
|
||||
/** Not anchored on `window`: a cast (`(window as unknown as { electron: … }).electron.ipcRenderer`)
|
||||
* sits between `window` and `.electron`, and aliasing the receiver hides the call from RAW_INVOKE
|
||||
* as well — so a `window`-anchored arm 2 passed with a live raw-bridge escape in the tree. Typing
|
||||
* the global does not close that door: `Window.electron` IS declared (`src/preload/api-types.ts`),
|
||||
* and the cast spelling still compiles. The lookbehind keeps `electronFoo.ipcRenderer` out. */
|
||||
const RAW_BRIDGE = /(?<!\w)electron\s*\.\s*ipcRenderer/
|
||||
/**
|
||||
* Two spellings, both of which were live escapes before they were added, and neither of which makes
|
||||
* this arm complete — see the note above. Dot form is not anchored on `window`, because a cast sits
|
||||
* between `window` and `.electron`; the lookbehind keeps `electronFoo.ipcRenderer` out. Quoted-key
|
||||
* form is matched against source that still has its strings, because blanking them is exactly what
|
||||
* hid `['electron']['ipcRenderer']`. Typing the global closes neither: `Window.electron` IS declared
|
||||
* (`src/preload/api-types.ts`), so the plain spelling already compiles.
|
||||
*/
|
||||
const RAW_BRIDGE_DOT = /(?<!\w)electron\s*\.\s*ipcRenderer/
|
||||
const RAW_BRIDGE_COMPUTED = /\[\s*(['"`])ipcRenderer\1\s*\]/
|
||||
|
||||
/** The complete set of names preload puts in the renderer's world, by either code path. */
|
||||
const MAIN_WORLD_GLOBALS = ['api', 'electron']
|
||||
const PRELOAD_ENTRY = 'src/preload/index.ts'
|
||||
|
||||
/**
|
||||
* Comments name this shape on purpose — the modules that consume the envelope explain where it
|
||||
* comes from — so the scan reads code only. A ratchet that fired on prose would be silenced by
|
||||
* rewording rather than by fixing anything.
|
||||
*/
|
||||
function withoutComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||||
}
|
||||
|
||||
function withoutCommentsOrStrings(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||||
.replace(/'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`/g, "''")
|
||||
return withoutComments(source).replace(
|
||||
/'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`/g,
|
||||
"''"
|
||||
)
|
||||
}
|
||||
|
||||
/** Tests may reach for the raw call: they are not shipped, and several drive it to prove the wrapper. */
|
||||
@@ -69,20 +105,47 @@ function collectSourceFiles(root: string): string[] {
|
||||
return found
|
||||
}
|
||||
|
||||
function offendingModules(pattern: RegExp): string[] {
|
||||
function offendingModules(pattern: RegExp, scrub = withoutCommentsOrStrings): string[] {
|
||||
return collectSourceFiles(SRC_ROOT)
|
||||
.filter((file) => pattern.test(withoutCommentsOrStrings(readFileSync(file, 'utf8'))))
|
||||
.filter((file) => pattern.test(scrub(readFileSync(file, 'utf8'))))
|
||||
.map((file) => relative(resolve(SRC_ROOT, '..'), file).replaceAll('\\', '/'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
/** Every name preload hands the renderer: the `exposeInMainWorld` pair and the fallback assignment. */
|
||||
function exposedMainWorldGlobals(): string[] {
|
||||
const source = withoutComments(readFileSync(join(SRC_ROOT, 'preload', 'index.ts'), 'utf8'))
|
||||
const names = new Set<string>()
|
||||
for (const [, name] of source.matchAll(/exposeInMainWorld\s*\(\s*['"`]([\w$]+)['"`]/g)) {
|
||||
names.add(name)
|
||||
}
|
||||
for (const [, name] of source.matchAll(/(?:^|\n)\s*window\s*\.\s*([\w$]+)\s*=[^=]/g)) {
|
||||
names.add(name)
|
||||
}
|
||||
return [...names].sort()
|
||||
}
|
||||
|
||||
describe('ipcRenderer.invoke stays behind the preload boundary', () => {
|
||||
it('is called in exactly one module', () => {
|
||||
expect(offendingModules(RAW_INVOKE)).toEqual([BOUNDARY_MODULE])
|
||||
})
|
||||
|
||||
it('is not reachable through the raw electron bridge either', () => {
|
||||
expect(offendingModules(RAW_BRIDGE)).toEqual([])
|
||||
/**
|
||||
* A tripwire, not a fence. It reddens on the two spellings that were demonstrated against it and
|
||||
* on the obvious one; it does not claim to redden on a spelling nobody has written yet.
|
||||
*/
|
||||
it('is not reached through the raw electron bridge by any spelling this can see', () => {
|
||||
expect(offendingModules(RAW_BRIDGE_DOT)).toEqual([])
|
||||
expect(offendingModules(RAW_BRIDGE_COMPUTED, withoutComments)).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* The arm that actually holds. `exposeInMainWorld` is the only way into an isolated renderer's
|
||||
* world, every call is in one file, and a new door has to be spelled out here to exist at all.
|
||||
*/
|
||||
it('gives the main world exactly these globals, from exactly one module', () => {
|
||||
expect(exposedMainWorldGlobals()).toEqual(MAIN_WORLD_GLOBALS)
|
||||
expect(offendingModules(/exposeInMainWorld\s*\(/)).toEqual([PRELOAD_ENTRY])
|
||||
})
|
||||
|
||||
/** A scan that matched nothing anywhere would pass both assertions above while enforcing nothing. */
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
* point it is rendered. Stripping here, where the envelope is created, is what makes the guarantee
|
||||
* hold for a call site nobody has written yet.
|
||||
*
|
||||
* The envelope is not lost, only demoted: the rejection keeps its identity, its properties and its
|
||||
* stack (V8 fixes `stack` at construction, so it still spells out the wrapped form), and the raw
|
||||
* message is logged with the channel that produced it before the message is narrowed.
|
||||
* The envelope is not lost, only demoted: it is logged here, against the channel that produced it,
|
||||
* before the message is narrowed. Preload is the last place it can be kept, because this rejection
|
||||
* does not reach the renderer as this object. `contextBridge` copies what crosses it, so a renderer
|
||||
* consumer receives a fresh plain `Error` carrying `message` and a `stack` regenerated from that
|
||||
* message — the prototype, own properties and object identity stop here, and so does the wrapped
|
||||
* form of the stack. Nothing is lost by narrowing in place that the bridge would not have dropped
|
||||
* anyway: an `ipcRenderer.invoke` rejection arrives already flattened to `message` and `stack`, its
|
||||
* own main-process class and properties gone one hop earlier.
|
||||
*
|
||||
* Measured across the real binary rather than reasoned about — see
|
||||
* `ipc-invoke-boundary-bridge.electron.test.ts`, which asserts the renderer's view.
|
||||
*/
|
||||
|
||||
import { ipcRenderer } from 'electron'
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* The leaking population, computed rather than quoted.
|
||||
*
|
||||
* The case for the boundary rests on a number, and the number had only ever been written down: the
|
||||
* enumeration that produced it did not survive into the repository, so the figure in the PR body
|
||||
* could not be checked by anyone reading it. Three independent attempts on this population reported
|
||||
* 115, 118 and 137, which is what an unreproducible census looks like from outside. This file is the
|
||||
* census itself, so the figure is whatever running it says.
|
||||
*
|
||||
* ## The axis
|
||||
*
|
||||
* One axis, stated so a disagreeing count can be attributed instead of argued: **renderer
|
||||
* expressions that pass a rejection's free text into a render sink, in a module that talks to the
|
||||
* preload surface**. Concretely, an expression is counted when all of these hold:
|
||||
*
|
||||
* - it is an argument to a `toast.*(…)` call or to a `set<Name>(…)` state setter;
|
||||
* - it reads free text off a binding introduced by `catch (…)` or `.catch(…)` in the same module —
|
||||
* `binding.message`, `String(binding)` or `${binding}`;
|
||||
* - that argument does not route through `extractIpcErrorMessage` or `stripIpcInvokeEnvelope`;
|
||||
* - the module contains a `window.api.*` call, so a rejection reaching it can have crossed IPC.
|
||||
*
|
||||
* ## What it cannot see
|
||||
*
|
||||
* Stated rather than implied, because this population has been undercounted repeatedly. It is regex
|
||||
* over source, not dataflow, so it is a floor and not a total:
|
||||
*
|
||||
* - text laundered through an intermediate variable, a helper in another module, or a store action
|
||||
* not named `set*`, is invisible to it;
|
||||
* - its sinks are `toast.*` and `set*` only — direct JSX rendering of `{err.message}`, error
|
||||
* boundaries and `alert` are not counted;
|
||||
* - it cannot see event-channel payloads: `ipcRenderer.on` is not `invoke`, so a main-process string
|
||||
* arriving over an event is outside both this census and the fix;
|
||||
* - the `window.api.*` test is module-level, so a module that both calls the preload surface and
|
||||
* catches something else contributes a false positive, and a module that receives its rejection
|
||||
* from a caller contributes a false negative.
|
||||
*
|
||||
* ## Before and after
|
||||
*
|
||||
* The "before" is what this file computes. The "after" is not a second scan and cannot be: the fix
|
||||
* is upstream of every expression counted here, so the source is textually identical either side of
|
||||
* it and re-running the census would report the same number. What changes is the value that arrives.
|
||||
* The after-column is carried by two other running tests, and this file is only honest alongside
|
||||
* them: `ipc-invoke-boundary-bridge.electron.test.ts` shows a renderer consumer receiving the
|
||||
* narrowed reason across a real `contextBridge`, and `ipc-invoke-boundary-ratchet.test.ts` shows
|
||||
* that the wrapper is the only path to `ipcRenderer.invoke`, which is what makes that observation
|
||||
* general rather than anecdotal.
|
||||
*
|
||||
* To regenerate `LEAKING_EXPRESSIONS` after a legitimate change, run this file: the failure prints
|
||||
* the current population as a diff against the recorded one.
|
||||
*/
|
||||
const REPO_ROOT = resolve(__dirname, '../../../..')
|
||||
const RENDERER_ROOT = join(REPO_ROOT, 'src/renderer/src')
|
||||
const IGNORED_DIRECTORIES = new Set([
|
||||
'node_modules',
|
||||
'dist',
|
||||
'out',
|
||||
'build',
|
||||
'.git',
|
||||
'__fixtures__'
|
||||
])
|
||||
|
||||
/** Every leaking expression, by module. Computed by this file; not transcribed from anywhere. */
|
||||
const LEAKING_EXPRESSIONS: Readonly<Record<string, number>> = {
|
||||
'src/renderer/src/app-shell/use-app-session-persistence.ts': 1,
|
||||
'src/renderer/src/components/GitLabItemDialog.tsx': 2,
|
||||
'src/renderer/src/components/LinearItemDrawer.tsx': 1,
|
||||
'src/renderer/src/components/NewWorkspaceComposerCard.tsx': 1,
|
||||
'src/renderer/src/components/Terminal.tsx': 1,
|
||||
'src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx': 1,
|
||||
'src/renderer/src/components/editor/useIpynbCellExecution.ts': 1,
|
||||
'src/renderer/src/components/emulator-pane/use-mobile-emulator-agent-setup-state.ts': 2,
|
||||
'src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx': 1,
|
||||
'src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts': 2,
|
||||
'src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx': 1,
|
||||
'src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx': 1,
|
||||
'src/renderer/src/components/jira-connect-dialog.tsx': 1,
|
||||
'src/renderer/src/components/linear-api-key-dialog.tsx': 1,
|
||||
'src/renderer/src/components/new-workspace/pick-local-project-folder.ts': 1,
|
||||
'src/renderer/src/components/onboarding/ThemeStep.tsx': 1,
|
||||
'src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts': 2,
|
||||
'src/renderer/src/components/pull-request-page/actions/merge-actions.ts': 3,
|
||||
'src/renderer/src/components/pull-request-page/checks/refresh.ts': 1,
|
||||
'src/renderer/src/components/pull-request-page/checks/rerun.ts': 1,
|
||||
'src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts': 1,
|
||||
'src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts': 1,
|
||||
'src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx': 1,
|
||||
'src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts': 1,
|
||||
'src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts': 1,
|
||||
'src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts': 1,
|
||||
'src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts': 2,
|
||||
'src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts': 1,
|
||||
'src/renderer/src/components/settings/AgentSkillSetupPanel.tsx': 1,
|
||||
'src/renderer/src/components/settings/BrowserUseExamples.tsx': 1,
|
||||
'src/renderer/src/components/settings/BrowserUsePane.tsx': 1,
|
||||
'src/renderer/src/components/settings/CliSection.tsx': 3,
|
||||
'src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx': 1,
|
||||
'src/renderer/src/components/settings/ComputerUsePane.tsx': 3,
|
||||
'src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx': 4,
|
||||
'src/renderer/src/components/settings/EphemeralVmsPane.tsx': 1,
|
||||
'src/renderer/src/components/settings/GrokAccountsSection.tsx': 1,
|
||||
'src/renderer/src/components/settings/KeybindingsFileActions.tsx': 2,
|
||||
'src/renderer/src/components/settings/ManageSessionsSection.tsx': 2,
|
||||
'src/renderer/src/components/settings/MobileEmulatorAvailabilityDetails.tsx': 2,
|
||||
'src/renderer/src/components/settings/MobileEmulatorExamples.tsx': 1,
|
||||
'src/renderer/src/components/settings/OrchestrationSkillPromptDialog.tsx': 1,
|
||||
'src/renderer/src/components/settings/RepositoryIconTabs.tsx': 1,
|
||||
'src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx': 4,
|
||||
'src/renderer/src/components/settings/SkillUsageExampleDialog.tsx': 1,
|
||||
'src/renderer/src/components/settings/SshPane.tsx': 8,
|
||||
'src/renderer/src/components/settings/SshPassphraseDialog.tsx': 2,
|
||||
'src/renderer/src/components/settings/VoicePane.tsx': 2,
|
||||
'src/renderer/src/components/settings/WslCliRegistration.tsx': 3,
|
||||
'src/renderer/src/components/settings/bitbucket-credentials-dialog.tsx': 1,
|
||||
'src/renderer/src/components/settings/bitbucket-integration-card.tsx': 1,
|
||||
'src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx': 1,
|
||||
'src/renderer/src/components/shared/useDaemonActions.tsx': 2,
|
||||
'src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx': 2,
|
||||
'src/renderer/src/components/sidebar/AddRepoSteps.tsx': 1,
|
||||
'src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx': 2,
|
||||
'src/renderer/src/components/sidebar/HostRemoveDialog.tsx': 1,
|
||||
'src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx': 2,
|
||||
'src/renderer/src/components/sidebar/NonGitFolderDialog.tsx': 1,
|
||||
'src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx': 1,
|
||||
'src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx': 1,
|
||||
'src/renderer/src/components/sidebar/use-add-repo-host-selection.ts': 2,
|
||||
'src/renderer/src/components/sidebar/useSidebarProjectDrop.ts': 1,
|
||||
'src/renderer/src/components/status-bar/SshStatusSegment.tsx': 1,
|
||||
'src/renderer/src/components/status-bar/SshTargetStatusRow.tsx': 2,
|
||||
'src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx': 1,
|
||||
'src/renderer/src/components/task-page/hooks/use-task-page-create-github-submit.ts': 1,
|
||||
'src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx': 1,
|
||||
'src/renderer/src/hooks/composer-state/attachment-drop-state.ts': 1,
|
||||
'src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts': 1,
|
||||
'src/renderer/src/hooks/composer-state/host-runtime-effects.ts': 2,
|
||||
'src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts': 4,
|
||||
'src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts': 1,
|
||||
'src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts': 1,
|
||||
'src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts': 1,
|
||||
'src/renderer/src/lib/agent-skill-cli-prerequisite.ts': 1,
|
||||
'src/renderer/src/lib/http-link-routing.ts': 1,
|
||||
'src/renderer/src/lib/launch-work-item-direct.ts': 1,
|
||||
'src/renderer/src/lib/sidebar-worktree-activation.ts': 1,
|
||||
'src/renderer/src/store/project-groups/nested-repository-operations.ts': 1,
|
||||
'src/renderer/src/store/repos/repo-removal.ts': 1,
|
||||
'src/renderer/src/store/slices/orca-profiles-auth-actions.ts': 5,
|
||||
'src/renderer/src/store/slices/orca-profiles.ts': 3,
|
||||
'src/renderer/src/store/slices/settings.ts': 1
|
||||
}
|
||||
|
||||
type Module = { path: string; source: string }
|
||||
|
||||
function isTestFile(path: string): boolean {
|
||||
return /\.(?:test|spec)\.tsx?$/.test(path) || path.includes('/__tests__/')
|
||||
}
|
||||
|
||||
function collectModules(root: string): Module[] {
|
||||
const found: Module[] = []
|
||||
for (const entry of readdirSync(root)) {
|
||||
if (IGNORED_DIRECTORIES.has(entry)) {
|
||||
continue
|
||||
}
|
||||
const full = join(root, entry)
|
||||
if (statSync(full).isDirectory()) {
|
||||
found.push(...collectModules(full))
|
||||
} else if ((entry.endsWith('.ts') || entry.endsWith('.tsx')) && !isTestFile(full)) {
|
||||
found.push({
|
||||
path: relative(REPO_ROOT, full).replaceAll('\\', '/'),
|
||||
source: readFileSync(full, 'utf8')
|
||||
})
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Strings go too: their contents are prose, and their parentheses would break argument balancing. */
|
||||
function withoutCommentsOrStringBodies(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||||
.replace(/'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"/g, '""')
|
||||
.replace(/`(?:[^`\\$]|\\.|\$(?!\{))*`/g, '""')
|
||||
}
|
||||
|
||||
/** Bindings a rejection can arrive on: `catch (e)` and the `.catch(e => …)` callback parameter. */
|
||||
function rejectionBindings(source: string): string[] {
|
||||
const names = new Set<string>()
|
||||
for (const [, name] of source.matchAll(/catch\s*\(\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
names.add(name)
|
||||
}
|
||||
for (const [, name] of source.matchAll(/\.catch\s*\(\s*(?:async\s*)?\(?\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
names.add(name)
|
||||
}
|
||||
names.delete('function')
|
||||
names.delete('async')
|
||||
return [...names]
|
||||
}
|
||||
|
||||
function balancedArgument(source: string, openParenIndex: number): string {
|
||||
let depth = 0
|
||||
for (let index = openParenIndex; index < source.length; index += 1) {
|
||||
if (source[index] === '(') {
|
||||
depth += 1
|
||||
} else if (source[index] === ')') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return source.slice(openParenIndex + 1, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return source.slice(openParenIndex + 1)
|
||||
}
|
||||
|
||||
const SINK = /\btoast\s*(?:\.\s*[\w$]+)?\s*\(|\bset[A-Z][\w$]*\s*\(/g
|
||||
const ALREADY_STRIPPED = /extractIpcErrorMessage\s*\(|stripIpcInvokeEnvelope/
|
||||
const REACHES_PRELOAD = /\bwindow\s*\.\s*api\s*\./
|
||||
|
||||
export function censusLeakingExpressions(modules: readonly Module[]): Record<string, number> {
|
||||
const leaking: Record<string, number> = {}
|
||||
for (const { path, source } of modules) {
|
||||
const cleaned = withoutCommentsOrStringBodies(source)
|
||||
if (!REACHES_PRELOAD.test(cleaned)) {
|
||||
continue
|
||||
}
|
||||
const bindings = rejectionBindings(cleaned)
|
||||
if (bindings.length === 0) {
|
||||
continue
|
||||
}
|
||||
const alternation = bindings.join('|')
|
||||
const readsFreeText = new RegExp(
|
||||
`\\b(?:${alternation})\\b\\s*\\.\\s*message\\b` +
|
||||
`|String\\(\\s*(?:${alternation})\\s*\\)` +
|
||||
`|\\$\\{\\s*(?:${alternation})\\s*\\}`
|
||||
)
|
||||
SINK.lastIndex = 0
|
||||
while (SINK.exec(cleaned) !== null) {
|
||||
const argument = balancedArgument(cleaned, SINK.lastIndex - 1)
|
||||
if (readsFreeText.test(argument) && !ALREADY_STRIPPED.test(argument)) {
|
||||
leaking[path] = (leaking[path] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return leaking
|
||||
}
|
||||
|
||||
/**
|
||||
* A module written to leak, injected rather than written to disk so the controls cannot leave the
|
||||
* tree mutated if the run is interrupted.
|
||||
*/
|
||||
const PLANTED_LEAK: Module = {
|
||||
path: 'src/renderer/src/planted-control.ts',
|
||||
source: `
|
||||
import { toast } from 'sonner'
|
||||
export async function planted(): Promise<void> {
|
||||
try {
|
||||
await window.api.ssh.addTarget()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
describe('the renderer expressions that would render an IPC envelope', () => {
|
||||
const treeModules = collectModules(RENDERER_ROOT)
|
||||
|
||||
it('are these, at these counts', () => {
|
||||
expect(censusLeakingExpressions(treeModules)).toEqual(LEAKING_EXPRESSIONS)
|
||||
})
|
||||
|
||||
it('total 131 expressions across 84 modules', () => {
|
||||
const census = censusLeakingExpressions(treeModules)
|
||||
const total = Object.values(census).reduce((sum, count) => sum + count, 0)
|
||||
|
||||
expect(total).toBe(131)
|
||||
expect(Object.keys(census)).toHaveLength(84)
|
||||
})
|
||||
|
||||
/**
|
||||
* The control the previous census never had: a census nobody has shown can detect the thing is
|
||||
* not evidence that the thing is absent.
|
||||
*/
|
||||
it('detects a planted leak, and counts exactly the one', () => {
|
||||
const before = censusLeakingExpressions(treeModules)
|
||||
const after = censusLeakingExpressions([...treeModules, PLANTED_LEAK])
|
||||
|
||||
expect(after[PLANTED_LEAK.path]).toBe(1)
|
||||
expect(Object.keys(after)).toHaveLength(Object.keys(before).length + 1)
|
||||
})
|
||||
|
||||
/** The other half of the control: the detector has to be able to say no, or it says nothing. */
|
||||
it('does not count the same expression once it is stripped, or outside the preload surface', () => {
|
||||
const stripped: Module = {
|
||||
path: PLANTED_LEAK.path,
|
||||
source: PLANTED_LEAK.source.replace(
|
||||
'err instanceof Error ? err.message : String(err)',
|
||||
'extractIpcErrorMessage(err)'
|
||||
)
|
||||
}
|
||||
const noPreloadCall: Module = {
|
||||
path: PLANTED_LEAK.path,
|
||||
source: PLANTED_LEAK.source.replace('window.api.ssh.addTarget()', 'somethingLocal()')
|
||||
}
|
||||
|
||||
expect(censusLeakingExpressions([stripped])).toEqual({})
|
||||
expect(censusLeakingExpressions([noPreloadCall])).toEqual({})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user