mruby-regexp: always allocate the capture name arena - #7166
Conversation
mrb_re_compile() copies the parsed capture names into an arena the compiled regexp owns, because until that point named_captures[i].name borrows either the caller's pattern bytes or the c.stripped buffer that the same function frees before it returns. The copy was guarded by a non-zero total name length, so on the zero-length path no arena was allocated and the borrowed pointers stayed in the compiled pattern, outliving the storage they point at. Allocate a one-byte arena for that case and run the copy loop unconditionally, so the postcondition the comment already states, that after the loop the regexp owns its names, holds on every path. Patterns that compile today are unaffected: the allocation size and the copy are byte for byte what they were. Two things this is not. It is not a fix for a reachable defect: the parser rejects an empty group name, so every registered capture has name_len >= 1 and the total is non-zero whenever there is a name at all. Nor was the old state unsafe on its own, since a zero total forces every name_len to zero and each of the three readers passes that length alongside the pointer, leaving it inert. What it removes is a stale pointer that a later change to the accepted group-name syntax would turn into a live one, with nothing at the copy site to catch it. Nulling the pointers would be the cheaper way to reach the same invariant, but it trades the stale pointer for a worse one. matchdata_name_to_group(), behind MatchData#[], #begin and #end, compares names with memcmp(), whose first parameter is declared nonnull, and it tests name_len before the comparison rather than after, so a stored zero-length name would reach memcmp(NULL, name, 0). Keeping name a valid owned pointer avoids that.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe regexp compiler now always allocates owned storage for named captures and copies all capture names into that storage, including when the calculated name length is zero. ChangesNamed-capture storage
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to This localized change ensures capture names are owned consistently without altering existing non-zero capture behavior, and the supplied tests pass. No actionable merge-blocking risk remains beyond normal checks and review. Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
mrb_re_compile()finishes by copying the parsed capture names into an arena that the compiled regexp owns. It has to: until that pointnamed_captures[i].nameborrows either the caller's pattern bytes or thec.strippedpreprocessing buffer, andc.strippedis freed bymrb_re_compile()itself a few lines further down.That copy was guarded by
if (total > 0), wheretotalis the sum of everyname_len. On the other branch no arena was allocated and the borrowed pointers were simply left in the compiled pattern. This makes the copy unconditional, allocating a single byte whentotalis zero, so the postcondition the comment above the loop already states, that the regexp owns its names, holds on every path.Let me be precise about what this is and is not, because both matter for judging whether it is worth taking.
It is not a fix for a reachable defect. The sole place a named capture is registered is the
(?<name>parse, which rejects an empty name outright, so every registered entry hasname_len >= 1andtotalis non-zero whenevernum_namedis. The zero-length branch cannot be reached by any pattern that compiles. I confirmed this two ways: instrumenting the branch withabort()and running the full suite, and fuzzing roughly 169k patterns across the various named-group opener spellings and name payloads, including ones forced throughpreprocess_pattern. Zero hits.Nor was the old code unsafe on its own.
totalis an unsigned sum of the individual lengths, so a zero total forces everyname_lento zero, and all three readers of the field pass that length alongside the pointer.memcpy(dst, p, 0)andmemcmp(p, q, 0)perform no access, so the stale pointer was inert rather than dangerous. No crash motivated this patch and none is claimed.What it buys is that the invariant stops depending on a coincidence. The guard is written in terms of
totalwhile the fact that keeps it safe is a parser rejection several hundred lines away, and nothing at the copy site connects the two. A future change to the accepted group-name syntax, supporting another opener or relaxing the empty-name check, would leave borrowed pointers in a structure that outlives the buffer they point into, silently. Allocating unconditionally removes that path entirely rather than relying on the parser to keep it unreachable.One alternative is worth naming, because it looks cheaper and is in fact worse: setting
name = NULLon the zero-length path.matchdata_name_to_group(), behindMatchData#[],#beginand#end, compares names withmemcmp(), whose first parameter is declarednonnull, and it testsname_lenfor equality before the comparison rather than after. A stored zero-length name and a request ofmd[""]satisfy0 == 0and reachmemcmp(NULL, name, 0), which is undefined behaviour even at length 0.That is not a hypothetical. Building this gem with the empty-name rejection removed, to stand in for exactly the future parser change the hardening is meant to survive, and then running
under
clang -fsanitize=address,undefinedgives, with the nulling variant:and with the variant in this PR, nothing. Nulling would trade an inert pointer for a reportable one in precisely the scenario the hardening exists to cover. Keeping
namea valid owned pointer avoids the question.Patterns that compile today are unaffected: when
totalis non-zero the allocation size and the copy loop are byte for byte what they were.rake -m teston the default build is unchanged at mrbtestTotal: 2075, OK: 2046, KO: 0, Crash: 0and bintestTotal: 105, OK: 105, KO: 0, and thefull-corebuild under-fsanitize=address,undefinedpasses withTotal: 2291, OK: 2289, KO: 0, Crash: 0and no sanitizer output.Summary by CodeRabbit