test: canonicalize housekeeping module boundaries
This commit is contained in:
1 parent
b6bf5e69ca
commit
a47b4eb195
1 file changed
+193
-24
@@ -136,6 +136,7 @@ const expressionWrapperTypes = new Set([
|
|||||||
"TSTypeAssertion",
|
"TSTypeAssertion",
|
||||||
"TypeCastExpression",
|
"TypeCastExpression",
|
||||||
]);
|
]);
|
||||||
|
const resolverExtensionPattern = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/i;
|
||||||
|
|
||||||
function isBabelNode(value: unknown): value is BabelNode {
|
function isBabelNode(value: unknown): value is BabelNode {
|
||||||
return (
|
return (
|
||||||
@@ -181,6 +182,38 @@ function readLiteralModuleSpecifier(node: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function memberPropertyName(node: BabelNode): string | null {
|
||||||
|
const property = unwrapModuleArgument(node.property);
|
||||||
|
if (!property) return null;
|
||||||
|
if (node.computed === true) return readLiteralModuleSpecifier(property);
|
||||||
|
return property.type === "Identifier" && typeof property.name === "string"
|
||||||
|
? property.name
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGuardedRequireCallee(node: unknown): boolean {
|
||||||
|
const callee = unwrapModuleArgument(node);
|
||||||
|
if (!callee) return false;
|
||||||
|
if (callee.type === "Identifier" && callee.name === "require") return true;
|
||||||
|
if (
|
||||||
|
callee.type !== "MemberExpression" &&
|
||||||
|
callee.type !== "OptionalMemberExpression"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const object = unwrapModuleArgument(callee.object);
|
||||||
|
const property = memberPropertyName(callee);
|
||||||
|
return (
|
||||||
|
(object?.type === "Identifier" &&
|
||||||
|
object.name === "module" &&
|
||||||
|
property === "require") ||
|
||||||
|
(object?.type === "Identifier" &&
|
||||||
|
object.name === "require" &&
|
||||||
|
property === "resolve")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function scanModuleAccesses(source: string): ModuleAccessScan {
|
function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||||
const root = babelParser.parse(source, {
|
const root = babelParser.parse(source, {
|
||||||
createImportExpressions: true,
|
createImportExpressions: true,
|
||||||
@@ -220,17 +253,18 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
|||||||
recordArgument(value.source, "import");
|
recordArgument(value.source, "import");
|
||||||
} else if (value.type === "TSImportType") {
|
} else if (value.type === "TSImportType") {
|
||||||
recordArgument(value.argument, "import");
|
recordArgument(value.argument, "import");
|
||||||
} else if (value.type === "CallExpression") {
|
} else if (
|
||||||
|
value.type === "CallExpression" ||
|
||||||
|
value.type === "OptionalCallExpression"
|
||||||
|
) {
|
||||||
const arguments_ = Array.isArray(value.arguments) ? value.arguments : [];
|
const arguments_ = Array.isArray(value.arguments) ? value.arguments : [];
|
||||||
if (isBabelNode(value.callee) && value.callee.type === "Import") {
|
if (isBabelNode(value.callee) && value.callee.type === "Import") {
|
||||||
recordArgument(arguments_[0], "import");
|
recordArgument(arguments_[0], "import");
|
||||||
} else if (
|
} else if (isGuardedRequireCallee(value.callee)) {
|
||||||
isBabelNode(value.callee) &&
|
|
||||||
value.callee.type === "Identifier" &&
|
|
||||||
value.callee.name === "require"
|
|
||||||
) {
|
|
||||||
recordArgument(arguments_[0], "require");
|
recordArgument(arguments_[0], "require");
|
||||||
}
|
}
|
||||||
|
} else if (value.type === "TSExternalModuleReference") {
|
||||||
|
recordArgument(value.expression, "require");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, child] of Object.entries(value)) {
|
for (const [key, child] of Object.entries(value)) {
|
||||||
@@ -241,18 +275,50 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
|||||||
visit(root);
|
visit(root);
|
||||||
return { specifiers, violations };
|
return { specifiers, violations };
|
||||||
}
|
}
|
||||||
function normalizeLocalSpecifier(
|
interface CanonicalLocalSpecifier {
|
||||||
|
candidates: readonly string[];
|
||||||
|
violation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeLocalSpecifier(
|
||||||
routeFile: string,
|
routeFile: string,
|
||||||
specifier: string,
|
specifier: string,
|
||||||
): string | null {
|
): CanonicalLocalSpecifier {
|
||||||
if (specifier.startsWith("@/")) {
|
const suffixIndex = specifier.search(/[?#]/);
|
||||||
return posix.normalize(`src/${specifier.slice(2)}`);
|
const withoutSuffix =
|
||||||
}
|
suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
|
||||||
if (specifier.startsWith(".")) {
|
const slashNormalized = withoutSuffix.replaceAll("\\", "/");
|
||||||
return posix.normalize(posix.join(posix.dirname(routeFile), specifier));
|
if (!slashNormalized.startsWith("@/") && !slashNormalized.startsWith(".")) {
|
||||||
|
return { candidates: [], violation: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(withoutSuffix).replaceAll("\\", "/");
|
||||||
|
} catch {
|
||||||
|
return { candidates: [], violation: "<malformed local specifier>" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized: string;
|
||||||
|
if (decoded.startsWith("@/")) {
|
||||||
|
normalized = posix.normalize(`src/${decoded.slice(2)}`);
|
||||||
|
} else if (decoded.startsWith(".")) {
|
||||||
|
normalized = posix.normalize(posix.join(posix.dirname(routeFile), decoded));
|
||||||
|
} else {
|
||||||
|
return { candidates: [], violation: "<malformed local specifier>" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionless = normalized.replace(resolverExtensionPattern, "");
|
||||||
|
return {
|
||||||
|
candidates: [...new Set([normalized, extensionless])],
|
||||||
|
violation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenModulePath(path: string): boolean {
|
||||||
|
return forbiddenModuleRoots.some(
|
||||||
|
(root) => path === root || path.startsWith(`${root}/`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findRouteImportBoundaryViolations(
|
function findRouteImportBoundaryViolations(
|
||||||
@@ -260,15 +326,17 @@ function findRouteImportBoundaryViolations(
|
|||||||
routeFile: string,
|
routeFile: string,
|
||||||
): readonly string[] {
|
): readonly string[] {
|
||||||
const scan = scanModuleAccesses(source);
|
const scan = scanModuleAccesses(source);
|
||||||
const forbiddenPaths = scan.specifiers
|
const violations = [...scan.violations];
|
||||||
.map((specifier) => normalizeLocalSpecifier(routeFile, specifier))
|
const forbiddenPaths: string[] = [];
|
||||||
.filter((path): path is string => path !== null)
|
|
||||||
.filter((path) =>
|
for (const specifier of scan.specifiers) {
|
||||||
forbiddenModuleRoots.some(
|
const canonical = canonicalizeLocalSpecifier(routeFile, specifier);
|
||||||
(root) => path === root || path.startsWith(`${root}/`),
|
if (canonical.violation) violations.push(canonical.violation);
|
||||||
),
|
const forbiddenPath = canonical.candidates.find(isForbiddenModulePath);
|
||||||
);
|
if (forbiddenPath) forbiddenPaths.push(forbiddenPath);
|
||||||
return [...scan.violations, ...forbiddenPaths];
|
}
|
||||||
|
|
||||||
|
return [...violations, ...forbiddenPaths];
|
||||||
}
|
}
|
||||||
|
|
||||||
function capabilityContext(
|
function capabilityContext(
|
||||||
@@ -489,6 +557,96 @@ describe("preview route import boundary", () => {
|
|||||||
const interpolationOpen = "$" + "{";
|
const interpolationOpen = "$" + "{";
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
[
|
||||||
|
"relative database import with resolver extension",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import db from "../../lib/db.js";',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"aliased database import with resolver extension",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import db from "@/lib/db.js";',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"action root import with resolver extension",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import actions from "../../actions.mjs";',
|
||||||
|
"src/actions",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"legacy mod root import with resolver extension",
|
||||||
|
"src/app/admin-next/layout.tsx",
|
||||||
|
'import mod from "../mod.cjs";',
|
||||||
|
"src/app/mod",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"database import with query suffix",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import db from "../../lib/db?server-only";',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"action import with hash suffix",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import("../../actions/users#server")',
|
||||||
|
"src/actions/users",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Windows-style relative database import",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
String.raw`import db from "..\\..\\lib\\db";`,
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Windows-style aliased database import",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
String.raw`import db from "@\\lib\\db";`,
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"percent-encoded database import",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import db from "../../lib/%64%62";',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"optional CommonJS database require",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'require?.("../../lib/db")',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"module database require",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'module.require("../../lib/db")',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"require.resolve database access",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'require.resolve("../../lib/db")',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"optional module database require",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'module.require?.("../../lib/db")',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"optional require.resolve database access",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'require.resolve?.("../../lib/db")',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"TypeScript import-equals database access",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import db = require("../../lib/db");',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"U+2028 line-continuation database import",
|
"U+2028 line-continuation database import",
|
||||||
"src/app/admin-next/page.tsx",
|
"src/app/admin-next/page.tsx",
|
||||||
@@ -634,6 +792,16 @@ describe("preview route import boundary", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
[
|
||||||
|
"optional CommonJS require",
|
||||||
|
"const path = '../../lib/db'; require?.(path)",
|
||||||
|
"<non-literal require>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"malformed local percent escape",
|
||||||
|
'import db from "../../lib/db%ZZ";',
|
||||||
|
"<malformed local specifier>",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"dynamic import",
|
"dynamic import",
|
||||||
"const path = '../../actions/users'; import(path)",
|
"const path = '../../actions/users'; import(path)",
|
||||||
@@ -652,7 +820,8 @@ describe("preview route import boundary", () => {
|
|||||||
|
|
||||||
it("does not reject substring lookalikes, comments, or ordinary strings", () => {
|
it("does not reject substring lookalikes, comments, or ordinary strings", () => {
|
||||||
const source = [
|
const source = [
|
||||||
'import database from "@/lib/database";',
|
'import database from "@/lib/database.js?raw";',
|
||||||
|
'import dbTools from "../../lib/db-tools.ts";',
|
||||||
'import auth from "../../lib/authentication";',
|
'import auth from "../../lib/authentication";',
|
||||||
'import preview from "../admin-next-shared";',
|
'import preview from "../admin-next-shared";',
|
||||||
"const documentation = \"import db from '../../lib/db'\";",
|
"const documentation = \"import db from '../../lib/db'\";",
|
||||||
|
|||||||
Reference in new issue
Block a user