Skip to content

string.c: a copy of a binary string is binary - #7080

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:string-binary-flag-survives-dup
Aug 10, 2026
Merged

string.c: a copy of a binary string is binary#7080
matz merged 1 commit into
mruby:masterfrom
takumin:string-binary-flag-survives-dup

Conversation

@takumin

@takumin takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

str_replace() copies MRB_STR_SINGLE_BYTE from the string it copies and not
MRB_STR_BINARY, so every copy of a byte-indexed string comes back without the
flag that says to read it as bytes.

s = "\u{1F600}".b   # F0 9F 98 80: four bytes, one character

s.encoding          # ASCII-8BIT
s.dup.encoding      # UTF-8   <- CRuby: ASCII-8BIT
s.clone.encoding    # UTF-8   <- CRuby: ASCII-8BIT
"x".replace(s)      # UTF-8   <- CRuby: ASCII-8BIT

Why it is more than a label

chars2bytes() and bytes2chars() both read MRB_STR_BINARY to decide
whether an index is a byte or a character, so a copy that lost the flag has
every offset computed off it switch units.

mruby-regexp reaches this without the caller making a copy at all.
create_matchdata() snapshots the subject with mrb_str_dup_frozen(), so that
changing the subject afterwards is not visible through the MatchData. The
snapshot loses the flag, and re_byte_to_char() counts UTF-8 lead bytes over a
string that holds no characters:

s = "\u{1F600}".b

s =~ Regexp.new("\x80")                        # 1  <- CRuby: 3
s.byteindex(Regexp.new("\x80"))                # 3, correct
md = s.match(Regexp.new("\x80"))
md.begin(0)                                    # 1
md.pre_match.bytesize                          # 3  <- disagrees with begin(0)

One MatchData reported the same span in two units.

The change

Add RSTR_COPY_BINARY_FLAG beside RSTR_COPY_SINGLE_BYTE_FLAG and call it in
str_replace(). A copy holds the same bytes as what it copies, so it is
byte-indexed exactly when that is. Three lines.

The flag is copied rather than set, so a copy of a UTF-8 string stays UTF-8 and
String#b, which dups and then sets the flag, is unaffected.

Testing

rake test is green on full-core with gcc on x86_64-linux: 2216 asserts,
no failures.

Both new tests fail without the change and pass with it:

Fail: String#encoding survives a copy (mrbgems: mruby-encoding)
Fail: Regexp - a byte-indexed subject is reported in bytes (mrbgems: mruby-regexp)
  KO: 2   ->   KO: 0

A differential sweep against CRuby 4.0.6, over a byte-indexed subject with a
byte-indexed pattern (0x80 to 0xFF in four shapes, with and without /i, ten
subjects, 10240 cells), reading the position with =~:

cells differing from CRuby
master 310
master + this change 226
master + #7078 84
both 0

The 84 this change closes are all offset reporting; the rest belong to #7078
and are unrelated to the flag.

Not in this change

String#* and the substring family have the same gap at their own sites, and
String#+ takes two strings, so which side decides is a rule to choose rather
than a flag to copy. String#size reads a binary string as UTF-8 for a
different reason: utf8_strlen() does not ask about the flag, while its two
neighbours do.

Summary by CodeRabbit

  • Bug Fixes

    • String copies and replacements now preserve binary or UTF-8 encoding correctly.
    • Regular expression matching on binary strings now reports consistent byte-based positions.
    • String#byteindex and MatchData#begin/end now return correct offsets for binary data.
    • Existing UTF-8 strings continue to report character-based positions as expected.
  • Tests

    • Added coverage for encoding preservation through duplication, cloning, freezing, and replacement.
    • Added regression tests for multibyte and binary string indexing behavior.

`String#b` marks a string byte-indexed with `MRB_STR_BINARY`, and
`str_replace()` copies `MRB_STR_SINGLE_BYTE` from the string it copies without
copying that one. `mrb_str_dup()` goes through it, so `dup`, `clone` and
`replace` all hand back a string holding the same bytes with nothing left to
say how to read them.

```ruby
s = "\u{1F600}".b   # F0 9F 98 80: four bytes, one character

s.encoding          # ASCII-8BIT
s.dup.encoding      # was UTF-8, CRuby: ASCII-8BIT
s.clone.encoding    # was UTF-8, CRuby: ASCII-8BIT
"x".replace(s)      # was UTF-8, CRuby: ASCII-8BIT
```

The copy is not only mislabelled. Every offset computed from it switches to
counting characters, which a byte-indexed string does not have. `mruby-regexp`
reaches this without a copy in sight, because `create_matchdata()` snapshots
the subject with `mrb_str_dup_frozen()` so that a later change to the subject
is not visible through the `MatchData`. The snapshot is no longer binary, and
`re_byte_to_char()` then counts UTF-8 lead bytes over it:

```ruby
s = "\u{1F600}".b

s =~ Regexp.new("\x80")                # was 1,  CRuby: 3
s.byteindex(Regexp.new("\x80"))        # 3, correct: no copy in the way
s.match(Regexp.new("\x80")).begin(0)   # was 1
s.match(Regexp.new("\x80")).pre_match  # three bytes, disagreeing with begin(0)
```

So one `MatchData` reported the same span two ways: `#pre_match` in bytes and
`#begin` in characters.

Copy the flag beside the one already copied. A copy holds the same bytes as
what it copies, so it is byte-indexed exactly when that is, and the two
neighbours `chars2bytes()` and `bytes2chars()` already read the flag rather
than the encoding of the moment.

`String#*`, the substring family and `String#+` have the same gap and are left
alone here: the first two want the same one-line treatment at their own sites,
and `+` takes two strings, so which side decides is a rule to choose rather
than a flag to copy.
@coderabbitai

coderabbitai Bot commented Aug 10, 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: fd54dcde-7587-403f-8fb8-e7db35779ce9

📥 Commits

Reviewing files that changed from the base of the PR and between 7184392 and f71bd22.

📒 Files selected for processing (4)
  • include/mruby/string.h
  • mrbgems/mruby-encoding/test/string.rb
  • mrbgems/mruby-regexp/test/regexp.rb
  • src/string.c

📝 Walkthrough

Walkthrough

The string flag API now copies binary-string metadata during replacement and copy operations. Encoding tests cover copied strings. Regexp tests verify byte-based offsets for byte-indexed strings and character-based offsets for regular UTF-8 strings.

Changes

Binary flag preservation

Layer / File(s) Summary
Binary flag propagation
include/mruby/string.h, src/string.c, mrbgems/mruby-encoding/test/string.rb
Adds RSTR_COPY_BINARY_FLAG, applies it in str_replace, and tests encoding preservation through copy and replacement operations.
Regexp byte-offset validation
mrbgems/mruby-regexp/test/regexp.rb
Tests byte-based regexp positions for byte-indexed UTF-8 strings and character-based positions for regular UTF-8 strings.

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

Possibly related PRs

  • mruby/mruby#7053: Both changes cover regexp handling of duplicated string subjects and binary-string metadata.
  • mruby/mruby#7075: Both changes cover String#byteindex and regexp tests.
  • mruby/mruby#7078: Both changes cover mruby-regexp handling and tests for byte-indexed strings.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: copies of binary strings retain their binary status.
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.
✨ 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants