Skip to content

Commit c079845

Browse files
committed
refactor(text,text-json,globalization,core): one UTF-8 scalar decode, in one place (#2354)
#2014 found five copies of this runtime's UTF-8 scalar decode, factored the rule into System/detail/Utf8Scalar.hpp and moved the two .cpp ones. #2354 was the follow-up for the three header-inline copies it left alone as "entangled with those types' own decode loops". THE TICKET NAMED THREE COPIES AND THERE WERE SIX. Measured by grepping for the continuation-byte test across every module: UnicodeEncoding.hpp named moved UTF32Encoding.hpp named moved Rune.hpp named moved Utf8JsonWriter.cpp NOT moved -- byte-for-byte identical to the shared one IdnMapping.cpp NOT moved UTF8Encoding.cpp NOT moved -- see below Zero copies remain. That the count could be wrong by a factor of two is the finding, not a footnote: a rule duplicated often enough that nobody can enumerate its copies is exactly the shape that produced the defects #2014 and the IdnMapping repair were written to fix -- "\xC2\x41" decoding to a garbage code point, "\xC0\x80" decoding straight through to real U+0000. THE COPIES WERE NOT GRATUITOUS, AND THAT IS PRESERVED RATHER THAN COLLAPSED. Three doors want three different things, and they differ on exactly one input class: a STRUCTURALLY VALID encoding of a value that is not a Unicode scalar -- a surrogate, or a value above U+10FFFF. Rune::TryGetRuneAt reports failure, consuming the SEQUENCE'S OWN LENGTH, so a caller can step over the whole thing any Encoding substitutes U+FFFD over ONE BYTE, so it emits one replacement per byte, which is .NET's replacement DecoderFallback IdnMapping throws, because a domain name comes from untrusted input So the shared header has TWO entry points, not one. TryDecodeUtf8Scalar reports; DecodeUtf8Scalar is five lines on top of it and substitutes. Collapsing the two would have been a silent behaviour change in whichever door lost. THE COPY #2014 COULD NOT MOVE. UTF8Encoding.cpp's wellFormedUtf8Length reads a (pointer, end) range rather than a std::string and returns a validity LENGTH rather than a code point, because its well-formed bytes pass through unchanged and there is no re-encoding step to fold a substitution into. That was a real obstacle. The answer is that the range is now a PARAMETER of the shared decode. `size` is an exclusive bound, so a caller decoding a sub-range gets a truncated sequence reported as ill-formed instead of read past. IdnMapping's two hand-spelled extra rejections are subsumed exactly: a 2-byte lead below 0xC2 is an overlong encoding, and a 4-byte lead above 0xF4 encodes a value above U+10FFFF. THE TEST THAT DID NOT EXIST. Rune::TryGetRuneAt had NO test anywhere in the repository, and the mutation that collapses its length onto the Encoding contract passed all 17,296 tests then present. Five cases added: Text 311 -> 314 the Rune/Encoding contrast, per-branch overlong rows Globalization 681 -> 683 the throwing contract, and the two subsumed rules Two files rather than one because modules/text does not depend on Globalization, and a refactor is not a reason to add a public component edge. The module graph is unchanged at 41 modules / 92 edges, verified. Five mutations, all caught -- but TWO of them only after a new assertion, and both are recorded rather than quietly fixed, because they measure how much of this rule was untested while it was duplicated six ways: collapsing the two contracts, and dropping the 3-byte branch's overlong rejection (the 2-byte row alone missed it, since each branch carries its own test). No behaviour change at any door, no signature change, no layout change, nothing to migrate. -179 lines. System::Text::detail still re-exports both entry points, so every caller written against the old spelling is unchanged. Also closes #2282 as MOOT: its subject, UnitySerializationHolder::GetRealObject(), was removed outright by #2281 under SA-9, so there is no misleading message left to repair and no replacement text left to defer. Gate: 17,301 run, 17,301 passed, 0 failed, 0 skipped across 38 executables, GREEN. docs/Utf8ScalarDecodeSingleDefinition.md
1 parent f99c46c commit c079845

13 files changed

Lines changed: 405 additions & 316 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# One UTF-8 scalar decode, in one place (ticket #2354)
5+
6+
*2026-08-18.* `System/detail/Utf8Scalar.hpp` is now the **only** definition of this runtime's
7+
UTF-8 scalar decode. Six copies existed across four modules; #2014 moved two, and #2354 moved the
8+
remaining four.
9+
10+
**No behaviour changes.** Every door produces byte-identical output for every input. There is no
11+
signature change, no layout change and nothing to migrate; this note is a design record, not a
12+
migration note.
13+
14+
---
15+
16+
## 1. Where the copies were
17+
18+
| Copy | Module | Named by the ticket? | Fate |
19+
|---|---|---|---|
20+
| `ASCIIEncoding.cpp` | `text` || moved by #2014 |
21+
| `Latin1Encoding.cpp` | `text` || written against the shared rule by #2014 |
22+
| `UnicodeEncoding.hpp` | `text` || moved |
23+
| `UTF32Encoding.hpp` | `text` || moved |
24+
| `Rune.hpp` | `text` || moved |
25+
| `Utf8JsonWriter.cpp` | `text-json` || moved — byte-for-byte identical |
26+
| `IdnMapping.cpp` | `globalization` || moved |
27+
| `UTF8Encoding.cpp` | `text` || moved — see §3 |
28+
29+
**The ticket named three and there were six.** That is the finding, not a footnote: a rule
30+
duplicated often enough that nobody could enumerate its copies is exactly the shape that produced
31+
the defects #2014 and the IdnMapping repair were written to fix (`"\xC2\x41"` decoding to a
32+
garbage code point, `"\xC0\x80"` decoding straight through to real U+0000).
33+
34+
## 2. Three contracts over one rule
35+
36+
The copies were not gratuitous. Three doors genuinely want three different things, and they
37+
differ on exactly one input class: a **structurally valid** encoding of a value that is **not a
38+
Unicode scalar** — a surrogate, or a value above `U+10FFFF`.
39+
40+
| Door | On ill-formed input | Consumes |
41+
|---|---|---|
42+
| `Rune::TryGetRuneAt` | reports `false` | the **sequence's own length** for a non-scalar, `1` for a structural break |
43+
| any `Encoding` | substitutes `U+FFFD` | always `1`, so one replacement per byte, as .NET's replacement `DecoderFallback` does |
44+
| `IdnMapping` | throws `ArgumentException` ||
45+
46+
Collapsing that difference would have been a silent behaviour change in whichever door lost, so
47+
it is a **parameter of the contract** rather than an accident of which copy you read:
48+
49+
```cpp
50+
bool TryDecodeUtf8Scalar(const char* s, std::size_t size, std::size_t i,
51+
std::uint32_t& codePoint, std::size_t& length); // reports
52+
void DecodeUtf8Scalar(const std::string& s, std::size_t i,
53+
std::uint32_t& codePoint, std::size_t& length); // substitutes
54+
```
55+
56+
`DecodeUtf8Scalar` is defined as `if (!Try(...)) { codePoint = 0xFFFD; length = 1; }` — five
57+
lines, and the substituting contract is now visible instead of being spread through five
58+
`return` statements.
59+
60+
`IdnMapping`'s two hand-spelled extra rejections are subsumed exactly: a 2-byte lead below
61+
`0xC2` is an overlong encoding, and a 4-byte lead above `0xF4` encodes a value above `U+10FFFF`.
62+
63+
## 3. The copy #2014 could not move
64+
65+
`UTF8Encoding.cpp`'s `wellFormedUtf8Length` reads a `(pointer, end)` range rather than a
66+
`std::string`, and returns a validity **length** rather than a code point — its well-formed bytes
67+
pass through unchanged, so there is no re-encoding step to fold a substitution into. That was a
68+
real obstacle, and the answer is that the range is now a **parameter** of the shared decode
69+
rather than a reason to keep a sixth copy. `size` is an exclusive bound, so a caller decoding a
70+
sub-range gets a truncated sequence reported as ill-formed instead of read past.
71+
72+
## 4. The test that did not exist
73+
74+
`Rune::TryGetRuneAt` had **no test anywhere in the repository**, and a mutation collapsing its
75+
length onto the `Encoding` contract passed all 17,296 tests then present. That gap is what makes
76+
a refactor like this dangerous, and it is closed first:
77+
78+
* `modules/text/tests/System/Text/Utf8SharedScalarDecodeTests.cpp`
79+
* `modules/globalization/tests/System/Globalization/IdnMappingUtf8DecodeTests.cpp`
80+
81+
Two files rather than one because `modules/text` does not depend on `Globalization`, and a
82+
refactor is not a reason to add a public component edge. **The module graph is unchanged at
83+
41 modules / 92 edges.**
84+
85+
| Mutation | Caught |
86+
|---|---|
87+
| Collapse the two contracts (a non-scalar reports length 1) | ✅ — **only after** the new pin; nothing caught it before |
88+
| The substituting form propagates the `Try` length | ✅ |
89+
| Drop the 3-byte branch's overlong rejection | ✅ — **only after** adding per-branch overlong rows; the 2-byte row alone missed it |
90+
| Drop the surrogate rejection | ✅ (three pre-existing suites) |
91+
| `UTF8Encoding` returns a length even on failure | ✅ |
92+
93+
Two of the five needed a new assertion. Both are recorded here rather than quietly fixed, because
94+
they are the measurement of how much of this rule was untested while it was duplicated six ways.
95+
96+
## 5. Net effect
97+
98+
−179 lines. One definition. `System::Text::detail` still re-exports both entry points, so every
99+
caller written against the old spelling is unchanged.

