Skip to content

Read what sprintf builds the way its bytes were read - #7143

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:binary-flag-through-sprintf
Aug 14, 2026
Merged

Read what sprintf builds the way its bytes were read#7143
matz merged 2 commits into
mruby:masterfrom
takumin:binary-flag-through-sprintf

Conversation

@takumin

@takumin takumin commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #7137, which named this seam among the ones it deliberately left out.

sprintf builds its result in a raw buffer and never looks at what the strings going into it were read as, so a byte-read argument loses that reading on the way through, and so does a byte-read format string:

171.chr                #=> ASCII-8BIT, valid
"%s" % [171.chr]       #=> UTF-8, invalid   (CRuby: ASCII-8BIT)
"%c" % [171.chr]       #=> UTF-8, invalid   (CRuby: ASCII-8BIT)
"%<x>s" % {x: 171.chr} #=> UTF-8, invalid   (CRuby: ASCII-8BIT)
"%s".b % ["ab"]        #=> UTF-8            (CRuby: ASCII-8BIT)

Since 80c781b refuses a subject whose bytes do not read as UTF-8, this is no longer a mis-indexing but a refusal: 171.chr matches against a pattern, while "%s" % [171.chr], the same single byte, raises ArgumentError.

The rule

The one #7137 settled, applied to the two places sprintf writes bytes it did not make itself.

  • The format string is the receiver. What sprintf builds is the format string's own bytes with the arguments written between them, so the result is read the way the format string was read, ASCII bytes and all. That is the reading a receiver holds through an append, which nothing written into it lifts.
  • An argument is what is written in. An argument read as bytes and going above ASCII spells no character where it lands, so it hands the result the byte reading along with itself. ASCII bytes read the same under any reading and move nothing.

Three sites: the result takes MRB_STR_BINARY from the format string where it is created, %s/%p marks from the string its argument converts to, and %c marks from a String argument. An Integer argument to %c is a code point rather than a byte, and the string %p builds for itself is one of its own, so neither marks anything.

What an argument is read as is a property of the argument, not of the part that reaches the buffer, so a precision cutting the byte above ASCII off the written part moves nothing: "%.1s" % ["a\xABb".b] is byte-read, as in CRuby.

This reproduces CRuby's answer for every pair it accepts. The pairs CRuby refuses with Encoding::CompatibilityError come out byte-read here rather than raising, the same choice #7137 made.

Tests

The first commit pins where every answer stands before any of it moves; the second flips exactly the pins it changes.

Whether a string is read as bytes or as UTF-8 is only visible through mruby-encoding, which this gem does not depend on, so such a test skips itself in the state mrbtest builds for this gem, in every configuration. The first commit takes that dependency in the test state when the build already carries the gem, the way mruby-regexp does since #7136. A build without mruby-encoding is unchanged, and no build gains a gem it did not already have. The test asks __ENCODING__ as well, since a build that reads every string as bytes has nothing for the marking to tell apart.

Still left out

Array#pack and String#unpack mark nothing ([171].pack("C") reports UTF-8, invalid), and a Symbol has nowhere to keep the marking (171.chr.to_sym.to_s reports UTF-8). Each is its own change.

Alongside #7142

#7142 leaves MRB_UTF8_STRING to the build, so carrying mruby-encoding stops meaning the build reads UTF-8, and a full-core build that indexes by byte runs in CI. That is what the __ENCODING__ guard is for: redundant while the gem still sets the define, load-bearing once it does not. The two series share no file. MRB_STR_BINARY and RSTR_COPY_BINARY_FLAG do not wait on MRB_UTF8_STRING, so what this one adds to sprintf.c compiles and runs the same on either side; what changes is only whether String#encoding shows the marking.

Verification

Full suite green at every commit, on full-core with MRB_UTF8_STRING (2279 tests), the same with the C++ ABI (2279), and the default gembox, where mruby-encoding is absent and the new test skips (2066). 0 failures, 0 crashes, no new compiler warnings.

Merged with #7142, full-core without MRB_UTF8_STRING is green as well (2239), the new test among the skips. That series turns the C++ ABI build into one of those, so its count above moves there once it lands.

Summary by CodeRabbit

  • Bug Fixes
    • Improved sprintf and String#% handling of binary and UTF-8 strings.
    • Preserved binary encoding when formatting non-ASCII character and string values.
    • Ensured formatted output remains valid across precision limits and conversion types.
  • Tests
    • Added coverage for encoding propagation, binary format strings, truncation, and UTF-8 output.

`Integer#chr` hands back a byte-read string for a byte above ASCII since
19d81d2, and the operations that build one string out of another carry the
marking with the bytes since 7806a13 and 037f816. What sprintf builds does
not. The bytes go through: a byte-read argument lands in the result whole,
and a byte-read format string lays its own bytes down as they are. The
reading that goes with them is left behind, so the result reports UTF-8 over
bytes that refuse to read as it, one derivation away from the state
`Integer#chr` stopped handing out.

Whether a string is read as bytes or as UTF-8 is only visible through
mruby-encoding, which this gem does not depend on, so a test asking the
question skips itself in the state mrbtest builds for this gem. Every such
test skips in every configuration, which leaves the answer unasserted rather
than asserted somewhere else. Take the dependency in the test state when the
build already carries the gem, the way mruby-regexp already does. A build
without mruby-encoding is unchanged, and no build gains a gem it did not
already have.

Pin where every answer stands before any of it moves: the argument written
through `%s`, `%c`, a width and a name, the format string's own bytes, and
the three that say nothing about the reading either way.
What sprintf builds is the format string's own bytes with the arguments
written between them, and it came back read as UTF-8 whatever went into it.
A byte-read argument above ASCII lost its reading on the way through `%s`,
which handed the result a claim its bytes could not honor: since 80c781b
refuses a subject that does not read as UTF-8, `171.chr` matches against a
pattern while `"%s" % [171.chr]`, the same byte, raises ArgumentError.

Carry the format string's own reading into the result, the way a receiver's
reading is carried through an append, and let an argument read as bytes and
going above ASCII hand its reading over, the way appended byte-read bytes
already do through `mrb_str_cat_str()`. `%c` writes an argument's bytes too
and takes the same marking; an Integer argument is a code point rather than
a byte and takes none, as does the string `%p` builds for itself.

What an argument is read as is a property of the argument, so a precision
that cuts the byte above ASCII off the written part moves nothing. This is
where CRuby lands on every pair it accepts: it refuses the incompatible ones
outright, where this says nothing rather than something false, as `+` and
the append already do.
@takumin
takumin requested a review from matz as a code owner August 14, 2026 03:29
@coderabbitai

coderabbitai Bot commented Aug 14, 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: 585168c2-eac0-4d3a-a780-7adb74c90b93

📥 Commits

Reviewing files that changed from the base of the PR and between f10d65b and 228bff1.

📒 Files selected for processing (3)
  • mrbgems/mruby-sprintf/mrbgem.rake
  • mrbgems/mruby-sprintf/src/sprintf.c
  • mrbgems/mruby-sprintf/test/sprintf.rb

📝 Walkthrough

Walkthrough

The mruby-sprintf gem now propagates binary-string status during formatting. It conditionally enables mruby-encoding for supported test builds and adds coverage for binary preservation, precision handling, byte-oriented formats, and UTF-8 results.

Changes

Sprintf encoding behavior

Layer / File(s) Summary
Binary status propagation
mrbgems/mruby-sprintf/src/sprintf.c
Formatted results inherit binary status from binary format strings or non-ASCII argument content. Character, string, and inspected-object conversions apply this status before output processing.
Encoding validation and test wiring
mrbgems/mruby-sprintf/mrbgem.rake, mrbgems/mruby-sprintf/test/sprintf.rb
The test dependency uses mruby-encoding only when that gem is present. Tests cover binary byte preservation, truncation, byte-oriented formats, and UTF-8 results.

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

Merge Risk: 🟡 Moderate · up to 228bf

A formatted string can be incorrectly treated as byte-encoded even when precision removes the non-ASCII bytes that would justify that behavior, leading to incorrect encoding-sensitive results. Merge should wait for this bounded correctness issue to be fixed.

Possibly related PRs

  • mruby/mruby#7080: Modifies binary-string flag propagation in other string operations.
  • mruby/mruby#7137: Applies related binary-string propagation behavior to other string-construction paths.
  • mruby/mruby#7142: Extends mruby-sprintf encoding tests and uses related test integration.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: preserving how sprintf reads and builds bytes.
✨ 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 599e124 into mruby:master Aug 14, 2026
21 checks passed
@takumin
takumin deleted the binary-flag-through-sprintf branch August 14, 2026 03:56
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