diff --git a/sdk/src/Core/Amazon.Runtime/Internal/Auth/AWS4Signer.cs b/sdk/src/Core/Amazon.Runtime/Internal/Auth/AWS4Signer.cs index 8e512a2a0ad4..067e4624d53d 100644 --- a/sdk/src/Core/Amazon.Runtime/Internal/Auth/AWS4Signer.cs +++ b/sdk/src/Core/Amazon.Runtime/Internal/Auth/AWS4Signer.cs @@ -22,6 +22,7 @@ using Amazon.Util; using Amazon.Runtime.Internal.Util; using Amazon.Runtime.Endpoints; +using ThirdParty.RuntimeBackports; namespace Amazon.Runtime.Internal.Auth { @@ -818,13 +819,14 @@ private static string CanonicalizeRequestHelper(Uri endpoint, IDictionary pathResources, bool doubleEncode) { - var canonicalRequest = new StringBuilder(); - canonicalRequest.AppendFormat("{0}\n", httpMethod); - canonicalRequest.AppendFormat("{0}\n", AWSSDKUtils.CanonicalizeResourcePathV2(endpoint, resourcePath, doubleEncode, pathResources)); - canonicalRequest.AppendFormat("{0}\n", canonicalQueryString); + var canonicalRequest = new ValueStringBuilder(512); + canonicalRequest.Append(httpMethod); + canonicalRequest.Append('\n'); + canonicalRequest.Append($"{AWSSDKUtils.CanonicalizeResourcePathV2(endpoint, resourcePath, doubleEncode, pathResources)}\n"); + canonicalRequest.Append($"{canonicalQueryString}\n"); - canonicalRequest.AppendFormat("{0}\n", CanonicalizeHeaders(sortedHeaders)); - canonicalRequest.AppendFormat("{0}\n", CanonicalizeHeaderNames(sortedHeaders)); + canonicalRequest.Append($"{CanonicalizeHeaders(sortedHeaders)}\n"); + canonicalRequest.Append($"{CanonicalizeHeaderNames(sortedHeaders)}\n"); if (precomputedBodyHash != null) { @@ -832,8 +834,7 @@ private static string CanonicalizeRequestHelper(Uri endpoint, } else { - string contentHash; - if (sortedHeaders.TryGetValue(HeaderKeys.XAmzContentSha256Header, out contentHash)) + if (sortedHeaders.TryGetValue(HeaderKeys.XAmzContentSha256Header, out var contentHash)) canonicalRequest.Append(contentHash); } @@ -870,16 +871,20 @@ protected internal static IDictionary SortAndPruneHeaders(IEnume /// Canonicalized string of headers, with the header names in lower case. protected internal static string CanonicalizeHeaders(IEnumerable> sortedHeaders) { - if (sortedHeaders == null || sortedHeaders.Count() == 0) + if (sortedHeaders == null) return string.Empty; - var builder = new StringBuilder(); - - foreach (var entry in sortedHeaders) + // Majority of the cases we will always have a IDictionary for headers which implements ICollection>. + var materializedSortedHeaders = sortedHeaders as ICollection> ?? sortedHeaders.ToList(); + if (materializedSortedHeaders.Count == 0) + return string.Empty; + + var builder = new ValueStringBuilder(512); + foreach (var entry in materializedSortedHeaders) { // Refer https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html. (Step #4: "To create the canonical headers list, convert all header names to lowercase and remove leading spaces and trailing spaces. Convert sequential spaces in the header value to a single space."). builder.Append(entry.Key.ToLowerInvariant()); - builder.Append(":"); + builder.Append(':'); builder.Append(AWSSDKUtils.CompressSpaces(entry.Value)?.Trim()); builder.Append("\n"); } @@ -893,15 +898,15 @@ protected internal static string CanonicalizeHeaders(IEnumerableFormatted string of header names protected static string CanonicalizeHeaderNames(IEnumerable> sortedHeaders) { - var builder = new StringBuilder(); - + var builder = new ValueStringBuilder(512); + foreach (var header in sortedHeaders) { if (builder.Length > 0) - builder.Append(";"); + builder.Append(';'); builder.Append(header.Key.ToLowerInvariant()); } - + return builder.ToString(); } diff --git a/sdk/src/Core/Amazon.Util/AWSSDKUtils.cs b/sdk/src/Core/Amazon.Util/AWSSDKUtils.cs index 0b23e6149e06..9e45563d1ee6 100644 --- a/sdk/src/Core/Amazon.Util/AWSSDKUtils.cs +++ b/sdk/src/Core/Amazon.Util/AWSSDKUtils.cs @@ -35,6 +35,7 @@ using System.Reflection; using System.Threading; using Amazon.Runtime.Endpoints; +using ThirdParty.RuntimeBackports; #if AWS_ASYNC_API using System.Threading.Tasks; @@ -1643,21 +1644,24 @@ public static string CompressSpaces(string data) return null; } - if (data.Length == 0) + var dataLength = data.Length; + if (dataLength == 0) { return string.Empty; } - var stringBuilder = new StringBuilder(); + var stringBuilder = new ValueStringBuilder(dataLength); + int index = 0; var isWhiteSpace = false; foreach (var character in data) { if (!isWhiteSpace | !(isWhiteSpace = char.IsWhiteSpace(character))) { stringBuilder.Append(isWhiteSpace ? ' ' : character); + index++; } } - return stringBuilder.ToString(); + return stringBuilder.ToString(0, index); } /// diff --git a/sdk/src/Core/ThirdParty/RuntimeBackports/ValueStringBuilder.cs b/sdk/src/Core/ThirdParty/RuntimeBackports/ValueStringBuilder.cs new file mode 100644 index 000000000000..2e421a7222f5 --- /dev/null +++ b/sdk/src/Core/ThirdParty/RuntimeBackports/ValueStringBuilder.cs @@ -0,0 +1,316 @@ +// Partially adapted from https://raw.githubusercontent.com/dotnet/runtime/main/src/libraries/Common/src/System/Text/ValueStringBuilder.cs +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace ThirdParty.RuntimeBackports +{ +#pragma warning disable CA1815 + internal ref struct ValueStringBuilder +#pragma warning restore CA1815 + { + private char[]? _arrayToReturnToPool; + private Span _chars; + private int _pos; + + public ValueStringBuilder(Span initialBuffer) + { + _arrayToReturnToPool = null; + _chars = initialBuffer; + _pos = 0; + } + + public ValueStringBuilder(int initialCapacity) + { + _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity); + _chars = _arrayToReturnToPool; + _pos = 0; + } + + public int Length + { + get => _pos; + set + { + Debug.Assert(value >= 0); + Debug.Assert(value <= _chars.Length); + _pos = value; + } + } + + public int Capacity => _chars.Length; + + public void EnsureCapacity(int capacity) + { + // This is not expected to be called this with negative capacity + Debug.Assert(capacity >= 0); + + // If the caller has a bug and calls this with negative capacity, make sure to call Grow to throw an exception. + if ((uint)capacity > (uint)_chars.Length) + Grow(capacity - _pos); + } + + /// + /// Get a pinnable reference to the builder. + /// Does not ensure there is a null char after + /// This overload is pattern matched in the C# 7.3+ compiler so you can omit + /// the explicit method call, and write eg "fixed (char* c = builder)" + /// + public ref char GetPinnableReference() + { + return ref MemoryMarshal.GetReference(_chars); + } + + /// + /// Get a pinnable reference to the builder. + /// + /// Ensures that the builder has a null char after + public ref char GetPinnableReference(bool terminate) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = '\0'; + } + return ref MemoryMarshal.GetReference(_chars); + } + + public ref char this[int index] + { + get + { + Debug.Assert(index < _pos); + return ref _chars[index]; + } + } + + public override string ToString() + { + string s = _chars.Slice(0, _pos).ToString(); + Dispose(); + return s; + } + + public string ToString(int start, int length) + { + string s = _chars.Slice(start, length).ToString(); + Dispose(); + return s; + } + + /// Returns the underlying storage of the builder. + public Span RawChars => _chars; + + /// + /// Returns a span around the contents of the builder. + /// + /// Ensures that the builder has a null char after + public ReadOnlySpan AsSpan(bool terminate) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = '\0'; + } + return _chars.Slice(0, _pos); + } + + public ReadOnlySpan AsSpan() => _chars.Slice(0, _pos); + public ReadOnlySpan AsSpan(int start) => _chars.Slice(start, _pos - start); + public ReadOnlySpan AsSpan(int start, int length) => _chars.Slice(start, length); + + public bool TryCopyTo(Span destination, out int charsWritten) + { + if (_chars.Slice(0, _pos).TryCopyTo(destination)) + { + charsWritten = _pos; + Dispose(); + return true; + } + else + { + charsWritten = 0; + Dispose(); + return false; + } + } + + public void Insert(int index, char value, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + _chars.Slice(index, count).Fill(value); + _pos += count; + } + + public void Insert(int index, string? s) + { + if (s == null) + { + return; + } + + int count = s.Length; + + if (_pos > (_chars.Length - count)) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + s +#if !NET + .AsSpan() +#endif + .CopyTo(_chars.Slice(index)); + _pos += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(char c) + { + int pos = _pos; + Span chars = _chars; + if ((uint)pos < (uint)chars.Length) + { + chars[pos] = c; + _pos = pos + 1; + } + else + { + GrowAndAppend(c); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(string? s) + { + if (s == null) + { + return; + } + + int pos = _pos; + if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + { + _chars[pos] = s[0]; + _pos = pos + 1; + } + else + { + AppendSlow(s); + } + } + + private void AppendSlow(string s) + { + int pos = _pos; + if (pos > _chars.Length - s.Length) + { + Grow(s.Length); + } + + s +#if !NET + .AsSpan() +#endif + .CopyTo(_chars.Slice(pos)); + _pos += s.Length; + } + + public void Append(char c, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + Span dst = _chars.Slice(_pos, count); + for (int i = 0; i < dst.Length; i++) + { + dst[i] = c; + } + _pos += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Span AppendSpan(int length) + { + int origPos = _pos; + if (origPos > _chars.Length - length) + { + Grow(length); + } + + _pos = origPos + length; + return _chars.Slice(origPos, length); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowAndAppend(char c) + { + Grow(1); + Append(c); + } + + /// + /// Resize the internal buffer either by doubling current buffer size or + /// by adding to + /// whichever is greater. + /// + /// + /// Number of chars requested beyond current position. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void Grow(int additionalCapacityBeyondPos) + { + Debug.Assert(additionalCapacityBeyondPos > 0); + Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed."); + + const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength + + // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try + // to double the size if possible, bounding the doubling to not go beyond the max array length. + int newCapacity = (int)Math.Max( + (uint)(_pos + additionalCapacityBeyondPos), + Math.Min((uint)_chars.Length * 2, ArrayMaxLength)); + + // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative. + // This could also go negative if the actual required length wraps around. + char[] poolArray = ArrayPool.Shared.Rent(newCapacity); + + _chars.Slice(0, _pos).CopyTo(poolArray); + + char[]? toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = poolArray; + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + char[]? toReturn = _arrayToReturnToPool; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + } +} \ No newline at end of file diff --git a/sdk/test/NetStandard/UnitTests/Core/AWS4SignerTests.cs b/sdk/test/NetStandard/UnitTests/Core/AWS4SignerTests.cs new file mode 100644 index 000000000000..00dd6cf11630 --- /dev/null +++ b/sdk/test/NetStandard/UnitTests/Core/AWS4SignerTests.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Amazon.Runtime.Internal.Auth; +using Xunit; + +namespace UnitTests.NetStandard.Core +{ + [Trait("Category", "Core")] + public class AWS4SignerTests + { + [Fact] + public void CanonicalizeHeaderNames() + { + var headers = new Dictionary + { + { "Header1", "Value1" }, + { "Header2", "Value2" }, + { "Header3", "Value3" } + }; + + var signer = new AWS4SignerTestee(); + var canonicalizedHeaders = signer.TestCanonicalizeHeaderNames(headers); + + Assert.Equal("header1;header2;header3", canonicalizedHeaders); + } + + [Fact] + public void CanonicalizeHeaders() + { + var headers = new Dictionary + { + { "Header1", "Value1" }, + { "Header2", " Value2 " }, + { "Header3", " Value3 " } + }; + + var signer = new AWS4SignerTestee(); + var canonicalizedHeaders = signer.TestCanonicalizeHeaders(headers); + + Assert.Equal("header1:Value1\nheader2:Value2\nheader3:Value3\n", canonicalizedHeaders); + } + + class AWS4SignerTestee : AWS4Signer + { + public string TestCanonicalizeHeaderNames(IDictionary headers) + { + return CanonicalizeHeaderNames(headers); + } + + public string TestCanonicalizeHeaders(IDictionary headers) + { + return CanonicalizeHeaders(headers); + } + } + } +} \ No newline at end of file diff --git a/sdk/test/NetStandard/UnitTests/Core/AWSSDKUtilsTests.cs b/sdk/test/NetStandard/UnitTests/Core/AWSSDKUtilsTests.cs index c7e5574664b4..1513b21f6920 100644 --- a/sdk/test/NetStandard/UnitTests/Core/AWSSDKUtilsTests.cs +++ b/sdk/test/NetStandard/UnitTests/Core/AWSSDKUtilsTests.cs @@ -24,5 +24,13 @@ public void ToHexLowercase() Assert.Equal("48656c6c6f20576f726c64", hexString); } + + [Fact] + public void CompressSpaces() + { + var data = "Hello, World!"; + var compressed = AWSSDKUtils.CompressSpaces(data); + Assert.Equal("Hello, World!", compressed); + } } }