Skip to content

mruby-regexp: stop truncating a group name to a uint16_t - #7007

Closed
takumin wants to merge 1 commit into
mruby:masterfrom
takumin:regexp-group-name-length
Closed

mruby-regexp: stop truncating a group name to a uint16_t#7007
takumin wants to merge 1 commit into
mruby:masterfrom
takumin:regexp-group-name-length

Conversation

@takumin

@takumin takumin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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 the
requested length. This one is the compile side of the same uint16_t: entirely in bounds, and
entirely 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_len was a uint16_t, and compile_atom() narrowed to it with a cast
rather 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.

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"

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.

long = "ab" + "A" * 65536              # length 65538, (uint16_t)65538 == 2
re = Regexp.new("(?<ab>x)(?<#{long}>y)")
md = re.match("xy")

md["ab"]
# mruby: "x", group 1

md.named_captures
# mruby: {"ab" => "y"}, group 2

MatchData#[] scans the table and stops at the first entry, so "ab" resolved to group 1.
Regexp#named_captures and MatchData#named_captures build a Hash and let the later entry
overwrite the earlier one, so the same name resolved to group 2. One name, two answers, in one
pattern.

\k<name> bound to the wrong group

The same collapse reached the matcher, where it changed what the pattern accepts.

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

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

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"

What this changes

re_named_capture::name_len is a uint32_t, and the locals, loop counters and comparisons
that 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_t limits are structural: RE_MAX_CAPTURES is 32 because two functions in
regexp.c hold int last_captures[RE_MAX_CAPTURES * 2] on the stack, and re_inst.offset
sizes 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 to
that, 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_name is a
ptrdiff_t over the pattern source and nothing limits the pattern's length, so a check has to
sit in front of the cast whatever the field's width is. 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, so a later change of
width is one line. RE_NAME_LEN_FITS() widens to uintmax_t before comparing, which is not
cosmetic: 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 naive comparison is against a constant the type cannot exceed,
and clang reports it.

warning: comparison of integers of different signs: 'int32_t' (aka 'int')
         and 'unsigned int' [-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 guard #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(), one edit away from the same overread.

Testing

  • rake test on the host build: 1942 total, 1924 OK, 0 KO, 0 crash, and bintest 105 OK.
  • An MRB_INT32 build with clang and -Wall -Wextra: 1847 total, 1829 OK, 0 KO, 0 crash, and
    no warnings from any mruby-regexp file.
  • The struct size was measured on x86_64 only. The 32-bit figure above is read from the layout,
    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

    • Fixed handling of very long named regular-expression captures and backreferences.
    • Prevented capture-name length truncation during matching and lookup.
    • Added safe validation for names exceeding the previously supported 16-bit length range.
    • Improved behavior for duplicate and exactly 65,536-byte capture names.
  • Tests

    • Expanded coverage for long named captures, named backreferences, and capture lookup.

`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.
@takumin
takumin requested a review from matz as a code owner August 3, 2026 10:58
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Named capture and backreference name lengths now use validated uint32_t values. Lookup avoids narrowing requested names. Tests cover long, duplicate, and exactly 65536-byte capture names.

Changes

Named capture length handling

Layer / File(s) Summary
Length contract and compilation
mrbgems/mruby-regexp/include/re_internal.h, mrbgems/mruby-regexp/src/re_compile.c
name_len and related local lengths now use uint32_t. Capture and backreference parsing reject lengths that do not fit.
Lookup and regression coverage
mrbgems/mruby-regexp/src/regexp.c, mrbgems/mruby-regexp/test/regexp.rb
Named capture lookup preserves full requested lengths. Tests cover long names, duplicate truncation cases, backreferences, and 65536-byte names.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • mruby/mruby#7000: Both changes update named-capture lookup and related tests, but address different behaviors.
  • mruby/mruby#7002: This change extends the overlong-name fix by widening lengths to uint32_t and adding boundary tests.

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: preventing named capture group names from truncating to uint16_t.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between c25f3fb and 15d33c6.

📒 Files selected for processing (4)
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/src/regexp.c
  • mrbgems/mruby-regexp/test/regexp.rb

Comment thread mrbgems/mruby-regexp/src/re_compile.c

takumin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

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.

@takumin

takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Resubmitted as #7035, which has been merged.

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.

1 participant