Skip to content

mruby-regexp: let a byte that starts no character begin a match - #7059

Merged
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-continuation-byte-start
Aug 10, 2026
Merged

mruby-regexp: let a byte that starts no character begin a match#7059
matz merged 3 commits into
mruby:masterfrom
takumin:regexp-continuation-byte-start

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

A match may not start inside a character, so pike_vm() (re_exec.c:333) and
backtrack_exec() (re_exec.c:677) refuse to seed an attempt at a byte in
0x80-0xBF. The test looks at the byte alone, and that is not the same
question: a byte no lead byte reaches is inside nothing. literal_exec(),
the fast path for a pure literal pattern, carries no such test at all, so the
three engines answer differently for the same pattern.

b = "\x81"
Regexp.new(b + b)     =~ (b + b)  # 0,   literal fast path
Regexp.new(b + "{2}") =~ (b + b)  # 0,   literal fast path
Regexp.new(b + "+")   =~ (b + b)  # nil, pike VM
Regexp.new(b + "*")   =~ (b + b)  # 2,   pike VM, an empty match past both bytes

Which engine runs is an implementation detail of the pattern: {2} copies the
atom and keeps the pattern literal, + emits an RE_SPLIT and does not. The
first two lines are what the same subject read as binary gives, so the pike VM
is the one out of step.

Nothing raises in any of these, and no pattern whose first byte is ASCII or a
lead byte is affected, since neither can fall inside a character.

Fix

Ask whether the byte is the interior of a character that starts earlier: walk
back to the nearest byte that is not a continuation byte and see whether the
length mrb_re_utf8_charlen() reports for it reaches this far. At most three
bytes are looked at, and only for a byte in 0x80-0xBF that the search has
stopped on.

literal_exec() takes the same test, so all three engines answer alike. It
needs the binary flag for that, which mrb_re_exec() already holds and
passes to the other two.

The fast path therefore changes in both directions: it declines a byte it used
to match, since Regexp.new("\x81") no longer finds the second byte of "あ",
which is the rule the other two engines already followed.

A subject read as binary is unaffected: the whole test is skipped there, as
before.

Tests

mrbgems/mruby-regexp/test/regexp.rb, a new
Regexp - a byte that belongs to no character is a match position block
before Regexp - multibyte (UTF-8) match extraction: the literal, +, *
and ? forms over a stray byte, a stray byte after an ASCII one, the three
interior positions of a two and a four byte character, and a stray byte on
either side of a character. The last assertion goes through pre_match
bytesize, since MatchData#begin counts characters in a build that has them
and bytes in one that does not.

Verified on x86_64-linux:

  • A consistency sweep of 6048 cases over bytes that never form a character
    together (a, \x81, \xFF), 168 patterns against 36 subjects, each run
    once as UTF-8 and once as binary. Every position in such a subject is a
    boundary, so the two readings have to agree: 539 disagreed before this
    change and none do after.
  • rake test: 1976 total, 1958 OK, 0 KO, 0 crash, and bintest 105 OK.
  • An MRB_INT32 build with clang and -Wall -Wextra: 2045 total, 2035 OK,
    0 KO, 0 crash, and no new warning from any mruby-regexp file (regexp.c
    already emits four -Wunused-parameter).

A start position inside a character, while an attempt runs

The test in pike_vm() carried one more term, curr.count == 0, which the
first commit kept: it skipped the whole loop iteration, so it could only run
while nothing was in flight, or a thread waiting at this position would have
been dropped with it. Any branch that keeps a thread alive past the character
therefore reopens the position.

"ĵ" is C4 B5 and "µ" is C2 B5, so the two share their trailing byte. The
branch of .? that consumes "ĵ" parks a thread past it, and the attempt seeded
at the shared byte then matches that byte on its own, cutting the character in
half.

# "ĵ" is C4 B5, "µ" is C2 B5
"ĵ".match(/.?[µ]/)            # a match of the lone B5, CRuby: nil
"ĵ".gsub(/.?[µ]/, "!").bytes  # [196, 33], CRuby: [196, 181]

