Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions mrbgems/mruby-regexp/mrblib/string_regexp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,17 @@ def sub(*args, &block)
# get_pat. Only the quoting is taken from it: get_pat_quoted also accepts
# anything answering `to_str`, where `__check_pattern` keeps to a real
# String, as `match` already does.
pattern = Regexp.new(Regexp.escape(pattern)) if String === pattern
literal = String === pattern
# CRuby searches for a literal byte by byte and never reads the subject as
# UTF-8 on the way, so quoting one into a Regexp here must not put the
# subject through a check CRuby does not make: `"a\x80b".sub("b", "!")`
# answers there, where the same call with `/b/` is refused.
pattern = Regexp.new(Regexp.escape(pattern)) if literal
# A replacement argument wins over the block, as in CRuby.
if args.length == 2
return Regexp.__sub_str(pattern, self, replacement.to_s)
return Regexp.__sub_str(pattern, self, replacement.to_s, literal)
end
md = Regexp.__search(pattern, self)
md = Regexp.__search(pattern, self, 0, literal)
return self.dup unless md
md.pre_match + block.call(md[0]).to_s + md.post_match
end
Expand All @@ -141,19 +146,23 @@ def sub!(*args, &block)
# Resolved here rather than left to `sub` because the match below decides
# the return value, and a String pattern is a literal on both paths.
pattern = Regexp.__check_pattern(args[0])
pattern = Regexp.new(Regexp.escape(pattern)) if String === pattern
literal = String === pattern
pattern = Regexp.new(Regexp.escape(pattern)) if literal
raise FrozenError, "can't modify frozen String" if frozen?
# Whether a substitution happened is a question about the match, not about
# the result: `"aaa".sub!(/a/, "a")` returns self even though the string is
# unchanged. A full search and not `match?`, so a failed match clears $~.
return nil unless Regexp.__search(pattern, self)
return nil unless Regexp.__search(pattern, self, 0, literal)
# `sub` matches again and publishes its own $~ over this one, leaving the
# caller the match `sub` would have left, a block's own matches included.
# The resolved pattern takes the place of the original argument so that a
# String is not quoted and compiled a second time. Overwriting `self`
# afterwards is safe: a MatchData snapshots its subject, so $~ keeps
# describing the string as it was matched.
str = args.length == 2 ? self.sub(pattern, args[1], &block) : self.sub(pattern, &block)
# String is not quoted and compiled a second time; a literal goes down as
# the String it was instead, since that is what tells `sub` to leave the
# subject unread, and quoting it twice is the price of saying so.
# Overwriting `self` afterwards is safe: a MatchData snapshots its subject,
# so $~ keeps describing the string as it was matched.
down = literal ? args[0] : pattern
str = args.length == 2 ? self.sub(down, args[1], &block) : self.sub(down, &block)
self.replace(str)
end

Expand All @@ -169,10 +178,13 @@ def gsub(*args, &block)
# After the to_enum return above, so that `"abc".gsub(:b)` yields an
# Enumerator and raises on the first iteration, as CRuby does.
pattern = Regexp.__check_pattern(pattern)
pattern = Regexp.new(Regexp.escape(pattern)) if String === pattern
# A String pattern is a literal, as in `sub`, and reaches the subject the
# way CRuby reaches it: byte by byte, with no reading of it as UTF-8.
literal = String === pattern
pattern = Regexp.new(Regexp.escape(pattern)) if literal
# A replacement argument wins over the block, as in CRuby.
if args.length == 2
return Regexp.__gsub_str(pattern, self, replacement.to_s)
return Regexp.__gsub_str(pattern, self, replacement.to_s, literal)
end
# block case: keep in Ruby to avoid VM callback from C
parts = []
Expand Down Expand Up @@ -227,12 +239,15 @@ def gsub!(*args, &block)
end
return to_enum(:gsub!, *args) if args.length == 1 && !block
pattern = Regexp.__check_pattern(args[0])
pattern = Regexp.new(Regexp.escape(pattern)) if String === pattern
literal = String === pattern
pattern = Regexp.new(Regexp.escape(pattern)) if literal
# As in `sub!`: the match decides the return value, and a failed search
# clears $~. What it publishes on success is replaced right away by the
# last match of the `gsub` below, which is the one CRuby leaves behind.
return nil unless Regexp.__search(pattern, self)
str = args.length == 2 ? self.gsub(pattern, args[1], &block) : self.gsub(pattern, &block)
# A literal goes down as the String it was, for the reason `sub!` gives.
return nil unless Regexp.__search(pattern, self, 0, literal)
down = literal ? args[0] : pattern
str = args.length == 2 ? self.gsub(down, args[1], &block) : self.gsub(down, &block)
self.replace(str)
end

