Skip to content

Commit 177546f

Browse files
test(regex): resolve bd-1djvkw nested-empty submatch as glibc artifact (document-don't-mirror)
Determined the remaining bd-1djvkw divergence (submatch offsets of nested nullable-quantified groups) is a glibc IMPLEMENTATION ARTIFACT, not a fl bug. Proof: for `(.(b*)*)*` on "aaaa." glibc reports group1=[0,5], but the group `(.(b*)*)` matches `.` (one char) + `(b*)*` (zero-width) = exactly ONE char per iteration, so a 5-char span is impossible for any single iteration — glibc is reporting first-iter-start..last-iter-end, not a group match. Same impossibility holds for ((a*)+b?)*->[0,4], (a(b?)+)*.a*->[0,2], (b(a?)+)*->[0,4], (.(b*b*)*)*.b*->[0,2]. frankenlibc reports the genuine POSIX last-iteration span. Characterization over 200k cases: ZERO whole-match/group-0 divergences across 119,930 mutually-matching inputs; only ~46 submatch-only divergences, all in nested nullable-quantified groups. fl is POSIX-principled (last iteration, at-most-one-empty, no empty re-loop); glibc is the divergent party. Same policy as the twalk tree-shape / ecvt rounding / remquo huge-quotient quirks: document-don't-mirror. Mirroring glibc's impossible-span artifact would be a regression, not a fix. - NEW conformance_diff_regex_nested_submatch.rs: pins fl's principled submatch on 6 nested cases, asserts whole-match parity with the LIVE glibc oracle, asserts glibc still diverges (keeps the record honest), and re-verifies single-level / once-matched groups match glibc EXACTLY (RepeatExitGuard fix intact). - Refreshed the 200k oracle's module doc + #[ignore] note with the determination. No source changes — behavior unchanged; this is a determination + regression pin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 20aac3f commit 177546f

2 files changed

Lines changed: 206 additions & 11 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#![cfg(target_os = "linux")]
2+
#![allow(unsafe_code)] // live host-glibc regcomp/regexec oracle
3+
4+
//! Regex submatch offsets for NESTED quantified nullable groups: a curated
5+
//! differential record + regression pin (bd-1djvkw, document-don't-mirror).
6+
//!
7+
//! ## The determination
8+
//!
9+
//! For nested quantified groups whose inner subgroup is nullable
10+
//! (`(.(b*)*)*`, `((a*)+b?)*`, ...), frankenlibc's Pike VM and glibc's regexec
11+
//! agree EXACTLY on the whole-match span (group 0) but diverge on the
12+
//! *submatch* offsets of the repeated groups. A 200k-case characterization
13+
//! (`regex_empty_iter_capture_differential_fuzz`, `#[ignore]`d) confirms the
14+
//! shape of the divergence: across 119,930 mutually-matching cases there are
15+
//! ZERO whole-match/group-0 divergences and only 46 submatch-only divergences,
16+
//! every one inside a nested nullable-quantified group.
17+
//!
18+
//! These divergences are a **glibc implementation artifact**, NOT a frankenlibc
19+
//! bug, and are deliberately not mirrored — the same policy applied to the
20+
//! glibc twalk tree-shape, ecvt rounding, and remquo huge-quotient quirks.
21+
//!
22+
//! ### Proof that glibc is the divergent party
23+
//!
24+
//! POSIX records, for a repeated subexpression, the substring matched by its
25+
//! LAST iteration — necessarily a string the subexpression can match in ONE
26+
//! iteration. glibc violates this: for `(.(b*)*)*` on `"aaaa."` it reports
27+
//! group 1 = `[0,5]`. But the group `(.(b*)*)` matches `.` (exactly one char)
28+
//! followed by `(b*)*` (zero-width — the subject has no `b`), so a SINGLE
29+
//! iteration of that group matches exactly ONE character. A 5-character span is
30+
//! impossible for any single iteration; glibc is reporting the distance from
31+
//! the first iteration's start to the last iteration's end, not a group match.
32+
//! The same impossibility holds for `((a*)+b?)*`→`[0,4]`, `(a(b?)+)*.a*`→`[0,2]`,
33+
//! `(b(a?)+)*`→`[0,4]`, and `(.(b*b*)*)*.b*`→`[0,2]`. frankenlibc reports the
34+
//! genuine last-iteration span (`[4,5]`, one char), which is the only valid
35+
//! single-iteration match.
36+
//!
37+
//! This test (a) asserts whole-match parity with the LIVE glibc oracle on every
38+
//! case, (b) pins frankenlibc's POSIX-principled submatch values so a future
39+
//! refactor can't silently regress them, and (c) records glibc's divergent
40+
//! (artifact) values in comments for traceability.
41+
42+
use std::ffi::{CString, c_char, c_int, c_void};
43+
44+
use frankenlibc_abi::string_abi as fl;
45+
46+
unsafe extern "C" {
47+
fn regcomp(preg: *mut c_void, pattern: *const c_char, cflags: c_int) -> c_int;
48+
fn regexec(
49+
preg: *const c_void,
50+
string: *const c_char,
51+
nmatch: usize,
52+
pmatch: *mut c_void,
53+
eflags: c_int,
54+
) -> c_int;
55+
fn regfree(preg: *mut c_void);
56+
}
57+
58+
const REG_EXTENDED: c_int = 1;
59+
60+
#[repr(C)]
61+
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
62+
struct M {
63+
so: i32,
64+
eo: i32,
65+
}
66+
67+
type CompFn = unsafe extern "C" fn(*mut c_void, *const c_char, c_int) -> c_int;
68+
type ExecFn = unsafe extern "C" fn(*const c_void, *const c_char, usize, *mut c_void, c_int) -> c_int;
69+
type FreeFn = unsafe extern "C" fn(*mut c_void);
70+
71+
#[repr(C, align(16))]
72+
struct Preg([u8; 256]);
73+
74+
fn run(comp: CompFn, exec: ExecFn, free: FreeFn, pat: &str, inp: &str) -> (bool, Vec<M>) {
75+
let cp = CString::new(pat).unwrap();
76+
let ci = CString::new(inp).unwrap();
77+
let mut preg = Preg([0u8; 256]);
78+
let c = unsafe { comp(preg.0.as_mut_ptr() as *mut c_void, cp.as_ptr(), REG_EXTENDED) };
79+
assert_eq!(c, 0, "regcomp failed for {pat:?}");
80+
let mut pm = vec![M::default(); 6];
81+
let e = unsafe {
82+
exec(
83+
preg.0.as_ptr() as *const c_void,
84+
ci.as_ptr(),
85+
6,
86+
pm.as_mut_ptr() as *mut c_void,
87+
0,
88+
)
89+
};
90+
unsafe { free(preg.0.as_mut_ptr() as *mut c_void) };
91+
(e == 0, pm)
92+
}
93+
94+
fn fl_run(pat: &str, inp: &str) -> (bool, Vec<M>) {
95+
run(fl::regcomp, fl::regexec, fl::regfree, pat, inp)
96+
}
97+
fn glibc_run(pat: &str, inp: &str) -> (bool, Vec<M>) {
98+
run(regcomp, regexec, regfree, pat, inp)
99+
}
100+
101+
fn m(so: i32, eo: i32) -> M {
102+
M { so, eo }
103+
}
104+
105+
/// frankenlibc's POSIX-principled submatch on nested nullable-quantified groups,
106+
/// where glibc diverges with an impossible multi-iteration span (see module doc).
107+
/// `expect` lists slots 0..=2 (whole match + first two groups).
108+
#[test]
109+
fn nested_submatch_fl_is_posix_principled_and_glibc_diverges() {
110+
struct Case {
111+
pat: &'static str,
112+
subj: &'static str,
113+
fl_expect: [M; 3],
114+
glibc_artifact: [M; 3], // documented, NOT asserted (glibc-version sensitive)
115+
}
116+
let cases = [
117+
// glibc g1=[0,5] is impossible: `(.(b*)*)` matches 1 char/iteration.
118+
Case { pat: "(.(b*)*)*", subj: "aaaa.", fl_expect: [m(0,5), m(4,5), m(5,5)], glibc_artifact: [m(0,5), m(0,5), m(1,1)] },
119+
// glibc g1=[0,4] impossible: one iteration of `((a*)+b?)` matches <=1 char here.
120+
Case { pat: "((a*)+b?)*", subj: "bbbb", fl_expect: [m(0,4), m(3,4), m(3,3)], glibc_artifact: [m(0,4), m(0,4), m(0,0)] },
121+
// glibc g1=[0,2] impossible: `(a(b?)+)` matches one 'a' per iteration here.
122+
Case { pat: "(a(b?)+)*.a*", subj: "aaa", fl_expect: [m(0,3), m(1,2), m(2,2)], glibc_artifact: [m(0,3), m(0,2), m(1,1)] },
123+
// glibc g1=[0,4] impossible: `(b(a?)+)` matches "ba" (2 chars) max per iteration.
124+
Case { pat: "(b(a?)+)*", subj: "babb", fl_expect: [m(0,4), m(3,4), m(4,4)], glibc_artifact: [m(0,4), m(0,4), m(1,2)] },
125+
// glibc g1=[0,2] impossible: `(.(b*b*)*)` matches 1 char per iteration.
126+
Case { pat: "(.(b*b*)*)*.b*", subj: "aa.", fl_expect: [m(0,3), m(1,2), m(2,2)], glibc_artifact: [m(0,3), m(0,2), m(1,1)] },
127+
// Tie-break (both spans are valid single iterations): glibc keeps the
128+
// trailing EMPTY inner iteration [3,3]; fl keeps the non-empty [2,3]
129+
// (POSIX leftmost-longest prefers the longer inner match).
130+
Case { pat: "((b*)+a)+", subj: "baba", fl_expect: [m(0,4), m(2,4), m(2,3)], glibc_artifact: [m(0,4), m(2,4), m(3,3)] },
131+
];
132+
133+
for c in &cases {
134+
let (fm, fpm) = fl_run(c.pat, c.subj);
135+
let (gm, gpm) = glibc_run(c.pat, c.subj);
136+
// (a) whole-match parity with live glibc — the invariant that always holds.
137+
assert!(fm && gm, "both engines must match {:?} on {:?}", c.pat, c.subj);
138+
assert_eq!(
139+
fpm[0], gpm[0],
140+
"whole-match (group 0) must agree with glibc for {:?} on {:?}",
141+
c.pat, c.subj
142+
);
143+
// (b) regression pin: frankenlibc's POSIX-principled submatch values.
144+
assert_eq!(
145+
&fpm[0..3],
146+
&c.fl_expect,
147+
"frankenlibc nested submatch drifted for {:?} on {:?}",
148+
c.pat, c.subj
149+
);
150+
// (c) sanity: glibc indeed diverges on the submatch slots (so this stays
151+
// a real "document-don't-mirror" record, not a stale no-op). We don't
152+
// assert glibc's exact artifact values (version-sensitive), only that it
153+
// differs from fl somewhere in the group slots.
154+
let _ = c.glibc_artifact;
155+
assert_ne!(
156+
&fpm[1..3],
157+
&gpm[1..3],
158+
"expected glibc to diverge on submatch for {:?} on {:?} (artifact); \
159+
if this fires, glibc changed and the determination should be revisited",
160+
c.pat, c.subj
161+
);
162+
}
163+
}
164+
165+
/// Boundary check: when the OUTER group is matched exactly once (or is not the
166+
/// repeated one), frankenlibc and glibc agree on ALL slots — the single-level
167+
/// `RepeatExitGuard` fix (bd-1djvkw partial) must stay intact.
168+
#[test]
169+
fn single_level_and_once_matched_groups_match_glibc_exactly() {
170+
let cases = [
171+
("(.(b*)*)*", "a"), // outer matches once
172+
("((a*)*a)", "abaaa"), // inner repeat, outer not repeated
173+
("((a*)*)", "aaa"),
174+
("(a*)*", "aaa"), // single-level empty iteration (the fixed case)
175+
(".()*a", "ba"), // documented single-level reproducer
176+
("(a*)*b?", "b"),
177+
];
178+
for (pat, subj) in cases {
179+
let (fm, fpm) = fl_run(pat, subj);
180+
let (gm, gpm) = glibc_run(pat, subj);
181+
assert_eq!(fm, gm, "match decision must agree for {pat:?} on {subj:?}");
182+
if fm {
183+
assert_eq!(
184+
fpm, gpm,
185+
"all submatch offsets must agree with glibc for {pat:?} on {subj:?}"
186+
);
187+
}
188+
}
189+
}

crates/frankenlibc-abi/tests/regex_empty_iter_capture_differential_fuzz.rs

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22
#![allow(unsafe_code)] // live host-glibc regcomp/regexec oracle
33

44
//! Characterization fuzzer (live host-glibc oracle) for POSIX submatch offsets
5-
//! of EMPTY / NULLABLE groups under quantifiers — documents the KNOWN gap
6-
//! bd-1djvkw. `#[ignore]`d because it currently FAILS: the whole-match decision
7-
//! (group 0) matches glibc, but the submatch offsets of empty/nullable quantified
8-
//! groups diverge — glibc records the participating empty span [p,p] (e.g. `()*`
9-
//! → group1=[p,p], `.()*a` on "ba" → group1=[1,1], `(a*)*b?` on "b" → group1=[0,0]),
10-
//! while frankenlibc's Pike VM drops them to [-1,-1]. The correct POSIX rule (a
11-
//! `*`/`+` may take at most one empty iteration, only as the first, without a
12-
//! trailing empty overwriting a non-empty capture) needs per-thread loop-progress
13-
//! tracking — a real VM change, not a structural one-liner (a naive restructure
14-
//! regresses `..(b*)+`; see bd-1djvkw). Un-ignore to drive 200k cases when fixing.
5+
//! of EMPTY / NULLABLE groups under quantifiers — bd-1djvkw.
6+
//!
7+
//! SINGLE-LEVEL empty/nullable submatch now matches glibc exactly (the
8+
//! `RepeatExitGuard` fix: `.()*a` on "ba" → group1=[1,1], `(a*)*b?` on "b" →
9+
//! group1=[0,0]). The remaining NESTED divergences are a glibc ARTIFACT that
10+
//! frankenlibc deliberately does NOT mirror (document-don't-mirror, same policy
11+
//! as the twalk tree-shape, ecvt rounding, and remquo quirks): glibc reports a
12+
//! repeated group's span as the distance from its first iteration's start to its
13+
//! last iteration's end — a span the group's subpattern cannot match in a SINGLE
14+
//! iteration (e.g. `(.(b*)*)*` on "aaaa." → glibc group1=[0,5], though one
15+
//! iteration of `(.(b*)*)` matches exactly one char). frankenlibc reports the
16+
//! genuine POSIX last-iteration span ([4,5]). Whole-match (group 0) parity is
17+
//! exact across all 200k cases (0 group-0 divergences; only ~46 submatch-only).
18+
//! The determination + regression pin live in
19+
//! `conformance_diff_regex_nested_submatch.rs`. This stays `#[ignore]`d because
20+
//! it characterizes the glibc artifact, not a frankenlibc bug.
1521
//!
1622
//! Grammar is deliberately ERE concatenation of {literal, `.`, group, and the
1723
//! three quantifiers} with NO top-level alternation and NO backreferences, so it
@@ -141,7 +147,7 @@ fn run(
141147
}
142148

143149
#[test]
144-
#[ignore = "bd-1djvkw PARTIAL: single-level empty/nullable-group submatch now matches glibc (RepeatExitGuard); deeply-NESTED loop-in-loop empty iterations (e.g. (.(b*)*)*) still diverge — un-ignore when the nested case lands"]
150+
#[ignore = "bd-1djvkw RESOLVED (document-don't-mirror): single-level empty/nullable submatch matches glibc (RepeatExitGuard). The remaining NESTED-loop divergences are a glibc ARTIFACT, not a fl bug — glibc reports group spans impossible for a single iteration (e.g. (.(b*)*)* on 'aaaa.' gives g1=[0,5] though the group matches 1 char/iteration); fl reports the POSIX-principled last-iteration span. Whole-match (group 0) parity is exact across all 200k cases (0 divergences; only ~46 submatch-only). Pinned + proven in conformance_diff_regex_nested_submatch.rs. Stays #[ignore]d: it characterizes the glibc artifact, which fl deliberately does NOT mirror (same policy as twalk/ecvt/remquo quirks)"]
145151
fn regex_empty_iter_capture_differential_fuzz_vs_glibc() {
146152
let mut r = Lcg(0xc2b2_ae3d_27d4_eb4f);
147153
let mut divs: Vec<String> = Vec::new();

0 commit comments

Comments
 (0)