From 89acbd028c9ebfd13f7f7bb4d75917052171b48d Mon Sep 17 00:00:00 2001 From: AmirHossein Roozbahani Date: Mon, 27 Jul 2026 03:59:36 +0200 Subject: [PATCH] feat(specializer): standing differential regexp fuzzer + 12 new reject classes from its first deep run (TASK-54) Grammar-biased duckdb-vs-engine fuzzer in the normal gate (~4s, fixed seed, env-overridable). First deep run: 122 divergences in 3k cases; after the retrans.rs fixes (reject list + POSIX class tracker + rewrite MaxSubmatch pre-scan), zero divergences over 40k cases / 8 seeds. Pins: pins-waveB/fuzzer-task54.json + spec addendum. Co-Authored-By: Claude Fable 5 --- ...erential-regexp-fuzzer-duckdb-vs-engine.md | 74 ++++ .../specs/2026-07-27-waveB-regexp-pins.md | 38 ++ .../specs/pins-waveB/fuzzer-task54.json | 101 ++++++ src/specializer/retrans.rs | 339 ++++++++++++++++-- tests/test_duckdb_regexp_fuzz.py | 258 +++++++++++++ 5 files changed, 789 insertions(+), 21 deletions(-) create mode 100644 backlog/tasks/task-54 - Specializer-standing-differential-regexp-fuzzer-duckdb-vs-engine.md create mode 100644 docs/superpowers/specs/pins-waveB/fuzzer-task54.json create mode 100644 tests/test_duckdb_regexp_fuzz.py diff --git a/backlog/tasks/task-54 - Specializer-standing-differential-regexp-fuzzer-duckdb-vs-engine.md b/backlog/tasks/task-54 - Specializer-standing-differential-regexp-fuzzer-duckdb-vs-engine.md new file mode 100644 index 0000000..621f423 --- /dev/null +++ b/backlog/tasks/task-54 - Specializer-standing-differential-regexp-fuzzer-duckdb-vs-engine.md @@ -0,0 +1,74 @@ +--- +id: TASK-54 +title: >- + Specializer standing differential regexp fuzzer — grammar-biased duckdb vs + engine parity +status: Done +assignee: [] +created_date: '2026-07-27 12:00' +updated_date: '2026-07-27 14:30' +labels: [] +milestone: m-7 +dependencies: + - TASK-53 +priority: medium +type: chore +ordinal: 48000 +--- + +## Description + + +Wave B (TASK-53) shipped the regexp family on a bind-time RE2→rust-regex +translation with a measured reject list (pins: +docs/superpowers/specs/2026-07-27-waveB-regexp-pins.md). The one-time +differential battery had 98 entries; the residual risk is untested constructs +slipping through translate_pattern's pass-through path and serving wrong +answers. + +Build a STANDING differential fuzzer: a pytest (bounded to ~2-5s, in the +normal gate) that generates random patterns from a grammar biased toward the +divergence-prone axes — Perl classes in/out of char classes, inline flags, +alternation, bounded repetition incl. the 1000 cap edge, escapes +(octal/hex/\Q\E/\u), Unicode properties, char-class edge shapes (POSIX +elements, rust set-notation `&&`/`--`/nested `[..]`), options strings, +replacement templates — and runs each against BOTH duckdb +(regexp_matches/extract/replace) and the engine (DuckDBInferFn on a tiny +table). + +Per-case contract: identical rows, OR the engine rejected at build time +(conservative), OR both engines errored. duck-errors-while-engine-serves is +always a failure. Deterministic fixed seed, env-overridable +(REGEXP_FUZZ_SEED / REGEXP_FUZZ_N) for exploratory deep runs; failure +messages carry seed + case index + SQL for direct reproduction. + +Repo process: any divergence found → the construct goes on the retrans.rs +reject list + a pin note in the wave-B spec addendum (pins discipline, +decision: never a wrong answer). + + +## Acceptance Criteria + +- [x] #1 Fuzzer test committed (tests/test_duckdb_regexp_fuzz.py): deterministic default seed, REGEXP_FUZZ_SEED/REGEXP_FUZZ_N overrides, bounded to ~2-5s in the normal gate +- [x] #2 Generator covers the divergence-prone axes: Perl classes in/out of classes, inline flags, alternation, bounded repetition (1000 cap edge), escapes, Unicode properties, char-class edge shapes, options strings, replacement templates +- [x] #3 Contract asserted per case: identical multiset of rows, or engine build-time rejection, or both error; duck-ok-engine-wrong and duck-err-engine-serves both fail with a reproducible message +- [x] #4 Exploratory deep run (≥20k cases across seeds) executed before landing; every divergence found lands on the retrans.rs reject list with a pin note in the spec addendum +- [x] #5 Gate green (cargo + pytest), clippy clean if Rust touched + + +## Final Summary + + +Standing fuzzer landed (tests/test_duckdb_regexp_fuzz.py, N=250 default ~4s +in the normal gate, seed fixed at 20260727, REGEXP_FUZZ_SEED/REGEXP_FUZZ_N +overrides, failure messages carry seed+case+SQL). First deep run found 12 +divergence classes the 98-entry battery missed — including three silent +wrong-answer shapes (class set-notation `--`/`&&`/`~~`, spaced bounds +`{1, 3}`, nested-class `[`) and one DuckDB self-inconsistency (anchor-only +patterns: row path literal-optimizes `$\z` to string equality while the +constant fold matches). All fixed in retrans.rs (reject list + POSIX tracker +fix + rewrite MaxSubmatch pre-scan) and pinned: +docs/superpowers/specs/pins-waveB/fuzzer-task54.json + spec addendum. +Re-swept to ZERO divergences over 40k cases / 8 seeds. Gate: 155 Rust + 802 +py green, clippy clean on retrans.rs. + diff --git a/docs/superpowers/specs/2026-07-27-waveB-regexp-pins.md b/docs/superpowers/specs/2026-07-27-waveB-regexp-pins.md index d8eb58c..68c430e 100644 --- a/docs/superpowers/specs/2026-07-27-waveB-regexp-pins.md +++ b/docs/superpowers/specs/2026-07-27-waveB-regexp-pins.md @@ -113,6 +113,44 @@ EXCLUDE-then-filter only. COLUMNS('re') in scope: SELECT-list expansion (incl. inside an expression, zipped per-column); COLUMNS in WHERE (AND-conjunction) and multi-set expressions stay unsupported. +## Standing fuzzer addendum (TASK-54, pins-waveB/fuzzer-task54.json) + +The one-time 98-entry battery left the pass-through path unguarded; the +standing differential fuzzer (tests/test_duckdb_regexp_fuzz.py, in the normal +gate) found 12 further divergence classes in its first deep run — each now on +the reject list or fixed, re-swept to ZERO divergences over 40k cases / 8 +seeds. Headlines (witnesses + measured outputs in the JSON): + +- `\1`-`\9` outside a class: RE2 backref-reject vs rust octal-mode serve — + the pinned `octal(true)` cuts both ways. Reject outside; in-class stays + (octal in both). +- Stacked quantifiers are a GRAMMAR, not a pair list: one lazy `?` is the + only legal follower; `{2}*`, `?*`, `*{2}`, `a???` all serve wrongly in + rust. Rejected via a quantifier-state machine. +- RE2 caps NESTED counted-repetition bound products at 1000 + ("invalid repetition size"); reject past the product, unbounded inner + counts as over-cap. +- `{1, 3}` with whitespace: literal to RE2, a repetition to rust — silent + wrong answers. Reject. +- Character classes are the minefield: `--`/`&&`/`~~` (rust set operations), + non-POSIX `[` (rust nested classes), Perl-class range endpoints, and + ranges starting at the class-leading `]` all reject; POSIX `[:...:]` + elements now consume atomically (the tracker used to desync and + mis-rewrite `[[:alpha:]\d]`). +- Anchor-only patterns (2+ text anchors, only flag/`(?:)` noise): DuckDB is + SELF-inconsistent — row path literal-optimizes `'$\z'` to string equality + (FALSE for 'hello') while its constant fold says TRUE. Unservable; reject. +- `(x){0}`: rust erases the capture group, shifting the group model the + rewrite quirks key off. Reject capturing-{0}. +- Rewrite templates: RE2's MaxSubmatch pre-check outranks the bad-escape + consume quirk even when the bad escape comes FIRST — translate_rewrite now + pre-scans the whole template. + +Fuzzer contract per case: identical rows, or engine build-time reject, or +both error; duck-errors-engine-serves and mismatches fail with seed + case +index + SQL in the message. `REGEXP_FUZZ_SEED` / `REGEXP_FUZZ_N` override the +fixed defaults for deep runs. + ## Implementation stages (each lands with tests + corpus replay green) 1. Regex infrastructure: `regex = "1"` dependency; translation module diff --git a/docs/superpowers/specs/pins-waveB/fuzzer-task54.json b/docs/superpowers/specs/pins-waveB/fuzzer-task54.json new file mode 100644 index 0000000..729237a --- /dev/null +++ b/docs/superpowers/specs/pins-waveB/fuzzer-task54.json @@ -0,0 +1,101 @@ +{ + "meta": { + "task": "TASK-54 standing differential regexp fuzzer", + "measured": "2026-07-27, DuckDB 1.5.5 vs the engine (rust regex 1.13 behind retrans.rs), tests/test_duckdb_regexp_fuzz.py grammar", + "method": "random grammar-biased patterns run through regexp_matches/full_match/extract/replace on both engines over the divergence-prone subject rows; every class below reproduced from a concrete generated case, then re-swept to zero divergences (40k cases, 8 seeds) after the fix", + "contract": "identical rows OR engine build-time reject OR both error; duck-errors-engine-serves and value mismatches are failures" + }, + "findings": [ + { + "construct": "\\1-\\9 outside a character class", + "witness": "regexp_full_match(s, '\\1((?m)\\W\u06639|...)a?')", + "duckdb": "Invalid Input Error: invalid escape sequence: \\1 (RE2 has no backrefs)", + "rust_regex": "octal(true) — pinned for in-class octal parity — reads \\1 as an octal escape and SERVES", + "decision": "reject at bind ('backreference-style \\N escape'); in-class \\1 stays served (octal in both)" + }, + { + "construct": "stacked quantifiers, full grammar (not just */+ pairs)", + "witness": "'z{3,}+' 'a.{2}*' 'h??*' 'X?*' 'a{1}+' '(?:..)*{2}' '\\pL{0,}+'", + "duckdb": "Invalid Input Error: bad repetition operator (one lazy '?' is the only legal follower of a quantifier)", + "rust_regex": "silently reinterprets and serves", + "decision": "reject: after a quantifier and optional single lazy '?', any further * + ? or strict {bounds} is an error" + }, + { + "construct": "nested counted repetition, bound product over 1000", + "witness": "regexp_matches(s, '(?:^(c{3,}\\dz0){1,999}\\p{L}+?\\D)|..')", + "duckdb": "Invalid Input Error: invalid repetition size: {1,999}", + "rust_regex": "no product cap at these sizes — serves", + "decision": "reject when nested {m,n} bound products exceed 1000; unbounded inner {n,} counts as over-cap (conservative: '(a{2,}){3}' rejects)" + }, + { + "construct": "whitespace inside repetition bounds", + "witness": "regexp_replace(s, '.*a{1, 3}\\D*', '') on 'abc123def'", + "duckdb": "'{1, 3}' is LITERAL (no match, input unchanged)", + "rust_regex": "parses it as the 1..3 repetition and replaces ('123def') — silent wrong answer", + "decision": "reject '{...}' bodies that are strict bounds after whitespace stripping but contain whitespace" + }, + { + "construct": "'--' / '&&' / '~~' inside a character class", + "witness": "regexp_replace(s, '[a-fz\\x41][--\u0660-\u0669]{0,}', '\\\\') ; regexp_matches(s, '[c\\s&&][^\u0660-\u0669~~\\052]')", + "duckdb": "literals / ranges ('[--\u0660]' = range '-'..'\u0660'; backwards ones error 'invalid character class range: h--')", + "rust_regex": "class set operations (difference/intersection/symmetric difference) — silent wrong answers measured both directions", + "decision": "reject any doubled '-' '&' '~' inside a class" + }, + { + "construct": "non-POSIX '[' inside a character class", + "witness": "regexp_replace(s, '..|[]a-f[b]]\\S\\A{0,}', '$1')", + "duckdb": "'[' is a literal class member", + "rust_regex": "opens a NESTED class — silent wrong answers", + "decision": "reject '[' inside a class unless it starts a '[:name:]' POSIX element" + }, + { + "construct": "POSIX element desyncing the class tracker", + "witness": "translate_pattern('[[:alpha:]\\d]') pre-fix emitted '[[:alpha:](?-u:\\d)]'", + "duckdb": "'[[:alpha:]\\d]' = alpha \u222a digits", + "rust_regex": "same semantics once the element is consumed atomically", + "decision": "FIX (not reject): consume '[:...:]' atomically; '[[:alpha:]\\d]' now translates to '[[:alpha:]0-9]'" + }, + { + "construct": "Perl class as a range endpoint inside a class", + "witness": "regexp_extract(s, '..[--\\s[:^digit:]]..', 3, 'i') ; shapes '[a-\\d]' '[\\d-a]' '[\\x08-\\s]'", + "duckdb": "Invalid Input Error: invalid escape sequence / invalid character class range", + "rust_regex": "the in-class expansion can COMPILE (e.g. '[\\x08-\\t\\n..]') and serve", + "decision": "reject a Perl class escape adjacent to an unescaped range '-' on either side; trailing literal '-' ('[\\d-]') stays fine" + }, + { + "construct": "range starting at the class-leading literal ']'", + "witness": "regexp_full_match(s, '[]-Z]\\w^h{999}') ; '[^]-0-7]'", + "duckdb": "']-Z' parses as a RANGE (backwards ones error: 'invalid character class range: ]-Z')", + "rust_regex": "treats the '-' as literal and serves; forward ranges ('[]-a]') would silently disagree on membership", + "decision": "reject '-' directly after the class-leading ']' unless immediately before ']' ('[]-]' stays fine)" + }, + { + "construct": "anchor-only pattern with 2+ text anchors", + "witness": "regexp_matches(s, '$\\z(?i)') over __THIS__ vs as a constant", + "duckdb": "SELF-INCONSISTENT (measured): row path FALSE for 'hello world' / TRUE only for '' (literal-comparison optimization); constant fold TRUE everywhere. 'd$\\z', '($\\z)', 'x|$\\z', '^()^' are all rescued and consistent", + "rust_regex": "matches the empty string at the anchors (agrees with duckdb's constant fold only)", + "decision": "reject patterns consisting only of ^ $ \\A \\z, \\b, flag groups, and empty '(?:)' when they contain 2+ text anchors — unservable either way" + }, + { + "construct": "capture group under an exactly-zero repetition", + "witness": "regexp_replace(s, '\\P{L}(\u00e9{1,999}\\x41+|\u212a*9?\u0663){0}', '$1-\\1')", + "duckdb": "keeps the group in the count: \\1 in range, non-participating -> '' ; replaces (' ' -> '$1-')", + "rust_regex": "erases the group from the compiled regex — the rewrite's MaxSubmatch pre-check sees 0 groups and no-ops; later group numbers would shift too", + "decision": "reject a capturing group followed by {0}/{0,0}; '(?:a){0}' and 'a{0}' stay fine" + }, + { + "construct": "rewrite template: out-of-range backref AFTER a bad escape", + "witness": "regexp_replace(s, '..|.', '\\x\\x\\2', 'ccg') with 0 groups", + "duckdb": "input UNCHANGED — RE2's MaxSubmatch pre-check scans the whole template first, out-of-range wins over the global bad-escape consume quirk", + "rust_regex": "n/a (bind-time template translation ordered the checks left-to-right)", + "decision": "FIX: translate_rewrite pre-scans the whole template for out-of-range \\N before the bad-escape scan" + }, + { + "construct": "\\C (any byte)", + "witness": "regexp_replace over 'h\u00e9llo \u0663\u0664' produced invalid UTF-8 (pyarrow decode failure on the ORACLE side)", + "duckdb": "serves raw bytes that can split multibyte chars", + "rust_regex": "does not support \\C — engine build-rejects via the compile error", + "decision": "no change (already an identical-reject); fuzzer harness treats undecodable duckdb output as a duckdb error" + } + ] +} diff --git a/src/specializer/retrans.rs b/src/specializer/retrans.rs index 96aee57..11919cc 100644 --- a/src/specializer/retrans.rs +++ b/src/specializer/retrans.rs @@ -51,13 +51,102 @@ pub fn parse_options(opts: &str, allow_g: bool) -> Result Option<(usize, u64, u64)> { + let rest = p.get(i + 1..)?; + let body = &rest[..rest.find('}')?]; + let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()); + let mut parts = body.splitn(2, ','); + let lo: u64 = parts.next().filter(|s| digits(s))?.parse().ok()?; + let (max, prod) = match parts.next() { + None => (lo, lo), + Some("") => (lo, u64::MAX), + Some(hi) if digits(hi) => { + let hi: u64 = hi.parse().ok()?; + (lo.max(hi), hi) + } + Some(_) => return None, + }; + Some((i + 1 + body.len() + 1, max, prod)) +} + +/// True if the pattern is nothing but text anchors (`^ $ \A \z`), `\b`, and +/// flag-group / empty `(?:)` noise, with at least two anchors. DuckDB's ROW +/// path literal-optimizes these into string equality ('' only) while its own +/// CONSTANT fold matches normally (fuzzer-measured: '$\z' over a table is +/// FALSE for 'hello' but TRUE as a constant) — unservable either way. +fn anchors_only_multi(p: &str) -> bool { + let b = p.as_bytes(); + let mut i = 0; + let mut anchors = 0; + while i < b.len() { + match b[i] { + b'^' | b'$' => { + anchors += 1; + i += 1; + } + b'\\' => match b.get(i + 1) { + Some(b'A') | Some(b'z') => { + anchors += 1; + i += 2; + } + Some(b'b') => i += 2, + _ => return false, + }, + b'(' if b.get(i + 1) == Some(&b'?') => { + i += 2; + if b.get(i) == Some(&b':') { + i += 1; // only the EMPTY (?:) counts as noise + } else { + while matches!( + b.get(i), + Some(b'i' | b'm' | b's' | b'u' | b'U' | b'x' | b'-') + ) { + i += 1; + } + } + if b.get(i) != Some(&b')') { + return false; + } + i += 1; + } + _ => return false, + } + } + anchors >= 2 +} + /// Rewrite a DuckDB/RE2 pattern into a rust-regex pattern with identical -/// semantics, or reject constructs on the measured divergence list. +/// semantics, or reject constructs on the measured divergence list (wave-B +/// pins + the TASK-54 standing fuzzer's findings). pub fn translate_pattern(p: &str) -> Result { + if anchors_only_multi(p) { + return Err(unsup( + "anchor-only regex pattern (DuckDB's row path diverges from its own \ + constant fold)", + )); + } let b = p.as_bytes(); let mut out = String::with_capacity(p.len() + 8); let mut i = 0; let mut in_class = false; + // Quantifier state (outside classes): 0 = none, 1 = after a quantifier, + // 2 = after quantifier + lazy '?'. RE2 rejects ALL further stacking + // ('a?*', 'a{2}+', 'a*{2}', 'a???') while rust silently reinterprets — + // wrong-answer risk, reject (fuzzer-measured, not just '*'/'+' pairs). + let mut quant: u8 = 0; + // Nested counted repetition: RE2 caps the PRODUCT of nested {m,n} bounds + // at 1000 ("invalid repetition size") while rust serves — track the max + // counted bound per open group (plus whether the group captures) and + // reject past the cap. + let mut groups: Vec<(u64, bool)> = Vec::new(); + let mut counted: u64 = 0; + // Character-class member tracking for range-endpoint rejects. + let mut class_members = 0usize; + let mut rangey_dash = false; // last member was a bare '-' with a left operand while i < b.len() { let c = b[i]; match c { @@ -69,6 +158,21 @@ pub fn translate_pattern(p: &str) -> Result { i += 1; continue; }; + let perl = matches!(n, b'd' | b'w' | b's'); + if in_class && perl { + // '[a-\d]': RE2 rejects a Perl-class range endpoint; the + // expansion could compile in rust — reject both sides. + if rangey_dash { + return Err(unsup( + "character class range with a Perl class endpoint", + )); + } + if b.get(i + 2) == Some(&b'-') && b.get(i + 3) != Some(&b']') { + return Err(unsup( + "character class range with a Perl class endpoint", + )); + } + } match n { // Perl classes: RE2 is ASCII, rust is Unicode (measured // rewrites; negated forms must be Unicode-mode classes, @@ -97,6 +201,14 @@ pub fn translate_pattern(p: &str) -> Result { // \B: DuckDB itself traps at runtime on non-ASCII (RE2's // ASCII \B matches inside multibyte chars) — unservable. b'B' => return Err(unsup("\\B in a regex (unservable RE2 byte semantics)")), + // \1-\9 outside a class: RE2 rejects them as backrefs, + // but octal mode makes rust read octal escapes — serve + // nothing (fuzzer-measured). In-class both are octal. + b'1'..=b'9' if !in_class => { + return Err(unsup( + "backreference-style \\N escape in a regex (RE2 rejects)", + )) + } // \uXXXX: rust-only extension, DuckDB rejects. b'u' | b'U' => { return Err(unsup("\\u escape in a regex (not RE2 syntax)")) @@ -109,6 +221,9 @@ pub fn translate_pattern(p: &str) -> Result { } } i += 2; + quant = 0; + class_members += 1; + rangey_dash = false; } b'[' if !in_class => { in_class = true; @@ -120,51 +235,154 @@ pub fn translate_pattern(p: &str) -> Result { out.push('^'); i += 1; } + class_members = 0; if b.get(i) == Some(&b']') { out.push(']'); i += 1; + class_members = 1; + // '[]-X]': a range with the literal ']' endpoint to RE2 + // (backwards ones error), a literal '-' to rust — reject + // unless the '-' is the trailing literal ('[]-]'). + if b.get(i) == Some(&b'-') && b.get(i + 1) != Some(&b']') { + return Err(unsup( + "character class range starting at a literal ']'", + )); + } + } + rangey_dash = false; + } + b'[' if in_class => { + // POSIX element ('[:alpha:]', '[:^digit:]'): same ASCII + // semantics in both engines — consume atomically so the + // class tracker stays in sync. Any other '[' in a class is + // literal to RE2 but a NESTED CLASS to rust — reject. + if p[i..].starts_with("[:") { + if let Some(end) = p[i..].find(":]") { + out.push_str(&p[i..i + end + 2]); + i += end + 2; + class_members += 1; + rangey_dash = false; + continue; + } } + return Err(unsup( + "'[' inside a character class (rust-regex nested-class semantics)", + )); } b']' if in_class => { in_class = false; out.push(']'); i += 1; + quant = 0; + } + b'&' | b'~' | b'-' if in_class && b.get(i + 1) == Some(&c) => { + // '--' / '&&' / '~~' in a class: literals-or-ranges to RE2, + // set operations to rust — silent wrong answers (measured). + return Err(unsup(format!( + "'{0}{0}' inside a character class (rust-regex set-operation syntax)", + c as char + ))); + } + b'-' if in_class => { + rangey_dash = class_members >= 1; + class_members += 1; + out.push('-'); + i += 1; } b'(' if !in_class => { // (?...) angle-bracket groups: DuckDB rejects them. if p[i..].starts_with("(?<") && !p[i..].starts_with("(?<=") { return Err(unsup("(?...) capture group (not RE2 syntax)")); } + let capturing = + b.get(i + 1) != Some(&b'?') || p[i..].starts_with("(?P<"); + groups.push((counted, capturing)); + counted = 0; out.push('('); i += 1; + quant = 0; + } + b')' if !in_class => { + let inner = counted; + let capturing; + (counted, capturing) = groups.pop().unwrap_or((0, false)); + if let Some((_, max, prod)) = strict_bounds(p, i + 1) + .filter(|_| b.get(i + 1) == Some(&b'{')) + { + // '(x){0}': RE2 keeps the group in its count while rust + // ERASES it, shifting every later group number and the + // rewrite's MaxSubmatch pre-check (fuzzer-measured). + if capturing && max == 0 { + return Err(unsup( + "capture group under a {0} repetition (rust-regex \ + erases the group)", + )); + } + if inner > 0 { + if inner.saturating_mul(prod) > 1000 { + return Err(unsup( + "nested counted repetition over RE2's size cap of 1000", + )); + } + counted = counted.max(inner.saturating_mul(prod)); + } + } else { + counted = counted.max(inner); + } + out.push(')'); + i += 1; + quant = 0; } b'{' if !in_class => { - // Repetition bound > 1000 is a DuckDB error; rust allows - // larger — reject past the pinned cap. - let close = b[i..].iter().position(|&x| x == b'}'); - if let Some(off) = close { - let body = &p[i + 1..i + off]; - let too_big = body - .split(',') - .filter_map(|s| s.trim().parse::().ok()) - .any(|n| n > 1000); - if too_big { + if let Some((end, max, prod)) = strict_bounds(p, i) { + if quant > 0 { + return Err(unsup("stacked regex quantifiers (RE2 rejects them)")); + } + // Repetition bound > 1000 is a DuckDB error; rust allows + // larger — reject past the pinned cap. + if max > 1000 { return Err(unsup(format!( - "regex repetition bound over 1000 ({{{body}}})" + "regex repetition bound over 1000 ({})", + &p[i..end] ))); } + counted = counted.max(prod.min(1001)); + out.push_str(&p[i..end]); + i = end; + quant = 1; + } else { + // Not strict bounds. Whitespace-padded digits ('{1, 3}') + // are LITERAL to RE2 but a repetition to rust — reject; + // anything else is literal '{' in both. + let body = match p[i + 1..].find('}') { + Some(e) => &p[i + 1..i + 1 + e], + None => "", + }; + let stripped: String = + body.chars().filter(|c| !c.is_whitespace()).collect(); + if stripped != body + && strict_bounds(&format!("{{{stripped}}}"), 0).is_some() + { + return Err(unsup(format!( + "whitespace inside repetition bounds ({{{body}}}) \ + (literal to RE2, a repetition to rust-regex)" + ))); + } + out.push('{'); + i += 1; + quant = 0; } - out.push('{'); - i += 1; } - b'*' | b'+' if !in_class => { - out.push(c as char); - i += 1; - // Stacked quantifiers (a*+, a++): DuckDB errors while rust - // silently reinterprets — a wrong-answer risk, reject. - if matches!(b.get(i), Some(b'*') | Some(b'+')) { + b'*' | b'+' | b'?' if !in_class => { + if c == b'?' && quant == 1 { + quant = 2; // lazy modifier — the one legal follower + } else if quant > 0 { return Err(unsup("stacked regex quantifiers (RE2 rejects them)")); + } else { + quant = 1; } + out.push(c as char); + i += 1; } _ => { // Preserve the raw byte run (UTF-8 safe: we only split at @@ -175,6 +393,9 @@ pub fn translate_pattern(p: &str) -> Result { i += 1; } out.push_str(&p[start..i]); + quant = 0; + class_members += 1; + rangey_dash = false; } } } @@ -216,6 +437,21 @@ pub enum Rewrite { pub fn translate_rewrite(r: &str, group_count: usize, global: bool) -> Rewrite { let b = r.as_bytes(); + // RE2's MaxSubmatch pre-check scans the WHOLE template before any + // per-match work (fuzzer-measured): an out-of-range \N anywhere is a + // full no-op even when a bad escape precedes it in the template. + let mut j = 0; + while j + 1 < b.len() { + if b[j] == b'\\' { + let d = b[j + 1]; + if d.is_ascii_digit() && (d - b'0') as usize > group_count { + return Rewrite::Identity; + } + j += 2; + } else { + j += 1; + } + } let mut out = String::with_capacity(r.len() + 4); let mut i = 0; while i < b.len() { @@ -289,6 +525,61 @@ mod tests { assert!(translate_pattern("a{1000}").is_ok()); } + #[test] + fn fuzzer_reject_list() { + // TASK-54 standing-fuzzer findings (spec addendum pins). + for p in [ + r"a\1", // RE2 backref reject vs rust octal-mode escape + "a?*", // stacked quantifiers beyond the */+ pairs + "a{2}+", + "a*{2}", + "a???", + "a{1, 3}", // spaced bounds: literal to RE2, repetition to rust + "(a{600}b){2}", // nested counted product over RE2's 1000 cap + "(a{2,}){3}", // unbounded inner counts as over-cap + "[a--b]", // rust class set operations vs RE2 literals/ranges + "[--0]", + "[a&&b]", + "[a~~b]", + "[a[b]]", // nested class in rust, literal '[' in RE2 + r"[a-\d]", // Perl-class range endpoint (RE2 rejects) + r"[\d-a]", + ] { + assert!(translate_pattern(p).is_err(), "{p} should reject"); + } + // Still-fine neighbors of the rejects. + assert_eq!(translate_pattern("a{2}?b").unwrap(), "a{2}?b"); + assert_eq!(translate_pattern("(a{2})b{3}").unwrap(), "(a{2})b{3}"); + assert_eq!(translate_pattern("(a{20}){50}").unwrap(), "(a{20}){50}"); + assert_eq!(translate_pattern("a{abc}").unwrap(), "a{abc}"); + assert_eq!(translate_pattern("[a-f-]").unwrap(), "[a-f-]"); + assert_eq!(translate_pattern(r"[-\d]").unwrap(), "[-0-9]"); + assert_eq!(translate_pattern(r"[\d-]").unwrap(), "[0-9-]"); + assert_eq!(translate_pattern(r"[\1]").unwrap(), r"[\1]"); // in-class octal + // POSIX elements pass through atomically — the tracker no longer + // desyncs, so a following Perl class still rewrites in-class. + assert_eq!(translate_pattern(r"[[:alpha:]\d]").unwrap(), "[[:alpha:]0-9]"); + assert_eq!(translate_pattern("[[:^digit:]x]").unwrap(), "[[:^digit:]x]"); + // Leading-']' ranges; the trailing-'-' literal form stays fine. + assert!(translate_pattern("[]-Z]").is_err()); + assert!(translate_pattern("[^]-0-7]").is_err()); + assert_eq!(translate_pattern("[]-]").unwrap(), "[]-]"); + // Anchor-only multi-anchor patterns (DuckDB row/const divergence); + // a consuming atom or a capture group rescues them. + for p in [r"$\z", "^^", r"\A\A", r"(?i)$\z", "^(?:)^", r"^\b$"] { + assert!(translate_pattern(p).is_err(), "{p} should reject"); + } + for p in [r"d$\z", "^", r"\z", r"($\z)", r"x|$\z", "^()^"] { + assert!(translate_pattern(p).is_ok(), "{p} should serve"); + } + // '(x){0}' erases the capture group in rust, shifting group numbers. + assert!(translate_pattern("(a){0}").is_err()); + assert!(translate_pattern("(?Pa){0,0}").is_err()); + assert!(translate_pattern("(?:a){0}").is_ok()); + assert!(translate_pattern("(a){0,1}").is_ok()); + assert!(translate_pattern("a{0}").is_ok()); + } + #[test] fn options_alphabet() { let o = parse_options("gi", true).unwrap(); @@ -312,8 +603,14 @@ mod tests { translate_rewrite("a$b", 0, false), Rewrite::Template(t) if t == "a$$b" )); - // Out-of-range backref: full no-op both modes. + // Out-of-range backref: full no-op both modes — even AFTER a bad + // escape (MaxSubmatch pre-scans the whole template). assert!(matches!(translate_rewrite(r"\2", 1, true), Rewrite::Identity)); + assert!(matches!(translate_rewrite(r"\x\2", 0, true), Rewrite::Identity)); + assert!(matches!( + translate_rewrite(r"\\2", 0, true), + Rewrite::Template(t) if t == r"\2" + )); // Bad escape: no-op non-global, consume-with-prefix global. assert!(matches!(translate_rewrite(r"A\xB", 0, false), Rewrite::Identity)); assert!(matches!( diff --git a/tests/test_duckdb_regexp_fuzz.py b/tests/test_duckdb_regexp_fuzz.py new file mode 100644 index 0000000..0a915bf --- /dev/null +++ b/tests/test_duckdb_regexp_fuzz.py @@ -0,0 +1,258 @@ +"""Standing differential regexp fuzzer: duckdb vs the engine (TASK-54). + +Wave B (TASK-53) translated DuckDB/RE2 patterns to rust-regex behind a +measured reject list (docs/superpowers/specs/2026-07-27-waveB-regexp-pins.md). +The one-time battery had 98 entries; the residual risk is constructs slipping +through translate_pattern's pass-through path. This test generates patterns +from a grammar biased toward the divergence-prone axes (Perl classes in/out +of char classes, inline flags, alternation, bounded repetition incl. the 1000 +cap, escapes, Unicode properties, char-class edge shapes like POSIX elements +and rust set-notation, options strings, replacement templates) and asserts, +per case: + + - duckdb ok + engine ok -> identical multiset of rows + - duckdb ok + engine rejects -> fine (conservative bind-time reject) + - duckdb err + engine rejects -> fine (identically rejected) + - duckdb err + engine serves -> FAIL (wrong-answer risk) + +A failure = a construct for the retrans.rs reject list + a pin note in the +spec addendum (pins discipline). Deterministic: fixed default seed; override +with REGEXP_FUZZ_SEED / REGEXP_FUZZ_N for exploratory deep runs. The failure +message carries seed, case index, and the SQL — rerun with that seed to +reproduce. +""" + +from __future__ import annotations + +import os +import random +from collections import Counter + +import duckdb +from test_duckdb_interpreter import _row_model, static + +from sql_transform._interpreter import DuckDBInferFn + +SEED = int(os.environ.get("REGEXP_FUZZ_SEED", "20260727")) +# ~15ms/case (engine bind dominates); 250 lands in the ~2-5s budget. +N = int(os.environ.get("REGEXP_FUZZ_N", "250")) + +SCHEMA = {"a": "int", "s": "str?"} +# Divergence-prone subjects: ASCII words/digits, Unicode digits (٣٤), accents, +# (?i)-fold traps (KELVIN K, sharp-s), newlines/tabs for (?m)/(?s)/\s, empty, +# NULL, and class-metachar literals (&, -, [, ]). +ROWS = [ + {"a": 1, "s": "hello world"}, + {"a": 2, "s": "abc123def"}, + {"a": 3, "s": None}, + {"a": 4, "s": ""}, + {"a": 5, "s": "héllo ٣٤"}, + {"a": 6, "s": "HELLO K ß"}, + {"a": 7, "s": "a\nb\tc d"}, + {"a": 8, "s": "x&&y--z[a]"}, +] + +_LITS = "abczXZ019 h" +_ULITS = "éß٣K" # é, sharp-s, arabic-indic 3, KELVIN sign +_PERL = [r"\d", r"\D", r"\w", r"\W", r"\s", r"\S"] +_PROPS = [r"\p{L}", r"\p{Lu}", r"\p{Nd}", r"\P{L}", r"\pL"] +_ESCAPES = [r"\n", r"\t", r"\x41", r"\x{212A}", r"\052", r"\.", r"\*", r"\-", r"\&"] +_ANCHORS = ["^", "$", r"\A", r"\z", r"\b", r"\B"] +_FLAGS = ["(?i)", "(?s)", "(?m)", "(?U)", "(?-i)", "(?is)"] +# Mostly reject-or-both-error shapes: keep them flowing through the contract. +_WEIRD = [ + r"\Qa.\E", + r"A", + r"\C", + r"\1", + r"\Z", + "a{2,1}", + "a{1, 3}", + "a{,3}", + "{2}", + "*", +] +_CLASS_RANGES = ["a-f", "0-7", "A-Z", "٠-٩", "é-ü"] +# rust-regex set notation / POSIX / nesting — literal chars to RE2. +_CLASS_EDGES = [ + "[:alpha:]", + "[:^digit:]", + "[", + "[b]", + "&&", + "--", + "~~", + "-", + r"\]", + r"\b", +] + + +def _class(rng: random.Random) -> str: + body = "" + if rng.random() < 0.3: + body += "^" + if rng.random() < 0.15: + body += "]" + for _ in range(rng.randint(1, 3)): + r = rng.random() + if r < 0.30: + body += rng.choice(_LITS.strip() + _ULITS) + elif r < 0.50: + body += rng.choice(_CLASS_RANGES) + elif r < 0.70: + body += rng.choice(_PERL) + elif r < 0.85: + body += rng.choice(_CLASS_EDGES) + else: + body += rng.choice(_ESCAPES + [r"\p{L}"]) + return f"[{body}]" + + +def _atom(rng: random.Random, depth: int) -> str: + r = rng.random() + if r < 0.25: + return rng.choice(_LITS) + if r < 0.33: + return rng.choice(_ULITS) + if r < 0.45: + return rng.choice(_PERL) + if r < 0.52: + return rng.choice(_PROPS) + if r < 0.57: + return rng.choice(_ESCAPES) + if r < 0.62: + return rng.choice(_ANCHORS) + if r < 0.67: + return "." + if r < 0.79: + return _class(rng) + if r < 0.83: + return rng.choice(_WEIRD) + if depth >= 2: + return rng.choice(_LITS) + inner = _pattern(rng, depth + 1) + kind = rng.random() + if kind < 0.4: + return f"({inner})" + if kind < 0.6: + return f"(?:{inner})" + if kind < 0.75: + return f"(?P{inner})" # dup names on purpose + if kind < 0.85: + return f"(?i:{inner})" + return f"(?{inner})" # angle group: pinned reject + + +def _quantified(rng: random.Random, depth: int) -> str: + a = _atom(rng, depth) + r = rng.random() + if r < 0.55: + return a + if r < 0.75: + q = rng.choice(["*", "+", "?", "*?", "+?", "??"]) + elif r < 0.9: + lo = rng.randint(0, 3) + q = rng.choice( + [f"{{{lo}}}", f"{{{lo},}}", f"{{{lo},{lo + rng.randint(0, 3)}}}"] + ) + else: + n = rng.choice([999, 1000, 1001, 1002]) + q = f"{{{rng.choice(['', '1,'])}{n}}}" + if rng.random() < 0.04: + q += rng.choice(["*", "+"]) # stacked: pinned reject + return a + q + + +def _pattern(rng: random.Random, depth: int = 0) -> str: + branches = [] + for _ in range(rng.randint(1, 2 if depth else 3)): + parts = [_quantified(rng, depth) for _ in range(rng.randint(1, 4))] + if rng.random() < 0.25: + parts.insert(rng.randrange(len(parts) + 1), rng.choice(_FLAGS)) + branches.append("".join(parts)) + return "|".join(branches) + + +def _rewrite(rng: random.Random) -> str: + parts = [ + rng.choice(["L", "-", "$", "$1", r"\0", r"\1", r"\2", r"\\", r"\x", "\\"]) + for _ in range(rng.randint(0, 3)) + ] + return "".join(parts) + + +def _options(rng: random.Random) -> str: + if rng.random() < 0.5: + return "" + n = rng.randint(1, 3) + alphabet = "cilmnpsg" + ("q " if rng.random() < 0.1 else "") + return "".join(rng.choice(alphabet) for _ in range(n)) + + +def _case_sql(rng: random.Random) -> str: + # Grammar alphabets exclude single quotes, so patterns embed directly. + p = _pattern(rng) + assert "'" not in p + form = rng.random() + if form < 0.3: + args = f"s, '{p}'" + if rng.random() < 0.4: + args += f", '{_options(rng)}'" + expr = f"regexp_matches({args})" + elif form < 0.45: + expr = f"regexp_full_match(s, '{p}')" + elif form < 0.7: + args = f"s, '{p}', {rng.randint(0, 3)}" + if rng.random() < 0.3: + args += f", '{_options(rng)}'" + expr = f"regexp_extract({args})" + else: + args = f"s, '{p}', '{_rewrite(rng)}'" + if rng.random() < 0.5: + args += f", '{_options(rng)}'" + expr = f"regexp_replace({args})" + return f"SELECT a, {expr} AS r FROM __THIS__" + + +def _rows_key(rows: list[dict]) -> list: + return sorted(sorted((k, repr(v)) for k, v in r.items()) for r in rows) + + +def test_regexp_differential_fuzz(): + rng = random.Random(SEED) # noqa: S311 - deterministic fuzzing, not crypto + model = _row_model(SCHEMA) + inputs = [model(**r) for r in ROWS] + con = duckdb.connect() + con.register("__arrow_this", static(SCHEMA, ROWS)) + con.execute("CREATE TABLE __THIS__ AS SELECT * FROM __arrow_this") + + stats: Counter[str] = Counter() + for i in range(N): + sql = _case_sql(rng) + ctx = f"seed={SEED} case={i}: {sql!r}" + + try: + want = con.execute(sql).to_arrow_table().to_pylist() + except duckdb.Error: + want = None + except UnicodeDecodeError: + # RE2's \C can serve raw bytes inside multibyte chars — duckdb + # produces invalid UTF-8 the oracle side can't even decode. The + # engine (rust String) never can; treat like a duckdb error. + want = None + + try: + fn = DuckDBInferFn(sql, row_tables={"__THIS__": model}, static_tables={}) + except ValueError: + stats["duck_err both_reject" if want is None else "reject"] += 1 + continue + got = [r.model_dump() for r in fn.infer({"__THIS__": inputs})] + + assert want is not None, f"duckdb errored but the engine served rows: {ctx}" + assert _rows_key(got) == _rows_key(want), f"{ctx}\n got={got}\nwant={want}" + stats["match"] += 1 + + # Self-check: a degenerate generator (everything rejected) proves nothing. + assert stats["match"] >= N * 0.2, f"fuzzer degenerated: {dict(stats)}" + print(f"\nregexp fuzz seed={SEED} n={N}: {dict(stats)}")