Extend `WebEncoders` API to avoid allocations within the methods

- rewrite existing methods in terms of the new ones
- don't allocate multiple 0-length arrays

nits:
- clarify a couple of doc comments e.g. using `<paramref/>`
- move an error message into a resource
 - pass parameter names into new resource
- rename parameters for consistency e.g. `inputLength` -> `count`
- name literal `int` parameters
- more `var`
This commit is contained in:
Doug Bunting 2016-02-17 23:13:33 -08:00
parent 5d8231ee0d
commit 8c120a0792
4 changed files with 286 additions and 74 deletions

View File

@ -1,4 +1,4 @@
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// <auto-generated> // <auto-generated>
// This code was generated by a tool. // This code was generated by a tool.
// Runtime Version:4.0.30319.42000 // Runtime Version:4.0.30319.42000
@ -76,5 +76,14 @@ namespace Microsoft.AspNetCore.WebUtilities {
return ResourceManager.GetString("HttpResponseStreamWriter_StreamNotWritable", resourceCulture); return ResourceManager.GetString("HttpResponseStreamWriter_StreamNotWritable", resourceCulture);
} }
} }
/// <summary>
/// Looks up a localized string similar to Invalid {0}, {1} or {2} length..
/// </summary>
internal static string WebEncoders_InvalidCountOffsetOrLength {
get {
return ResourceManager.GetString("WebEncoders_InvalidCountOffsetOrLength", resourceCulture);
}
}
} }
} }

View File

