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>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
@ -76,5 +76,14 @@ namespace Microsoft.AspNetCore.WebUtilities {
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"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</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>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
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
: and then encoded with base64 encoding.
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
: and then encoded with base64 encoding.
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
: and then encoded with base64 encoding.
-->
@ -123,4 +123,7 @@
<data name="HttpResponseStreamWriter_StreamNotWritable" xml:space="preserve">
<value>The stream must support writing.</value>
</data>
<data name="WebEncoders_InvalidCountOffsetOrLength" xml:space="preserve">
<value>Invalid {0}, {1} or {2} length.</value>
</data>
</root>

View File

@ -3,6 +3,7 @@
using System;
using System.Diagnostics;
using System.Globalization;
namespace Microsoft.AspNetCore.WebUtilities
{
@ -11,6 +12,8 @@ namespace Microsoft.AspNetCore.WebUtilities
/// </summary>
public static class WebEncoders
{
private static readonly byte[] EmptyBytes = new byte[0];
/// <summary>
/// Decodes a base64url-encoded string.
/// </summary>
@ -18,7 +21,7 @@ namespace Microsoft.AspNetCore.WebUtilities
/// <returns>The base64url-decoded form of the input.</returns>
/// <remarks>
/// 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>
public static byte[] Base64UrlDecode(string input)
{
@ -27,7 +30,7 @@ namespace Microsoft.AspNetCore.WebUtilities
throw new ArgumentNullException(nameof(input));
}
return Base64UrlDecode(input, 0, input.Length);
return Base64UrlDecode(input, offset: 0, count: input.Length);
}
/// <summary>
@ -39,7 +42,7 @@ namespace Microsoft.AspNetCore.WebUtilities
/// <returns>The base64url-decoded form of the input.</returns>
/// <remarks>
/// 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>
public static byte[] Base64UrlDecode(string input, int offset, int count)
{
@ -48,50 +51,139 @@ namespace Microsoft.AspNetCore.WebUtilities
throw new ArgumentNullException(nameof(input));
}
ValidateParameters(input.Length, offset, count);
ValidateParameters(input.Length, nameof(input), offset, count);
// Special-case empty input
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.
// First, we need to add the padding characters back.
int numPaddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count);
char[] completeBase64Array = new char[checked(count + numPaddingCharsToAdd)];
Debug.Assert(completeBase64Array.Length % 4 == 0, "Invariant: Array length must be a multiple of 4.");
input.CopyTo(offset, completeBase64Array, 0, count);
for (int i = 1; i <= numPaddingCharsToAdd; i++)
var paddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count);
var arraySizeRequired = checked(count + paddingCharsToAdd);
Debug.Assert(arraySizeRequired % 4 == 0, "Invariant: Array length must be a multiple of 4.");
if (buffer.Length - bufferOffset < arraySizeRequired)
{
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 '_' -> '/'
for (int i = 0; i < completeBase64Array.Length; i++)
// Copy input into buffer, fixing up '-' -> '+' and '_' -> '/'.
var i = bufferOffset;
for (var j = offset; i - bufferOffset < count; i++, j++)
{
char c = completeBase64Array[i];
if (c == '-')
var ch = input[j];
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.
return Convert.FromBase64CharArray(completeBase64Array, 0, completeBase64Array.Length);
return Convert.FromBase64CharArray(buffer, bufferOffset, arraySizeRequired);
}
/// <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>
/// <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)
{
if (input == null)
@ -99,16 +191,16 @@ namespace Microsoft.AspNetCore.WebUtilities
throw new ArgumentNullException(nameof(input));
}
return Base64UrlEncode(input, 0, input.Length);
return Base64UrlEncode(input, offset: 0, count: input.Length);
}
/// <summary>
/// Encodes an input using base64url encoding.
/// 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="count">The number of bytes of <paramref name="input"/> to encode.</param>
/// <returns>The base64url-encoded form of the input.</returns>
/// <param name="count">The number of bytes from <paramref name="input"/> to encode.</param>
/// <returns>The base64url-encoded form of <paramref name="input"/>.</returns>
public static string Base64UrlEncode(byte[] input, int offset, int count)
{
if (input == null)
@ -116,7 +208,7 @@ namespace Microsoft.AspNetCore.WebUtilities
throw new ArgumentNullException(nameof(input));
}
ValidateParameters(input.Length, offset, count);
ValidateParameters(input.Length, nameof(input), offset, count);
// Special-case empty input
if (count == 0)
@ -124,38 +216,104 @@ namespace Microsoft.AspNetCore.WebUtilities
return string.Empty;
}
// We're going to use base64url encoding with no padding characters.
// See RFC 4648, Sec. 5.
char[] buffer = new char[GetNumBase64CharsRequiredForInput(count)];
int numBase64Chars = Convert.ToBase64CharArray(input, offset, count, buffer, 0);
var buffer = new char[GetArraySizeRequiredToEncode(count)];
var numBase64Chars = Base64UrlEncode(input, offset, buffer, outputOffset: 0, count: count);
// Fix up '+' -> '-' and '/' -> '_'
for (int i = 0; i < numBase64Chars; i++)
return new String(buffer, startIndex: 0, length: numBase64Chars);
}
/// <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 == '+')
{
buffer[i] = '-';
output[i] = '-';
}
else if (ch == '/')
{
buffer[i] = '_';
output[i] = '_';
}
else if (ch == '=')
{
// We've reached a padding character: truncate the string from this point
return new String(buffer, 0, i);
// We've reached a padding character; truncate the remainder.
return i - outputOffset;
}
}
// If we got this far, the buffer didn't contain any padding chars, so turn
// it directly into a string.
return new String(buffer, 0, numBase64Chars);
return 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);
}
@ -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)
{
@ -202,7 +360,14 @@ namespace Microsoft.AspNetCore.WebUtilities
}
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);
}
[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]
[InlineData(0, 1, 0)]
[InlineData(0, 0, 1)]