test(acl): each window's code imports only the wrappers it is granted

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:12:19 -07:00
co-authored by Claude Opus 5.5
parent e10b16a081
commit 8bac2a9533
+194
View File
@@ -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<T>("command"` → NAME → command. Exactly one literal per wrapper. */
function wrapperCommands(): Map<string, string> {
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<string, string>();
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<string> {
const seen = new Set<string>();
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<string>();
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<string>();
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([]);
});
});