@ -123,4 +123,7 @@
<data name="HttpResponseStreamWriter_StreamNotWritable" xml:space="preserve"> <data name="HttpResponseStreamWriter_StreamNotWritable" xml:space="preserve">
<value>The stream must support writing.</value> <value>The stream must support writing.</value>
</data> </data>
<data name="WebEncoders_InvalidCountOffsetOrLength" xml:space="preserve">
<value>Invalid {0}, {1} or {2} length.</value>
</data>
</root> </root>

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
namespace Microsoft.AspNetCore.WebUtilities namespace Microsoft.AspNetCore.WebUtilities
{ {
@ -11,6 +12,8 @@ namespace Microsoft.AspNetCore.WebUtilities
/// </summary> /// </summary>
public static class WebEncoders public static class WebEncoders
{ {
private static readonly byte[] EmptyBytes = new byte[0];
/// <summary> /// <summary>
/// Decodes a base64url-encoded string. /// Decodes a base64url-encoded string.
/// </summary> /// </summary>
@ -18,7 +21,7 @@ namespace Microsoft.AspNetCore.WebUtilities
/// <returns>The base64url-decoded form of the input.</returns> /// <returns>The base64url-decoded form of the input.</returns>
/// <remarks> /// <remarks>
/// The input must not contain any whitespace or padding characters. /// The input must not contain any whitespace or padding characters.
/// Throws FormatException if the input is malformed. /// Throws <see cref="FormatException"/> if the input is malformed.
/// </remarks> /// </remarks>
public static byte[] Base64UrlDecode(string input) public static byte[] Base64UrlDecode(string input)
{ {
@ -27,7 +30,7 @@ namespace Microsoft.AspNetCore.WebUtilities
throw new ArgumentNullException(nameof(input)); throw new ArgumentNullException(nameof(input));
} }
return Base64UrlDecode(input, 0, input.Length); return Base64UrlDecode(input, offset: 0, count: input.Length);
} }
/// <summary> /// <summary>
@ -39,7 +42,7 @@ namespace Microsoft.AspNetCore.WebUtilities
/// <returns>The base64url-decoded form of the input.</returns> /// <returns>The base64url-decoded form of the input.</returns>
/// <remarks> /// <remarks>
/// The input must not contain any whitespace or padding characters. /// The input must not contain any whitespace or padding characters.
/// Throws FormatException if the input is malformed. /// Throws <see cref="FormatException"/> if the input is malformed.
/// </remarks> /// </remarks>
public static byte[] Base64UrlDecode(string input, int offset, int count) public static byte[] Base64UrlDecode(string input, int offset, int count)
{ {
@ -48,50 +51,139 @@ namespace Microsoft.AspNetCore.WebUtilities
throw new ArgumentNullException(nameof(input)); throw new ArgumentNullException(nameof(input));
} }
ValidateParameters(input.Length, offset, count); ValidateParameters(input.Length, nameof(input), offset, count);
// Special-case empty input // Special-case empty input
if (count == 0) if (count == 0)
{ {
return new byte[0]; return EmptyBytes;
}
// Create array large enough for the Base64 characters, not just shorter Base64-URL-encoded form.
var buffer = new char[GetArraySizeRequiredToDecode(count)];
return Base64UrlDecode(input, offset, buffer, bufferOffset: 0, count: count);
}
/// <summary>
/// Decodes a base64url-encoded <paramref name="input"/> into a <c>byte[]</c>.
/// </summary>
/// <param name="input">A string containing the base64url-encoded input to decode.</param>
/// <param name="offset">The position in <paramref name="input"/> at which decoding should begin.</param>
/// <param name="buffer">
/// Scratch buffer to hold the <see cref="char"/>s to decode. Array must be large enough to hold
/// <paramref name="bufferOffset"/> and <paramref name="count"/> characters as well as Base64 padding
/// characters. Content is not preserved.
/// </param>
/// <param name="bufferOffset">
/// The offset into <paramref name="buffer"/> at which to begin writing the <see cref="char"/>s to decode.
/// </param>
/// <param name="count">The number of characters in <paramref name="input"/> to decode.</param>
/// <returns>The base64url-decoded form of the <paramref name="input"/>.</returns>
/// <remarks>
/// The input must not contain any whitespace or padding characters.
/// Throws <see cref="FormatException"/> if the input is malformed.
/// </remarks>
public static byte[] Base64UrlDecode(string input, int offset, char[] buffer, int bufferOffset, int count)
{
if (input == null)
{
throw new ArgumentNullException(nameof(input));
}
if (buffer == null)
{
throw new ArgumentNullException(nameof(buffer));
}
ValidateParameters(input.Length, nameof(input), offset, count);
if (bufferOffset < 0)
{
throw new ArgumentOutOfRangeException(nameof(bufferOffset));
}
if (count == 0)
{
return EmptyBytes;
} }
// Assumption: input is base64url encoded without padding and contains no whitespace. // Assumption: input is base64url encoded without padding and contains no whitespace.
// First, we need to add the padding characters back. var paddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count);
int numPaddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count); var arraySizeRequired = checked(count + paddingCharsToAdd);
char[] completeBase64Array = new char[checked(count + numPaddingCharsToAdd)]; Debug.Assert(arraySizeRequired % 4 == 0, "Invariant: Array length must be a multiple of 4.");
Debug.Assert(completeBase64Array.Length % 4 == 0, "Invariant: Array length must be a multiple of 4.");
input.CopyTo(offset, completeBase64Array, 0, count); if (buffer.Length - bufferOffset < arraySizeRequired)
for (int i = 1; i <= numPaddingCharsToAdd; i++)
{ {
completeBase64Array[completeBase64Array.Length - i] = '='; throw new ArgumentException(
string.Format(
CultureInfo.CurrentCulture,
Resources.WebEncoders_InvalidCountOffsetOrLength,
nameof(count),
nameof(bufferOffset),
nameof(input)),
nameof(count));
} }
// Next, fix up '-' -> '+' and '_' -> '/' // Copy input into buffer, fixing up '-' -> '+' and '_' -> '/'.
for (int i = 0; i < completeBase64Array.Length; i++) var i = bufferOffset;
for (var j = offset; i - bufferOffset < count; i++, j++)
{ {
char c = completeBase64Array[i]; var ch = input[j];
if (c == '-') if (ch == '-')
{ {
completeBase64Array[i] = '+'; buffer[i] = '+';
} }
else if (c == '_') else if (ch == '_')
{ {
completeBase64Array[i] = '/'; buffer[i] = '/';
}
else
{
buffer[i] = ch;
} }
} }
// Finally, decode. // Add the padding characters back.
for (; paddingCharsToAdd > 0; i++, paddingCharsToAdd--)
{
buffer[i] = '=';
}
// Decode.
// If the caller provided invalid base64 chars, they'll be caught here. // If the caller provided invalid base64 chars, they'll be caught here.
return Convert.FromBase64CharArray(completeBase64Array, 0, completeBase64Array.Length); return Convert.FromBase64CharArray(buffer, bufferOffset, arraySizeRequired);
} }
/// <summary> /// <summary>
/// Encodes an input using base64url encoding. /// Gets the minimum <c>char[]</c> size required for decoding of <paramref name="count"/> characters
/// with the <see cref="Base64UrlDecode(char[], int, int)"/> method.
/// </summary>
/// <param name="count">The number of characters to decode.</param>
/// <returns>
/// The minimum <c>char[]</c> size required for decoding of <paramref name="count"/> characters.
/// </returns>
public static int GetArraySizeRequiredToDecode(int count)
{
if (count < 0)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
if (count == 0)
{
return 0;
}
var numPaddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count);
return checked(count + numPaddingCharsToAdd);
}
/// <summary>
/// Encodes <paramref name="input"/> using base64url encoding.
/// </summary> /// </summary>
/// <param name="input">The binary input to encode.</param> /// <param name="input">The binary input to encode.</param>
/// <returns>The base64url-encoded form of the input.</returns> /// <returns>The base64url-encoded form of <paramref name="input"/>.</returns>
public static string Base64UrlEncode(byte[] input) public static string Base64UrlEncode(byte[] input)
{ {
if (input == null) if (input == null)
@ -99,16 +191,16 @@ namespace Microsoft.AspNetCore.WebUtilities
throw new ArgumentNullException(nameof(input)); throw new ArgumentNullException(nameof(input));
} }
return Base64UrlEncode(input, 0, input.Length); return Base64UrlEncode(input, offset: 0, count: input.Length);
} }
/// <summary> /// <summary>
/// Encodes an input using base64url encoding. /// Encodes <paramref name="input"/> using base64url encoding.
/// </summary> /// </summary>
/// <param name="input">The binary input to encode.</param> /// <param name="input">The binary input to encode.</param>
/// <param name="offset">The offset into <paramref name="input"/> at which to begin encoding.</param> /// <param name="offset">The offset into <paramref name="input"/> at which to begin encoding.</param>
/// <param name="count">The number of bytes of <paramref name="input"/> to encode.</param> /// <param name="count">The number of bytes from <paramref name="input"/> to encode.</param>
/// <returns>The base64url-encoded form of the input.</returns> /// <returns>The base64url-encoded form of <paramref name="input"/>.</returns>
public static string Base64UrlEncode(byte[] input, int offset, int count) public static string Base64UrlEncode(byte[] input, int offset, int count)
{ {
if (input == null) if (input == null)
@ -116,7 +208,7 @@ namespace Microsoft.AspNetCore.WebUtilities
throw new ArgumentNullException(nameof(input)); throw new ArgumentNullException(nameof(input));
} }
ValidateParameters(input.Length, offset, count); ValidateParameters(input.Length, nameof(input), offset, count);
// Special-case empty input // Special-case empty input
if (count == 0) if (count == 0)
@ -124,38 +216,104 @@ namespace Microsoft.AspNetCore.WebUtilities
return string.Empty; return string.Empty;
} }
// We're going to use base64url encoding with no padding characters. var buffer = new char[GetArraySizeRequiredToEncode(count)];
// See RFC 4648, Sec. 5. var numBase64Chars = Base64UrlEncode(input, offset, buffer, outputOffset: 0, count: count);
char[] buffer = new char[GetNumBase64CharsRequiredForInput(count)];
int numBase64Chars = Convert.ToBase64CharArray(input, offset, count, buffer, 0);
// Fix up '+' -> '-' and '/' -> '_' return new String(buffer, startIndex: 0, length: numBase64Chars);
for (int i = 0; i < numBase64Chars; i++) }
/// <summary>
/// Encodes <paramref name="input"/> using base64url encoding.
/// </summary>
/// <param name="input">The binary input to encode.</param>
/// <param name="offset">The offset into <paramref name="input"/> at which to begin encoding.</param>
/// <param name="output">
/// Buffer to receive the base64url-encoded form of <paramref name="input"/>. Array must be large enough to
/// hold <paramref name="outputOffset"/> characters and the full base64-encoded form of
/// <paramref name="input"/>, including padding characters.
/// </param>
/// <param name="outputOffset">
/// The offset into <paramref name="output"/> at which to begin writing the base64url-encoded form of
/// <paramref name="input"/>.
/// </param>
/// <param name="count">The number of <c>byte</c>s from <paramref name="input"/> to encode.</param>
/// <returns>
/// The number of characters written to <paramref name="output"/>, less any padding characters.
/// </returns>
public static int Base64UrlEncode(byte[] input, int offset, char[] output, int outputOffset, int count)
{
if (input == null)
{ {
char ch = buffer[i]; throw new ArgumentNullException(nameof(input));
}
if (output == null)
{
throw new ArgumentNullException(nameof(output));
}
ValidateParameters(input.Length, nameof(input), offset, count);
if (outputOffset < 0)
{
throw new ArgumentOutOfRangeException(nameof(outputOffset));
}
var arraySizeRequired = GetArraySizeRequiredToEncode(count);
if (output.Length - outputOffset < arraySizeRequired)
{
throw new ArgumentException(
string.Format(
CultureInfo.CurrentCulture,
Resources.WebEncoders_InvalidCountOffsetOrLength,
nameof(count),
nameof(outputOffset),
nameof(output)),
nameof(count));
}
// Special-case empty input.
if (count == 0)
{
return 0;
}
// Use base64url encoding with no padding characters. See RFC 4648, Sec. 5.
// Start with default Base64 encoding.
var numBase64Chars = Convert.ToBase64CharArray(input, offset, count, output, outputOffset);
// Fix up '+' -> '-' and '/' -> '_'. Drop padding characters.
for (var i = outputOffset; i - outputOffset < numBase64Chars; i++)
{
var ch = output[i];
if (ch == '+') if (ch == '+')
{ {
buffer[i] = '-'; output[i] = '-';
} }
else if (ch == '/') else if (ch == '/')
{ {
buffer[i] = '_'; output[i] = '_';
} }
else if (ch == '=') else if (ch == '=')
{ {
// We've reached a padding character: truncate the string from this point // We've reached a padding character; truncate the remainder.
return new String(buffer, 0, i); return i - outputOffset;
} }
} }
// If we got this far, the buffer didn't contain any padding chars, so turn return numBase64Chars;
// it directly into a string.
return new String(buffer, 0, numBase64Chars);
} }
private static int GetNumBase64CharsRequiredForInput(int inputLength) /// <summary>
/// Get the minimum output <c>char[]</c> size required for encoding <paramref name="count"/>
/// <see cref="byte"/>s with the <see cref="Base64UrlEncode(byte[], int, int, char[], int)"/> method.
/// </summary>
/// <param name="count">The number of characters to encode.</param>
/// <returns>
/// The minimum output <c>char[]</c> size required for encoding <paramref name="count"/> <see cref="byte"/>s.
/// </returns>
public static int GetArraySizeRequiredToEncode(int count)
{ {
int numWholeOrPartialInputBlocks = checked(inputLength + 2) / 3; var numWholeOrPartialInputBlocks = checked(count + 2) / 3;
return checked(numWholeOrPartialInputBlocks * 4); return checked(numWholeOrPartialInputBlocks * 4);
} }
@ -190,7 +348,7 @@ namespace Microsoft.AspNetCore.WebUtilities
} }
} }
private static void ValidateParameters(int bufferLength, int offset, int count) private static void ValidateParameters(int bufferLength, string inputName, int offset, int count)
{ {
if (offset < 0) if (offset < 0)
{ {
@ -202,7 +360,14 @@ namespace Microsoft.AspNetCore.WebUtilities
} }
if (bufferLength - offset < count) if (bufferLength - offset < count)
{ {
throw new ArgumentException("Invalid offset / length."); throw new ArgumentException(
string.Format(
CultureInfo.CurrentCulture,
Resources.WebEncoders_InvalidCountOffsetOrLength,
nameof(count),
nameof(offset),
inputName),
nameof(count));
} }
} }
} }

