Skip to content

mruby-regexp: type-check the sub, gsub, scan, split and =~ pattern - #7001

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:string-sub-gsub-scan-pattern-type
Aug 3, 2026
Merged

mruby-regexp: type-check the sub, gsub, scan, split and =~ pattern#7001
matz merged 2 commits into
mruby:masterfrom
takumin:string-sub-gsub-scan-pattern-type

Conversation

@takumin

@takumin takumin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

String#sub, #gsub and #scan compiled a String pattern and passed
everything else through untouched, so a pattern that is neither a Regexp nor a
String reached an internal helper and failed there with a NoMethodError
naming it. #split did check its pattern, but built the message from the
class in every case, so true and false were named TrueClass and
FalseClass.

The block form of #sub was worse than a wrong message: it reached the
pattern through match, and a Symbol answers that call with the operands
reversed, so the symbol became the subject and the receiver the pattern.

"ab".sub(:xaby) { "Z" }
# CRuby: TypeError (wrong argument type Symbol (expected Regexp))
# mruby: "xZy"

The type checks that did exist read the argument through is_a? and nil?,
both redefinable. String#=~ rejects a String up front because dispatching
one to the argument would come straight back to this method and recurse, and a
String subclass denying that it is a String slipped past the guard into that
very recursion.

class Denier < String
  def is_a?(klass)
    false
  end
end

d = Denier.new("b")
d =~ d   # CRuby: TypeError (type mismatch: String given)
         # mruby: SystemStackError (stack level too deep)

String#split has the mirror image of the problem: it sends nil and String
patterns to the core implementation before its own check is reached, so an
object answering nil? or is_a? could route itself to __split and never
reach the check at all.

Changes

  • String#=~ and #split read the real type through Module#===, which
    cannot be redefined.
  • #sub, #gsub, #scan and #split go through Regexp.__match_pattern,
    the check match and match? already use, which names nil, true and
    false by value and everything else by class.

The check stays in C so the argument cannot pose as a Regexp through a
redefined is_a? or class, and what to do with an accepted String stays in
Ruby, as fd5e075 established: match compiles it, sub, gsub and scan
quote it first, and split needs no such line, having already sent nil and
String patterns to the core implementation before the check is reached.

Two orderings that already match CRuby are kept and pinned by a test:
"abc".gsub(:b) returns an Enumerator and raises on the first iteration, and
"abc".split(true, 1) returns ["abc"] without examining the pattern. The
accept path is pinned as well, covering a Regexp subclass and a quoted String
pattern, so that the check can be reworked without the accepted cases moving
with it.

rake test passes.

Summary by CodeRabbit

  • Bug Fixes
    • Improved pattern validation across String regular-expression methods.
    • Invalid pattern types now consistently raise the appropriate errors.
    • String subclasses and objects mimicking type checks are handled correctly.
    • String patterns are treated literally where expected, preventing unintended regular-expression interpretation.
    • Improved behavior for gsub enumerators and split edge cases.
  • Tests
    • Added regression coverage for validation, subclass handling, literal patterns, and edge-case behavior.

takumin added 2 commits August 3, 2026 14:13
…split`

`String#=~` rejects a String argument up front, because dispatching one to
the argument would come straight back to this method and recurse. The guard
asked the argument for its type through `is_a?`, which is redefinable, so a
String subclass denying that it is a String slipped past the guard and
reached the very dispatch the guard exists to prevent.

```ruby
class Denier < String
  def is_a?(klass)
    false
  end
end

d = Denier.new("b")
d =~ d   # CRuby: TypeError (type mismatch: String given)
         # mruby: SystemStackError (stack level too deep)
```

`String#split` has the mirror image of the problem. It sends `nil` and
String patterns to the core implementation before its own type check is
reached, and asked the argument with `nil?` and `is_a?`, both redefinable,
so an object answering either one could route itself to `__split` and never
reach the check at all.

Both now read the real type through `Module#===`, which cannot be
redefined.