modules/core/include/System/detail/Utf8Scalar.hpp

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,14 @@
99
namespace System::detail {
1010

1111
/**
12-
* @brief One UTF-8 scalar decode, in one place.
12+
* @brief One UTF-8 scalar decode, in one place — the reporting form.
1313
*
1414
* This runtime stores `System::String` as UTF-8, so every component that converts *characters*
1515
* rather than *storage bytes* has to decode a scalar first — and by ticket #2014 five copies
16-
* of that decode had accumulated across `modules/text`: `ASCIIEncoding.cpp`,
17-
* `UTF8Encoding.cpp` (as a validity-length variant), `UnicodeEncoding.hpp`,
18-
* `UTF32Encoding.hpp` and `Rune.hpp`. Adding a sixth for `Latin1Encoding` is exactly the
19-
* duplication this repository keeps having to repair, so #2014 factored the rule out here and
20-
* moved the two `.cpp` copies onto it. The three header-inline copies are a separate,
21-
* recorded follow-up (**#2354**), because they are entangled with those types' own loops.
16+
* of that decode had accumulated across `modules/text`. #2014 factored the rule out here and
17+
* moved the two `.cpp` copies onto it; **ticket #2354 (2026-08-18) moved the last three**,
18+
* which are header-inline and entangled with their types' own loops: `UnicodeEncoding.hpp`,
19+
* `UTF32Encoding.hpp` and `Rune.hpp`. There is now exactly one definition.
2220
*
2321
* **It lives in `Core.Base` as of ticket #2106**, not in `modules/text`. `System::BinaryData`
2422
* needed the same decode to give `ToString()` .NET's replacement behaviour, and
@@ -27,40 +25,52 @@ namespace System::detail {
2725
* already depends on. `Core.Base` is that place. `System::Text::detail` re-exports these
2826
* names, so every existing caller is unchanged.
2927
*
30-
* @param s The UTF-8 text.
31-
* @param i Index of the first byte of the sequence; must be `< s.size()`.
32-
* @param codePoint Receives the decoded scalar, or `U+FFFD` for an ill-formed sequence.
33-
* @param length Receives the number of bytes consumed — always `1` for an ill-formed
34-
* sequence, so a caller resumes one byte later and cannot loop forever.
28+
* **Why there are two forms.** `Rune::TryGetRuneAt` must *report* an ill-formed sequence,
29+
* while an `Encoding` must *substitute* `U+FFFD` and carry on; and the two disagree about
30+
* one thing that a single function cannot express — how many bytes a **structurally valid**
31+
* sequence that encodes a **non-scalar** (a surrogate, or a value above `U+10FFFF`) consumes.
32+
* `Rune` reports the sequence's own length there, so a caller can skip the whole thing; the
33+
* substituting form always reports `1`, so one replacement character is emitted per byte, as
34+
* .NET's replacement fallback does. Collapsing that difference would have been a silent
35+
* behaviour change in whichever door lost, so it is a parameter of the contract instead.
3536
*
36-
* Conformance, unchanged from the copies it replaces: continuation bytes are validated,
37-
* overlong encodings are rejected, and a surrogate or an out-of-range scalar decodes to
38-
* `U+FFFD`.
37+
* @param s The UTF-8 bytes.
38+
* @param size Exclusive end of the readable range; a caller decoding a sub-range
39+
* passes that sub-range's end, and a truncated sequence at the boundary
40+
* is reported as ill-formed rather than read past.
41+
* @param i Index of the first byte of the sequence; must be `< size`.
42+
* @param codePoint Receives the decoded scalar; untouched unless the call returns `true`.
43+
* @param length Receives the number of bytes to advance by — the sequence's length on
44+
* success; `1` for a structurally ill-formed sequence (bad lead byte, bad
45+
* or missing continuation byte, overlong encoding), so a caller resumes one
46+
* byte later and cannot loop forever; and the sequence's own length for a
47+
* structurally valid encoding of a non-scalar value.
48+
* @return @c true when a Unicode scalar was decoded.
3949
*/
40-
inline void DecodeUtf8Scalar(const std::string& s, std::size_t i,
41-
std::uint32_t& codePoint, std::size_t& length) {
50+
[[nodiscard]] inline bool TryDecodeUtf8Scalar(const char* s, std::size_t size, std::size_t i,
51+
std::uint32_t& codePoint, std::size_t& length) {
4252
auto isContinuation = [](unsigned char b) { return (b & 0xC0) == 0x80; };
4353
const unsigned char c0 = static_cast<unsigned char>(s[i]);
4454
std::uint32_t cp;
4555
std::size_t len;
4656
if (c0 < 0x80) {
4757
cp = c0;
4858
len = 1;
49-
} else if ((c0 & 0xE0) == 0xC0 && i + 1 < s.size() &&
59+
} else if ((c0 & 0xE0) == 0xC0 && i + 1 < size &&
5060
isContinuation(static_cast<unsigned char>(s[i + 1]))) {
5161
cp = (static_cast<std::uint32_t>(c0 & 0x1F) << 6) |
5262
(static_cast<unsigned char>(s[i + 1]) & 0x3F);
5363
len = 2;
54-
if (cp < 0x80) { codePoint = 0xFFFD; length = 1; return; }
55-
} else if ((c0 & 0xF0) == 0xE0 && i + 2 < s.size() &&
64+
if (cp < 0x80) { length = 1; return false; } // overlong
65+
} else if ((c0 & 0xF0) == 0xE0 && i + 2 < size &&
5666
isContinuation(static_cast<unsigned char>(s[i + 1])) &&
5767
isContinuation(static_cast<unsigned char>(s[i + 2]))) {
5868
cp = (static_cast<std::uint32_t>(c0 & 0x0F) << 12) |
5969
((static_cast<unsigned char>(s[i + 1]) & 0x3F) << 6) |
6070
(static_cast<unsigned char>(s[i + 2]) & 0x3F);
6171
len = 3;
62-
if (cp < 0x800) { codePoint = 0xFFFD; length = 1; return; }
63-
} else if ((c0 & 0xF8) == 0xF0 && i + 3 < s.size() &&
72+
if (cp < 0x800) { length = 1; return false; } // overlong
73+
} else if ((c0 & 0xF8) == 0xF0 && i + 3 < size &&
6474
isContinuation(static_cast<unsigned char>(s[i + 1])) &&
6575
isContinuation(static_cast<unsigned char>(s[i + 2])) &&
6676
isContinuation(static_cast<unsigned char>(s[i + 3]))) {
@@ -69,19 +79,45 @@ namespace System::detail {
6979
((static_cast<unsigned char>(s[i + 2]) & 0x3F) << 6) |
7080
(static_cast<unsigned char>(s[i + 3]) & 0x3F);
7181
len = 4;
72-
if (cp < 0x10000) { codePoint = 0xFFFD; length = 1; return; }
82+
if (cp < 0x10000) { length = 1; return false; } // overlong
7383
} else {
74-
codePoint = 0xFFFD;
7584
length = 1;
76-
return;
85+
return false;
7786
}
7887
if (cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) {
79-
codePoint = 0xFFFD;
80-
length = 1;
81-
return;
88+
length = len; // structurally valid, but not a scalar -- see the note above
89+
return false;
8290
}
8391
codePoint = cp;
8492
length = len;
93+
return true;
94+
}
95+
96+
/** @brief `std::string` overload of the reporting form. */
97+
[[nodiscard]] inline bool TryDecodeUtf8Scalar(const std::string& s, std::size_t i,
98+
std::uint32_t& codePoint, std::size_t& length) {
99+
return TryDecodeUtf8Scalar(s.data(), s.size(), i, codePoint, length);
100+
}
101+
102+
/**
103+
* @brief One UTF-8 scalar decode — the substituting form every `Encoding` uses.
104+
*
105+
* Ill-formed input decodes to `U+FFFD` over a single byte, matching .NET's default
106+
* `DecoderFallback`. See `TryDecodeUtf8Scalar` for why the length differs between the two
107+
* forms on one specific class of input.
108+
*
109+
* @param s The UTF-8 text.
110+
* @param i Index of the first byte of the sequence; must be `< s.size()`.
111+
* @param codePoint Receives the decoded scalar, or `U+FFFD` for an ill-formed sequence.
112+
* @param length Receives the number of bytes consumed — always `1` for an ill-formed
113+
* sequence, so a caller resumes one byte later and cannot loop forever.
114+
*/
115+
inline void DecodeUtf8Scalar(const std::string& s, std::size_t i,
116+
std::uint32_t& codePoint, std::size_t& length) {
117+
if (!TryDecodeUtf8Scalar(s, i, codePoint, length)) {
118+
codePoint = 0xFFFD;
119+
length = 1;
120+
}
85121
}
86122

87123
/**

modules/globalization/src/System/Globalization/IdnMapping.cpp

Lines changed: 19 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#include "System/Globalization/IdnMapping.hpp"
5+
#include "System/detail/Utf8Scalar.hpp"
56
#include <algorithm>
67
#include <stdexcept>
78
#include <string>
@@ -14,59 +15,30 @@ namespace System::Globalization {
1415
// UTF-8 helpers
1516
// ---------------------------------------------------------------------------
1617

17-
namespace {
18-
// A continuation byte must match the 10xxxxxx bit pattern (RFC 3629).
19-
bool isContinuationByte(unsigned char c) { return (c & 0xC0) == 0x80; }
20-
}
21-
2218
std::u32string IdnMapping::utf8ToCodePoints(const std::string& s) {
2319
std::u32string out;
2420
out.reserve(s.size());
21+
// Each branch here previously trusted the continuation bytes' low 6 bits without checking
22+
// that their top 2 bits are 10xxxxxx, and never rejected overlong encodings, surrogate code
23+
// points, or out-of-range results -- so malformed UTF-8 was silently misinterpreted as some
24+
// other, unrelated code point. Confirmed with a standalone repro: "\xC2\x41" (a valid
25+
// 2-byte lead byte followed by 'A') produced a garbage Punycode result with no exception.
26+
// That matters here specifically, since domain names routinely originate from untrusted
27+
// input.
28+
//
29+
// Ticket #2354 (2026-08-18) found this copy, which the ticket itself did not name. It is a
30+
// THIRD contract over the same rule -- Rune reports, an Encoding substitutes, and this one
31+
// throws -- and all three are now spellings of System/detail/Utf8Scalar.hpp's one decode.
32+
// The two extra rejections this copy spelled out by hand are subsumed exactly: a 2-byte
33+
// lead below 0xC2 is an overlong encoding, and a 4-byte lead above 0xF4 is above U+10FFFF.
2534
size_t i = 0;
2635
while (i < s.size()) {
27-
unsigned char c = static_cast<unsigned char>(s[i]);
28-
char32_t cp;
29-
// Each branch below previously trusted the continuation bytes' low 6 bits without
30-
// checking their top 2 bits are actually 10xxxxxx, and never rejected overlong
31-
// encodings, surrogate code points, or out-of-Unicode-range results -- so malformed
32-
// UTF-8 (e.g. a valid lead byte followed by an ordinary ASCII byte instead of a real
33-
// continuation byte) was silently misinterpreted as some other, unrelated code point
34-
// instead of being rejected. Confirmed via a standalone repro before this fix: "\xC2\x41"
35-
// (a valid 2-byte lead byte followed by 'A', not a continuation byte) produced a
36-
// garbage Punycode result with no exception at all. This matters for IdnMapping
37-
// specifically since domain names routinely originate from untrusted input.
38-
if (c < 0x80) {
39-
cp = c; ++i;
40-
} else if ((c & 0xE0) == 0xC0) {
41-
if (c < 0xC2 || i + 1 >= s.size() || !isContinuationByte(static_cast<unsigned char>(s[i+1])))
42-
throw System::ArgumentException("IdnMapping: invalid UTF-8 sequence.");
43-
cp = (char32_t(c & 0x1F) << 6) | (static_cast<unsigned char>(s[i+1]) & 0x3F);
44-
i += 2;
45-
} else if ((c & 0xF0) == 0xE0) {
46-
if (i + 2 >= s.size() ||
47-
!isContinuationByte(static_cast<unsigned char>(s[i+1])) ||
48-
!isContinuationByte(static_cast<unsigned char>(s[i+2])))
49-
throw System::ArgumentException("IdnMapping: invalid UTF-8 sequence.");
50-
cp = (char32_t(c & 0x0F) << 12) | ((static_cast<unsigned char>(s[i+1]) & 0x3F) << 6)
51-
| (static_cast<unsigned char>(s[i+2]) & 0x3F);
52-
if (cp < 0x800 || (cp >= 0xD800 && cp <= 0xDFFF))
53-
throw System::ArgumentException("IdnMapping: invalid UTF-8 sequence.");
54-
i += 3;
55-
} else if ((c & 0xF8) == 0xF0) {
56-
if (c > 0xF4 || i + 3 >= s.size() ||
57-
!isContinuationByte(static_cast<unsigned char>(s[i+1])) ||
58-
!isContinuationByte(static_cast<unsigned char>(s[i+2])) ||
59-
!isContinuationByte(static_cast<unsigned char>(s[i+3])))
60-
throw System::ArgumentException("IdnMapping: invalid UTF-8 sequence.");
61-
cp = (char32_t(c & 0x07) << 18) | ((static_cast<unsigned char>(s[i+1]) & 0x3F) << 12)
62-
| ((static_cast<unsigned char>(s[i+2]) & 0x3F) << 6) | (static_cast<unsigned char>(s[i+3]) & 0x3F);
63-
if (cp < 0x10000 || cp > 0x10FFFF)
64-
throw System::ArgumentException("IdnMapping: invalid UTF-8 sequence.");
65-
i += 4;
66-
} else {
36+
std::uint32_t cp = 0;
37+
std::size_t len = 0;
38+
if (!System::detail::TryDecodeUtf8Scalar(s, i, cp, len))
6739
throw System::ArgumentException("IdnMapping: invalid UTF-8 sequence.");
68-
}
69-
out.push_back(cp);
40+
out.push_back(static_cast<char32_t>(cp));
41+
i += len;
7042
}
7143
return out;
7244
}

0 commit comments

Comments
 (0)