File: HeaderUtilities.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.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.Globalization;
using Microsoft.Extensions.Primitives;
 
namespace Microsoft.Net.Http.Headers;
 
/// <summary>
/// Provides utilities to parse and modify HTTP header values.
/// </summary>
public static class HeaderUtilities
{
    private const int _qualityValueMaxCharCount = 10; // Little bit more permissive than RFC7231 5.3.1
    private const string QualityName = "q";
    internal const string BytesUnit = "bytes";
 
    internal static void SetQuality(IList<NameValueHeaderValue> parameters, double? value)
    {
        var qualityParameter = NameValueHeaderValue.Find(parameters, QualityName);
        if (value.HasValue)
        {
            // Note that even if we check the value here, we can't prevent a user from adding an invalid quality
            // value using Parameters.Add(). Even if we would prevent the user from adding an invalid value
            // using Parameters.Add() they could always add invalid values using HttpHeaders.AddWithoutValidation().
            // So this check is really for convenience to show users that they're trying to add an invalid
            // value.
            if ((value < 0) || (value > 1))
            {
                throw new ArgumentOutOfRangeException(nameof(value));
            }
 
            var qualityString = ((double)value).ToString("0.0##", NumberFormatInfo.InvariantInfo);
            if (qualityParameter != null)
            {
                qualityParameter.Value = qualityString;
            }
            else
            {
                parameters.Add(new NameValueHeaderValue(QualityName, qualityString));
            }
        }
        else
        {
            // Remove quality parameter
            if (qualityParameter != null)
            {
                parameters.Remove(qualityParameter);
            }
        }
    }
 
    internal static double? GetQuality(IList<NameValueHeaderValue>? parameters)
    {
        var qualityParameter = NameValueHeaderValue.Find(parameters, QualityName);
        if (qualityParameter != null)
        {
            // Note that the RFC requires decimal '.' regardless of the culture. I.e. using ',' as decimal
            // separator is considered invalid (even if the current culture would allow it).
            if (TryParseQualityDouble(qualityParameter.Value, 0, out var qualityValue, out _))
            {
                return qualityValue;
            }
        }
        return null;
    }
 
    internal static void CheckValidToken(StringSegment value, string parameterName)
    {
        if (StringSegment.IsNullOrEmpty(value))
        {
            throw new ArgumentException("An empty string is not allowed.", parameterName);
        }
 
        if (HttpRuleParser.GetTokenLength(value, 0) != value.Length)
        {
            throw new FormatException(string.Format(CultureInfo.InvariantCulture, "Invalid token '{0}'.", value));
        }
    }
 
    internal static bool AreEqualCollections<T>(ICollection<T>? x, ICollection<T>? y)
    {
        return AreEqualCollections(x, y, null);
    }
 
    internal static bool AreEqualCollections<T>(ICollection<T>? x, ICollection<T>? y, IEqualityComparer<T>? comparer)
    {
        if (x == null)
        {
            return (y == null) || (y.Count == 0);
        }
 
        if (y == null)
        {
            return (x.Count == 0);
        }
 
        if (x.Count != y.Count)
        {
            return false;
        }
 
        if (x.Count == 0)
        {
            return true;
        }
 
        // We have two unordered lists. So comparison is an O(n*m) operation which is expensive. Usually
        // headers have 1-2 parameters (if any), so this comparison shouldn't be too expensive.
        var alreadyFound = new bool[x.Count];
        var i = 0;
        foreach (var xItem in x)
        {
            Contract.Assert(xItem != null);
 
            i = 0;
            var found = false;
            foreach (var yItem in y)
            {
                if (!alreadyFound[i])
                {
                    if (((comparer == null) && xItem.Equals(yItem)) ||
                        ((comparer != null) && comparer.Equals(xItem, yItem)))
                    {
                        alreadyFound[i] = true;
                        found = true;
                        break;
                    }
                }
                i++;
            }
 
            if (!found)
            {
                return false;
            }
        }
 
        // Since we never re-use a "found" value in 'y', we expected 'alreadyFound' to have all fields set to 'true'.
        // Otherwise the two collections can't be equal and we should not get here.
        Contract.Assert(Contract.ForAll(alreadyFound, value => { return value; }),
            "Expected all values in 'alreadyFound' to be true since collections are considered equal.");
 
        return true;
    }
 
