mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Make template tag scanners quote-aware (#710)
This commit is contained in:
1 parent
23d369d84e
commit
6fc43a60e2
19 files changed
+747
-39
No files matched your search
@@ -0,0 +1,46 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { jsonParseLinter } from "./json-lint";
|
||||
|
||||
// The linter only reads the document text off the view
|
||||
function view(doc: string): EditorView {
|
||||
return { state: { doc: { toString: () => doc } } } as EditorView;
|
||||
}
|
||||
|
||||
const lint = jsonParseLinter();
|
||||
|
||||
describe("jsonParseLinter", () => {
|
||||
test("reports nothing for an empty document", () => {
|
||||
expect(lint(view(" "))).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports nothing for valid JSON", () => {
|
||||
expect(lint(view('{"a": 1}'))).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports a message for invalid JSON", () => {
|
||||
expect(lint(view('{"a": }'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("ignores a template tag standing in for a value", () => {
|
||||
expect(lint(view('{"a": "${[ my_var ]}"}'))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores a tag whose quoted argument contains `]}`", () => {
|
||||
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}"}`))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores a tag whose quoted argument contains an escaped quote", () => {
|
||||
expect(lint(view(`{"a": "\${[ fn(arg='it\\'s ]}') ]}"}`))).toEqual([]);
|
||||
});
|
||||
|
||||
test("still reports errors around a tag with a quoted `]}`", () => {
|
||||
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}",}`))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("keeps the tag's length so error offsets stay correct", () => {
|
||||
const doc = `{"a" "\${[ fn(arg='x]}y') ]}"}`;
|
||||
const [error] = lint(view(doc));
|
||||
expect(error?.from).toEqual(doc.indexOf('"${['));
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||
|
||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
||||
import { replaceTemplateTags } from "../../../lib/templateTags";
|
||||
|
||||
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
||||
const MESSAGES: Record<string, string> = {
|
||||
@@ -36,7 +35,7 @@ export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
const escapedDoc = replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start));
|
||||
|
||||
const errors: ParseError[] = [];
|
||||
parse(escapedDoc, errors, {
|
||||
|
||||
@@ -4,4 +4,6 @@ export const highlight = styleTags({
|
||||
TagOpen: t.bracket,
|
||||
TagClose: t.bracket,
|
||||
TagContent: t.keyword,
|
||||
// Strings are part of the tag body, so they get the same color as the rest of it
|
||||
TagString: t.keyword,
|
||||
});
|
||||
@@ -1,13 +1,17 @@
|
||||
@top Template { (Tag | Text)* }
|
||||
|
||||
// `TagString` keeps a quoted argument from closing the tag, so `${[ fn(a='x]}y') ]}` stays a
|
||||
// single Tag. Only a terminated string matches, so a half-typed `'` degrades to TagContent and
|
||||
// the tag still closes at the next `]}`.
|
||||
@local tokens {
|
||||
TagClose { "]}" }
|
||||
TagString { "'" (![\\'] | "\\" _)* "'" }
|
||||
@else TagContent
|
||||
}
|
||||
|
||||
@skip { } {
|
||||
TagOpen { "${[" }
|
||||
Tag { TagOpen (TagContent)+ TagClose }
|
||||
Tag { TagOpen (TagContent | TagString)+ TagClose }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
|
||||
@@ -3,5 +3,6 @@ export const Template = 1,
|
||||
Tag = 2,
|
||||
TagOpen = 3,
|
||||
TagContent = 4,
|
||||
TagClose = 5,
|
||||
Text = 6;
|
||||
TagString = 5,
|
||||
TagClose = 6,
|
||||
Text = 7;
|
||||
@@ -23,6 +23,10 @@ function hasError(input: string): boolean {
|
||||
return getNodeNames(input).includes("⚠");
|
||||
}
|
||||
|
||||
function countTags(input: string): number {
|
||||
return getNodeNames(input).filter((n) => n === "Tag").length;
|
||||
}
|
||||
|
||||
describe("twig grammar", () => {
|
||||
describe("${[var]} format (valid template tags)", () => {
|
||||
test("parses simple variable as Tag", () => {
|
||||
@@ -83,6 +87,50 @@ describe("twig grammar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoted strings inside tags", () => {
|
||||
test("keeps a quoted `]}` inside the tag", () => {
|
||||
const input = "${[ fn(a='x]}y') ]}";
|
||||
expect(countTags(input)).toBe(1);
|
||||
expect(hasError(input)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps an escaped quote inside the string", () => {
|
||||
const input = "${[ fn(a='it\\'s ]}') ]}";
|
||||
expect(countTags(input)).toBe(1);
|
||||
expect(hasError(input)).toBe(false);
|
||||
});
|
||||
|
||||
test("parses a b64 string as one Tag", () => {
|
||||
const input = "${[ b64'Zm9v' ]}";
|
||||
expect(countTags(input)).toBe(1);
|
||||
expect(hasError(input)).toBe(false);
|
||||
});
|
||||
|
||||
test("closes at the first `]}` when a quote is left unterminated", () => {
|
||||
// Half-typed strings must not swallow the rest of the document
|
||||
const input = "${[ fn(a='unterminated ]} after";
|
||||
expect(countTags(input)).toBe(1);
|
||||
expect(hasError(input)).toBe(false);
|
||||
const tag = parser.parse(input).topNode.getChild("Tag");
|
||||
expect(input.slice(tag?.from ?? 0, tag?.to ?? 0)).toBe("${[ fn(a='unterminated ]}");
|
||||
});
|
||||
|
||||
test("handles two tags with quoted `]}` in the same string", () => {
|
||||
const input = "${[ a(x='1]}') ]} and ${[ b(y='2]}') ]}";
|
||||
expect(countTags(input)).toBe(2);
|
||||
expect(hasError(input)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps JSON around a tag with a quoted `]}` intact", () => {
|
||||
const tag = "${[ fn(a='x]}y') ]}";
|
||||
const json = '{"a": "' + tag + '"}';
|
||||
expect(countTags(json)).toBe(1);
|
||||
expect(hasError(json)).toBe(false);
|
||||
const node = parser.parse(json).topNode.getChild("Tag");
|
||||
expect(json.slice(node?.from ?? 0, node?.to ?? 0)).toBe(tag);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("handles $ at end of string", () => {
|
||||
expect(hasError("hello$")).toBe(false);
|
||||
|
||||
@@ -4,17 +4,24 @@ import { highlight } from "./highlight";
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
|
||||
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
|
||||
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
|
||||
nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text",
|
||||
maxTerm: 10,
|
||||
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cd'#CdQQOPOOOOOO'#Ce'#CeObOQO,58xOOOO-E6b-E6bOOOO-E6c-E6cOOOO1G.d1G.d",
|
||||
stateData: "m~OVROZPO~OSTOTTO~OSTOTTOUXO~O",
|
||||
goto: "oYPPZ_PPPPciTROSTQOSQSORVSQUQRWU",
|
||||
nodeNames: "⚠ Template Tag TagOpen TagContent TagString TagClose Text",
|
||||
maxTerm: 11,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 2,
|
||||
tokenData:
|
||||
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
|
||||
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
|
||||
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTV~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOV~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OZ~",
|
||||
tokenizers: [
|
||||
1,
|
||||
new LocalTokenGroup(
|
||||
"#R~RQwxX#P#Q!u~[VOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o<%lOX~vOT~~yRO;'SX;'S;=`!S;=`OX~!VWOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o;=`<%lX<%lOX~!rP;=`<%lX~!xP#q#r!{~#QOU~~",
|
||||
94,
|
||||
4,
|
||||
),
|
||||
],
|
||||
topRules: { Template: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
});
|
||||
@@ -35,6 +35,24 @@ describe("resolvedModelName", () => {
|
||||
).toEqual("fn(arg='my key')/a/other(b='x y')/b");
|
||||
});
|
||||
|
||||
test("keeps a quoted `]}` inside the tag", () => {
|
||||
expect(resolvedModelName(httpRequest("${[ fn(arg='x]}y') ]}/users"))).toEqual(
|
||||
"fn(arg='x]}y')/users",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps an escaped quote inside the tag", () => {
|
||||
expect(resolvedModelName(httpRequest("${[ fn(arg='it\\'s ]}') ]}/users"))).toEqual(
|
||||
"fn(arg='it\\'s ]}')/users",
|
||||
);
|
||||
});
|
||||
|
||||
test("closes at the first `]}` when a quote is left unterminated", () => {
|
||||
expect(resolvedModelName(httpRequest("${[ fn(arg='oops ]}/users"))).toEqual(
|
||||
"fn(arg='oops/users",
|
||||
);
|
||||
});
|
||||
|
||||
test("stops each tag at its first closing bracket", () => {
|
||||
expect(resolvedModelName(httpRequest("${[ a ]}${[ b ]}"))).toEqual("ab");
|
||||
});
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { foldersAtom } from "@yaakapp-internal/models";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
/**
|
||||
* Matches a template tag up to the first `]}`, the same way the Lezer grammar and the JSON
|
||||
* linter close a tag. Quoted arguments may contain spaces, so this can't stop at whitespace.
|
||||
* The capture group holds the tag's inner content, trimmed of the padding spaces.
|
||||
*/
|
||||
const TEMPLATE_TAG_REGEX = /\$\{\[\s*([\s\S]*?)\s*]}/g;
|
||||
import { replaceTemplateTags } from "./templateTags";
|
||||
|
||||
export function resolvedModelName(r: AnyModel | null): string {
|
||||
if (r == null) return "";
|
||||
@@ -22,7 +16,7 @@ export function resolvedModelName(r: AnyModel | null): string {
|
||||
}
|
||||
|
||||
// Replace variable syntax with variable name
|
||||
const withoutVariables = r.url.replace(TEMPLATE_TAG_REGEX, "$1");
|
||||
const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim());
|
||||
if (withoutVariables.trim() === "") {
|
||||
return r.model === "http_request"
|
||||
? r.bodyType && r.bodyType === "graphql"
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/* oxlint-disable no-template-curly-in-string */
|
||||
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { findTemplateTags, replaceTemplateTags } from "./templateTags";
|
||||
|
||||
/** Seeded so a fuzz failure reproduces. */
|
||||
function random(seed: number): () => number {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function millis(run: () => void): number {
|
||||
const started = performance.now();
|
||||
run();
|
||||
return performance.now() - started;
|
||||
}
|
||||
|
||||
describe("findTemplateTags", () => {
|
||||
test("finds a simple tag", () => {
|
||||
expect(findTemplateTags("${[ my_var ]}")).toEqual([{ start: 0, end: 13, inner: " my_var " }]);
|
||||
});
|
||||
|
||||
test("finds a tag surrounded by text", () => {
|
||||
const text = 'a="${[ my_var ]}"';
|
||||
expect(findTemplateTags(text)).toEqual([{ start: 3, end: 16, inner: " my_var " }]);
|
||||
expect(text.slice(3, 16)).toEqual("${[ my_var ]}");
|
||||
});
|
||||
|
||||
test("finds an empty tag", () => {
|
||||
expect(findTemplateTags("${[]}")).toEqual([{ start: 0, end: 5, inner: "" }]);
|
||||
});
|
||||
|
||||
test("keeps a quoted `]}` inside the tag", () => {
|
||||
const text = "${[ fn(a='x]}y') ]}";
|
||||
expect(findTemplateTags(text)).toEqual([
|
||||
{ start: 0, end: text.length, inner: " fn(a='x]}y') " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps an escaped quote inside the string", () => {
|
||||
const text = "${[ fn(a='it\\'s ]}') ]}";
|
||||
expect(findTemplateTags(text)).toEqual([
|
||||
{ start: 0, end: text.length, inner: " fn(a='it\\'s ]}') " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a b64 string in one tag", () => {
|
||||
const text = "${[ b64'Zm9v' ]}";
|
||||
expect(findTemplateTags(text)).toEqual([{ start: 0, end: text.length, inner: " b64'Zm9v' " }]);
|
||||
});
|
||||
|
||||
test("closes at the first `]}` when a quote is left unterminated", () => {
|
||||
const text = "${[ fn(a='unterminated ]} rest";
|
||||
expect(findTemplateTags(text)).toEqual([{ start: 0, end: 25, inner: " fn(a='unterminated " }]);
|
||||
expect(text.slice(0, 25)).toEqual("${[ fn(a='unterminated ]}");
|
||||
});
|
||||
|
||||
test("finds two tags with text between them", () => {
|
||||
expect(findTemplateTags("one ${[ a ]} two ${[ b ]} three")).toEqual([
|
||||
{ start: 4, end: 12, inner: " a " },
|
||||
{ start: 17, end: 25, inner: " b " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds two adjacent tags", () => {
|
||||
expect(findTemplateTags("${[ a ]} ${[ b ]}")).toEqual([
|
||||
{ start: 0, end: 8, inner: " a " },
|
||||
{ start: 9, end: 17, inner: " b " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds two tags that each quote a `]}`", () => {
|
||||
const text = "${[ a(x='1]}') ]} and ${[ b(y='2]}') ]}";
|
||||
expect(findTemplateTags(text)).toEqual([
|
||||
{ start: 0, end: 17, inner: " a(x='1]}') " },
|
||||
{ start: 22, end: 39, inner: " b(y='2]}') " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds nothing in an unclosed tag", () => {
|
||||
expect(findTemplateTags("${[ no close")).toEqual([]);
|
||||
});
|
||||
|
||||
test("finds nothing in text without tags", () => {
|
||||
expect(findTemplateTags('{"a": "${b}", "c": 1}')).toEqual([]);
|
||||
});
|
||||
|
||||
test("treats a nested `${[` as ordinary content", () => {
|
||||
expect(findTemplateTags("${[ a ${[ b ]}")).toEqual([{ start: 0, end: 14, inner: " a ${[ b " }]);
|
||||
});
|
||||
|
||||
test("finishes fast on a long run of quotes", () => {
|
||||
const text = `\${[${"'".repeat(20_000)} ]}`;
|
||||
let matches: unknown;
|
||||
const elapsed = millis(() => {
|
||||
matches = findTemplateTags(text);
|
||||
});
|
||||
expect(matches).toEqual([{ start: 0, end: text.length, inner: `${"'".repeat(20_000)} ` }]);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test("finishes fast on a long run of unpairable quotes", () => {
|
||||
// Every quote is escaped, so none of them opens a string that closes
|
||||
const text = `\${[${"\\'".repeat(20_000)} ]}`;
|
||||
let matches: unknown;
|
||||
const elapsed = millis(() => {
|
||||
matches = findTemplateTags(text);
|
||||
});
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test("finishes fast on a long run of openers that never close", () => {
|
||||
const text = "${[".repeat(20_000);
|
||||
let matches: unknown;
|
||||
const elapsed = millis(() => {
|
||||
matches = findTemplateTags(text);
|
||||
});
|
||||
expect(matches).toEqual([]);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test("never throws, and always reports real bounds", () => {
|
||||
const alphabet = ["$", "{", "[", "]", "}", "'", "\\", "a", "b"];
|
||||
const next = random(0x5eed);
|
||||
|
||||
for (let n = 0; n < 2_000; n++) {
|
||||
let text = "";
|
||||
const length = Math.floor(next() * 31);
|
||||
for (let i = 0; i < length; i++) {
|
||||
text += alphabet[Math.floor(next() * alphabet.length)];
|
||||
}
|
||||
|
||||
let matches: ReturnType<typeof findTemplateTags> = [];
|
||||
expect(() => {
|
||||
matches = findTemplateTags(text);
|
||||
}, text).not.toThrow();
|
||||
|
||||
let previousEnd = 0;
|
||||
for (const match of matches) {
|
||||
expect(text.slice(match.start, match.start + 3), text).toEqual("${[");
|
||||
expect(text.slice(match.end - 2, match.end), text).toEqual("]}");
|
||||
expect(text.slice(match.start + 3, match.end - 2), text).toEqual(match.inner);
|
||||
expect(match.start, text).toBeGreaterThanOrEqual(previousEnd);
|
||||
previousEnd = match.end;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceTemplateTags", () => {
|
||||
test("leaves text without tags alone", () => {
|
||||
expect(replaceTemplateTags('{"a": 1}', () => "x")).toEqual('{"a": 1}');
|
||||
});
|
||||
|
||||
test("replaces each tag with its trimmed contents", () => {
|
||||
expect(replaceTemplateTags("${[ a ]}/x/${[ b ]}", (m) => m.inner.trim())).toEqual("a/x/b");
|
||||
});
|
||||
|
||||
test("keeps the replaced length when padding", () => {
|
||||
const tag = "${[ fn(arg='x]}y') ]}";
|
||||
const text = `{"a": "${tag}"}`;
|
||||
const replaced = replaceTemplateTags(text, (m) => "1".repeat(m.end - m.start));
|
||||
expect(replaced).toEqual(`{"a": "${"1".repeat(tag.length)}"}`);
|
||||
expect(replaced).toHaveLength(text.length);
|
||||
});
|
||||
|
||||
test("leaves an unclosed tag in place", () => {
|
||||
expect(replaceTemplateTags("${[ oops", () => "x")).toEqual("${[ oops");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Finding template tags in text.
|
||||
*
|
||||
* A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the
|
||||
* printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to
|
||||
* step over quoted strings. It mirrors the Lezer grammar in
|
||||
* `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever
|
||||
* follows it) is consumed whole, and an unterminated quote is just another character, so a
|
||||
* half-typed tag still closes at the next `]}` instead of swallowing the rest of the document.
|
||||
*
|
||||
* This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated
|
||||
* quote needed a negative lookahead that rescanned to the end of the input at every unpaired
|
||||
* quote, which is quadratic on text an attacker picks. The scan below is linear instead — see
|
||||
* the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why.
|
||||
*/
|
||||
|
||||
const QUOTE = 0x27; // '
|
||||
const BACKSLASH = 0x5c; // \
|
||||
const CLOSE_BRACKET = 0x5d; // ]
|
||||
const CLOSE_BRACE = 0x7d; // }
|
||||
|
||||
/** The `${[`…`]}` bounds of one tag, and what sits between them. */
|
||||
export interface TemplateTagMatch {
|
||||
/** Index of the `$` that opens the tag. */
|
||||
start: number;
|
||||
/** Index just past the closing `]}`. */
|
||||
end: number;
|
||||
/** Everything between `${[` and `]}`, not trimmed. */
|
||||
inner: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or
|
||||
* `-1` when nothing does.
|
||||
*
|
||||
* Built right to left in a single pass, which is the trick that replaces the regex lookahead:
|
||||
* whether a quote opens a terminated string depends only on the text after it, so the answer
|
||||
* can be computed once for every position instead of re-derived at each quote. Skipping a
|
||||
* string of any length then costs one lookup.
|
||||
*/
|
||||
function closingQuoteTable(text: string): Int32Array {
|
||||
// Two extra slots so a `\` in the last position can read past the end and find `-1`
|
||||
const table = new Int32Array(text.length + 2).fill(-1);
|
||||
for (let i = text.length - 1; i >= 0; i--) {
|
||||
const c = text.charCodeAt(i);
|
||||
// A backslash escapes whatever follows it, including a quote
|
||||
const next = c === BACKSLASH ? table[i + 2] : table[i + 1];
|
||||
table[i] = c === QUOTE ? i : (next ?? -1);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every template tag in `text`, in order and without overlaps.
|
||||
*
|
||||
* A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body —
|
||||
* both are ordinary content, same as the regex this replaced treated them.
|
||||
*/
|
||||
export function findTemplateTags(text: string): TemplateTagMatch[] {
|
||||
const matches: TemplateTagMatch[] = [];
|
||||
|
||||
// Both built only once a scan actually needs them, so short values allocate nothing
|
||||
let closingQuotes: Int32Array | null = null;
|
||||
|
||||
/*
|
||||
* Body positions a previous scan already ran off the end of the text from. Where a scan ends
|
||||
* up depends only on where it starts, so a scan that walks into one of these would repeat
|
||||
* that same walk and fail the same way, and can stop right there.
|
||||
*
|
||||
* That bound is what keeps the whole pass linear: every position enters this set at most
|
||||
* once, and every step of every scan either visits a position for the first time or ends
|
||||
* that scan.
|
||||
*/
|
||||
let deadEnds: Set<number> | null = null;
|
||||
|
||||
let searchFrom = 0;
|
||||
while (searchFrom < text.length) {
|
||||
const start = text.indexOf("${[", searchFrom);
|
||||
if (start < 0) break;
|
||||
|
||||
const visited: number[] = [];
|
||||
let end = -1;
|
||||
let i = start + 3;
|
||||
while (i < text.length) {
|
||||
if (deadEnds?.has(i)) break;
|
||||
visited.push(i);
|
||||
|
||||
const c = text.charCodeAt(i);
|
||||
if (c === QUOTE) {
|
||||
closingQuotes ??= closingQuoteTable(text);
|
||||
const close = closingQuotes[i + 1] ?? -1;
|
||||
// An unterminated quote is an ordinary character, so only step over a closed string
|
||||
i = close < 0 ? i + 1 : close + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === CLOSE_BRACKET && text.charCodeAt(i + 1) === CLOSE_BRACE) {
|
||||
end = i + 2;
|
||||
break;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (end < 0) {
|
||||
// Not a tag. Resume past the opener: a real tag may start inside a string this scan
|
||||
// stepped over.
|
||||
deadEnds ??= new Set();
|
||||
for (const position of visited) deadEnds.add(position);
|
||||
searchFrom = start + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({ start, end, inner: text.slice(start + 3, end - 2) });
|
||||
searchFrom = end;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** Rewrites every template tag in `text` with whatever `replace` returns for it. */
|
||||
export function replaceTemplateTags(
|
||||
text: string,
|
||||
replace: (match: TemplateTagMatch) => string,
|
||||
): string {
|
||||
const matches = findTemplateTags(text);
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
let result = "";
|
||||
let copiedTo = 0;
|
||||
for (const match of matches) {
|
||||
result += text.slice(copiedTo, match.start) + replace(match);
|
||||
copiedTo = match.end;
|
||||
}
|
||||
|
||||
return result + text.slice(copiedTo);
|
||||
}
|
||||
@@ -26,6 +26,18 @@ describe("validateHttpHeader", () => {
|
||||
expect(validateHttpHeader("${[ fn(arg='my key') ]}-${[ other(b='x y') ]}")).toEqual(true);
|
||||
});
|
||||
|
||||
test("allows a tag whose quoted argument contains `]}`", () => {
|
||||
expect(validateHttpHeader("${[ fn(arg='x]}y') ]}")).toEqual(true);
|
||||
});
|
||||
|
||||
test("allows a tag whose quoted argument contains an escaped quote", () => {
|
||||
expect(validateHttpHeader("${[ fn(arg='it\\'s ]}') ]}")).toEqual(true);
|
||||
});
|
||||
|
||||
test("rejects a tag with a quoted `]}` followed by an invalid character", () => {
|
||||
expect(validateHttpHeader("${[ fn(arg='x]}y') ]} oops")).toEqual(false);
|
||||
});
|
||||
|
||||
test("rejects a tag followed by an invalid character", () => {
|
||||
expect(validateHttpHeader("${[ my_var ]} oops")).toEqual(false);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* Matches a template tag up to the first `]}`, the same way the Lezer grammar and the JSON
|
||||
* linter close a tag. Quoted arguments may contain spaces, so this can't stop at whitespace.
|
||||
*/
|
||||
const TEMPLATE_TAG_REGEX = /\$\{\[[\s\S]*?]}/g;
|
||||
import { replaceTemplateTags } from "./templateTags";
|
||||
|
||||
export function validateHttpHeader(v: string): boolean {
|
||||
if (v === "") {
|
||||
@@ -10,6 +6,6 @@ export function validateHttpHeader(v: string): boolean {
|
||||
}
|
||||
|
||||
// Template strings are not allowed so we replace them with a valid example string
|
||||
const withoutTemplateStrings = v.replace(TEMPLATE_TAG_REGEX, "123");
|
||||
const withoutTemplateStrings = replaceTemplateTags(v, () => "123");
|
||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
}
|
||||
Reference in new issue
Block a user