Skip to content

Commit cd45b1a

Browse files
committed
docs(uri): #2003 resolves with NO change -- the approval it asked for points away from .NET
#2003 asked for approval to "make System::Uri reject an embedded NUL anywhere in the URI string with UriFormatException, ACCEPTING THAT THE .NET BEHAVIOUR IT MATCHES COULD NOT BE RE-MEASURED IN THIS ENVIRONMENT". /rv/tmp/runtime is present, so it was re-measured, and it says the opposite. The approval must not be sought. No production statement changed. - UriHelper.s_notSafeForUnescapeChars lists U+0000 through U+001F explicitly, so a NUL is never unescaped back from %00 -- it is carried, not refused. - Uri.TryCreateThis (UriExt.cs:30-70) has NO whole-string character precheck at all; the only rejection paths are the scheme, the host and the port. - A control character in the path, query or fragment is percent-ESCAPED during canonicalisation. The data survives; only its rendering differs. THIS PORT ALREADY MATCHES. Rejection is confined to scheme, host and port in both runtimes. The one difference -- raw rather than %00 -- is the already-declared no-percent-encoding boundary (plan 15.1), not a second divergence. TWO PREMISE CORRECTIONS, both measured: 1. The title says a NUL crosses "into every component". It does not any more: #2359 gave the host .NET's DNS character set four days after #2003 was written, and a NUL is outside it exactly as a space is. 2. "No truncation" was asserted of the STRING and never of the component ACCESSORS. It holds for those too, and every row now asserts a LENGTH -- a first probe of this ticket printed the accessors with %s and appeared to show truncation that was not there, which is exactly the shape a byte count catches and a rendered string does not. THE DECISION IS ENFORCED RATHER THAN WRITTEN DOWN. Two changes were built and run before being reverted: truncating the input at the first NUL is caught by 4 tests, and THE REPAIR THE TICKET PROPOSED -- reject anywhere with UriFormatException -- is caught by 3. If that approval were ever granted and implemented, the suite fails immediately and names this decision. Gate: 17,386 run, 17,386 passed, 0 failed, 0 skipped across 38 executables (+1, in SharpRuntimeTests_Uri, 278 -> 279). Built in build/ with --parallel 2. No behaviour changed, so nothing to migrate downstream. docs/Migration-UriEmbeddedNul.md
1 parent af0ddea commit cd45b1a

4 files changed

