From 0b46a94d5777d9595096ca13c3765e2043e17913 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 25 Aug 2026 21:11:37 +0200 Subject: [PATCH] test: close housekeeping import-policy escapes --- .../foundation/preview-route-contract.test.ts | 302 ++++++++++++++++-- 1 file changed, 268 insertions(+), 34 deletions(-) diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index cd5ffa79e7..1888032925 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -99,14 +99,183 @@ const forbiddenModuleRoots = [ "src/app/mod", ] as const; +function decodeJavaScriptStringEscapes(value: string): string { + let decoded = ""; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character !== "\\") { + decoded += character; + continue; + } + + 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; + } + } + } + + const standardEscapes: Readonly> = { + "0": "\0", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + v: "\v", + "\\": "\\", + "/": "/", + '"': '"', + "'": "'", + "`": "`", + }; + decoded += standardEscapes[escaped] ?? escaped; + index += 1; + } + + return decoded; +} + interface SourceToken { kind: "word" | "string" | "punctuation"; value: string; } -function tokenizeModuleSource(source: string): readonly SourceToken[] { +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; + } + + return { index, value: decodeJavaScriptStringEscapes(rawValue) }; +} + +function scanTemplateLiteral(source: string, start: number): TokenizeResult { const tokens: SourceToken[] = []; - let index = 0; + 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; + } + + return { index, tokens }; +} + +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]; @@ -117,59 +286,54 @@ function tokenizeModuleSource(source: string): readonly SourceToken[] { continue; } if (character === "/" && nextCharacter === "/") { - index = source.indexOf("\n", index + 2); - if (index === -1) break; + const lineEnd = source.indexOf("\n", index + 2); + index = lineEnd === -1 ? source.length : lineEnd + 1; continue; } if (character === "/" && nextCharacter === "*") { - const end = source.indexOf("*/", index + 2); - index = end === -1 ? source.length : end + 2; + const commentEnd = source.indexOf("*/", index + 2); + index = commentEnd === -1 ? source.length : commentEnd + 2; continue; } - if (character === '"' || character === "'" || character === "`") { - const quote = character; - let value = ""; - let interpolated = false; - index += 1; - - while (index < source.length) { - const current = source[index]; - if (current === "\\") { - value += source[index + 1] ?? ""; - index += 2; - continue; - } - if (quote === "`" && current === "$" && source[index + 1] === "{") { - interpolated = true; - } - if (current === quote) { - index += 1; - break; - } - value += current; - index += 1; - } - - if (!interpolated) tokens.push({ kind: "string", value }); + 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 start = index; + 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(start, index) }); + 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 tokens; + return { index, tokens }; } +function tokenizeModuleSource(source: string): readonly SourceToken[] { + return tokenizeCode(source).tokens; +} function extractModuleSpecifiers(source: string): readonly string[] { const tokens = tokenizeModuleSource(source); const specifiers: string[] = []; @@ -453,7 +617,57 @@ describe("preview route import boundary", () => { } }); + const interpolationOpen = "$" + "{"; + it.each([ + [ + "template-expression dynamic action import", + "src/app/admin-next/page.tsx", + `const x = \`${interpolationOpen}import("../../actions/users")}\`;`, + "src/actions/users", + ], + [ + "nested template-expression dynamic action import", + "src/app/admin-next/[domain]/page.tsx", + `const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../actions/nested")}\` : ""}\`;`, + "src/actions/nested", + ], + [ + "unicode escaped dynamic app-action import", + "src/app/admin-next/page.tsx", + 'import("\\u002e\\u002e/actions/users")', + "src/app/actions/users", + ], + [ + "code-point escaped dynamic app-action import", + "src/app/admin-next/page.tsx", + 'import("\\u{2e}\\u{2e}/actions/users")', + "src/app/actions/users", + ], + [ + "hex escaped export-from auth import", + "src/app/admin-next/page.tsx", + 'export * from "\\x2e\\x2e/\\x2e\\x2e/lib/auth";', + "src/lib/auth", + ], + [ + "escaped-slash permissions import", + "src/app/admin-next/page.tsx", + 'import permissions from "..\\/..\\/lib\\/permissions";', + "src/lib/permissions", + ], + [ + "unknown escape database import", + "src/app/admin-next/page.tsx", + 'import db from "../../\\lib/db";', + "src/lib/db", + ], + [ + "line-continuation database import", + "src/app/admin-next/page.tsx", + 'import db from "../\\' + "\n" + '../lib/db";', + "src/lib/db", + ], [ "aliased database descendant import", "src/app/admin-next/page.tsx", @@ -514,6 +728,7 @@ describe("preview route import boundary", () => { 'import auth from "../../lib/authentication";', 'import preview from "../admin-next-shared";', "const documentation = \"import db from '../../lib/db'\";", + 'const rawTemplate = `import("../../actions/users")`;', '// import db from "../../lib/db";', ].join("\n"); @@ -521,4 +736,23 @@ describe("preview route import boundary", () => { findForbiddenRouteImports(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(""); + }); });