From 8bac2a95336cd31a866b491244f6b882ec29c736 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 22 Sep 2026 22:28:02 -0700 Subject: [PATCH] test(acl): each window's code imports only the wrappers it is granted Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/test/capabilities.test.ts | 194 ++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 app/src/test/capabilities.test.ts diff --git a/app/src/test/capabilities.test.ts b/app/src/test/capabilities.test.ts new file mode 100644 index 0000000..ebe0861 --- /dev/null +++ b/app/src/test/capabilities.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from "vitest"; +import { existsSync, readdirSync, readFileSync, statSync } from "fs"; +import { dirname, join, relative, resolve } from "path"; + +/** + * The capability files are the IPC ACL. Since the AppManifest lockdown, a window can only + * invoke the app commands its file grants; `build.rs` proves every command is granted in the + * file its name says it belongs to. This proves the other half: the code that *runs* in each + * window imports only wrappers that window is granted. A wrapper imported on the wrong side + * fails here, not with `Command … not allowed by ACL` in a release build. + * + * It works on imports rather than `invoke(` literals because the viewer never calls invoke: + * everything goes through `lib/tauri-commands.ts`, which is the only file allowed to import + * `@tauri-apps/api/core` (that rule is what makes this test complete). + */ +const srcDir = resolve(__dirname, ".."); +const capDir = resolve(srcDir, "../src-tauri/capabilities"); +const WRAPPERS = resolve(srcDir, "lib/tauri-commands.ts"); +const VIEWER_ENTRY = resolve(srcDir, "viewer/main.tsx"); + +const toPermission = (command: string) => `allow-${command.replace(/_/g, "-")}`; + +function readCapability(file: string) { + const cap = JSON.parse(readFileSync(resolve(capDir, file), "utf-8")) as { + windows: string[]; + permissions: (string | { identifier: string })[]; + }; + const ids = cap.permissions.map((p) => (typeof p === "string" ? p : p.identifier)); + return { + windows: cap.windows, + bare: ids.filter((id) => !id.includes(":")).sort(), + prefixed: ids.filter((id) => id.includes(":")).sort(), + }; +} + +/** Every non-test .ts/.tsx under src/, excluding src/test. */ +function sourceFiles(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + if (name !== "test") sourceFiles(path, out); + } else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) && !name.endsWith(".d.ts")) { + out.push(path); + } + } + return out; +} + +/** `export const NAME = … invoke("command"` → NAME → command. Exactly one literal per wrapper. */ +function wrapperCommands(): Map { + const text = readFileSync(WRAPPERS, "utf-8"); + const [head, ...chunks] = text.split(/^export const /m); + expect(head.match(/\binvoke(?:<[^>]*>)?\(/g) ?? [], "invoke calls outside an export const wrapper").toHaveLength(0); + const map = new Map(); + for (const chunk of chunks) { + const name = /^(\w+)/.exec(chunk)?.[1] ?? ""; + const calls = chunk.match(/\binvoke(?:<[^>]*>)?\(/g) ?? []; + const literals = [...chunk.matchAll(/\binvoke(?:<[^>]*>)?\(\s*"([a-z_]+)"/g)].map((m) => m[1]); + expect(calls, `${name} must call invoke exactly once`).toHaveLength(1); + expect(literals, `${name} must invoke a string literal (a computed name cannot be audited)`).toHaveLength(1); + map.set(name, literals[0]); + } + expect(map.size).toBeGreaterThan(100); + return map; +} + +function resolveRelativeImport(from: string, spec: string): string | null { + if (!spec.startsWith(".")) return null; // a package + const base = resolve(dirname(from), spec); + if (/\.(css|svg|png|json)$/.test(base)) return null; + for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + throw new Error(`${relative(srcDir, from)}: cannot resolve import "${spec}"`); +} + +const IMPORT_SPEC = /^import\b[^;]*?\bfrom\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm; +const WRAPPER_IMPORT = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["'][^"']*\/lib\/tauri-commands["']/g; + +function importSpecs(file: string): string[] { + const text = readFileSync(file, "utf-8"); + return [...text.matchAll(IMPORT_SPEC)].map((m) => m[1] ?? m[2]); +} + +function wrapperNamesImportedBy(file: string): string[] { + const text = readFileSync(file, "utf-8"); + return [...text.matchAll(WRAPPER_IMPORT)].flatMap((m) => + m[1] + .split(",") + .map((s) => s.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim()) + .filter((s) => s.length > 0), + ); +} + +/** Transitive relative-import closure from the viewer entry. */ +function viewerClosure(): Set { + const seen = new Set(); + const queue = [VIEWER_ENTRY]; + while (queue.length > 0) { + const file = queue.pop()!; + if (seen.has(file)) continue; + seen.add(file); + for (const spec of importSpecs(file)) { + const target = resolveRelativeImport(file, spec); + if (target && !seen.has(target)) queue.push(target); + } + } + return seen; +} + +describe("capability files match the code each window runs", () => { + const defaultCap = readCapability("default.json"); + const viewerCap = readCapability("file-viewer.json"); + const files = sourceFiles(srcDir); + + it("only lib/tauri-commands.ts imports @tauri-apps/api/core", () => { + const offenders = files + .filter((f) => f !== WRAPPERS) + .filter((f) => /from\s*["']@tauri-apps\/api\/core["']/.test(readFileSync(f, "utf-8"))) + .map((f) => relative(srcDir, f)); + expect(offenders).toEqual([]); + }); + + it("the windows lists are the reviewed ones", () => { + expect(defaultCap.windows).toEqual(["main"]); + expect(viewerCap.windows).toEqual(["file-viewer-*"]); + }); + + it("the plugin/core grants are the reviewed ones", () => { + expect(defaultCap.prefixed).toEqual([ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "dialog:allow-open", + "dialog:allow-save", + ]); + expect(viewerCap.prefixed).toEqual([ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "core:window:allow-destroy", + ]); + }); + + it("the viewer window imports exactly the wrappers file-viewer.json grants", () => { + const wrappers = wrapperCommands(); + const closure = viewerClosure(); + expect(closure.has(WRAPPERS), "the viewer reaches tauri-commands.ts").toBe(true); + const viewerCommands = new Set(); + for (const file of closure) { + for (const name of wrapperNamesImportedBy(file)) { + const command = wrappers.get(name); + expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined(); + viewerCommands.add(command!); + } + } + const granted = [...viewerCommands].map(toPermission).sort(); + expect(granted).toEqual(viewerCap.bare); + }); + + it("the main window imports only wrappers default.json grants, and none of the viewer's", () => { + const wrappers = wrapperCommands(); + const closure = viewerClosure(); + const mainCommands = new Set(); + for (const file of files) { + if (closure.has(file)) continue; + for (const name of wrapperNamesImportedBy(file)) { + const command = wrappers.get(name); + expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined(); + mainCommands.add(command!); + } + } + expect(mainCommands.size).toBeGreaterThan(50); + const ungranted = [...mainCommands].map(toPermission).filter((p) => !defaultCap.bare.includes(p)).sort(); + expect(ungranted, "main-window code imports wrappers default.json does not grant").toEqual([]); + const crossed = [...mainCommands].filter((c) => viewerCap.bare.includes(toPermission(c))).sort(); + expect(crossed, "main-window code imports viewer-only wrappers").toEqual([]); + }); + + it("every wrapper's command is granted in exactly one capability file", () => { + const wrappers = wrapperCommands(); + const both: string[] = []; + const neither: string[] = []; + for (const command of new Set(wrappers.values())) { + const p = toPermission(command); + const inDefault = defaultCap.bare.includes(p); + const inViewer = viewerCap.bare.includes(p); + if (inDefault && inViewer) both.push(command); + if (!inDefault && !inViewer) neither.push(command); + } + expect(both).toEqual([]); + expect(neither, "granted nowhere — cargo check would fail too, but you may not have run it").toEqual([]); + }); +});