Skip to content

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

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#7126
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

CRuby raises ArgumentError when 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:

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

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? 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.

What this leaves for the next PR

A quoted String pattern is refused here as well, where CRuby answers for it:

"あ\x80b".sub("b", "!")  # CRuby: "あ\x80!", here: ArgumentError

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 sub down 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 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. A gsub block that modifies the subject clears the flag and the next turn walks what is left; mruby allows such a block where CRuby raises RuntimeError: 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 -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.

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:

  • It ran the check at the entry to each mrblib loop, through a new Regexp.__check_encoding class method and a flag passed into __search out of __regexp_rsearch, on the grounds that checking inside __byte_search walks 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_search holds the check.
  • It said that a subject searched twice is walked twice, so a walk fell on every search. The flag survives the search and a copy of the string, so the walk falls on the string.
  • It said an out of range pos answers nil for index, rindex and byteindex. 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 too.

Positions

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.

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:

"\x80ab" =~ /(?<=\x80a)b/          # 2
"\xE3\x81ab" =~ /(?<=\xE3\x81a)b/  # 3

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_STRING is 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 test on the default gembox: 2064 assertions, 0 failures; bintest 105, 0 failures
  • rake test on a full-core build (MRB_UTF8_STRING): 2259 assertions, 0 failures
  • rake test on a build without MRB_UTF8_STRING, holding mruby-regexp and mruby-string-ext: 1060 assertions, 0 failures
  • 20 calls over a broken subject compared against CRuby 4.0.6, and the position cases above

Summary by CodeRabbit

  • Bug Fixes

    • Regular expression operations now reject malformed UTF-8 subjects with ArgumentError.
    • Validation consistently applies to matching, searching, replacements, scanning, splitting, and related operations.
    • Binary strings remain supported without UTF-8 validation.
    • Valid UTF-8 behavior and position handling remain unchanged.
  • Tests

    • Added coverage for malformed UTF-8 across regular expression and string APIs, including lookbehind and mutation operations.

@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: b771ffd3-9166-42e4-abc7-55a687b947c0

📥 Commits

Reviewing files that changed from the base of the PR and between 80c781b and 0f47e39.

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

📝 Walkthrough

Walkthrough

The 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.

Changes

UTF-8 subject validation

Layer / File(s) Summary
Encoding validation and regexp entry points
mrbgems/mruby-regexp/src/regexp.c
Adds shared validation for invalid UTF-8 subjects and applies it across regexp matching, searching, replacement, and scanning entry points.
Malformed subject regression coverage
mrbgems/mruby-regexp/test/regexp_utf8.rb, mrbgems/mruby-regexp/test/regexp_syntax.rb
Tests malformed UTF-8 rejection and preserves binary-string, valid UTF-8, position, and lookbehind behavior across encoding modes.

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

Possibly related PRs

  • mruby/mruby#7110: Modifies the same regexp validation paths and malformed UTF-8 tests.
  • mruby/mruby#7119: Extends UTF-8 subject validation in regexp.c and related tests.
  • mruby/mruby#7127: Overlaps with UTF-8 subject validation across regexp matching and substitution entry points.

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 non-UTF-8 regexp subjects.
✨ 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.

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.
@matz

matz commented Aug 12, 2026

Copy link
Copy Markdown
Member

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 re_compile.c with git checkout <commit> -- <path>, which stages as well as writes the file. Restoring the working tree with cp left the old version in the index, and the next commit took it. That put #7125's compute_fixed_len change back to counting lead bytes, and it was pushed. Restored in 1e6a2e6, with the history left alone.

What I checked before merging, on top of the verification in your description:

On the piece still to come: split, byteindex and gsub with a block are the three that still answer, and hoisting the check to the entry of each is the right shape for them. No hurry from me.

@matz matz closed this Aug 12, 2026
takumin pushed a commit to takumin/mruby that referenced this pull request Aug 12, 2026
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
@takumin
takumin deleted the regexp-refuse-broken-utf8-subject branch August 12, 2026 23:59
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.

2 participants