Skip to content

mruby-string-ext: validate UTF-8 sequences with mrb_utf8len - #7105

Merged
matz merged 3 commits into
mruby:masterfrom
takumin:string-ext-utf8-validation
Aug 12, 2026
Merged

mruby-string-ext: validate UTF-8 sequences with mrb_utf8len#7105
matz merged 3 commits into
mruby:masterfrom
takumin:string-ext-utf8-validation

Conversation

@takumin

@takumin takumin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

mrb_utf8len() decides what counts as a UTF-8 character in core, under the
RFC 3629 rules it was given in #7093. mruby-string-ext decides it twice more,
in utf8code() and in str_scrub_char_len(), and both were taught those same
rules by hand in that same PR. Ask core instead.

Reading a refusal

mrb_utf8len() answers 1 for every sequence it turns down. A real one-byte
character is a lead byte below 0x80, so a length of 1 on a byte at or above
0x80 is the refusal. That is the whole bridge; both helpers are a few lines on
top of it.

static mrb_int
str_scrub_char_len(const unsigned char *p, const unsigned char *e)
{
  mrb_int len = mrb_utf8len((const char*)p, (const char*)e);
  if (len == 1 && p[0] >= 0x80) return -1;
  return len;
}

utf8code() keeps only the part core has no answer for, the code point itself,
and reports the byte length it decoded through a new lenp argument.
String#codepoints was stepping over characters by the raw
mrb_utf8len_table, which is the length a lead byte claims rather than the
length that was accepted; it now steps by what came back. The table is still
read by String#chars, so the declaration stays.

22 lines of C replace 50.

Equivalence

Nothing here is meant to change behavior, so the old and new helpers were run
against each other over both leading bytes, nine representative tail bytes and
every available length from 1 to 4: 21,233,664 cases, with no difference in
the code point, in what is refused, or in the byte length.

Tests

String#ord and String#codepoints had nothing holding them to the RFC 3629
rules; the tests for those go through String#scrub, which reads its lengths
from the other helper. The first commit pins them, and it passes both before
and after the two that follow.

Verified

  • rake test on a full-core build (MRB_UTF8_STRING through mruby-encoding):
    2243 tests, all green, at each of the three commits
  • rake test on the default gembox (no MRB_UTF8_STRING): 2055 tests, all
    green
  • prek run --all-files passes, except that markdownlint could not install
    locally (npm engine mismatch); no Markdown is touched here

Summary by CodeRabbit

  • Bug Fixes

    • Improved UTF-8 decoding for string operations.
    • String#ord and String#codepoints now correctly reject malformed UTF-8 sequences with ArgumentError.
    • UTF-8 scrubbing now consistently validates character sequences.
  • Tests

    • Added coverage for malformed UTF-8, including invalid bytes following valid four-byte characters.

Core stopped accepting the UTF-8 sequences RFC 3629 forbids in mruby#7093,
and `utf8code()` in this gem was taught the same rules by hand. The
tests for those rules go through `String#scrub`, which reads its lengths
from a different helper, so nothing holds `ord` and `codepoints` to
them.

Pin them: a stray continuation byte, a truncated sequence, an overlong
encoding, a surrogate, a code point above U+10FFFF and a five-byte lead
all raise. `String#codepoints` also has to keep its place across a
4-byte character, so a string that continues past one is checked too.
`str_scrub_char_len()` asks whether the bytes at p start a valid UTF-8
character and, if they do, how many bytes it takes. `mrb_utf8len()`
answers that question in core, and answers it by the same rules: both
were brought to RFC 3629 in mruby#7093, one after the other.

What has to be bridged is how a refusal comes back. `mrb_utf8len()`
answers 1 for every sequence it turns down, and a real one-byte
character is a lead byte below 0x80, so a length of 1 on a byte at or
above 0x80 is the refusal.

The two were checked against each other over both leading bytes, nine
representative tail bytes and every available length from 1 to 4:
21,233,664 cases, no difference.
`utf8code()` judged the sequence itself before decoding it: the
continuation bytes, then the RFC 3629 rules for overlong encodings,
surrogates and code points above U+10FFFF, all written a second time.
`mrb_utf8len()` passes that same judgment, so ask it and decode what it
accepts.

`String#codepoints` stepped over each character by the raw
`mrb_utf8len_table`, which is the length a lead byte claims rather than
the length that was accepted. The two agree wherever `utf8code()`
returns, but the caller no longer has to know that: the byte length
comes back through `lenp`. The table is still read by `String#chars`,
so the declaration stays.

The two were checked against each other over the same 21,233,664 cases
as `str_scrub_char_len()`, with no difference in the code point, in
what is refused, or in the byte length.
@takumin
takumin requested a review from matz as a code owner August 12, 2026 07:44
@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: c704fd33-9ac1-479c-886d-d3bfca6e5edc

📥 Commits

Reviewing files that changed from the base of the PR and between 15e2deb and c7362cb.

📒 Files selected for processing (2)
  • mrbgems/mruby-string-ext/src/string.c
  • mrbgems/mruby-string-ext/test/string.rb

📝 Walkthrough

Walkthrough

UTF-8 decoding now uses shared sequence-length validation. String#ord, String#codepoints, and scrubbing consume the returned length. Tests cover malformed UTF-8 sequences and continuation after valid four-byte characters.

Changes

UTF-8 validation

Layer / File(s) Summary
Shared UTF-8 decoder integration
mrbgems/mruby-string-ext/src/string.c
utf8code, String#ord, scrubbing, and codepoint iteration now use mrb_utf8len() for sequence validation and length handling.
Malformed input coverage
mrbgems/mruby-string-ext/test/string.rb
Tests cover stray, truncated, overlong, surrogate, out-of-range, and invalid five-byte sequences for String#codepoints and String#ord.

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

Possibly related PRs

  • mruby/mruby#7072: Addresses malformed four-byte UTF-8 validation in related string code paths.
  • mruby/mruby#7093: Strengthens the shared mrb_utf8len() validation used by this change.
  • mruby/mruby#7102: Introduces related shared UTF-8 sequence validation for core string encoding.

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 and concisely identifies the main change: validating UTF-8 sequences in mruby-string-ext with mrb_utf8len.
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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants