mruby-regexp: reject a lookbehind over a multibyte character class - #7069
Merged
Conversation
`compute_fixed_len()` folded `RE_CHAR`, `RE_CLASS` and `RE_NCLASS` into one arm and counted each as a single byte. That holds for `RE_CHAR`, since a multibyte literal compiles to a run of one-byte instructions, but not for a class: `re_charclass` carries a non-ASCII codepoint range list beside its ASCII bitmap, `class_match()` reads it for any codepoint at or above 128, and the executor advances by the decoded character width. The count stored in `re_inst.a` was therefore short by one byte per multibyte class member. Both lookbehind opcodes subtract it from `sp` and re-enter the matcher there, so the rewind landed on a continuation byte. The sub-pattern failed with no diagnostic, which made a positive lookbehind report no match and a negative one report a match: ```ruby "Āx" =~ /(?<=[Ā])x/ # CRuby: 1, mruby: nil "Āb" =~ /(?<![Ā])b/ # CRuby: nil, mruby: 2 "あx" =~ /(?<=[^あ])x/ # CRuby: nil, mruby: 3 "aあx" =~ /(?<=a\W)x/ # CRuby: 2, mruby: nil ``` The indices differ between the two anyway, because CRuby counts characters where this build counts bytes; read the rows as match against no match. A corrected constant is not available. A class can match characters of different widths in the same position, so `[aĀ]` is one byte or two and `\W` is one to four. Measure only a class that is ASCII throughout, and return -1 for the rest, which is what the function already does for `RE_ANY` and every other shape it cannot measure. A lookbehind that cannot be measured now raises `RegexpError` instead of answering wrongly. `class_is_ascii_only()` names the test, and `first_set_walk()` shares it with the new arm.
|
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 compiler now identifies ASCII-only character classes consistently. Fixed-length lookbehind rejects multibyte-capable and negated classes. First-byte analysis uses the same classification. Regression tests cover accepted and rejected patterns. ChangesRegexp lookbehind validation
Estimated code review effort: 2 (Simple) | ~15 minutes Suggested labels: 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 10, 2026
matz
pushed a commit
that referenced
this pull request
Aug 10, 2026
#7069 stopped `compute_fixed_len()` from measuring a character class that can match a non-ASCII codepoint, so a lookbehind over one raises `RegexpError` instead of rewinding into the middle of a character and answering wrongly. That traded a wrong answer for an exception; it did not make the pattern work. These all raised, and all answer in CRuby: ```ruby "Āx" =~ /(?<=[Ā])x/ # CRuby: 1, mruby: RegexpError "Āb" =~ /(?<![Ā])b/ # CRuby: nil, mruby: RegexpError "あx" =~ /(?<=[^あ])x/ # CRuby: nil, mruby: RegexpError "aあx" =~ /(?<=a\W)x/ # CRuby: 2, mruby: RegexpError "ax" =~ /(?<=.)x/ # CRuby: 1, mruby: RegexpError ``` The count in `re_inst.a` is a byte count, and both lookbehind opcodes subtract it from `sp` directly. A class has no fixed byte width, but it consumes exactly one character whatever its members are, so a character count makes every class measurable, and `RE_ANY` with it. One count cannot serve both subjects, though. `bt_match()` advances a binary subject one byte at a time and hands `class_match()` the raw byte as its codepoint, so against `"Āx".b` the stored byte count is exactly right, and a character count would rewind `(?<=Ā)` one byte instead of two. The compiler does not know the subject, so the opcode carries both: `a` keeps the byte count, and a carrier instruction, `RE_LB_WIDTH`, emitted right after it holds the character count, with the sub-pattern body starting at `pc + 2`. The instruction stream already holds logical units spanning several 4-byte words, since a multibyte literal is a run of one-byte `RE_CHAR` instructions forming one atom. `compute_fixed_len()` returns both counts from its one walk. The byte count stays one per consuming instruction, because a binary subject advances one byte whatever the instruction is. The character count adds one per class or `RE_ANY` and counts only lead bytes across an `RE_CHAR` run. The `class_is_ascii_only()` test and the `RE_NCLASS` and `RE_ANY` rejections all disappear; `RE_SPLIT` stays rejected, so `(?<=ab|c)` keeps raising as before. The executor keeps `sp - a` for a binary subject and otherwise steps back `code[pc + 1].a` characters. The backward step is built on `mrb_re_utf8_interior_p()`, whose definition (a continuation byte no lead reaches is a character of its own) keeps the walk on the same boundaries the forward decode uses, broken input included. Running out of text keeps its current meaning: the positive form fails, the negative form succeeds. The 255 limit stays a byte limit, and the character count never exceeds the byte count, so it fits the carrier's `uint8_t`.
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.
compute_fixed_len()counts a character class as exactly one byte, but a class can holdnon-ASCII codepoints, so a lookbehind over one rewinds by the wrong number of bytes and
lands in the middle of a character. A positive lookbehind then never matches, and a
negative lookbehind matches where it must not.
The indices differ between the two anyway, because CRuby counts characters where this
build counts bytes, so read the rows as match against no match. The ASCII side is
unaffected:
"ax" =~ /(?<=[aĀ])x/answers 1 in both.A negated class goes wrong in the opposite direction, since the rewind lands on a
continuation byte that the complement happily accepts:
Shorthand like
\W,\Sand\Dreaches the same broken position, but a bare(?<=\W)hides it: the rewind lands on a continuation byte,mrb_re_utf8_decode()hands that raw byte back as its own codepoint, and
utf8_anyaccepts anything at orabove 128, so the wrong position answers right by accident. Put one more element beside
the class and the accident stops:
Cause
compute_fixed_len()foldsRE_CHAR,RE_CLASSandRE_NCLASSinto one arm and adds1 byte for each. That is right for
RE_CHAR, which is a single byte by construction,and wrong for a class:
re_charclasscarries a non-ASCII codepoint range list besideits ASCII bitmap,
class_match()reads it for any codepoint at or above 128, and theexecutor advances by the decoded character width, not by one byte.
The result is stored in
re_inst.aand used as a raw byte count by both lookbehindopcodes, which subtract it from
spand re-enterbt_match()there. With the countshort by one byte per multibyte class member, that position is a continuation byte, the
sub-pattern fails, and the failure is silent.
Fix
A corrected constant is not available. A class can match characters of different widths
in the same position, so
[aĀ]is one byte or two and\Wis one to four. This patchmeasures only a class that is ASCII throughout, and returns -1 for the rest, which is
what the function already does for
RE_ANYand every other shape it cannot measure. Alookbehind that cannot be measured raises
RegexpErrorinstead of answering wrongly.RE_NCLASSalways returns -1: the complement of an ASCII bitmap admits non-ASCIIwhatever its members are.
RE_CHARkeeps its own arm, since a multibyte literal is arun of one-byte
RE_CHARinstructions and so(?<=Ā)xmeasures correctly today.class_is_ascii_only()names the test;first_set_walk()already spelled out the samecondition inline and now shares the helper.
Rewinding by characters instead of bytes would make every class work rather than raise,
and would also let
(?<=.)compile, which CRuby accepts and this gem rejects today. Itneeds a backwards UTF-8 helper and changes what
re_inst.ameans for the lookbehindopcodes, so it is left for a separate change.
Tests
Two cases next to the existing lookbehind group in
mrbgems/mruby-regexp/test/regexp.rb: one pins the patterns that now raise, includingthe negated class and the uppercase shorthands, the other pins what must keep working,
[a-z],\d,\sand the multibyte literal.rake testis clean.Summary by CodeRabbit
Bug Fixes
RegexpErrorwhen used in unsupported fixed-length lookbehinds.Tests