From d21eceb286adaf6c69ceeb6eafaa1a234f1cdf29 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Fri, 22 May 2026 19:16:24 +0900 Subject: [PATCH] mruby-regexp: disable first-byte skip when pattern can match empty first_set_walk returned TRUE when it reached RE_MATCH via epsilon transitions, but that's exactly the case where the optimization is wrong: an empty-matchable pattern can start matching at any position, including bytes that aren't in the computed first-byte set. The skip-ahead loop in pike_vm then advanced past valid empty-match positions, producing a match at the wrong offset (e.g. /a?/.match("b") reported the empty match at index 1 instead of 0). Co-authored-by: Claude --- mrbgems/mruby-regexp/src/re_compile.c | 10 ++++++++-- mrbgems/mruby-regexp/test/regexp.rb | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/mrbgems/mruby-regexp/src/re_compile.c b/mrbgems/mruby-regexp/src/re_compile.c index c136722a5..79bb9109d 100644 --- a/mrbgems/mruby-regexp/src/re_compile.c +++ b/mrbgems/mruby-regexp/src/re_compile.c @@ -823,12 +823,18 @@ first_set_walk(const re_inst *code, uint32_t code_len, case RE_ANY: case RE_ANY_NL: return FALSE; /* any byte possible */ case RE_MATCH: - return TRUE; /* empty match; first_bytes still valid for other branches */ + /* Reaching MATCH via epsilon transitions means the regex can match + zero characters at any position. Skipping bytes that aren't in the + first-byte set would skip past valid empty-match positions, so the + optimization isn't safe -- bail out and accept any starting byte. */ + return FALSE; default: return FALSE; } } - return TRUE; + /* Walked off the end without hitting MATCH or a consuming op. Treat as + empty-matchable, same as RE_MATCH. */ + return FALSE; } static mrb_bool diff --git a/mrbgems/mruby-regexp/test/regexp.rb b/mrbgems/mruby-regexp/test/regexp.rb index b4dc6a237..d3e24d25a 100644 --- a/mrbgems/mruby-regexp/test/regexp.rb +++ b/mrbgems/mruby-regexp/test/regexp.rb @@ -478,3 +478,17 @@ assert("Regexp - consecutive optional quantifiers (#6853)") do assert_equal [""], /a*b*/.match("").to_a assert_equal [""], /a?b?c?d?/.match("").to_a end + +assert("Regexp - empty-matchable patterns find earliest match position") do + # When a regex can match zero characters via epsilon transitions, the + # first-byte skip-ahead optimization is unsafe: skipping past bytes + # that aren't in the first-byte set would also skip past valid + # empty-match positions. + md = /a?/.match("b") + assert_equal "", md[0] + assert_equal 0, md.begin(0) + + md = /a?b?/.match("c") + assert_equal "", md[0] + assert_equal 0, md.begin(0) +end