Skip to content

mruby-regexp: refuse a subject whose bytes are not UTF-8 - #7110

Closed
takumin wants to merge 2 commits into
mruby:masterfrom
takumin:regexp-reject-broken-encoding-v2
Closed

mruby-regexp: refuse a subject whose bytes are not UTF-8#7110
takumin wants to merge 2 commits into
mruby:masterfrom
takumin:regexp-reject-broken-encoding-v2

Conversation

@takumin

@takumin takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

CRuby raises ArgumentError when a search is given a subject holding a byte
that spells no character. mruby answered for it, so the same program took a
result CRuby would not have produced:

"あ\x80b" =~ /b/         # CRuby: ArgumentError, mruby: 2
"あ\x80b".scan(/./)      # CRuby: ArgumentError, mruby: ["あ", "\x80", "b"]
"\xC0\xBC" =~ /[^<]/     # CRuby: ArgumentError, mruby: 0

Refuse the search instead, for =~, match, match?, ===, index,
rindex, byteindex, byterindex, [], []=, sub, gsub, scan,
split, partition, rpartition, start_with? and slice!. String#scrub is how a
subject like this becomes matchable.

Two commits

The first moves the tests that ask what the engine does with a byte standing
for no character onto a byte-indexed subject. What each of them pins belongs to
the pattern, and a byte-indexed subject puts the same question to the engine,
so they keep asking it after the second commit refuses the UTF-8 subject they
used to go through. That commit changes no behavior and is green on its own.

The second is the refusal itself, which leaves it holding the check, the tests
whose answer it changes, and nothing else.

What goes through untouched

A binary string is indexed by byte from end to end, so its bytes make no claim
that could be broken.

A quoted String pattern is exempt on the grounds CRuby exempts it: a literal is
searched for byte by byte, and the subject is read as UTF-8 nowhere along the
way.

"あ\x80b".sub("b", "!")  #=> "あ\x80!" in both, where /b/ is refused in both
"あ\x80b".scan("b")      # ArgumentError in both: `scan` refuses a literal too

sub, sub!, gsub and gsub! quote a String pattern into a Regexp before
they search, so each carries the fact that it was a literal down to the search
rather than letting the quoting hide it.

A string derived from a binary one does not carry MRB_STR_BINARY with it, so
b + "" and b.sub(...) are refused where b itself is not. That gap predates
this change and is left as it is.

Where the check runs

re_check_encoding() walks the whole subject, so it runs once per search a
method makes rather than once per match it finds.

The entry points that take a subject from Ruby check it themselves:
Regexp#match, #=~, #===, #match?, and the class methods __search,
__search_p, __sub_str, __gsub_str and __scan, the last three of which
loop in C over a subject already checked.

The two entry points an mrblib loop drives leave the check to that loop, which
runs it once on the subject it holds fixed. Regexp.__check_encoding at the top
of gsub with a block, of split and of byteindex covers their
__byte_search calls, and __regexp_rsearch, which backs rindex,
byterindex and rpartition, runs it and passes the flag that says so to
__search.

Checking inside those loops instead walks the subject once per match, which is
quadratic in the number of matches: ("あ," * 20000).split(/,/) took 8 times as
long that way.

Cost

What remains is one walk per search, which a whole subject pays as well. The
flag String#length leaves behind cannot stand in for the walk, since a string
of stray bytes has one byte per character too, so a subject searched twice is
walked twice.

The walk skips a run of ASCII a word at a time and decodes only where a byte
leaves that range, so the cost follows how much of the subject is multi-byte.
Measured at -O3 on a full-core build, three runs each:

before after
("あ" * 100000 + "z") =~ /z/, 200 times 62 ms 117 ms
the same over an ASCII subject of the same size 11.8 ms 12.0 ms
"hello world" =~ /world/, 100000 times 139 ms 140 ms

One known difference

An out of range positive pos still answers nil rather than raising, since
re_char_to_byte() rejects it before the check runs. CRuby raises there for
Regexp#match; for index, rindex and byteindex it answers nil as this
does.

Tests

