mruby-regexp: let a byte that starts no character begin a match - #7059
Conversation
📝 WalkthroughWalkthroughThe 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 ChangesUTF-8 match boundaries
Estimated code review effort: 2 (Simple) | ~10 minutes 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 |
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
mrbgems/mruby-regexp/include/re_internal.hmrbgems/mruby-regexp/src/re_exec.cmrbgems/mruby-regexp/test/regexp.rb
|
This needs a rebase. Six of the regexp changes landed together just now, up to 1f3f28b, and they touch For the record on what went in, so you can see what your branch is landing on top of:
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.
4b8ef1d to
608ec7d
Compare
|
Rebased onto 1f3f28b. It was mechanical, as you expected. The only conflict was in The C side did not conflict at all. #7056 works in Re-verified on I also dropped the closing note in the description about #7056 needing a |
`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.
A match may not start inside a character, so
pike_vm()(re_exec.c:333) andbacktrack_exec()(re_exec.c:677) refuse to seed an attempt at a byte in0x80-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.
Which engine runs is an implementation detail of the pattern:
{2}copies theatom and keeps the pattern literal,
+emits anRE_SPLITand does not. Thefirst 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 threebytes 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. Itneeds the
binaryflag for that, whichmrb_re_exec()already holds andpasses 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 newRegexp - a byte that belongs to no character is a match positionblockbefore
Regexp - multibyte (UTF-8) match extraction: the literal,+,*and
?forms over a stray byte, a stray byte after an ASCII one, the threeinterior 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_matchbytesize, sinceMatchData#begincounts characters in a build that has themand bytes in one that does not.
Verified on
x86_64-linux:together (
a,\x81,\xFF), 168 patterns against 36 subjects, each runonce 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.MRB_INT32build with clang and-Wall -Wextra: 2045 total, 2035 OK,0 KO, 0 crash, and no new warning from any
mruby-regexpfile (regexp.calready 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 thefirst 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. Thebranch of
.?that consumes"ĵ"parks a thread past it, and the attempt seededat the shared byte then matches that byte on its own, cutting the character in
half.
This one is older than the rest of the branch and reproduces on
master.backtrack_exec()never carried the term, and/.?[µ](?=)/, which thebacktracking engine runs, answers
nilthere already.The second half of the guard is the fix: test the seeding alone instead of the
iteration. The
curr.countterm goes away, threads seeded earlier still stepat 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 characterblock after the one above. The match and thegsubover the sharedbyte, 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 testonx86_64-linux: 1989total, 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
Tests