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