test: close housekeeping import-policy escapes
This commit is contained in:
1 parent
ff2b2af6d4
commit
0b46a94d57
1 file changed
+268
-34
@@ -99,14 +99,183 @@ const forbiddenModuleRoots = [
|
|||||||
"src/app/mod",
|
"src/app/mod",
|
||||||
] as const;
|
] 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<Record<string, string>> = {
|
||||||
|
"0": "\0",
|
||||||
|
b: "\b",
|
||||||
|
f: "\f",
|
||||||
|
n: "\n",
|
||||||
|
r: "\r",
|
||||||
|
t: "\t",
|
||||||
|
v: "\v",
|
||||||
|
"\\": "\\",
|
||||||
|
"/": "/",
|
||||||
|
'"': '"',
|
||||||
|
"'": "'",
|
||||||
|
"`": "`",
|
||||||
|
};
|
||||||
|
decoded += standardEscapes[escaped] ?? escaped;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
interface SourceToken {
|
interface SourceToken {
|
||||||
kind: "word" | "string" | "punctuation";
|
kind: "word" | "string" | "punctuation";
|
||||||
value: string;
|
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[] = [];
|
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) {
|
while (index < source.length) {
|
||||||
const character = source[index];
|
const character = source[index];
|
||||||
@@ -117,59 +286,54 @@ function tokenizeModuleSource(source: string): readonly SourceToken[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (character === "/" && nextCharacter === "/") {
|
if (character === "/" && nextCharacter === "/") {
|
||||||
index = source.indexOf("\n", index + 2);
|
const lineEnd = source.indexOf("\n", index + 2);
|
||||||
if (index === -1) break;
|
index = lineEnd === -1 ? source.length : lineEnd + 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (character === "/" && nextCharacter === "*") {
|
if (character === "/" && nextCharacter === "*") {
|
||||||
const end = source.indexOf("*/", index + 2);
|
const commentEnd = source.indexOf("*/", index + 2);
|
||||||
index = end === -1 ? source.length : end + 2;
|
index = commentEnd === -1 ? source.length : commentEnd + 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (character === '"' || character === "'" || character === "`") {
|
if (character === '"' || character === "'") {
|
||||||
const quote = character;
|
const string = scanQuotedString(source, index, character);
|
||||||
let value = "";
|
tokens.push({ kind: "string", value: string.value });
|
||||||
let interpolated = false;
|
index = string.index;
|
||||||
index += 1;
|
continue;
|
||||||
|
}
|
||||||
while (index < source.length) {
|
if (character === "`") {
|
||||||
const current = source[index];
|
const template = scanTemplateLiteral(source, index);
|
||||||
if (current === "\\") {
|
tokens.push(...template.tokens);
|
||||||
value += source[index + 1] ?? "";
|
index = template.index;
|
||||||
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 });
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/[A-Za-z_$]/.test(character)) {
|
if (/[A-Za-z_$]/.test(character)) {
|
||||||
const start = index;
|
const wordStart = index;
|
||||||
index += 1;
|
index += 1;
|
||||||
while (index < source.length && /[A-Za-z0-9_$]/.test(source[index])) {
|
while (index < source.length && /[A-Za-z0-9_$]/.test(source[index])) {
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
tokens.push({ kind: "word", value: source.slice(start, index) });
|
tokens.push({ kind: "word", value: source.slice(wordStart, index) });
|
||||||
continue;
|
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 });
|
tokens.push({ kind: "punctuation", value: character });
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokens;
|
return { index, tokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tokenizeModuleSource(source: string): readonly SourceToken[] {
|
||||||
|
return tokenizeCode(source).tokens;
|
||||||
|
}
|
||||||
function extractModuleSpecifiers(source: string): readonly string[] {
|
function extractModuleSpecifiers(source: string): readonly string[] {
|
||||||
const tokens = tokenizeModuleSource(source);
|
const tokens = tokenizeModuleSource(source);
|
||||||
const specifiers: string[] = [];
|
const specifiers: string[] = [];
|
||||||
@@ -453,7 +617,57 @@ describe("preview route import boundary", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const interpolationOpen = "$" + "{";
|
||||||
|
|
||||||
it.each([
|
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",
|
"aliased database descendant import",
|
||||||
"src/app/admin-next/page.tsx",
|
"src/app/admin-next/page.tsx",
|
||||||
@@ -514,6 +728,7 @@ describe("preview route import boundary", () => {
|
|||||||
'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'\";",
|
||||||
|
'const rawTemplate = `import("../../actions/users")`;',
|
||||||
'// import db from "../../lib/db";',
|
'// import db from "../../lib/db";',
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
@@ -521,4 +736,23 @@ describe("preview route import boundary", () => {
|
|||||||
findForbiddenRouteImports(source, "src/app/admin-next/page.tsx"),
|
findForbiddenRouteImports(source, "src/app/admin-next/page.tsx"),
|
||||||
).toEqual([]);
|
).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("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user