File: ContentDispositionHeaderValue.cs
Web Access
Project: src\src\Http\Headers\src\Microsoft.Net.Http.Headers.csproj (Microsoft.Net.Http.Headers)
// 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.Buffers.Text;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Extensions.Primitives;
 
namespace Microsoft.Net.Http.Headers;
 
/// <summary>
/// Represents the value of a <c>Content-Disposition</c> header.
/// </summary>
/// <remarks>
/// Note this is for use both in HTTP (<see href="https://tools.ietf.org/html/rfc6266"/>) and MIME (<see href="https://tools.ietf.org/html/rfc2183"/>).
/// </remarks>
public class ContentDispositionHeaderValue
{
    private const string FileNameString = "filename";
    private const string NameString = "name";
    private const string FileNameStarString = "filename*";
    private const string CreationDateString = "creation-date";
    private const string ModificationDateString = "modification-date";
    private const string ReadDateString = "read-date";
    private const string SizeString = "size";
    private const int MaxStackAllocSizeBytes = 256;
    private static readonly char[] QuestionMark = new char[] { '?' };
    private static readonly char[] SingleQuote = new char[] { '\'' };
    private static readonly char[] EscapeChars = new char[] { '\\', '"' };
    private static ReadOnlySpan<byte> MimePrefix => "\"=?utf-8?B?"u8;
    private static ReadOnlySpan<byte> MimeSuffix => "?=\""u8;
 
    // attr-char definition from RFC5987
    // Same as token except ( "*" / "'" / "%" )
    private static readonly SearchValues<char> Rfc5987AttrChar =
        SearchValues.Create("!#$&+-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`abcdefghijklmnopqrstuvwxyz|~");
 
    private static readonly HttpHeaderParser<ContentDispositionHeaderValue> Parser
        = new GenericHeaderParser<ContentDispositionHeaderValue>(false, GetDispositionTypeLength);
 
    // Use list instead of dictionary since we may have multiple parameters with the same name.
    private ObjectCollection<NameValueHeaderValue>? _parameters;
    private StringSegment _dispositionType;
 
    private ContentDispositionHeaderValue()
    {
        // Used by the parser to create a new instance of this type.
    }
 
    /// <summary>
    /// Initializes a new instance of <see cref="ContentDispositionHeaderValue"/>.
    /// </summary>
    /// <param name="dispositionType">A <see cref="StringSegment"/> that represents a content disposition type.</param>
    public ContentDispositionHeaderValue(StringSegment dispositionType)
    {
        CheckDispositionTypeFormat(dispositionType, "dispositionType");
        _dispositionType = dispositionType;
    }
 
    /// <summary>
    /// Gets or sets a content disposition type.
    /// </summary>
    public StringSegment DispositionType
    {
        get { return _dispositionType; }
        set
        {
            CheckDispositionTypeFormat(value, "value");
            _dispositionType = value;
        }
    }
 
    /// <summary>
    /// Gets a collection of parameters included the <c>Content-Disposition</c> header.
    /// </summary>
    public IList<NameValueHeaderValue> Parameters
    {
        get
        {
            if (_parameters == null)
            {
                _parameters = new ObjectCollection<NameValueHeaderValue>();
            }
            return _parameters;
        }
    }
 
    // Helpers to access specific parameters in the list
 
    /// <summary>
    /// Gets or sets the name of the content body part.
    /// </summary>
    public StringSegment Name
    {
        get { return GetName(NameString); }
        set { SetName(NameString, value); }
    }
 
    /// <summary>
    /// Gets or sets a value that suggests how to construct a filename for storing the message payload
    /// to be used if the entity is detached and stored in a separate file.
    /// </summary>
    public StringSegment FileName
    {
        get { return GetName(FileNameString); }
        set { SetName(FileNameString, value); }
    }
 
