File: FormReader.cs
Web Access
Project: src\src\Http\WebUtilities\src\Microsoft.AspNetCore.WebUtilities.csproj (Microsoft.AspNetCore.WebUtilities)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using Microsoft.Extensions.Primitives;
 
namespace Microsoft.AspNetCore.WebUtilities;
 
/// <summary>
/// Used to read an 'application/x-www-form-urlencoded' form.
/// </summary>
public class FormReader : IDisposable
{
    /// <summary>
    /// Gets the default value for <see cref="ValueCountLimit"/>.
    /// Defaults to 1024.
    /// </summary>
    public const int DefaultValueCountLimit = 1024;
 
    /// <summary>
    /// Gets the default value for <see cref="KeyLengthLimit"/>.
    /// Defaults to 2,048 bytes, which is approximately 2KB.
    /// </summary>
    public const int DefaultKeyLengthLimit = 1024 * 2;
 
    /// <summary>
    /// Gets the default value for <see cref="ValueLengthLimit" />.
    /// Defaults to 4,194,304 bytes, which is approximately 4MB.
    /// </summary>
    public const int DefaultValueLengthLimit = 1024 * 1024 * 4;
 
    private const int _rentedCharPoolLength = 8192;
    private readonly TextReader _reader;
    private readonly char[] _buffer;
    private readonly ArrayPool<char> _charPool;
    private readonly StringBuilder _builder = new StringBuilder();
    private int _bufferOffset;
    private int _bufferCount;
    private string? _currentKey;
    private string? _currentValue;
    private bool _endOfStream;
    private bool _disposed;
 
    /// <summary>
    /// Initializes a new instance of <see cref="FormReader"/>.
    /// </summary>
    /// <param name="data">The data to read.</param>
    public FormReader(string data)
        : this(data, ArrayPool<char>.Shared)
    {
    }
 
    /// <summary>
    /// Initializes a new instance of <see cref="FormReader"/>.
    /// </summary>
    /// <param name="data">The data to read.</param>
    /// <param name="charPool">The <see cref="ArrayPool{T}"/> to use.</param>
    public FormReader(string data, ArrayPool<char> charPool)
    {
        ArgumentNullException.ThrowIfNull(data);
 
        _buffer = charPool.Rent(_rentedCharPoolLength);
        _charPool = charPool;
        _reader = new StringReader(data);
    }
 
    /// <summary>
    /// Initializes a new instance of <see cref="FormReader"/>.
    /// </summary>
    /// <param name="stream">The <see cref="Stream"/> to read. Assumes a utf-8 encoded stream.</param>
    public FormReader(Stream stream)
        : this(stream, Encoding.UTF8, ArrayPool<char>.Shared)
    {
    }
 
    /// <summary>
    /// Initializes a new instance of <see cref="FormReader"/>.
    /// </summary>
    /// <param name="stream">The <see cref="Stream"/> to read.</param>
    /// <param name="encoding">The character encoding to use.</param>
    public FormReader(Stream stream, Encoding encoding)
        : this(stream, encoding, ArrayPool<char>.Shared)
    {
    }
 
