Repository navigation
fix(core): evaluate server-supplied schema patterns in bounded time (v2) #2877
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
93ee71b
fix(core): evaluate server-supplied schema patterns in bounded time
cliffhall a3a4d0d
fix(core): bound counted-repeat expansion by compile work
cliffhall 34c90ea
fix(core): share one compile budget across a schema's patterns
cliffhall 7d8b9ac
fix(core): charge patterns compiled on an $id lookup to the schema bu…
cliffhall cbd467e
fix(core): cap pattern length and charge it before any pattern work
cliffhall File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,298 @@ | ||
| /** | ||
| * Tests for the linear-time `pattern` matcher (core/json/linearRegExp.ts). | ||
| * | ||
| * Two properties carry the fix: the matcher answers exactly what the native | ||
| * engine answers (checked differentially over a corpus that exercises every | ||
| * parser branch), and its work is bounded by input length × program size | ||
| * whatever the pattern's shape (checked on the catastrophic shapes, with the | ||
| * bound asserted rather than timed, so there is nothing to flake). | ||
| */ | ||
| import { describe, it, expect, vi } from "vitest"; | ||
| import { | ||
| COMPILE_BUDGET_EXHAUSTED, | ||
| compileLinearRegExp, | ||
| isCompileBudgetExhausted, | ||
| MAX_COMPILE_STEPS, | ||
| MAX_PATTERN_LENGTH, | ||
| MAX_PROGRAM_SIZE, | ||
| type LinearRegExp, | ||
| } from "@inspector/core/json/linearRegExp"; | ||
|
|
||
| const NO_LIMIT = Number.MAX_SAFE_INTEGER; | ||
|
|
||
| function compiled(pattern: string, unicode = true): LinearRegExp { | ||
| const result = compileLinearRegExp(pattern, unicode); | ||
| if (!result.ok) throw new Error(`unexpectedly unsupported: ${result.reason}`); | ||
| return result.regExp; | ||
| } | ||
|
|
||
| function unsupportedReason(pattern: string, unicode = true): string { | ||
| const result = compileLinearRegExp(pattern, unicode); | ||
| if (result.ok) throw new Error(`unexpectedly supported: ${pattern}`); | ||
| return result.reason; | ||
| } | ||
|
|
||
| /** Patterns valid under both flag settings, each with probe inputs. */ | ||
| const SHARED_CORPUS: [string, string[]][] = [ | ||
| ["", ["", "x"]], | ||
| ["abc", ["abc", "xabcx", "ab", ""]], | ||
| ["^abc$", ["abc", "abcd", "zabc"]], | ||
| ["^a+$", ["a", "aaaa", "", "aab"]], | ||
| ["^a*b?c{2}$", ["cc", "acc", "aabcc", "abc", "bbcc"]], | ||
| ["^a{2,}$", ["a", "aa", "aaaaa"]], | ||
| ["^a{1,3}$", ["", "a", "aaa", "aaaa"]], | ||
| ["^a{0}b$", ["b", "ab"]], | ||
| ["^a+?b*?$", ["a", "ab", "b"]], | ||
| ["^(a|bc|)d$", ["ad", "bcd", "d", "bd"]], | ||
| ["^(?:ab)+$", ["ab", "abab", "aba"]], | ||
| ["^(?<word>\\w+)-(\\d+)$", ["abc-12", "abc-", "-12"]], | ||
| ["^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$", ["a.b@c.io", "a@b", "@b.io"]], | ||
| ["^[^\\s]+$", ["abc", "a c", ""]], | ||
| ["[\\]]", ["]", "a"]], | ||
| ["^\\d\\D\\w\\W\\s\\S$", ["1a_ \tx", "1a_ x", "aa_ \tx"]], | ||
| ["\\bfoo\\b", ["foo", "a foo b", "afoo", "foob"]], | ||
| ["\\Bo\\B", ["foo bar", "o", "too"]], | ||
| ["^.$", ["a", "\n", "ab"]], | ||
| ["^\\x41\\u0042$", ["AB", "AC"]], | ||
| ["^\\cJ$", ["\n", "J"]], | ||
| ["^a\\0b$", ["a\0b", "a0b"]], | ||
| ["^\\.\\*\\/$", [".*/", "a*/"]], | ||
| ["^(a*)*$", ["", "aaaa", "aab"]], | ||
| ["^(|a)+$", ["", "a", "aa", "b"]], | ||
| ["^(a|a)*b$", ["aaab", "aaa"]], | ||
| ["^((ab)+c)+$", ["abcababc", "abca"]], | ||
| ["(\\d{1,3}\\.){3}\\d{1,3}", ["10.0.0.1", "10.0.1"]], | ||
| ["^é+$", ["éé", "e"]], | ||
| // A quantified group around an assertion is valid natively, unlike a bare | ||
| // quantified assertion. | ||
| ["(\\b)?a", ["a", " a", "ba", ""]], | ||
| ["(^)*a", ["a", "ba", ""]], | ||
| ["($){0}a", ["a", "b"]], | ||
| ["^(\\b|$)+$", ["", "a"]], | ||
| ]; | ||
|
|
||
| /** Patterns whose meaning depends on the `u` flag. */ | ||
| const UNICODE_CORPUS: [string, string[]][] = [ | ||
| ["^\u{1F600}+$", ["\u{1F600}\u{1F600}", "\u{1F600}x", "\uD83D"]], | ||
| ["^.$", ["\u{1F600}", "\uD83D"]], | ||
| ["^\\u{1F600}$", ["\u{1F600}", "x"]], | ||
| ["^\\uD83D\\uDE00$", ["\u{1F600}", "\uD83D"]], | ||
| ["^\\uD83D\\u0041$", ["\uD83DA", "A"]], | ||
| ["^\\p{L}+\\P{L}$", ["abc1", "abc", "éé!"]], | ||
| ["^[\\p{Lu}]$", ["A", "a"]], | ||
| ]; | ||
|
|
||
| /** Patterns that are only valid (or only mean this) without the `u` flag. */ | ||
| const LEGACY_CORPUS: [string, string[]][] = [ | ||
| ["^.$", ["\u{1F600}", "\uD83D"]], | ||
| ["^a{x$", ["a{x", "ax"]], | ||
| ["^]}$", ["]}", "]"]], | ||
| ["^\\p$", ["p", "\\p"]], | ||
| ["^\\u004$", ["u004", "\\u004"]], | ||
| ["^\\x4G$", ["x4G", "\\x4G"]], | ||
| ["^\\uD83D\\uDE0$", ["\uD83D\\uDE0", "uDE0"]], | ||
| ["^\\u{2}$", ["uu", "u"]], | ||
| ["^\u{1F600}$", ["\u{1F600}", "\uD83D"]], | ||
| ["^\\\u{1F600}$", ["\u{1F600}", "x"]], | ||
| ]; | ||
|
|
||
| describe("compileLinearRegExp — agrees with the native engine", () => { | ||
| const cases: [string, boolean, string[]][] = [ | ||
| ...SHARED_CORPUS.map(([p, s]): [string, boolean, string[]] => [p, true, s]), | ||
| ...SHARED_CORPUS.map(([p, s]): [string, boolean, string[]] => [ | ||
| p, | ||
| false, | ||
| s, | ||
| ]), | ||
| ...UNICODE_CORPUS.map(([p, s]): [string, boolean, string[]] => [ | ||
| p, | ||
| true, | ||
| s, | ||
| ]), | ||
| ...LEGACY_CORPUS.map(([p, s]): [string, boolean, string[]] => [ | ||
| p, | ||
| false, | ||
| s, | ||
| ]), | ||
| ]; | ||
| it.each(cases)("/%s/ (unicode: %s)", (pattern, unicode, inputs) => { | ||
| const native = new RegExp(pattern, unicode ? "u" : ""); | ||
| const linear = compiled(pattern, unicode); | ||
| for (const input of inputs) { | ||
| expect( | ||
| { input, matched: linear.search(input, NO_LIMIT) }, | ||
| `/${pattern}/ on ${JSON.stringify(input)}`, | ||
| ).toEqual({ input, matched: native.test(input) }); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe("compileLinearRegExp — unsupported constructs", () => { | ||
| it.each([ | ||
| ["(?=a)a", "lookaround or group modifier"], | ||
| ["(?!a)b", "lookaround or group modifier"], | ||
| ["(?<=a)b", "lookaround or group modifier"], | ||
| ["(?<!a)b", "lookaround or group modifier"], | ||
| ["(a)\\1", "backreference"], | ||
| ["(?<n>a)\\k<n>", "backreference"], | ||
| ])("declines /%s/", (pattern, reason) => { | ||
| expect(unsupportedReason(pattern)).toBe(reason); | ||
| }); | ||
|
|
||
| it("declines legacy octal and incomplete control escapes", () => { | ||
| expect(unsupportedReason("\\01", false)).toBe("octal escape"); | ||
| expect(unsupportedReason("\\c1", false)).toBe("incomplete control escape"); | ||
| expect(unsupportedReason("a\\c", false)).toBe("incomplete control escape"); | ||
| expect(unsupportedReason("\\k", false)).toBe("backreference"); | ||
| }); | ||
|
|
||
| it("declines groups nested past the depth limit", () => { | ||
| const deep = `${"(".repeat(70)}a${")".repeat(70)}`; | ||
| expect(unsupportedReason(deep)).toBe("groups nested too deeply"); | ||
| }); | ||
|
|
||
| it("declines a pattern that expands past the program size limit", () => { | ||
| expect(unsupportedReason(`a{${MAX_PROGRAM_SIZE + 1}}`)).toBe( | ||
| "pattern too complex", | ||
| ); | ||
| expect(unsupportedReason("(a{1,200}){1,200}")).toBe("pattern too complex"); | ||
| }); | ||
|
|
||
| it("compiles a huge counted repeat of an empty body without expanding it", () => { | ||
| // Without the empty-body cut-off each of these loops `min` times while | ||
| // emitting nothing, so the program-size cap never fires and compilation | ||
| // never returns; with it, each compiles to a program of the empty match. | ||
| for (const pattern of [ | ||
| "(){1000000000000}", | ||
| "(?:){1000000000000}", | ||
| "(a{0}){1000000000000}", | ||
| "((){1000}){1000000000}", | ||
| "(){1000000000000,}", | ||
| "(a{0}){5,1000000000000}", | ||
| "^(a{0}){1000000000000}$", | ||
| ]) { | ||
| const regExp = compiled(pattern); | ||
| expect(regExp.size, pattern).toBeLessThanOrEqual(4); | ||
| expect(regExp.search("", NO_LIMIT), pattern).toBe(true); | ||
| } | ||
| expect(compiled("^(a{0}){1000000000000}$").search("a", NO_LIMIT)).toBe( | ||
| false, | ||
| ); | ||
| }); | ||
|
|
||
| it("declines a huge bounded repeat of an empty body at the program size limit", () => { | ||
| // Nothing emitted by a mandatory copy cuts these short, so the optional | ||
| // copies each emit a split and the program-size cap stops the expansion. | ||
| expect(unsupportedReason("(){0,1000000000000}")).toBe( | ||
| "pattern too complex", | ||
| ); | ||
| expect(unsupportedReason("(a{0}){0,1000000000000}")).toBe( | ||
| "pattern too complex", | ||
| ); | ||
| }); | ||
|
|
||
| it("declines a repeat whose body costs compile work without emitting", () => { | ||
| // Each copy visits 21 nodes but emits one instruction, so the program | ||
| // stays under its cap while the work does not: the step cap stops it. | ||
| const body = `(?:${"(){0}".repeat(20)}x)`; | ||
| const copies = Math.ceil(MAX_COMPILE_STEPS / 21) + 1; | ||
| expect(copies).toBeLessThan(MAX_PROGRAM_SIZE); | ||
| expect(unsupportedReason(`${body}{${copies}}`)).toBe("pattern too complex"); | ||
| }); | ||
|
|
||
| it("spends a shared budget across compilations and declines once it is gone", () => { | ||
| // Each copy emits its program less the final `match`, which is not charged. | ||
| const emitted = compiled("^a{9000}b$").size - 1; | ||
| const budget = { steps: MAX_COMPILE_STEPS, size: 2 * emitted }; | ||
| expect(compileLinearRegExp("^a{9000}b$", true, budget).ok).toBe(true); | ||
| expect(compileLinearRegExp("^a{9000}c$", true, budget).ok).toBe(true); | ||
| expect(isCompileBudgetExhausted(budget)).toBe(false); | ||
| const third = compileLinearRegExp("^a{9000}d$", true, budget); | ||
| expect(third).toEqual({ ok: false, reason: COMPILE_BUDGET_EXHAUSTED }); | ||
| expect(isCompileBudgetExhausted(budget)).toBe(true); | ||
| // Once exhausted, even a trivial pattern is declined, at no cost. | ||
| expect(compileLinearRegExp("a", true, budget).ok).toBe(false); | ||
|
|
||
| // Its length (4) is charged up front; the compile visits (5) then run out. | ||
| const steps = { steps: 6, size: MAX_PROGRAM_SIZE }; | ||
| expect(compileLinearRegExp("abcd", true, steps)).toEqual({ | ||
| ok: false, | ||
| reason: COMPILE_BUDGET_EXHAUSTED, | ||
| }); | ||
| expect(isCompileBudgetExhausted(steps)).toBe(true); | ||
| }); | ||
|
|
||
| it("declines an over-length or over-budget pattern before the native constructor sees it", () => { | ||
| const native = vi.spyOn(globalThis, "RegExp"); | ||
| try { | ||
| const long = "a".repeat(MAX_PATTERN_LENGTH + 1); | ||
| expect(compileLinearRegExp(long, true)).toEqual({ | ||
| ok: false, | ||
| reason: `pattern longer than ${MAX_PATTERN_LENGTH} characters`, | ||
| }); | ||
| // Its length is charged even when nothing else is: this one fits the | ||
| // per-pattern cap but not what remains of the budget. | ||
| const budget = { steps: MAX_PATTERN_LENGTH - 1, size: MAX_PROGRAM_SIZE }; | ||
| const atCap = `[${"a".repeat(MAX_PATTERN_LENGTH - 2)}]`; | ||
| expect(compileLinearRegExp(atCap, true, budget)).toEqual({ | ||
| ok: false, | ||
| reason: COMPILE_BUDGET_EXHAUSTED, | ||
| }); | ||
| expect(native).not.toHaveBeenCalled(); | ||
| // The spy does see a pattern that is compiled. | ||
| compileLinearRegExp("a", true); | ||
| expect(native).toHaveBeenCalled(); | ||
| } finally { | ||
| native.mockRestore(); | ||
| } | ||
| expect( | ||
| compiled(`[${"a".repeat(MAX_PATTERN_LENGTH - 2)}]`).search("a", NO_LIMIT), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("lets an invalid pattern throw, as the native engine would", () => { | ||
| expect(() => compileLinearRegExp("(a", true)).toThrow(SyntaxError); | ||
| expect(() => compileLinearRegExp("a{2,1}", true)).toThrow(SyntaxError); | ||
| }); | ||
| }); | ||
|
|
||
| describe("LinearRegExp — bounded work", () => { | ||
| it("answers catastrophic-backtracking shapes without backtracking", () => { | ||
| // Each of these takes exponential (or high-polynomial) time on the | ||
| // native engine at these lengths; here each is one pass. | ||
| const nearMiss = `${"a".repeat(5_000)}!`; | ||
| for (const pattern of [ | ||
| "^(a+)+$", | ||
| "^(a|a)*$", | ||
|
cliffhall marked this conversation as resolved.
Dismissed
|
||
| "^(a|aa)+$", | ||
|
cliffhall marked this conversation as resolved.
Dismissed
|
||
| "^(a*)*$", | ||
|
cliffhall marked this conversation as resolved.
Dismissed
|
||
| "^(\\w+\\s?)+$", | ||
|
cliffhall marked this conversation as resolved.
Dismissed
|
||
| "a*a*a*a*a*b", | ||
| ]) { | ||
| const regExp = compiled(pattern); | ||
| expect(regExp.search(nearMiss, NO_LIMIT), pattern).toBe(false); | ||
| expect(regExp.search("a".repeat(5_000), NO_LIMIT), pattern).toBe( | ||
| pattern !== "a*a*a*a*a*b", | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| it("reports its cost as input length x program size", () => { | ||
| const regExp = compiled("^(a+)+$"); | ||
|
cliffhall marked this conversation as resolved.
Dismissed
|
||
| expect(regExp.size).toBeGreaterThan(0); | ||
| expect(regExp.cost("aaaa")).toBe(5 * regExp.size); | ||
| }); | ||
|
|
||
| it("declines to run, answering undefined, when the cost exceeds the budget", () => { | ||
| const regExp = compiled("^(a+)+$"); | ||
|
cliffhall marked this conversation as resolved.
Dismissed
|
||
| const input = "a".repeat(100); | ||
| expect(regExp.search(input, regExp.cost(input) - 1)).toBeUndefined(); | ||
| expect(regExp.search(input, regExp.cost(input))).toBe(true); | ||
| }); | ||
|
|
||
| it("stops early once a start-anchored pattern has no live thread", () => { | ||
| expect(compiled("^ab").search(`x${"a".repeat(10_000)}`, NO_LIMIT)).toBe( | ||
| false, | ||
| ); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.