    /// <summary>
    /// Gets or sets a value that suggests how to construct filenames for storing message payloads
    /// to be used if the entities are detached and stored in a separate files.
    /// </summary>
    public StringSegment FileNameStar
    {
        get { return GetName(FileNameStarString); }
        set { SetName(FileNameStarString, value); }
    }
 
    /// <summary>
    /// Gets or sets the <see cref="DateTimeOffset"/> at which the file was created.
    /// </summary>
    public DateTimeOffset? CreationDate
    {
        get { return GetDate(CreationDateString); }
        set { SetDate(CreationDateString, value); }
    }
 
    /// <summary>
    /// Gets or sets the <see cref="DateTimeOffset"/> at which the file was last modified.
    /// </summary>
    public DateTimeOffset? ModificationDate
    {
        get { return GetDate(ModificationDateString); }
        set { SetDate(ModificationDateString, value); }
    }
 
    /// <summary>
    /// Gets or sets the <see cref="DateTimeOffset"/> at which the file was last read.
    /// </summary>
    public DateTimeOffset? ReadDate
    {
        get { return GetDate(ReadDateString); }
        set { SetDate(ReadDateString, value); }
    }
 
    /// <summary>
    /// Gets or sets the approximate size, in bytes, of the file.
    /// </summary>
    public long? Size
    {
        get
        {
            var sizeParameter = NameValueHeaderValue.Find(_parameters, SizeString);
            if (sizeParameter != null)
            {
                var sizeString = sizeParameter.Value;
                if (HeaderUtilities.TryParseNonNegativeInt64(sizeString, out var value))
                {
                    return value;
                }
            }
            return null;
        }
        set
        {
            var sizeParameter = NameValueHeaderValue.Find(_parameters, SizeString);
            if (value == null)
            {
                // Remove parameter
                if (sizeParameter != null)
                {
                    _parameters!.Remove(sizeParameter);
                }
            }
            else if (value < 0)
            {
                throw new ArgumentOutOfRangeException(nameof(value));
            }
            else if (sizeParameter != null)
            {
                sizeParameter.Value = value.GetValueOrDefault().ToString(CultureInfo.InvariantCulture);
            }
            else
            {
                var sizeString = value.GetValueOrDefault().ToString(CultureInfo.InvariantCulture);
                Parameters.Add(new NameValueHeaderValue(SizeString, sizeString));
            }
        }
    }
 
    /// <summary>
    /// Sets both FileName and FileNameStar using encodings appropriate for HTTP headers.
    /// </summary>
    /// <param name="fileName"></param>
    public void SetHttpFileName(StringSegment fileName)
    {
        if (!StringSegment.IsNullOrEmpty(fileName))
        {
            FileName = Sanitize(fileName);
        }
        else
        {
            FileName = fileName;
        }
        FileNameStar = fileName;
    }
 
    /// <summary>
    /// Sets the FileName parameter using encodings appropriate for MIME headers.
    /// The FileNameStar parameter is removed.
    /// </summary>
    /// <param name="fileName"></param>
    public void SetMimeFileName(StringSegment fileName)
    {
        FileNameStar = null;
        FileName = fileName;
    }
 
    /// <inheritdoc />
    public override string ToString()
    {
        return _dispositionType + NameValueHeaderValue.ToString(_parameters, ';', true);
    }
 
    /// <inheritdoc />
    public override bool Equals(object? obj)
    {
        var other = obj as ContentDispositionHeaderValue;
 
        if (other == null)
        {
            return false;
        }
 
        return _dispositionType.Equals(other._dispositionType, StringComparison.OrdinalIgnoreCase) &&
            HeaderUtilities.AreEqualCollections(_parameters, other._parameters);
    }
 
    /// <inheritdoc />
    public override int GetHashCode()
    {
        // The dispositionType string is case-insensitive.
        return StringSegmentComparer.OrdinalIgnoreCase.GetHashCode(_dispositionType) ^ NameValueHeaderValue.GetHashCode(_parameters);
    }
 
