mruby-regexp: do not truncate a stored capture name length - #7035
Merged
Conversation
`compile_atom()` stored a named capture's name length in a `uint16_t`
and truncated to it with a cast rather than rejecting the name. The cast
is silent, and every consumer of the stored name then worked from the
truncated bytes: the pattern still compiled, the group still captured,
but its name was no longer the name that was written.
```ruby
n = "A" * 65539
re = Regexp.new("(?<#{n}>x)")
re.named_captures.keys.map(&:length) # CRuby: [65539], mruby: [3]
re.match("x")[n] # CRuby: "x", mruby: IndexError
re.match("x")["AAA"] # CRuby: IndexError, mruby: "x"
```
Two groups could also collapse onto one stored name, and the two APIs
that resolve a name then picked different groups. `MatchData#[]` scans
the table and stops at the first entry, while `@named_captures` is a Hash
keyed by the stored name and lets the later entry overwrite the earlier
one.
```ruby
long = "ab" + "A" * 65536 # 65538 bytes, (uint16_t)65538 == 2
re = Regexp.new("(?<ab>x)(?<#{long}>y)")
re.named_captures # mruby: {"ab" => [2]}
md = re.match("xy")
md["ab"] # mruby: "x", which is group 1
md.named_captures # mruby: {"ab" => "y"}, group 2
```
The same collapse reached the matcher, where it changed what a pattern
accepts: a `\k` backreference written against the second group bound to
the first.
```ruby
long = "ab" + "A" * 65536
re = Regexp.new("(?<ab>x)(?<#{long}>y)\\k<#{long}>")
re.match("xyx") # CRuby: nil, mruby: matches
re.match("xyy") # CRuby: matches, mruby: nil
```
A name of exactly 65536 bytes truncated to 0, so the group answered to
the empty name.
```ruby
z = "Z" * 65536
re = Regexp.new("(?<#{z}>x)")
re.named_captures.keys.map(&:length) # CRuby: [65536], mruby: [0]
re.match("x")[""] # CRuby: IndexError, mruby: "x"
```
Widen `re_named_capture::name_len` to `uint32_t`, so a stored length is
the name's true length, and let every local, loop counter and comparison
that consumes it follow. All four wrong answers above then match CRuby.
On x86_64 the widening is free: the struct already padded to 16 bytes and
still measures 16.
`c->p - cap_name` is a `ptrdiff_t` over the pattern source and nothing in
`mrb_re_compile()` limits the pattern's length, so a bound still has to
sit in front of the cast. It moves to `UINT32_MAX`, out of reach of any
pattern this gem can compile, and it is what keeps the cast lossless. The
bound is spelled once beside the field it describes, as
`RE_MAX_NAME_LEN` and `RE_NAME_LEN_FITS()`, so a later change of width is
one line.
`RE_NAME_LEN_FITS()` widens its argument to `uintmax_t` rather than
comparing it directly. On a target whose argument type is no wider than
the field, a `ptrdiff_t` on ILP32 or an `mrb_int` on `MRB_INT32`, the
direct comparison is against a constant that type cannot exceed, and
clang reports it under `-Wsign-compare`. `uintmax_t` rather than
`uint64_t` because C99 makes the exact-width 64-bit types optional, and
every `uint64_t` in mruby today sits behind `MRB_GC_PROFILE` or
`MRB_INT64`.
The bound `matchdata_aref()` already carried on the requested length
stays, reading through the same macro. It is what makes the cast on the
line below it lossless, and dropping it would put a truncating
comparison back next to an untruncated `memcmp()`.
|
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 (4)
📝 WalkthroughWalkthroughNamed regexp capture and backreference names now support lengths beyond 65,535 bytes. Length conversions are validated, lookup uses full-width comparisons, and regression tests cover oversized names. ChangesNamed capture length handling
Estimated code review effort: 3 (Moderate) | ~20 minutes 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 |
This was referenced Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
compile_atom()stores a named capture's name length in auint16_tandtruncates to it with a cast rather than rejecting the name. The truncation is
silent, and every consumer of the stored name then works from the truncated
bytes: the pattern still compiles, the group still captures, but its name is no
longer the name that was written.
The group exists and matched, but it cannot be reached by the name it was
given, and it answers to a three-byte name that appears nowhere in the pattern.
The
IndexErroron the second row is the current answer, not the historicalone: the lookup for a name longer than 65535 bytes used to read past the named
capture arena, and #7002 bounded it. That fix is about the read, and it leaves
the truncation this pull request is about exactly where it was.
Two consumers disagree about the same name
The truncation is not confined to lookup by name. It also lets two groups
collapse onto one stored name, and the two APIs that resolve a name then pick
different groups.
MatchData#[]scans the table and stops at the first entry, so"ab"resolvesto group 1. The
@named_capturestable andMatchData#named_capturesareHashes keyed by the stored name and let the later entry overwrite the earlier
one, so the same name resolves to group 2 through
Regexp#named_captures,which derives from that table. One name, two answers, in one pattern.
\kbinds to the wrong groupThe same collapse reaches the matcher, where it changes what the pattern
accepts.
The backreference was written against the second group and binds to the first.
This is a wrong answer from a pattern that compiled without complaint, not a
diagnostic gap.
A name of exactly 65536 bytes becomes the empty name
(uint16_t)65536 == 0, so the name is truncated away entirely and the groupanswers to
"".Cause
Two casts in
compile_atom(), one at capture registration and one at\kresolution:
c->p - cap_nameis aptrdiff_tover the pattern source, whilere_named_capture::name_lenis auint16_t. The truncated value is what getsstored and what sizes the owned name arena, so nothing reads out of bounds: the
arena is built from the same truncated lengths that every reader uses. What is
lost is the rest of the name, and with it the identity of the group.
Every reader works from the stored length and is therefore consistent with the
truncation rather than with the pattern: the
\klookup, the@named_capturestableRegexp#named_capturesderives from,MatchData#named_capturesandMatchData#[].Fix
Widen
re_named_capture::name_lentouint32_t, so a stored length is thename's true length, and let every local, loop counter and comparison that
consumes it follow. All four wrong answers above then match CRuby.
On a 64-bit target the widening is free: the struct is
const char *name; uint16_t name_len; uint16_t group;, which already pads to16 bytes, and
uint32_t name_lenstill fits in 16 (measured withcc, bothsizes 16). On a 32-bit target it grows the entry from 8 bytes to 12. That is
the whole cost, paid per named capture.
The bound the cast needs does not go away with the wider field.
c->p - cap_nameis aptrdiff_tover the pattern source and nothing inmrb_re_compile()limits the pattern's length, so a check has to sit in frontof the cast whatever the field's width is. It moves to
UINT32_MAX, out ofreach of any pattern this gem can compile, and it is what keeps the cast
lossless.
The bound is spelled once, beside the field it describes, so a later change of
width is one line:
The widening inside the macro is not cosmetic. On a target whose argument type
is no wider than the field, a
ptrdiff_ton ILP32 or anmrb_intonMRB_INT32, the naive comparison is against a constant the type cannot exceed,and clang reports it:
uintmax_trather thanuint64_t: C99 makes the exact-width 64-bit typesoptional (7.18.1.1p3), and every
uint64_tin mruby today sits behindMRB_GC_PROFILEorMRB_INT64, so a gem header should not require oneunconditionally.
CONTRIBUTING.mdasks for code as close to C99 as possible.The guard #7002 put in
matchdata_aref()stays, reading through the samemacro. It is what makes the cast on the line below it lossless, and dropping it
would put a truncating comparison back next to an untruncated
memcmp(), oneedit away from the same overread. The test #7002 added alongside it needs
nothing either way: its pattern is
/(?<abc>x)/, so the over-long name itlooks up resolves to no group whatever the field's width is.
Alternative considered
Rejecting a name that does not fit is two lines and no representation change.
It turns four wrong answers into one
RegexpErrorand leaves therepresentation alone, but it is still a divergence from CRuby, which has no
such limit and resolves the full name. Widening makes all four cases agree with
CRuby instead, for one byte per entry on 32-bit targets and nothing on 64-bit
ones.
Tests
One
assert()inmrbgems/mruby-regexp/test/regexp.rb, immediately afterassert("MatchData#[] - group name longer than a uint16 length"), which is#7002's test and the read-side counterpart of this one. It carries one
assertion per wrong answer above: the name survives lookup and
Regexp#named_captures, two names that shared a truncation stay distinct andboth APIs agree on which group each names,
\kbinds to the group its name waswritten on, and a 65536-byte name is not the empty name.
Verified on
x86_64-linux:rake test: 1957 total, 1939 OK, 0 KO, 0 crash, and bintest 105 OK.MRB_INT32build with clang and-Wall -Wextra: 1862 total, 1844 OK,0 KO, 0 crash, and no new warning from any
mruby-regexpfile.of 12 bytes is read from the layout, not measured; no
-m32toolchain wasavailable.
Summary by CodeRabbit