diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index eadef3bea7176c..abe146edc0f0ce 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -888,6 +888,36 @@ public sealed class SocketsHttpHandler_HttpClientHandler_Proxy_Test : HttpClient { public SocketsHttpHandler_HttpClientHandler_Proxy_Test(ITestOutputHelper output) : base(output) { } + [Theory] + [InlineData("http")] + [InlineData("https")] + [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..c410509aa8b178 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)) + // 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; } @@ -1851,7 +1854,24 @@ public bool Equals([NotNullWhen(true)] Uri? other) string otherUrl = otherInfo.RemoteUrl ??= other.GetParts(components, UriFormat.SafeUnescaped); // if IsUncOrDosPath is true then we ignore case in the path comparison - return string.Equals(selfUrl, otherUrl, IsUncOrDosPath ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + 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) + { + // 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; + } + } + + return true; } public Uri MakeRelativeUri(Uri uri) 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..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 }; 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)