    /// <summary>
    /// Parses <paramref name="input"/> as a <see cref="ContentDispositionHeaderValue"/> value.
    /// </summary>
    /// <param name="input">The values to parse.</param>
    /// <returns>The parsed values.</returns>
    public static ContentDispositionHeaderValue Parse(StringSegment input)
    {
        var index = 0;
        return Parser.ParseValue(input, ref index)!;
    }
 
    /// <summary>
    /// Attempts to parse the specified <paramref name="input"/> as a <see cref="ContentDispositionHeaderValue"/>.
    /// </summary>
    /// <param name="input">The value to parse.</param>
    /// <param name="parsedValue">The parsed value.</param>
    /// <returns><see langword="true"/> if input is a valid <see cref="ContentDispositionHeaderValue"/>, otherwise <see langword="false"/>.</returns>
    public static bool TryParse(StringSegment input, [NotNullWhen(true)] out ContentDispositionHeaderValue? parsedValue)
    {
        var index = 0;
        return Parser.TryParseValue(input, ref index, out parsedValue!);
    }
 
    private static int GetDispositionTypeLength(StringSegment input, int startIndex, out ContentDispositionHeaderValue? parsedValue)
    {
        Contract.Requires(startIndex >= 0);
 
        parsedValue = null;
 
        if (StringSegment.IsNullOrEmpty(input) || (startIndex >= input.Length))
        {
            return 0;
        }
 
        // Caller must remove leading whitespaces. If not, we'll return 0.
        var dispositionTypeLength = GetDispositionTypeExpressionLength(input, startIndex, out var dispositionType);
 
        if (dispositionTypeLength == 0)
        {
            return 0;
        }
 
        var current = startIndex + dispositionTypeLength;
        current = current + HttpRuleParser.GetWhitespaceLength(input, current);
        var contentDispositionHeader = new ContentDispositionHeaderValue();
        contentDispositionHeader._dispositionType = dispositionType;
 
        // If we're not done and we have a parameter delimiter, then we have a list of parameters.
        if ((current < input.Length) && (input[current] == ';'))
        {
            current++; // skip delimiter.
            int parameterLength = NameValueHeaderValue.GetNameValueListLength(input, current, ';',
                contentDispositionHeader.Parameters);
 
            parsedValue = contentDispositionHeader;
            return current + parameterLength - startIndex;
        }
 
        // We have a ContentDisposition header without parameters.
        parsedValue = contentDispositionHeader;
        return current - startIndex;
    }
 
    private static int GetDispositionTypeExpressionLength(StringSegment input, int startIndex, out StringSegment dispositionType)
    {
        Contract.Requires((input.Length > 0) && (startIndex < input.Length));
 
        // This method just parses the disposition type string, it does not parse parameters.
        dispositionType = null;
 
        // Parse the disposition type, i.e. <dispositiontype> in content-disposition string
        // "<dispositiontype>; param1=value1; param2=value2"
        var typeLength = HttpRuleParser.GetTokenLength(input, startIndex);
 
        if (typeLength == 0)
        {
            return 0;
        }
 
        dispositionType = input.Subsegment(startIndex, typeLength);
        return typeLength;
    }
 
    private static void CheckDispositionTypeFormat(StringSegment dispositionType, string parameterName)
    {
        if (StringSegment.IsNullOrEmpty(dispositionType))
        {
            throw new ArgumentException("An empty string is not allowed.", parameterName);
        }
 
        // When adding values using strongly typed objects, no leading/trailing LWS (whitespaces) are allowed.
        var dispositionTypeLength = GetDispositionTypeExpressionLength(dispositionType, 0, out var tempDispositionType);
        if ((dispositionTypeLength == 0) || (tempDispositionType.Length != dispositionType.Length))
        {
            throw new FormatException(string.Format(CultureInfo.InvariantCulture,
                "Invalid disposition type '{0}'.", dispositionType));
        }
    }
 
