mruby-regexp: refuse a subject whose bytes are not UTF-8 - #7110
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughRegexp APIs now validate UTF-8 subjects and raise ChangesRegexp encoding validation
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
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.
🧹 Nitpick comments (1)
mrbgems/mruby-regexp/test/regexp_utf8.rb (1)
588-598: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for
String#[]=with a Regexp pattern.The block asserts refusal for
[]at Line 588 and forslice!at Line 598.String#[]=takes the sameRegexp.__searchpath inmrblib/string_regexp.rbat 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
📒 Files selected for processing (3)
mrbgems/mruby-regexp/mrblib/string_regexp.rbmrbgems/mruby-regexp/src/regexp.cmrbgems/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__`.
40850c2 to
b571f1f
Compare
|
@coderabbitai Added, thanks. s = "あ\x80b"
s[/b/] = "!" # ArgumentError: invalid byte sequence in UTF-8, in bothThe assertion now sits next to the |
|
Tip For best results, initiate chat on the files or code changes.
🐇 ✅ 🧠 Learnings usedIf 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. |
|
Closing this in favour of four smaller pull requests. As it stood, a reviewer Split as follows, each green on its own:
3 and 4 touch the same lines of |
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.
CRuby raises
ArgumentErrorwhen a search is given a subject holding a bytethat spells no character. mruby answered for it, so the same program took a
result CRuby would not have produced:
Refuse the search instead, for
=~,match,match?,===,index,rindex,byteindex,byterindex,[],[]=,sub,gsub,scan,split,partition,rpartition,start_with?andslice!.String#scrubis how asubject 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.
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.
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_strand__scan, the last three of whichloop 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_encodingat the topof
gsubwith a block, ofsplitand ofbyteindexcovers their__byte_searchcalls, and__regexp_rsearch, which backsrindex,byterindexandrpartition, 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 aslong that way.
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, three runs each:("あ" * 100000 + "z") =~ /z/, 200 times"hello world" =~ /world/, 100000 timesOne 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; forindex,rindexandbyteindexit answers nil as thisdoes.
Tests
What a match position inside a whole character is remains pinned on subjects
that are whole UTF-8. A build without
MRB_UTF8_STRINGreads no encoding forthe 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 endis the case where that mattered: it was a fuzz-derivedregression 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 teston a full-core build (MRB_UTF8_STRINGthrough mruby-encoding):2246 tests at the first commit and 2247 at the second, all green
rake teston the default gembox (noMRB_UTF8_STRING): 2057 and 2058 tests,all green
The two remaining differences are
split("b"), which CRuby refuses and thealiased C
__splitstill answers, andslice!("b"), which is wrong on wholeUTF-8 too and is not this change
prek run --all-filespasses, except thatmarkdownlintcould not installlocally (npm engine mismatch); no Markdown is touched here
Summary by CodeRabbit
ArgumentErrorconsistently in UTF-8 builds.