diff --git a/app/src/test/capabilities.test.ts b/app/src/test/capabilities.test.ts index ebe0861..3e4fba6 100644 --- a/app/src/test/capabilities.test.ts +++ b/app/src/test/capabilities.test.ts @@ -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 { 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[] { 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 { const seen = new Set(); 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(); 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(); 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!);