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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user