    // Gets a parameter of the given name and attempts to extract a date.
    // Returns null if the parameter is not present or the format is incorrect.
    private DateTimeOffset? GetDate(string parameter)
    {
        var dateParameter = NameValueHeaderValue.Find(_parameters, parameter);
        if (dateParameter != null)
        {
            var dateString = dateParameter.Value;
            // Should have quotes, remove them.
            if (IsQuoted(dateString))
            {
                dateString = dateString.Subsegment(1, dateString.Length - 2);
            }
            DateTimeOffset date;
            if (HttpRuleParser.TryStringToDate(dateString, out date))
            {
                return date;
            }
        }
        return null;
    }
 
    // Add the given parameter to the list. Remove if date is null.
    private void SetDate(string parameter, DateTimeOffset? date)
    {
        var dateParameter = NameValueHeaderValue.Find(_parameters, parameter);
        if (date == null)
        {
            // Remove parameter
            if (dateParameter != null)
            {
                _parameters!.Remove(dateParameter);
            }
        }
        else
        {
            // Must always be quoted
            var dateString = HeaderUtilities.FormatDate(date.GetValueOrDefault(), quoted: true);
            if (dateParameter != null)
            {
                dateParameter.Value = dateString;
            }
            else
            {
                Parameters.Add(new NameValueHeaderValue(parameter, dateString));
            }
        }
    }
 
    // Gets a parameter of the given name and attempts to decode it if necessary.
    // Returns null if the parameter is not present or the raw value if the encoding is incorrect.
    private StringSegment GetName(string parameter)
    {
        var nameParameter = NameValueHeaderValue.Find(_parameters, parameter);
        if (nameParameter != null)
        {
            string? result;
            // filename*=utf-8'lang'%7FMyString
            if (parameter.EndsWith('*'))
            {
                if (TryDecode5987(nameParameter.Value, out result))
                {
                    return result;
                }
                return null; // Unrecognized encoding
            }
 
            // filename="=?utf-8?B?BDFSDFasdfasdc==?="
            if (TryDecodeMime(nameParameter.Value, out result))
            {
                return result;
            }
            // May not have been encoded
            return HeaderUtilities.RemoveQuotes(nameParameter.Value);
        }
        return null;
    }
 
    // Add/update the given parameter in the list, encoding if necessary.
    // Remove if value is null/Empty
    private void SetName(StringSegment parameter, StringSegment value)
    {
        var nameParameter = NameValueHeaderValue.Find(_parameters, parameter);
        if (StringSegment.IsNullOrEmpty(value))
        {
            // Remove parameter
            if (nameParameter != null)
            {
                _parameters!.Remove(nameParameter);
            }
        }
        else
        {
            StringSegment processedValue;
            if (parameter.EndsWith("*", StringComparison.Ordinal))
            {
                processedValue = Encode5987(value);
            }
            else
            {
                processedValue = EncodeAndQuoteMime(value);
            }
 
            if (nameParameter != null)
            {
                nameParameter.Value = processedValue;
            }
            else
            {
                Parameters.Add(new NameValueHeaderValue(parameter, processedValue));
            }
        }
    }
 