Expand Down
65 changes: 56 additions & 9 deletions mrbgems/mruby-regexp/src/regexp.c
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,31 @@ re_binary_string_p(mrb_value str)
return RSTR_BINARY_P(RSTRING(str));
}

/* CRuby refuses a search whose subject holds a byte that spells no character,
and mruby answers for it. Refuse it here too, so that a program moved from
one to the other is told about the subject rather than handed a result the
other would not have produced.

A binary string is exempt because it is indexed by byte throughout, so its
bytes make no claim that could be broken. A quoted String pattern is exempt
for a narrower reason: CRuby searches for a literal byte by byte and reads
the subject as UTF-8 nowhere along the way, so `"a\x80b".sub("b", "!")`
answers there while the same call with `/b/` is refused.

The check walks the whole subject, so it runs once per search a method
makes and not once per match it finds: every entry point below checks what
arrives from Ruby, and the ones that loop in C over a subject do so after it
has been checked once. `__byte_search` is left out, since the mrblib loops
that drive it search the same subject over and over and want one check at
the entry to the method; that is the commit after this one. */
static void
re_check_encoding(mrb_state *mrb, mrb_value str)
{
if (!mrb_str_valid_encoding_p(mrb, str)) {
mrb_raise(mrb, E_ARGUMENT_ERROR, "invalid byte sequence in UTF-8");
}
}

static mrb_value
regexp_binary_string_p(mrb_state *mrb, mrb_value self)
{
Expand Down Expand Up @@ -362,6 +387,7 @@ regexp_match(mrb_state *mrb, mrb_value self)
return mrb_nil_value();
}

re_check_encoding(mrb, str);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Validate the subject before converting pos.

re_char_to_byte() can return an out-of-range result before re_check_encoding() runs. For example, /b/.match("a\x80b", 4) returns nil instead of raising ArgumentError.

  • mrbgems/mruby-regexp/src/regexp.c#L390-L390: Call re_check_encoding() immediately after match_operand() and before re_char_to_byte().
  • mrbgems/mruby-regexp/src/regexp.c#L445-L445: Apply the same ordering in Regexp.__search.
  • mrbgems/mruby-regexp/src/regexp.c#L481-L481: Apply the same ordering in exec_match_p.
  • mrbgems/mruby-regexp/test/regexp_utf8.rb#L227-L230: Add positive and negative out-of-range position cases for invalid subjects.
📍 Affects 2 files
  • mrbgems/mruby-regexp/src/regexp.c#L390-L390 (this comment)
  • mrbgems/mruby-regexp/src/regexp.c#L445-L445
  • mrbgems/mruby-regexp/src/regexp.c#L481-L481
  • mrbgems/mruby-regexp/test/regexp_utf8.rb#L227-L230
🤖 Prompt for 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.

In `@mrbgems/mruby-regexp/src/regexp.c` at line 390, In the regexp matching flows,
call re_check_encoding() immediately after match_operand() and before
re_char_to_byte() in mrbgems/mruby-regexp/src/regexp.c lines 390, 445, and 481.
Add positive and negative out-of-range position tests for invalid subjects in
mrbgems/mruby-regexp/test/regexp_utf8.rb lines 227-230, verifying the subject
raises ArgumentError rather than returning nil.