    internal static int GetNextNonEmptyOrWhitespaceIndex(
        StringSegment input,
        int startIndex,
        bool skipEmptyValues,
        out bool separatorFound)
    {
        Contract.Requires(startIndex <= input.Length); // it's OK if index == value.Length.
 
        separatorFound = false;
        var current = startIndex + HttpRuleParser.GetWhitespaceLength(input, startIndex);
 
        if ((current == input.Length) || (input[current] != ','))
        {
            return current;
        }
 
        // If we have a separator, skip the separator and all following whitespaces. If we support
        // empty values, continue until the current character is neither a separator nor a whitespace.
        separatorFound = true;
        current++; // skip delimiter.
        current = current + HttpRuleParser.GetWhitespaceLength(input, current);
 
        if (skipEmptyValues)
        {
            while ((current < input.Length) && (input[current] == ','))
            {
                current++; // skip delimiter.
                current = current + HttpRuleParser.GetWhitespaceLength(input, current);
            }
        }
 
        return current;
    }
 
    private static int AdvanceCacheDirectiveIndex(int current, string headerValue)
    {
        // Skip until the next potential name
        current += HttpRuleParser.GetWhitespaceLength(headerValue, current);
 
        // Skip the value if present
        if (current < headerValue.Length && headerValue[current] == '=')
        {
            current++; // skip '='
            current += NameValueHeaderValue.GetValueLength(headerValue, current);
        }
 
        // Find the next delimiter
        current = headerValue.IndexOf(',', current);
 
        if (current == -1)
        {
            // If no delimiter found, skip to the end
            return headerValue.Length;
        }
 
        current++; // skip ','
        current += HttpRuleParser.GetWhitespaceLength(headerValue, current);
 
        return current;
    }
 
    /// <summary>
    /// Try to find a target header value among the set of given header values and parse it as a
    /// <see cref="TimeSpan"/>.
    /// </summary>
    /// <param name="headerValues">
    /// The <see cref="StringValues"/> containing the set of header values to search.
    /// </param>
    /// <param name="targetValue">
    /// The target header value to look for.
    /// </param>
    /// <param name="value">
    /// When this method returns, contains the parsed <see cref="TimeSpan"/>, if the parsing succeeded, or
    /// null if the parsing failed. The conversion fails if the <paramref name="targetValue"/> was not
    /// found or could not be parsed as a <see cref="TimeSpan"/>. This parameter is passed uninitialized;
    /// any value originally supplied in result will be overwritten.
    /// </param>
    /// <returns>
    /// <see langword="true" /> if <paramref name="targetValue"/> is found and successfully parsed; otherwise,
    /// <see langword="false" />.
    /// </returns>
    // e.g. { "headerValue=10, targetHeaderValue=30" }
    public static bool TryParseSeconds(StringValues headerValues, string targetValue, [NotNullWhen(true)] out TimeSpan? value)
    {
        if (StringValues.IsNullOrEmpty(headerValues) || string.IsNullOrEmpty(targetValue))
        {
            value = null;
            return false;
        }
 
        for (var i = 0; i < headerValues.Count; i++)
        {
            var segment = headerValues[i] ?? string.Empty;
 
            // Trim leading white space
            var current = HttpRuleParser.GetWhitespaceLength(segment, 0);
 
            while (current < segment.Length)
            {
                long seconds;
                var initial = current;
                var tokenLength = HttpRuleParser.GetTokenLength(headerValues[i], current);
                if (tokenLength == targetValue.Length
                    && string.Compare(headerValues[i], current, targetValue, 0, tokenLength, StringComparison.OrdinalIgnoreCase) == 0
                    && TryParseNonNegativeInt64FromHeaderValue(current + tokenLength, segment, out seconds))
                {
                    // Token matches target value and seconds were parsed
                    value = TimeSpan.FromSeconds(seconds);
                    return true;
                }
 
                current = AdvanceCacheDirectiveIndex(current + tokenLength, segment);
 
                // Ensure index was advanced
                if (current <= initial)
                {
                    Debug.Assert(false, $"Index '{nameof(current)}' not advanced, this is a bug.");
                    value = null;
                    return false;
                }
            }
        }
        value = null;
        return false;
    }
 
