Skip to content

repr: bound regex compile memory, not just the pattern's byte length - #37976

Open
def- wants to merge 1 commit into
mainfrom
def/fuzz-06-repr-regex
Open

repr: bound regex compile memory, not just the pattern's byte length#37976
def- wants to merge 1 commit into
mainfrom
def/fuzz-06-repr-regex

Conversation

@def-

@def- def- commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

MAX_REGEX_SIZE_BEFORE_COMPILATION caps pattern.len() at 1 MiB to keep a
user-supplied regex from OOMing envd. A byte budget cannot do that job. The
memory goes to regex-syntax translating the pattern's AST into its HIR, a
stage that runs before anything size_limit measures, and while cost there is
linear in the AST's node count, the cost per node spans two orders of magnitude:

group, alternation, repetition, assertion 0.33 - 0.53 KB
literal, ., [a-c] 0.42 - 0.60 KB
\d, \s 0.65 - 1.1 KB
\w, \W, case-sensitive \p{L} 6 - 18 KB
case-insensitive \p{L}, \p{Alphabetic} 39 - 41 KB

So a pattern one byte under the limit could allocate ~7.8 GB. Confirmed on a
single-node environment with unprivileged SQL, no table and no cluster work,
from ~90 bytes of query text:

SELECT 'x' ~* repeat(chr(92) || 'p{L}', 209715);

envd VmRSS went 320 MB -> 7 620 MB over ~18 s (VmHWM 8 303 MB) while clusterd
stayed flat at ~420 MB, and only then did the statement error with "Compiled
regex exceeds size limit". statement_timeout does not help, since the
allocation precedes the error. Any user who can issue a SELECT could do this
on demand, and two concurrent queries exceed any realistic envd memory limit.

Project the cost from the AST instead, which is cheap to obtain (~30 ms and
~10 MB at 174 KB, flat in the pattern's shape) and charge each node a
conservative upper bound: 48 KB for a Unicode-property or Perl class item, the
kinds that expand to hundreds of ranges, and 768 bytes for everything else.
Reject above 1 GiB. Classes nested in a bracketed class are charged too, else
[\p{L}] repeated evades the budget at the same cost.

This is additive. The byte limit stays, since it is what bounds the AST parse
this check needs, and its number stays valid in the user-facing docs. Patterns
that were cheap remain accepted: a 1 MiB pattern of plain literals, the shape a
machine-generated alternation takes, still compiles. A counted repetition needs
no budget of its own, since \p{L}{200000} stays two AST nodes and the NFA
compiler's incremental size_limit check already rejects it in 40 ms at 17 MB.

A pattern we cannot parse falls through to RegexBuilder so users keep its
error message. That is not an escape hatch: both parse through the same
regex-syntax 0.8.11 with the same default configuration, verified against 35
syntax corner cases to never split in the direction that would skip the check.

@def-
def- marked this pull request as ready for review July 31, 2026 09:58
@def-
def- requested a review from a team as a code owner July 31, 2026 09:58
@def-
def- changed the base branch from def/fuzz-05-repr-proto to main July 31, 2026 10:05
@def-
def- requested review from a team as code owners July 31, 2026 10:05
`MAX_REGEX_SIZE_BEFORE_COMPILATION` caps `pattern.len()` at 1 MiB to keep a
user-supplied regex from OOMing envd. A byte budget cannot do that job. The
memory goes to `regex-syntax` translating the pattern's AST into its HIR, a
stage that runs before anything `size_limit` measures, and while cost there is
linear in the AST's node count, the cost per node spans two orders of magnitude:

  group, alternation, repetition, assertion    0.33 - 0.53 KB
  literal, `.`, `[a-c]`                        0.42 - 0.60 KB
  `\d`, `\s`                                   0.65 - 1.1 KB
  `\w`, `\W`, case-sensitive `\p{L}`           6 - 18 KB
  case-insensitive `\p{L}`, `\p{Alphabetic}`   39 - 41 KB

So a pattern one byte under the limit could allocate ~7.8 GB. Confirmed on a
single-node environment with unprivileged SQL, no table and no cluster work,
from ~90 bytes of query text:

  SELECT 'x' ~* repeat(chr(92) || 'p{L}', 209715);

envd VmRSS went 320 MB -> 7 620 MB over ~18 s (VmHWM 8 303 MB) while clusterd
stayed flat at ~420 MB, and only then did the statement error with "Compiled
regex exceeds size limit". `statement_timeout` does not help, since the
allocation precedes the error. Any user who can issue a `SELECT` could do this
on demand, and two concurrent queries exceed any realistic envd memory limit.

Project the cost from the AST instead, which is cheap to obtain (~30 ms and
~10 MB at 174 KB, flat in the pattern's shape) and charge each node a
conservative upper bound: 48 KB for a Unicode-property or Perl class item, the
kinds that expand to hundreds of ranges, and 768 bytes for everything else.
Reject above 1 GiB. Classes nested in a bracketed class are charged too, else
`[\p{L}]` repeated evades the budget at the same cost.

This is additive. The byte limit stays, since it is what bounds the AST parse
this check needs, and its number stays valid in the user-facing docs. Patterns
that were cheap remain accepted: a 1 MiB pattern of plain literals, the shape a
machine-generated alternation takes, still compiles. A counted repetition needs
no budget of its own, since `\p{L}{200000}` stays two AST nodes and the NFA
compiler's incremental `size_limit` check already rejects it in 40 ms at 17 MB.

A pattern we cannot parse falls through to `RegexBuilder` so users keep its
error message. That is not an escape hatch: both parse through the same
`regex-syntax` 0.8.11 with the same default configuration, verified against 35
syntax corner cases to never split in the direction that would skip the check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant