Skip to content

mruby-sprintf: reject a %c argument with no UTF-8 encoding - #7113

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:sprintf-percent-c-utf8-range
Aug 12, 2026
Merged

mruby-sprintf: reject a %c argument with no UTF-8 encoding#7113
matz merged 1 commit into
mruby:masterfrom
takumin:sprintf-percent-c-utf8-range

Conversation

@takumin

@takumin takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

sprintf("%c", cp) wrote an uninitialized stack byte for any integer
mrb_utf8_to_buf() cannot encode.

The encoder leaves its buffer untouched and returns 0 for a codepoint above
U+10FFFF. %c read that 0 as "invalid codepoint: write single byte" and
pushed cbuf[0], which nothing had written:

clen = (int)mrb_utf8_to_buf(cbuf, (uint32_t)code);
if (clen == 0) clen = 1;  /* invalid codepoint: write single byte */

On a MRB_UTF8_STRING build the emitted byte tracks whatever was on the
stack, so it changes with the surrounding format string:

sprintf("%c", 0x110000).bytes              #=> [64]
sprintf("hello world %c", 0x110000).bytes  #=> [..., 200]
sprintf("%c", -1).bytes                    #=> [200]

A negative argument reaches the same path, since mrb_int is cast to
uint32_t and -1 becomes 0xFFFFFFFF.

CRuby raises for both:

sprintf("%c", 0x110000)  # ArgumentError: invalid character
sprintf("%c", -1)        # ArgumentError: invalid character

Fix

Check the range before calling the encoder and raise the same
ArgumentError CRuby raises. The check has to come first rather than
testing the return value, because the cast to uint32_t wraps an
out-of-range value into a valid codepoint: (1 << 32) + 0x41 would
otherwise print as A. With the range checked, mrb_utf8_to_buf() can no
longer return 0.

This matches int_chr_utf8() in mruby-string-ext, which likewise rejects
the value before calling the shared encoder.

Scope

Surrogates are left alone. mrb_utf8_to_buf() encodes U+D800 to U+DFFF
while mrb_utf8len() rejects the resulting bytes, but CRuby has the same
asymmetry and produces the same output, so %c keeps matching it:

sprintf("%c", 0xD800).bytes            #=> [237, 160, 128]
sprintf("%c", 0xD800).valid_encoding?  #=> false

The other callers of mrb_utf8_to_buf() are already safe: pack_utf8()
raises RangeError when the encoder answers 0, and int_chr_utf8() and
check_unicode_cp() reject the value beforehand.

Builds without MRB_UTF8_STRING are untouched: %c still takes the low
byte of the argument.

Tests

A new case in mrbgems/mruby-sprintf/test/sprintf.rb covers the U+10FFFF,
negative, and wrapping arguments, skipped on builds that are not UTF-8. The
shift for the wrapping argument is computed at run time rather than written as
a literal, because on a build with a 32 bit mrb_int and no bigint the
constant folder rejects 1 << 32 while the file is being compiled and the
whole test file is dropped without a word. On such a build the value cannot be
constructed at all, so the assertion is skipped.

Suites pass:

  • full-core with MRB_UTF8_STRING: 2250 tests, 0 failures
  • default build: 2059 tests plus 105 bintests, 0 failures
  • MRB_INT32 without bigint: 1504 tests, 0 failures. With the literal form it
    is 1493, the difference being every mruby-sprintf test.

Summary by CodeRabbit

  • Bug Fixes

    • Improved UTF-8 character formatting to reject negative values and values outside the valid Unicode range.
    • Invalid character values now raise ArgumentError instead of producing unintended characters.
  • Tests

    • Added regression coverage for invalid and overflowing character values in sprintf.

@takumin
takumin requested a review from matz as a code owner August 12, 2026 11:10
@coderabbitai

coderabbitai Bot commented Aug 12, 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: cbb2412f-9f6b-4a34-b01f-51354621b8f4

📥 Commits

Reviewing files that changed from the base of the PR and between c9a0ae0 and 0543f3a.

📒 Files selected for processing (1)
  • mrbgems/mruby-sprintf/test/sprintf.rb
🚧 Files skipped from review as they are similar to previous changes (1)
  • mrbgems/mruby-sprintf/test/sprintf.rb

📝 Walkthrough

Walkthrough

The %c formatter now rejects integer values outside 0..0x10FFFF when UTF-8 strings are enabled. Regression tests cover negative, oversized, and wrapping values.

Changes

UTF-8 character formatting

Layer / File(s) Summary
Validate UTF-8 code points
mrbgems/mruby-sprintf/src/sprintf.c, mrbgems/mruby-sprintf/test/sprintf.rb
%c raises ArgumentError for invalid Unicode code points. Regression tests cover negative, out-of-range, and wrapping integers.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Possibly related PRs

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 identifies the mruby-sprintf %c validation change for UTF-8 encoding.
✨ 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.

`mrb_utf8_to_buf()` leaves its buffer untouched and returns 0 for a value
it cannot encode. `%c` turned that 0 into a length of 1 and pushed the
uninitialized first byte of its stack buffer, so what came out was
whatever the stack happened to hold at that call site:

```ruby
sprintf("%c", 0x110000).bytes              #=> [64]
sprintf("hello world %c", 0x110000).bytes  #=> [..., 200]
sprintf("%c", -1).bytes                    #=> [200]
```

The exact bytes vary with the build and the surrounding format, so the
result is not reproducible even between two call sites in one program.
CRuby raises for both arguments:

```ruby
sprintf("%c", 0x110000)  # ArgumentError: invalid character
sprintf("%c", -1)        # ArgumentError: invalid character
```

The range has to be checked before the encoder call, not after it, because
`mrb_utf8_to_buf()` takes a `uint32_t`: `(1 << 32) + 0x41` wraps to U+0041
and would print as `A`.

Reject the value up front, the way `int_chr_utf8()` in mruby-string-ext
already does, and raise the `ArgumentError` CRuby raises here. Once the
range is checked, `mrb_utf8_to_buf()` can no longer answer 0, so nothing
is left to test on the way back.

A surrogate still encodes, since CRuby encodes it too, and builds without
`MRB_UTF8_STRING` keep taking the low byte as before.
@takumin
takumin force-pushed the sprintf-percent-c-utf8-range branch from c9a0ae0 to 0543f3a Compare August 12, 2026 11:44
@takumin

takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a test fix (the C change is unchanged).

The test built its wrapping argument from the literal 1 << 32. That is
constant folded while the file is compiled, so on a build with MRB_INT32 and
no bigint the compile fails and the whole mrbgems/mruby-sprintf/test/sprintf.rb
is dropped without a word, taking every sprintf test with it. A rescue there
does not help, since nothing is running yet.

Measured on MRB_INT32 without bigint:

  • with the literal: 1493 tests
  • with the shift computed at run time: 1504 tests

The shift now comes from a variable, so the value is built at run time and is
simply unavailable where mrb_int cannot hold it.

The same review point applies to #7114, which is fixed there too.

@matz
matz merged commit b35f0c1 into mruby:master Aug 12, 2026
21 checks passed
@takumin
takumin deleted the sprintf-percent-c-utf8-range branch August 12, 2026 12:38
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