    // Returns input for decoding failures, as the content might not be encoded
    private StringSegment EncodeAndQuoteMime(StringSegment input)
    {
        var result = input;
        var needsQuotes = false;
        // Remove bounding quotes, they'll get re-added later
        if (IsQuoted(result))
        {
            result = result.Subsegment(1, result.Length - 2);
            needsQuotes = true;
        }
 
        if (RequiresEncoding(result))
        {
            // EncodeMimeWithQuotes will Base64 encode any quotes in the input, and surround the payload in quotes
            // so there is no need to add quotes
            needsQuotes = false;
            result = EncodeMimeWithQuotes(result); // "=?utf-8?B?asdfasdfaesdf?="
        }
        else if (!needsQuotes && HttpRuleParser.GetTokenLength(result, 0) != result.Length)
        {
            needsQuotes = true;
        }
 
        if (needsQuotes)
        {
            if (result.IndexOfAny(EscapeChars) != -1)
            {
                // '\' and '"' must be escaped in a quoted string
                result = result.ToString().Replace(@"\", @"\\").Replace(@"""", @"\""");
            }
            // Re-add quotes "value"
            result = string.Format(CultureInfo.InvariantCulture, "\"{0}\"", result);
        }
        return result;
    }
 
    // Replaces characters not suitable for HTTP headers with '_' rather than MIME encoding them.
    private static StringSegment Sanitize(StringSegment input)
    {
        var result = input;
 
        if (RequiresEncoding(result))
        {
            var builder = new StringBuilder(result.Length);
            for (int i = 0; i < result.Length; i++)
            {
                var c = result[i];
                if ((int)c >= 0x7f || (int)c < 0x20)
                {
                    c = '_'; // Replace out-of-range characters
                }
                builder.Append(c);
            }
            result = builder.ToString();
        }
 
        return result;
    }
 
    // Returns true if the value starts and ends with a quote
    private static bool IsQuoted(StringSegment value)
    {
        Contract.Assert(value != null);
 
        return value.Length > 1 && value.StartsWith("\"", StringComparison.Ordinal)
            && value.EndsWith("\"", StringComparison.Ordinal);
    }
 
    // tspecials are required to be in a quoted string.  Only non-ascii and control characters need to be encoded.
    private static bool RequiresEncoding(StringSegment input)
    {
        Contract.Assert(input != null);
 
        return input.AsSpan().IndexOfAnyExceptInRange((char)0x20, (char)0x7e) >= 0;
    }
 
    // Encode using MIME encoding
    // And adds surrounding quotes, Encoded data must always be quoted, the equals signs are invalid in tokens
    [SkipLocalsInit]
    private string EncodeMimeWithQuotes(StringSegment input)
    {
        var requiredLength = MimePrefix.Length +
            Base64.GetMaxEncodedToUtf8Length(Encoding.UTF8.GetByteCount(input.AsSpan())) +
            MimeSuffix.Length;
        byte[]? bufferFromPool = null;
        Span<byte> buffer = requiredLength <= MaxStackAllocSizeBytes
            ? stackalloc byte[MaxStackAllocSizeBytes]
            : bufferFromPool = ArrayPool<byte>.Shared.Rent(requiredLength);
        buffer = buffer[..requiredLength];
 
        MimePrefix.CopyTo(buffer);
        var bufferContent = buffer.Slice(MimePrefix.Length);
        var contentLength = Encoding.UTF8.GetBytes(input.AsSpan(), bufferContent);
 
        Base64.EncodeToUtf8InPlace(bufferContent, contentLength, out var base64ContentLength);
 
        MimeSuffix.CopyTo(bufferContent.Slice(base64ContentLength));
 
        var result = Encoding.UTF8.GetString(buffer.Slice(0, MimePrefix.Length + base64ContentLength + MimeSuffix.Length));
 
        if (bufferFromPool is not null)
        {
            ArrayPool<byte>.Shared.Return(bufferFromPool);
        }
 
        return result;
    }
 
    // Attempt to decode MIME encoded strings
    private static bool TryDecodeMime(StringSegment input, [NotNullWhen(true)] out string? output)
    {
        Contract.Assert(input != null);
 
        output = null;
        var processedInput = input;
        // Require quotes, min of "=?e?b??="
        if (!IsQuoted(processedInput) || processedInput.Length < 10)
        {
            return false;
        }
 
        var parts = processedInput.Split(QuestionMark).ToArray();
        // "=, encodingName, encodingType, encodedData, ="
        if (parts.Length != 5 || parts[0] != "\"=" || parts[4] != "=\""
            || !parts[2].Equals("b", StringComparison.OrdinalIgnoreCase))
        {
            // Not encoded.
            // This does not support multi-line encoding.
            // Only base64 encoding is supported, not quoted printable
            return false;
        }
 
        try
        {
            var encoding = Encoding.GetEncoding(parts[1].ToString());
            var bytes = Convert.FromBase64String(parts[3].ToString());
            output = encoding.GetString(bytes, 0, bytes.Length);
            return true;
        }
        catch (ArgumentException)
        {
            // Unknown encoding or bad characters
        }
        catch (FormatException)
        {
            // Bad base64 decoding
        }
        return false;
    }
 
    // Encode a string using RFC 5987 encoding
    // encoding'lang'PercentEncodedSpecials
    [SkipLocalsInit]
    private static string Encode5987(StringSegment input)
    {
        var builder = new StringBuilder("UTF-8\'\'");
        var remaining = input.AsSpan();
        while (remaining.Length > 0)
        {
            var length = remaining.IndexOfAnyExcept(Rfc5987AttrChar);
            if (length < 0)
            {
                length = remaining.Length;
            }
            builder.Append(remaining[..length]);
 
            remaining = remaining.Slice(length);
            if (remaining.Length == 0)
            {
                break;
            }
 
            length = remaining.IndexOfAny(Rfc5987AttrChar);
            if (length < 0)
            {
                length = remaining.Length;
            }
 
            for (var i = 0; i < length;)
            {
                Rune.DecodeFromUtf16(remaining.Slice(i), out Rune rune, out var runeLength);
                EncodeToUtf8Hex(rune, builder);
                i += runeLength;
            }
 
            remaining = remaining.Slice(length);
        }
 
        return builder.ToString();
    }
 
    private static readonly char[] HexUpperChars = {
                                   '0', '1', '2', '3', '4', '5', '6', '7',
                                   '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };
 
    private static void EncodeToUtf8Hex(Rune rune, StringBuilder builder)
    {
        // Inspired by https://source.dot.net/#System.Private.CoreLib/src/libraries/System.Private.CoreLib/src/System/Text/Rune.cs TryEncodeToUtf8
        var value = (uint)rune.Value;
        if (rune.IsAscii)
        {
            var byteValue = (byte)value;
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
        }
        else if (rune.Value <= 0x7FFu)
        {
            // Scalar 00000yyy yyxxxxxx -> bytes [ 110yyyyy 10xxxxxx ]
            var byteValue = (byte)((value + (0b110u << 11)) >> 6);
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
            byteValue = (byte)((value & 0x3Fu) + 0x80u);
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
        }
        else if (rune.Value <= 0xFFFFu)
        {
            // Scalar zzzzyyyy yyxxxxxx -> bytes [ 1110zzzz 10yyyyyy 10xxxxxx ]
            var byteValue = (byte)((value + (0b1110 << 16)) >> 12);
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
            byteValue = (byte)(((value & (0x3Fu << 6)) >> 6) + 0x80u);
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
            byteValue = (byte)((value & 0x3Fu) + 0x80u);
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
        }
        else
        {
            // Scalar 000uuuuu zzzzyyyy yyxxxxxx -> bytes [ 11110uuu 10uuzzzz 10yyyyyy 10xxxxxx ]
            var byteValue = (byte)((value + (0b11110 << 21)) >> 18);
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
            byteValue = (byte)(((value & (0x3Fu << 12)) >> 12) + 0x80u);
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
            byteValue = (byte)(((value & (0x3Fu << 6)) >> 6) + 0x80u);
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
            byteValue = (byte)((value & 0x3Fu) + 0x80u);
            builder.Append(CultureInfo.InvariantCulture, $"%{HexUpperChars[(byteValue & 0xf0) >> 4]}{HexUpperChars[byteValue & 0xf]}");
        }
    }
 
    // Attempt to decode using RFC 5987 encoding.
    // encoding'language'my%20string
    private static bool TryDecode5987(StringSegment input, [NotNullWhen(true)] out string? output)
    {
        output = null;
 
        var parts = input.Split(SingleQuote).ToArray();
        if (parts.Length != 3)
        {
            return false;
        }
 
        var decoded = new StringBuilder();
        byte[]? unescapedBytes = null;
        try
        {
            var encoding = Encoding.GetEncoding(parts[0].ToString());
 
            var dataString = parts[2];
            unescapedBytes = ArrayPool<byte>.Shared.Rent(dataString.Length);
            var unescapedBytesCount = 0;
            for (var index = 0; index < dataString.Length; index++)
            {
                if (IsHexEncoding(dataString, index)) // %FF
                {
                    // Unescape and cache bytes, multi-byte characters must be decoded all at once
                    unescapedBytes[unescapedBytesCount++] = HexUnescape(dataString, ref index);
                    index--; // HexUnescape did +=3; Offset the for loop's ++
                }
                else
                {
                    if (unescapedBytesCount > 0)
                    {
                        // Decode any previously cached bytes
                        decoded.Append(encoding.GetString(unescapedBytes, 0, unescapedBytesCount));
                        unescapedBytesCount = 0;
                    }
                    decoded.Append(dataString[index]); // Normal safe character
                }
            }
 
            if (unescapedBytesCount > 0)
            {
                // Decode any previously cached bytes
                decoded.Append(encoding.GetString(unescapedBytes, 0, unescapedBytesCount));
            }
        }
        catch (ArgumentException)
        {
            return false; // Unknown encoding or bad characters
        }
        finally
        {
            if (unescapedBytes != null)
            {
                ArrayPool<byte>.Shared.Return(unescapedBytes);
            }
        }
 
        output = decoded.ToString();
        return true;
    }
 
    private static bool IsHexEncoding(StringSegment pattern, int index)
    {
        if ((pattern.Length - index) < 3)
        {
            return false;
        }
        if ((pattern[index] == '%') && IsEscapedAscii(pattern[index + 1], pattern[index + 2]))
        {
            return true;
        }
        return false;
    }
 
    private static bool IsEscapedAscii(char digit, char next)
    {
        if (!(((digit >= '0') && (digit <= '9'))
            || ((digit >= 'A') && (digit <= 'F'))
            || ((digit >= 'a') && (digit <= 'f'))))
        {
            return false;
        }
 
        if (!(((next >= '0') && (next <= '9'))
            || ((next >= 'A') && (next <= 'F'))
            || ((next >= 'a') && (next <= 'f'))))
        {
            return false;
        }
 
        return true;
    }
 
    private static byte HexUnescape(StringSegment pattern, ref int index)
    {
        if ((index < 0) || (index >= pattern.Length))
        {
            throw new ArgumentOutOfRangeException(nameof(index));
        }
        if ((pattern[index] == '%')
            && (pattern.Length - index >= 3))
        {
            var ret = UnEscapeAscii(pattern[index + 1], pattern[index + 2]);
            index += 3;
            return ret;
        }
        return (byte)pattern[index++];
    }
 
    internal static byte UnEscapeAscii(char digit, char next)
    {
        if (!(((digit >= '0') && (digit <= '9'))
            || ((digit >= 'A') && (digit <= 'F'))
            || ((digit >= 'a') && (digit <= 'f'))))
        {
            throw new ArgumentOutOfRangeException(nameof(digit));
        }
 
        var res = (digit <= '9')
            ? ((int)digit - (int)'0')
            : (((digit <= 'F')
            ? ((int)digit - (int)'A')
            : ((int)digit - (int)'a'))
               + 10);
 
        if (!(((next >= '0') && (next <= '9'))
            || ((next >= 'A') && (next <= 'F'))
            || ((next >= 'a') && (next <= 'f'))))
        {
            throw new ArgumentOutOfRangeException(nameof(next));
        }
 
        return (byte)((res << 4) + ((next <= '9')
                ? ((int)next - (int)'0')
                : (((next <= 'F')
                    ? ((int)next - (int)'A')
                    : ((int)next - (int)'a'))
                   + 10)));
    }
}