mruby-regexp: stop truncating a group name to a uint16_t - #7007
Conversation
`re_named_capture::name_len` was a `uint16_t`, and the compiler narrowed
to it with a cast rather than rejecting the name. The truncation was
silent, and every consumer then worked from the truncated bytes: the
pattern compiled, the group 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
```
Two groups could also collapse onto one stored name, and the two APIs
that resolve a name then disagreed about which group it named.
`MatchData#[]` scans the table and stops at the first entry, while
`named_captures` builds a Hash and lets the later entry overwrite the
earlier one.
```ruby
long = "ab" + "A" * 65536 # (uint16_t)65538 == 2
md = Regexp.new("(?<ab>x)(?<#{long}>y)").match("xy")
md["ab"] # mruby: "x", 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<name>` written against the second group bound to the
first. A name of exactly 65536 bytes truncated away entirely and the
group answered to `""`.
The field is a `uint32_t` now, so a stored length is the name's true
length and every answer above matches CRuby. On a 64-bit target the
struct is unchanged at 16 bytes, since `const char *name; uint16_t
name_len; uint16_t group;` already padded to that. On a 32-bit target
the entry grows from 8 bytes to 12.
The bound the cast needs does not go away with the wider field, because
`c->p - cap_name` is a `ptrdiff_t` over the pattern source and nothing
limits the pattern's length. It moves to `UINT32_MAX`, out of reach of
any pattern this gem can compile, and it is what keeps the cast
lossless.
`RE_MAX_NAME_LEN` spells that bound once, beside the field it describes,
and `RE_NAME_LEN_FITS()` widens to `uintmax_t` before comparing. Without
the widening, a target whose argument type is no wider than the field, a
`ptrdiff_t` on ILP32 or an `mrb_int` on `MRB_INT32`, compares against a
constant its type cannot exceed, which compilers warn about.
The guard mruby#7002 added to `matchdata_aref()` stays, now 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()`.
Related to mruby#7002.
📝 WalkthroughWalkthroughNamed capture and backreference name lengths now use validated 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 |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@mrbgems/mruby-regexp/src/re_compile.c`:
- Around line 819-826: Update the numeric backreference parsing in the visible
compile-error path to validate each parsed value against c->num_captures - 1
before executing n = n * 10 + (name[i] - '0'), rejecting oversized references
without signed integer overflow. Preserve relative-reference handling and add
regression coverage for a numeric reference exceeding 65535 bytes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 15f17e83-887b-4211-aac4-bc1e87be16de
📒 Files selected for processing (4)
mrbgems/mruby-regexp/include/re_internal.hmrbgems/mruby-regexp/src/re_compile.cmrbgems/mruby-regexp/src/regexp.cmrbgems/mruby-regexp/test/regexp.rb
|
Thank you for taking a look. I’m going to close this PR for now. I’ll reorganize the individual changes, make their dependencies and intended submission order explicit, and then resubmit them as appropriately scoped PRs. Sorry for the churn. |
|
Resubmitted as #7035, which has been merged. |
Split out of #7002 rather than folded into it. That pull request fixed a heap-buffer-overflow
read in
MatchData#[]for an over-long group name, and closed it with a one-line bound on therequested length. This one is the compile side of the same
uint16_t: entirely in bounds, andentirely a compatibility bug. #7002 said the compile-side truncation was consistent and not
worth a guard of its own, which was right about the memory safety and wrong about the
behaviour.
re_named_capture::name_lenwas auint16_t, andcompile_atom()narrowed to it with a castrather than rejecting the name. The truncation was silent, and every reader worked from the
truncated bytes, so nothing read out of bounds: the arena was built from the same truncated
lengths every reader used. What was lost was the rest of the name, and with it the identity of
the group.
The group existed and matched, but it could not be reached by the name it was given, and it
answered to a three-byte name that appeared nowhere in the pattern.
Two consumers disagreed about the same name
The truncation also let two groups 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, so"ab"resolved to group 1.Regexp#named_capturesandMatchData#named_capturesbuild a Hash and let the later entryoverwrite the earlier one, so the same name resolved to group 2. One name, two answers, in one
pattern.
\k<name>bound to the wrong groupThe same collapse reached the matcher, where it changed what the pattern accepts.
The backreference was written against the second group and bound to the first. That is a wrong
answer from a pattern that compiled without complaint, not a diagnostic gap.
A name of exactly 65536 bytes became the empty name
(uint16_t)65536 == 0, so the name was truncated away entirely and the group answered to"".What this changes
re_named_capture::name_lenis auint32_t, and the locals, loop counters and comparisonsthat consume it follow. Every answer above now matches CRuby.
The alternative was to reject a name that does not fit, which is two lines and no
representation change. It was not taken because the limit is not one the design needs. The
gem's other
uint16_tlimits are structural:RE_MAX_CAPTURESis 32 because two functions inregexp.choldint last_captures[RE_MAX_CAPTURES * 2]on the stack, andre_inst.offsetsizes every instruction. A name's length is neither. On a 64-bit target the struct is unchanged
at 16 bytes, since
const char *name; uint16_t name_len; uint16_t group;already padded tothat, and on a 32-bit target the entry grows from 8 bytes to 12. Trading four wrong answers for
that seemed the better side of the deal.
The bound the cast needs does not go away with the wider field.
c->p - cap_nameis aptrdiff_tover the pattern source and nothing limits the pattern's length, so a check has tosit in front of the cast whatever the field's width is. It moves to
UINT32_MAX, out of reachof any pattern this gem can compile, and it is what keeps the cast lossless.
RE_MAX_NAME_LENspells that bound once, beside the field it describes, so a later change ofwidth is one line.
RE_NAME_LEN_FITS()widens touintmax_tbefore comparing, which is notcosmetic: on a target whose argument type is no wider than the field, a
ptrdiff_ton ILP32 oran
mrb_intonMRB_INT32, the naive comparison is against a constant the type cannot exceed,and clang reports it.
uintmax_trather thanuint64_tbecause C99 makes the exact-width 64-bit types optional andevery
uint64_tin mruby today sits behindMRB_GC_PROFILEorMRB_INT64.The guard #7002 added to
matchdata_aref()stays, now reading through the same macro. It iswhat makes the cast on the line below it lossless, and dropping it would put a truncating
comparison back next to an untruncated
memcmp(), one edit away from the same overread.Testing
rake teston the host build: 1942 total, 1924 OK, 0 KO, 0 crash, and bintest 105 OK.MRB_INT32build with clang and-Wall -Wextra: 1847 total, 1829 OK, 0 KO, 0 crash, andno warnings from any
mruby-regexpfile.not measured.
The new test sits next to #7002's, which is the read-side counterpart of this one.
Related to #7002.
Summary by CodeRabbit
Bug Fixes
Tests