test(acl): resolve like Vite and cover every code file in capabilities.test.ts
Relative specifiers now follow Vite 6's tryCleanFsResolve order (exact file, js->ts twin, .mjs/.js/.mts/.ts/.jsx/.tsx/.json, then index), so a dotted name like ./evil.impl and a .mjs shadowing a .ts resolve to the file Vite loads. The @tauri-apps/api(/core) boundary covers every code file under src/, tests included; the main-window count includes .js/.mjs/.mts/.jsx sources. tauri-commands.ts must call invoke inside a wrapper's function body and may not load modules dynamically. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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, sep } from "path";
|
||||||
import { builtinModules } from "module";
|
import { builtinModules } from "module";
|
||||||
import ts from "typescript";
|
import ts from "typescript";
|
||||||
|
|
||||||
@@ -42,19 +42,23 @@ function readCapability(file: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every non-test .ts/.tsx under src/, excluding src/test. */
|
/** A code extension: anything Vite would run as a module rather than serve as an asset. */
|
||||||
function sourceFiles(dir: string, out: string[] = []): string[] {
|
const CODE_EXTENSION = /\.(mjs|js|mts|ts|jsx|tsx|cjs|cts)$/;
|
||||||
|
|
||||||
|
/** Every code file under src/, tests and src/test included. */
|
||||||
|
function codeFiles(dir: string, out: string[] = []): string[] {
|
||||||
for (const name of readdirSync(dir)) {
|
for (const name of readdirSync(dir)) {
|
||||||
const path = join(dir, name);
|
const path = join(dir, name);
|
||||||
if (statSync(path).isDirectory()) {
|
if (statSync(path).isDirectory()) codeFiles(path, out);
|
||||||
if (name !== "test") sourceFiles(path, out);
|
else if (CODE_EXTENSION.test(name)) out.push(path);
|
||||||
} else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) && !name.endsWith(".d.ts")) {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Code that ships in a window: not under src/test, not a `*.test.*`, not a declaration file. */
|
||||||
|
const isAppSource = (file: string) =>
|
||||||
|
!relative(srcDir, file).startsWith(`test${sep}`) && !/\.test\.[^./]+$/.test(file) && !/\.d\.[cm]?ts$/.test(file);
|
||||||
|
|
||||||
const rel = (file: string) => relative(srcDir, file);
|
const rel = (file: string) => relative(srcDir, file);
|
||||||
|
|
||||||
function fail(file: string, node: ts.Node | undefined, message: string): never {
|
function fail(file: string, node: ts.Node | undefined, message: string): never {
|
||||||
@@ -79,12 +83,41 @@ function walk(node: ts.Node, visit: (n: ts.Node) => void) {
|
|||||||
* re-exports it as `core`. Only `lib/tauri-commands.ts` may use either. */
|
* re-exports it as `core`. Only `lib/tauri-commands.ts` may use either. */
|
||||||
const INVOKE_SPECIFIER = /^@tauri-apps\/api(\/(core|index)(\.[cm]?js)?)?\/?$/;
|
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. */
|
/** Vite 6's default `resolve.extensions`, in its order (vite.config.ts does not override it). */
|
||||||
const SOURCE_EXTENSION = /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/;
|
const VITE_EXTENSIONS = [".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx", ".json"];
|
||||||
const HAS_EXTENSION = /\.[^./]+$/;
|
|
||||||
/** A bare npm package name (optionally scoped) followed by an optional subpath. */
|
/** A bare npm package name (optionally scoped) followed by an optional subpath. */
|
||||||
const PACKAGE_NAME = /^((?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*)(\/.*)?$/i;
|
const PACKAGE_NAME = /^((?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*)(\/.*)?$/i;
|
||||||
|
|
||||||
|
const isFile = (p: string) => existsSync(p) && statSync(p).isFile();
|
||||||
|
const isDir = (p: string) => existsSync(p) && statSync(p).isDirectory();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite 6's `tryCleanFsResolve` for a relative path, step for step, so the file analysed is the
|
||||||
|
* file Vite would load: the exact path if it is a file; else a `.js`/`.mjs`/`.cjs`/`.jsx` path's
|
||||||
|
* TypeScript twin (`.js` → `.ts`, then `.tsx`); else `path + ext` over VITE_EXTENSIONS in order
|
||||||
|
* (so `shadow.mjs` beats `shadow.ts`, and `./evil.impl` finds `evil.impl.ts`); else, for a
|
||||||
|
* directory, `index + ext` in the same order. A directory with a package.json would switch Vite to
|
||||||
|
* package-entry resolution, which this test does not model, so it fails closed.
|
||||||
|
*/
|
||||||
|
function viteResolveRelative(path: string, from: string, node: ts.Node): string | undefined {
|
||||||
|
if (isFile(path)) return path;
|
||||||
|
if (/\.(?:js|mjs|cjs|jsx)$/.test(path)) {
|
||||||
|
const ext = path.slice(path.lastIndexOf("."));
|
||||||
|
const stem = path.slice(0, -ext.length);
|
||||||
|
const twin = [stem + ext.replace("js", "ts"), ...(ext === ".js" ? [`${stem}.tsx`] : [])].find(isFile);
|
||||||
|
if (twin) return twin;
|
||||||
|
}
|
||||||
|
const withExt = VITE_EXTENSIONS.map((e) => path + e).find(isFile);
|
||||||
|
if (withExt) return withExt;
|
||||||
|
if (isDir(path)) {
|
||||||
|
if (existsSync(join(path, "package.json"))) {
|
||||||
|
fail(from, node, `imports directory ${rel(path)}, which has a package.json this test does not model`);
|
||||||
|
}
|
||||||
|
return VITE_EXTENSIONS.map((e) => join(path, `index${e}`)).find(isFile);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a module specifier to the source file it names, or `null` for something that is not
|
* 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
|
* part of `src/`'s module graph (a real package, a node builtin, an asset). Everything else
|
||||||
@@ -96,14 +129,10 @@ function resolveSpecifier(from: string, spec: string, node: ts.Node): string | n
|
|||||||
fail(from, node, `import "${spec}" carries a query/fragment suffix this test cannot audit`);
|
fail(from, node, `import "${spec}" carries a query/fragment suffix this test cannot audit`);
|
||||||
}
|
}
|
||||||
if (spec.startsWith("./") || spec.startsWith("../")) {
|
if (spec.startsWith("./") || spec.startsWith("../")) {
|
||||||
if (HAS_EXTENSION.test(spec) && !SOURCE_EXTENSION.test(spec)) return null; // a non-source asset
|
const found = viteResolveRelative(resolve(dirname(from), spec), from, node);
|
||||||
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 (!found) fail(from, node, `cannot resolve import "${spec}"`);
|
||||||
if (rel(found).startsWith("..")) fail(from, node, `import "${spec}" resolves outside src/ (${found})`);
|
if (rel(found).startsWith("..")) fail(from, node, `import "${spec}" resolves outside src/ (${found})`);
|
||||||
return found;
|
return CODE_EXTENSION.test(found) ? found : null; // css, svg, json, … — an asset, not a module
|
||||||
}
|
}
|
||||||
if (spec.startsWith("node:") || builtinModules.includes(spec)) return null;
|
if (spec.startsWith("node:") || builtinModules.includes(spec)) return null;
|
||||||
const pkg = PACKAGE_NAME.exec(spec)?.[1];
|
const pkg = PACKAGE_NAME.exec(spec)?.[1];
|
||||||
@@ -257,6 +286,13 @@ function wrapperCommands(): Map<string, string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(sf, (node) => {
|
walk(sf, (node) => {
|
||||||
|
if (
|
||||||
|
ts.isCallExpression(node) &&
|
||||||
|
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||||
|
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
|
||||||
|
) {
|
||||||
|
fail(WRAPPERS, node, "tauri-commands.ts may not load modules dynamically (import()/require())");
|
||||||
|
}
|
||||||
if (!ts.isIdentifier(node) || node.text !== "invoke" || ts.isImportSpecifier(node.parent)) return;
|
if (!ts.isIdentifier(node) || node.text !== "invoke" || ts.isImportSpecifier(node.parent)) return;
|
||||||
const call = node.parent;
|
const call = node.parent;
|
||||||
if (!ts.isCallExpression(call) || call.expression !== node) {
|
if (!ts.isCallExpression(call) || call.expression !== node) {
|
||||||
@@ -269,6 +305,16 @@ function wrapperCommands(): Map<string, string> {
|
|||||||
}
|
}
|
||||||
const calls = callsByWrapper.get((decl as ts.VariableDeclaration).name.getText());
|
const calls = callsByWrapper.get((decl as ts.VariableDeclaration).name.getText());
|
||||||
if (!calls) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper");
|
if (!calls) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper");
|
||||||
|
// Only inside the wrapper's own function body: anything else (`export const x = invoke(…)`, an
|
||||||
|
// IIFE, a default argument) runs at module load in every window that imports this file.
|
||||||
|
const init = (decl as ts.VariableDeclaration).initializer;
|
||||||
|
const inBody =
|
||||||
|
init !== undefined &&
|
||||||
|
(ts.isArrowFunction(init) || ts.isFunctionExpression(init)) &&
|
||||||
|
call.pos >= init.body.pos &&
|
||||||
|
call.end <= init.body.end &&
|
||||||
|
!enclosedInIife(call, init);
|
||||||
|
if (!inBody) fail(WRAPPERS, call, "invoke must be called inside the wrapper's function body, not at module load");
|
||||||
calls.push(call);
|
calls.push(call);
|
||||||
});
|
});
|
||||||
for (const [name, calls] of callsByWrapper) {
|
for (const [name, calls] of callsByWrapper) {
|
||||||
@@ -281,6 +327,38 @@ function wrapperCommands(): Map<string, string> {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether `node` sits in a function expression that is called on the spot, between it and `outer`. */
|
||||||
|
function enclosedInIife(node: ts.Node, outer: ts.Node): boolean {
|
||||||
|
for (let n = node.parent; n !== outer; n = n.parent) {
|
||||||
|
let fn: ts.Node = n;
|
||||||
|
if (!(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) continue;
|
||||||
|
while (ts.isParenthesizedExpression(fn.parent)) fn = fn.parent;
|
||||||
|
if (ts.isCallExpression(fn.parent) && fn.parent.expression === fn) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Module specifiers a file names — static imports, `export … from`, `import … = require`, and the
|
||||||
|
* argument of `import()`/`require()` — without resolving anything or applying the closure rules,
|
||||||
|
* so it can run over test files too. A computed `import()`/`require()` argument yields `null`. */
|
||||||
|
function namedSpecifiers(file: string): (string | null)[] {
|
||||||
|
const out: (string | null)[] = [];
|
||||||
|
walk(parse(file), (node) => {
|
||||||
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
|
||||||
|
out.push(stringLiteralText(node.moduleSpecifier) ?? null);
|
||||||
|
} else if (ts.isExternalModuleReference(node)) {
|
||||||
|
out.push(stringLiteralText(node.expression) ?? null);
|
||||||
|
} else if (
|
||||||
|
ts.isCallExpression(node) &&
|
||||||
|
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||||
|
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
|
||||||
|
) {
|
||||||
|
out.push(stringLiteralText(node.arguments[0]) ?? null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
const factsCache = new Map<string, ModuleFacts>();
|
const factsCache = new Map<string, ModuleFacts>();
|
||||||
function factsOf(file: string): ModuleFacts {
|
function factsOf(file: string): ModuleFacts {
|
||||||
let facts = factsCache.get(file);
|
let facts = factsCache.get(file);
|
||||||
@@ -307,14 +385,16 @@ function viewerClosure(): Set<string> {
|
|||||||
describe("capability files match the code each window runs", () => {
|
describe("capability files match the code each window runs", () => {
|
||||||
const defaultCap = readCapability("default.json");
|
const defaultCap = readCapability("default.json");
|
||||||
const viewerCap = readCapability("file-viewer.json");
|
const viewerCap = readCapability("file-viewer.json");
|
||||||
const files = sourceFiles(srcDir);
|
const allCode = codeFiles(srcDir);
|
||||||
|
const files = allCode.filter(isAppSource);
|
||||||
|
|
||||||
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
|
// Every code file, tests included: the viewer closure can reach anything a relative import can.
|
||||||
|
const offenders = allCode
|
||||||
.filter((f) => f !== WRAPPERS)
|
.filter((f) => f !== WRAPPERS)
|
||||||
.filter((f) => factsOf(f).specifiers.some((s) => INVOKE_SPECIFIER.test(s)))
|
.filter((f) => namedSpecifiers(f).some((s) => s === null || INVOKE_SPECIFIER.test(s)))
|
||||||
.map(rel);
|
.map(rel);
|
||||||
expect(offenders).toEqual([]);
|
expect(offenders, "imports @tauri-apps/api(/core), or loads a computed specifier").toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the windows lists are the reviewed ones", () => {
|
it("the windows lists are the reviewed ones", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user