diff --git a/src/Http/Headers/src/CookieHeaderParser.cs b/src/Http/Headers/src/CookieHeaderParser.cs index b77263bc18f7..cb05706921cc 100644 --- a/src/Http/Headers/src/CookieHeaderParser.cs +++ b/src/Http/Headers/src/CookieHeaderParser.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Diagnostics.Contracts; using Microsoft.Extensions.Primitives; namespace Microsoft.Net.Http.Headers @@ -13,83 +12,24 @@ internal CookieHeaderParser(bool supportsMultipleValues) { } - public override bool TryParseValue(StringSegment value, ref int index, out CookieHeaderValue? parsedValue) + public override bool TryParseValue(StringSegment value, ref int index, out CookieHeaderValue? cookieValue) { - parsedValue = null; + cookieValue = null; - // If multiple values are supported (i.e. list of values), then accept an empty string: The header may - // be added multiple times to the request/response message. E.g. - // Accept: text/xml; q=1 - // Accept: - // Accept: text/plain; q=0.2 - if (StringSegment.IsNullOrEmpty(value) || (index == value.Length)) - { - return SupportsMultipleValues; - } - - var current = GetNextNonEmptyOrWhitespaceIndex(value, index, SupportsMultipleValues, out bool separatorFound); - - if (separatorFound && !SupportsMultipleValues) - { - return false; // leading separators not allowed if we don't support multiple values. - } - - if (current == value.Length) - { - if (SupportsMultipleValues) - { - index = current; - } - return SupportsMultipleValues; - } - - if (!CookieHeaderValue.TryGetCookieLength(value, ref current, out var result)) + if (!CookieHeaderParserShared.TryParseValue(value, ref index, SupportsMultipleValues, out var parsedName, out var parsedValue)) { return false; } - current = GetNextNonEmptyOrWhitespaceIndex(value, current, SupportsMultipleValues, out separatorFound); - - // If we support multiple values and we've not reached the end of the string, then we must have a separator. - if ((separatorFound && !SupportsMultipleValues) || (!separatorFound && (current < value.Length))) + if (parsedName == null || parsedValue == null) { - return false; + // Successfully parsed, but no values. + return true; } - index = current; - parsedValue = result; - return true; - } - - private 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] != ',' && 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) - { - // Most headers only split on ',', but cookies primarily split on ';' - while ((current < input.Length) && ((input[current] == ',') || (input[current] == ';'))) - { - current++; // skip delimiter. - current = current + HttpRuleParser.GetWhitespaceLength(input, current); - } - } + cookieValue = new CookieHeaderValue(parsedName.Value, parsedValue.Value); - return current; + return true; } } } diff --git a/src/Http/Headers/src/CookieHeaderValue.cs b/src/Http/Headers/src/CookieHeaderValue.cs index f566b0082d96..c8b6ab5505c0 100644 --- a/src/Http/Headers/src/CookieHeaderValue.cs +++ b/src/Http/Headers/src/CookieHeaderValue.cs @@ -165,121 +165,6 @@ public static bool TryParseStrictList(IList? inputs, [NotNullWhen(true)] return MultipleValueParser.TryParseStrictValues(inputs, out parsedValues); } - // name=value; name="value" - internal static bool TryGetCookieLength(StringSegment input, ref int offset, [NotNullWhen(true)] out CookieHeaderValue? parsedValue) - { - Contract.Requires(offset >= 0); - - parsedValue = null; - - if (StringSegment.IsNullOrEmpty(input) || (offset >= input.Length)) - { - return false; - } - - var result = new CookieHeaderValue(); - - // The caller should have already consumed any leading whitespace, commas, etc.. - - // Name=value; - - // Name - var itemLength = HttpRuleParser.GetTokenLength(input, offset); - if (itemLength == 0) - { - return false; - } - result._name = input.Subsegment(offset, itemLength); - offset += itemLength; - - // = (no spaces) - if (!ReadEqualsSign(input, ref offset)) - { - return false; - } - - // value or "quoted value" - // The value may be empty - result._value = GetCookieValue(input, ref offset); - - parsedValue = result; - return true; - } - - // cookie-value = *cookie-octet / ( DQUOTE* cookie-octet DQUOTE ) - // cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E - // ; US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon, and backslash - internal static StringSegment GetCookieValue(StringSegment input, ref int offset) - { - Contract.Requires(offset >= 0); - Contract.Ensures((Contract.Result() >= 0) && (Contract.Result() <= (input.Length - offset))); - - var startIndex = offset; - - if (offset >= input.Length) - { - return StringSegment.Empty; - } - var inQuotes = false; - - if (input[offset] == '"') - { - inQuotes = true; - offset++; - } - - while (offset < input.Length) - { - var c = input[offset]; - if (!IsCookieValueChar(c)) - { - break; - } - - offset++; - } - - if (inQuotes) - { - if (offset == input.Length || input[offset] != '"') - { - // Missing final quote - return StringSegment.Empty; - } - offset++; - } - - int length = offset - startIndex; - if (offset > startIndex) - { - return input.Subsegment(startIndex, length); - } - - return StringSegment.Empty; - } - - private static bool ReadEqualsSign(StringSegment input, ref int offset) - { - // = (no spaces) - if (offset >= input.Length || input[offset] != '=') - { - return false; - } - offset++; - return true; - } - - // cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E - // ; US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon, and backslash - private static bool IsCookieValueChar(char c) - { - if (c < 0x21 || c > 0x7E) - { - return false; - } - return !(c == '"' || c == ',' || c == ';' || c == '\\'); - } - internal static void CheckNameFormat(StringSegment name, string parameterName) { if (name == null) @@ -301,7 +186,7 @@ internal static void CheckValueFormat(StringSegment value, string parameterName) } var offset = 0; - var result = GetCookieValue(value, ref offset); + var result = CookieHeaderParserShared.GetCookieValue(value, ref offset); if (result.Length != value.Length) { throw new ArgumentException("Invalid cookie value: " + value, parameterName); diff --git a/src/Http/Headers/src/Microsoft.Net.Http.Headers.csproj b/src/Http/Headers/src/Microsoft.Net.Http.Headers.csproj index 1bd100563caa..c5036c490f5a 100644 --- a/src/Http/Headers/src/Microsoft.Net.Http.Headers.csproj +++ b/src/Http/Headers/src/Microsoft.Net.Http.Headers.csproj @@ -13,6 +13,9 @@ + + + diff --git a/src/Http/Headers/src/SetCookieHeaderValue.cs b/src/Http/Headers/src/SetCookieHeaderValue.cs index b0874a7bbb62..11a71634c0cd 100644 --- a/src/Http/Headers/src/SetCookieHeaderValue.cs +++ b/src/Http/Headers/src/SetCookieHeaderValue.cs @@ -494,7 +494,7 @@ private static int GetSetCookieLength(StringSegment input, int startIndex, out S // value or "quoted value" // The value may be empty - result._value = CookieHeaderValue.GetCookieValue(input, ref offset); + result._value = CookieHeaderParserShared.GetCookieValue(input, ref offset); // *(';' SP cookie-av) while (offset < input.Length) diff --git a/src/Http/Http/perf/Microbenchmarks/RequestCookieCollectionBenchmarks.cs b/src/Http/Http/perf/Microbenchmarks/RequestCookieCollectionBenchmarks.cs new file mode 100644 index 000000000000..035a367b1324 --- /dev/null +++ b/src/Http/Http/perf/Microbenchmarks/RequestCookieCollectionBenchmarks.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Http +{ + public class RequestCookieCollectionBenchmarks + { + private StringValues _cookie; + + [IterationSetup] + public void Setup() + { + _cookie = ".AspNetCore.Cookies=CfDJ8BAklVa9EYREk8_ipRUUYJYhRsleKr485k18s_q5XD6vcRJ-DtowUuLCwwMiY728zRZ3rVFY3DEcXDAQUOTtg1e4tkSIVmYLX38Q6mqdFFyw-8dksclDywe9vnN84cEWvfV0wP3EgOsJGHaND7kTJ47gr7Pc1tLHWOm4Pe7Q1vrT9EkcTMr1Wts3aptBl3bdOLLqjmSdgk-OI7qG7uQGz1OGdnSer6-KLUPBcfXblzs4YCjvwu3bGnM42xLGtkZNIF8izPpyqKkIf7ec6O6LEHMp4gcq86PGHCXHn5NKuNSD"; + } + + [Benchmark] + public void Parse_TypicalCookie() + { + RequestCookieCollection.Parse(_cookie); + } + } +} diff --git a/src/Http/Http/perf/Microbenchmarks/RouteValueDictionaryBenchmark.cs b/src/Http/Http/perf/Microbenchmarks/RouteValueDictionaryBenchmark.cs index 2dfc36afa4cc..76a8659c2a48 100644 --- a/src/Http/Http/perf/Microbenchmarks/RouteValueDictionaryBenchmark.cs +++ b/src/Http/Http/perf/Microbenchmarks/RouteValueDictionaryBenchmark.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; diff --git a/src/Http/Http/src/Features/RequestCookiesFeature.cs b/src/Http/Http/src/Features/RequestCookiesFeature.cs index 5abdfc906375..16c0758fbaf8 100644 --- a/src/Http/Http/src/Features/RequestCookiesFeature.cs +++ b/src/Http/Http/src/Features/RequestCookiesFeature.cs @@ -75,7 +75,7 @@ public IRequestCookieCollection Cookies if (_parsedValues == null || _original != current) { _original = current; - _parsedValues = RequestCookieCollection.Parse(current.ToArray()); + _parsedValues = RequestCookieCollection.Parse(current); } return _parsedValues; diff --git a/src/Http/Http/src/Internal/RequestCookieCollection.cs b/src/Http/Http/src/Internal/RequestCookieCollection.cs index d6542a23d76c..f7ae17212887 100644 --- a/src/Http/Http/src/Internal/RequestCookieCollection.cs +++ b/src/Http/Http/src/Internal/RequestCookieCollection.cs @@ -5,6 +5,7 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Http @@ -56,33 +57,25 @@ public string? this[string key] } } - public static RequestCookieCollection Parse(IList values) - => ParseInternal(values, AppContext.TryGetSwitch(ResponseCookies.EnableCookieNameEncoding, out var enabled) && enabled); + public static RequestCookieCollection Parse(StringValues values) + => ParseInternal(values, AppContext.TryGetSwitch(ResponseCookies.EnableCookieNameEncoding, out var enabled) && enabled); - internal static RequestCookieCollection ParseInternal(IList values, bool enableCookieNameEncoding) + internal static RequestCookieCollection ParseInternal(StringValues values, bool enableCookieNameEncoding) { if (values.Count == 0) { return Empty; } + var collection = new RequestCookieCollection(values.Count); + var store = collection.Store!; - if (CookieHeaderValue.TryParseList(values, out var cookies)) + if (CookieHeaderParserShared.TryParseValues(values, store, enableCookieNameEncoding, supportsMultipleValues: true)) { - if (cookies.Count == 0) + if (store.Count == 0) { return Empty; } - var collection = new RequestCookieCollection(cookies.Count); - var store = collection.Store!; - for (var i = 0; i < cookies.Count; i++) - { - var cookie = cookies[i]; - var name = enableCookieNameEncoding ? Uri.UnescapeDataString(cookie.Name.Value) : cookie.Name.Value; - var value = Uri.UnescapeDataString(cookie.Value.Value); - store[name] = value; - } - return collection; } return Empty; diff --git a/src/Http/Http/src/Microsoft.AspNetCore.Http.csproj b/src/Http/Http/src/Microsoft.AspNetCore.Http.csproj index 3a2eaae79951..18e06e184ca4 100644 --- a/src/Http/Http/src/Microsoft.AspNetCore.Http.csproj +++ b/src/Http/Http/src/Microsoft.AspNetCore.Http.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core default HTTP feature implementations. @@ -15,6 +15,9 @@ + + + diff --git a/src/Http/Http/src/Properties/AssemblyInfo.cs b/src/Http/Http/src/Properties/AssemblyInfo.cs index 2b8d94f4a543..21cadcc624da 100644 --- a/src/Http/Http/src/Properties/AssemblyInfo.cs +++ b/src/Http/Http/src/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Http.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Http.MicroBenchmarks, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Http/Shared/CookieHeaderParserShared.cs b/src/Http/Shared/CookieHeaderParserShared.cs new file mode 100644 index 000000000000..061fe0b52874 --- /dev/null +++ b/src/Http/Shared/CookieHeaderParserShared.cs @@ -0,0 +1,246 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Net.Http.Headers +{ + internal static class CookieHeaderParserShared + { + public static bool TryParseValues(StringValues values, Dictionary store, bool enableCookieNameEncoding, bool supportsMultipleValues) + { + // If a parser returns an empty list, it means there was no value, but that's valid (e.g. "Accept: "). The caller + // can ignore the value. + if (values.Count == 0) + { + return false; + } + bool hasFoundValue = false; + + for (var i = 0; i < values.Count; i++) + { + var value = values[i]; + var index = 0; + + while (!string.IsNullOrEmpty(value) && index < value.Length) + { + if (TryParseValue(value, ref index, supportsMultipleValues, out var parsedName, out var parsedValue)) + { + // The entry may not contain an actual value, like " , " + if (parsedName != null && parsedValue != null) + { + var name = enableCookieNameEncoding ? Uri.UnescapeDataString(parsedName.Value.Value) : parsedName.Value.Value; + store[name] = Uri.UnescapeDataString(parsedValue.Value.Value); + hasFoundValue = true; + } + } + else + { + // Skip the invalid values and keep trying. + index++; + } + } + } + + return hasFoundValue; + } + + public static bool TryParseValue(StringSegment value, ref int index, bool supportsMultipleValues, [NotNullWhen(true)] out StringSegment? parsedName, [NotNullWhen(true)] out StringSegment? parsedValue) + { + parsedName = null; + parsedValue = null; + + // If multiple values are supported (i.e. list of values), then accept an empty string: The header may + // be added multiple times to the request/response message. E.g. + // Accept: text/xml; q=1 + // Accept: + // Accept: text/plain; q=0.2 + if (StringSegment.IsNullOrEmpty(value) || (index == value.Length)) + { + return supportsMultipleValues; + } + + var current = GetNextNonEmptyOrWhitespaceIndex(value, index, supportsMultipleValues, out bool separatorFound); + + if (separatorFound && !supportsMultipleValues) + { + return false; // leading separators not allowed if we don't support multiple values. + } + + if (current == value.Length) + { + if (supportsMultipleValues) + { + index = current; + } + return supportsMultipleValues; + } + + if (!TryGetCookieLength(value, ref current, out parsedName, out parsedValue)) + { + return false; + } + + current = GetNextNonEmptyOrWhitespaceIndex(value, current, supportsMultipleValues, out separatorFound); + + // If we support multiple values and we've not reached the end of the string, then we must have a separator. + if ((separatorFound && !supportsMultipleValues) || (!separatorFound && (current < value.Length))) + { + return false; + } + + index = current; + + return true; + } + + private 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] != ',' && 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) + { + // Most headers only split on ',', but cookies primarily split on ';' + while ((current < input.Length) && ((input[current] == ',') || (input[current] == ';'))) + { + current++; // skip delimiter. + current = current + HttpRuleParser.GetWhitespaceLength(input, current); + } + } + + return current; + } + + // name=value; name="value" + internal static bool TryGetCookieLength(StringSegment input, ref int offset, [NotNullWhen(true)] out StringSegment? parsedName, [NotNullWhen(true)] out StringSegment? parsedValue) + { + Contract.Requires(offset >= 0); + + parsedName = null; + parsedValue = null; + + if (StringSegment.IsNullOrEmpty(input) || (offset >= input.Length)) + { + return false; + } + + // The caller should have already consumed any leading whitespace, commas, etc.. + + // Name=value; + + // Name + var itemLength = HttpRuleParser.GetTokenLength(input, offset); + if (itemLength == 0) + { + return false; + } + + parsedName = input.Subsegment(offset, itemLength); + offset += itemLength; + + // = (no spaces) + if (!ReadEqualsSign(input, ref offset)) + { + return false; + } + + // value or "quoted value" + // The value may be empty + parsedValue = GetCookieValue(input, ref offset); + + return true; + } + + // cookie-value = *cookie-octet / ( DQUOTE* cookie-octet DQUOTE ) + // cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E + // ; US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon, and backslash + internal static StringSegment GetCookieValue(StringSegment input, ref int offset) + { + Contract.Requires(offset >= 0); + Contract.Ensures((Contract.Result() >= 0) && (Contract.Result() <= (input.Length - offset))); + + var startIndex = offset; + + if (offset >= input.Length) + { + return StringSegment.Empty; + } + var inQuotes = false; + + if (input[offset] == '"') + { + inQuotes = true; + offset++; + } + + while (offset < input.Length) + { + var c = input[offset]; + if (!IsCookieValueChar(c)) + { + break; + } + + offset++; + } + + if (inQuotes) + { + if (offset == input.Length || input[offset] != '"') + { + // Missing final quote + return StringSegment.Empty; + } + offset++; + } + + int length = offset - startIndex; + if (offset > startIndex) + { + return input.Subsegment(startIndex, length); + } + + return StringSegment.Empty; + } + + private static bool ReadEqualsSign(StringSegment input, ref int offset) + { + // = (no spaces) + if (offset >= input.Length || input[offset] != '=') + { + return false; + } + offset++; + return true; + } + + // cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E + // ; US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon, and backslash + private static bool IsCookieValueChar(char c) + { + if (c < 0x21 || c > 0x7E) + { + return false; + } + return !(c == '"' || c == ',' || c == ';' || c == '\\'); + } + } +} diff --git a/src/Http/Headers/src/HttpParseResult.cs b/src/Http/Shared/HttpParseResult.cs similarity index 100% rename from src/Http/Headers/src/HttpParseResult.cs rename to src/Http/Shared/HttpParseResult.cs diff --git a/src/Http/Headers/src/HttpRuleParser.cs b/src/Http/Shared/HttpRuleParser.cs similarity index 100% rename from src/Http/Headers/src/HttpRuleParser.cs rename to src/Http/Shared/HttpRuleParser.cs