What a match position inside a whole character is remains pinned on subjects
that are whole UTF-8. A build without MRB_UTF8_STRING reads no encoding for
the bytes to break, so the cases that turn on one answer positions there and
branch on __ENCODING__.

The engine reads a byte-indexed subject through a branch of its own, so the
tests the first commit moved stop covering the UTF-8 one. Regexp - truncated UTF-8 at subject end is the case where that mattered: it was a fuzz-derived
regression test for a read past the end of the string buffer, and the code path
it covered is no longer reachable from Ruby under this rule.

Verified

  • rake test on a full-core build (MRB_UTF8_STRING through mruby-encoding):
    2246 tests at the first commit and 2247 at the second, all green
  • rake test on the default gembox (no MRB_UTF8_STRING): 2057 and 2058 tests,
    all green
  • 30 String-argument methods compared against CRuby 4.0.6 on a broken subject.
    The two remaining differences are split("b"), which CRuby refuses and the
    aliased C __split still answers, and slice!("b"), which is wrong on whole
    UTF-8 too and is not this change
  • prek run --all-files passes, except that markdownlint could not install
    locally (npm engine mismatch); no Markdown is touched here

Summary by CodeRabbit

  • Bug Fixes
    • Improved UTF-8 validation across regular expression search, matching, splitting, scanning, and substitution operations.
    • Invalid UTF-8 subjects now raise ArgumentError consistently in UTF-8 builds.
    • Preserved byte-oriented behavior for literal string operations and binary data.
    • Improved handling of malformed, truncated, and overlong UTF-8 sequences, including character-class matching.

The tests that ask what the engine does with a byte standing for no character
reach it through a UTF-8 subject carrying that byte. What each of them pins
belongs to the pattern: which byte a quantifier binds to, whether `/i` folds a
byte above 127, whether a class holds a byte or the character whose spelling
ends in it, and where a match may start. A byte-indexed subject puts the same
question to the engine, and its own indexing agrees with every position that
comes back, so ask it there.

The commit that follows refuses a subject whose bytes are not UTF-8, which is
where these subjects stop reaching the engine at all. Moving them first leaves
that commit holding the refusal and the cases that turn on it, rather than this
rewrite as well.

The engine reads a byte-indexed subject through a branch of its own, so these
stop covering the UTF-8 one. The cases where that branch is the point, and the
ones whose answer the refusal changes, stay where they are and are dealt with
next.
@takumin
takumin requested a review from matz as a code owner August 12, 2026 08:56
@coderabbitai

coderabbitai Bot commented Aug 12, 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: 9102c5d2-35e6-44b7-822f-dc280a1d88ba

📥 Commits

Reviewing files that changed from the base of the PR and between 40850c2 and b571f1f.

📒 Files selected for processing (1)
  • mrbgems/mruby-regexp/test/regexp_utf8.rb

📝 Walkthrough

Walkthrough

Regexp APIs now validate UTF-8 subjects and raise ArgumentError for invalid encoding. String regexp paths preserve literal byte behavior and pass validation state to native helpers. Tests cover malformed sequences, byte-indexed subjects, and affected regexp and String APIs.

Changes

Regexp encoding validation

Layer / File(s) Summary
Native validation and helper contracts
mrbgems/mruby-regexp/src/regexp.c
Native regexp operations validate subjects. Internal helpers accept prior validation state. Regexp.__check_encoding is exposed to mrblib.
String regexp path integration
mrbgems/mruby-regexp/mrblib/string_regexp.rb
Substitution, splitting, reverse search, and byteindex paths perform explicit checks and preserve literal String patterns.
UTF-8 and byte-oriented coverage
mrbgems/mruby-regexp/test/regexp_utf8.rb
Tests cover invalid UTF-8 rejection, byte-indexed matching, malformed sequences, and regexp-aware String methods.

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

Sequence Diagram(s)

