Skip to content

Add net9 Uri span escaping APIs - #595

Merged
SimonCropp merged 1 commit into
mainfrom
uri-escaping
Sep 10, 2026
Merged

Add net9 Uri span escaping APIs#595
SimonCropp merged 1 commit into
mainfrom
uri-escaping

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

Four members, all net9: Uri.EscapeDataString(ReadOnlySpan<char>), Uri.UnescapeDataString(ReadOnlySpan<char>), Uri.TryEscapeDataString and Uri.TryUnescapeDataString.

Delegating to the string overloads is the correct choice here, not just the easy one

Uri.EscapeDataString does not escape the same set of characters on .NET Framework as it does on .NET — !*'() is the well known difference. Routing the span overloads through the string overload on the same target means the two agree on every framework, which is what a caller mixing them would expect. Reimplementing RFC 3986 escaping in the polyfill would have made the span overloads disagree with the string overloads on .NET Framework, which is worse.

The cost is an intermediate string, which is precisely what the span overloads exist to avoid. That is //Note:d on all four, and falls under the "deliverability over perf" rule.

Semantics verified against net11

  • A failed Try reports charsWritten of 0. The BCL may leave the destination partially written on failure; the polyfill leaves it untouched, which no caller can depend on either way since charsWritten is 0.
  • An exactly sized destination succeeds.
  • Unescaping only ever shrinks, so a destination that overlaps the source works. There is a test for it, since materialising through a string is what makes the polyfill safe here.
  • An empty span yields an empty string.

A BCL bug on net9.0 and net10.0

Worth flagging separately. Uri.TryUnescapeDataString throws ArgumentOutOfRangeException instead of returning false whenever the destination is smaller than the literal text preceding the first escape sequence:

input destination net9.0 / net10.0 net11.0 and the polyfill
abc%20def 0, 1, 2 ArgumentOutOfRangeException false
abc%20def 3–6 false false
abc%20def 7+ true, 7 written true, 7 written

The boundary is exactly destination.Length < input.IndexOf('%'); inputs with no % are unaffected, and TryEscapeDataString does not have the problem. It is fixed in net11, and the polyfill behaves the way net11 does, so net8.0 and below return false.

This turned up because the sizing test — which runs the full destination-size matrix on every target — failed on net9.0 and net10.0 while passing on net11.0 and on the polyfill. Rather than trimming the test to hide it, the affected region is asserted explicitly by TryUnescapeDataString_DestinationSmallerThanLiteralPrefix, which expects the throw on those two frameworks and false everywhere else.

Result

API count 1105 → 1109.

Solution clean in Release, Consume clean across all 22 TFMs, tests green on net11.0 (1697), net10.0 (1697), net9.0 (1697), net8.0 (1694), net462 (1659), plus PublicTests, EmbeddedTests, UnsafeTests, NoRefsTests and NoExtrasTests. net7.0 and net5.0 compile but cannot be run on this machine, as those runtimes are not installed.

EscapeDataString, UnescapeDataString, TryEscapeDataString and TryUnescapeDataString
over spans. Each routes through the string overload on the same target, so the escaped
set matches whatever that framework's Uri.EscapeDataString produces rather than
backporting net11 behaviour onto .NET Framework. That costs an intermediate string,
which is the whole point of the span overloads, so it is noted on all four.

Verified against net11: a failed Try reports zero chars written, an exactly sized
destination succeeds, and an overlapping destination works for unescaping, which only
ever shrinks.

While testing, found that net9.0 and net10.0 throw ArgumentOutOfRangeException from
Uri.TryUnescapeDataString instead of returning false whenever the destination is
smaller than the literal text preceding the first escape sequence. Fixed in net11.
The polyfill matches net11, so net8.0 and below return false. Covered by a test that
asserts the throw on the two affected frameworks.

API count 1105 -> 1109.
@SimonCropp SimonCropp added this to the 11.3.0 milestone Sep 10, 2026
@SimonCropp
SimonCropp merged commit 9a731ed into main Sep 10, 2026
4 of 6 checks passed
@SimonCropp
SimonCropp deleted the uri-escaping branch September 10, 2026 05:22
SimonCropp added a commit that referenced this pull request Sep 10, 2026
…framework (#596)

The tests added in #595 asserted that Uri.TryUnescapeDataString throws on net9.0 and
net10.0, which bets that neither is ever serviced. dotnet/runtime#128610 is a written
backport of the fix to release/10.0 that was deferred rather than rejected, so that bet
could be lost, and net10.0 is in support until 2028.

The behaviour is now probed once instead. net9.0 and net10.0 accept either outcome, and
the assertion that it must not throw is kept for net11.0 and for every target where the
polyfill is active.
This was referenced Sep 10, 2026
This was referenced Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant