Skip to content

mruby-regexp: always allocate the capture name arena - #7166

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-always-allocate-name-arena
Aug 14, 2026
Merged

mruby-regexp: always allocate the capture name arena#7166
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-always-allocate-name-arena

Conversation

@takumin

@takumin takumin commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

mrb_re_compile() finishes by copying the parsed capture names into an arena that the compiled regexp owns. It has to: until that point named_captures[i].name borrows either the caller's pattern bytes or the c.stripped preprocessing buffer, and c.stripped is freed by mrb_re_compile() itself a few lines further down.

That copy was guarded by if (total > 0), where total is the sum of every name_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 when total is 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 has name_len >= 1 and total is non-zero whenever num_named is. The zero-length branch cannot be reached by any pattern that compiles. I confirmed this two ways: instrumenting the branch with abort() and running the full suite, and fuzzing roughly 169k patterns across the various named-group opener spellings and name payloads, including ones forced through preprocess_pattern. Zero hits.

Nor was the old code unsafe on its own. total is an unsigned sum of the individual lengths, so a zero total forces every name_len to zero, and all three readers of the field pass that length alongside the pointer. memcpy(dst, p, 0) and memcmp(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 total while 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 = NULL on the zero-length path. matchdata_name_to_group(), behind MatchData#[], #begin and #end, compares names with memcmp(), whose first parameter is declared nonnull, and it tests name_len for equality before the comparison rather than after. A stored zero-length name and a request of md[""] satisfy 0 == 0 and reach memcmp(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

r = Regexp.new("(?<>\\d+)")
m = r.match("42")
m[""]; m.begin(""); m.end("")

under clang -fsanitize=address,undefined gives, with the nulling variant:

mrbgems/mruby-regexp/src/regexp.c:847:18: runtime error: null pointer passed as argument 1,
                                          which is declared to never be null
/usr/include/string.h:65:33: note: nonnull attribute specified here

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 name a valid owned pointer avoids the question.

Patterns that compile today are unaffected: when total is non-zero the allocation size and the copy loop are byte for byte what they were. rake -m test on the default build is unchanged at mrbtest Total: 2075, OK: 2046, KO: 0, Crash: 0 and bintest Total: 105, OK: 105, KO: 0, and the full-core build under -fsanitize=address,undefined passes with Total: 2291, OK: 2289, KO: 0, Crash: 0 and no sanitizer output.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of named regular-expression captures when capture names have zero-length calculated storage.
    • Ensured capture names remain available and correctly associated during regular-expression processing.

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.
@takumin
takumin requested a review from matz as a code owner August 14, 2026 08:49
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bc0d4b9-a2fe-4f63-b556-cc9450df96e2

📥 Commits

Reviewing files that changed from the base of the PR and between 783e3b2 and 0cb7b7e.

📒 Files selected for processing (1)
  • mrbgems/mruby-regexp/src/re_compile.c

📝 Walkthrough

Walkthrough

The 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.

Changes

Named-capture storage

Layer / File(s) Summary
Allocate and copy capture names
mrbgems/mruby-regexp/src/re_compile.c
Named-capture storage now allocates at least one byte. Every registered name is copied and rebound to the owned arena.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 0cb7b

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

  • mruby/mruby#7002: Both changes modify named-capture name handling in mruby-regexp.
  • mruby/mruby#7007: Both changes modify named-capture name storage and copying in mr_compile.c.
  • mruby/mruby#7021: Both changes modify named-capture handling in mrbgems/mruby-regexp/src/re_compile.c.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: unconditional allocation of the capture-name arena.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matz
matz merged commit fa8d372 into mruby:master Aug 14, 2026
21 checks passed
@takumin
takumin deleted the regexp-always-allocate-name-arena branch August 14, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants