diff --git a/src/Http/Http.Extensions/src/UriHelper.cs b/src/Http/Http.Extensions/src/UriHelper.cs index 40c26bd1a1eb..dbcd8dd819f3 100644 --- a/src/Http/Http.Extensions/src/UriHelper.cs +++ b/src/Http/Http.Extensions/src/UriHelper.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Buffers; +using System.Runtime.CompilerServices; using System.Text; namespace Microsoft.AspNetCore.Http.Extensions @@ -15,6 +17,7 @@ public static class UriHelper private const char Hash = '#'; private const char QuestionMark = '?'; private static readonly string SchemeDelimiter = Uri.SchemeDelimiter; + private static readonly SpanAction InitializeAbsoluteUriStringSpanAction = new(InitializeAbsoluteUriString); /// /// Combines the given URI components into a string that is properly encoded for use in HTTP headers. @@ -58,24 +61,36 @@ public static string BuildAbsolute( throw new ArgumentNullException(nameof(scheme)); } - var combinedPath = (pathBase.HasValue || path.HasValue) ? (pathBase + path).ToString() : "/"; + var hostText = host.ToUriComponent(); + var pathBaseText = pathBase.ToUriComponent(); + var pathText = path.ToUriComponent(); + var queryText = query.ToUriComponent(); + var fragmentText = fragment.ToUriComponent(); - var encodedHost = host.ToString(); - var encodedQuery = query.ToString(); - var encodedFragment = fragment.ToString(); + // PERF: Calculate string length to allocate correct buffer size for string.Create. + var length = + scheme.Length + + Uri.SchemeDelimiter.Length + + hostText.Length + + pathBaseText.Length + + pathText.Length + + queryText.Length + + fragmentText.Length; - // PERF: Calculate string length to allocate correct buffer size for StringBuilder. - var length = scheme.Length + SchemeDelimiter.Length + encodedHost.Length - + combinedPath.Length + encodedQuery.Length + encodedFragment.Length; + if (string.IsNullOrEmpty(pathBaseText) && string.IsNullOrEmpty(pathText)) + { + pathText = "/"; + length++; + } + else if (pathBaseText.Length > 0 && pathBaseText[^1] == '/') + { + // If the path string has a trailing slash and the other string has a leading slash, we need + // to trim one of them. + // Just decrement the total lenght, for now. + length--; + } - return new StringBuilder(length) - .Append(scheme) - .Append(SchemeDelimiter) - .Append(encodedHost) - .Append(combinedPath) - .Append(encodedQuery) - .Append(encodedFragment) - .ToString(); + return string.Create(length, (scheme, hostText, pathBaseText, pathText, queryText, fragmentText), InitializeAbsoluteUriStringSpanAction); } /// @@ -214,5 +229,47 @@ public static string GetDisplayUrl(this HttpRequest request) .Append(queryString) .ToString(); } + + /// + /// Copies the specified to the specified starting at the specified . + /// + /// The buffer to copy text to. + /// The buffer start index. + /// The text to copy. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int CopyTextToBuffer(Span buffer, int index, ReadOnlySpan text) + { + text.CopyTo(buffer.Slice(index, text.Length)); + return index + text.Length; + } + + /// + /// Initializes the URI for . + /// + /// The URI 's buffer. + /// The URI parts. + private static void InitializeAbsoluteUriString(Span buffer, (string scheme, string host, string pathBase, string path, string query, string fragment) uriParts) + { + var index = 0; + + var pathBaseSpan = uriParts.pathBase.AsSpan(); + + if (uriParts.path.Length > 0 && pathBaseSpan.Length > 0 && pathBaseSpan[^1] == '/') + { + // If the path string has a trailing slash and the other string has a leading slash, we need + // to trim one of them. + // Trim the last slahs from pathBase. The total lenght was decremented before the call to string.Create. + pathBaseSpan = pathBaseSpan[..^1]; + } + + index = CopyTextToBuffer(buffer, index, uriParts.scheme.AsSpan()); + index = CopyTextToBuffer(buffer, index, Uri.SchemeDelimiter.AsSpan()); + index = CopyTextToBuffer(buffer, index, uriParts.host.AsSpan()); + index = CopyTextToBuffer(buffer, index, pathBaseSpan); + index = CopyTextToBuffer(buffer, index, uriParts.path.AsSpan()); + index = CopyTextToBuffer(buffer, index, uriParts.query.AsSpan()); + _ = CopyTextToBuffer(buffer, index, uriParts.fragment.AsSpan()); + } } } diff --git a/src/Http/Http.Extensions/test/UriHelperTests.cs b/src/Http/Http.Extensions/test/UriHelperTests.cs index ba604d576e79..368feb0fdec2 100644 --- a/src/Http/Http.Extensions/test/UriHelperTests.cs +++ b/src/Http/Http.Extensions/test/UriHelperTests.cs @@ -42,6 +42,46 @@ public void EncodeFullUrl() Assert.Equal("http://my.xn--host-cpd:80/un%3Fescaped/base/un%3Fescaped?name=val%23ue#my%20value", result); } + [Theory] + [InlineData("http", "example.com", "", "", "", "", "http://example.com/")] + [InlineData("https", "example.com", "", "", "", "", "https://example.com/")] + [InlineData("http", "example.com", "", "/foo/bar", "", "", "http://example.com/foo/bar")] + [InlineData("http", "example.com", "", "/foo/bar", "?baz=1", "", "http://example.com/foo/bar?baz=1")] + [InlineData("http", "example.com", "", "/foo", "", "#col=2", "http://example.com/foo#col=2")] + [InlineData("http", "example.com", "", "/foo", "?bar=1", "#col=2", "http://example.com/foo?bar=1#col=2")] + [InlineData("http", "example.com", "/base", "/foo", "?bar=1", "#col=2", "http://example.com/base/foo?bar=1#col=2")] + [InlineData("http", "example.com", "/base/", "/foo", "?bar=1", "#col=2", "http://example.com/base/foo?bar=1#col=2")] + [InlineData("http", "example.com", "", "", "?bar=1", "#col=2", "http://example.com/?bar=1#col=2")] + [InlineData("http", "example.com", "", "", "", "#frag?stillfrag/stillfrag", "http://example.com/#frag?stillfrag/stillfrag")] + [InlineData("http", "example.com", "", "", "?q/stillq", "#frag?stillfrag/stillfrag", "http://example.com/?q/stillq#frag?stillfrag/stillfrag")] + [InlineData("http", "example.com", "", "/fo#o", "", "#col=2", "http://example.com/fo%23o#col=2")] + [InlineData("http", "example.com", "", "/fo?o", "", "#col=2", "http://example.com/fo%3Fo#col=2")] + [InlineData("ftp", "example.com", "", "/", "", "", "ftp://example.com/")] + [InlineData("ftp", "example.com", "/", "/", "", "", "ftp://example.com/")] + [InlineData("https", "127.0.0.0:80", "", "/bar", "", "", "https://127.0.0.0:80/bar")] + [InlineData("http", "[1080:0:0:0:8:800:200C:417A]", "", "/index.html", "", "", "http://[1080:0:0:0:8:800:200C:417A]/index.html")] + [InlineData("http", "example.com", "", "///", "", "", "http://example.com///")] + [InlineData("http", "example.com", "///", "///", "", "", "http://example.com/////")] + public void BuildAbsoluteGenerationChecks( + string scheme, + string host, + string pathBase, + string path, + string query, + string fragment, + string expectedUri) + { + var uri = UriHelper.BuildAbsolute( + scheme, + new HostString(host), + new PathString(pathBase), + new PathString(path), + new QueryString(query), + new FragmentString(fragment)); + + Assert.Equal(expectedUri, uri); + } + [Fact] public void GetEncodedUrlFromRequest() {