mruby-regexp: refuse a subject whose bytes are not UTF-8 in the C searches - #7119
mruby-regexp: refuse a subject whose bytes are not UTF-8 in the C searches#7119takumin wants to merge 1 commit into
Conversation
…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.
📝 WalkthroughWalkthroughRegexp 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. ChangesRegexp UTF-8 behavior
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: 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/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
📒 Files selected for processing (3)
mrbgems/mruby-regexp/mrblib/string_regexp.rbmrbgems/mruby-regexp/src/regexp.cmrbgems/mruby-regexp/test/regexp_utf8.rb
| return mrb_nil_value(); | ||
| } | ||
|
|
||
| re_check_encoding(mrb, str); |
There was a problem hiding this comment.
🎯 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: Callre_check_encoding()immediately aftermatch_operand()and beforere_char_to_byte().mrbgems/mruby-regexp/src/regexp.c#L445-L445: Apply the same ordering inRegexp.__search.mrbgems/mruby-regexp/src/regexp.c#L481-L481: Apply the same ordering inexec_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-L445mrbgems/mruby-regexp/src/regexp.c#L481-L481mrbgems/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.
|
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. |
Third of the four pieces #7110 was split into, on top of #7115 and #7116.
CRuby raises
ArgumentErrorwhen a search is given a subject holding a bytethat spells no character. mruby answered for it instead, so the same program
took a result CRuby would not have produced:
Refuse the search instead, wherever the search itself runs in C:
=~,match,match?,===,index,rindex,byterindex,[],[]=,sub,sub!,gsubwith a replacement,gsub!,scan,partition,rpartition,start_with?andslice!.String#scrubis how a subject like this becomesmatchable.
What is left for the fourth piece
gsubwith a block,splitandbyteindexkeep answering for now:Each drives
Regexp.__byte_searchfrom a loop in mrblib that searches the samesubject 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 backsrindex,byterindexandrpartition, is aloop 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.
sub,sub!,gsubandgsub!quote a String pattern into aRegexpbeforethey 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_BINARYwith it, sob + ""andb.sub(...)are refused wherebitself is not. That gap predatesthis change and is left as it is.
Where the check runs
re_check_encoding()walks the whole subject, so it runs once per search amethod 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_strand__scan, the last threeof 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#lengthleaves behind cannot stand in for the walk, since a stringof 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
-O3on a full-core build, the fastest of seven runs each:("あ" * 100000 + "z") =~ /z/, 200 times"hello world" =~ /world/, 100000 timesThe 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
posstill answers nil rather than raising, sincere_char_to_byte()rejects it before the check runs. CRuby raises there forRegexp#match; forindexandrindexit 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 endis worth calling out: it was afuzz-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 withoutMRB_UTF8_STRINGreads no encoding for the bytes to break.Verified
rake teston a full-core build (MRB_UTF8_STRINGthrough mruby-encoding):2254 tests, all green
rake teston the default gembox (noMRB_UTF8_STRING): 2061 tests, allgreen
the three the next piece takes (
byteindex,gsubwith a block,split),split("b"), which CRuby refuses and the aliased C__splitstill answersand which is not this change, and
String#inspecton a byte-indexed string,which prints the bytes rather than escaping them
prek run --all-filespasses, except thatmarkdownlintcould not installlocally (npm engine mismatch); no Markdown is touched here
Summary by CodeRabbit
ArgumentErrorinstead of producing unreliable matches.