mruby-regexp: refuse a subject whose bytes are not UTF-8 - #7126
Conversation
|
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)
📝 WalkthroughWalkthroughThe regexp implementation validates non-binary UTF-8 subjects before matching, searching, replacement, and scanning. Tests cover malformed UTF-8 rejection, binary-string support, valid UTF-8 behavior, position handling, and lookbehind behavior. ChangesUTF-8 subject validation
Estimated code review effort: 3 (Moderate) | ~20 minutes 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 |
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 a search reads the subject: `=~`, `match`, `match?`,
`===`, `index`, `rindex`, `byteindex`, `byterindex`, `[]`, `[]=`, `sub`, `sub!`,
`gsub`, `gsub!`, `scan`, `split`, `partition`, `rpartition`, `start_with?` and
`slice!`. `String#scrub` is how a subject like this becomes matchable.
A binary string goes through untouched, since it is indexed by byte from end to
end and its bytes make no claim that could be broken. 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.
A quoted String pattern is refused here as well, where CRuby answers for it.
CRuby searches for a literal byte by byte and reads the subject as UTF-8 nowhere
along the way, so
```ruby
"あ\x80b".sub("b", "!") # CRuby: "あ\x80!", here: ArgumentError
```
Exempting it means carrying the fact that the pattern was a literal from `sub`
down to the search it makes, which `sub!` and `gsub!` pay for by quoting the
pattern twice, so that is a change of its own and comes next. `scan` needs none
of it: CRuby refuses a literal there too.
The check goes in each search rather than at the entry to the methods that drive
one, because core remembers a string it has read as valid UTF-8. Most of the
entry points here search once for what arrives from Ruby, but `__byte_search`,
which the mrblib loops of `gsub`, `split` and `byteindex` drive, is called once
per match, and `__regexp_rsearch` steps `__search` over every match position.
Those turns read the flag core left on the subject after the first one, so what
they cost is a flag test and not a walk. The flag `String#length` leaves behind
cannot stand in for it, since a string of stray bytes has one byte per character
too, which is why only the walk decides.
What a subject nothing has walked yet pays is one walk. It 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 fifteen runs each, with `mb` for `"あ" * 100000` and `cs` for
`"あ," * 20000`:
| | before | after |
|---|---|---|
| `(mb + "z") =~ /z/`, 200 times, less the cost of building the subjects | 52.1 ms | 103.8 ms |
| the same with an ASCII subject of the same byte size | 1.2 ms | 0.7 ms |
| `"hello world" =~ /world/`, 100000 times | 133.6 ms | 133.0 ms |
| `cs.split(/,/)`, one walk and 20000 searches | 71.1 ms | 61.4 ms |
| `cs.gsub(/,/) { ";" }`, the same | 75.1 ms | 72.9 ms |
| `cs.rindex(/,/)`, one walk and 20000 match positions | 2105.9 ms | 2114.6 ms |
The first row builds a subject for every search so that every search walks one.
The rest reuse a subject, which is why the loop rows show what the flag costs
rather than what the walk does, and they and the ASCII row are inside the noise
of the machine they were taken on.
A `pos` outside the subject still answers nil rather than raising, since
`re_char_to_byte()` rejects it before the check runs. `String#index` answers nil
for one in CRuby too; `Regexp#match` raises there, where this answers nil.
`rindex` clamps a position past the end to the end rather than rejecting it, so
it reaches the check and refuses, which is what CRuby answers as well.
Most of the tests that ask what the engine does with a byte standing for no
character already put the question to a byte-indexed subject, so this refuses
none of them. What is added is the list of methods that refuse. The two blocks
that ask it of a subject read as UTF-8 keep both answers and branch on
`__ENCODING__`, since a build without `MRB_UTF8_STRING` reads no encoding for
the bytes to break. One turned on what mruby knows about the string and now
asks that the refusal not turn on it. The other measures a lookbehind whose
bytes spell no character, and a UTF-8 build refuses such a subject before the
rewind runs, so only the build that reads no encoding still reaches the
measurement; the byte rewind it also asserts is reached in both.
80c781b to
0f47e39
Compare
mruby-regexp: exempt a quoted String pattern from the subject check Carries #7126 as well; the lookbehind assertions the subject check makes unaskable were moved to a byte-indexed subject in the commit before this.
|
Merged, and thank you for the split. The commits are in master as 80c781b and 456a708; I took them locally rather than through the button, so these stay open and I am closing them by hand. Two things happened on the way in that are worth recording. The lookbehind assertions. #7125 landed before these, and its subjects are the ones this refuses, so the block crashed once both were in. I resolved it by dropping those assertions to the byte-indexed forms. You had solved the same problem better in the rebase: asserting the refusal on a build that reads an encoding, and keeping the widths on the one that does not. I have taken your version (c2b7b3a). Mine recorded that the question could no longer be asked; yours asks the new one. A mistake of mine, since it touched your work. While measuring whether the lookbehind fix was still reachable after the refusal, I reverted What I checked before merging, on top of the verification in your description:
On the piece still to come: |
A search under mruby-regexp refuses a subject whose bytes are not UTF-8 (mruby#7126), and what an unwalked subject pays there is one walk. The walk's answer is remembered on the string it read, and mruby#7131 lets a string already known ASCII answer without one, but the operations a subject is usually built with still dropped what their sources knew: `+` copied no flags at all, `<<` and interpolation took them down on every append, and a piece cut out of a walked string kept only the single-byte mark. So the subject of `(mb + "z") =~ /z/` walked 300001 bytes per search, 300000 of which a walk had already read. Validity survives every one of those builds, each for a reason the build can afford to check: - Concatenation: bytes that read as UTF-8 followed by bytes that do read as UTF-8 end to end, each side's sequences being complete on their own. `+`, `<<`, `concat` and interpolation hand the result the flag when both sides carry it, and two ASCII sides make an ASCII whole, which carries MRB_STR_SINGLE_BYTE the same way. An empty side carries both by holding nothing. - Repetition: `str * n` holds nothing but the source's bytes over and over, so the source's answer is the result's. - A byte range: a range of a valid string reads whole exactly when both cuts land on character boundaries, and in a valid string every byte that is not a continuation byte begins a character. So the byte each cut lands on decides it--the one at `beg`, and the one just past the range, with the end of the string standing for a boundary--in two byte tests, no walk. This is what hands a searched subject's flag down to the pieces `split`, `scan` and `byteslice` cut from it. - Measuring: past the ASCII run the count now returns early on, it decodes every sequence anyway, so the same walk also says whether each spelled a character, and leaves that answer where the next search finds it. A stray byte still counts as one character, which is the count this has always returned. What would defeat the hand-off is the short literal nothing has walked: the "z" above arrives from the pool flagless on every evaluation, and one flagless side spoils the pair. So a side of at most 16 bytes is walked on the spot--all-ASCII answers in a word test--bounded by the length check and below the cost of the copy its caller is doing anyway. That is what lets `mb + "z"`, `"#{mb}z"` and the `"あ" * 100000` that built `mb` come out flagged. The answer is not written back to the walked side: it is most often a literal this evaluation made and the next one remakes, so there is no later read to save a walk for, and re-walking a reused one costs these same few bytes. The flag never claims more than a walk of the same bytes would find, so no answer changes. The test that puts the same bytes through the same operations warm and cold now runs the new builders too, over broken bases as well as whole ones, and a new block pins the boundary-byte cases where a cut must not inherit. At `-O3` on a full-core build, the fastest of seven runs interleaved between builds, with `mb = "あ" * 100000`: | | master | with this | |---|---|---| | `(mb + "z") =~ /z/`, 200 times, less the builds | 178.2 ms | 96.1 ms | | `"#{mb}z" =~ /z/`, the same | 195.3 ms | 111.9 ms | | `"hello world" =~ /world/`, 100000 times | 235.0 ms | 242.0 ms | | `s << "abcdefgh"`, 100000 times, never searched | 15.1 ms | 16.1 ms | The first two land where they stood before mruby#7126 made subjects checkable at all (96.3 ms and 113.7 ms on the same machine): nothing walks but the "z". Pieces split off a walked subject stop paying their own first walk the same way. The last row is the cost's worst case, a loop of nothing but eight-byte appends to a buffer that stays known-valid, where the flag reads are around a third of the growth and the bounded walk the rest; it moved six to twelve percent across measurement rounds here. A loop appending sides longer than the bound does not walk them, and one whose buffer was never known valid drops to the flag tests alone. `split`, `gsub`, `rindex`, the third row and String#length move a few percent either way between rounds, with no changed code on their paths--the jitter code layout pays for this file growing. A literal born flagged would close the walk of the "z" too, but the pool entry has no bit left to carry the answer in (IREP_TT_STR spends them all), and stamping it at OP_STRING would only move the same walk there. That is a dump-format question for another day. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6X4dd382CCZmfrXSpFsb8
CRuby raises
ArgumentErrorwhen a search is given a subject holding a byte that spells no character. mruby answers for it instead, so the same program takes a result CRuby would not have produced:This follows CRuby wherever a search reads the subject:
=~,match,match?,===,index,rindex,byteindex,byterindex,[],[]=,sub,sub!,gsub,gsub!,scan,split,partition,rpartition,start_with?andslice!.String#scrubis how a subject like this becomes matchable.A binary string goes through untouched, since it is indexed by byte from end to end and its bytes make no claim that could be broken.
What this leaves for the next PR
A quoted String pattern is refused here as well, where CRuby answers for it:
CRuby searches for a literal byte by byte and reads the subject as UTF-8 nowhere along the way. Exempting it means carrying the fact that the pattern was a literal from
subdown to the search it makes, which is a change of its own; it is the PR that follows this one.Where the check goes, and what it costs
The check goes in each search rather than at the entry to the methods that drive one, because core remembers a string it has read as valid UTF-8. Most entry points search once for what arrives from Ruby, but
__byte_search, which the mrblib loops ofgsub,splitandbyteindexdrive, is called once per match, and__regexp_rsearchsteps__searchover every match position. Those turns read the flag core left on the subject after the first one, so what they cost is a flag test and not a walk. Agsubblock that modifies the subject clears the flag and the next turn walks what is left; mruby allows such a block where CRuby raisesRuntimeError: string modified, and this leaves that as it is.The walk skips a run of ASCII a word at a time and decodes only where a byte leaves that range, so what a subject nothing has walked yet pays follows how much of it is multi-byte. At
-O3on a full-core build, the fastest of fifteen runs each, withmbfor"あ" * 100000andcsfor"あ," * 20000:(mb + "z") =~ /z/, 200 times, less the cost of building the subjects"hello world" =~ /world/, 100000 timescs.split(/,/), one walk and 20000 searchescs.gsub(/,/) { ";" }, the samecs.rindex(/,/), one walk and 20000 match positionsThe first row builds a subject for every search so that every search walks one. The rest reuse a subject, which is why the loop rows show what the flag costs rather than what the walk does, and they and the ASCII row are inside the noise of the machine they were taken on.
What changed since #7110
#7110 was all of this in one PR. Its test moves are in master already (#7115, #7116, #7120, #7121), so what is left here is the refusal itself, and the quoted String pattern it exempted is the PR that follows this one. Three things it said do not hold, which is why the shape is different:
Regexp.__check_encodingclass method and a flag passed into__searchout of__regexp_rsearch, on the grounds that checking inside__byte_searchwalks the subject once per match, quadratic in the number of matches, and that("あ," * 20000).split(/,/)took 8 times as long that way. Measured again on master it does not: the flag core sets makes every turn after the first a flag test, and neither placement is distinguishable from the other or from master. The class method and the hoist are gone with it, and__byte_searchholds the check.posanswers nil forindex,rindexandbyteindex.rindexclamps a position past the end to the end rather than rejecting it, so it reaches the check and refuses, which is what CRuby answers too.Positions
A
posoutside the subject still answers nil rather than raising, sincere_char_to_byte()rejects it before the check runs.String#indexanswers nil for one in CRuby too;Regexp#matchraises there, where this answers nil.rindexclamps a position past the end to the end rather than rejecting it, so it reaches the check and refuses, which is what CRuby answers as well.What the lookbehind tests ask now
#7125 measures a lookbehind in the characters its bytes spell, and its tests put the question to a subject whose bytes spell no character, read as UTF-8:
A search like that is refused here, so the block branches on
__ENCODING__and keeps both answers: a UTF-8 build asserts the refusal, and a build that reads no encoding for the bytes to break asserts the widths. The byte rewind the same block asserts is reached in both, since a byte-indexed subject is exempt.That leaves the character measurement reachable only where
MRB_UTF8_STRINGis off. A subject that spells characters throughout never matches a lookbehind holding a byte that spells none, at either width, so no other subject puts the question.Checked
rake teston the default gembox: 2064 assertions, 0 failures; bintest 105, 0 failuresrake teston a full-core build (MRB_UTF8_STRING): 2259 assertions, 0 failuresrake teston a build withoutMRB_UTF8_STRING, holding mruby-regexp and mruby-string-ext: 1060 assertions, 0 failuresSummary by CodeRabbit
Bug Fixes
ArgumentError.Tests