From 6cf1b9e8c732f42abc3335f5b21cc2cefc6aeaa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yifan=20Zhu=F0=9F=A6=85?= Date: Mon, 12 Oct 2026 16:05:20 +0800 Subject: [PATCH 1/3] Add IPv6 scope ID handling in Uri and related tests --- .../FunctionalTests/SocketsHttpHandlerTest.cs | 29 +++++ .../System.Private.Uri/src/System/Uri.cs | 33 ++++-- .../FunctionalTests/UriCreationOptionsTest.cs | 5 + .../tests/FunctionalTests/UriMethodsTests.cs | 30 ++++++ .../tests/FunctionalTests/UriParserTest.cs | 100 ++++++++++++++++++ 5 files changed, 188 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index fb66bb6c6f6955..0b07a10418f5af 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -817,6 +817,35 @@ public sealed class SocketsHttpHandler_HttpClientHandler_Proxy_Test : HttpClient { public SocketsHttpHandler_HttpClientHandler_Proxy_Test(ITestOutputHelper output) : base(output) { } + [Theory] + [InlineData("http")] + [InlineData("socks5")] + public async Task Proxy_IPv6ScopeChange_ConnectsToSelectedProxy(string proxyScheme) + { + var proxy = new WebProxy(); + using var handler = new SocketsHttpHandler { Proxy = proxy }; + DnsEndPoint endPoint = null; + handler.ConnectCallback = (context, _) => + { + endPoint = context.DnsEndPoint; + throw new InvalidOperationException(nameof(Proxy_IPv6ScopeChange_ConnectsToSelectedProxy)); + }; + using var client = new HttpClient(handler); + + for (int scope = 1; scope <= 2; scope++) + { + var proxyUri = new Uri($"{proxyScheme}://[fe80::1%{scope}]:8080/"); + proxy.Address = proxyUri; + endPoint = null; + + HttpRequestException exception = await Assert.ThrowsAsync(() => client.GetAsync("http://example.test/")); + Assert.Equal(nameof(Proxy_IPv6ScopeChange_ConnectsToSelectedProxy), Assert.IsType(exception.InnerException).Message); + Assert.NotNull(endPoint); + Assert.Equal(IPAddress.Parse(proxyUri.IdnHost), IPAddress.Parse(endPoint.Host)); + Assert.Equal(proxyUri.Port, endPoint.Port); + } + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/libraries/System.Private.Uri/src/System/Uri.cs b/src/libraries/System.Private.Uri/src/System/Uri.cs index f1ca81fdecd185..5dafab06ef854d 100644 --- a/src/libraries/System.Private.Uri/src/System/Uri.cs +++ b/src/libraries/System.Private.Uri/src/System/Uri.cs @@ -1550,14 +1550,16 @@ public override int GetHashCode() string remoteUrl = info.RemoteUrl ??= GetParts(components, UriFormat.SafeUnescaped); - if (IsFile) - { - return StringComparer.OrdinalIgnoreCase.GetHashCode(remoteUrl); - } - else - { - return remoteUrl.GetHashCode(); - } + int hashCode = IsFile ? + StringComparer.OrdinalIgnoreCase.GetHashCode(remoteUrl) : + remoteUrl.GetHashCode(); + + // Custom parsers define identity through HttpRequestUrl and may return it without + // initializing the host. ScopeId can remain null until Host or IdnHost is accessed, + // so including it could change the hash after those properties populate the cache. + return HostType == Flags.IPv6HostType && _syntax.IsSimple && info.ScopeId is string scopeId ? + HashCode.Combine(hashCode, scopeId) : + hashCode; } } @@ -1760,7 +1762,8 @@ public bool Equals([NotNullWhen(true)] Uri? other) if (NotAny(Flags.AllUriInfoSet) || other.NotAny(Flags.AllUriInfoSet)) { // Try raw compare for _strings as the last chance to keep the working set small - if (string.Equals(_string, other._string, IsUncOrDosPath ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + // Simple IPv6 parsers must keep scope IDs case-sensitive, even for file URIs. + if (string.Equals(_string, other._string, IsUncOrDosPath && (HostType != Flags.IPv6HostType || !_syntax.IsSimple) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) { return true; } @@ -1850,6 +1853,18 @@ public bool Equals([NotNullWhen(true)] Uri? other) string selfUrl = selfInfo.RemoteUrl ??= GetParts(components, UriFormat.SafeUnescaped); string otherUrl = otherInfo.RemoteUrl ??= other.GetParts(components, UriFormat.SafeUnescaped); + // Scope IDs are omitted from HttpRequestUrl and must be compared separately. + if (HostType == Flags.IPv6HostType || other.HostType == Flags.IPv6HostType) + { + // Custom parsers define equality through HttpRequestUrl. + string? selfScopeId = HostType == Flags.IPv6HostType && _syntax.IsSimple ? selfInfo.ScopeId : null; + string? otherScopeId = other.HostType == Flags.IPv6HostType && other._syntax.IsSimple ? otherInfo.ScopeId : null; + if (selfScopeId != otherScopeId) + { + return false; + } + } + // if IsUncOrDosPath is true then we ignore case in the path comparison return string.Equals(selfUrl, otherUrl, IsUncOrDosPath ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } diff --git a/src/libraries/System.Private.Uri/tests/FunctionalTests/UriCreationOptionsTest.cs b/src/libraries/System.Private.Uri/tests/FunctionalTests/UriCreationOptionsTest.cs index ae35839af8428e..2a17e4bf0f2d5f 100644 --- a/src/libraries/System.Private.Uri/tests/FunctionalTests/UriCreationOptionsTest.cs +++ b/src/libraries/System.Private.Uri/tests/FunctionalTests/UriCreationOptionsTest.cs @@ -170,6 +170,11 @@ public void DisableCanonicalization_OnlyEqualToUrisWithMatchingFlag() var absoluteRawSameAuthority = new Uri(AbsoluteUri + ":80" + Path, new UriCreationOptions { DangerousDisablePathAndQueryCanonicalization = true }); Equal(absoluteRaw, absoluteRawSameAuthority); + var rawOptions = new UriCreationOptions { DangerousDisablePathAndQueryCanonicalization = true }; + var scoped = new Uri("http://[fe80::1%1]/foo", rawOptions); + NotEqual(scoped, new Uri("http://[fe80::1%2]/foo", rawOptions)); + Equal(scoped, new Uri("http://[FE80:0:0:0:0:0:0:1%1]:80/foo", rawOptions)); + static void Equal(Uri left, Uri right) { Assert.True(left.Equals(right)); diff --git a/src/libraries/System.Private.Uri/tests/FunctionalTests/UriMethodsTests.cs b/src/libraries/System.Private.Uri/tests/FunctionalTests/UriMethodsTests.cs index 5b5b7fbbaf03ef..738b24908eddfd 100644 --- a/src/libraries/System.Private.Uri/tests/FunctionalTests/UriMethodsTests.cs +++ b/src/libraries/System.Private.Uri/tests/FunctionalTests/UriMethodsTests.cs @@ -423,6 +423,36 @@ public void EqualsTest(Uri uri1, object obj, bool expected) } } + [Theory] + [InlineData("http://[fe80::1%1]/", "http://[fe80::1%2]/", false)] + [InlineData("http://[fe80::1]/", "http://[fe80::1%0]/", false)] + [InlineData("http://[fe80::1%1]/", "HTTP://[FE80:0:0:0:0:0:0:1%1]:80/", true)] + [InlineData("file://[fe80::1%eth0]/path", "file://[fe80::1%ETH0]/path", false)] + [InlineData("file://[fe80::1%eth0]/PATH", "file://[FE80::1%eth0]/path", true)] + [InlineData("mailto:user@[fe80::1%1]", "mailto:user@[fe80::1%2]", false)] + public void Equals_IPv6ScopeId(string firstUriString, string secondUriString, bool expected) + { + foreach (bool hashFirst in new[] { false, true }) + { + var firstUri = new Uri(firstUriString); + var secondUri = new Uri(secondUriString); + + if (hashFirst) + { + _ = firstUri.GetHashCode(); + _ = secondUri.GetHashCode(); + } + + Assert.Equal(expected, firstUri.Equals(secondUri)); + Assert.Equal(expected, secondUri.Equals(firstUri)); + Assert.Equal(expected ? 1 : 2, new HashSet { firstUri, secondUri }.Count); + if (expected) + { + Assert.Equal(firstUri.GetHashCode(), secondUri.GetHashCode()); + } + } + } + [Theory] [InlineData("", "")] [InlineData("Hello", "Hello")] diff --git a/src/libraries/System.Private.Uri/tests/FunctionalTests/UriParserTest.cs b/src/libraries/System.Private.Uri/tests/FunctionalTests/UriParserTest.cs index b10d5387d6fc8b..497b07659a9005 100644 --- a/src/libraries/System.Private.Uri/tests/FunctionalTests/UriParserTest.cs +++ b/src/libraries/System.Private.Uri/tests/FunctionalTests/UriParserTest.cs @@ -26,6 +26,7 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // +using System.Collections.Generic; using Xunit; namespace System.PrivateUri.Tests @@ -606,6 +607,105 @@ public static void CustomParserCanRecoverOnInvalidUri(UriKind uriKind, bool reco } } + [Theory] + [InlineData("[fe80::1%1]", "[fe80::1%2]", false, true)] + [InlineData("[fe80::1%1]", "[fe80::1]", true, true)] + [InlineData("[fe80::1%1]", "[fe80::1%1]", true, false)] + [InlineData("first.test", "[fe80::1]", true, true)] + public static void CustomParser_EqualsAndGetHashCode_UseRequestUrl(string firstHost, string secondHost, bool useHttpScheme, bool expected) + { + bool rewriteHost = firstHost == "first.test"; + string scheme = $"custom-ipv6-scope-{useHttpScheme}-{rewriteHost}-{expected}"; + var parser = new CustomParser_FormatsRequestUrlWithoutParsingHost(rewriteHost ? "[fe80::1%1]" : null); + UriParser.Register(parser, scheme, 80); + + var firstUri = new Uri($"{scheme}://{firstHost}/"); + var secondUri = new Uri($"{(useHttpScheme ? Uri.UriSchemeHttp : scheme)}://{secondHost}/"); + Assert.Equal(expected, firstUri.Equals(secondUri)); + Assert.Equal(expected, secondUri.Equals(firstUri)); + + int firstHashCode = firstUri.GetHashCode(); + var uris = new HashSet { firstUri, secondUri }; + Assert.Equal(firstUri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.SafeUnescaped).GetHashCode(), firstHashCode); + Assert.Equal(0, parser.HostRequests); + Assert.Equal(0, parser.PortRequests); + + Assert.Equal("fe80::1%1", firstUri.IdnHost); + Assert.Equal(firstHashCode, firstUri.GetHashCode()); + Assert.Equal(expected, firstUri.Equals(secondUri)); + Assert.Equal(expected, secondUri.Equals(firstUri)); + Assert.Equal(expected ? 1 : 2, uris.Count); + Assert.Contains(firstUri, uris); + if (expected) + { + Assert.Equal(firstHashCode, secondUri.GetHashCode()); + } + } + + [Theory] + [InlineData("2", false)] + [InlineData("1", true)] + public static void RegisteredSimpleParser_EqualsAndGetHashCode_IncludeIPv6ScopeId(string secondScopeId, bool expected) + { + string scheme = $"simple-ipv6-scope-{secondScopeId}"; + UriParser.Register(new GenericUriParser(GenericUriParserOptions.Default), scheme, 80); + + var firstUri = new Uri($"{scheme}://[fe80::1%1]/"); + var secondUri = new Uri($"{scheme}://[FE80:0:0:0:0:0:0:1%{secondScopeId}]/"); + Assert.Equal(expected, firstUri.Equals(secondUri)); + Assert.Equal(expected, secondUri.Equals(firstUri)); + Assert.Equal(expected ? 1 : 2, new HashSet { firstUri, secondUri }.Count); + if (expected) + { + Assert.Equal(firstUri.GetHashCode(), secondUri.GetHashCode()); + } + } + + private sealed class CustomParser_FormatsRequestUrlWithoutParsingHost(string? hostOverride) : GenericUriParser(GenericUriParserOptions.Default) + { + public int HostRequests; + public int PortRequests; + + protected override void InitializeAndValidate(Uri uri, out UriFormatException? parsingError) + { + if (hostOverride is not null) + { + parsingError = null; + } + else + { + base.InitializeAndValidate(uri, out parsingError); + } + } + + protected override string GetComponents(Uri uri, UriComponents components, UriFormat format) + { + if (components == UriComponents.HttpRequestUrl) + { + return "http://[fe80::1]/"; + } + + if (components == UriComponents.Host) + { + HostRequests++; + if (hostOverride is not null) + { + return hostOverride; + } + } + else if (components == UriComponents.StrongPort) + { + PortRequests++; + if (hostOverride is not null) + { + return "80"; + } + } + + return base.GetComponents(uri, components, format); + } + } + private sealed class CustomParser_RecoversOnInvalidUri(bool recover) : GenericUriParser(GenericUriParserOptions.Default) { protected override void InitializeAndValidate(Uri uri, out UriFormatException? parsingError) From 220a7202900a77731b67ba3a92da7192c0490473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yifan=20Zhu=F0=9F=A6=85?= Date: Mon, 12 Oct 2026 16:18:35 +0800 Subject: [PATCH 2/3] Add support for HTTPS in Proxy_IPv6ScopeChange test --- .../tests/FunctionalTests/SocketsHttpHandlerTest.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index 0b07a10418f5af..b7e9ff1d1a8335 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -819,6 +819,7 @@ public SocketsHttpHandler_HttpClientHandler_Proxy_Test(ITestOutputHelper output) [Theory] [InlineData("http")] + [InlineData("https")] [InlineData("socks5")] public async Task Proxy_IPv6ScopeChange_ConnectsToSelectedProxy(string proxyScheme) { From 51482444d0b9be8b8a0714f46b293752fb99c04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yifan=20Zhu=F0=9F=A6=85?= Date: Mon, 12 Oct 2026 20:11:05 +0800 Subject: [PATCH 3/3] update to resolve comments --- .../System.Private.Uri/src/System/Uri.cs | 13 +++++-- .../tests/FunctionalTests/UriMethodsTests.cs | 37 ++++--------------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/src/libraries/System.Private.Uri/src/System/Uri.cs b/src/libraries/System.Private.Uri/src/System/Uri.cs index 5dafab06ef854d..c410509aa8b178 100644 --- a/src/libraries/System.Private.Uri/src/System/Uri.cs +++ b/src/libraries/System.Private.Uri/src/System/Uri.cs @@ -1762,8 +1762,8 @@ public bool Equals([NotNullWhen(true)] Uri? other) if (NotAny(Flags.AllUriInfoSet) || other.NotAny(Flags.AllUriInfoSet)) { // Try raw compare for _strings as the last chance to keep the working set small - // Simple IPv6 parsers must keep scope IDs case-sensitive, even for file URIs. - if (string.Equals(_string, other._string, IsUncOrDosPath && (HostType != Flags.IPv6HostType || !_syntax.IsSimple) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + // Keep IPv6 scope IDs case-sensitive even when UNC/DOS paths ignore case. + if (string.Equals(_string, other._string, IsUncOrDosPath && HostType != Flags.IPv6HostType ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) { return true; } @@ -1853,6 +1853,12 @@ public bool Equals([NotNullWhen(true)] Uri? other) string selfUrl = selfInfo.RemoteUrl ??= GetParts(components, UriFormat.SafeUnescaped); string otherUrl = otherInfo.RemoteUrl ??= other.GetParts(components, UriFormat.SafeUnescaped); + // if IsUncOrDosPath is true then we ignore case in the path comparison + if (!string.Equals(selfUrl, otherUrl, IsUncOrDosPath ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + { + return false; + } + // Scope IDs are omitted from HttpRequestUrl and must be compared separately. if (HostType == Flags.IPv6HostType || other.HostType == Flags.IPv6HostType) { @@ -1865,8 +1871,7 @@ public bool Equals([NotNullWhen(true)] Uri? other) } } - // if IsUncOrDosPath is true then we ignore case in the path comparison - return string.Equals(selfUrl, otherUrl, IsUncOrDosPath ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + return true; } public Uri MakeRelativeUri(Uri uri) diff --git a/src/libraries/System.Private.Uri/tests/FunctionalTests/UriMethodsTests.cs b/src/libraries/System.Private.Uri/tests/FunctionalTests/UriMethodsTests.cs index 738b24908eddfd..fa82fe5f96e429 100644 --- a/src/libraries/System.Private.Uri/tests/FunctionalTests/UriMethodsTests.cs +++ b/src/libraries/System.Private.Uri/tests/FunctionalTests/UriMethodsTests.cs @@ -337,6 +337,11 @@ public static IEnumerable Equals_TestData() yield return new object[] { new Uri("http://www.domain.com:100/path?name#fragment"), new Uri("http://www.domain.com:800/path?name#fragment"), false }; // Different port yield return new object[] { new Uri("http://www.domain.com:100/path?name#fragment"), new Uri("http://www.domain.com:80/path?name#fragment"), false }; // Different port + yield return new object[] { new Uri("http://[fe80::1%1]/"), new Uri("http://[fe80::1%2]/"), false }; + yield return new object[] { new Uri("http://[fe80::1]/"), new Uri("http://[fe80::1%0]/"), false }; + yield return new object[] { new Uri("http://[fe80::1%1]/"), new Uri("HTTP://[FE80:0:0:0:0:0:0:1%1]:80/"), true }; + yield return new object[] { new Uri("mailto:user@[fe80::1%1]"), new Uri("mailto:user@[fe80::1%2]"), false }; + // File paths yield return new object[] { new Uri("file://C:/path1/path2/file"), new Uri("file://C:/path1/path2/file"), true }; yield return new object[] { new Uri("file://C:/path1/path2/file"), new Uri("file://C:/path1/Path2/File"), true }; @@ -346,6 +351,8 @@ public static IEnumerable Equals_TestData() yield return new object[] { new Uri("file://C:/path1/path2/file"), new Uri("file://C:/path1/path2/file!"), false }; yield return new object[] { new Uri("file://C:/path1/path2/file"), new Uri("http://domain.com"), false }; yield return new object[] { new Uri("file://C:/path1/path2/file"), new Uri(@"\\server\path1\path2\file"), false }; + yield return new object[] { new Uri("file://[fe80::1%eth0]/path"), new Uri("file://[fe80::1%ETH0]/path"), false }; + yield return new object[] { new Uri("file://[fe80::1%eth0]/PATH"), new Uri("file://[FE80::1%eth0]/path"), true }; // UNC paths that normalize to the same value as a regular file: URI yield return new object[] { new Uri("\\\\\u202a"), new Uri("file:///"), true }; @@ -423,36 +430,6 @@ public void EqualsTest(Uri uri1, object obj, bool expected) } } - [Theory] - [InlineData("http://[fe80::1%1]/", "http://[fe80::1%2]/", false)] - [InlineData("http://[fe80::1]/", "http://[fe80::1%0]/", false)] - [InlineData("http://[fe80::1%1]/", "HTTP://[FE80:0:0:0:0:0:0:1%1]:80/", true)] - [InlineData("file://[fe80::1%eth0]/path", "file://[fe80::1%ETH0]/path", false)] - [InlineData("file://[fe80::1%eth0]/PATH", "file://[FE80::1%eth0]/path", true)] - [InlineData("mailto:user@[fe80::1%1]", "mailto:user@[fe80::1%2]", false)] - public void Equals_IPv6ScopeId(string firstUriString, string secondUriString, bool expected) - { - foreach (bool hashFirst in new[] { false, true }) - { - var firstUri = new Uri(firstUriString); - var secondUri = new Uri(secondUriString); - - if (hashFirst) - { - _ = firstUri.GetHashCode(); - _ = secondUri.GetHashCode(); - } - - Assert.Equal(expected, firstUri.Equals(secondUri)); - Assert.Equal(expected, secondUri.Equals(firstUri)); - Assert.Equal(expected ? 1 : 2, new HashSet { firstUri, secondUri }.Count); - if (expected) - { - Assert.Equal(firstUri.GetHashCode(), secondUri.GetHashCode()); - } - } - } - [Theory] [InlineData("", "")] [InlineData("Hello", "Hello")]