    /// <summary>
    /// Initializes a new instance of <see cref="FormReader"/>.
    /// </summary>
    /// <param name="stream">The <see cref="Stream"/> to read.</param>
    /// <param name="encoding">The character encoding to use.</param>
    /// <param name="charPool">The <see cref="ArrayPool{T}"/> to use.</param>
    public FormReader(Stream stream, Encoding encoding, ArrayPool<char> charPool)
    {
        ArgumentNullException.ThrowIfNull(stream);
        ArgumentNullException.ThrowIfNull(encoding);
 
        _buffer = charPool.Rent(_rentedCharPoolLength);
        _charPool = charPool;
        _reader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true, bufferSize: 1024 * 2, leaveOpen: true);
    }
 
    /// <summary>
    /// The limit on the number of form values to allow in ReadForm or ReadFormAsync.
    /// </summary>
    public int ValueCountLimit { get; set; } = DefaultValueCountLimit;
 
    /// <summary>
    /// The limit on the length of form keys.
    /// </summary>
    public int KeyLengthLimit { get; set; } = DefaultKeyLengthLimit;
 
    /// <summary>
    /// The limit on the length of form values.
    /// </summary>
    public int ValueLengthLimit { get; set; } = DefaultValueLengthLimit;
 
    // Format: key1=value1&key2=value2
    /// <summary>
    /// Reads the next key value pair from the form.
    /// For unbuffered data use the async overload instead.
    /// </summary>
    /// <returns>The next key value pair, or null when the end of the form is reached.</returns>
    public KeyValuePair<string, string>? ReadNextPair()
    {
        ReadNextPairImpl();
        if (ReadSucceeded())
        {
            return new KeyValuePair<string, string>(_currentKey, _currentValue);
        }
        return null;
    }
 
    private void ReadNextPairImpl()
    {
        StartReadNextPair();
        while (!_endOfStream)
        {
            // Empty
            if (_bufferCount == 0)
            {
                Buffer();
            }
            if (TryReadNextPair())
            {
                break;
            }
        }
    }
 
    // Format: key1=value1&key2=value2
    /// <summary>
    /// Asynchronously reads the next key value pair from the form.
    /// </summary>
    /// <param name="cancellationToken"></param>
    /// <returns>The next key value pair, or null when the end of the form is reached.</returns>
    public async Task<KeyValuePair<string, string>?> ReadNextPairAsync(CancellationToken cancellationToken = new CancellationToken())
    {
        await ReadNextPairAsyncImpl(cancellationToken);
        if (ReadSucceeded())
        {
            return new KeyValuePair<string, string>(_currentKey, _currentValue);
        }
        return null;
    }
 
    private async Task ReadNextPairAsyncImpl(CancellationToken cancellationToken = new CancellationToken())
    {
        StartReadNextPair();
        while (!_endOfStream)
        {
            // Empty
            if (_bufferCount == 0)
            {
                await BufferAsync(cancellationToken);
            }
            if (TryReadNextPair())
            {
                break;
            }
        }
    }
 
    private void StartReadNextPair()
    {
        _currentKey = null;
        _currentValue = null;
    }
 
    private bool TryReadNextPair()
    {
        if (_currentKey == null)
        {
            if (!TryReadWord('=', KeyLengthLimit, out _currentKey))
            {
                return false;
            }
 
            if (_bufferCount == 0)
            {
                return false;
            }
        }
 
        if (_currentValue == null)
        {
            if (!TryReadWord('&', ValueLengthLimit, out _currentValue))
            {
                return false;
            }
        }
        return true;
    }
 
    private bool TryReadWord(char separator, int limit, [NotNullWhen(true)] out string? value)
    {
        do
        {
            if (ReadChar(separator, limit, out value))
            {
                return true;
            }
        } while (_bufferCount > 0);
        return false;
    }
 
    private bool ReadChar(char separator, int limit, [NotNullWhen(true)] out string? word)
    {
        // End
        if (_bufferCount == 0)
        {
            word = BuildWord();
            return true;
        }
 
        var c = _buffer[_bufferOffset++];
        _bufferCount--;
 
        if (c == separator)
        {
            word = BuildWord();
            return true;
        }
        if (_builder.Length >= limit)
        {
            throw new InvalidDataException($"Form key or value length limit {limit} exceeded.");
        }
        _builder.Append(c);
        word = null;
        return false;
    }
 
    // '+' un-escapes to ' ', %HH un-escapes as ASCII (or utf-8?)
    private string BuildWord()
    {
        _builder.Replace('+', ' ');
        var result = _builder.ToString();
        _builder.Clear();
        return Uri.UnescapeDataString(result); // TODO: Replace this, it's not completely accurate.
    }
 
    private void Buffer()
    {
        _bufferOffset = 0;
        _bufferCount = _reader.Read(_buffer, 0, _buffer.Length);
        _endOfStream = _bufferCount == 0;
    }
 
    private async Task BufferAsync(CancellationToken cancellationToken)
    {
        // TODO: StreamReader doesn't support cancellation?
        cancellationToken.ThrowIfCancellationRequested();
        _bufferOffset = 0;
        _bufferCount = await _reader.ReadAsync(_buffer, 0, _buffer.Length);
        _endOfStream = _bufferCount == 0;
    }
 
    /// <summary>
    /// Parses text from an HTTP form body.
    /// </summary>
    /// <returns>The collection containing the parsed HTTP form body.</returns>
    public Dictionary<string, StringValues> ReadForm()
    {
        var accumulator = new KeyValueAccumulator();
        while (!_endOfStream)
        {
            ReadNextPairImpl();
            Append(ref accumulator);
        }
        return accumulator.GetResults();
    }
 
    /// <summary>
    /// Parses an HTTP form body.
    /// </summary>
    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
    /// <returns>The collection containing the parsed HTTP form body.</returns>
    public async Task<Dictionary<string, StringValues>> ReadFormAsync(CancellationToken cancellationToken = new CancellationToken())
    {
        var accumulator = new KeyValueAccumulator();
        while (!_endOfStream)
        {
            await ReadNextPairAsyncImpl(cancellationToken);
            Append(ref accumulator);
        }
        return accumulator.GetResults();
    }
 
    [MemberNotNullWhen(true, nameof(_currentKey), nameof(_currentValue))]
    private bool ReadSucceeded()
    {
        return _currentKey != null && _currentValue != null;
    }
 
    private void Append(ref KeyValueAccumulator accumulator)
    {
        if (ReadSucceeded())
        {
            accumulator.Append(_currentKey, _currentValue);
            if (accumulator.ValueCount > ValueCountLimit)
            {
                throw new InvalidDataException($"Form value count limit {ValueCountLimit} exceeded.");
            }
        }
    }
 
    /// <inheritdoc />
    public void Dispose()
    {
        if (!_disposed)
        {
            _disposed = true;
            _charPool.Return(_buffer);
        }
    }
}