Skip to content

mruby-regexp: refuse a subject whose bytes are not UTF-8 in the C searches - #7119

Closed
takumin wants to merge 1 commit into
mruby:masterfrom
takumin:regexp-refuse-broken-utf8-subject
Closed

mruby-regexp: refuse a subject whose bytes are not UTF-8 in the C searches#7119
takumin wants to merge 1 commit into
mruby:masterfrom
takumin:regexp-refuse-broken-utf8-subject

Conversation

@takumin

@takumin takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Third of the four pieces #7110 was split into, on top of #7115 and #7116.

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:

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

Refuse the search instead, wherever the search itself runs in C: =~, match,
match?, ===, index, rindex, byterindex, [], []=, sub, sub!,
gsub with a replacement, gsub!, scan, partition, rpartition,
start_with? and slice!. String#scrub is how a subject like this becomes
matchable.

What is left for the fourth piece

gsub with a block, split and byteindex keep answering for now:

"あ\x80b".split(/b/)        # ["あ\x80"], where "あ\x80b" =~ /b/ raises here
"あ\x80b".byteindex(/b/)    # 4
"あ\x80b".gsub(/b/) { "!" } # "あ\x80!"

Each drives Regexp.__byte_search from a loop in mrblib that searches the same
subject once per match, so a check in the search walks the whole subject again
for every match it finds. What they need is one check at the entry to the
method, which is the next piece, measured there at 105 ms against 1404 ms for
("あ," * 20000).split(/,/).

__regexp_rsearch, which backs rindex, byterindex and rpartition, is a
loop of that kind too. It refuses here, because each of its searches checks;
the same piece hoists the check out of it.

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. Regexp#match, #=~, #===
and #match? check what arrives from Ruby, and so do 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.

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, the fastest of seven runs each:

before after
("あ" * 100000 + "z") =~ /z/, 200 times 70 ms 117 ms
the same over an ASCII subject of the same byte size 7.7 ms 6.6 ms
"hello world" =~ /world/, 100000 times 213 ms 149 ms

The last two are inside the noise of the machine they were taken on, which is
what the second one answering faster after the change says.

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 and rindex it answers nil as this does.

Tests

The tests whose subject this refuses are the ones #7115 and #7116 gave a
byte-indexed subject to ask through, so each keeps its question and loses only
the subject that no longer reaches the engine. What a match position inside a
whole character is stays pinned on subjects that are whole UTF-8.

Regexp - truncated UTF-8 at subject end is worth calling out: it was a
fuzz-derived regression test for a read past the end of the string buffer, and
the UTF-8 half of the walk it covered is no longer reachable from Ruby under
this rule. The byte-indexed half #7116 added still walks to the end of the
buffer and is what remains.

The one case that turns on what mruby knows about the string rather than on the
engine keeps both answers and branches on __ENCODING__, since a build without
MRB_UTF8_STRING reads no encoding for the bytes to break.

Verified

  • rake test on a full-core build (MRB_UTF8_STRING through mruby-encoding):
    2254 tests, all green
  • rake test on the default gembox (no MRB_UTF8_STRING): 2061 tests, all
    green
  • 34 calls on a broken subject compared against CRuby 4.0.6. All agree except
    the three the next piece takes (byteindex, gsub with a block, split),
    split("b"), which CRuby refuses and the aliased C __split still answers
    and which is not this change, and String#inspect on a byte-indexed string,
    which prints the bytes rather than escaping them
  • 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
    • Added UTF-8 validation for regular expression operations on text subjects.
    • Invalid UTF-8 now raises ArgumentError instead of producing unreliable matches.
    • Preserved byte-oriented matching for literal strings and byte-indexed data.
    • Improved consistency across matching, scanning, substitution, and global substitution operations.

…rches

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 wherever the search itself runs in C: `=~`, `match`, `match?`,
`===`, `index`, `rindex`, `byterindex`, `[]`, `[]=`, `sub`, `sub!`, `gsub` with
a replacement, `gsub!`, `scan`, `partition`, `rpartition`, `start_with?` and
`slice!`. `String#scrub` is how a subject like this becomes matchable.

`gsub` with a block, `split` and `byteindex` are left answering for now. Each
drives `Regexp.__byte_search` from a loop in mrblib that searches the same
subject once per match, and a check inside such a loop walks the subject once
per match too, which is quadratic in the number of matches. What they need is
one check at the entry to the method, which is the commit after this one.
`__regexp_rsearch`, which backs `rindex`, `byterindex` and `rpartition`, is a
loop of that kind as well; it refuses here because each of its searches checks,
and that commit hoists the check out of it.

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. `Regexp#match`, `#=~`, `#===`
and `#match?` check what arrives from Ruby, and so do 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.

