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

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<root> <root>
<!-- <!--
Microsoft ResX Schema Microsoft ResX Schema
Version 2.0 Version 2.0
The primary goals of this format is to allow a simple XML format The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes various data types are done through the TypeConverter classes
associated with the data types. associated with the data types.
Example: Example:
... ado.net/XML headers & schema ... ... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader> <resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader> <resheader name="version">2.0</resheader>
@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment> <comment>This is a comment</comment>
</data> </data>
There are any number of "resheader" rows that contain simple There are any number of "resheader" rows that contain simple
name/value pairs. name/value pairs.
Each data row contains a name, and value. The row also contains a Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture. text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the Classes that don't support this are serialized and stored with the
mimetype set. mimetype set.
The mimetype is used for serialized objects, and tells the The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly: extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below. read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64 mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64 mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64 mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter : using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
--> -->
@ -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)]