From 1355f0870415a3280dfe2f033bb100b3239fa785 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:53:52 +0000 Subject: [PATCH 1/5] Initial plan From 6bf72b6d377c30404609150deb8e416b8ecd7752 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:06:08 +0000 Subject: [PATCH 2/5] fix(library): avoid components key validation timeouts under load Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../Rules/OpenApiComponentsRules.cs | 51 +++++++++++++-- .../OpenApiComponentsValidationTests.cs | 65 ++++++++++++++++++- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs index e3c6e1b4f..61fa24066 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using System; using System.Collections.Generic; using System.Text.RegularExpressions; @@ -11,12 +10,17 @@ namespace Microsoft.OpenApi /// The validation rules for . /// [OpenApiRule] - public static class OpenApiComponentsRules + public static partial class OpenApiComponentsRules { /// - /// The key regex. + /// The key regex pattern. /// - internal static readonly Regex KeyRegex = new(@"^[a-zA-Z0-9\.\-_]+$", RegexOptions.None, TimeSpan.FromMilliseconds(100)); + internal const string KeyPattern = @"^[a-zA-Z0-9\.\-_]+$"; + +#if NET8_0_OR_GREATER + [GeneratedRegex(KeyPattern, RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex KeyRegex(); +#endif /// /// All the fixed fields declared above are objects @@ -54,12 +58,47 @@ private static void ValidateKeys(IValidationContext context, IEnumerable foreach (var key in keys) { - if (!KeyRegex.IsMatch(key)) +#if NET8_0_OR_GREATER + var isValidKey = KeyRegex().IsMatch(key); +#else + var isValidKey = IsValidKey(key); +#endif + if (!isValidKey) { context.CreateError(nameof(KeyMustBeRegularExpression), - string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, component, KeyRegex.ToString())); + string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, component, KeyPattern)); } } } + + internal static bool IsValidKey(string key) + { + // Match the fixed character class without wall-clock timeouts on downlevel targets. + var length = key.Length; + // The regex's $ anchor also accepts a single final newline. + if (length > 0 && key[length - 1] == '\n') + { + length--; + } + + if (length == 0) + { + return false; + } + + for (var i = 0; i < length; i++) + { + var character = key[i]; + if (!(character >= 'a' && character <= 'z') && + !(character >= 'A' && character <= 'Z') && + !(character >= '0' && character <= '9') && + character != '.' && character != '-' && character != '_') + { + return false; + } + } + + return true; + } } } diff --git a/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs b/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs index 68f89a2a2..b330c6b4c 100644 --- a/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs @@ -2,7 +2,11 @@ // Licensed under the MIT license. using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; using Xunit; namespace Microsoft.OpenApi.Validations.Tests @@ -32,8 +36,67 @@ public void ValidateKeyMustMatchRegularExpressionInComponents() Assert.False(result); Assert.NotNull(errors); var error = Assert.Single(errors); - Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, "responses", OpenApiComponentsRules.KeyRegex.ToString()), + Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, "responses", @"^[a-zA-Z0-9\.\-_]+$"), error.Message); } + + [Theory] + [InlineData("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_", true)] + [InlineData("", false)] + [InlineData("a b", false)] + [InlineData("a/b", false)] + [InlineData("é", false)] + [InlineData("12", false)] + [InlineData("a\n", true)] + [InlineData("\n", false)] + [InlineData("a\n\n", false)] + [InlineData("a\r\n", false)] + [InlineData("a\nb", false)] + [InlineData("a\0", false)] + public void ValidateComponentKeyPreservesRegexBehavior(string key, bool isValid) + { + var components = new OpenApiComponents + { + Schemas = new Dictionary + { + { key, new OpenApiSchema() } + } + }; + + var rules = new ValidationRuleSet(); + rules.Add(typeof(OpenApiComponents), OpenApiComponentsRules.KeyMustBeRegularExpression); + var errors = components.Validate(rules); + + Assert.Equal(isValid, OpenApiComponentsRules.IsValidKey(key)); + Assert.Equal(isValid, !errors.Any()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LoadAsyncValidatesLongComponentKeys(bool isValid) + { + var key = new string('a', 1_000_000) + (isValid ? string.Empty : "!"); + Assert.Equal(isValid, OpenApiComponentsRules.IsValidKey(key)); + var json = """ + {"openapi":"3.1.0","info":{"title":"Test","version":"1.0"},"paths":{},"components":{"schemas":{ + """ + JsonSerializer.Serialize(key) + ":{\"type\":\"string\"}}}}"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var result = await OpenApiDocument.LoadAsync(stream, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result.Document); + Assert.NotNull(result.Diagnostic); + if (isValid) + { + Assert.Empty(result.Diagnostic.Errors); + } + else + { + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, + key, "schemas", @"^[a-zA-Z0-9\.\-_]+$"), error.Message); + } + } } } From ce2211c95aeeb1b291dc4948e089915c46691e1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:24:00 +0000 Subject: [PATCH 3/5] fix(library): use conditional regex fallback for older frameworks Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- agents.md | 6 ++++ .../Rules/OpenApiComponentsRules.cs | 34 +++---------------- .../OpenApiComponentsValidationTests.cs | 6 ++-- 3 files changed, 12 insertions(+), 34 deletions(-) create mode 100644 agents.md diff --git a/agents.md b/agents.md new file mode 100644 index 000000000..467bb37b9 --- /dev/null +++ b/agents.md @@ -0,0 +1,6 @@ +# Regex handling + +- For fixed patterns on modern targets, use source-generated regexes with explicit match timeouts (`GeneratedRegex` under `NET8_0_OR_GREATER`). +- Use conditional compilation to provide a regular `Regex` with the same pattern and an explicit match timeout for older targets. Do not duplicate regex validation with a manually maintained character scanner. +- Older-runtime regex matching may still time out under load because timeouts use wall-clock time. If consumers encounter this limitation, recommend upgrading to a modern runtime that uses the source-generated implementation. +- Keep shared patterns in constants and reference those constants in validation diagnostics and tests. diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs index 61fa24066..7473bce91 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System; using System.Collections.Generic; using System.Text.RegularExpressions; @@ -20,6 +21,8 @@ public static partial class OpenApiComponentsRules #if NET8_0_OR_GREATER [GeneratedRegex(KeyPattern, RegexOptions.None, matchTimeoutMilliseconds: 100)] private static partial Regex KeyRegex(); +#else + private static readonly Regex KeyRegex = new(KeyPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)); #endif /// @@ -61,7 +64,7 @@ private static void ValidateKeys(IValidationContext context, IEnumerable #if NET8_0_OR_GREATER var isValidKey = KeyRegex().IsMatch(key); #else - var isValidKey = IsValidKey(key); + var isValidKey = KeyRegex.IsMatch(key); #endif if (!isValidKey) { @@ -71,34 +74,5 @@ private static void ValidateKeys(IValidationContext context, IEnumerable } } - internal static bool IsValidKey(string key) - { - // Match the fixed character class without wall-clock timeouts on downlevel targets. - var length = key.Length; - // The regex's $ anchor also accepts a single final newline. - if (length > 0 && key[length - 1] == '\n') - { - length--; - } - - if (length == 0) - { - return false; - } - - for (var i = 0; i < length; i++) - { - var character = key[i]; - if (!(character >= 'a' && character <= 'z') && - !(character >= 'A' && character <= 'Z') && - !(character >= '0' && character <= '9') && - character != '.' && character != '-' && character != '_') - { - return false; - } - } - - return true; - } } } diff --git a/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs b/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs index b330c6b4c..8993c17a5 100644 --- a/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs @@ -36,7 +36,7 @@ public void ValidateKeyMustMatchRegularExpressionInComponents() Assert.False(result); Assert.NotNull(errors); var error = Assert.Single(errors); - Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, "responses", @"^[a-zA-Z0-9\.\-_]+$"), + Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, key, "responses", OpenApiComponentsRules.KeyPattern), error.Message); } @@ -67,7 +67,6 @@ public void ValidateComponentKeyPreservesRegexBehavior(string key, bool isValid) rules.Add(typeof(OpenApiComponents), OpenApiComponentsRules.KeyMustBeRegularExpression); var errors = components.Validate(rules); - Assert.Equal(isValid, OpenApiComponentsRules.IsValidKey(key)); Assert.Equal(isValid, !errors.Any()); } @@ -77,7 +76,6 @@ public void ValidateComponentKeyPreservesRegexBehavior(string key, bool isValid) public async Task LoadAsyncValidatesLongComponentKeys(bool isValid) { var key = new string('a', 1_000_000) + (isValid ? string.Empty : "!"); - Assert.Equal(isValid, OpenApiComponentsRules.IsValidKey(key)); var json = """ {"openapi":"3.1.0","info":{"title":"Test","version":"1.0"},"paths":{},"components":{"schemas":{ """ + JsonSerializer.Serialize(key) + ":{\"type\":\"string\"}}}}"; @@ -95,7 +93,7 @@ public async Task LoadAsyncValidatesLongComponentKeys(bool isValid) { var error = Assert.Single(result.Diagnostic.Errors); Assert.Equal(string.Format(SRResource.Validation_ComponentsKeyMustMatchRegularExpr, - key, "schemas", @"^[a-zA-Z0-9\.\-_]+$"), error.Message); + key, "schemas", OpenApiComponentsRules.KeyPattern), error.Message); } } } From ebaff49a1da08c39a60308c0c3760b546d5a0c2f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 9 Oct 2026 10:02:23 -0400 Subject: [PATCH 4/5] ci: adds backtracking instructions --- agents.md | 1 + 1 file changed, 1 insertion(+) diff --git a/agents.md b/agents.md index 467bb37b9..abeed736e 100644 --- a/agents.md +++ b/agents.md @@ -1,5 +1,6 @@ # Regex handling +- Analyze regex patterns for matching complexity and excessive backtracking, including on long and near-matching inputs. Prefer equivalent patterns with less backtracking when available, while preserving matching semantics and target-framework compatibility. - For fixed patterns on modern targets, use source-generated regexes with explicit match timeouts (`GeneratedRegex` under `NET8_0_OR_GREATER`). - Use conditional compilation to provide a regular `Regex` with the same pattern and an explicit match timeout for older targets. Do not duplicate regex validation with a manually maintained character scanner. - Older-runtime regex matching may still time out under load because timeouts use wall-clock time. If consumers encounter this limitation, recommend upgrading to a modern runtime that uses the source-generated implementation. From 0a241bdc45f8a0e94fb06ec22029738700f8b61e Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 9 Oct 2026 10:27:21 -0400 Subject: [PATCH 5/5] fix: updates additional regex to use generated versions --- .../Expressions/CompositeExpression.cs | 19 +++++++++++++--- .../Expressions/RuntimeExpressionTests.cs | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs b/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs index cac554318..db2a97caf 100644 --- a/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs +++ b/src/Microsoft.OpenApi/Expressions/CompositeExpression.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -10,10 +11,17 @@ namespace Microsoft.OpenApi /// /// String literal with embedded expressions /// - public class CompositeExpression : RuntimeExpression + public partial class CompositeExpression : RuntimeExpression { private readonly string template; - private readonly Regex expressionPattern = new(@"{(?\$[^}]*)"); + private const string ExpressionPattern = @"{(?\$[^}]*)"; + +#if NET8_0_OR_GREATER + [GeneratedRegex(ExpressionPattern, RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex ExpressionRegex(); +#else + private static readonly Regex ExpressionRegex = new(ExpressionPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100)); +#endif /// /// Expressions embedded into string literal @@ -24,12 +32,17 @@ public class CompositeExpression : RuntimeExpression /// Create a composite expression from a string literal with an embedded expression /// /// + /// Extracting embedded expressions exceeds the regex match timeout. public CompositeExpression(string expression) { template = expression; // Extract subexpressions and convert to RuntimeExpressions - var matches = expressionPattern.Matches(expression); +#if NET8_0_OR_GREATER + var matches = ExpressionRegex().Matches(expression); +#else + var matches = ExpressionRegex.Matches(expression); +#endif foreach (var item in matches.Cast()) { diff --git a/test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs b/test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs index 08a1debc0..91509184b 100644 --- a/test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs +++ b/test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs @@ -145,6 +145,28 @@ public void BuildRuntimeExpressionTwiceCreatesNewEquivalentInstances(string expr Assert.Equal(runtimeExpression1, runtimeExpression2); } + [Fact] + public void CompositeRuntimeExpressionPreservesMultilineCaptures() + { + const string expression = "prefix {$request.header.foo\nbar} {$url} suffix"; + + var composite = Assert.IsType(RuntimeExpression.Build(expression)); + + Assert.Equal(expression, composite.Expression); + Assert.Equal(new[] { "$request.header.foo\nbar", "$url" }, + composite.ContainedExpressions.Select(static item => item.Expression)); + } + + [Fact] + public void CompositeRuntimeExpressionPreservesUnterminatedCapture() + { + const string expression = "prefix {$url"; + + var composite = Assert.IsType(RuntimeExpression.Build(expression)); + + Assert.IsType(Assert.Single(composite.ContainedExpressions)); + } + [Fact] public void CompositeRuntimeExpressionContainsExpression() {