View File

@ -57,6 +57,41 @@ namespace Microsoft.AspNetCore.WebUtilities
Assert.Equal(roundTrippedAsBase64, base64Input); Assert.Equal(roundTrippedAsBase64, base64Input);
} }
[Theory]
[InlineData("", "")]
[InlineData("123456qwerty++//X+/x", "123456qwerty--__X-_x")]
[InlineData("123456qwerty++//X+/xxw==", "123456qwerty--__X-_xxw")]
[InlineData("123456qwerty++//X+/xxw0=", "123456qwerty--__X-_xxw0")]
public void Base64UrlEncode_And_Decode_WithBufferOffsets(string base64Input, string expectedBase64Url)
{
// Arrange
var input = new byte[3].Concat(Convert.FromBase64String(base64Input)).Concat(new byte[2]).ToArray();
var buffer = new char[30];
var output = new char[30];
for (var i = 0; i < buffer.Length; i++)
{
buffer[i] = '^';
output[i] = '^';
}
// Act 1
var numEncodedChars =
WebEncoders.Base64UrlEncode(input, offset: 3, output: output, outputOffset: 4, count: input.Length - 5);
// Assert 1
var encodedString = new string(output, startIndex: 4, length: numEncodedChars);
Assert.Equal(expectedBase64Url, encodedString);
// Act 2
var roundTripInput = new string(output);
var roundTripped =
WebEncoders.Base64UrlDecode(roundTripInput, offset: 4, buffer: buffer, bufferOffset: 5, count: numEncodedChars);
// Assert 2, verify that values round-trip
var roundTrippedAsBase64 = Convert.ToBase64String(roundTripped);
Assert.Equal(roundTrippedAsBase64, base64Input);
}
[Theory] [Theory]
[InlineData(0, 1, 0)] [InlineData(0, 1, 0)]
[InlineData(0, 0, 1)] [InlineData(0, 0, 1)]