test(acl): scan capabilities.test.ts imports with the TypeScript AST, fail-closed
Replace the regex/hand-rolled lexer with ts.createSourceFile + AST walking: module edges from import/export-from/literal import(); aliases, query suffixes, computed import(), require, import.meta.glob and any non member-access use of a tauri-commands namespace alias throw; wrapper to command extraction is read from the AST too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+240
-282
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
||||||
import { dirname, join, relative, resolve } from "path";
|
import { dirname, join, relative, resolve } from "path";
|
||||||
import { builtinModules } from "module";
|
import { builtinModules } from "module";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The capability files are the IPC ACL. Since the AppManifest lockdown, a window can only
|
* The capability files are the IPC ACL. Since the AppManifest lockdown, a window can only
|
||||||
@@ -14,11 +15,11 @@ import { builtinModules } from "module";
|
|||||||
* everything goes through `lib/tauri-commands.ts`, which is the only file allowed to import
|
* 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).
|
* `@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
|
* Every file is parsed with the TypeScript compiler (`ts.createSourceFile`), not scanned with
|
||||||
* boundary cases below (namespace imports, re-exports, dynamic imports, path aliases) are each
|
* regexes, so comments, strings, template substitutions and regex literals are the parser's
|
||||||
* handled explicitly rather than left to fall through silently, because a silent fall-through
|
* problem rather than ours. Anything the walk below cannot account for — a computed `import()`,
|
||||||
* here is exactly the shape of gap this test exists to close. Anything this file's heuristics
|
* a path alias, a namespace of the wrappers handed around as a value — throws (fail-closed);
|
||||||
* cannot make sense of is a thrown error (fail-closed), never a quiet `null`/skip.
|
* nothing is ever skipped quietly.
|
||||||
*/
|
*/
|
||||||
const srcDir = resolve(__dirname, "..");
|
const srcDir = resolve(__dirname, "..");
|
||||||
const capDir = resolve(srcDir, "../src-tauri/capabilities");
|
const capDir = resolve(srcDir, "../src-tauri/capabilities");
|
||||||
@@ -54,283 +55,243 @@ function sourceFiles(dir: string, out: string[] = []): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `export const NAME = … invoke<T>("command"` → NAME → command. Exactly one literal per wrapper. */
|
const rel = (file: string) => relative(srcDir, file);
|
||||||
|
|
||||||
|
function fail(file: string, node: ts.Node | undefined, message: string): never {
|
||||||
|
const where = node
|
||||||
|
? `:${node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()).line + 1}`
|
||||||
|
: "";
|
||||||
|
throw new Error(`${rel(file)}${where}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(file: string): ts.SourceFile {
|
||||||
|
const kind = /\.[jt]sx$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
||||||
|
return ts.createSourceFile(file, readFileSync(file, "utf-8"), ts.ScriptTarget.Latest, true, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-first visit of every node (JSDoc is not a child, so comments never show up). */
|
||||||
|
function walk(node: ts.Node, visit: (n: ts.Node) => void) {
|
||||||
|
visit(node);
|
||||||
|
ts.forEachChild(node, (child) => walk(child, visit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Specifiers that reach Tauri's raw `invoke`: `core` itself, and the package root, which
|
||||||
|
* re-exports it as `core`. Only `lib/tauri-commands.ts` may use either. */
|
||||||
|
const INVOKE_SPECIFIER = /^@tauri-apps\/api(\/(core|index)(\.[cm]?js)?)?\/?$/;
|
||||||
|
|
||||||
|
/** Only these are walked as source; any other extension (css, svg, png, json, …) is an asset. */
|
||||||
|
const SOURCE_EXTENSION = /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/;
|
||||||
|
const HAS_EXTENSION = /\.[^./]+$/;
|
||||||
|
/** A bare npm package name (optionally scoped) followed by an optional subpath. */
|
||||||
|
const PACKAGE_NAME = /^((?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*)(\/.*)?$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a module specifier to the source file it names, or `null` for something that is not
|
||||||
|
* part of `src/`'s module graph (a real package, a node builtin, an asset). Everything else
|
||||||
|
* throws: a path alias, a Vite query suffix (`?worker`, `?raw`), a relative path that leaves
|
||||||
|
* `src/` or names nothing.
|
||||||
|
*/
|
||||||
|
function resolveSpecifier(from: string, spec: string, node: ts.Node): string | null {
|
||||||
|
if (spec.includes("?") || spec.includes("#")) {
|
||||||
|
fail(from, node, `import "${spec}" carries a query/fragment suffix this test cannot audit`);
|
||||||
|
}
|
||||||
|
if (spec.startsWith("./") || spec.startsWith("../")) {
|
||||||
|
if (HAS_EXTENSION.test(spec) && !SOURCE_EXTENSION.test(spec)) return null; // a non-source asset
|
||||||
|
const base = resolve(dirname(from), spec);
|
||||||
|
const candidates = [base, ...["ts", "tsx", "js", "jsx"].flatMap((e) => [`${base}.${e}`, join(base, `index.${e}`)])];
|
||||||
|
if (/\.jsx?$/.test(base)) candidates.push(base.replace(/\.js(x?)$/, ".ts$1"), base.replace(/\.jsx?$/, ".tsx"));
|
||||||
|
const found = candidates.find((c) => existsSync(c) && statSync(c).isFile());
|
||||||
|
if (!found) fail(from, node, `cannot resolve import "${spec}"`);
|
||||||
|
if (rel(found).startsWith("..")) fail(from, node, `import "${spec}" resolves outside src/ (${found})`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
if (spec.startsWith("node:") || builtinModules.includes(spec)) return null;
|
||||||
|
const pkg = PACKAGE_NAME.exec(spec)?.[1];
|
||||||
|
if (pkg && existsSync(join(nodeModulesDir, pkg, "package.json"))) return null;
|
||||||
|
fail(
|
||||||
|
from,
|
||||||
|
node,
|
||||||
|
`import "${spec}" is neither relative nor an installed package — likely a path alias. This test ` +
|
||||||
|
`only understands relative imports and real dependencies; teach it the alias rather than letting ` +
|
||||||
|
`the file drop out of the closure.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringLiteralText = (node: ts.Node | undefined) =>
|
||||||
|
node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined;
|
||||||
|
|
||||||
|
interface ModuleFacts {
|
||||||
|
/** Every module specifier the file names: static imports, `export … from`, literal `import()`. */
|
||||||
|
specifiers: string[];
|
||||||
|
/** The source files those specifiers resolve to (packages and assets excluded). */
|
||||||
|
targets: string[];
|
||||||
|
/** Wrapper names the file reaches from `lib/tauri-commands.ts`. */
|
||||||
|
wrapperNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses one file and returns its module edges and the wrappers it reaches. Wrapper usage is:
|
||||||
|
* named imports and named re-exports (by their exported name), and `X.name` / `X?.name` (or
|
||||||
|
* `typeof X.name` in a type) where `X` is a namespace import of the wrappers. Fails closed on
|
||||||
|
* everything else that could carry a wrapper: a default import, `export *` / `export * as` of the wrappers, `import()` of them (it
|
||||||
|
* resolves to the namespace object), a computed `import()`, `require`, `import X = require`,
|
||||||
|
* `import.meta.glob`, and any reference to a namespace alias other than `X.name`.
|
||||||
|
*
|
||||||
|
* Namespace references are matched by identifier text, not symbol: every Identifier spelled `X`
|
||||||
|
* anywhere in the file must be the object of a property access (or the name *of* one, `o.X`,
|
||||||
|
* which is not a reference). A local that shadows `X` needs a declaration spelled `X`, and that
|
||||||
|
* declaration is itself such an Identifier, so shadowing fails closed rather than confusing it.
|
||||||
|
*/
|
||||||
|
function analyzeModule(file: string): ModuleFacts {
|
||||||
|
const sf = parse(file);
|
||||||
|
const facts: ModuleFacts = { specifiers: [], targets: [], wrapperNames: [] };
|
||||||
|
const namespaceAliases = new Map<string, ts.Identifier>();
|
||||||
|
|
||||||
|
const edge = (spec: string, node: ts.Node) => {
|
||||||
|
facts.specifiers.push(spec);
|
||||||
|
const target = resolveSpecifier(file, spec, node);
|
||||||
|
if (target) facts.targets.push(target);
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const stmt of sf.statements) {
|
||||||
|
if (ts.isImportDeclaration(stmt)) {
|
||||||
|
const target = edge(stringLiteralText(stmt.moduleSpecifier)!, stmt);
|
||||||
|
const clause = stmt.importClause;
|
||||||
|
if (target !== WRAPPERS || !clause) continue;
|
||||||
|
if (clause.name) fail(file, stmt, "default-imports tauri-commands.ts, which has no default export");
|
||||||
|
const bindings = clause.namedBindings;
|
||||||
|
if (bindings && ts.isNamespaceImport(bindings)) namespaceAliases.set(bindings.name.text, bindings.name);
|
||||||
|
if (bindings && ts.isNamedImports(bindings)) {
|
||||||
|
for (const el of bindings.elements) facts.wrapperNames.push((el.propertyName ?? el.name).text);
|
||||||
|
}
|
||||||
|
} else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier) {
|
||||||
|
const target = edge(stringLiteralText(stmt.moduleSpecifier)!, stmt);
|
||||||
|
if (target !== WRAPPERS) continue;
|
||||||
|
const clause = stmt.exportClause;
|
||||||
|
if (!clause || ts.isNamespaceExport(clause)) {
|
||||||
|
fail(
|
||||||
|
file,
|
||||||
|
stmt,
|
||||||
|
`re-exports tauri-commands.ts with "export *${clause ? " as …" : ""}", which cannot be audited — ` +
|
||||||
|
`re-export wrappers by name (export { wrapperName } from "…/tauri-commands")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const el of clause.elements) facts.wrapperNames.push((el.propertyName ?? el.name).text);
|
||||||
|
} else if (ts.isImportEqualsDeclaration(stmt) && ts.isExternalModuleReference(stmt.moduleReference)) {
|
||||||
|
fail(file, stmt, `"import … = require(…)" is not followed by this test; use an ES import`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(sf, (node) => {
|
||||||
|
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||||
|
const spec = stringLiteralText(node.arguments[0]);
|
||||||
|
if (spec === undefined || node.arguments.length === 0) {
|
||||||
|
fail(file, node, "import() with a computed specifier cannot be followed; use a string literal");
|
||||||
|
}
|
||||||
|
if (edge(spec, node) === WRAPPERS) {
|
||||||
|
fail(file, node, "import() of tauri-commands.ts yields the whole namespace object; import wrappers by name");
|
||||||
|
}
|
||||||
|
} else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") {
|
||||||
|
fail(file, node, "require() is not followed by this test; use an ES import");
|
||||||
|
} else if (
|
||||||
|
ts.isPropertyAccessExpression(node) &&
|
||||||
|
ts.isMetaProperty(node.expression) &&
|
||||||
|
node.expression.keywordToken === ts.SyntaxKind.ImportKeyword &&
|
||||||
|
node.name.text.startsWith("glob")
|
||||||
|
) {
|
||||||
|
fail(file, node, "import.meta.glob pulls in modules this test cannot enumerate");
|
||||||
|
} else if (ts.isIdentifier(node) && namespaceAliases.has(node.text)) {
|
||||||
|
if (node === namespaceAliases.get(node.text)) return; // the `import * as X` binding itself
|
||||||
|
const parent = node.parent;
|
||||||
|
if (ts.isPropertyAccessExpression(parent) && parent.name === node) return; // `o.X` — not a reference
|
||||||
|
if (ts.isPropertyAccessExpression(parent) && parent.expression === node && ts.isIdentifier(parent.name)) {
|
||||||
|
facts.wrapperNames.push(parent.name.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `typeof X.name` in a type: a QualifiedName, type-only, counted anyway (the safe direction).
|
||||||
|
if (ts.isQualifiedName(parent) && parent.left === node && ts.isTypeQueryNode(parent.parent)) {
|
||||||
|
facts.wrapperNames.push(parent.right.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(
|
||||||
|
file,
|
||||||
|
node,
|
||||||
|
`"${node.text}" (a namespace import of tauri-commands.ts) is used in \`${parent.getText().slice(0, 60)}\` ` +
|
||||||
|
`rather than as "${node.text}.wrapperName" — only direct member access can be audited; import ` +
|
||||||
|
`the wrappers by name instead`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return facts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `export const NAME = … invoke<T>("command", …)` → NAME → command, read from the AST: each exported
|
||||||
|
* const calls `invoke` exactly once, with a string literal. `invoke` must be imported by name from
|
||||||
|
* `@tauri-apps/api/core` and appear nowhere except as the callee of such a call.
|
||||||
|
*/
|
||||||
function wrapperCommands(): Map<string, string> {
|
function wrapperCommands(): Map<string, string> {
|
||||||
const text = readFileSync(WRAPPERS, "utf-8");
|
const sf = parse(WRAPPERS);
|
||||||
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>();
|
const map = new Map<string, string>();
|
||||||
for (const chunk of chunks) {
|
const callsByWrapper = new Map<string, ts.CallExpression[]>();
|
||||||
const name = /^(\w+)/.exec(chunk)?.[1] ?? "";
|
for (const stmt of sf.statements) {
|
||||||
const calls = chunk.match(/\binvoke(?:<[^>]*>)?\(/g) ?? [];
|
if (ts.isImportDeclaration(stmt) && INVOKE_SPECIFIER.test(stringLiteralText(stmt.moduleSpecifier)!)) {
|
||||||
const literals = [...chunk.matchAll(/\binvoke(?:<[^>]*>)?\(\s*"([a-z_]+)"/g)].map((m) => m[1]);
|
const b = stmt.importClause?.namedBindings;
|
||||||
|
const onlyInvoke =
|
||||||
|
!stmt.importClause?.name &&
|
||||||
|
b !== undefined &&
|
||||||
|
ts.isNamedImports(b) &&
|
||||||
|
b.elements.every((el) => !el.propertyName && el.name.text === "invoke");
|
||||||
|
if (!onlyInvoke) fail(WRAPPERS, stmt, `must import exactly { invoke } from ${stringLiteralText(stmt.moduleSpecifier)}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ts.isVariableStatement(stmt) &&
|
||||||
|
stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
|
||||||
|
stmt.declarationList.flags & ts.NodeFlags.Const
|
||||||
|
) {
|
||||||
|
for (const decl of stmt.declarationList.declarations) {
|
||||||
|
if (!ts.isIdentifier(decl.name)) fail(WRAPPERS, decl, "an exported wrapper must be a plain `export const NAME`");
|
||||||
|
callsByWrapper.set(decl.name.text, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(sf, (node) => {
|
||||||
|
if (!ts.isIdentifier(node) || node.text !== "invoke" || ts.isImportSpecifier(node.parent)) return;
|
||||||
|
const call = node.parent;
|
||||||
|
if (!ts.isCallExpression(call) || call.expression !== node) {
|
||||||
|
fail(WRAPPERS, node, "invoke is referenced other than as a direct call");
|
||||||
|
}
|
||||||
|
let decl: ts.Node = call;
|
||||||
|
while (!(ts.isVariableDeclaration(decl) && decl.parent.parent.parent === sf)) {
|
||||||
|
decl = decl.parent;
|
||||||
|
if (decl === sf) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper");
|
||||||
|
}
|
||||||
|
const calls = callsByWrapper.get((decl as ts.VariableDeclaration).name.getText());
|
||||||
|
if (!calls) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper");
|
||||||
|
calls.push(call);
|
||||||
|
});
|
||||||
|
for (const [name, calls] of callsByWrapper) {
|
||||||
expect(calls, `${name} must call invoke exactly once`).toHaveLength(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);
|
const command = stringLiteralText(calls[0].arguments[0]) ?? "<not a string literal>";
|
||||||
map.set(name, literals[0]);
|
expect(command, `${name} must invoke a string literal (a computed name cannot be audited)`).toMatch(/^[a-z_]+$/);
|
||||||
|
map.set(name, command);
|
||||||
}
|
}
|
||||||
expect(map.size).toBeGreaterThan(100);
|
expect(map.size).toBeGreaterThan(100);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A non-relative specifier resolves to a real dependency: a node builtin or a node_modules package. */
|
const factsCache = new Map<string, ModuleFacts>();
|
||||||
function isRealPackage(spec: string): boolean {
|
function factsOf(file: string): ModuleFacts {
|
||||||
if (builtinModules.includes(spec) || spec.startsWith("node:")) return true;
|
let facts = factsCache.get(file);
|
||||||
const parts = spec.split("/");
|
if (!facts) {
|
||||||
const pkgName = spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
facts = analyzeModule(file);
|
||||||
return existsSync(resolve(nodeModulesDir, pkgName));
|
factsCache.set(file, facts);
|
||||||
|
}
|
||||||
|
return facts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only .ts/.tsx/.js/.jsx are source; anything else with an extension (css, svg, png, woff, …) is an asset. */
|
/** Transitive closure from the viewer entry over static imports, `export … from` and `import()`. */
|
||||||
const SOURCE_EXTENSION = /\.(ts|tsx|js|jsx)$/;
|
|
||||||
const HAS_EXTENSION = /\.[^./]+$/;
|
|
||||||
|
|
||||||
function resolveRelativeImport(from: string, spec: string): string | null {
|
|
||||||
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);
|
|
||||||
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;
|
|
||||||
/** `export { x } from "…"`, `export * from "…"` and `export * as ns from "…"` — a re-export is a
|
|
||||||
* module-graph edge too. */
|
|
||||||
const EXPORT_FROM_SPEC = /^export\s*(?:\*(?:\s*as\s+\w+)?|(?: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;
|
|
||||||
/** `export * as ns from ".../tauri-commands"` — a namespace re-export. Resolving `ns`'s downstream
|
|
||||||
* uses (by whoever imports it from *this* file) is out of reach for a per-file regex scan, so this
|
|
||||||
* form is fail-closed rather than resolved, same as an unhandled path alias. No `g` flag, same
|
|
||||||
* `.test()`-reuse reason as STAR_REEXPORT_WRAPPERS above. */
|
|
||||||
const NAMESPACE_REEXPORT = /^export\s*\*\s*as\s+\w+\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]),
|
|
||||||
...[...text.matchAll(EXPORT_FROM_SPEC)].map((m) => m[1]),
|
|
||||||
...[...text.matchAll(DYNAMIC_IMPORT_SPEC)].map((m) => m[1]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Scans a double- or single-quoted string starting at `text[start]` (the opening quote) and
|
|
||||||
* returns the index just past its closing quote, honoring backslash escapes. Throws (fail-closed)
|
|
||||||
* on an unterminated string rather than silently running to end-of-file. */
|
|
||||||
function skipQuoted(text: string, start: number, file: string): number {
|
|
||||||
const quote = text[start];
|
|
||||||
let i = start + 1;
|
|
||||||
while (i < text.length) {
|
|
||||||
if (text[i] === "\\") {
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (text[i] === quote) return i + 1;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
throw new Error(`${relative(srcDir, file)}: unterminated ${quote === '"' ? "double" : "single"}-quoted string`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Scans a `${...}` substitution's contents starting right after the `${`, tracking nesting of
|
|
||||||
* `{`/`(`/`[` (and skipping over any string/template/comment inside) so an unrelated `}` doesn't
|
|
||||||
* end the substitution early. Returns the raw source text of the substitution (not including the
|
|
||||||
* closing `}`) and the index just past that `}`. Throws (fail-closed) if it runs off the end
|
|
||||||
* without finding a balancing `}` — an unparseable substitution is not silently ignored. */
|
|
||||||
function scanSubstitution(text: string, start: number, file: string): { content: string; endIndex: number } {
|
|
||||||
let i = start;
|
|
||||||
let depth = 0;
|
|
||||||
while (i < text.length) {
|
|
||||||
const c = text[i];
|
|
||||||
if (c === "\\") {
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === '"' || c === "'") {
|
|
||||||
i = skipQuoted(text, i, file);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "`") {
|
|
||||||
i = maskTemplate(text, i, file).endIndex;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "/" && text[i + 1] === "*") {
|
|
||||||
const end = text.indexOf("*/", i + 2);
|
|
||||||
if (end === -1) throw new Error(`${relative(srcDir, file)}: unterminated block comment`);
|
|
||||||
i = end + 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "/" && text[i + 1] === "/") {
|
|
||||||
const end = text.indexOf("\n", i + 2);
|
|
||||||
i = end === -1 ? text.length : end;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "{" || c === "(" || c === "[") {
|
|
||||||
depth++;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === ")" || c === "]") {
|
|
||||||
depth--;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "}") {
|
|
||||||
if (depth === 0) return { content: text.slice(start, i), endIndex: i + 1 };
|
|
||||||
depth--;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
throw new Error(`${relative(srcDir, file)}: unterminated template substitution ("\${...}" never closes)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Masks a backtick template literal starting at `text[start]` (the opening backtick): literal
|
|
||||||
* template *text* is dropped, but every `${...}` substitution's source is kept — recursively
|
|
||||||
* stripped of its own comments/strings/nested templates via `stripCommentsAndStrings` — since a
|
|
||||||
* namespace alias referenced only inside a substitution is passed by reference to whatever
|
|
||||||
* consumes it (a tagged template hands each substitution to the tag function unstringified,
|
|
||||||
* exactly like `fn(alias)`) and must stay visible to the occurrence scan. Throws (fail-closed) on
|
|
||||||
* an unterminated template. */
|
|
||||||
function maskTemplate(text: string, start: number, file: string): { result: string; endIndex: number } {
|
|
||||||
let i = start + 1;
|
|
||||||
let result = "";
|
|
||||||
while (i < text.length) {
|
|
||||||
const c = text[i];
|
|
||||||
if (c === "\\") {
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "`") return { result, endIndex: i + 1 };
|
|
||||||
if (c === "$" && text[i + 1] === "{") {
|
|
||||||
const { content, endIndex } = scanSubstitution(text, i + 2, file);
|
|
||||||
result += ` ${stripCommentsAndStrings(content, file)} `;
|
|
||||||
i = endIndex;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
throw new Error(`${relative(srcDir, file)}: unterminated template literal`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best-effort removal of block/line comments and string/template literal *text*, so the
|
|
||||||
* namespace-usage scan below doesn't trip on the alias appearing in prose or as string data — while
|
|
||||||
* keeping the source of any `${...}` template substitution intact, recursively, since that's live
|
|
||||||
* code that can reference the alias. A small hand-rolled scanner, not a full parser: good enough
|
|
||||||
* for this test's purpose. Throws (fail-closed) on anything it can't make sense of — an
|
|
||||||
* unterminated string, comment or template — rather than silently truncating. */
|
|
||||||
function stripCommentsAndStrings(text: string, file: string): string {
|
|
||||||
let out = "";
|
|
||||||
let i = 0;
|
|
||||||
while (i < text.length) {
|
|
||||||
const c = text[i];
|
|
||||||
if (c === "/" && text[i + 1] === "*") {
|
|
||||||
const end = text.indexOf("*/", i + 2);
|
|
||||||
if (end === -1) throw new Error(`${relative(srcDir, file)}: unterminated block comment`);
|
|
||||||
i = end + 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "/" && text[i + 1] === "/") {
|
|
||||||
const end = text.indexOf("\n", i + 2);
|
|
||||||
i = end === -1 ? text.length : end;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === '"' || c === "'") {
|
|
||||||
i = skipQuoted(text, i, file);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "`") {
|
|
||||||
const masked = maskTemplate(text, i, file);
|
|
||||||
out += masked.result;
|
|
||||||
i = masked.endIndex;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out += c;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every occurrence of a namespace-imported alias, after its import line, must be a plain
|
|
||||||
* `alias.identifier` member access — `fn(alias)`, `const y = alias`, `alias["name"]`,
|
|
||||||
* `alias[expr]`, and a tagged-template substitution like `` tag`${alias}` `` all hand the whole
|
|
||||||
* namespace object somewhere this scan can't follow, which is exactly the silent-smuggling shape
|
|
||||||
* this test exists to close. Any such occurrence is fail-closed rather than silently accepted. */
|
|
||||||
function assertNamespaceUsedAsMemberAccessOnly(file: string, text: string, alias: string, importEnd: number) {
|
|
||||||
const rest = stripCommentsAndStrings(text.slice(importEnd), file);
|
|
||||||
const occurrence = new RegExp(`\\b${alias}\\b(\\.[A-Za-z_$][\\w$]*)?`, "g");
|
|
||||||
for (const m of rest.matchAll(occurrence)) {
|
|
||||||
if (!m[1]) {
|
|
||||||
throw new Error(
|
|
||||||
`${relative(srcDir, file)}: "${alias}" (a namespace import of tauri-commands.ts) is used as a ` +
|
|
||||||
`bare value rather than always as "${alias}.wrapperName" — this test can only audit direct ` +
|
|
||||||
`member access; rewrite as named imports (import { wrapperName } from "…/tauri-commands") so ` +
|
|
||||||
`wrapper usage stays auditable.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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.
|
|
||||||
* `strict` additionally fails closed on forms this scan cannot safely resolve — the viewer side of
|
|
||||||
* the ACL boundary, where a silent miss would be a real escape, uses it; the main-window side (used
|
|
||||||
* only to build an inclusive usage count, not to bound anything) does not. */
|
|
||||||
function wrapperNamesImportedBy(file: string, wrappers: Map<string, string>, strict: boolean): string[] {
|
|
||||||
const text = readFileSync(file, "utf-8");
|
|
||||||
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()] : [];
|
|
||||||
if (strict && NAMESPACE_REEXPORT.test(text)) {
|
|
||||||
throw new Error(
|
|
||||||
`${relative(srcDir, file)}: re-exports tauri-commands.ts via "export * as ... from" — this test ` +
|
|
||||||
`cannot audit a namespace re-export; use named re-exports (export { wrapperName } from ` +
|
|
||||||
`"…/tauri-commands") instead.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const namespaceImports = [...text.matchAll(NAMESPACE_IMPORT)].filter(
|
|
||||||
(m) => resolveRelativeImport(file, m[2]) === WRAPPERS,
|
|
||||||
);
|
|
||||||
if (strict) {
|
|
||||||
for (const m of namespaceImports) {
|
|
||||||
assertNamespaceUsedAsMemberAccessOnly(file, text, m[1], m.index + m[0].length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const namespaced = namespaceImports.flatMap((m) => {
|
|
||||||
const alias = m[1];
|
|
||||||
const memberAccess = new RegExp(`\\b${alias}\\.(\\w+)\\b`, "g");
|
|
||||||
return [...text.matchAll(memberAccess)].map((mm) => mm[1]);
|
|
||||||
});
|
|
||||||
return [...named, ...starReexported, ...namespaced];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Transitive relative-import closure from the viewer entry, following static imports, dynamic
|
|
||||||
* `import()`, and `export … from` re-exports alike. */
|
|
||||||
function viewerClosure(): Set<string> {
|
function viewerClosure(): Set<string> {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const queue = [VIEWER_ENTRY];
|
const queue = [VIEWER_ENTRY];
|
||||||
@@ -338,10 +299,7 @@ function viewerClosure(): Set<string> {
|
|||||||
const file = queue.pop()!;
|
const file = queue.pop()!;
|
||||||
if (seen.has(file)) continue;
|
if (seen.has(file)) continue;
|
||||||
seen.add(file);
|
seen.add(file);
|
||||||
for (const spec of importSpecs(file)) {
|
for (const target of factsOf(file).targets) if (!seen.has(target)) queue.push(target);
|
||||||
const target = resolveRelativeImport(file, spec);
|
|
||||||
if (target && !seen.has(target)) queue.push(target);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return seen;
|
return seen;
|
||||||
}
|
}
|
||||||
@@ -354,8 +312,8 @@ describe("capability files match the code each window runs", () => {
|
|||||||
it("only lib/tauri-commands.ts imports @tauri-apps/api/core", () => {
|
it("only lib/tauri-commands.ts imports @tauri-apps/api/core", () => {
|
||||||
const offenders = files
|
const offenders = files
|
||||||
.filter((f) => f !== WRAPPERS)
|
.filter((f) => f !== WRAPPERS)
|
||||||
.filter((f) => CORE_IMPORT.test(readFileSync(f, "utf-8")))
|
.filter((f) => factsOf(f).specifiers.some((s) => INVOKE_SPECIFIER.test(s)))
|
||||||
.map((f) => relative(srcDir, f));
|
.map(rel);
|
||||||
expect(offenders).toEqual([]);
|
expect(offenders).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -386,9 +344,9 @@ describe("capability files match the code each window runs", () => {
|
|||||||
expect(closure.has(WRAPPERS), "the viewer reaches tauri-commands.ts").toBe(true);
|
expect(closure.has(WRAPPERS), "the viewer reaches tauri-commands.ts").toBe(true);
|
||||||
const viewerCommands = new Set<string>();
|
const viewerCommands = new Set<string>();
|
||||||
for (const file of closure) {
|
for (const file of closure) {
|
||||||
for (const name of wrapperNamesImportedBy(file, wrappers, true)) {
|
for (const name of factsOf(file).wrapperNames) {
|
||||||
const command = wrappers.get(name);
|
const command = wrappers.get(name);
|
||||||
expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined();
|
expect(command, `${rel(file)} imports unknown wrapper ${name}`).toBeDefined();
|
||||||
viewerCommands.add(command!);
|
viewerCommands.add(command!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,9 +360,9 @@ describe("capability files match the code each window runs", () => {
|
|||||||
const mainCommands = new Set<string>();
|
const mainCommands = new Set<string>();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (closure.has(file)) continue;
|
if (closure.has(file)) continue;
|
||||||
for (const name of wrapperNamesImportedBy(file, wrappers, false)) {
|
for (const name of factsOf(file).wrapperNames) {
|
||||||
const command = wrappers.get(name);
|
const command = wrappers.get(name);
|
||||||
expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined();
|
expect(command, `${rel(file)} imports unknown wrapper ${name}`).toBeDefined();
|
||||||
mainCommands.add(command!);
|
mainCommands.add(command!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user