|
| 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 | +} |
0 commit comments