mruby-regexp: a match may not end inside a character - #7070
Merged
Conversation
A pattern is compiled byte by byte and `RE_CHAR` consumes exactly one byte
of the subject, so a pattern holding a byte that no character reaches ends
its match in the middle of one. `"ĵ"` is C4 B5, and a pattern of the single
byte C4 matched its lead byte and stopped there:
```ruby
j = "ĵ" # C4 B5
j.match(Regexp.new("\xc4"))[0].bytes # mruby: [196], CRuby: RegexpError
j.gsub(Regexp.new("\xc4"), "!").bytes # mruby: [33, 181], CRuby: RegexpError
```
What comes back is a `String` that is not valid UTF-8: `[196]` is half a
character, and the `gsub` leaves the orphaned continuation byte behind.
Nothing raises at any point.
The engines already refuse to start a match inside a character. Ask the same
question where the match closes. That position is the `RE_SAVE` of slot 1,
the end of group 0, in `add_thread()` and `bt_match()`, and `found + plen` in
`literal_exec()`; `mrb_re_utf8_interior_p()` answers it, the same helper the
seeding loops use. A byte that no lead byte reaches is a boundary of its own,
so `Regexp.new("\x81")` keeps finding a standalone `\x81` byte.
CRuby raises `RegexpError` instead, but that is an encoding rule, and its way
out is `Regexp.new("\x81".b)`, a `Regexp` whose encoding is BINARY. A `Regexp`
here carries no encoding, so such a pattern has nothing to opt into and
rejecting it would take byte patterns away outright.
Killing the thread rather than the whole attempt leaves the other branches
alive, so a longer one can still match: `Regexp.new("\xc4(?:\xb5)??")` now
answers the two byte match instead of the one byte one.
Slot 1 is emitted once, around the whole pattern, so a lookaround, which ends
at a position without consuming it, keeps its own answer. A subject read as
binary skips the test, as before.
|
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 (2)
📝 WalkthroughWalkthroughThe regexp engine now rejects complete matches that end inside UTF-8 characters across the Pike VM, backtracking engine, and literal fast path. Regression tests cover UTF-8, binary strings, quantifiers, groups, lookarounds, and substitution. ChangesUTF-8 boundary validation
Estimated code review effort: 2 (Simple) | ~10 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 |
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A pattern is compiled byte by byte: a literal character becomes a run of
RE_CHAR, andRE_CHARconsumes exactly one byte of the subject. For apattern that is valid UTF-8 that is harmless, since its bytes form whole
characters and a run that matches ends where a character ends. A pattern
holding a byte that no character reaches has no such property, and the match
then ends in the middle of one.
"ĵ"is C4 B5. A pattern of the single byte C4 matches its lead byte andstops there:
What comes back is a
Stringthat is not valid UTF-8:[196]is half acharacter, and the
gsubleaves the orphaned continuation byte behind.Nothing raises at any point. CRuby 4.0.6 answers
RegexpError: invalid multibyte characterto all three atRegexp.newtime.The engines already refuse to start a match inside a character, in
pike_vm(),backtrack_exec()andliteral_exec(). The byte C4 is a leadbyte, so it is a legal start, and no test looked at where the match ends. The
two halves of the same character therefore answered differently:
Fix
Ask the same question where the match closes.
That position is the
RE_SAVEof slot 1, which the compiler emits once,around the whole pattern, as the end of group 0.
add_thread()andbt_match()take the test there, andliteral_exec()takes it onfound + plen.mrb_re_utf8_interior_p()answers it, the same helper theseeding loops already use, so a byte that no lead byte reaches stays a
boundary of its own and
Regexp.new("\x81")keeps finding a standalone\x81byte in a subject.Rejecting the pattern outright, the way CRuby does, was the other way out.
CRuby's rule is an encoding rule: a pattern must be valid in its own encoding,
so
Regexp.new("\x81")raises there too, and the way to write a byte patternis
Regexp.new("\x81".b), aRegexpwhose encoding is BINARY. ARegexphere carries no encoding, and the binary flag is read off the subject at match
time, never off the pattern, so such a pattern has nothing to opt into.
Rejecting every pattern that is not valid UTF-8 would therefore take byte
patterns away outright, including the ones the tests pin, and a build without
Encodingwould have no way to ask for one back.What the pattern keeps instead is the bytes it names, wherever they form no
character:
The test kills a thread, not the whole attempt, so the other branches stay
alive and a longer one can still match:
Slot 1 belongs to group 0 alone, so a lookaround, which ends at a position
without consuming it and closes on an
RE_MATCHof its own, keeps its answer.A subject read as binary skips the whole test, as before.
What this does not cover
The end of a capture, which can close inside a character while the whole
match does not, is unchanged:
Regexp.new("(\xc4)\xb5")against"ĵ"stillhands back half a character in group 1. The start of a capture has the same
hole today and always has, since the existing rule guards where an attempt is
seeded rather than where group 0 opens. Closing one end of a capture and not
the other would trade a symmetric gap for an asymmetric one, so this change
keeps to group 0, where the rule it mirrors already lives.
Tests
mrbgems/mruby-regexp/test/regexp.rb, a newRegexp - a match does not end inside a characterblock beforeRegexp - multibyte (UTF-8) match extraction: the C4 pattern through each ofthe three engines and through
gsub, the greedy and non-greedy branches thatdo end on a boundary, a lookahead over the same byte, a stray byte on its own
and after an ASCII one, and the binary reading of the same subject.
Verified on
x86_64-linux:a,"ĵ","あ", an astral character,\x81and\xff. Every match waschecked for a begin and an end that is a character boundary: 3471 ended
inside a character before this change and none do after, while no match
begins inside one either way. The same sweep compares a subject that holds
no multi byte character against its binary reading, where every position is
a boundary and the two have to agree: no disagreement before or after.
rake test: 2009 total, 1991 OK, 0 KO, 0 crash, and bintest 105 OK.MRB_INT32build with clang and-Wall -Wextra: 2078 total, 2068 OK,0 KO, 0 crash, and no new warning from any
mruby-regexpfile (regexp.calready emits four
-Wunused-parameter).Summary by CodeRabbit
Bug Fixes
Tests