sequenceDiagram
  participant StringRegexp
  participant RegexpCheckEncoding
  participant RegexpSearch
  participant RegexpEngine
  StringRegexp->>RegexpCheckEncoding: Check subject encoding
  RegexpCheckEncoding->>RegexpEngine: Validate UTF-8 subject
  StringRegexp->>RegexpSearch: Call checked search or substitution
  RegexpSearch->>RegexpEngine: Execute regexp operation
  RegexpEngine-->>RegexpSearch: Return match or substitution result
  RegexpSearch-->>StringRegexp: Return result
Loading

Possibly related PRs

  • mruby/mruby#7005: Adds the related regexp pattern-checking helper used by these paths.
  • mruby/mruby#7068: Modifies related UTF-8 validation and matching behavior.
  • mruby/mruby#7075: Introduces regexp-aware String search paths updated by this change.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: rejecting regexp subjects that contain invalid UTF-8 bytes.
✨ 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.

🧹 Nitpick comments (1)
mrbgems/mruby-regexp/test/regexp_utf8.rb (1)

588-598: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for String#[]= with a Regexp pattern.

The block asserts refusal for [] at Line 588 and for slice! at Line 598. String#[]= takes the same Regexp.__search path in mrblib/string_regexp.rb at Line 426, so it also refuses an invalid subject. Add one assertion so a future change to that path does not go unnoticed.

🧪 Proposed additional assertion
   assert_raise(ArgumentError) { broken.start_with?(/b/) }
   assert_raise(ArgumentError) { broken.dup.slice!(/b/) }
+  assert_raise(ArgumentError) { broken.dup[/b/] = "!" }
🤖 Prompt for 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.

In `@mrbgems/mruby-regexp/test/regexp_utf8.rb` around lines 588 - 598, Add an
assert_raise(ArgumentError) case for assigning through String#[]= with a Regexp
pattern, using the existing broken invalid subject and /b/ pattern near the
other regexp operation assertions. Keep the assertion focused on confirming
invalid subjects are rejected.
🤖 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.

Nitpick comments:
In `@mrbgems/mruby-regexp/test/regexp_utf8.rb`:
- Around line 588-598: Add an assert_raise(ArgumentError) case for assigning
through String#[]= with a Regexp pattern, using the existing broken invalid
subject and /b/ pattern near the other regexp operation assertions. Keep the
assertion focused on confirming invalid subjects are rejected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 39f085e1-e40d-4de4-a70c-7c6c84866a94

📥 Commits

Reviewing files that changed from the base of the PR and between 6817857 and 40850c2.

📒 Files selected for processing (3)
  • mrbgems/mruby-regexp/mrblib/string_regexp.rb
  • mrbgems/mruby-regexp/src/regexp.c
  • mrbgems/mruby-regexp/test/regexp_utf8.rb

CRuby raises `ArgumentError` when a search is given a subject holding a byte
that spells no character. mruby answered for it instead, so the same program
took a result CRuby would not have produced:

```ruby
"あ\x80b" =~ /b/         # CRuby: ArgumentError, mruby: 2
"あ\x80b".scan(/./)      # CRuby: ArgumentError, mruby: ["あ", "\x80", "b"]
"\xC0\xBC" =~ /[^<]/     # CRuby: ArgumentError, mruby: 0
```

Follow CRuby for `=~`, `match`, `match?`, `===`, `index`, `rindex`,
`byteindex`, `byterindex`, `[]`, `[]=`, `sub`, `gsub`, `scan`, `split`,
`partition`, `rpartition`, `start_with?` and `slice!`. `String#scrub` is how
a subject like this becomes matchable.

Two subjects go through untouched. A binary string is indexed by byte from
end to end, so its bytes make no claim that could be broken. A quoted String
pattern is exempt on the grounds CRuby exempts it: a literal is searched for
byte by byte and the subject is read as UTF-8 nowhere along the way, so

```ruby
"あ\x80b".sub("b", "!")  #=> "あ\x80!" in both, where /b/ is refused in both
"あ\x80b".scan("b")      # ArgumentError in both: `scan` refuses a literal too
```

`sub`, `sub!`, `gsub` and `gsub!` quote a String pattern into a Regexp before
they search, so each carries the fact that it was a literal down to the
search rather than letting the quoting hide it. A string derived from a
binary one does not carry `MRB_STR_BINARY` with it, so `b + ""` and
`b.sub(...)` are refused where `b` itself is not; that gap predates this
change and is left as it is.