Lines changed: 135 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/Migration-UriEmbeddedNul.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Decision — `System::Uri` keeps an embedded NUL, and the proposed repair was backwards (ticket #2003)
5+
6+
*2026-08-19.* **No production statement changed.** This is a decision and its evidence, in the
7+
shape of #2015 and #2324: the ticket asked for approval to make `Uri` reject an embedded NUL, and
8+
the reference — which the ticket could not read — says .NET does not reject it.
9+
10+
---
11+
12+
## 1. The approval must not be sought
13+
14+
#2003's recorded gate is a sentence to be approved:
15+
16+
> *"Make `System::Uri` reject an embedded NUL anywhere in the URI string with
17+
> `UriFormatException`, **accepting that the .NET behaviour it matches could not be re-measured
18+
> in this environment**."*
19+
20+
`/rv/tmp/runtime` is present, so it was re-measured, and it points the other way:
21+
22+
* **`UriHelper.s_notSafeForUnescapeChars` lists U+0000–U+001F explicitly.** A NUL is never
23+
unescaped back from `%00`; it is carried, not refused.
24+
* **`Uri.TryCreateThis` (`UriExt.cs:30-70`) has no whole-string character precheck at all.** The
25+
only rejection paths are the scheme, the host and the port.
26+
* A control character in the path, query or fragment is percent-**escaped** during
27+
canonicalisation. The data survives; only its rendering differs.
28+
29+
So the proposed repair points **away** from the reference. The ticket closes with no change.
30+
31+
## 2. This port already matches, and the one difference is an existing declared boundary
32+
33+
| Position | .NET | this port |
34+
|---|---|---|
35+
| host | rejected — `DomainNameHelper.IsValid` excludes it | rejected (#2359) |
36+
| port | rejected — not a digit | rejected |
37+
| scheme | not a scheme char → the reference is **relative** | relative, whole text becomes the path |
38+
| path, query, fragment, userinfo | carried, as `%00` | carried, **raw** |
39+
40+
The last row is the only difference, and it is the already-declared **no-percent-encoding**
41+
boundary (`docs/SystemUriNamespaceReviewPlan.md` §15.1), not a second divergence. Both runtimes
42+
preserve the byte; they render it differently.
43+
44+
## 3. Two premise corrections, both measured
45+
46+
1. **The ticket's title says a NUL crosses "into every component".** It does not any more.
47+
**#2359** gave the host .NET's DNS character set, and a NUL is outside it exactly as a space
48+
is — so the host has rejected it since that ticket landed, four days after #2003 was written.
49+
2. **"No truncation" was asserted of the string and never of the component accessors.** It holds
50+
for those too, and every row now states a **length**, because a truncating parser and a
51+
preserving one differ by a byte count that no `operator<<` would show — a first probe of this
52+
ticket printed the accessors with `%s` and appeared to show truncation that was not there.
53+
54+
## 4. Evidence: the decision is enforced, not merely written down
55+
56+
Two changes were **built and run** before being reverted, which is what makes this a measurement
57+
rather than an assertion:
58+
59+
| Change | Result |
60+
|---|---|
61+
| truncate the input at the first NUL | **caught by 4 tests** |
62+
| **the repair the ticket proposed** — reject anywhere with `UriFormatException` | **caught by 3 tests** |
63+
64+
The second is the one that matters: if the approval were ever granted and implemented, the suite
65+
fails immediately and names this decision. That is the signal these pins exist to produce.
66+
67+
`Decl2003_RejectionIsConfinedToTheThreePlacesDotNetRejects` states the three rejecting positions
68+
and seven carrying ones, each with an asserted length.
69+
70+
## 5. Downstream
71+
72+
No behaviour changed, so nothing to migrate.

modules/uri/tests/System/UriTests.cpp

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,16 +1371,35 @@ TEST(UriTests, RelativeComponents_UriKindRelativeSeesTheSameSplit) {
13711371
}
13721372

13731373
// ---------------------------------------------------------------------------
1374-
// Embedded NUL — ticket #2003, pinned rather than changed.
1374+
// Embedded NUL — ticket #2003 RESOLVED, and the resolution is that nothing changes.
13751375
//
1376-
// Measured across every component position: a NUL is carried through as ordinary data. There
1377-
// is NO prefix-only parse, NO silent truncation and no length loss — Uri("http://h/a\0b")
1378-
// keeps all 12 bytes, round-trips, and does NOT compare equal to Uri("http://h/a"). The port
1379-
// position already rejects it, because a NUL is not a digit. Whether .NET rejects a NUL
1380-
// outright could not be measured here (/rv/tmp/runtime/src/libraries/ is absent), so choosing
1381-
// rejection would be a narrowing on no surviving evidence — the line #1998 and #1963 sit on.
1382-
// The current, self-consistent behaviour is pinned so a later change is deliberate.
1383-
// See docs/SystemUriNamespaceReviewPlan.md §30.
1376+
// The deferral was correct and its gate is now discharged. #2003 asked for approval to
1377+
// "make System::Uri reject an embedded NUL anywhere in the URI string with
1378+
// UriFormatException, accepting that the .NET behaviour it matches could not be re-measured
1379+
// in this environment". With the reference present, that behaviour IS measurable, and it
1380+
// says the opposite: .NET does NOT reject.
1381+
//
1382+
// * `UriHelper.s_notSafeForUnescapeChars` lists U+0000 through U+001F explicitly, so a NUL
1383+
// is never unescaped back from `%00` — it is carried, not refused.
1384+
// * `Uri.TryCreateThis` (`UriExt.cs:30-70`) has no whole-string character precheck at all;
1385+
// the only rejection paths are the scheme, the host and the port.
1386+
// * A control character in the path, query or fragment is percent-ESCAPED during
1387+
// canonicalisation. The data survives; only its rendering differs.
1388+
//
1389+
// So the approval must NOT be sought: the proposed repair points away from the reference.
1390+
// This port preserves the same data RAW rather than escaped, which is the already-declared
1391+
// no-percent-encoding boundary (plan §15.1), not a second divergence.
1392+
//
1393+
// TWO PREMISE CORRECTIONS, both measured:
1394+
// 1. The ticket's title says a NUL crosses "into every component". It does not any more —
1395+
// #2359 gave the host .NET's DNS character set, and a NUL is outside it exactly as a
1396+
// space is. The host row below is that rejection, and it agrees with .NET's own
1397+
// `DomainNameHelper.IsValid`.
1398+
// 2. "No truncation" is true of the STRING and had never been asserted of the component
1399+
// ACCESSORS. It holds for those too, and each row states a length so a `%s`-shaped
1400+
// truncation cannot pass unnoticed.
1401+
//
1402+
// See docs/SystemUriNamespaceReviewPlan.md §30 and docs/Migration-UriEmbeddedNul.md.
13841403
// ---------------------------------------------------------------------------
13851404

13861405
TEST(UriTests, EmbeddedNul_IsCarriedThroughEveryComponentWithoutTruncation) {
@@ -1428,6 +1447,40 @@ TEST(UriTests, EmbeddedNul_InThePortPositionIsStillRejected) {
14281447
EXPECT_THROW(Uri(std::string("http://h:8\0 0/p", 15)), System::UriFormatException);
14291448
}
14301449

1450+
// #2003's resolution, stated as assertions rather than only as prose: the three places .NET
1451+
// rejects are the three places this port rejects, and everywhere else the byte survives.
1452+
TEST(UriTests, Decl2003_RejectionIsConfinedToTheThreePlacesDotNetRejects) {
1453+
// HOST -- DomainNameHelper.IsValid tests IndexOfAnyExcept over "-0-9A-Z_a-z.", and a NUL is
1454+
// outside it (#2359).
1455+
EXPECT_THROW((void)Uri(std::string("http://h\0st/p", 13)), System::UriFormatException);
1456+
// PORT -- not a digit.
1457+
EXPECT_THROW((void)Uri(std::string("http://h:8\0 0/p", 15)), System::UriFormatException);
1458+
// SCHEME -- a NUL is not a scheme character, so the reference is RELATIVE rather than
1459+
// rejected, and the whole text becomes the path. That is .NET's fallback too, and it is
1460+
// why "reject anywhere" would have been wrong here as well as in the path.
1461+
Uri notAScheme(std::string("ht\0tp://h/p", 11));
1462+
EXPECT_FALSE(notAScheme.getIsAbsoluteUriProperty());
1463+
EXPECT_EQ(notAScheme.getAbsolutePathProperty().size(), 11u);
1464+
1465+
// Everywhere else: carried, with the LENGTH asserted, because a truncating parser and a
1466+
// preserving one differ only in a byte count that `operator<<` would not show.
1467+
struct Row { std::string text; size_t expected; };
1468+
const Row rows[] = {
1469+
{std::string("http://h/a\0b", 12), 12},
1470+
{std::string("http://h/p?q\0r", 14), 14},
1471+
{std::string("http://h/p#f\0g", 14), 14},
1472+
{std::string("http://u\0r@h/p", 14), 14},
1473+
{std::string("http://h/p\0", 11), 11},
1474+
{std::string("a\0b", 3), 3},
1475+
{std::string("mailto:a\0b", 10), 10},
1476+
};
1477+
for (const auto& row : rows) {
1478+
Uri u(row.text);
1479+
EXPECT_EQ(u.ToString().size(), row.expected) << "input of " << row.text.size() << " bytes";
1480+
EXPECT_EQ(u.ToString(), row.text);
1481+
}
1482+
}
1483+
14311484
// ---------------------------------------------------------------------------
14321485
// Surrounding whitespace — ticket #2005 RESOLVED.
14331486
//

plan.sqlite3

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)