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()
{