The accept path of `sub`, `gsub`, `scan` and `split` is pinned by a test
alongside, covering a Regexp subclass and a quoted String pattern, so that
the check itself can be reworked without the accepted cases moving with it.
`sub`, `gsub` and `scan` compiled a String pattern and passed everything
else through untouched, so a pattern that is neither a Regexp nor a String
reached an internal helper and failed there with a `NoMethodError` naming
it. `split` did check its pattern, but built the message from the class in
every case, so `true` and `false` were named `TrueClass` and `FalseClass`.

All four now go through `Regexp.__match_pattern`, the check `match` and
`match?` already use, which names `nil`, `true` and `false` by value and
everything else by class. The check stays in C so the argument cannot pose
as a Regexp through a redefined `is_a?` or `class`, and what to do with an
accepted String stays in Ruby, as fd5e075 established: `match` compiles
it, `sub`, `gsub` and `scan` quote it first.

`split` needs no such line, having already sent `nil` and String patterns
to the core implementation before the check is reached.

The block form of `sub` returned a wrong string rather than raising, since
it reached the pattern through `match`, and a Symbol answers that call with
the operands reversed: the symbol becomes the subject and the receiver the
pattern.

```ruby
"ab".sub(:xaby) { "Z" }
# CRuby: TypeError (wrong argument type Symbol (expected Regexp))
# mruby: "xZy"
```

Two orderings that already match CRuby are kept: `"abc".gsub(:b)` returns
an Enumerator and raises on the first iteration, and `"abc".split(true, 1)`
returns `["abc"]` without examining the pattern.
@takumin
takumin requested a review from matz as a code owner August 3, 2026 05:18
@coderabbitai

coderabbitai Bot commented Aug 3, 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: f5f009ad-8487-4373-a090-092b29959c16

📥 Commits

Reviewing files that changed from the base of the PR and between ad9d982 and 45993b4.

📒 Files selected for processing (3)
  • mrbgems/mruby-regexp/mrblib/string_regexp.rb
  • mrbgems/mruby-regexp/src/regexp.c
  • mrbgems/mruby-regexp/test/regexp.rb

📝 Walkthrough

Walkthrough

Regexp-related String methods now use Regexp.__match_pattern and exact type checks. String patterns remain quoted where required. Tests cover spoofed types, subclasses, deferred gsub validation, and split early returns.

Changes

Regexp pattern validation

Layer / File(s) Summary
Shared pattern validation integration
mrbgems/mruby-regexp/mrblib/string_regexp.rb, mrbgems/mruby-regexp/src/regexp.c
=~, sub, gsub, scan, and split now use exact type checks or Regexp.__match_pattern. The resolver documentation describes these callers.
Pattern validation regression coverage
mrbgems/mruby-regexp/test/regexp.rb
Tests cover invalid patterns, spoofed type identities, subclasses, literal String quoting, deferred gsub errors, and split limit-1 behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • mruby/mruby#6988: Extends String#=~ String-type validation and related regexp pattern checks.
  • mruby/mruby#6989: Modifies String#sub and String#gsub argument handling with overlapping tests.
  • mruby/mruby#6994: Extends shared Regexp.__match_pattern validation to regexp-related String methods.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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 identifies the pattern type-checking changes for the specified String methods.
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 5318313 into mruby:master Aug 3, 2026
21 checks passed
@takumin
takumin deleted the string-sub-gsub-scan-pattern-type branch August 3, 2026 05:59
takumin added a commit to takumin/mruby that referenced this pull request Aug 3, 2026
The limit conversion is guarded by `limit.is_a?(Integer)`, which asks the
argument for its own type. `is_a?` is redefinable, so an argument claiming
to be an Integer skips the conversion entirely and reaches the split loop
as itself.

Answering none of the operators the loop uses, the failure surfaces as a
`NoMethodError` naming an internal comparison, which says nothing about the
limit being wrong. Answering `==`, `>` and `-`, there is no error at all:
the loop runs to completion with a non-Integer limit and returns a
plausible wrong result.

