Skip to content

mruby-regexp: reject a lookbehind over a multibyte character class - #7069

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:lookbehind-multibyte-class
Aug 10, 2026
Merged

mruby-regexp: reject a lookbehind over a multibyte character class#7069
matz merged 1 commit into
mruby:masterfrom
takumin:lookbehind-multibyte-class

Conversation

@takumin

@takumin takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

compute_fixed_len() counts a character class as exactly one byte, but a class can hold
non-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.

"Āx" =~ /(?<=[Ā])x/      # CRuby: 1,   mruby: nil
"Āb" =~ /(?<![Ā])b/      # CRuby: nil, mruby: 2
"Ăx" =~ /(?<=[Ā-ă])x/    # CRuby: 1,   mruby: nil
"Āx" =~ /(?<=[aĀ])x/     # CRuby: 1,   mruby: nil
"ĀĀx" =~ /(?<=[Ā]{2})x/  # CRuby: 2,   mruby: nil

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:

"あx" =~ /(?<=[^あ])x/    # CRuby: nil, mruby: 3
"あb" =~ /(?<![^あ])b/    # CRuby: 1,   mruby: nil

Shorthand like \W, \S and \D reaches 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_any accepts anything at or
above 128, so the wrong position answers right by accident. Put one more element beside
the class and the accident stops:

"aあx" =~ /(?<=a\W)x/     # CRuby: 2,   mruby: nil
"aあb" =~ /(?<!a\W)b/     # CRuby: nil, mruby: 4
"あĀx" =~ /(?<=\W\W)x/    # CRuby: 2,   mruby: nil

Cause

compute_fixed_len() folds RE_CHAR, RE_CLASS and RE_NCLASS into one arm and adds
1 byte for each. That is right for RE_CHAR, which is a single byte by construction,
and wrong 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, not by one byte.

The result is stored in re_inst.a and used as a raw byte count by both lookbehind
opcodes, which subtract it from sp and re-enter bt_match() there. With the count
short 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 \W is one to four. This patch
measures only a class that is ASCII throughout, and returns -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 raises RegexpError instead of answering wrongly.

RE_NCLASS always returns -1: the complement of an ASCII bitmap admits non-ASCII
whatever its members are. RE_CHAR keeps its own arm, since a multibyte literal is a
run of one-byte RE_CHAR instructions and so (?<=Ā)x measures correctly today.

class_is_ascii_only() names the test; first_set_walk() already spelled out the same
condition 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. It
needs a backwards UTF-8 helper and changes what re_inst.a means for the lookbehind
opcodes, 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, including
the negated class and the uppercase shorthands, the other pins what must keep working,
[a-z], \d, \s and the multibyte literal. rake test is clean.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed regular expression lookbehind validation for character classes that may match multibyte characters.
    • Negated character classes and multibyte-capable shorthands now correctly raise RegexpError when used in unsupported fixed-length lookbehinds.
    • Improved matching optimization without changing valid ASCII-only lookbehind behavior.
  • Tests

    • Added regression coverage for multibyte character classes, shorthands, ASCII-only classes, and multibyte literals in lookbehinds.

`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.
@takumin
takumin requested a review from matz as a code owner August 10, 2026 08:34
@coderabbitai

coderabbitai Bot commented Aug 10, 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: 68860792-e619-439a-a7b3-a0a3ceb028f1

📥 Commits

Reviewing files that changed from the base of the PR and between 8ad6907 and 1fa7d26.

📒 Files selected for processing (2)
  • mrbgems/mruby-regexp/src/re_compile.c
  • mrbgems/mruby-regexp/test/regexp.rb

📝 Walkthrough

Walkthrough

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

Changes

Regexp lookbehind validation

Layer / File(s) Summary
ASCII-only class classification and validation
mrbgems/mruby-regexp/src/re_compile.c, mrbgems/mruby-regexp/test/regexp.rb
The compiler uses a shared ASCII-only classifier for lookbehind width and first-byte analysis. Multibyte-capable and negated classes become indeterminate in lookbehind analysis. Tests cover character classes, shorthands, quantifiers, ASCII-only classes, and multibyte literals.

Estimated code review effort: 2 (Simple) | ~15 minutes

Suggested labels: mrbgems

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: rejecting lookbehinds over multibyte character classes.
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.
✨ 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.

@matz
matz merged commit 9099333 into mruby:master Aug 10, 2026
20 of 22 checks passed
@takumin
takumin deleted the lookbehind-multibyte-class branch August 10, 2026 10:23
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`.
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