`re_check_encoding()` walks the whole subject, so it runs once per search a
method makes rather than once per match it finds. The entry points that take
a subject from Ruby check it themselves: `Regexp#match`, `#=~`, `#===`,
`#match?`, and the class methods `__search`, `__search_p`, `__sub_str`,
`__gsub_str` and `__scan`, the last three of which loop in C over a subject
already checked. The two entry points an mrblib loop drives leave the check
to that loop, which runs it once on the subject it holds fixed:
`Regexp.__check_encoding` at the top of `gsub` with a block, of `split` and
of `byteindex` covers their `__byte_search` calls, and `__regexp_rsearch`,
which backs `rindex`, `byterindex` and `rpartition`, runs it and passes the
flag that says so to `__search`. Checking inside those loops instead walks
the subject once per match, which is quadratic in the number of matches:
`("あ," * 20000).split(/,/)` took 8 times as long that way.

What remains is one walk per search, which a whole subject pays as well. The
flag `String#length` leaves behind cannot stand in for the walk, since a
string of stray bytes has one byte per character too, so a subject searched
twice is walked twice. The walk skips a run of ASCII a word at a time and
decodes only where a byte leaves that range, so the cost follows how much of
the subject is multi-byte: at `-O3`, `("あ" * 100000 + "z") =~ /z/` takes
about twice as long as before, while the same search over an ASCII subject
of the same size, and a search over a short one, are inside the noise.

An out of range positive `pos` still answers nil rather than raising, since
`re_char_to_byte()` rejects it before the check runs. CRuby raises there for
`Regexp#match`; for `index`, `rindex` and `byteindex` it answers nil as this
does.

The tests the commit before this one did not move are the ones whose answer
this changes, and they assert the refusal where the question belonged to
reading the subject as UTF-8. What a match position inside a whole character
is remains pinned on subjects that are whole UTF-8. A build without
`MRB_UTF8_STRING` reads no encoding for the bytes to break, so the cases that
turn on one answer positions there and branch on `__ENCODING__`.
@takumin
takumin force-pushed the regexp-reject-broken-encoding-v2 branch from 40850c2 to b571f1f Compare August 12, 2026 10:24
@takumin

takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai Added, thanks. String#[]= does reach Regexp.__search, and it
refuses a broken subject the way CRuby does:

s = "あ\x80b"
s[/b/] = "!"   # ArgumentError: invalid byte sequence in UTF-8, in both

The assertion now sits next to the slice! one in the second commit, and
[]= is named alongside the other methods in the commit message and in the
description, which had left it out.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@takumin Thanks for the update. The regression assertion covers String#[]= through Regexp.__search, and the commit message and PR description now list the method.

🐇 ✅


🧠 Learnings used
Learnt from: takumin
Repo: mruby/mruby PR: 7063
File: mrbgems/mruby-regexp/mrblib/string_regexp.rb:288-288
Timestamp: 2026-08-09T23:12:08.334Z
Learning: In `mrbgems/mruby-regexp/mrblib/string_regexp.rb`, `String#[]`, `String#[]=`, and `String#slice!` select their Regexp-specific path with non-overridable `Regexp === args[0]`, then intentionally call `args[0].match(self)`. `Regexp.__check_pattern` is only a type check for APIs that also accept String patterns; it does not provide a resolved non-overridable matching helper. A genuine Regexp subclass may override `Regexp#match` for these methods as ordinary Ruby dispatch.

Learnt from: takumin
Repo: mruby/mruby PR: 7067
File: mrbgems/mruby-regexp/mrblib/string_regexp.rb:0-0
Timestamp: 2026-08-10T08:46:19.230Z
Learning: In `mrbgems/mruby-regexp/mrblib/string_regexp.rb`, `String#match`, `String#match?`, `String#sub`, `String#sub!`, `String#gsub`, `String#gsub!`, and `String#scan` use `Regexp.__check_pattern`. `String#split` delegates nil and String patterns to `__split` and uses `Regexp.__check_pattern` only on its Regexp path. `String#[]`, `String#[]=`, and `String#slice!` select their Regexp paths with `Regexp === pattern`; `String#=~` rejects String and forwards every other argument to that argument's `=~`. Accepted String patterns are compiled for `match` and `match?`, and quoted with `Regexp.escape` for `sub`, `sub!`, `gsub`, `gsub!`, and `scan`.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@takumin

takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Closing this in favour of four smaller pull requests. As it stood, a reviewer
had to read a behaviour change and a rewrite of most of regexp_utf8.rb at the
same time, and the two argue for each other in a way that is hard to check in
one pass.

Split as follows, each green on its own:

  1. mruby-regexp: ask the engine's byte questions of a byte-indexed subject too #7115 asks the engine's byte questions of a byte-indexed subject too. Test
    only, purely additive, no behaviour change. Independent of the rest.
  2. mruby-regexp: ask the malformed sequence cases of a byte-indexed subject too #7116 does the same for the malformed sequence cases (overlong, surrogate,
    above U+10FFFF, truncated at the subject end, truncated in a class). Test
    only, purely additive, independent of mruby-regexp: ask the engine's byte questions of a byte-indexed subject too #7115.
  3. The refusal itself, wherever the search runs in C: =~, match, match?,
    ===, index, rindex, byterindex, [], []=, sub, sub!, gsub
    with a replacement, gsub!, scan, partition, rpartition,
    start_with? and slice!, with the quoted String pattern exempt as CRuby
    exempts it. With 1 and 2 in, the tests whose subject this refuses keep their
    question and lose only the subject that no longer reaches the engine, so this
    piece is the check, the tests it changes, and nothing else.
  4. Regexp.__check_encoding for the three entry points an mrblib loop drives
    (gsub with a block, split, byteindex), plus hoisting the check out of
    the backward walk that backs rindex, byterindex and rpartition. One
    walk per search rather than one per match: measured at 105 ms against
    1404 ms for ("あ," * 20000).split(/,/).

3 and 4 touch the same lines of regexp_utf8.rb as 1 and 2, so I will open them
once those two are settled rather than post a diff that carries their content
along. The behaviour, the exemptions and the cost are the same as described
here; nothing has been dropped from the plan.

@takumin

takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

#7115 and #7116 are in, so the third piece is #7119. The fourth follows once that one is settled, since the two touch the same lines of regexp_utf8.rb.

@takumin
takumin deleted the regexp-reject-broken-encoding-v2 branch August 12, 2026 12:56
matz pushed a commit that referenced this pull request Aug 13, 2026
The two commits before this refuse a search whose subject holds a byte that
spells no character, and exempt the literal a quoted String pattern is searched
for. `split` is what they left: CRuby refuses it whatever the pattern, and here
a String or nil pattern answered.

```ruby
"あ\x80b".split("b")  # CRuby: ArgumentError, before this: ["あ\x80"]
"あ\x80b".split       # CRuby: ArgumentError, before this: ["あ\x80b"]
"あ\x80b".split(" ")  # CRuby: ArgumentError, before this: ["あ\x80b"]
```

`String#split` hands those three to core's `split`, which this gem keeps under
`__split`, so the path reaches no search of the gem's and the check every other
call runs never fires. The refusal goes at the entry instead, through a
`Regexp.__check_encoding` class method that is `re_check_encoding()` under a
name mrblib can call.

That name was in #7110 for a different reason: to run the check once at the
entry to each mrblib loop rather than once per match. It went away when the
flag core leaves on a string it has read made the two placements measure the
same. What brings it back is a path no search covers, not what a search costs.

A limit of 1 hands the subject back whole without reading it, whatever the
pattern, and CRuby answers there too, so the check waits behind it. The limit
is converted before either way: `"あ\x80b".split("b", "x")` raises `TypeError`
in both.

A binary subject goes through as it did, since `mrb_str_valid_encoding_p()`
takes a byte-indexed string as valid whatever its bytes are, and a build
without `MRB_UTF8_STRING` reads no encoding for them to break.
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