md = exec_match(mrb, self, str, pos);
if (!mrb_nil_p(md) && !mrb_nil_p(block)) {
return mrb_yield(mrb, block, md);
Expand All @@ -383,22 +409,28 @@ check_regexp_arg(mrb_state *mrb, mrb_value re)
}

/*
* Regexp.__search(re, str, pos = 0)
* Regexp.__search(re, str, pos = 0, checked = false)
*
* Internal: `Regexp#match` with the pattern as an argument and no block form.
* The String overrides in mrblib search through this so that the search never
* dispatches on the pattern, where a singleton method would replace it; see
* the note at the top of mrblib/string_regexp.rb. A nil subject clears the
* match globals and answers nil, as `Regexp#match` does, which is what the
* overrides use to report a miss.
*
* `checked` says the caller has settled the encoding question for the subject
* and this search must not ask it again. `sub`, `sub!`, `gsub` and `gsub!` set
* it when their pattern is a quoted String, which CRuby searches for without
* reading the subject as UTF-8 at all.
*/
static mrb_value
regexp_s_search(mrb_state *mrb, mrb_value klass)
{
mrb_value re, str;
mrb_int pos = 0;
mrb_bool checked = FALSE;

mrb_get_args(mrb, "oo|i", &re, &str, &pos);
mrb_get_args(mrb, "oo|ib", &re, &str, &pos, &checked);
check_regexp_arg(mrb, re);
if (mrb_nil_p(str)) {
clear_match_globals(mrb);
Expand All @@ -410,6 +442,7 @@ regexp_s_search(mrb_state *mrb, mrb_value klass)
clear_match_globals(mrb);
return mrb_nil_value();
}
if (!checked) re_check_encoding(mrb, str);
return exec_match(mrb, re, str, pos);
}

Expand Down Expand Up @@ -445,6 +478,7 @@ exec_match_p(mrb_state *mrb, mrb_value re, mrb_value str, mrb_int pos)

mrb_regexp_pattern *pat = DATA_GET_PTR(mrb, re, &regexp_type, mrb_regexp_pattern);
if (!pat) mrb_raise(mrb, E_ARGUMENT_ERROR, "uninitialized Regexp");
re_check_encoding(mrb, str);

int ncap = mrb_re_exec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), pos, NULL, 0,
re_binary_string_p(str));
Expand Down Expand Up @@ -493,6 +527,7 @@ regexp_match_op(mrb_state *mrb, mrb_value self)
return mrb_nil_value();
}
str = match_operand(mrb, str);
re_check_encoding(mrb, str);

mrb_value md = exec_match(mrb, self, str, 0);
if (mrb_nil_p(md)) return mrb_nil_value();
Expand All @@ -516,6 +551,7 @@ regexp_case_match(mrb_state *mrb, mrb_value self)

pat = DATA_GET_PTR(mrb, self, &regexp_type, mrb_regexp_pattern);
if (!pat) return mrb_false_value();
re_check_encoding(mrb, str);

md = exec_match(mrb, self, str, 0);
return mrb_bool_value(!mrb_nil_p(md));
Expand Down Expand Up @@ -1071,17 +1107,23 @@ has_backslash(const char *s, mrb_int len)
}

/*
* Regexp.__gsub_str(re, str, replacement) - gsub core without block
* Regexp.__gsub_str(re, str, replacement, checked = false) - gsub core without block
*
* `checked` says the caller has settled the encoding question for the subject,
* which for `gsub` means the pattern is a quoted String and the search is a
* literal one that CRuby runs without reading the subject as UTF-8.
*/
static mrb_value
regexp_s_gsub_str(mrb_state *mrb, mrb_value klass)
{
mrb_value re, str, replacement;
mrb_get_args(mrb, "oSS", &re, &str, &replacement);
mrb_bool checked = FALSE;
mrb_get_args(mrb, "oSS|b", &re, &str, &replacement, &checked);
check_regexp_arg(mrb, re);

mrb_regexp_pattern *pat = DATA_GET_PTR(mrb, re, &regexp_type, mrb_regexp_pattern);
if (!pat) mrb_raise(mrb, E_ARGUMENT_ERROR, "uninitialized Regexp");
if (!checked) re_check_encoding(mrb, str);

const char *s = RSTRING_PTR(str);
mrb_int slen = RSTRING_LEN(str);
Expand Down Expand Up @@ -1161,17 +1203,21 @@ regexp_s_gsub_str(mrb_state *mrb, mrb_value klass)
}

