diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index 18880329..eef6b2ff 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { posix, resolve } from "node:path"; import { createElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -99,281 +100,147 @@ const forbiddenModuleRoots = [ "src/app/mod", ] as const; -function decodeJavaScriptStringEscapes(value: string): string { - let decoded = ""; +interface BabelNode { + type: string; + [key: string]: unknown; +} - for (let index = 0; index < value.length; index += 1) { - const character = value[index]; - if (character !== "\\") { - decoded += character; - continue; - } +interface BabelParser { + parse( + source: string, + options: { + createImportExpressions: boolean; + plugins: readonly ["typescript", "jsx"]; + sourceType: "module"; + }, + ): BabelNode; +} - const escaped = value[index + 1]; - if (escaped === undefined) { - decoded += "\\"; - continue; - } - if (escaped === "\n") { - index += 1; - continue; - } - if (escaped === "\r") { - index += value[index + 2] === "\n" ? 2 : 1; - continue; - } - if (escaped === "x") { - const hexadecimal = value.slice(index + 2, index + 4); - if (/^[0-9A-Fa-f]{2}$/.test(hexadecimal)) { - decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16)); - index += 3; - continue; - } - } - if (escaped === "u") { - if (value[index + 2] === "{") { - const closingBrace = value.indexOf("}", index + 3); - const hexadecimal = value.slice(index + 3, closingBrace); - if ( - closingBrace >= 0 && - /^[0-9A-Fa-f]{1,6}$/.test(hexadecimal) && - Number.parseInt(hexadecimal, 16) <= 0x10ffff - ) { - decoded += String.fromCodePoint(Number.parseInt(hexadecimal, 16)); - index = closingBrace; - continue; - } - } else { - const hexadecimal = value.slice(index + 2, index + 6); - if (/^[0-9A-Fa-f]{4}$/.test(hexadecimal)) { - decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16)); - index += 5; - continue; - } - } - } +interface ModuleAccessScan { + specifiers: readonly string[]; + violations: readonly string[]; +} - const standardEscapes: Readonly> = { - "0": "\0", - b: "\b", - f: "\f", - n: "\n", - r: "\r", - t: "\t", - v: "\v", - "\\": "\\", - "/": "/", - '"': '"', - "'": "'", - "`": "`", - }; - decoded += standardEscapes[escaped] ?? escaped; - index += 1; +const projectRequire = createRequire(import.meta.url); +const requireFromVitest = createRequire( + projectRequire.resolve("vitest/package.json"), +); +const babelParser = requireFromVitest("@babel/parser") as BabelParser; + +const expressionWrapperTypes = new Set([ + "ParenthesizedExpression", + "TSAsExpression", + "TSInstantiationExpression", + "TSNonNullExpression", + "TSSatisfiesExpression", + "TSTypeAssertion", + "TypeCastExpression", +]); + +function isBabelNode(value: unknown): value is BabelNode { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function unwrapModuleArgument(node: unknown): BabelNode | null { + let current = isBabelNode(node) ? node : null; + while (current && expressionWrapperTypes.has(current.type)) { + current = isBabelNode(current.expression) ? current.expression : null; } - - return decoded; + return current; } -interface SourceToken { - kind: "word" | "string" | "punctuation"; - value: string; -} - -interface TokenizeResult { - index: number; - tokens: readonly SourceToken[]; -} - -function scanQuotedString( - source: string, - start: number, - quote: '"' | "'", -): { index: number; value: string } { - let index = start + 1; - let rawValue = ""; - - while (index < source.length) { - const character = source[index]; - if (character === "\\") { - rawValue += character; - const escaped = source[index + 1]; - if (escaped !== undefined) { - rawValue += escaped; - index += 2; - if (escaped === "\r" && source[index] === "\n") { - rawValue += "\n"; - index += 1; - } - continue; - } - index += 1; - continue; - } - if (character === quote) { - return { - index: index + 1, - value: decodeJavaScriptStringEscapes(rawValue), - }; - } - rawValue += character; - index += 1; +function readLiteralModuleSpecifier(node: unknown): string | null { + const literal = unwrapModuleArgument(node); + if (!literal) return null; + if (literal.type === "StringLiteral" && typeof literal.value === "string") { + return literal.value; } + if (literal.type !== "TemplateLiteral") return null; - return { index, value: decodeJavaScriptStringEscapes(rawValue) }; -} + const expressions = Array.isArray(literal.expressions) + ? literal.expressions + : []; + const quasis = Array.isArray(literal.quasis) ? literal.quasis : []; + if (expressions.length !== 0 || quasis.length !== 1) return null; -function scanTemplateLiteral(source: string, start: number): TokenizeResult { - const tokens: SourceToken[] = []; - let index = start + 1; - let rawValue = ""; - let interpolated = false; - - while (index < source.length) { - const character = source[index]; - if (character === "\\") { - rawValue += character; - const escaped = source[index + 1]; - if (escaped !== undefined) { - rawValue += escaped; - index += 2; - if (escaped === "\r" && source[index] === "\n") { - rawValue += "\n"; - index += 1; - } - continue; - } - index += 1; - continue; - } - if (character === "`") { - if (!interpolated) { - tokens.push({ - kind: "string", - value: decodeJavaScriptStringEscapes(rawValue), - }); - } - return { index: index + 1, tokens }; - } - if (character === "$" && source[index + 1] === "{") { - interpolated = true; - const expression = tokenizeCode(source, index + 2, true); - tokens.push(...expression.tokens); - index = expression.index; - continue; - } - rawValue += character; - index += 1; + const quasi = isBabelNode(quasis[0]) ? quasis[0] : null; + const value = quasi?.value; + if ( + typeof value === "object" && + value !== null && + "cooked" in value && + typeof value.cooked === "string" + ) { + return value.cooked; } - - return { index, tokens }; + return null; } -function tokenizeCode( - source: string, - start = 0, - stopAtClosingBrace = false, -): TokenizeResult { - const tokens: SourceToken[] = []; - let braceDepth = stopAtClosingBrace ? 1 : 0; - let index = start; - - while (index < source.length) { - const character = source[index]; - const nextCharacter = source[index + 1]; - - if (/\s/.test(character)) { - index += 1; - continue; - } - if (character === "/" && nextCharacter === "/") { - const lineEnd = source.indexOf("\n", index + 2); - index = lineEnd === -1 ? source.length : lineEnd + 1; - continue; - } - if (character === "/" && nextCharacter === "*") { - const commentEnd = source.indexOf("*/", index + 2); - index = commentEnd === -1 ? source.length : commentEnd + 2; - continue; - } - if (character === '"' || character === "'") { - const string = scanQuotedString(source, index, character); - tokens.push({ kind: "string", value: string.value }); - index = string.index; - continue; - } - if (character === "`") { - const template = scanTemplateLiteral(source, index); - tokens.push(...template.tokens); - index = template.index; - continue; - } - if (/[A-Za-z_$]/.test(character)) { - const wordStart = index; - index += 1; - while (index < source.length && /[A-Za-z0-9_$]/.test(source[index])) { - index += 1; - } - tokens.push({ kind: "word", value: source.slice(wordStart, index) }); - continue; - } - if (stopAtClosingBrace && character === "{") { - braceDepth += 1; - } - if (stopAtClosingBrace && character === "}") { - braceDepth -= 1; - if (braceDepth === 0) return { index: index + 1, tokens }; - } - - tokens.push({ kind: "punctuation", value: character }); - index += 1; - } - - return { index, tokens }; -} - -function tokenizeModuleSource(source: string): readonly SourceToken[] { - return tokenizeCode(source).tokens; -} -function extractModuleSpecifiers(source: string): readonly string[] { - const tokens = tokenizeModuleSource(source); +function scanModuleAccesses(source: string): ModuleAccessScan { + const root = babelParser.parse(source, { + createImportExpressions: true, + plugins: ["typescript", "jsx"], + sourceType: "module", + }); const specifiers: string[] = []; + const violations: string[] = []; - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index]; - if ( - token.kind !== "word" || - (token.value !== "import" && token.value !== "export") + function recordArgument(argument: unknown, kind: "import" | "require") { + const specifier = readLiteralModuleSpecifier(argument); + if (specifier === null) { + violations.push(``); + return; + } + specifiers.push(specifier); + } + + function visit(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!isBabelNode(value)) return; + + if (value.type === "ImportDeclaration") { + const specifier = readLiteralModuleSpecifier(value.source); + if (specifier !== null) specifiers.push(specifier); + } else if ( + (value.type === "ExportNamedDeclaration" || + value.type === "ExportAllDeclaration") && + value.source !== null ) { - continue; - } - - const next = tokens[index + 1]; - if (token.value === "import" && next?.value === "(") { - const argument = tokens[index + 2]; - if (argument?.kind === "string") specifiers.push(argument.value); - continue; - } - if (token.value === "import" && next?.kind === "string") { - specifiers.push(next.value); - continue; - } - - for (let cursor = index + 1; cursor < tokens.length; cursor += 1) { - const candidate = tokens[cursor]; - if (candidate.value === ";") break; - if (candidate.kind === "word" && candidate.value === "from") { - const moduleSpecifier = tokens[cursor + 1]; - if (moduleSpecifier?.kind === "string") { - specifiers.push(moduleSpecifier.value); - } - break; + const specifier = readLiteralModuleSpecifier(value.source); + if (specifier !== null) specifiers.push(specifier); + } else if (value.type === "ImportExpression") { + recordArgument(value.source, "import"); + } else if (value.type === "TSImportType") { + recordArgument(value.argument, "import"); + } else if (value.type === "CallExpression") { + const arguments_ = Array.isArray(value.arguments) ? value.arguments : []; + if (isBabelNode(value.callee) && value.callee.type === "Import") { + recordArgument(arguments_[0], "import"); + } else if ( + isBabelNode(value.callee) && + value.callee.type === "Identifier" && + value.callee.name === "require" + ) { + recordArgument(arguments_[0], "require"); } } + + for (const [key, child] of Object.entries(value)) { + if (key !== "type") visit(child); + } } - return specifiers; + visit(root); + return { specifiers, violations }; } - function normalizeLocalSpecifier( routeFile: string, specifier: string, @@ -388,11 +255,12 @@ function normalizeLocalSpecifier( return null; } -function findForbiddenRouteImports( +function findRouteImportBoundaryViolations( source: string, routeFile: string, ): readonly string[] { - return extractModuleSpecifiers(source) + const scan = scanModuleAccesses(source); + const forbiddenPaths = scan.specifiers .map((specifier) => normalizeLocalSpecifier(routeFile, specifier)) .filter((path): path is string => path !== null) .filter((path) => @@ -400,6 +268,7 @@ function findForbiddenRouteImports( (root) => path === root || path.startsWith(`${root}/`), ), ); + return [...scan.violations, ...forbiddenPaths]; } function capabilityContext( @@ -612,7 +481,7 @@ describe("preview route import boundary", () => { for (const path of routeFiles) { const source = readFileSync(resolve(process.cwd(), path), "utf8"); - expect(findForbiddenRouteImports(source, path), path).toEqual([]); + expect(findRouteImportBoundaryViolations(source, path), path).toEqual([]); expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/); } }); @@ -620,6 +489,48 @@ describe("preview route import boundary", () => { const interpolationOpen = "$" + "{"; it.each([ + [ + "U+2028 line-continuation database import", + "src/app/admin-next/page.tsx", + 'import db from "../\\' + "\u2028" + '../lib/db";', + "src/lib/db", + ], + [ + "U+2029 line-continuation database import", + "src/app/admin-next/page.tsx", + 'import db from "../\\' + "\u2029" + '../lib/db";', + "src/lib/db", + ], + [ + "parenthesized dynamic action import", + "src/app/admin-next/page.tsx", + 'import(("../../actions/users"))', + "src/actions/users", + ], + [ + "regex-brace template-expression action import", + "src/app/admin-next/page.tsx", + `const x = \`${interpolationOpen}/}/.test(value) ? import("../../actions/users") : null}\`;`, + "src/actions/users", + ], + [ + "CommonJS database require", + "src/app/admin-next/page.tsx", + 'require("../../lib/db")', + "src/lib/db", + ], + [ + "template-literal dynamic action import", + "src/app/admin-next/page.tsx", + "import(`../../actions/users`)", + "src/actions/users", + ], + [ + "TypeScript-asserted dynamic action import", + "src/app/admin-next/page.tsx", + 'import(("../../actions/users" as string))', + "src/actions/users", + ], [ "template-expression dynamic action import", "src/app/admin-next/page.tsx", @@ -717,11 +628,28 @@ describe("preview route import boundary", () => { "src/app/mod/users/page", ], ] as const)("detects %s", (_name, routeFile, source, expectedPath) => { - expect(findForbiddenRouteImports(source, routeFile)).toContain( + expect(findRouteImportBoundaryViolations(source, routeFile)).toContain( expectedPath, ); }); + it.each([ + [ + "dynamic import", + "const path = '../../actions/users'; import(path)", + "", + ], + [ + "CommonJS require", + "const path = '../../lib/db'; require(path)", + "", + ], + ] as const)("fails closed for non-literal %s", (_name, source, violation) => { + expect( + findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"), + ).toContain(violation); + }); + it("does not reject substring lookalikes, comments, or ordinary strings", () => { const source = [ 'import database from "@/lib/database";', @@ -733,26 +661,7 @@ describe("preview route import boundary", () => { ].join("\n"); expect( - findForbiddenRouteImports(source, "src/app/admin-next/page.tsx"), + findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"), ).toEqual([]); }); - - it("decodes JavaScript string-literal escapes", () => { - expect(decodeJavaScriptStringEscapes(String.raw`\u0041`)).toBe("A"); - expect(decodeJavaScriptStringEscapes(String.raw`\u{1f600}`)).toBe("😀"); - expect(decodeJavaScriptStringEscapes(String.raw`\x2f`)).toBe("/"); - expect(decodeJavaScriptStringEscapes(String.raw`\/`)).toBe("/"); - expect(decodeJavaScriptStringEscapes(String.raw`\\`)).toBe("\\"); - expect(decodeJavaScriptStringEscapes(String.raw`\"`)).toBe('"'); - expect(decodeJavaScriptStringEscapes(String.raw`\'`)).toBe("'"); - expect(decodeJavaScriptStringEscapes(String.raw`\b\f\n\r\t\v\0`)).toBe( - "\b\f\n\r\t\v\0", - ); - expect(decodeJavaScriptStringEscapes(String.raw`\q`)).toBe("q"); - }); - - it("decodes CRLF and LF string-literal line continuations", () => { - expect(decodeJavaScriptStringEscapes("\\" + "\r\n")).toBe(""); - expect(decodeJavaScriptStringEscapes("\\" + "\n")).toBe(""); - }); });