From 79ed2419de8d5e63c699637b2c94ddce1b4840ef Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 18 Aug 2026 13:56:53 -0700 Subject: [PATCH 01/13] fix: harden yaml parsing --- .../Microsoft.OpenApi.YamlReader.csproj | 2 +- .../OpenApiReaderSettingsExtensions.cs | 17 +- .../OpenApiYamlReader.cs | 167 ++++++-- .../OpenApiYamlReaderSettings.cs | 73 ++++ .../PublicAPI.Unshipped.txt | 21 + .../YamlConversionBudget.cs | 102 +++++ .../YamlConverter.cs | 267 ++++++++---- .../YamlJsonParser.cs | 272 ++++++++++++ .../Validations/Rules/OpenApiSchemaRules.cs | 107 +++-- .../OpenApiReaderSettingsExtensionsTests.cs | 15 + .../OpenApiYamlReaderTests.cs | 404 +++++++++++++++++- .../YamlConverterGlobalSettingsCollection.cs | 9 + .../YamlConverterGlobalSettingsTests.cs | 81 ++++ .../YamlConverterTests.cs | 45 +- .../Reader/OpenApiJsonReaderTests.cs | 39 ++ .../OpenApiSchemaValidationTests.cs | 199 +++++++++ 16 files changed, 1644 insertions(+), 176 deletions(-) create mode 100644 src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs create mode 100644 src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs create mode 100644 src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs create mode 100644 test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsCollection.cs create mode 100644 test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsTests.cs diff --git a/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj b/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj index 8e7dcfde9..5f2197eb8 100644 --- a/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj +++ b/src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj @@ -38,7 +38,7 @@ all - + diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs index ee5add0a0..006d1fb3d 100644 --- a/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs +++ b/src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.OpenApi.YamlReader; +using System; +using Microsoft.OpenApi.YamlReader; namespace Microsoft.OpenApi.Reader; @@ -17,4 +18,18 @@ public static void AddYamlReader(this OpenApiReaderSettings settings) settings.TryAddReader(OpenApiConstants.Yaml, yamlReader); settings.TryAddReader(OpenApiConstants.Yml, yamlReader); } + + /// + /// Adds a YAML reader for the specified format using per-reader resource limits. + /// + /// The settings to add the reader to. + /// The YAML reader settings. + public static void AddYamlReader(this OpenApiReaderSettings settings, OpenApiYamlReaderSettings yamlSettings) + { + if (settings is null) throw new ArgumentNullException(nameof(settings)); + if (yamlSettings is null) throw new ArgumentNullException(nameof(yamlSettings)); + var yamlReader = new OpenApiYamlReader(yamlSettings); + settings.TryAddReader(OpenApiConstants.Yaml, yamlReader); + settings.TryAddReader(OpenApiConstants.Yml, yamlReader); + } } diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs index cea996152..13f17d77d 100644 --- a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs +++ b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs @@ -7,9 +7,8 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.OpenApi.Reader; -using SharpYaml.Serialization; +using SharpYaml; using System; -using System.Linq; using System.Text; namespace Microsoft.OpenApi.YamlReader @@ -17,10 +16,46 @@ namespace Microsoft.OpenApi.YamlReader /// /// Reader for parsing YAML files into an OpenAPI document. /// + /// + /// Input is converted directly from SharpYaml parser events so resource limits are enforced + /// before SharpYaml's recursive YAML model loader can compose or expand the document. + /// public class OpenApiYamlReader : IOpenApiReader { private const int copyBufferSize = 4096; private static readonly OpenApiJsonReader _jsonReader = new(); + private readonly OpenApiYamlReaderSettings _yamlSettings; + + /// + /// Initializes a YAML reader using the current legacy global conversion limits. + /// + public OpenApiYamlReader() + : this(new() + { + MaxDepth = YamlConverter.MaxDepth, + MaxNodeCount = YamlConverter.MaxNodeCount, + MaxAliasExpansionNodeCount = YamlConverter.MaxAliasExpansionNodeCount, + }) + { + } + + /// + /// Initializes a YAML reader with immutable per-reader resource limits. + /// + /// The YAML reader settings. + public OpenApiYamlReader(OpenApiYamlReaderSettings settings) + { + if (settings is null) throw new ArgumentNullException(nameof(settings)); + settings.Validate(); + _yamlSettings = new() + { + MaxDepth = settings.MaxDepth, + MaxNodeCount = settings.MaxNodeCount, + MaxAliasExpansionNodeCount = settings.MaxAliasExpansionNodeCount, + MaxInputByteCount = settings.MaxInputByteCount, + MaxScalarLength = settings.MaxScalarLength, + }; + } /// public async Task ReadAsync(Stream input, @@ -29,16 +64,33 @@ public async Task ReadAsync(Stream input, CancellationToken cancellationToken = default) { if (input is null) throw new ArgumentNullException(nameof(input)); + if (settings is null) throw new ArgumentNullException(nameof(settings)); if (input is MemoryStream memoryStream) { - return UpdateFormat(Read(memoryStream, location, settings)); + return ReadCore(memoryStream, location, settings, cancellationToken); } else { using var preparedStream = new MemoryStream(); - await input.CopyToAsync(preparedStream, copyBufferSize, cancellationToken).ConfigureAwait(false); + try + { + await CopyToMemoryStreamAsync( + input, + preparedStream, + _yamlSettings.MaxInputByteCount, + cancellationToken).ConfigureAwait(false); + } + catch (OpenApiReaderException ex) + { + return new() + { + Document = null, + Diagnostic = CreateDiagnostic(new(ex)), + }; + } + preparedStream.Position = 0; - return UpdateFormat(Read(preparedStream, location, settings)); + return ReadCore(preparedStream, location, settings, cancellationToken); } } @@ -46,14 +98,22 @@ public async Task ReadAsync(Stream input, public ReadResult Read(MemoryStream input, Uri location, OpenApiReaderSettings settings) + => ReadCore(input, location, settings, CancellationToken.None); + + private ReadResult ReadCore(MemoryStream input, + Uri location, + OpenApiReaderSettings settings, + CancellationToken cancellationToken) { if (input is null) throw new ArgumentNullException(nameof(input)); if (settings is null) throw new ArgumentNullException(nameof(settings)); + cancellationToken.ThrowIfCancellationRequested(); JsonNode jsonNode; // Parse the YAML text in the stream into a sequence of JsonNodes try { + EnsureInputWithinLimit(input, _yamlSettings.MaxInputByteCount); #if NET // this represents net core, net5 and up using var stream = new StreamReader(input, default, true, -1, settings.LeaveStreamOpen); @@ -61,33 +121,65 @@ public ReadResult Read(MemoryStream input, // the implementation differs and results in a null reference exception in NETFX using var stream = new StreamReader(input, Encoding.UTF8, true, 4096, settings.LeaveStreamOpen); #endif - jsonNode = LoadJsonNodesFromYamlDocument(stream); + jsonNode = LoadJsonNodesFromYamlDocument(stream, cancellationToken); } catch (JsonException ex) { - var diagnostic = new OpenApiDiagnostic(); - diagnostic.Errors.Add(new($"#line={ex.LineNumber}", ex.Message)); - diagnostic.Format = OpenApiConstants.Yaml; return new() { Document = null, - Diagnostic = diagnostic, + Diagnostic = CreateDiagnostic(new($"#line={ex.LineNumber}", ex.Message)), }; } catch (OpenApiReaderException ex) { - var diagnostic = new OpenApiDiagnostic(); - diagnostic.Errors.Add(new(ex)); - diagnostic.Format = OpenApiConstants.Yaml; return new() { Document = null, - Diagnostic = diagnostic, + Diagnostic = CreateDiagnostic(new(ex)), }; } + cancellationToken.ThrowIfCancellationRequested(); return UpdateFormat(Read(jsonNode, location, settings)); } + + private static async Task CopyToMemoryStreamAsync( + Stream input, + MemoryStream output, + uint maxInputByteCount, + CancellationToken cancellationToken) + { + var buffer = new byte[copyBufferSize]; + long totalBytesRead = 0; + int bytesRead; + while ((bytesRead = await input.ReadAsync( + buffer, + 0, + buffer.Length, + cancellationToken).ConfigureAwait(false)) > 0) + { + if (bytesRead > (long)maxInputByteCount - totalBytesRead) + { + throw CreateInputLimitException(maxInputByteCount); + } + + await output.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + totalBytesRead += bytesRead; + } + } + + private static void EnsureInputWithinLimit(MemoryStream input, uint maxInputByteCount) + { + if (input.Length - input.Position > maxInputByteCount) + { + throw CreateInputLimitException(maxInputByteCount); + } + } + + private static OpenApiReaderException CreateInputLimitException(uint maxInputByteCount) + => new($"The YAML input exceeds the maximum supported size of {maxInputByteCount} bytes."); + private static ReadResult UpdateFormat(ReadResult result) { result.Diagnostic ??= new OpenApiDiagnostic(); @@ -114,13 +206,22 @@ public static ReadResult Read(JsonNode jsonNode, Uri location, OpenApiReaderSett // Parse the YAML try { - using var stream = new StreamReader(input); - jsonNode = LoadJsonNodesFromYamlDocument(stream); + EnsureInputWithinLimit(input, _yamlSettings.MaxInputByteCount); +#if NET + using var stream = new StreamReader(input, default, true, -1, settings?.LeaveStreamOpen ?? false); +#else + using var stream = new StreamReader(input, Encoding.UTF8, true, 4096, settings?.LeaveStreamOpen ?? false); +#endif + jsonNode = LoadJsonNodesFromYamlDocument(stream, CancellationToken.None); } catch (JsonException ex) { - diagnostic = new(); - diagnostic.Errors.Add(new($"#line={ex.LineNumber}", ex.Message)); + diagnostic = CreateDiagnostic(new($"#line={ex.LineNumber}", ex.Message)); + return default; + } + catch (OpenApiReaderException ex) + { + diagnostic = CreateDiagnostic(new(ex)); return default; } @@ -134,20 +235,34 @@ public static ReadResult Read(JsonNode jsonNode, Uri location, OpenApiReaderSett } /// - /// Helper method to turn streams into a sequence of JsonNodes + /// Converts the first YAML document in a stream into a JSON node. /// /// Stream containing YAML formatted text - /// Instance of a YamlDocument - static JsonNode LoadJsonNodesFromYamlDocument(TextReader input) + /// Propagates notification that parsing should be cancelled. + /// The converted JSON node. + private JsonNode LoadJsonNodesFromYamlDocument(TextReader input, CancellationToken cancellationToken) { - var yamlStream = new YamlStream(); - yamlStream.Load(input); - if (yamlStream.Documents.Any() && yamlStream.Documents[0].ToJsonNode() is { } jsonNode) + try { - return jsonNode; + return new YamlJsonParser(_yamlSettings).Parse(input, cancellationToken); } + catch (YamlException ex) + { + var location = ex.Start.Line >= 0 + ? $" at line {ex.Start.Line + 1}, column {ex.Start.Column + 1}" + : string.Empty; + throw new OpenApiReaderException($"Unable to parse the YAML document{location}: {ex.Message}", ex); + } + } - throw new InvalidOperationException("No documents found in the YAML stream."); + private static OpenApiDiagnostic CreateDiagnostic(OpenApiError error) + { + var diagnostic = new OpenApiDiagnostic + { + Format = OpenApiConstants.Yaml, + }; + diagnostic.Errors.Add(error); + return diagnostic; } } } diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs new file mode 100644 index 000000000..b525f65c6 --- /dev/null +++ b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReaderSettings.cs @@ -0,0 +1,73 @@ +using System; + +namespace Microsoft.OpenApi.YamlReader; + +/// +/// Configures resource limits for an . +/// +public sealed class OpenApiYamlReaderSettings +{ + /// + /// Default maximum number of input bytes read from a single YAML document (128 MiB). + /// Bounds the buffered copy of a non-seekable stream, so an endless or oversized response body + /// cannot exhaust memory before parsing begins. + /// + public const uint DefaultMaxInputByteCount = 128 * 1024 * 1024; + + /// + /// Default maximum length of a single YAML scalar value (65,536 UTF-16 code units). + /// Bounds the cost of any one key, string, number, date or block literal. For reference, the + /// longest scalar in the Microsoft Graph beta description is 1,833 code units, so this leaves + /// substantial headroom for legitimate documents. + /// + public const uint DefaultMaxScalarLength = 64 * 1024; + + /// + /// Gets or sets the maximum YAML nesting depth. + /// Defaults to and cannot exceed + /// . + /// + public uint MaxDepth { get; set; } = YamlConverter.DefaultMaxDepth; + + /// + /// Gets or sets the maximum number of JSON nodes materialized from one YAML document. + /// Defaults to and cannot exceed + /// . + /// + public uint MaxNodeCount { get; set; } = YamlConverter.DefaultMaxNodeCount; + + /// + /// Gets or sets the maximum number of JSON nodes materialized specifically from aliases. + /// Defaults to . + /// + public uint MaxAliasExpansionNodeCount { get; set; } = YamlConverter.DefaultMaxAliasExpansionNodeCount; + + /// + /// Gets or sets the maximum number of input bytes read from one YAML document. + /// Defaults to . + /// + public uint MaxInputByteCount { get; set; } = DefaultMaxInputByteCount; + + /// + /// Gets or sets the maximum length of one YAML scalar value. + /// Defaults to . + /// + public uint MaxScalarLength { get; set; } = DefaultMaxScalarLength; + + internal void Validate() + { + YamlConverter.ValidateMaxDepth(MaxDepth, nameof(MaxDepth)); + YamlConverter.ValidateMaxNodeCount(MaxNodeCount, nameof(MaxNodeCount)); + ValidatePositive(MaxAliasExpansionNodeCount, nameof(MaxAliasExpansionNodeCount)); + ValidatePositive(MaxInputByteCount, nameof(MaxInputByteCount)); + ValidatePositive(MaxScalarLength, nameof(MaxScalarLength)); + } + + private static void ValidatePositive(uint value, string parameterName) + { + if (value == 0) + { + throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be greater than zero."); + } + } +} diff --git a/src/Microsoft.OpenApi.YamlReader/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi.YamlReader/PublicAPI.Unshipped.txt index 7dc5c5811..ae188a765 100644 --- a/src/Microsoft.OpenApi.YamlReader/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi.YamlReader/PublicAPI.Unshipped.txt @@ -1 +1,22 @@ #nullable enable +Microsoft.OpenApi.YamlReader.OpenApiYamlReader.OpenApiYamlReader(Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings! settings) -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxAliasExpansionNodeCount.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxAliasExpansionNodeCount.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxDepth.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxDepth.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxInputByteCount.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxInputByteCount.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxNodeCount.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxNodeCount.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxScalarLength.get -> uint +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxScalarLength.set -> void +Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.OpenApiYamlReaderSettings() -> void +const Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.DefaultMaxInputByteCount = 134217728 -> uint +const Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.DefaultMaxScalarLength = 65536 -> uint +const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxAliasExpansionNodeCount = 5000 -> uint +const Microsoft.OpenApi.YamlReader.YamlConverter.MaximumAllowedDepth = 256 -> uint +const Microsoft.OpenApi.YamlReader.YamlConverter.MaximumAllowedNodeCount = 10000000 -> uint +static Microsoft.OpenApi.Reader.OpenApiReaderSettingsExtensions.AddYamlReader(this Microsoft.OpenApi.Reader.OpenApiReaderSettings! settings, Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings! yamlSettings) -> void +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxAliasExpansionNodeCount.get -> uint +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxAliasExpansionNodeCount.set -> void diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs b/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs new file mode 100644 index 000000000..e76276f7b --- /dev/null +++ b/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs @@ -0,0 +1,102 @@ +namespace Microsoft.OpenApi.YamlReader; + +/// +/// Tracks the resource budget consumed while materializing a single YAML document. +/// +/// +/// +/// A budget instance is scoped to one document and is not thread safe. Callers charge the budget +/// before allocating, so a document that would breach a limit is rejected without the +/// allocation ever happening. +/// +/// +/// Every limit breach throws , which the reader converts into an +/// OpenApiDiagnostic. That is the whole point of this type: hostile input produces a reportable +/// diagnostic rather than an unrecoverable process failure. +/// +/// +internal sealed class YamlConversionBudget +{ + private readonly uint _maxDepth; + private readonly uint _maxNodeCount; + private readonly uint _maxAliasExpansionNodeCount; + private uint _nodeCount; + private uint _aliasExpansionNodeCount; + + /// + /// Initializes a budget for a single document. + /// + /// Maximum nesting depth. Bounds stack and structural growth. + /// Maximum total nodes materialized from the document. + /// + /// Maximum nodes materialized specifically by expanding aliases. This is the anti-amplification + /// limit and is deliberately far smaller than : a large document is + /// legitimate, but a small document that expands into a large one is not. + /// + public YamlConversionBudget(uint maxDepth, uint maxNodeCount, uint maxAliasExpansionNodeCount) + { + _maxDepth = maxDepth; + _maxNodeCount = maxNodeCount; + _maxAliasExpansionNodeCount = maxAliasExpansionNodeCount; + } + + /// + /// Charges one node at the supplied depth. + /// + /// Nesting depth of the node being materialized. + /// The depth or total node limit would be exceeded. + public void EnterNode(uint depth) + { + if (depth > _maxDepth) + { + throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}."); + } + + AddNodes(1); + } + + /// + /// Charges the full cost of expanding an alias, against both the alias budget and the total budget. + /// + /// Nesting depth at which the alias appears. + /// Number of nodes the alias will materialize when cloned. + /// The depth, alias, or total node limit would be exceeded. + /// + /// Must be called before the clone is taken. Charging afterwards would allow the very allocation + /// this limit exists to prevent. + /// + public void EnterAlias(uint depth, uint expandedNodeCount) + { + if (depth > _maxDepth) + { + throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}."); + } + + if (expandedNodeCount > _maxAliasExpansionNodeCount - _aliasExpansionNodeCount) + { + throw new OpenApiReaderException($"The YAML document expands aliases to more than the maximum supported number of nodes ({_maxAliasExpansionNodeCount})."); + } + + _aliasExpansionNodeCount += expandedNodeCount; + AddNodes(expandedNodeCount); + } + + /// + /// Charges nodes against the total node budget. + /// + /// + /// The remaining headroom is compared as count > _maxNodeCount - _nodeCount rather than + /// _nodeCount + count > _maxNodeCount. Both operands are unsigned, so the latter form could + /// wrap and silently admit an over-budget document; the invariant _nodeCount <= _maxNodeCount + /// makes the subtraction used here safe from underflow. + /// + private void AddNodes(uint count) + { + if (count > _maxNodeCount - _nodeCount) + { + throw new OpenApiReaderException($"The YAML document expands to more than the maximum supported number of nodes ({_maxNodeCount}). This may indicate a YAML anchor/alias expansion attack."); + } + + _nodeCount += count; + } +} diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs index 3a09ff876..21e942af2 100644 --- a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs +++ b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; using SharpYaml; @@ -12,6 +13,10 @@ namespace Microsoft.OpenApi.YamlReader /// /// Provides extensions to convert YAML models to JSON models. /// + /// + /// These limits apply after a SharpYaml model exists. Use + /// for untrusted input so limits are enforced before SharpYaml model loading. + /// public static class YamlConverter { /// @@ -28,25 +33,38 @@ public static class YamlConverter /// public const uint DefaultMaxNodeCount = 5_000_000; + /// + /// Default maximum number of JSON nodes that may be materialized from YAML aliases. + /// + public const uint DefaultMaxAliasExpansionNodeCount = 5_000; + + /// + /// Maximum configurable YAML nesting depth. + /// + public const uint MaximumAllowedDepth = 256; + + /// + /// Maximum configurable number of JSON nodes that may be materialized from a single YAML document. + /// Bounds the running node totals so they cannot overflow while accumulating, which would surface as an + /// instead of a reportable diagnostic. + /// + public const uint MaximumAllowedNodeCount = 10_000_000; + private static uint _maxDepth = DefaultMaxDepth; private static uint _maxNodeCount = DefaultMaxNodeCount; + private static uint _maxAliasExpansionNodeCount = DefaultMaxAliasExpansionNodeCount; /// /// Gets or sets the maximum nesting depth allowed when converting a YAML node graph into JSON nodes. - /// Defaults to . Raise this if legitimate deeply nested documents are - /// being rejected, or lower it to fail faster when only shallow documents are expected. + /// Defaults to and cannot exceed the library's safe depth ceiling. /// - /// Thrown when set to zero. + /// Thrown when set outside the supported range. public static uint MaxDepth { get => _maxDepth; set { - if (value == 0) - { - throw new ArgumentOutOfRangeException(nameof(value), "MaxDepth must be greater than zero."); - } - + ValidateMaxDepth(value, nameof(value)); _maxDepth = value; } } @@ -55,50 +73,51 @@ public static uint MaxDepth /// Gets or sets the maximum number of JSON nodes that may be materialized from a single YAML document. /// Defaults to , guarding against YAML anchor/alias expansion /// ("billion laughs") attacks. Raise this if legitimate large documents are being rejected, or lower - /// it to fail faster when only small documents are expected. + /// it to fail faster when only small documents are expected. Cannot exceed + /// . /// - /// Thrown when set to zero. + /// Thrown when set outside the supported range. public static uint MaxNodeCount { get => _maxNodeCount; set { - if (value == 0) - { - throw new ArgumentOutOfRangeException(nameof(value), "MaxNodeCount must be greater than zero."); - } - + ValidateMaxNodeCount(value, nameof(value)); _maxNodeCount = value; } } /// - /// Tracks and enforces resource limits while converting a YAML node graph into JSON nodes, - /// failing fast when a hostile document would otherwise exhaust memory or the stack. + /// Gets or sets the maximum number of JSON nodes that may be materialized from YAML aliases. /// - private sealed class YamlConversionBudget + /// Thrown when set to zero. + public static uint MaxAliasExpansionNodeCount { - private readonly uint _maxDepth; - private readonly uint _maxNodeCount; - private uint _nodeCount; - - public YamlConversionBudget(uint maxDepth, uint maxNodeCount) + get => _maxAliasExpansionNodeCount; + set { - _maxDepth = maxDepth; - _maxNodeCount = maxNodeCount; + if (value == 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "MaxAliasExpansionNodeCount must be greater than zero."); + } + + _maxAliasExpansionNodeCount = value; } + } - public void EnterNode(uint depth) + internal static void ValidateMaxDepth(uint value, string parameterName) + { + if (value == 0 || value > MaximumAllowedDepth) { - if (depth > _maxDepth) - { - throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}."); - } + throw new ArgumentOutOfRangeException(parameterName, $"MaxDepth must be between 1 and {MaximumAllowedDepth}."); + } + } - if (++_nodeCount > _maxNodeCount) - { - throw new OpenApiReaderException($"The YAML document expands to more than the maximum supported number of nodes ({_maxNodeCount}). This may indicate a YAML anchor/alias expansion (billion laughs) attack."); - } + internal static void ValidateMaxNodeCount(uint value, string parameterName) + { + if (value == 0 || value > MaximumAllowedNodeCount) + { + throw new ArgumentOutOfRangeException(parameterName, $"MaxNodeCount must be between 1 and {MaximumAllowedNodeCount}."); } } @@ -130,19 +149,7 @@ public static JsonNode ToJsonNode(this YamlDocument yaml) /// Thrown for YAML that is not compatible with JSON. public static JsonNode ToJsonNode(this YamlNode yaml) { - return yaml.ToJsonNode(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0); - } - - private static JsonNode ToJsonNode(this YamlNode yaml, YamlConversionBudget budget, uint depth) - { - budget.EnterNode(depth); - return yaml switch - { - YamlMappingNode map => map.ToJsonObject(budget, depth), - YamlSequenceNode seq => seq.ToJsonArray(budget, depth), - YamlScalarNode scalar => scalar.ToJsonValue(), - _ => throw new NotSupportedException("This yaml isn't convertible to JSON") - }; + return CreateConversionContext().Convert(yaml, 0).Node; } /// @@ -173,19 +180,7 @@ public static YamlNode ToYamlNode(this JsonNode json) /// public static JsonObject ToJsonObject(this YamlMappingNode yaml) { - return yaml.ToJsonObject(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0); - } - - private static JsonObject ToJsonObject(this YamlMappingNode yaml, YamlConversionBudget budget, uint depth) - { - var node = new JsonObject(); - foreach (var keyValuePair in yaml) - { - var key = ((YamlScalarNode)keyValuePair.Key).Value!; - node[key] = keyValuePair.Value.ToJsonNode(budget, depth + 1); - } - - return node; + return (JsonObject)CreateConversionContext().Convert(yaml, 0).Node; } private static YamlMappingNode ToYamlMapping(this JsonObject obj) @@ -203,18 +198,7 @@ private static YamlMappingNode ToYamlMapping(this JsonObject obj) /// public static JsonArray ToJsonArray(this YamlSequenceNode yaml) { - return yaml.ToJsonArray(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0); - } - - private static JsonArray ToJsonArray(this YamlSequenceNode yaml, YamlConversionBudget budget, uint depth) - { - var node = new JsonArray(); - foreach (var value in yaml) - { - node.Add(value.ToJsonNode(budget, depth + 1)); - } - - return node; + return (JsonArray)CreateConversionContext().Convert(yaml, 0).Node; } private static YamlSequenceNode ToYamlSequence(this JsonArray arr) @@ -230,19 +214,142 @@ private static YamlSequenceNode ToYamlSequence(this JsonArray arr) "NULL" }; - private static JsonValue ToJsonValue(this YamlScalarNode yaml) + private static YamlConversionContext CreateConversionContext() { - return yaml.Style switch + var maxDepth = MaxDepth; + var maxNodeCount = MaxNodeCount; + var maxAliasExpansionNodeCount = MaxAliasExpansionNodeCount; + ValidateMaxDepth(maxDepth, nameof(MaxDepth)); + return new( + new YamlConversionBudget(maxDepth, maxNodeCount, maxAliasExpansionNodeCount)); + } + + internal static JsonValue ToJsonValue(string? value, ScalarStyle style) + { + return style switch { - ScalarStyle.Plain when decimal.TryParse(yaml.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => JsonValue.Create(d), - ScalarStyle.Plain when bool.TryParse(yaml.Value, out var b) => JsonValue.Create(b), - ScalarStyle.Plain when YamlNullRepresentations.Contains(yaml.Value) => (JsonValue)JsonNullSentinel.JsonNull.DeepClone(), - ScalarStyle.Plain => JsonValue.Create(yaml.Value), - ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted or ScalarStyle.Literal or ScalarStyle.Folded or ScalarStyle.Any => JsonValue.Create(yaml.Value), - _ => throw new ArgumentOutOfRangeException(nameof(yaml)), + ScalarStyle.Plain when decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => JsonValue.Create(d), + ScalarStyle.Plain when bool.TryParse(value, out var b) => JsonValue.Create(b), + ScalarStyle.Plain when value is not null && YamlNullRepresentations.Contains(value) => (JsonValue)JsonNullSentinel.JsonNull.DeepClone(), + ScalarStyle.Plain => JsonValue.Create(value ?? string.Empty), + ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted or ScalarStyle.Literal or ScalarStyle.Folded or ScalarStyle.Any => JsonValue.Create(value ?? string.Empty), + _ => throw new ArgumentOutOfRangeException(nameof(style)), }; } + private sealed class YamlConversionContext + { + private readonly YamlConversionBudget _budget; + private readonly Dictionary _completed = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _active = new(ReferenceEqualityComparer.Instance); + + public YamlConversionContext(YamlConversionBudget budget) + { + _budget = budget; + } + + public MaterializedNode Convert(YamlNode yaml, uint depth) + { + try + { + RuntimeHelpers.EnsureSufficientExecutionStack(); + } + catch (InsufficientExecutionStackException ex) + { + throw new OpenApiReaderException("The YAML node graph is too deeply nested to convert safely.", ex); + } + + if (_active.Contains(yaml)) + { + throw new OpenApiReaderException("The YAML node graph contains a cycle."); + } + + if (_completed.TryGetValue(yaml, out var completed)) + { + _budget.EnterAlias(depth, completed.NodeCount); + return new(completed.Node.DeepClone(), completed.NodeCount); + } + + _budget.EnterNode(depth); + _active.Add(yaml); + try + { + var materialized = yaml switch + { + YamlMappingNode map => ConvertMapping(map, depth), + YamlSequenceNode sequence => ConvertSequence(sequence, depth), + YamlScalarNode scalar => new MaterializedNode(ToJsonValue(scalar.Value, scalar.Style), 1), + _ => throw new NotSupportedException("This yaml isn't convertible to JSON") + }; + _completed.Add(yaml, materialized); + return materialized; + } + finally + { + _active.Remove(yaml); + } + } + + private MaterializedNode ConvertMapping(YamlMappingNode yaml, uint depth) + { + var node = new JsonObject(); + uint nodeCount = 1; + foreach (var keyValuePair in yaml) + { + if (keyValuePair.Key is not YamlScalarNode scalarKey || scalarKey.Value is null) + { + throw new OpenApiReaderException("YAML mapping keys must be scalar values."); + } + + if (node.ContainsKey(scalarKey.Value)) + { + throw new OpenApiReaderException($"The YAML mapping contains the duplicate key '{scalarKey.Value}'."); + } + + var child = Convert(keyValuePair.Value, depth + 1); + node.Add(scalarKey.Value, child.Node); + nodeCount = checked(nodeCount + child.NodeCount); + } + + return new(node, nodeCount); + } + + private MaterializedNode ConvertSequence(YamlSequenceNode yaml, uint depth) + { + var node = new JsonArray(); + uint nodeCount = 1; + foreach (var value in yaml) + { + var child = Convert(value, depth + 1); + node.Add(child.Node); + nodeCount = checked(nodeCount + child.NodeCount); + } + + return new(node, nodeCount); + } + } + + private sealed class MaterializedNode + { + public MaterializedNode(JsonNode node, uint nodeCount) + { + Node = node; + NodeCount = nodeCount; + } + + public JsonNode Node { get; } + public uint NodeCount { get; } + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer where T : class + { + public static ReferenceEqualityComparer Instance { get; } = new(); + + public bool Equals(T? x, T? y) => ReferenceEquals(x, y); + + public int GetHashCode(T obj) => RuntimeHelpers.GetHashCode(obj); + } + private static bool NeedsQuoting(string value) => string.IsNullOrEmpty(value) || decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _) || diff --git a/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs new file mode 100644 index 000000000..cac76df41 --- /dev/null +++ b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Nodes; +using System.Threading; +using SharpYaml; +using SharpYaml.Events; + +namespace Microsoft.OpenApi.YamlReader; + +/// +/// Iteratively materializes the first YAML document from parser events under resource limits. +/// +internal sealed class YamlJsonParser +{ + private const int LookAheadBufferCapacity = 8; + + private readonly YamlConversionBudget _budget; + private readonly Dictionary _anchors = new(StringComparer.Ordinal); + private readonly HashSet _activeAnchors = new(StringComparer.Ordinal); + private readonly Stack _containers = new(); + private readonly uint _maxScalarLength; + private JsonNode? _root; + + public YamlJsonParser(OpenApiYamlReaderSettings settings) + { + _budget = new(settings.MaxDepth, settings.MaxNodeCount, settings.MaxAliasExpansionNodeCount); + _maxScalarLength = settings.MaxScalarLength; + } + + public JsonNode Parse(TextReader input, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var cancellationReader = new CancellationTokenTextReader(input, cancellationToken); + var parser = new Parser(new LookAheadBuffer(cancellationReader, LookAheadBufferCapacity)); + var documentStarted = false; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!parser.MoveNext()) + { + break; + } + + switch (parser.Current) + { + case StreamStart: + break; + case DocumentStart: + documentStarted = true; + break; + case MappingStart mappingStart: + StartContainer(new JsonObject(), mappingStart.Anchor); + break; + case SequenceStart sequenceStart: + StartContainer(new JsonArray(), sequenceStart.Anchor); + break; + case Scalar scalar: + AddScalar(scalar, cancellationToken); + break; + case AnchorAlias alias: + AddAlias(alias, cancellationToken); + break; + case MappingEnd: + case SequenceEnd: + EndContainer(); + break; + case DocumentEnd: + return _root ?? throw new OpenApiReaderException("No content found in the YAML document."); + case StreamEnd: + if (documentStarted) + { + return _root ?? throw new OpenApiReaderException("No content found in the YAML document."); + } + + throw new OpenApiReaderException("No documents found in the YAML stream."); + default: + throw new OpenApiReaderException( + $"Unsupported YAML parser event '{parser.Current?.GetType().Name ?? ""}'."); + } + } + + throw new OpenApiReaderException("No documents found in the YAML stream."); + } + + private void StartContainer(JsonNode container, string? anchor) + { + _budget.EnterNode((uint)_containers.Count); + RegisterActiveAnchor(anchor); + _containers.Push(new(container, anchor)); + } + + private void AddScalar(Scalar scalar, CancellationToken cancellationToken) + { + if (scalar.Value is { } value && value.Length > _maxScalarLength) + { + throw new OpenApiReaderException( + $"The YAML scalar exceeds the maximum supported length of {_maxScalarLength} characters."); + } + + _budget.EnterNode((uint)_containers.Count); + cancellationToken.ThrowIfCancellationRequested(); + var materialized = new MaterializedNode( + YamlConverter.ToJsonValue(scalar.Value, scalar.Style), + 1, + scalar.Value); + + RegisterCompletedAnchor(scalar.Anchor, materialized); + AddNode(materialized); + } + + private void AddAlias(AnchorAlias alias, CancellationToken cancellationToken) + { + if (_activeAnchors.Contains(alias.Value)) + { + throw new OpenApiReaderException($"The YAML alias '*{alias.Value}' forms a cycle."); + } + + if (!_anchors.TryGetValue(alias.Value, out var anchor)) + { + throw new OpenApiReaderException($"The YAML alias '*{alias.Value}' refers to an unknown anchor."); + } + + _budget.EnterAlias((uint)_containers.Count, anchor.NodeCount); + cancellationToken.ThrowIfCancellationRequested(); + AddNode(new(anchor.Node.DeepClone(), anchor.NodeCount, anchor.MappingKey)); + } + + private void EndContainer() + { + if (_containers.Count == 0) + { + throw new OpenApiReaderException("The YAML document contains an unexpected container terminator."); + } + + var frame = _containers.Pop(); + if (frame.PendingKey is not null) + { + throw new OpenApiReaderException("The YAML mapping contains a key without a value."); + } + + var materialized = new MaterializedNode(frame.Container, frame.NodeCount, null); + if (frame.Anchor is not null) + { + _activeAnchors.Remove(frame.Anchor); + _anchors.Add(frame.Anchor, materialized); + } + + AddNode(materialized); + } + + private void AddNode(MaterializedNode materialized) + { + if (_containers.Count == 0) + { + if (_root is not null) + { + throw new OpenApiReaderException("The YAML document contains more than one root node."); + } + + _root = materialized.Node; + return; + } + + var frame = _containers.Peek(); + switch (frame.Container) + { + case JsonArray array: + array.Add(materialized.Node); + frame.NodeCount = checked(frame.NodeCount + materialized.NodeCount); + break; + case JsonObject map when frame.PendingKey is null: + frame.PendingKey = materialized.MappingKey + ?? throw new OpenApiReaderException("YAML mapping keys must be scalar values."); + break; + case JsonObject map: + if (map.ContainsKey(frame.PendingKey)) + { + throw new OpenApiReaderException($"The YAML mapping contains the duplicate key '{frame.PendingKey}'."); + } + + map.Add(frame.PendingKey, materialized.Node); + frame.PendingKey = null; + frame.NodeCount = checked(frame.NodeCount + materialized.NodeCount); + break; + } + } + + private void RegisterActiveAnchor(string? anchor) + { + if (anchor is null || anchor.Length == 0) + { + return; + } + + if (_anchors.ContainsKey(anchor) || !_activeAnchors.Add(anchor)) + { + throw new OpenApiReaderException($"The YAML document contains the duplicate anchor '&{anchor}'."); + } + } + + private void RegisterCompletedAnchor(string? anchor, MaterializedNode materialized) + { + if (anchor is null || anchor.Length == 0) + { + return; + } + + if (_anchors.ContainsKey(anchor) || _activeAnchors.Contains(anchor)) + { + throw new OpenApiReaderException($"The YAML document contains the duplicate anchor '&{anchor}'."); + } + + _anchors.Add(anchor, materialized); + } + + private sealed class ContainerFrame(JsonNode container, string? anchor) + { + public JsonNode Container { get; } = container; + public string? Anchor { get; } = anchor; + public string? PendingKey { get; set; } + public uint NodeCount { get; set; } = 1; + } + + private sealed class MaterializedNode + { + public MaterializedNode(JsonNode node, uint nodeCount, string? mappingKey) + { + Node = node; + NodeCount = nodeCount; + MappingKey = mappingKey; + } + + public JsonNode Node { get; } + public uint NodeCount { get; } + public string? MappingKey { get; } + } + + private sealed class CancellationTokenTextReader(TextReader innerReader, CancellationToken cancellationToken) : TextReader + { + public override int Peek() + { + cancellationToken.ThrowIfCancellationRequested(); + return innerReader.Peek(); + } + + public override int Read() + { + cancellationToken.ThrowIfCancellationRequested(); + return innerReader.Read(); + } + + public override int Read(char[] buffer, int index, int count) + { + cancellationToken.ThrowIfCancellationRequested(); + return innerReader.Read(buffer, index, count); + } + + public override string? ReadLine() + { + cancellationToken.ThrowIfCancellationRequested(); + return base.ReadLine(); + } + + public override string ReadToEnd() + { + cancellationToken.ThrowIfCancellationRequested(); + return base.ReadToEnd(); + } + } +} diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs index 4f2a122a9..f73de3fc7 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs @@ -8,6 +8,7 @@ namespace Microsoft.OpenApi using System; using System.ComponentModel; using System.Linq; + using System.Runtime.CompilerServices; /// /// The validation rules for . @@ -73,32 +74,17 @@ public static class OpenApiSchemaRules [Browsable(false)] public static bool ValidateChildSchemaAgainstDiscriminator(IOpenApiSchema schema, string? discriminatorName) { - if (discriminatorName is not null) + if (discriminatorName is null) { - if (schema.Required is null || !schema.Required.Contains(discriminatorName)) - { - // recursively check nested schema.OneOf, schema.AnyOf or schema.AllOf and their required fields for the discriminator - if (schema.OneOf is { Count: > 0}) - { - return TraverseSchemaElements(discriminatorName, schema.OneOf); - } - if (schema.AnyOf is { Count: > 0}) - { - return TraverseSchemaElements(discriminatorName, schema.AnyOf); - } - if (schema.AllOf is { Count: > 0}) - { - return TraverseSchemaElements(discriminatorName, schema.AllOf); - } - } - else - { - return true; - } return false; - } + } - return false; + if (schema.Required?.Contains(discriminatorName) == true) + { + return true; + } + + return TraverseSchemaElementsIterative(discriminatorName, GetSchemaCombinators(schema)); } /// @@ -111,24 +97,87 @@ public static bool ValidateChildSchemaAgainstDiscriminator(IOpenApiSchema schema [Obsolete("This method will be made private in future versions.")] [Browsable(false)] public static bool TraverseSchemaElements(string discriminatorName, IList? childSchema) + => TraverseSchemaElementsIterative(discriminatorName, childSchema); + + private static bool TraverseSchemaElementsIterative(string discriminatorName, IEnumerable? childSchemas) { - if (childSchema is null) + if (childSchemas is null) { return false; } - foreach (var childItem in childSchema) + + var schemasToVisit = new Queue(); + var visitedSchemas = new HashSet(SchemaReferenceEqualityComparer.Instance); + + EnqueueSchemas(schemasToVisit, childSchemas); + + while (schemasToVisit.Count > 0) { - if ((!childItem.Properties?.ContainsKey(discriminatorName) ?? false) && - (!childItem.Required?.Contains(discriminatorName) ?? false)) + var childItem = schemasToVisit.Dequeue(); + if (!visitedSchemas.Add(childItem)) { - return ValidateChildSchemaAgainstDiscriminator(childItem, discriminatorName); + continue; } - else + + if (childItem.Properties?.ContainsKey(discriminatorName) == true || + childItem.Required?.Contains(discriminatorName) == true) { return true; } + + EnqueueSchemas(schemasToVisit, GetSchemaCombinators(childItem)); } + return false; } + + private static IEnumerable GetSchemaCombinators(IOpenApiSchema schema) + { + if (schema.OneOf is { Count: > 0 } oneOf) + { + foreach (var childSchema in oneOf) + { + yield return childSchema; + } + } + + if (schema.AnyOf is { Count: > 0 } anyOf) + { + foreach (var childSchema in anyOf) + { + yield return childSchema; + } + } + + if (schema.AllOf is { Count: > 0 } allOf) + { + foreach (var childSchema in allOf) + { + yield return childSchema; + } + } + } + + private static void EnqueueSchemas(Queue schemasToVisit, IEnumerable childSchemas) + { + foreach (var childSchema in childSchemas) + { + if (childSchema is not null) + { + schemasToVisit.Enqueue(childSchema); + } + } + } + + private sealed class SchemaReferenceEqualityComparer : IEqualityComparer + { + internal static SchemaReferenceEqualityComparer Instance { get; } = new(); + + public bool Equals(IOpenApiSchema? x, IOpenApiSchema? y) + => ReferenceEquals(x, y); + + public int GetHashCode(IOpenApiSchema obj) + => RuntimeHelpers.GetHashCode(obj); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderSettingsExtensionsTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderSettingsExtensionsTests.cs index ff3f4509e..7268c5304 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderSettingsExtensionsTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderSettingsExtensionsTests.cs @@ -29,4 +29,19 @@ public void IsAvailableOnSameNamespace() var extensionsNS = typeof(OpenApiReaderSettingsExtensions).Namespace; Assert.Equal(settingsNS, extensionsNS, StringComparer.Ordinal); } + + [Fact] + public void AddsYamlReaderWithPerReaderSettings() + { + var settings = new OpenApiReaderSettings(); + var yamlSettings = new OpenApiYamlReaderSettings + { + MaxAliasExpansionNodeCount = 10, + }; + + settings.AddYamlReader(yamlSettings); + + var yamlReader = Assert.IsType(settings.GetReader(OpenApiConstants.Yaml)); + Assert.Same(yamlReader, settings.GetReader(OpenApiConstants.Yml)); + } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs index ea0ef0fd9..0b419c8d4 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -28,20 +29,69 @@ public async Task ReadAsyncParsesDocumentsFromNonMemoryStreams() var result = await reader.ReadAsync(stream, DocumentLocation, SettingsFixture.ReaderSettings, CancellationToken.None); - Assert.NotNull(result.Document); + Assert.True(result.Document is not null, string.Join(Environment.NewLine, result.Diagnostic.Errors.Select(static error => error.Message))); Assert.Equal("Sample API", result.Document.Info.Title); Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); } [Fact] - public void ReadThrowsWhenYamlDoesNotContainADocument() + public async Task ReadAsyncHonorsCancellationForMemoryStreams() + { + var reader = new OpenApiYamlReader(); + await using var stream = CreateStream( + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + await Assert.ThrowsAnyAsync( + () => reader.ReadAsync( + stream, + DocumentLocation, + SettingsFixture.ReaderSettings, + cancellationSource.Token)); + } + + [Fact] + public async Task ReadAsyncPropagatesCancellationDuringYamlScanning() + { + var reader = new OpenApiYamlReader(); + using var cancellationSource = new CancellationTokenSource(); + await using var stream = new CancelingMemoryStream( + Encoding.UTF8.GetBytes( + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """), + cancellationSource); + + await Assert.ThrowsAnyAsync( + () => reader.ReadAsync( + stream, + DocumentLocation, + SettingsFixture.ReaderSettings, + cancellationSource.Token)); + } + + [Fact] + public void ReadReturnsDiagnosticWhenYamlDoesNotContainADocument() { var reader = new OpenApiYamlReader(); using var stream = CreateStream(string.Empty); - var exception = Assert.Throws(() => reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings)); + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); - Assert.Equal("No documents found in the YAML stream.", exception.Message); + Assert.Null(result.Document); + Assert.Single(result.Diagnostic.Errors); + Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); } [Fact] @@ -75,6 +125,19 @@ public void ReadThrowsWhenSettingsIsNull() Assert.Throws(() => reader.Read(stream, DocumentLocation, null!)); } + [Fact] + public async Task ReadAsyncThrowsWhenSettingsIsNullWithoutConsumingTheStream() + { + var reader = new OpenApiYamlReader(); + var inner = CreateStream("openapi: 3.0.1"); + await using var stream = new NonMemoryStream(inner); + + await Assert.ThrowsAsync( + () => reader.ReadAsync(stream, DocumentLocation, null!, CancellationToken.None)); + + Assert.Equal(0, inner.Position); + } + [Fact] public void ReadReturnsDiagnosticErrorForExponentialAliasExpansion() { @@ -101,6 +164,311 @@ public void ReadReturnsDiagnosticErrorForExponentialAliasExpansion() Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); } + [Fact] + public void ReadRejectsAliasBombBelowTotalNodeLimit() + { + const int fanOut = 21; + var yaml = new StringBuilder(); + yaml.AppendLine($"a: &a [{string.Join(",", Enumerable.Repeat("\"x\"", fanOut))}]"); + var previousAnchor = 'a'; + for (var anchor = 'b'; anchor <= 'e'; anchor++) + { + yaml.AppendLine($"{anchor}: &{anchor} [{string.Join(",", Enumerable.Repeat($"*{previousAnchor}", fanOut))}]"); + previousAnchor = anchor; + } + + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml.ToString()); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("expands aliases", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadReturnsDiagnosticForDeepNestingBeforeYamlDomComposition(bool flowStyle) + { + const int depth = 5_000; + var yaml = flowStyle + ? new string('[', depth) + new string(']', depth) + : string.Concat(Enumerable.Repeat("- ", depth)) + "value"; + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum supported nesting depth", StringComparison.Ordinal)); + } + + [Fact] + public void ReadStopsAfterFirstYamlDocument() + { + const int depth = 5_000; + var yaml = + """ + openapi: 3.0.1 + info: + title: First document + version: 1.0.0 + paths: {} + --- + """ + + Environment.NewLine + + new string('[', depth) + + new string(']', depth); + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.True(result.Document is not null, string.Join(Environment.NewLine, result.Diagnostic.Errors.Select(static error => error.Message))); + Assert.Equal("First document", result.Document.Info.Title); + Assert.Empty(result.Diagnostic.Errors); + } + + [Fact] + public void ReadReturnsDiagnosticForCyclicAlias() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream("x-cycle: &cycle [*cycle]"); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("forms a cycle", StringComparison.Ordinal)); + } + + [Fact] + public void ReadFragmentReturnsDiagnosticForAliasExpansion() + { + var reader = new OpenApiYamlReader(new() + { + MaxAliasExpansionNodeCount = 1, + }); + using var stream = CreateStream( + """ + type: &value string + title: *value + description: *value + """); + + var schema = reader.ReadFragment( + stream, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.Null(schema); + Assert.Single(diagnostic.Errors); + Assert.Equal(OpenApiConstants.Yaml, diagnostic.Format); + } + + [Fact] + public void ReadFragmentReturnsDiagnosticWhenInputExceedsByteLimit() + { + var reader = new OpenApiYamlReader(new() + { + MaxInputByteCount = 10, + }); + using var stream = CreateStream("type: string"); + + var schema = reader.ReadFragment( + stream, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.Null(schema); + Assert.Contains(diagnostic.Errors, error => error.Message.Contains("maximum supported size", StringComparison.Ordinal)); + Assert.Equal(OpenApiConstants.Yaml, diagnostic.Format); + } + + [Fact] + public void ReadMeasuresOnlyTheRemainingBytesOfAPositionedStream() + { + const string padding = "##################################################"; + const string yaml = + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """; + var reader = new OpenApiYamlReader(new() + { + MaxInputByteCount = (uint)Encoding.UTF8.GetByteCount(yaml), + }); + using var stream = CreateStream(padding + yaml); + stream.Position = padding.Length; + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.True(result.Document is not null, string.Join(Environment.NewLine, result.Diagnostic.Errors.Select(static error => error.Message))); + Assert.Equal("Sample API", result.Document.Info.Title); + } + + [Fact] + public void ReadReturnsDiagnosticWhenInputExceedsByteLimit() + { + var reader = new OpenApiYamlReader(new() + { + MaxInputByteCount = 10, + }); + using var stream = CreateStream("openapi: 3.0.1"); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum supported size", StringComparison.Ordinal)); + } + + [Fact] + public async Task ReadAsyncReturnsDiagnosticWhenNonMemoryStreamExceedsByteLimit() + { + var reader = new OpenApiYamlReader(new() + { + MaxInputByteCount = 10, + }); + await using var stream = new NonMemoryStream(CreateStream("openapi: 3.0.1")); + + var result = await reader.ReadAsync( + stream, + DocumentLocation, + SettingsFixture.ReaderSettings, + CancellationToken.None); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum supported size", StringComparison.Ordinal)); + } + + [Fact] + public void ReadReturnsDiagnosticWhenScalarExceedsLengthLimit() + { + var reader = new OpenApiYamlReader(new() + { + MaxScalarLength = 3, + }); + using var stream = CreateStream("value: 1234"); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum supported length", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("a: 1\na: 2")] + [InlineData("? [1, 2]: value")] + [InlineData("[")] + [InlineData("# comment only")] + public void ReadReturnsDiagnosticForInvalidYaml(string yaml) + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.NotEmpty(result.Diagnostic.Errors); + Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); + } + + [Fact] + public void ReaderCopiesPerInstanceSettings() + { + var yamlSettings = new OpenApiYamlReaderSettings + { + MaxAliasExpansionNodeCount = 1, + }; + var reader = new OpenApiYamlReader(yamlSettings); + yamlSettings.MaxAliasExpansionNodeCount = 100; + using var stream = CreateStream( + """ + type: &value string + title: *value + description: *value + """); + + var schema = reader.ReadFragment( + stream, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.Null(schema); + Assert.Single(diagnostic.Errors); + } + + [Theory] + [InlineData(0u, 5_000_000u, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(YamlConverter.MaximumAllowedDepth + 1, 5_000_000u, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, 0u, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, YamlConverter.MaximumAllowedNodeCount + 1, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, 5_000_000u, 0u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, 5_000_000u, 5_000u, 0u, OpenApiYamlReaderSettings.DefaultMaxScalarLength)] + [InlineData(64u, 5_000_000u, 5_000u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount, 0u)] + public void ReaderRejectsInvalidResourceLimits( + uint maxDepth, + uint maxNodeCount, + uint maxAliasExpansionNodeCount, + uint maxInputByteCount, + uint maxScalarLength) + { + var settings = new OpenApiYamlReaderSettings + { + MaxDepth = maxDepth, + MaxNodeCount = maxNodeCount, + MaxAliasExpansionNodeCount = maxAliasExpansionNodeCount, + MaxInputByteCount = maxInputByteCount, + MaxScalarLength = maxScalarLength, + }; + + Assert.Throws(() => new OpenApiYamlReader(settings)); + } + + [Fact] + public void ReaderLimitsDefaultToDocumentedValues() + { + Assert.Equal(134_217_728u, OpenApiYamlReaderSettings.DefaultMaxInputByteCount); + Assert.Equal(65_536u, OpenApiYamlReaderSettings.DefaultMaxScalarLength); + + var settings = new OpenApiYamlReaderSettings(); + + Assert.Equal(YamlConverter.DefaultMaxDepth, settings.MaxDepth); + Assert.Equal(YamlConverter.DefaultMaxNodeCount, settings.MaxNodeCount); + Assert.Equal(YamlConverter.DefaultMaxAliasExpansionNodeCount, settings.MaxAliasExpansionNodeCount); + Assert.Equal(OpenApiYamlReaderSettings.DefaultMaxInputByteCount, settings.MaxInputByteCount); + Assert.Equal(OpenApiYamlReaderSettings.DefaultMaxScalarLength, settings.MaxScalarLength); + } + + [Fact] + public void ReaderAcceptsMaxNodeCountAtTheSafeCeiling() + { + var settings = new OpenApiYamlReaderSettings + { + MaxNodeCount = YamlConverter.MaximumAllowedNodeCount, + }; + var reader = new OpenApiYamlReader(settings); + using var stream = CreateStream( + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.True(result.Document is not null, string.Join(Environment.NewLine, result.Diagnostic.Errors.Select(static error => error.Message))); + Assert.Equal("Sample API", result.Document.Info.Title); + } + private static MemoryStream CreateStream(string yaml) { return new MemoryStream(Encoding.UTF8.GetBytes(yaml)); @@ -134,4 +502,32 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } + + private sealed class CancelingMemoryStream(byte[] buffer, CancellationTokenSource cancellationSource) : MemoryStream(buffer) + { + private bool _canceled; + + public override int Read(byte[] buffer, int offset, int count) + { + var bytesRead = base.Read(buffer, offset, count); + CancelAfterFirstRead(); + return bytesRead; + } + + public override int Read(Span buffer) + { + var bytesRead = base.Read(buffer); + CancelAfterFirstRead(); + return bytesRead; + } + + private void CancelAfterFirstRead() + { + if (!_canceled) + { + _canceled = true; + cancellationSource.Cancel(); + } + } + } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsCollection.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsCollection.cs new file mode 100644 index 000000000..8c1c979f5 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsCollection.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class YamlConverterGlobalSettingsCollection +{ + public const string Name = "YamlConverterGlobalSettings"; +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsTests.cs new file mode 100644 index 000000000..6e423cd85 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterGlobalSettingsTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Text.Json.Nodes; +using Microsoft.OpenApi.YamlReader; +using SharpYaml.Serialization; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests; + +[Collection(YamlConverterGlobalSettingsCollection.Name)] +public class YamlConverterGlobalSettingsTests +{ + [Fact] + public void ConversionLimitsDefaultToDocumentedValues() + { + Assert.Equal(64u, YamlConverter.DefaultMaxDepth); + Assert.Equal(5_000_000u, YamlConverter.DefaultMaxNodeCount); + Assert.Equal(5_000u, YamlConverter.DefaultMaxAliasExpansionNodeCount); + Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); + Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount); + Assert.Equal(YamlConverter.DefaultMaxAliasExpansionNodeCount, YamlConverter.MaxAliasExpansionNodeCount); + } + + [Fact] + public void SettingMaxDepthToZeroThrows() + { + Assert.Throws(() => YamlConverter.MaxDepth = 0); + Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); + } + + [Fact] + public void SettingMaxNodeCountToZeroThrows() + { + Assert.Throws(() => YamlConverter.MaxNodeCount = 0); + Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount); + } + + [Fact] + public void SettingMaxAliasExpansionNodeCountToZeroThrows() + { + Assert.Throws(() => YamlConverter.MaxAliasExpansionNodeCount = 0); + Assert.Equal(YamlConverter.DefaultMaxAliasExpansionNodeCount, YamlConverter.MaxAliasExpansionNodeCount); + } + + [Fact] + public void SettingMaxDepthAboveSafeCeilingThrows() + { + Assert.Throws(() => YamlConverter.MaxDepth = YamlConverter.MaximumAllowedDepth + 1); + Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); + } + + [Fact] + public void SettingMaxNodeCountAboveSafeCeilingThrows() + { + Assert.Throws(() => YamlConverter.MaxNodeCount = YamlConverter.MaximumAllowedNodeCount + 1); + Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount); + } + + [Fact] + public void RaisingMaxDepthAllowsDocumentsDeeperThanTheDefault() + { + const int depth = 70; + YamlNode deeplyNested = new YamlScalarNode("value"); + for (var index = 0; index < depth; index++) + { + deeplyNested = new YamlSequenceNode(deeplyNested); + } + + try + { + YamlConverter.MaxDepth = depth + 10; + + var jsonNode = deeplyNested.ToJsonNode(); + + Assert.IsType(jsonNode); + } + finally + { + YamlConverter.MaxDepth = YamlConverter.DefaultMaxDepth; + } + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs index 7bc094d48..99424d089 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs @@ -382,48 +382,23 @@ public void LegitimateAliasesStillConvert() } [Fact] - public void ConversionLimitsDefaultToDocumentedValues() + public void CyclicYamlNodeGraphIsRejected() { - Assert.Equal(64u, YamlConverter.DefaultMaxDepth); - Assert.Equal(5_000_000u, YamlConverter.DefaultMaxNodeCount); - Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); - Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount); - } + var sequence = new YamlSequenceNode(); + sequence.Add(sequence); - [Fact] - public void SettingMaxDepthToZeroThrows() - { - Assert.Throws(() => YamlConverter.MaxDepth = 0); - // The invalid assignment must not have changed the effective limit. - Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); - } - - [Fact] - public void SettingMaxNodeCountToZeroThrows() - { - Assert.Throws(() => YamlConverter.MaxNodeCount = 0); - // The invalid assignment must not have changed the effective limit. - Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount); + Assert.Throws(() => sequence.ToJsonNode()); } [Fact] - public void RaisingMaxDepthAllowsDocumentsDeeperThanTheDefault() + public void ComplexMappingKeyIsRejectedAsReaderException() { - // A document nested deeper than the default depth limit (64) is rejected by default - // but can be permitted by a consumer that opts into a higher limit. - const int depth = 70; - var deeplyNested = new string('[', depth) + new string(']', depth); - - try + var mapping = new YamlMappingNode { - YamlConverter.MaxDepth = depth + 10; - var jsonNode = ConvertYamlStringToJsonNode(deeplyNested); - Assert.IsType(jsonNode); - } - finally - { - YamlConverter.MaxDepth = YamlConverter.DefaultMaxDepth; - } + { new YamlSequenceNode(new YamlScalarNode("key")), new YamlScalarNode("value") } + }; + + Assert.Throws(() => mapping.ToJsonNode()); } private static JsonNode ConvertYamlStringToJsonNode(string yamlInput) diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs index 8b0fac733..e6dd63989 100644 --- a/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs @@ -57,6 +57,45 @@ public void ReadValidatesParsedDocumentAgainstConfiguredRules() Assert.Equal("Document failed validation.", error.Message); } + [Fact] + public void ReadReturnsValidationErrorForSelfReferentialDiscriminatorSchema() + { + var reader = new OpenApiJsonReader(); + using var stream = CreateStream( + """ + { + "openapi": "3.0.1", + "info": { + "title": "Sample", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Pet": { + "type": "object", + "discriminator": { + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Pet" + } + ] + } + } + } + } + """); + + var result = reader.Read(stream, DocumentLocation, new OpenApiReaderSettings()); + + Assert.NotNull(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => + error is OpenApiValidatorError validatorError && + validatorError.RuleName == nameof(OpenApiSchemaRules.ValidateSchemaDiscriminator)); + } + [Fact] public void ReadReturnsDiagnosticWhenRootNodeCannotBeParsedAsDocument() { diff --git a/test/Microsoft.OpenApi.Tests/Validations/OpenApiSchemaValidationTests.cs b/test/Microsoft.OpenApi.Tests/Validations/OpenApiSchemaValidationTests.cs index 8a5751666..7f5cd47e2 100644 --- a/test/Microsoft.OpenApi.Tests/Validations/OpenApiSchemaValidationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Validations/OpenApiSchemaValidationTests.cs @@ -392,5 +392,204 @@ public void ValidateOneOfSchemaPropertyNameContainsPropertySpecifiedInTheDiscrim //Assert Assert.Empty(errors); } + + [Fact] + public void ValidateSchemaDiscriminatorReturnsExistingErrorForSelfCycleWithoutValidDiscriminator() + { + // Arrange + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Discriminator = new() + { + PropertyName = "type" + } + }; + schema.OneOf = [schema]; + + var components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["Person"] = schema + } + }; + + // Act + var validator = new OpenApiValidator(ValidationRuleSet.GetDefaultRuleSet()); + var walker = new OpenApiWalker(validator); + walker.Walk(components); + + // Assert + Assert.Equivalent(new List + { + new(nameof(OpenApiSchemaRules.ValidateSchemaDiscriminator), "#/schemas/Person/discriminator", + string.Format(SRResource.Validation_SchemaRequiredFieldListMustContainThePropertySpecifiedInTheDiscriminator, + string.Empty, "type")) + }, validator.Errors); + } + + [Fact] +#pragma warning disable CS0618 + public void TraverseSchemaElementsReturnsFalseForMultiNodeCycleWithoutDiscriminator() + { + // Arrange + var first = new OpenApiSchema(); + var second = new OpenApiSchema(); + var third = new OpenApiSchema(); + + first.OneOf = [second]; + second.AnyOf = [third]; + third.AllOf = [first]; + + // Act + var result = OpenApiSchemaRules.TraverseSchemaElements("type", [first]); + + // Assert + Assert.False(result); + } +#pragma warning restore CS0618 + + [Fact] +#pragma warning disable CS0618 + public void ValidateChildSchemaAgainstDiscriminatorReturnsTrueForDeepAcyclicTraversal() + { + // Arrange + const string discriminatorName = "type"; + var root = CreateSchemaChain(5000, discriminatorName); + + // Act + var result = OpenApiSchemaRules.ValidateChildSchemaAgainstDiscriminator(root, discriminatorName); + + // Assert + Assert.True(result); + } +#pragma warning restore CS0618 + + [Fact] + public void ValidateSchemaDiscriminatorCanFindValidLaterOneOfChild() + { + // Arrange + var components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["Person"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Discriminator = new() + { + PropertyName = "type" + }, + OneOf = + [ + new OpenApiSchema(), + new OpenApiSchema + { + Properties = new Dictionary + { + ["type"] = new OpenApiSchema + { + Type = JsonSchemaType.String + } + } + } + ] + } + } + }; + + // Act + var validator = new OpenApiValidator(ValidationRuleSet.GetDefaultRuleSet()); + var walker = new OpenApiWalker(validator); + walker.Walk(components); + + // Assert + Assert.Empty(validator.Errors); + } + + [Fact] + public void ValidateSchemaDiscriminatorChecksOneOfAnyOfAndAllOf() + { + // Arrange + var components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["Person"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Discriminator = new() + { + PropertyName = "type" + }, + OneOf = + [ + new OpenApiSchema() + ], + AnyOf = + [ + new OpenApiSchema() + ], + AllOf = + [ + new OpenApiSchema + { + Properties = new Dictionary + { + ["type"] = new OpenApiSchema + { + Type = JsonSchemaType.String + } + } + } + ] + } + } + }; + + // Act + var validator = new OpenApiValidator(ValidationRuleSet.GetDefaultRuleSet()); + var walker = new OpenApiWalker(validator); + walker.Walk(components); + + // Assert + Assert.Empty(validator.Errors); + } + + private static OpenApiSchema CreateSchemaChain(int depth, string discriminatorName) + { + var root = new OpenApiSchema(); + var current = root; + + for (var i = 0; i < depth; i++) + { + var next = new OpenApiSchema(); + switch (i % 3) + { + case 0: + current.OneOf = [next]; + break; + case 1: + current.AnyOf = [next]; + break; + default: + current.AllOf = [next]; + break; + } + + current = next; + } + + current.Properties = new Dictionary + { + [discriminatorName] = new OpenApiSchema + { + Type = JsonSchemaType.String + } + }; + + return root; + } } } From a327d7f4b601459e94d631d42d8bb5590b1f3586 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 18 Aug 2026 15:18:27 -0700 Subject: [PATCH 02/13] fix tests and nesting behavior on streams with SharpYaml update --- .../YamlConversionBudget.cs | 43 +++++++++++---- .../YamlConverter.cs | 20 ++++--- .../YamlJsonParser.cs | 29 +++++++++-- .../OpenApiYamlReaderTests.cs | 52 ++++++++++++++++++- .../YamlConverterTests.cs | 29 ++++++++++- 5 files changed, 149 insertions(+), 24 deletions(-) diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs b/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs index e76276f7b..4b3ed75d9 100644 --- a/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs +++ b/src/Microsoft.OpenApi.YamlReader/YamlConversionBudget.cs @@ -43,33 +43,39 @@ public YamlConversionBudget(uint maxDepth, uint maxNodeCount, uint maxAliasExpan /// /// Charges one node at the supplied depth. /// - /// Nesting depth of the node being materialized. + /// Zero-based nesting depth of the node being materialized. /// The depth or total node limit would be exceeded. public void EnterNode(uint depth) { - if (depth > _maxDepth) - { - throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}."); - } - + ValidateDepth(depth); AddNodes(1); } /// /// Charges the full cost of expanding an alias, against both the alias budget and the total budget. /// - /// Nesting depth at which the alias appears. + /// Zero-based nesting depth at which the alias appears. /// Number of nodes the alias will materialize when cloned. + /// + /// Height of the subtree the alias will materialize, where a scalar has height 1. + /// /// The depth, alias, or total node limit would be exceeded. /// /// Must be called before the clone is taken. Charging afterwards would allow the very allocation /// this limit exists to prevent. /// - public void EnterAlias(uint depth, uint expandedNodeCount) + public void EnterAlias(uint depth, uint expandedNodeCount, uint expandedHeight) { - if (depth > _maxDepth) + ValidateDepth(depth); + + // The alias site clears the depth check on its own, but expanding it grafts an entire + // subtree at this position. Without charging the grafted height, an anchor defined at a + // legal depth can be replayed from another legal depth to produce a tree deeper than the + // limit. The underlying YAML parser cannot catch this either, because it sees an alias as + // a single event and never re-walks the anchored content. + if (expandedHeight > _maxDepth - depth) { - throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}."); + throw new OpenApiReaderException($"The YAML document expands an alias to more than the maximum supported nesting depth of {_maxDepth}."); } if (expandedNodeCount > _maxAliasExpansionNodeCount - _aliasExpansionNodeCount) @@ -81,6 +87,23 @@ public void EnterAlias(uint depth, uint expandedNodeCount) AddNodes(expandedNodeCount); } + /// + /// Validates that a node at is within the depth limit. + /// + /// + /// is zero-based, so a node at that depth occupies level + /// depth + 1. Rejecting depth >= _maxDepth therefore admits exactly + /// _maxDepth levels, matching the limit enforced by the underlying YAML parser. + /// The comparison avoids arithmetic so it cannot overflow. + /// + private void ValidateDepth(uint depth) + { + if (depth >= _maxDepth) + { + throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}."); + } + } + /// /// Charges nodes against the total node budget. /// diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs index 21e942af2..aa73618f5 100644 --- a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs +++ b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs @@ -266,8 +266,8 @@ public MaterializedNode Convert(YamlNode yaml, uint depth) if (_completed.TryGetValue(yaml, out var completed)) { - _budget.EnterAlias(depth, completed.NodeCount); - return new(completed.Node.DeepClone(), completed.NodeCount); + _budget.EnterAlias(depth, completed.NodeCount, completed.Height); + return new(completed.Node.DeepClone(), completed.NodeCount, completed.Height); } _budget.EnterNode(depth); @@ -278,7 +278,7 @@ public MaterializedNode Convert(YamlNode yaml, uint depth) { YamlMappingNode map => ConvertMapping(map, depth), YamlSequenceNode sequence => ConvertSequence(sequence, depth), - YamlScalarNode scalar => new MaterializedNode(ToJsonValue(scalar.Value, scalar.Style), 1), + YamlScalarNode scalar => new MaterializedNode(ToJsonValue(scalar.Value, scalar.Style), 1, 1), _ => throw new NotSupportedException("This yaml isn't convertible to JSON") }; _completed.Add(yaml, materialized); @@ -294,6 +294,7 @@ private MaterializedNode ConvertMapping(YamlMappingNode yaml, uint depth) { var node = new JsonObject(); uint nodeCount = 1; + uint maxChildHeight = 0; foreach (var keyValuePair in yaml) { if (keyValuePair.Key is not YamlScalarNode scalarKey || scalarKey.Value is null) @@ -309,36 +310,43 @@ private MaterializedNode ConvertMapping(YamlMappingNode yaml, uint depth) var child = Convert(keyValuePair.Value, depth + 1); node.Add(scalarKey.Value, child.Node); nodeCount = checked(nodeCount + child.NodeCount); + maxChildHeight = Math.Max(maxChildHeight, child.Height); } - return new(node, nodeCount); + return new(node, nodeCount, maxChildHeight + 1); } private MaterializedNode ConvertSequence(YamlSequenceNode yaml, uint depth) { var node = new JsonArray(); uint nodeCount = 1; + uint maxChildHeight = 0; foreach (var value in yaml) { var child = Convert(value, depth + 1); node.Add(child.Node); nodeCount = checked(nodeCount + child.NodeCount); + maxChildHeight = Math.Max(maxChildHeight, child.Height); } - return new(node, nodeCount); + return new(node, nodeCount, maxChildHeight + 1); } } private sealed class MaterializedNode { - public MaterializedNode(JsonNode node, uint nodeCount) + public MaterializedNode(JsonNode node, uint nodeCount, uint height) { Node = node; NodeCount = nodeCount; + Height = height; } public JsonNode Node { get; } public uint NodeCount { get; } + + /// Number of levels in this subtree, where a scalar has height 1. + public uint Height { get; } } private sealed class ReferenceEqualityComparer : IEqualityComparer where T : class diff --git a/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs index cac76df41..568753c32 100644 --- a/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs +++ b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs @@ -20,19 +20,27 @@ internal sealed class YamlJsonParser private readonly HashSet _activeAnchors = new(StringComparer.Ordinal); private readonly Stack _containers = new(); private readonly uint _maxScalarLength; + private readonly uint _maxDepth; private JsonNode? _root; public YamlJsonParser(OpenApiYamlReaderSettings settings) { _budget = new(settings.MaxDepth, settings.MaxNodeCount, settings.MaxAliasExpansionNodeCount); _maxScalarLength = settings.MaxScalarLength; + _maxDepth = settings.MaxDepth; } public JsonNode Parse(TextReader input, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var cancellationReader = new CancellationTokenTextReader(input, cancellationToken); - var parser = new Parser(new LookAheadBuffer(cancellationReader, LookAheadBufferCapacity)); + + // SharpYaml applies its own nesting limit, defaulting to 64. Passing the configured limit + // keeps the two enforcement points in agreement; leaving it unset would silently cap every + // reader at 64 regardless of MaxDepth, making values above the default a no-op. + var parser = new Parser( + new LookAheadBuffer(cancellationReader, LookAheadBufferCapacity), + (int)_maxDepth); var documentStarted = false; while (true) @@ -104,6 +112,7 @@ private void AddScalar(Scalar scalar, CancellationToken cancellationToken) var materialized = new MaterializedNode( YamlConverter.ToJsonValue(scalar.Value, scalar.Style), 1, + 1, scalar.Value); RegisterCompletedAnchor(scalar.Anchor, materialized); @@ -122,9 +131,9 @@ private void AddAlias(AnchorAlias alias, CancellationToken cancellationToken) throw new OpenApiReaderException($"The YAML alias '*{alias.Value}' refers to an unknown anchor."); } - _budget.EnterAlias((uint)_containers.Count, anchor.NodeCount); + _budget.EnterAlias((uint)_containers.Count, anchor.NodeCount, anchor.Height); cancellationToken.ThrowIfCancellationRequested(); - AddNode(new(anchor.Node.DeepClone(), anchor.NodeCount, anchor.MappingKey)); + AddNode(new(anchor.Node.DeepClone(), anchor.NodeCount, anchor.Height, anchor.MappingKey)); } private void EndContainer() @@ -140,7 +149,7 @@ private void EndContainer() throw new OpenApiReaderException("The YAML mapping contains a key without a value."); } - var materialized = new MaterializedNode(frame.Container, frame.NodeCount, null); + var materialized = new MaterializedNode(frame.Container, frame.NodeCount, frame.MaxChildHeight + 1, null); if (frame.Anchor is not null) { _activeAnchors.Remove(frame.Anchor); @@ -169,6 +178,7 @@ private void AddNode(MaterializedNode materialized) case JsonArray array: array.Add(materialized.Node); frame.NodeCount = checked(frame.NodeCount + materialized.NodeCount); + frame.MaxChildHeight = Math.Max(frame.MaxChildHeight, materialized.Height); break; case JsonObject map when frame.PendingKey is null: frame.PendingKey = materialized.MappingKey @@ -183,6 +193,7 @@ private void AddNode(MaterializedNode materialized) map.Add(frame.PendingKey, materialized.Node); frame.PendingKey = null; frame.NodeCount = checked(frame.NodeCount + materialized.NodeCount); + frame.MaxChildHeight = Math.Max(frame.MaxChildHeight, materialized.Height); break; } } @@ -221,19 +232,27 @@ private sealed class ContainerFrame(JsonNode container, string? anchor) public string? Anchor { get; } = anchor; public string? PendingKey { get; set; } public uint NodeCount { get; set; } = 1; + + /// Height of the tallest child added so far; 0 while the container is empty. + public uint MaxChildHeight { get; set; } } private sealed class MaterializedNode { - public MaterializedNode(JsonNode node, uint nodeCount, string? mappingKey) + public MaterializedNode(JsonNode node, uint nodeCount, uint height, string? mappingKey) { Node = node; NodeCount = nodeCount; + Height = height; MappingKey = mappingKey; } public JsonNode Node { get; } public uint NodeCount { get; } + + /// Number of levels in this subtree, where a scalar has height 1. + public uint Height { get; } + public string? MappingKey { get; } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs index 0b419c8d4..60376caa9 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Linq; using System.Text; @@ -201,7 +201,55 @@ public void ReadReturnsDiagnosticForDeepNestingBeforeYamlDomComposition(bool flo var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); Assert.Null(result.Document); - Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum supported nesting depth", StringComparison.Ordinal)); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("maximum nesting depth", StringComparison.Ordinal)); + } + + [Fact] + public void ReadHonoursMaxDepthAboveTheUnderlyingParserDefault() + { + // SharpYaml applies its own nesting limit, defaulting to 64. Unless the reader forwards + // MaxDepth to it, every configured value above that default silently has no effect. + const int depth = 100; + var yaml = new string('[', depth) + new string(']', depth); + var reader = new OpenApiYamlReader(new OpenApiYamlReaderSettings { MaxDepth = 200 }); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.DoesNotContain(result.Diagnostic.Errors, error => error.Message.Contains("nesting depth", StringComparison.Ordinal)); + } + + [Fact] + public void ReadHonoursMaxDepthBelowTheUnderlyingParserDefault() + { + const int depth = 40; + var yaml = new string('[', depth) + new string(']', depth); + var reader = new OpenApiYamlReader(new OpenApiYamlReaderSettings { MaxDepth = 32 }); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("nesting depth of 32", StringComparison.Ordinal)); + } + + [Fact] + public void ReadRejectsAliasExpansionThatExceedsMaxDepth() + { + // The anchor and the alias each sit within the depth limit, but expanding the alias grafts + // the anchored subtree onto an equally deep position, producing a tree twice as deep. The + // YAML parser cannot catch this because it sees the alias as a single event. + const int half = 50; + var yaml = + $"a: &d {new string('[', half)}{new string(']', half)}\n" + + $"b: {new string('[', half)}*d{new string(']', half)}\n"; + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(yaml); + + var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings); + + Assert.Null(result.Document); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.Contains("expands an alias", StringComparison.Ordinal)); } [Fact] diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs index 99424d089..ce8393696 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs @@ -363,7 +363,7 @@ public void ExcessiveNestingDepthIsRejected() const int depth = 70; var deeplyNested = new string('[', depth) + new string(']', depth); - Assert.Throws(() => ConvertYamlStringToJsonNode(deeplyNested)); + Assert.Throws(() => ConvertYamlStringToJsonNode(deeplyNested)); } [Fact] @@ -401,6 +401,33 @@ public void ComplexMappingKeyIsRejectedAsReaderException() Assert.Throws(() => mapping.ToJsonNode()); } + [Fact] + public void SharedSubtreeGraftedTooDeepIsRejected() + { + // The converter memoizes each YamlNode it has already materialized, then deep-clones the + // result when the same instance reappears. The shared subtree clears the depth check where + // it is first seen, so without charging its height at the reuse site it can be replayed + // from a deeper position to build a tree past the limit. + YamlNode shared = new YamlScalarNode("value"); + for (var index = 0; index < 40; index++) + { + shared = new YamlSequenceNode(shared); + } + + var grafted = shared; + for (var index = 0; index < 30; index++) + { + grafted = new YamlSequenceNode(grafted); + } + + // The shallow element is converted first and memoizes the shared subtree; the deep element + // then reuses it 31 levels down, which would materialize 72 levels against a limit of 64. + var root = new YamlSequenceNode(shared, grafted); + + var exception = Assert.Throws(() => root.ToJsonNode()); + Assert.Contains("expands an alias", exception.Message, StringComparison.Ordinal); + } + private static JsonNode ConvertYamlStringToJsonNode(string yamlInput) { var yamlDocument = new YamlStream(); From 51cf449751187674d6535c857a11353dc621250d Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 18 Aug 2026 15:30:25 -0700 Subject: [PATCH 03/13] CodeQL fixes --- src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs | 4 ++-- .../Validations/Rules/OpenApiSchemaRules.cs | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs index 568753c32..c7a3aab90 100644 --- a/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs +++ b/src/Microsoft.OpenApi.YamlReader/YamlJsonParser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.Json.Nodes; @@ -180,7 +180,7 @@ private void AddNode(MaterializedNode materialized) frame.NodeCount = checked(frame.NodeCount + materialized.NodeCount); frame.MaxChildHeight = Math.Max(frame.MaxChildHeight, materialized.Height); break; - case JsonObject map when frame.PendingKey is null: + case JsonObject when frame.PendingKey is null: frame.PendingKey = materialized.MappingKey ?? throw new OpenApiReaderException("YAML mapping keys must be scalar values."); break; diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs index f73de3fc7..eff687253 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiSchemaRules.cs @@ -160,12 +160,9 @@ private static IEnumerable GetSchemaCombinators(IOpenApiSchema s private static void EnqueueSchemas(Queue schemasToVisit, IEnumerable childSchemas) { - foreach (var childSchema in childSchemas) + foreach (var childSchema in childSchemas.Where(childSchema => childSchema is not null)) { - if (childSchema is not null) - { - schemasToVisit.Enqueue(childSchema); - } + schemasToVisit.Enqueue(childSchema); } } From 684c8d6b994adb35b525428409190cbccd3c00d9 Mon Sep 17 00:00:00 2001 From: "Gavin Barron (from Dev Box)" Date: Tue, 18 Aug 2026 16:21:55 -0700 Subject: [PATCH 04/13] update benchmarks --- .../performance.Descriptions-report-github.md | 24 +++---- .../performance.Descriptions-report.csv | 12 ++-- .../performance.Descriptions-report.html | 24 +++---- .../performance.Descriptions-report.json | 2 +- .../performance.EmptyModels-report-github.md | 68 +++++++++---------- .../performance.EmptyModels-report.csv | 56 +++++++-------- .../performance.EmptyModels-report.html | 66 +++++++++--------- .../performance.EmptyModels-report.json | 2 +- 8 files changed, 127 insertions(+), 127 deletions(-) diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md index 6ee697332..70573f5a0 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md @@ -1,20 +1,20 @@ ``` BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat) -INTEL XEON PLATINUM 8573C 2.30GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 10.0.302 - [Host] : .NET 8.0.29 (8.0.29, 8.0.2926.32403), X64 RyuJIT x86-64-v4 - ShortRun : .NET 8.0.29 (8.0.29, 8.0.2926.32403), X64 RyuJIT x86-64-v4 +INTEL XEON PLATINUM 8573C 3.39GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.400 + [Host] : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v4 + ShortRun : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v4 Job=ShortRun IterationCount=3 LaunchCount=1 WarmupCount=3 ``` -| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | -|------------- |---------------:|--------------:|-------------:|----------:|----------:|----------:|-------------:| -| PetStoreYaml | 477.6 μs | 237.53 μs | 13.02 μs | 3.9063 | - | - | 375.67 KB | -| PetStoreJson | 193.3 μs | 29.03 μs | 1.59 μs | 1.9531 | - | - | 209.67 KB | -| GHESYaml | 878,325.2 μs | 555,688.50 μs | 30,459.16 μs | 4000.0000 | 3000.0000 | 1000.0000 | 310814.2 KB | -| GHESJson | 225,445.4 μs | 35,058.33 μs | 1,921.67 μs | 1000.0000 | - | - | 140426.65 KB | -| GHESNextYaml | 1,254,063.7 μs | 245,210.30 μs | 13,440.80 μs | 8000.0000 | 6000.0000 | 2000.0000 | 512928.9 KB | -| GHESNextJson | 588,121.5 μs | 83,680.85 μs | 4,586.83 μs | 5000.0000 | 4000.0000 | 1000.0000 | 344754.7 KB | +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | +|------------- |-------------:|--------------:|-------------:|----------:|----------:|----------:|-------------:| +| PetStoreYaml | 441.7 μs | 119.48 μs | 6.55 μs | 3.9063 | - | - | 327.8 KB | +| PetStoreJson | 167.1 μs | 44.75 μs | 2.45 μs | 1.9531 | - | - | 209.67 KB | +| GHESYaml | 639,148.3 μs | 104,953.08 μs | 5,752.83 μs | 4000.0000 | 3000.0000 | 1000.0000 | 267097.41 KB | +| GHESJson | 183,609.4 μs | 234,899.82 μs | 12,875.65 μs | 1000.0000 | - | - | 140442.72 KB | +| GHESNextYaml | 877,586.8 μs | 245,901.99 μs | 13,478.72 μs | 6000.0000 | 4000.0000 | 1000.0000 | 469032.8 KB | +| GHESNextJson | 521,883.6 μs | 100,709.23 μs | 5,520.21 μs | 5000.0000 | 4000.0000 | 1000.0000 | 344770.8 KB | diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv index c5aeb2762..20abd8b12 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv @@ -1,7 +1,7 @@ Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Gen0,Gen1,Gen2,Allocated -PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,477.6 μs,237.53 μs,13.02 μs,3.9063,0.0000,0.0000,375.67 KB -PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,193.3 μs,29.03 μs,1.59 μs,1.9531,0.0000,0.0000,209.67 KB -GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"878,325.2 μs","555,688.50 μs","30,459.16 μs",4000.0000,3000.0000,1000.0000,310814.2 KB -GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"225,445.4 μs","35,058.33 μs","1,921.67 μs",1000.0000,0.0000,0.0000,140426.65 KB -GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"1,254,063.7 μs","245,210.30 μs","13,440.80 μs",8000.0000,6000.0000,2000.0000,512928.9 KB -GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"588,121.5 μs","83,680.85 μs","4,586.83 μs",5000.0000,4000.0000,1000.0000,344754.7 KB +PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,441.7 μs,119.48 μs,6.55 μs,3.9063,0.0000,0.0000,327.8 KB +PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,167.1 μs,44.75 μs,2.45 μs,1.9531,0.0000,0.0000,209.67 KB +GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"639,148.3 μs","104,953.08 μs","5,752.83 μs",4000.0000,3000.0000,1000.0000,267097.41 KB +GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"183,609.4 μs","234,899.82 μs","12,875.65 μs",1000.0000,0.0000,0.0000,140442.72 KB +GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"877,586.8 μs","245,901.99 μs","13,478.72 μs",6000.0000,4000.0000,1000.0000,469032.8 KB +GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"521,883.6 μs","100,709.23 μs","5,520.21 μs",5000.0000,4000.0000,1000.0000,344770.8 KB diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html index f015841f0..a8eee52cd 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html @@ -2,7 +2,7 @@ -performance.Descriptions-20260807-144733 +performance.Descriptions-20260818-223332