/*
* Regexp.__sub_str(re, str, replacement) - sub core without block
* Regexp.__sub_str(re, str, replacement, checked = false) - sub core without block
*
* `checked` carries the same meaning as in `__gsub_str`.
*/
static mrb_value
regexp_s_sub_str(mrb_state *mrb, mrb_value klass)
{
mrb_value re, str, replacement;
mrb_get_args(mrb, "oSS", &re, &str, &replacement);
mrb_bool checked = FALSE;
mrb_get_args(mrb, "oSS|b", &re, &str, &replacement, &checked);
check_regexp_arg(mrb, re);

mrb_regexp_pattern *pat = DATA_GET_PTR(mrb, re, &regexp_type, mrb_regexp_pattern);
if (!pat) mrb_raise(mrb, E_ARGUMENT_ERROR, "uninitialized Regexp");
if (!checked) re_check_encoding(mrb, str);

const char *s = RSTRING_PTR(str);
mrb_int slen = RSTRING_LEN(str);
Expand Down Expand Up @@ -1226,6 +1272,7 @@ regexp_s_scan(mrb_state *mrb, mrb_value klass)

mrb_regexp_pattern *pat = DATA_GET_PTR(mrb, re, &regexp_type, mrb_regexp_pattern);
if (!pat) mrb_raise(mrb, E_ARGUMENT_ERROR, "uninitialized Regexp");
re_check_encoding(mrb, str);

const char *s = RSTRING_PTR(str);
mrb_int slen = RSTRING_LEN(str);
Expand Down Expand Up @@ -1341,7 +1388,7 @@ mrb_mruby_regexp_gem_init(mrb_state *mrb)
mrb_define_class_method(mrb, re, "quote", regexp_escape, MRB_ARGS_REQ(1));
mrb_define_class_method(mrb, re, "__binary_string?", regexp_binary_string_p, MRB_ARGS_REQ(1));
mrb_define_class_method(mrb, re, "__check_pattern", regexp_check_pattern, MRB_ARGS_REQ(1));
mrb_define_class_method(mrb, re, "__search", regexp_s_search, MRB_ARGS_ARG(2, 1));
mrb_define_class_method(mrb, re, "__search", regexp_s_search, MRB_ARGS_ARG(2, 2));
mrb_define_class_method(mrb, re, "__byte_search", regexp_s_byte_search, MRB_ARGS_ARG(2, 1));
mrb_define_class_method(mrb, re, "__search_p", regexp_s_search_p, MRB_ARGS_ARG(2, 1));

Expand All @@ -1358,8 +1405,8 @@ mrb_mruby_regexp_gem_init(mrb_state *mrb)
mrb_define_method(mrb, re, "hash", regexp_hash, MRB_ARGS_NONE());
mrb_define_method(mrb, re, "options", regexp_options, MRB_ARGS_NONE());
mrb_define_method(mrb, re, "casefold?", regexp_casefold_p, MRB_ARGS_NONE());
mrb_define_class_method(mrb, re, "__gsub_str", regexp_s_gsub_str, MRB_ARGS_REQ(3));
mrb_define_class_method(mrb, re, "__sub_str", regexp_s_sub_str, MRB_ARGS_REQ(3));
mrb_define_class_method(mrb, re, "__gsub_str", regexp_s_gsub_str, MRB_ARGS_ARG(3, 1));
mrb_define_class_method(mrb, re, "__sub_str", regexp_s_sub_str, MRB_ARGS_ARG(3, 1));
mrb_define_class_method(mrb, re, "__scan", regexp_s_scan, MRB_ARGS_REQ(2));

/* MatchData class */
Expand Down
Loading
Loading