    /// <summary>
    /// Check if a target directive exists among the set of given cache control directives.
    /// </summary>
    /// <param name="cacheControlDirectives">
    /// The <see cref="StringValues"/> containing the set of cache control directives.
    /// </param>
    /// <param name="targetDirectives">
    /// The target cache control directives to look for.
    /// </param>
    /// <returns>
    /// <see langword="true" /> if <paramref name="targetDirectives"/> is contained in <paramref name="cacheControlDirectives"/>;
    /// otherwise, <see langword="false" />.
    /// </returns>
    public static bool ContainsCacheDirective(StringValues cacheControlDirectives, string targetDirectives)
    {
        if (StringValues.IsNullOrEmpty(cacheControlDirectives) || string.IsNullOrEmpty(targetDirectives))
        {
            return false;
        }
 
        for (var i = 0; i < cacheControlDirectives.Count; i++)
        {
            var segment = cacheControlDirectives[i] ?? string.Empty;
 
            // Trim leading white space
            var current = HttpRuleParser.GetWhitespaceLength(segment, 0);
 
            while (current < segment.Length)
            {
                var initial = current;
 
                var tokenLength = HttpRuleParser.GetTokenLength(segment, current);
                if (tokenLength == targetDirectives.Length
                    && string.Compare(segment, current, targetDirectives, 0, tokenLength, StringComparison.OrdinalIgnoreCase) == 0)
                {
                    // Token matches target value
                    return true;
                }
 
                current = AdvanceCacheDirectiveIndex(current + tokenLength, segment);
 
                // Ensure index was advanced
                if (current <= initial)
                {
                    Debug.Assert(false, $"Index '{nameof(current)}' not advanced, this is a bug.");
                    return false;
                }
            }
        }
 
        return false;
    }
 
    private static bool TryParseNonNegativeInt64FromHeaderValue(int startIndex, string headerValue, out long result)
    {
        // Trim leading whitespace
        startIndex += HttpRuleParser.GetWhitespaceLength(headerValue, startIndex);
 
        // Match and skip '=', it also can't be the last character in the headerValue
        if (startIndex >= headerValue.Length - 1 || headerValue[startIndex] != '=')
        {
            result = 0;
            return false;
        }
        startIndex++;
 
        // Trim trailing whitespace
        startIndex += HttpRuleParser.GetWhitespaceLength(headerValue, startIndex);
 
        // Try parse the number
        if (TryParseNonNegativeInt64(new StringSegment(headerValue, startIndex, HttpRuleParser.GetNumberLength(headerValue, startIndex, false)), out result))
        {
            return true;
        }
 
        result = 0;
        return false;
    }
 
    /// <summary>
    /// Try to convert a string representation of a positive number to its 64-bit signed integer equivalent.
    /// A return value indicates whether the conversion succeeded or failed.
    /// </summary>
    /// <param name="value">
    /// A string containing a number to convert.
    /// </param>
    /// <param name="result">
    /// When this method returns, contains the 64-bit signed integer value equivalent of the number contained
    /// in the string, if the conversion succeeded, or zero if the conversion failed. The conversion fails if
    /// the string is null or String.Empty, is not of the correct format, is negative, or represents a number
    /// greater than Int64.MaxValue. This parameter is passed uninitialized; any value originally supplied in
    /// result will be overwritten.
    /// </param>
    /// <returns><see langword="true" /> if parsing succeeded; otherwise, <see langword="false" />.</returns>
    public static bool TryParseNonNegativeInt32(StringSegment value, out int result)
    {
        if (string.IsNullOrEmpty(value.Buffer) || value.Length == 0)
        {
            result = 0;
            return false;
        }
 
        return int.TryParse(value.AsSpan(), NumberStyles.None, NumberFormatInfo.InvariantInfo, out result);
    }
 
