Files
reverse-engineering-anything/src/contracts/portablePattern.ts
T
morluto d0fee4e58e fix(mcp): re-emit advertised patterns in a portable regex grammar (#10582)
* test(mcp): check advertised schemas against a portable dialect in every profile

* fix(mcp): re-emit advertised patterns in a portable regex grammar

* fix(mcp): validate advertised dialect by selected profile

* fix(mcp): preserve schema widening through references

* fix(mcp): refuse zero-padded repeat bounds and record host-specific path patterns

* fix(mcp): keep references valid when presentation widens their targets

* fix(mcp): relax references into negations and conditionals presentation may remove

* fix(mcp): keep astral literals and dynamic references widening in every mode

* fix(mcp): omit astral literals and relax untracked references when presentation widens

* fix(mcp): read reference pointers by schema position, not segment spelling

* fix(mcp): protect references into collapsing boolean unions and keep referencing branches out of the collapse

* fix(mcp): omit patterns whose classes name astral characters

* fix(mcp): decide union collapse and pointer protection from canonical branches

* docs(mcp): document non-u astral matching and pin BMP agreement across V8 modes

* test(mcp): expect the advertised base64 schema to omit the unbounded pad-bit probe

* fix(mcp): protect references into unevaluated keywords lost evaluation removes

* fix(mcp): emit stack-safe classes with non-ASCII members and protect draft-07 reference siblings

* fix(mcp): protect references into literal siblings of relaxed draft-07 references
2026-11-15 17:06:43 +08:00

528 lines
18 KiB
TypeScript

import { portablePatternViolations } from "./advertisedDialect.js";
import {
INVALID_BASE64_CHARACTER_PATTERN,
INVALID_BASE64_PADDING_PATTERN,
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN,
} from "../domain/stringPatterns.js";
/**
* An advertised `pattern` re-emitted into the portable regex grammar.
* `exact` matches the same strings as the source under ECMAScript `u`
* semantics, `wider` matches a superset, and `unexpressible` has neither form
* (a backreference, for example), so the advertisement must omit it. Server
* validation keeps the canonical expression either way.
*
* V8's plain, `u` and `v` modes accept the same BMP strings. Known
* limitation: V8 without `u` matches UTF-16 units, so where a wildcard,
* shorthand or negated class must match one astral character (`^.$`,
* `^[^]$`, `^[\S]$`, `^\S\W\D$`), a client compiling without `u` rejects an
* astral character at that position, which the canonical pattern accepts. These
* are rare and keeping the constraint beats dropping it. Literal and class
* astral members are omitted instead (see `#literal`). Repeated classes are
* unaffected, and valid base64, which `^(?:....)*$` checks, is ASCII.
*/
export type PortablePatternProjection =
| { readonly kind: "exact"; readonly pattern: string }
| { readonly kind: "wider"; readonly pattern: string }
| { readonly kind: "unexpressible" };
class Unexpressible extends Error {}
// Any string, as runs separated by NUL.
const ANY_STRING = "[^\\x00]*(?:\\x00[^\\x00]*)*";
const UNBOUNDED_BASE64_PROBES = new Set([
INVALID_BASE64_CHARACTER_PATTERN.source,
INVALID_BASE64_PADDING_PATTERN.source,
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source,
]);
const ANY_CHARACTER = "(?:[^\\x00]|\\x00)";
const NON_ASCII = "[^\\x00-\\x7f]";
const ALNUM = /^[A-Za-z0-9]$/u;
const LITERAL_OUTSIDE_CLASS = new Set("_ !#%&,:;<=>@`~'\"-/");
const ESCAPED_OUTSIDE_CLASS = new Set("^$\\.*+?()[]{}|/");
const LITERAL_IN_CLASS = new Set("!#%&,:;<=>@`~'\" _.+*?$");
const ESCAPED_IN_CLASS = new Set("^$\\.*+?()[]{}|/-");
const MAX_REPEAT = 1000;
const hex = (code: number) => `\\x${code.toString(16).padStart(2, "0")}`;
const emitOutside = (code: number): string => {
const character = String.fromCodePoint(code);
if (ALNUM.test(character) || LITERAL_OUTSIDE_CLASS.has(character))
return character;
if (ESCAPED_OUTSIDE_CLASS.has(character)) return `\\${character}`;
return hex(code);
};
const emitInClass = (code: number, rangeEndpoint: boolean): string => {
const character = String.fromCodePoint(code);
if (ALNUM.test(character)) return character;
// A punctuator that starts a range is read as a literal followed by `-` in
// some engines, so range endpoints use the hexadecimal form.
if (!rangeEndpoint && LITERAL_IN_CLASS.has(character)) return character;
if (!rangeEndpoint && character !== "/" && ESCAPED_IN_CLASS.has(character))
return `\\${character}`;
return hex(code);
};
/** ASCII members as merged ranges, emitted without repeated punctuators. */
const emitAsciiMembers = (members: ReadonlySet<number>): string => {
const sorted = [...members].sort((left, right) => left - right);
let emitted = "";
for (let index = 0; index < sorted.length;) {
let end = index;
while (end + 1 < sorted.length && sorted[end + 1] === sorted[end]! + 1)
end++;
const first = sorted[index]!;
const last = sorted[end]!;
if (last - first >= 2)
emitted += `${emitInClass(first, true)}-${emitInClass(last, true)}`;
else
for (let code = first; code <= last; code++)
emitted += emitInClass(code, false);
index = end + 1;
}
return emitted;
};
/** Non-ASCII membership of a class, as far as it changes the projection. */
type NonAscii = "none" | "some" | "all";
const unionNonAscii = (left: NonAscii, right: NonAscii): NonAscii =>
left === "all" || right === "all"
? "all"
: left === "some" || right === "some"
? "some"
: "none";
const ASCII_CODES = Array.from({ length: 0x80 }, (_, code) => code);
/**
* Re-emit one character class, given its exact ASCII members (computed with
* V8 itself) and how much of the non-ASCII range it contains.
*/
const emitClass = (
ascii: ReadonlySet<number>,
nonAscii: NonAscii,
negated: boolean,
widen: () => void,
): string => {
if (nonAscii === "some") widen();
if (!negated) {
if (nonAscii === "none") {
if (ascii.size === 0) throw new Unexpressible();
return `[${emitAsciiMembers(ascii)}]`;
}
if (ascii.size === 0) return NON_ASCII;
// One negated class admits every non-ASCII character; an alternation of
// two classes would cost a backtracking frame per repeated character.
const missing = new Set(ASCII_CODES.filter((code) => !ascii.has(code)));
return missing.size === 0
? ANY_CHARACTER
: `[^${emitAsciiMembers(missing)}]`;
}
if (nonAscii === "all") {
const rest = new Set(ASCII_CODES.filter((code) => !ascii.has(code)));
if (rest.size === 0) throw new Unexpressible();
return `[${emitAsciiMembers(rest)}]`;
}
// A negated class keeps every non-ASCII character it does not name; with
// "some" named, admitting all of them only widens.
return ascii.size === 0 ? ANY_CHARACTER : `[^${emitAsciiMembers(ascii)}]`;
};
const SHORTHAND_NON_ASCII: Readonly<Record<string, NonAscii>> = {
d: "none",
w: "none",
s: "some",
D: "all",
W: "all",
S: "some",
};
class Projector {
#index = 0;
#wider = false;
constructor(private readonly source: string) {}
project(): {
readonly kind: "exact" | "wider";
readonly pattern: string;
} {
const alternatives = this.#disjunction();
if (this.#index !== this.source.length) throw new Unexpressible();
// A search for an unanchored alternative is a full match of the same
// alternative between two arbitrary strings.
const completed = alternatives.map((terms) => {
const first = terms[0] === "^" ? 1 : 0;
const anchoredEnd = terms.length > first && terms.at(-1) === "$";
const body = terms.slice(first, anchoredEnd ? -1 : undefined).join("");
return `${first === 1 ? "" : ANY_STRING}${body}${anchoredEnd ? "" : ANY_STRING}`;
});
const pattern =
completed.length === 1
? `^${completed[0]!}$`
: `^(?:${completed.join("|")})$`;
return { kind: this.#wider ? "wider" : "exact", pattern };
}
#peek(offset = 0): string {
return this.source[this.#index + offset] ?? "";
}
#widen = (): void => {
this.#wider = true;
};
#disjunction(): string[][] {
const alternatives = [this.#alternative()];
while (this.#peek() === "|") {
this.#index++;
alternatives.push(this.#alternative());
}
return alternatives;
}
#group(): string {
return this.#disjunction()
.map((terms) => terms.join(""))
.join("|");
}
#alternative(): string[] {
const terms: string[] = [];
while (this.#index < this.source.length) {
const character = this.#peek();
if (character === "|" || character === ")") break;
const atom = this.#term();
if (atom === undefined) continue;
const quantifier = this.#quantifier();
if (atom.kind === "assertion") {
// A quantified assertion matches the same positions or none.
if (quantifier !== "") throw new Unexpressible();
terms.push(atom.text);
} else
terms.push(
atom.text === ANY_CHARACTER && quantifier === "*"
? ANY_STRING
: atom.text + quantifier,
);
}
return terms;
}
#term():
| { readonly kind: "atom" | "assertion"; readonly text: string }
| undefined {
const character = this.#peek();
if (character === "^" || character === "$") {
this.#index++;
return { kind: "assertion", text: character };
}
if (character === "(") return this.#parenthesized();
if (character === "[") return { kind: "atom", text: this.#class() };
if (character === ".") {
this.#index++;
// Without the `s` flag, `.` excludes four line terminators; two are ASCII.
return {
kind: "atom",
text: emitClass(new Set([0x0a, 0x0d]), "some", true, this.#widen),
};
}
if (character === "\\") return this.#atomEscape();
const code = this.source.codePointAt(this.#index)!;
this.#index += String.fromCodePoint(code).length;
return { kind: "atom", text: this.#literal(code) };
}
#literal(code: number): string {
if (code < 0x80) return emitOutside(code);
// V8 without `u` reads an astral literal as two UTF-16 units, so one
// non-ASCII unit would reject it there, and a one-or-two-unit atom
// backtracks exponentially under a repeat.
if (code > 0xffff) throw new Unexpressible();
this.#widen();
return NON_ASCII;
}
#parenthesized():
| { readonly kind: "atom" | "assertion"; readonly text: string }
| undefined {
const rest = this.source.slice(this.#index);
const lookAround = /^\(\?<?[=!]/u.exec(rest);
if (lookAround !== null) {
// Removing a zero-width assertion admits a superset of strings.
this.#index += lookAround[0].length;
this.#group();
this.#expect(")");
this.#widen();
// A quantifier on a removed assertion has nothing left to repeat.
this.#quantifier();
return undefined;
}
let open = "(";
if (rest.startsWith("(?:")) {
open = "(?:";
this.#index += 3;
} else {
// Named groups capture like plain groups; only backreferences read names.
const named = /^\(\?<[A-Za-z_$][\w$]*>/u.exec(rest);
if (named !== null) this.#index += named[0].length;
else if (rest.startsWith("(?")) throw new Unexpressible();
else this.#index++;
}
const inner = this.#group();
this.#expect(")");
return { kind: "atom", text: `${open}${inner})` };
}
#expect(character: string): void {
if (this.#peek() !== character) throw new Unexpressible();
this.#index++;
}
#quantifier(): string {
const character = this.#peek();
let quantifier = "";
if (character === "*" || character === "+" || character === "?") {
this.#index++;
quantifier = character;
} else if (character === "{") {
const repeat = /^\{(\d+)(?:(,)(\d*))?\}/u.exec(
this.source.slice(this.#index),
);
if (repeat === null) throw new Unexpressible();
this.#index += repeat[0].length;
let lower = Number(repeat[1]);
let upper =
repeat[2] === undefined
? lower
: repeat[3] === ""
? undefined
: Number(repeat[3]);
if (lower > MAX_REPEAT) {
lower = MAX_REPEAT;
upper = undefined;
this.#widen();
}
if (upper !== undefined && upper > MAX_REPEAT) {
upper = undefined;
this.#widen();
}
quantifier =
upper === lower
? `{${String(lower)}}`
: `{${String(lower)},${upper === undefined ? "" : String(upper)}}`;
} else return "";
// Laziness changes which match is found, not whether one exists.
if (this.#peek() === "?") this.#index++;
return quantifier;
}
/** One escaped code point, or a shorthand class letter. */
#escape(inClass: boolean): number | { readonly shorthand: string } {
this.#index++; // backslash
const character = this.#peek();
this.#index++;
if (character in SHORTHAND_NON_ASCII) return { shorthand: character };
if (character === "p" || character === "P") {
const property = /^\{[^}]*\}/u.exec(this.source.slice(this.#index));
if (property === null) throw new Unexpressible();
this.#index += property[0].length;
return { shorthand: `${character}${property[0]}` };
}
const controls: Readonly<Record<string, number>> = {
t: 0x09,
n: 0x0a,
v: 0x0b,
f: 0x0c,
r: 0x0d,
};
if (Object.hasOwn(controls, character)) return controls[character]!;
if (character === "0" && !/^[0-9]$/u.test(this.#peek())) return 0;
if (character === "b" && inClass) return 0x08;
if (character === "-" && inClass) return 0x2d;
if (character === "c") {
const letter = this.#peek();
if (!/^[A-Za-z]$/u.test(letter)) throw new Unexpressible();
this.#index++;
return letter.charCodeAt(0) % 32;
}
if (character === "x") {
const digits = /^[0-9A-Fa-f]{2}/u.exec(this.source.slice(this.#index));
if (digits === null) throw new Unexpressible();
this.#index += 2;
return Number.parseInt(digits[0], 16);
}
if (character === "u") {
const braced = /^\{([0-9A-Fa-f]+)\}/u.exec(
this.source.slice(this.#index),
);
if (braced !== null) {
this.#index += braced[0].length;
return Number.parseInt(braced[1]!, 16);
}
const digits = /^[0-9A-Fa-f]{4}/u.exec(this.source.slice(this.#index));
if (digits === null) throw new Unexpressible();
this.#index += 4;
const unit = Number.parseInt(digits[0], 16);
const low = /^\\u([Dd][C-Fc-f][0-9A-Fa-f]{2})/u.exec(
this.source.slice(this.#index),
);
if (unit >= 0xd800 && unit <= 0xdbff && low !== null) {
this.#index += low[0].length;
return (
(unit - 0xd800) * 0x400 +
(Number.parseInt(low[1]!, 16) - 0xdc00) +
0x10000
);
}
return unit;
}
// Identity escapes of syntax characters; `u` mode admits no others.
if (ESCAPED_IN_CLASS.has(character) && character !== "-") {
return character.charCodeAt(0);
}
// Backreferences and word boundaries are handled by the caller.
throw new Unexpressible();
}
#atomEscape():
| { readonly kind: "atom" | "assertion"; readonly text: string }
| undefined {
const next = this.#peek(1);
if (next === "b" || next === "B") {
// Removing a zero-width assertion admits a superset of strings.
this.#index += 2;
this.#widen();
this.#quantifier();
return undefined;
}
// Backreferences have no portable form.
if (/^[1-9k]$/u.test(next)) throw new Unexpressible();
const escaped = this.#escape(false);
if (typeof escaped === "number")
return { kind: "atom", text: this.#literal(escaped) };
const { ascii, nonAscii } = this.#membership(`[\\${escaped.shorthand}]`, [
escaped,
]);
return {
kind: "atom",
text: emitClass(ascii, nonAscii, false, this.#widen),
};
}
/** Parse a class at the cursor and re-emit it. */
#class(): string {
const start = this.#index;
this.#index++;
const negated = this.#peek() === "^";
if (negated) this.#index++;
const members: (number | { shorthand: string } | [number, number])[] = [];
while (this.#peek() !== "]") {
if (this.#index >= this.source.length) throw new Unexpressible();
const low = this.#classAtom();
if (
typeof low === "number" &&
this.#peek() === "-" &&
this.#peek(1) !== "]" &&
this.#peek(1) !== ""
) {
this.#index++;
const high = this.#classAtom();
if (typeof high !== "number") throw new Unexpressible();
members.push([low, high]);
} else members.push(low);
}
this.#index++;
// As for literals: V8 without `u` reads an astral member as two UTF-16
// units, and the one-unit non-ASCII class emitted for it would reject it.
if (
members.some((member) =>
typeof member === "number"
? member > 0xffff
: Array.isArray(member) && member[1] > 0xffff,
)
)
throw new Unexpressible();
const source = this.source.slice(start, this.#index);
const { ascii: matched, nonAscii } = this.#membership(source, members);
const ascii = negated
? new Set(ASCII_CODES.filter((code) => !matched.has(code)))
: matched;
return emitClass(ascii, nonAscii, negated, this.#widen);
}
#classAtom(): number | { shorthand: string } {
if (this.#peek() === "\\") return this.#escape(true);
const code = this.source.codePointAt(this.#index)!;
this.#index += String.fromCodePoint(code).length;
return code;
}
#membership(
classSource: string,
members: readonly (number | { shorthand: string } | [number, number])[],
): { readonly ascii: ReadonlySet<number>; readonly nonAscii: NonAscii } {
// V8 decides ASCII membership exactly, including property escapes.
const expression = new RegExp(`^${classSource}$`, "u");
const ascii = new Set(
ASCII_CODES.filter((code) => expression.test(String.fromCharCode(code))),
);
let nonAscii: NonAscii = "none";
for (const member of members) {
if (typeof member === "number")
nonAscii = unionNonAscii(nonAscii, member >= 0x80 ? "some" : "none");
else if (Array.isArray(member))
nonAscii = unionNonAscii(
nonAscii,
member[1] < 0x80
? "none"
: member[0] <= 0x80 && member[1] >= 0x10ffff
? "all"
: "some",
);
else
nonAscii = unionNonAscii(
nonAscii,
SHORTHAND_NON_ASCII[member.shorthand] ?? "some",
);
}
return { ascii, nonAscii };
}
}
const projections = new Map<string, PortablePatternProjection>();
/**
* Re-emit an advertised ECMAScript pattern in the portable grammar. Results
* are memoized: advertised patterns come from a fixed set of contracts.
*/
export const projectPortablePattern = (
pattern: string,
): PortablePatternProjection => {
const cached = projections.get(pattern);
if (cached !== undefined) return cached;
let projection: PortablePatternProjection;
try {
// Anchoring these unbounded negative probes requires a repeated universal
// prefix; V8 v-mode can overflow its stack on a large valid base64 value.
if (UNBOUNDED_BASE64_PROBES.has(pattern)) throw new Unexpressible();
// JSON Schema validators read `pattern` with Unicode semantics.
new RegExp(pattern, "u");
const projected = new Projector(pattern).project();
// The emitter's own output must stay inside the grammar it targets.
projection =
portablePatternViolations(projected.pattern).length > 0
? { kind: "unexpressible" }
: projected;
} catch (cause: unknown) {
if (!(cause instanceof Unexpressible || cause instanceof SyntaxError))
throw cause;
projection = { kind: "unexpressible" };
}
projections.set(pattern, projection);
return projection;
};