Terminal file viewer/editor + per-window app-command lockdown #60
@@ -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!);
|
||||
|
||||
Reference in New Issue
Block a user