This one is older than the rest of the branch and reproduces on master.
backtrack_exec() never carried the term, and /.?[µ](?=)/, which the
backtracking engine runs, answers nil there already.

The second half of the guard is the fix: test the seeding alone instead of the
iteration. The curr.count term goes away, threads seeded earlier still step
at this position, and a start position inside a character stays closed however
many attempts are running.

Tests: a new Regexp - an attempt in flight opens no match position inside a character block after the one above. The match and the gsub over the shared
byte, the same subject behind a three byte character, the two cases where the class
does hold the character that follows, and the shared byte where no lead byte
reaches it. Every case was read off CRuby 4.0.6 first, and the five that are
valid UTF-8 on both sides agree with it. rake test on x86_64-linux: 1989
total, 1970 OK, 0 KO, 0 crash, and bintest 105 OK. Reverting the C change
fails the block on its first three assertions.

Summary by CodeRabbit

  • Bug Fixes

    • Improved UTF-8 regular expression matching to avoid starting matches inside valid multibyte characters.
    • Corrected handling of standalone UTF-8 continuation bytes as independently matchable data.
    • Fixed literal and quantified matching positions and byte-based preceding-match sizing.
  • Tests

    • Added regression coverage for standalone continuation bytes and multibyte-character boundaries.

@takumin
takumin requested a review from matz as a code owner August 9, 2026 16:28
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The regexp engine distinguishes standalone continuation bytes from bytes inside valid UTF-8 characters. Pike VM, backtracking, and literal matching paths use bounded interior checks. Regression tests cover matching, quantifiers, offsets, and byte-based pre_match sizing.

Changes

UTF-8 match boundaries

