mruby-regexp: measure a lookbehind in the characters its bytes spell - #7125
Merged
Conversation
`compute_fixed_len()` counted the characters of a `RE_CHAR` run by the lead bytes in it, so a byte matching 10xxxxxx was never one of them. The executor rewinds by another rule: `lookbehind_start()` steps back through `mrb_re_utf8_interior_p()`, which asks `mrb_utf8_char_head()`, and there a continuation byte that no lead byte reaches is a character of its own. A lookbehind holding such a byte rewound too little and began the sub-pattern past the text it describes. ```ruby s = "\x80ab" s =~ /\x80ab/ # => 0, those bytes do match as a literal s =~ /(?<=\x80a)b/ # => nil, expected 2 s =~ /(?<!\x80a)b/ # => 2, expected nil "\xE3\x81ab" =~ /(?<=\xE3\x81a)b/ # => nil, expected 3 ``` CRuby never measures such a lookbehind, since it refuses both sides of the case: the pattern with `RegexpError: invalid multibyte character`, and a subject whose bytes spell no character with `ArgumentError: invalid byte sequence in UTF-8`. Where it does run the case, binary on both sides, it answers 2, nil and 3, which is what the byte count here has always answered for the same bytes. This engine has no such refusal and answers rather than raising, so its two units have to agree, and the character count is the one that was wrong. Gather the run into a four byte window and step it with `mrb_utf8len()`, the function `mrb_utf8_char_head()` agrees with, rather than read the lead bit. Four bytes is the longest character there is, and a run never splits one because `emit_char_bytes()` emits every byte of a character, so the window reaches whatever character starts at the instruction.
|
Caution Review failedAn error occurred during the review process. Please try again later. ✨ 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 12, 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.
Problem
compute_fixed_len()measures a lookbehind twice: in bytes for a byte-indexedsubject, and in characters for a UTF-8 one. The character count came from the
lead bytes of the
RE_CHARrun, so a byte matching 10xxxxxx counted as zero.The executor rewinds by another rule.
lookbehind_start()steps back throughmrb_re_utf8_interior_p(), which asksmrb_utf8_char_head(), and there acontinuation byte that no lead byte reaches is a character of its own, the rule
Stringindexing walks by too. A lookbehind over bytes that spell no charactertherefore rewound too little and began the sub-pattern past its own text.
The negative form fails the other way around: it reports a match where the text
it refuses is present.
The byte count was right all along, so the same compiled pattern answered
differently depending on how the subject is indexed:
What CRuby says
CRuby (4.0.6) never reaches this measurement, because two earlier gates stop it:
The first refuses a pattern holding bytes that spell no character, the second a
subject holding them, whatever the pattern. The one configuration it does run is
binary on both sides, and there it answers exactly what the byte count here
answers:
This engine carries no such refusal, so it answers where CRuby raises, and the
two units it measures the same lookbehind with have to agree on that answer.
They now do, on the value CRuby gives in the comparable configuration. The
control with a whole character agrees too:
"\u{100}ab" =~ /(?<=\u{100}a)b/is2 in CRuby and on an
MRB_UTF8_STRINGbuild, and a byte-indexed build reportsits byte offset there as it does everywhere. Whether the refusal itself belongs
in this engine is a separate question, untouched here.
Fix
Gather the run of
RE_CHARinstructions into a four byte window and step itwith
mrb_utf8len(), which is what the rewind'smrb_utf8_char_head()agreeswith. Four bytes is the longest character there is, and a run never splits one
because
emit_char_bytes()emits every byte of a character, so the windowreaches whatever character starts at the instruction. The inline
(inst.a & 0xC0) != 0x80test goes away with it, and the byte count keepscoming out of the same walk.
A pattern whose bytes spell whole characters is unaffected: the run still
advances one character per lead byte.
Tests
mrbgems/mruby-regexp/test/regexp_syntax.rbgains nine assertions beside theother lookbehind width tests: a stray continuation byte, a sequence cut short,
the escaped and the raw spelling of the same pattern, the byte-indexed subject
that already answered correctly, and a whole multibyte character as the control.
Four of them fail before the change.
The offsets asserted are the ones both builds agree on, and the case involving a
whole multibyte character asserts the matched text instead of a position, so the
file needs no build guard.
rake testis green on the default build (2062 assertions, 105 bintests) and ona
full-corebuild, which carriesMRB_UTF8_STRING(2255 assertions).