What that costs 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` on a full-core build, the fastest of seven runs each:

| | before | after |
|---|---|---|
| `("あ" * 100000 + "z") =~ /z/`, 200 times | 70 ms | 117 ms |
| the same over an ASCII subject of the same byte size | 7.7 ms | 6.6 ms |
| `"hello world" =~ /world/`, 100000 times | 213 ms | 149 ms |

The last two are inside the noise of the machine they were taken on, which is
what the second one answering faster after the change says.

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` and `rindex` it answers nil as this does.

The tests whose subject this refuses are the ones the two commits before this
one gave a byte-indexed subject to ask through, so each keeps its question and
loses only the subject that no longer reaches the engine. What a match position
inside a whole character is stays pinned on subjects that are whole UTF-8. The
one case that turns on what mruby knows about the string rather than on the
engine keeps both answers and branches on `__ENCODING__`, since a build without
`MRB_UTF8_STRING` reads no encoding for the bytes to break.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Regexp operations now reject invalid UTF-8 subjects in UTF-8 builds. Literal String substitutions preserve byte-oriented behavior through internal regexp helpers. Tests cover malformed sequences, byte indexing, API validation, and substitution exceptions.

Changes

Regexp UTF-8 behavior

Layer / File(s) Summary
Encoding validation and helper contracts
mrbgems/mruby-regexp/src/regexp.c
Regexp matching, searching, scanning, and substitution paths validate non-binary subjects. Internal helpers accept an optional checked flag.
Literal substitution helper wiring
mrbgems/mruby-regexp/mrblib/string_regexp.rb, mrbgems/mruby-regexp/src/regexp.c
sub, sub!, gsub, and gsub! preserve literal String metadata and pass the original pattern to substitution helpers.
UTF-8 and byte-indexed regression coverage
mrbgems/mruby-regexp/test/regexp_utf8.rb
Tests cover invalid UTF-8 rejection, byte-indexed matching, malformed sequences, API behavior, and literal substitution exceptions.

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

Possibly related PRs

  • mruby/mruby#7110: Modifies the same regexp helpers and String substitution paths for UTF-8 validation behavior.
  • mruby/mruby#7115: Extends related byte-indexed UTF-8 tests in regexp_utf8.rb.
  • mruby/mruby#6989: Modifies the same String#sub and String#gsub argument-handling paths.

Suggested labels: mrbgems

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 summarizes the main change: C-based regexp searches now reject subjects with invalid UTF-8 bytes.
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/regexp.c`:
- Line 390: In the regexp matching flows, call re_check_encoding() immediately
after match_operand() and before re_char_to_byte() in
mrbgems/mruby-regexp/src/regexp.c lines 390, 445, and 481. Add positive and
negative out-of-range position tests for invalid subjects in
mrbgems/mruby-regexp/test/regexp_utf8.rb lines 227-230, verifying the subject
raises ArgumentError rather than returning nil.
🪄 Autofix

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: 2d061b7f-9841-4a8b-b5ee-8f6c74a94116

📥 Commits

Reviewing files that changed from the base of the PR and between 1c034f7 and 48b1dab.

📒 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

return mrb_nil_value();
}

re_check_encoding(mrb, str);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Validate the subject before converting pos.

re_char_to_byte() can return an out-of-range result before re_check_encoding() runs. For example, /b/.match("a\x80b", 4) returns nil instead of raising ArgumentError.

  • mrbgems/mruby-regexp/src/regexp.c#L390-L390: Call re_check_encoding() immediately after match_operand() and before re_char_to_byte().
  • mrbgems/mruby-regexp/src/regexp.c#L445-L445: Apply the same ordering in Regexp.__search.
  • mrbgems/mruby-regexp/src/regexp.c#L481-L481: Apply the same ordering in exec_match_p.
  • mrbgems/mruby-regexp/test/regexp_utf8.rb#L227-L230: Add positive and negative out-of-range position cases for invalid subjects.
📍 Affects 2 files
  • mrbgems/mruby-regexp/src/regexp.c#L390-L390 (this comment)
  • mrbgems/mruby-regexp/src/regexp.c#L445-L445
  • mrbgems/mruby-regexp/src/regexp.c#L481-L481
  • mrbgems/mruby-regexp/test/regexp_utf8.rb#L227-L230
🤖 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/src/regexp.c` at line 390, In the regexp matching flows,
call re_check_encoding() immediately after match_operand() and before
re_char_to_byte() in mrbgems/mruby-regexp/src/regexp.c lines 390, 445, and 481.
Add positive and negative out-of-range position tests for invalid subjects in
mrbgems/mruby-regexp/test/regexp_utf8.rb lines 227-230, verifying the subject
raises ArgumentError rather than returning nil.

@takumin

takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Closing this one: it carried the deletion of roughly a hundred lines of assertions alongside the check itself, and deleting tests is worth a pull request of its own rather than a paragraph inside one that changes behaviour.

Split again, so the deletions land first:

Nothing has changed in the behaviour, the exemptions or the cost; only how it is cut up.

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