1import { checkSync } from "recheck";
2import { expect, test } from "vitest";
3import * as z from "zod/v4";
4
5const { regexes } = z.core;
6
7/** Every pattern `core/regexes.ts` ships, with the parameterized ones materialized across their argument space. */
8function allPatterns(): [string, RegExp][] {
9 const patterns: [string, RegExp][] = [];
10 for (const [name, value] of Object.entries(regexes)) {
11 if (value instanceof RegExp) patterns.push([name, value]);
12 }
13
14 patterns.push(
15 ["emoji()", regexes.emoji()],
16 ["mac()", regexes.mac()],
17 ["mac('-')", regexes.mac("-")],
18 ["uuid()", regexes.uuid()],
19 ["string()", regexes.string()],
20 ["string({min,max})", regexes.string({ minimum: 1, maximum: 10 })]
21 );
22 for (const version of [1, 2, 3, 4, 5, 6, 7, 8]) patterns.push([`uuid(${version})`, regexes.uuid(version)]);
23 for (const precision of [null, -1, 0, 3, 6]) {
24 patterns.push([`time(${precision})`, regexes.time({ precision })]);
25 for (const local of [false, true]) {
26 for (const offset of [false, true]) {
27 patterns.push([`datetime(${precision},${local},${offset})`, regexes.datetime({ precision, local, offset })]);
28 }
29 }
30 }
31 return patterns;
32}
33
34test("no built-in pattern is ReDoS-vulnerable", () => {
35 const patterns = allPatterns();
36 // Guards against the reflection above silently going empty if the module layout changes.
37 expect(patterns.length).toBeGreaterThan(80);
38
39 const vulnerable: string[] = [];
40 for (const [name, pattern] of patterns) {
41 // Flags are load-bearing. Without "u" the checker reads `\p{...}` as a literal `p{...}`
42 // and reports an exponential pattern as safe.
43 const result = checkSync(pattern.source, pattern.flags);
44 if (result.status !== "safe") vulnerable.push(`${name} (${result.status}): ${pattern.source}`);
45 }
46 expect(vulnerable).toEqual([]);
47}, 60000);
48
49test("emoji rejects a backtracking payload in linear time", () => {
50 // U+1F9B0-U+1F9B3 are the only code points in both \p{Extended_Pictographic} and
51 // \p{Emoji_Component}. A failing match over them used to backtrack exponentially:
52 // 26 of them took ~1.9s, and each additional character roughly doubled it.
53 const start = performance.now();
54 expect(z.emoji().safeParse(`${"🦰".repeat(26)} `).success).toBe(false);
55 expect(performance.now() - start).toBeLessThan(100);
56});