    /// <summary>
    /// Try to convert a <see cref="StringSegment"/> representation of a positive number to its 64-bit signed
    /// integer equivalent. A return value indicates whether the conversion succeeded or failed.
    /// </summary>
    /// <param name="value">
    /// A <see cref="StringSegment"/> containing a number to convert.
    /// </param>
    /// <param name="result">
    /// When this method returns, contains the 64-bit signed integer value equivalent of the number contained
    /// in the string, if the conversion succeeded, or zero if the conversion failed. The conversion fails if
    /// the <see cref="StringSegment"/> is null or String.Empty, is not of the correct format, is negative, or
    /// represents a number greater than Int64.MaxValue. This parameter is passed uninitialized; any value
    /// originally supplied in result will be overwritten.
    /// </param>
    /// <returns><see langword="true" /> if parsing succeeded; otherwise, <see langword="false" />.</returns>
    public static bool TryParseNonNegativeInt64(StringSegment value, out long result)
    {
        if (string.IsNullOrEmpty(value.Buffer) || value.Length == 0)
        {
            result = 0;
            return false;
        }
        return long.TryParse(value.AsSpan(), NumberStyles.None, NumberFormatInfo.InvariantInfo, out result);
    }
 
    // Strict and fast RFC9110 12.4.2 Quality value parser (and without memory allocation)
    // See https://tools.ietf.org/html/rfc9110#section-12.4.2
    // Check is made to verify if the value is between 0 and 1 (and it returns False if the check fails).
    internal static bool TryParseQualityDouble(StringSegment input, int startIndex, out double quality, out int length)
    {
        quality = 0;
        length = 0;
 
        var inputLength = input.Length;
        var current = startIndex;
        var limit = startIndex + _qualityValueMaxCharCount;
        var decPart = 0;
        var decPow = 1;
 
        if (current >= inputLength)
        {
            return false;
        }
 
        var ch = input[current];
 
        int intPart;
        if (ch >= '0' && ch <= '1') // Only values between 0 and 1 are accepted, according to RFC
        {
            intPart = ch - '0';
            current++;
        }
        else
        {
            // The RFC doesn't allow decimal values starting with dot. I.e. value ".123" is invalid. It must be in the
            // form "0.123".
            return false;
        }
 
        if (current < inputLength)
        {
            ch = input[current];
 
            if (ch >= '0' && ch <= '9')
            {
                // The RFC accepts only one digit before the dot
                return false;
            }
 
            if (ch == '.')
            {
                current++;
 
                while (current < inputLength)
                {
                    ch = input[current];
                    if (ch >= '0' && ch <= '9')
                    {
                        if (current >= limit)
                        {
                            return false;
                        }
 
                        decPart = decPart * 10 + ch - '0';
                        decPow *= 10;
                        current++;
                    }
                    else
                    {
                        break;
                    }
                }
            }
        }
 
        if (decPart != 0)
        {
            quality = intPart + decPart / (double)decPow;
        }
        else
        {
            quality = intPart;
        }
 
        if (quality > 1)
        {
            // reset quality
            quality = 0;
            return false;
        }
 
        length = current - startIndex;
        return true;
    }
 
    /// <summary>
    /// Converts the non-negative 64-bit numeric value to its equivalent string representation.
    /// </summary>
    /// <param name="value">
    /// The number to convert.
    /// </param>
    /// <returns>
    /// The string representation of the value of this instance, consisting of a sequence of digits ranging from 0 to 9 with no leading zeroes.
    /// </returns>
    public static string FormatNonNegativeInt64(long value)
    {
        if (value < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(value), value, "The value to be formatted must be non-negative.");
        }
 
        if (value == 0)
        {
            return "0";
        }
 