Layer / File(s) Summary
UTF-8 boundary detection
mrbgems/mruby-regexp/include/re_internal.h, mrbgems/mruby-regexp/src/re_exec.c
The engine replaces continuation-byte checks with bounded UTF-8 interior detection. Pike VM and backtracking match attempts use the new check.
Literal matching and regression coverage
mrbgems/mruby-regexp/src/re_exec.c, mrbgems/mruby-regexp/test/regexp.rb
The literal fast path receives binary mode and skips prefixes inside UTF-8 characters in text mode. Tests cover standalone continuation bytes, quantifiers, offsets, pre_match, and valid multibyte characters.

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

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 summarizes the main change: allowing standalone bytes that start no UTF-8 character to begin a match.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mrbgems/mruby-regexp/include/re_internal.h`:
- Line 171: Update the loop condition around the visible backtracking loop to
validate the pointer distance from s to str before computing s - back; only
subtract back when that distance is large enough, preserving the existing bounds
and iteration behavior without forming an out-of-range pointer.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 84d79984-9139-4074-8a6e-b9350390509e

📥 Commits

Reviewing files that changed from the base of the PR and between 9233195 and e979961.

📒 Files selected for processing (3)
  • mrbgems/mruby-regexp/include/re_internal.h
  • mrbgems/mruby-regexp/src/re_exec.c
  • mrbgems/mruby-regexp/test/regexp.rb

Comment thread mrbgems/mruby-regexp/include/re_internal.h Outdated
@matz

matz commented Aug 9, 2026

Copy link
Copy Markdown
Member

This needs a rebase. Six of the regexp changes landed together just now, up to 1f3f28b, and they touch test/regexp.rb and src/re_compile.c heavily enough that anything queued behind them collides.

For the record on what went in, so you can see what your branch is landing on top of:

#7051 mrb_str_cat() compares the append source through uintptr_t
#7052 a character class range split at the ASCII boundary
#7053 create_matchdata() snapshots the subject
#7054 the regexp form of String#[] and #slice
#7055 (?#...) comment groups
#7056 a multibyte literal as one atom

I verified the six together against CRuby before merging: sixteen rows, all agreeing, and the suite clean under ASan and UBSan.

Nothing in that set is aimed at what this pull request changes, so I expect the rebase to be mechanical, mostly in the test file. If it turns out not to be, say so and I will look at the interaction rather than have you work around it.

A match may not start inside a character, so `pike_vm()` and
`backtrack_exec()` refuse to seed an attempt at a byte in 0x80-0xBF. That test
looks at the byte alone, but a byte no lead byte reaches is inside nothing.
Such a byte was skipped as a starting position, while `literal_exec()`, which
has no such test, matched there. The two disagree on the same pattern.

```ruby
b = "\x81"
Regexp.new(b + b)     =~ (b + b)  # 0,   literal fast path
Regexp.new(b + "{2}") =~ (b + b)  # 0,   literal fast path
Regexp.new(b + "+")   =~ (b + b)  # nil, pike VM
Regexp.new(b + "*")   =~ (b + b)  # 2,   pike VM, an empty match past both bytes
```

Ask whether the byte is the interior of a character that starts earlier
instead: walk back to the nearest byte that is not a continuation byte and see
whether the length `mrb_re_utf8_charlen()` reports for it reaches this far.
`literal_exec()` takes the same test and the `binary` flag `mrb_re_exec()`
already holds, so all three engines answer alike.

The fast path is the one that changes twice over: it now declines a byte it
used to match, since `Regexp.new("\x81")` no longer finds the second byte of
`"あ"`. That is the rule the other two engines already followed.

A subject read as binary is unaffected, and so is every pattern whose first
byte is ASCII or a lead byte, which cannot fall inside a character to begin
with.
`mrb_re_utf8_interior_p` scanned backward with `s - back >= str`,
which computes `str - 1` when `s` sits at the start of the string.
Forming a pointer before the first element is undefined behavior even
though the comparison rejects it before any dereference. Compare the
distance `s - str` instead, which is an equivalent condition that
never forms an out-of-range pointer.
@takumin
takumin force-pushed the regexp-continuation-byte-start branch from 4b8ef1d to 608ec7d Compare August 9, 2026 22:24
@takumin

takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto 1f3f28b. It was mechanical, as you expected.

The only conflict was in test/regexp.rb, and it was two additions landing at
the same spot rather than a disagreement. #7056 puts Regexp - quantifier on a multibyte literal and Regexp - quantifier on an invalid multibyte literal
immediately before Regexp - multibyte (UTF-8) match extraction, which is
where this branch puts Regexp - a byte that belongs to no character is a match position. Both sides kept, mine after the two from #7056.

The C side did not conflict at all. #7056 works in re_compile.c, where the
atom is formed, and this change works in re_exec.c, where a match attempt is
seeded, so the two never touch the same lines. mrb_re_utf8_interior_p() and
its three call sites applied unchanged.

Re-verified on x86_64-linux after the rebase: rake test is 1988 total, 1969
OK, 0 KO, 0 crash, and bintest 105 OK. The counts moved from the ones in the
description because the six merged pull requests bring their own tests with
them.

I also dropped the closing note in the description about #7056 needing a
rebase, now that it has landed.

`pike_vm()` refuses to seed a match attempt at a byte that falls inside a
character, but only while nothing is in flight: the test carries a
`curr.count == 0` term because it skipped the whole loop iteration, and a
thread waiting at this position would have been dropped with it.

Any branch that keeps a thread alive past the character therefore reopens the
position. `"ĵ"` is C4 B5 and `"µ"` is C2 B5, so the two share their trailing
byte. The branch of `.?` that consumes `"ĵ"` parks a thread past it, and the
attempt seeded at the shared byte then matches that byte on its own, cutting
the character in half.

```ruby
# "ĵ" is C4 B5, "µ" is C2 B5
"ĵ".match(/.?[µ]/)            # mruby: a match of the lone B5, CRuby: nil
"ĵ".gsub(/.?[µ]/, "!").bytes  # mruby: [196, 33], CRuby: [196, 181]
```

Guard the seeding alone instead. The test drops the `curr.count` term and
threads seeded earlier still step at this position, so a start position inside
a character stays closed however many attempts are running.

`backtrack_exec()` and `literal_exec()` never carried the term and answer
`nil` here already, so this is the pike VM catching up to them.
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