fix(acl): close the closure-walk blind spots capabilities.test.ts missed

Fixes four plan-mandated Important findings from review round 1:
namespace imports of tauri-commands.ts are resolved into the wrapper
set (not dropped), export{...}from/export*from re-exports are
followed by the closure walk and counted as wrapper usage, relative
dynamic import() is followed by the closure walk and also checked at
the @tauri-apps/api/core boundary, and an unresolvable non-relative
specifier now throws (fail-closed) instead of silently exiting the
closure. Also narrows the asset-extension filter to an allowlist of
.ts/.tsx/.js/.jsx as source, everything else treated as a non-source
asset.

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 8bac2a9533
commit 5688534a4a
+70 -11
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { dirname, join, relative, resolve } from "path";
import { builtinModules } from "module";
/**
* The capability files are the IPC ACL. Since the AppManifest lockdown, a window can only
@@ -12,9 +13,16 @@ import { dirname, join, relative, resolve } from "path";
* 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).
*
* This is a static, regex-based approximation of a module graph, not a real parser — the
* boundary cases below (namespace imports, re-exports, dynamic imports, path aliases) are each
* handled explicitly rather than left to fall through silently, because a silent fall-through
* here is exactly the shape of gap this test exists to close. Anything this file's heuristics
* cannot make sense of is a thrown error (fail-closed), never a quiet `null`/skip.
*/
const srcDir = resolve(__dirname, "..");
const capDir = resolve(srcDir, "../src-tauri/capabilities");
const nodeModulesDir = resolve(srcDir, "../node_modules");
const WRAPPERS = resolve(srcDir, "lib/tauri-commands.ts");
const VIEWER_ENTRY = resolve(srcDir, "viewer/main.tsx");
@@ -64,35 +72,86 @@ function wrapperCommands(): Map<string, string> {
return map;
}
/** A non-relative specifier resolves to a real dependency: a node builtin or a node_modules package. */
function isRealPackage(spec: string): boolean {
if (builtinModules.includes(spec) || spec.startsWith("node:")) return true;
const parts = spec.split("/");
const pkgName = spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
return existsSync(resolve(nodeModulesDir, pkgName));
}
/** Only .ts/.tsx/.js/.jsx are source; anything else with an extension (css, svg, png, woff, …) is an asset. */
const SOURCE_EXTENSION = /\.(ts|tsx|js|jsx)$/;
const HAS_EXTENSION = /\.[^./]+$/;
function resolveRelativeImport(from: string, spec: string): string | null {
if (!spec.startsWith(".")) return null; // a package
if (!spec.startsWith(".")) {
if (isRealPackage(spec)) return null; // a real package — not walked
throw new Error(
`${relative(srcDir, from)}: import "${spec}" is neither relative nor a resolvable node_modules ` +
`package — likely an unhandled path alias. This test only understands relative imports and real ` +
`dependencies; add alias support here rather than letting it drop out of the closure silently.`,
);
}
if (HAS_EXTENSION.test(spec) && !SOURCE_EXTENSION.test(spec)) return null; // a non-source asset
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")]) {
for (const candidate of [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.jsx`, 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;
/** `export { x } from "…"` and `export * from "…"` — a re-export is a module-graph edge too. */
const EXPORT_FROM_SPEC = /^export\s*(?:\*|(?:type\s*)?\{[^}]*\})\s*from\s*["']([^"']+)["']/gm;
/** A dynamic `import("…")` with a plain string literal argument. Template/computed specs aren't walked. */
const DYNAMIC_IMPORT_SPEC = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
/** Named or re-exported wrapper identifiers, e.g. `import { x } from ".../tauri-commands"` or
* `export { x } from ".../tauri-commands"` (including a re-export of every wrapper via `export *`,
* handled separately since it names no identifiers). */
const WRAPPER_IMPORT = /(?:import|export)\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["'][^"']*\/lib\/tauri-commands["']/g;
/** `export * from ".../tauri-commands"` re-exports every wrapper by name. No `g` flag: this is
* used with `.test()`, and a global regex's `.test()` mutates `lastIndex` across calls, which
* would make later files silently skip a real match. */
const STAR_REEXPORT_WRAPPERS = /^export\s*\*\s*from\s*["'][^"']*\/lib\/tauri-commands["']/m;
/** `import * as NS from ".../tauri-commands"` — NS's member accesses must be resolved, not dropped. */
const NAMESPACE_IMPORT = /^import\s*\*\s*as\s+(\w+)\s*from\s*["']([^"']+)["']/gm;
/** Presence of the `@tauri-apps/api/core` specifier, static or dynamic — this file is the only
* place allowed to reach it. */
const CORE_IMPORT = /from\s*["']@tauri-apps\/api\/core["']|import\(\s*["']@tauri-apps\/api\/core["']\s*\)/;
function importSpecs(file: string): string[] {
const text = readFileSync(file, "utf-8");
return [...text.matchAll(IMPORT_SPEC)].map((m) => m[1] ?? m[2]);
return [
...[...text.matchAll(IMPORT_SPEC)].map((m) => m[1] ?? m[2]),
...[...text.matchAll(EXPORT_FROM_SPEC)].map((m) => m[1]),
...[...text.matchAll(DYNAMIC_IMPORT_SPEC)].map((m) => m[1]),
];
}
function wrapperNamesImportedBy(file: string): string[] {
/** Wrapper names a file's code actually reaches: named imports/re-exports, a `export *` of all of
* them, and member accesses through a `import * as NS from ".../tauri-commands"` namespace. */
function wrapperNamesImportedBy(file: string, wrappers: Map<string, string>): string[] {
const text = readFileSync(file, "utf-8");
return [...text.matchAll(WRAPPER_IMPORT)].flatMap((m) =>
const named = [...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),
);
const starReexported = STAR_REEXPORT_WRAPPERS.test(text) ? [...wrappers.keys()] : [];
const namespaceAliases = [...text.matchAll(NAMESPACE_IMPORT)]
.filter((m) => resolveRelativeImport(file, m[2]) === WRAPPERS)
.map((m) => m[1]);
const namespaced = namespaceAliases.flatMap((alias) => {
const memberAccess = new RegExp(`\\b${alias}\\.(\\w+)\\b`, "g");
return [...text.matchAll(memberAccess)].map((m) => m[1]);
});
return [...named, ...starReexported, ...namespaced];
}
/** Transitive relative-import closure from the viewer entry. */
/** Transitive relative-import closure from the viewer entry, following static imports, dynamic
* `import()`, and `export … from` re-exports alike. */
function viewerClosure(): Set<string> {
const seen = new Set<string>();
const queue = [VIEWER_ENTRY];
@@ -116,7 +175,7 @@ describe("capability files match the code each window runs", () => {
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")))
.filter((f) => CORE_IMPORT.test(readFileSync(f, "utf-8")))
.map((f) => relative(srcDir, f));
expect(offenders).toEqual([]);
});
@@ -148,7 +207,7 @@ describe("capability files match the code each window runs", () => {
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)) {
for (const name of wrapperNamesImportedBy(file, wrappers)) {
const command = wrappers.get(name);
expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined();
viewerCommands.add(command!);
@@ -164,7 +223,7 @@ describe("capability files match the code each window runs", () => {
const mainCommands = new Set<string>();
for (const file of files) {
if (closure.has(file)) continue;
for (const name of wrapperNamesImportedBy(file)) {
for (const name of wrapperNamesImportedBy(file, wrappers)) {
const command = wrappers.get(name);
expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined();
mainCommands.add(command!);