```ruby
class Cmp
  def is_a?(klass); true; end
  def ==(other); false; end
  def >(other); true; end
  def -(other); 1; end
end

"a,b,c".split(/,/, Cmp.new)   # CRuby: TypeError
                              # mruby: ["a", "b,c"]
```

`Module#===` reads the real type and cannot be redefined, the same
substitution mruby#7001 made for the pattern argument a few lines below.

The two halves of the method used to disagree about one argument. A String
pattern delegates to `__split`, which converts the limit again in C, where
the argument gets no say, so only the regexp path was reachable with an
unconverted limit. Both paths now raise the same `TypeError`.

8f85a84 dropped the `to_int` dispatch this guard used to stand in front
of, so the block behind it is now a single `__to_int` call. That call is
what a redefined `is_a?` still skips, so the guard remains the only thing
between such an argument and the loop.
takumin added a commit to takumin/mruby that referenced this pull request Aug 3, 2026
The limit conversion is guarded by `limit.is_a?(Integer)`, which asks the
argument for its own type. `is_a?` is redefinable, so an argument claiming
to be an Integer skips the conversion entirely and reaches the split loop
as itself.

Answering none of the operators the loop uses, the failure surfaces as a
`NoMethodError` naming an internal comparison, which says nothing about the
limit being wrong. Answering `==`, `>` and `-`, there is no error at all:
the loop runs to completion with a non-Integer limit and returns a
plausible wrong result.

```ruby
class Cmp
  def is_a?(klass); true; end
  def ==(other); false; end
  def >(other); true; end
  def -(other); 1; end
end

"a,b,c".split(/,/, Cmp.new)   # CRuby: TypeError
                              # mruby: ["a", "b,c"]
```

`Module#===` reads the real type and cannot be redefined, the same
substitution mruby#7001 made for the pattern argument a few lines below.

The two halves of the method used to disagree about one argument. A String
pattern delegates to `__split`, which converts the limit again in C, where
the argument gets no say, so only the regexp path was reachable with an
unconverted limit. Both paths now raise the same `TypeError`.

8f85a84 dropped the `to_int` dispatch this guard used to stand in front
of, so the block behind it is now a single `__to_int` call. That call is
what a redefined `is_a?` still skips, so the guard remains the only thing
between such an argument and the loop.

Related to mruby#7003.
takumin added a commit to takumin/mruby that referenced this pull request Aug 3, 2026
The limit conversion is guarded by `limit.is_a?(Integer)`, which asks the
argument for its own type. `is_a?` is redefinable, so an argument claiming
to be an Integer skips the conversion entirely and reaches the split loop
as itself.

Answering none of the operators the loop uses, the failure surfaces as a
`NoMethodError` naming an internal comparison, which says nothing about the
limit being wrong. Answering `==`, `>` and `-`, there is no error at all:
the loop runs to completion with a non-Integer limit and returns a
plausible wrong result.

```ruby
class Cmp
  def is_a?(klass); true; end
  def ==(other); false; end
  def >(other); true; end
  def -(other); 1; end
end

"a,b,c".split(/,/, Cmp.new)   # CRuby: TypeError
                              # mruby: ["a", "b,c"]
```

`Module#===` reads the real type and cannot be redefined, the same
substitution mruby#7001 made for the pattern argument a few lines below.

The two halves of the method used to disagree about one argument. A String
pattern delegates to `__split`, which converts the limit again in C, where
the argument gets no say, so only the regexp path was reachable with an
unconverted limit. Both paths now raise the same `TypeError`.

8f85a84 dropped the `to_int` dispatch this guard used to stand in front
of, so the block behind it is now a single `__to_int` call. That call is
what a redefined `is_a?` still skips, so the guard remains the only thing
between such an argument and the loop.

Related to mruby#7003.
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