Skip to content

Commit bfe711e

Browse files
ahrzbclaude
authored andcommitted
feat(specializer): two new fuzzer-found regex reject classes — leading-$ row-path bug + RE2 program-size budget
The standing fuzzer's 20k-case deep run on seed 20260728 caught: - Class 13: DuckDB's row path literal-optimizes a leading '$'+literal into a PREFIX match ('$hello' matches 'hello world') while its own constant fold matches normally — self-inconsistent, unservable. Reject: unescaped '$' outside a class not followed by end, '|', ')', '$', or an anchor escape. - Class 14: counted repetitions can blow RE2's POST-SIMPLIFICATION program budget ('(\p{L}){1,500}' is 'pattern too large' in DuckDB) where rust-regex still compiles. Guard: one-sided weight estimate (\p/\P = 800 range-units, classes = member count, literals 4; threshold 100k, below the measured ~216k error floor) — the engine may over-refuse but can never serve where DuckDB errors. Pins: pins-waveB/fuzzer-20260728.json. Doc + twin rows added. Gates: cargo 163, pytest 609 + 13 xfail, corpus 529 unchanged, 20k re-sweep on the finding seed divergence-free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2f067c0 commit bfe711e

4 files changed

Lines changed: 87 additions & 1 deletion

File tree

docs/known-limitations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ wrong answer or require semantics we can't reproduce exactly:
9292
| `^` operator | It IS pow in DuckDB, but sqlparser's precedence differs from DuckDB's (`2*x^y` would parse as `(2*x)^y`). Mapping it computes the wrong tree silently. Use `pow()`. |
9393
| prefix `~`, `#`, `NOT GLOB` | Same class: precedence/parse divergences that would silently mis-associate. `xor()` covers bit-xor; `NOT (x GLOB p)` works. |
9494
| Regex reject list: `\B`, `\Q…\E`, `(?<name>…)`, duplicate group names, bounds > 1000, stacked quantifiers (`a*+`), `\u` escapes, negated Perl classes inside `[...]` | The RE2↔rust-regex differential battery (98 entries) proved these are the constructs where the engines disagree or DuckDB itself is broken (`\B` crashes DuckDB at runtime on non-ASCII). Everything else is byte-identical. |
95-
| Fuzzer-found regex rejects (TASK-54): `\1``\9` backrefs outside classes, the full stacked-quantifier grammar (`{2}*`, `?*`, `a???` — one lazy `?` is the only legal follower), nested repetition products > 1000, whitespace inside `{m, n}` bounds, class set-op lookalikes (`--`/`&&`/`~~`), non-POSIX `[` inside classes, Perl-class range endpoints (`[a-\d]`), capturing `(x){0}`, anchor-only multi-anchor patterns (DuckDB is SELF-inconsistent on these — its row path disagrees with its own constant fold) | The standing differential fuzzer (`tests/test_duckdb_regexp_fuzz.py`, in the normal gate) found these 12 classes in its first 3k-case deep run — each one a silent-wrong-answer risk in rust-regex — then re-swept to ZERO divergences over 40k cases across 8 seeds. Pins: `pins-waveB/fuzzer-task54.json`. |
95+
| Fuzzer-found regex rejects (TASK-54): `\1`–`\9` backrefs outside classes, the full stacked-quantifier grammar (`{2}*`, `?*`, `a???` — one lazy `?` is the only legal follower), nested repetition products > 1000, whitespace inside `{m, n}` bounds, class set-op lookalikes (`--`/`&&`/`~~`), non-POSIX `[` inside classes, Perl-class range endpoints (`[a-\d]`), capturing `(x){0}`, anchor-only multi-anchor patterns (DuckDB is SELF-inconsistent on these — its row path disagrees with its own constant fold), `$` anchors in non-final position (`'$hello'` — DuckDB's row path literal-optimizes the leading `$`+literal into a PREFIX match, matching "hello world", while its own constant fold matches normally; found by the standing fuzzer on seed 20260728), and counted repetitions over RE2's PROGRAM-SIZE budget (`(\p{L}){1,500}` is "pattern too large" in DuckDB while rust-regex serves it — rejected via a one-sided weight estimate that always fires before DuckDB's real budget; same seed, pins `pins-waveB/fuzzer-20260728.json`) | The standing differential fuzzer (`tests/test_duckdb_regexp_fuzz.py`, in the normal gate) found these 12 classes in its first 3k-case deep run — each one a silent-wrong-answer risk in rust-regex — then re-swept to ZERO divergences over 40k cases across 8 seeds. Pins: `pins-waveB/fuzzer-task54.json`. |
9696
| `SIMILAR TO ... ESCAPE` | Not implemented in DuckDB itself. |
9797
| `* EXCLUDE (t.key)` on a USING join | DuckDB UNMERGES the coalesced column (it reappears at the right table's position) — measured, not modeled. Unqualified EXCLUDE works. |
9898
| `BETWEEN`/`IN` mixing non-numeric string literals with numbers | DuckDB converts at EXECUTION time (an empty input succeeds!); a bind-time conversion was measured to be over-eager. Numeric literals convert fine. |
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"topic": "fuzzer deep-run classes, seed 20260728 (overnight run, TASK-56 branch)",
3+
"duckdb_version": "1.5.5",
4+
"pins": [
5+
{
6+
"q": "SELECT regexp_matches(s, '$h') FROM t -- s='hello world'",
7+
"result": "row path TRUE, constant fold FALSE",
8+
"note": "Class 13: DuckDB SELF-inconsistent on a leading '$' anchor followed by a literal. Its row path literal-optimizes '$'+literal into a PREFIX match: '$hello' matches 'hello world', '$b' matches 'b' (but not 'ab'); the constant path matches normally (never). Consistent (both False/True) for: 'h$', '^h', 'x^h', 'a$|b', '(h$)x', '($)h', 'a$b*', '$h*', '$[hb]', '$(h)', '(?i)$H', '[$]h', '\\\\$h', bare '$'. Reject: unescaped '$' outside a class not followed by end, '|', ')', '$', or an anchor escape (\\A \\z \\b \\B). Found: fuzz case 4275."
9+
},
10+
{
11+
"q": "SELECT regexp_full_match(s, '\\S*([^\\wé-üa]{0}.|(?m)\\p{L}){1,1000}ab')",
12+
"result": "DuckDB: Invalid Input Error: pattern too large - compile failed; rust-regex: compiles and serves",
13+
"note": "Class 14: RE2 program-size budget. Boundary probes: (\\p{L}){1,300} ok, {1,500} ERR; (\\p{Nd}){1,1000} ok; (\\d){1,1000} ok; ([^\\w]){1,1000} ok; ([é-ü]){1,1000} ok; (.|\\p{L}){1,1000} ok (RE2 SIMPLIFIES the alternation to '.'). The budget is on the POST-SIMPLIFICATION program (range units), so exact modeling is impossible; the engine uses a one-sided over-estimate (\\p/\\P = 800 units >= any property's range count, classes = member count, literals 4) and rejects counted repetitions whose weight product clears 100_000 — below the measured error floor (~216k units), so the engine may over-refuse but can never serve where DuckDB errors. Found: fuzz case 17388."
14+
}
15+
],
16+
"summary": "Two new reject classes from the 20k-case deep run on seed 20260728: (13) leading-'$'-then-atom patterns are DuckDB-self-inconsistent (row-path prefix optimization bug) and unservable in principle; (14) counted repetitions can blow RE2's post-simplification program budget where rust-regex still compiles — guarded by a conservative one-sided weight estimate. After both rejects the full 20k sweep is divergence-free."
17+
}

src/specializer/retrans.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ pub fn translate_pattern(p: &str) -> Result<String, PrepareError> {
147147
// Character-class member tracking for range-endpoint rejects.
148148
let mut class_members = 0usize;
149149
let mut rangey_dash = false; // last member was a bare '-' with a left operand
150+
// RE2 program-size budget (fuzzer 2026-07-28 seed 20260728: DuckDB
151+
// errors "pattern too large" on '(\p{L}){1,500}' while rust serves).
152+
// One-sided over-estimate in "range units": \p/\P weigh 800 (above any
153+
// property's real range count), classes their member count, literals a
154+
// flat 4; a counted repetition whose weight product clears 100_000 is
155+
// rejected — always BEFORE DuckDB's real budget (measured error floor
156+
// ~216k units), so we can refuse but never serve where DuckDB errors.
157+
let mut weights: Vec<u64> = Vec::new(); // per open group
158+
let mut cur_weight: u64 = 0;
159+
let mut last_weight: u64 = 4;
160+
let mut class_p: u64 = 0; // \p/\P weight inside the open class
150161
while i < b.len() {
151162
let c = b[i];
152163
match c {
@@ -220,6 +231,13 @@ pub fn translate_pattern(p: &str) -> Result<String, PrepareError> {
220231
out.push(other as char);
221232
}
222233
}
234+
let w = if matches!(n, b'p' | b'P') { 800 } else { 8 };
235+
if in_class {
236+
class_p += w;
237+
} else {
238+
last_weight = w;
239+
cur_weight = cur_weight.saturating_add(w);
240+
}
223241
i += 2;
224242
quant = 0;
225243
class_members += 1;
@@ -274,6 +292,9 @@ pub fn translate_pattern(p: &str) -> Result<String, PrepareError> {
274292
out.push(']');
275293
i += 1;
276294
quant = 0;
295+
last_weight = 2 + 2 * class_members as u64 + class_p;
296+
cur_weight = cur_weight.saturating_add(last_weight);
297+
class_p = 0;
277298
}
278299
b'&' | b'~' | b'-' if in_class && b.get(i + 1) == Some(&c) => {
279300
// '--' / '&&' / '~~' in a class: literals-or-ranges to RE2,
@@ -289,6 +310,30 @@ pub fn translate_pattern(p: &str) -> Result<String, PrepareError> {
289310
out.push('-');
290311
i += 1;
291312
}
313+
b'$' if !in_class => {
314+
// A '$' anchor in NON-final position (not before '|', ')',
315+
// or the end): DuckDB's row path literal-optimizes a
316+
// leading '$'+literal into a PREFIX match ('$hello' matches
317+
// 'hello world'!) while its own constant fold matches
318+
// normally — self-inconsistent, unservable (fuzzer-measured
319+
// 2026-07-28, seed 20260728 case 4275).
320+
let benign = match b.get(i + 1) {
321+
None | Some(b'|') | Some(b')') | Some(b'$') => true,
322+
// Another anchor right after keeps both paths agreeing
323+
// (a '$$h' chain still rejects at ITS last '$').
324+
Some(b'\\') => matches!(b.get(i + 2), Some(b'A' | b'z' | b'b' | b'B')),
325+
_ => false,
326+
};
327+
if !benign {
328+
return Err(unsup(
329+
"regex '$' anchor in non-final position (DuckDB's row \
330+
path diverges from its own constant fold)",
331+
));
332+
}
333+
out.push('$');
334+
i += 1;
335+
quant = 0;
336+
}
292337
b'(' if !in_class => {
293338
// (?<name>...) angle-bracket groups: DuckDB rejects them.
294339
if p[i..].starts_with("(?<") && !p[i..].starts_with("(?<=") {
@@ -298,6 +343,8 @@ pub fn translate_pattern(p: &str) -> Result<String, PrepareError> {
298343
b.get(i + 1) != Some(&b'?') || p[i..].starts_with("(?P<");
299344
groups.push((counted, capturing));
300345
counted = 0;
346+
weights.push(cur_weight);
347+
cur_weight = 0;
301348
out.push('(');
302349
i += 1;
303350
quant = 0;
@@ -329,6 +376,9 @@ pub fn translate_pattern(p: &str) -> Result<String, PrepareError> {
329376
} else {
330377
counted = counted.max(inner);
331378
}
379+
// The group is one atom for a following {m,n}'s budget.
380+
last_weight = cur_weight.max(1);
381+
cur_weight = weights.pop().unwrap_or(0).saturating_add(last_weight);
332382
out.push(')');
333383
i += 1;
334384
quant = 0;
@@ -347,6 +397,14 @@ pub fn translate_pattern(p: &str) -> Result<String, PrepareError> {
347397
)));
348398
}
349399
counted = counted.max(prod.min(1001));
400+
if last_weight.saturating_mul(max.max(1)) > 100_000 {
401+
return Err(unsup(
402+
"counted repetition over RE2's program-size budget \
403+
(pattern too large in DuckDB)",
404+
));
405+
}
406+
cur_weight =
407+
cur_weight.saturating_add(last_weight.saturating_mul(max.max(1)));
350408
out.push_str(&p[i..end]);
351409
i = end;
352410
quant = 1;
@@ -396,6 +454,10 @@ pub fn translate_pattern(p: &str) -> Result<String, PrepareError> {
396454
quant = 0;
397455
class_members += 1;
398456
rangey_dash = false;
457+
if !in_class {
458+
last_weight = 4;
459+
cur_weight = cur_weight.saturating_add(4);
460+
}
399461
}
400462
}
401463
}

tests/test_known_limitations.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,13 @@ def test_ubigint_static_payloads_reject():
186186
("SELECT regexp_matches(s, '[a-\\d]') FROM __THIS__", "Perl class endpoint"),
187187
("SELECT regexp_matches(s, '(x){0}') FROM __THIS__", "unsupported"),
188188
("SELECT regexp_matches(s, '^$') FROM __THIS__", "anchor-only"),
189+
# Seed-20260728 fuzzer classes: leading-$ prefix optimization bug;
190+
# RE2 program-size budget ('pattern too large' in DuckDB).
191+
("SELECT regexp_matches(s, '$h') FROM __THIS__", "non-final position"),
192+
(
193+
"SELECT regexp_matches(s, '(\\p{L}){1,500}') FROM __THIS__",
194+
"program-size budget",
195+
),
189196
# Not implemented in DuckDB itself.
190197
("SELECT s SIMILAR TO 'a' ESCAPE 'x' FROM __THIS__", "escape"),
191198
# Exec-time conversion order (an empty input succeeds in DuckDB).

0 commit comments

Comments
 (0)