        return ((ulong)value).ToString(NumberFormatInfo.InvariantInfo);
    }
 
    /// <summary>
    /// Converts the 64-bit numeric value to its equivalent string representation.
    /// </summary>
    /// <param name="value">
    /// The number to convert.
    /// </param>
    /// <returns>
    /// The string representation of the value of this instance, consisting of a sequence of digits ranging from 0 to 9 with no leading zeroes.
    /// In case of negative numeric value it will have a leading minus sign.
    /// </returns>
    internal static string FormatInt64(long value)
    {
        return value switch
        {
            0 => "0",
            1 => "1",
            -1 => "-1",
            _ => value.ToString(NumberFormatInfo.InvariantInfo)
        };
    }
 
    /// <summary>
    ///Attempts to parse the specified <paramref name="input"/> as a <see cref="DateTimeOffset"/> value.
    /// </summary>
    /// <param name="input">The input value.</param>
    /// <param name="result">The parsed value.</param>
    /// <returns>
    /// <see langword="true" /> if <paramref name="input"/> can be parsed as a date, otherwise <see langword="false" />.
    /// </returns>
    public static bool TryParseDate(StringSegment input, out DateTimeOffset result)
    {
        return HttpRuleParser.TryStringToDate(input, out result);
    }
 
    /// <summary>
    /// Formats the <paramref name="dateTime"/> using the RFC1123 format specifier.
    /// </summary>
    /// <param name="dateTime">The date to format.</param>
    /// <returns>The formatted date.</returns>
    public static string FormatDate(DateTimeOffset dateTime)
    {
        return FormatDate(dateTime, quoted: false);
    }
 
    /// <summary>
    /// Formats the <paramref name="dateTime"/> using the RFC1123 format specifier and optionally quotes it.
    /// </summary>
    /// <param name="dateTime">The date to format.</param>
    /// <param name="quoted">Determines if the formatted date should be quoted.</param>
    /// <returns>The formatted date.</returns>
    public static string FormatDate(DateTimeOffset dateTime, bool quoted)
    {
        if (quoted)
        {
            return string.Create(31, dateTime, (span, dt) =>
            {
                span[0] = span[30] = '"';
                dt.TryFormat(span.Slice(1), out _, "r", CultureInfo.InvariantCulture);
            });
        }
 
        return dateTime.ToString("r", CultureInfo.InvariantCulture);
    }
 
    /// <summary>
    /// Removes quotes from the specified <paramref name="input"/> if quoted.
    /// </summary>
    /// <param name="input">The input to remove quotes from.</param>
    /// <returns>The value without quotes.</returns>
    public static StringSegment RemoveQuotes(StringSegment input)
    {
        if (IsQuoted(input))
        {
            input = input.Subsegment(1, input.Length - 2);
        }
        return input;
    }
 
    /// <summary>
    /// Determines if the specified <paramref name="input"/> is quoted.
    /// </summary>
    /// <param name="input">The value to inspect.</param>
    /// <returns><see langword="true"/> if the value is quoted, otherwise <see langword="false"/>.</returns>
    public static bool IsQuoted(StringSegment input)
    {
        return !StringSegment.IsNullOrEmpty(input) && input.Length >= 2 && input[0] == '"' && input[input.Length - 1] == '"';
    }
 
    /// <summary>
    /// Given a quoted-string as defined by <see href="https://tools.ietf.org/html/rfc7230#section-3.2.6">the RFC specification</see>,
    /// removes quotes and unescapes backslashes and quotes. This assumes that the input is a valid quoted-string.
    /// </summary>
    /// <param name="input">The quoted-string to be unescaped.</param>
    /// <returns>An unescaped version of the quoted-string.</returns>
    public static StringSegment UnescapeAsQuotedString(StringSegment input)
    {
        input = RemoveQuotes(input);
 
        // First pass to calculate the size of the string
        var backSlashCount = CountBackslashesForDecodingQuotedString(input);
 
        if (backSlashCount == 0)
        {
            return input;
        }
 
        return string.Create(input.Length - backSlashCount, input, (span, segment) =>
        {
            var spanIndex = 0;
            var spanLength = span.Length;
            for (var i = 0; i < segment.Length && (uint)spanIndex < (uint)spanLength; i++)
            {
                int nextIndex = i + 1;
                if ((uint)nextIndex < (uint)segment.Length && segment[i] == '\\')
                {
                    // If there is an backslash character as the last character in the string,
                    // we will assume that it should be included literally in the unescaped string
                    // Ex: "hello\\" => "hello\\"
                    // Also, if a sender adds a quoted pair like '\\''n',
                    // we will assume it is over escaping and just add a n to the string.
                    // Ex: "he\\llo" => "hello"
                    span[spanIndex] = segment[nextIndex];
                    i++;
                }
                else
                {
                    span[spanIndex] = segment[i];
                }
 
                spanIndex++;
            }
        });
    }
 
    private static int CountBackslashesForDecodingQuotedString(StringSegment input)
    {
        var numberBackSlashes = 0;
        for (var i = 0; i < input.Length; i++)
        {
            if (i < input.Length - 1 && input[i] == '\\')
            {
                // If there is an backslash character as the last character in the string,
                // we will assume that it should be included literally in the unescaped string
                // Ex: "hello\\" => "hello\\"
                // Also, if a sender adds a quoted pair like '\\''n',
                // we will assume it is over escaping and just add a n to the string.
                // Ex: "he\\llo" => "hello"
                if (input[i + 1] == '\\')
                {
                    // Only count escaped backslashes once
                    i++;
                }
                numberBackSlashes++;
            }
        }
        return numberBackSlashes;
    }
 
    /// <summary>
    /// Escapes a <see cref="StringSegment"/> as a quoted-string, which is defined by
    /// <see href="https://tools.ietf.org/html/rfc7230#section-3.2.6">the RFC specification</see>.
    /// </summary>
    /// <remarks>
    /// This will add a backslash before each backslash and quote and add quotes
    /// around the input. Assumes that the input does not have quotes around it,
    /// as this method will add them. Throws if the input contains any invalid escape characters,
    /// as defined by rfc7230.
    /// </remarks>
    /// <param name="input">The input to be escaped.</param>
    /// <returns>An escaped version of the quoted-string.</returns>
    public static StringSegment EscapeAsQuotedString(StringSegment input)
    {
        // By calling this, we know that the string requires quotes around it to be a valid token.
        var backSlashCount = CountAndCheckCharactersNeedingBackslashesWhenEncoding(input);
 
        // 2 for quotes
        return string.Create(input.Length + backSlashCount + 2, input, (span, segment) =>
        {
            // Helps to elide the bounds check for span[0]
            span[span.Length - 1] = span[0] = '\"';
 
            var spanIndex = 1;
            for (var i = 0; i < segment.Length; i++)
            {
                if (segment[i] == '\\' || segment[i] == '\"')
                {
                    span[spanIndex++] = '\\';
                }
                else if ((segment[i] <= 0x1F || segment[i] == 0x7F) && segment[i] != 0x09)
                {
                    // Control characters are not allowed in a quoted-string, which include all characters
                    // below 0x1F (except for 0x09 (TAB)) and 0x7F.
                    throw new FormatException($"Invalid control character '{segment[i]}' in input.");
                }
                span[spanIndex++] = segment[i];
            }
        });
    }
 
    private static int CountAndCheckCharactersNeedingBackslashesWhenEncoding(StringSegment input)
    {
        var numberOfCharactersNeedingEscaping = 0;
        for (var i = 0; i < input.Length; i++)
        {
            if (input[i] == '\\' || input[i] == '\"')
            {
                numberOfCharactersNeedingEscaping++;
            }
        }
        return numberOfCharactersNeedingEscaping;
    }
 
    internal static void ThrowIfReadOnly(bool isReadOnly)
    {
        if (isReadOnly)
        {
            throw new InvalidOperationException("The object cannot be modified because it is read-only.");
        }
    }
}