diff --git a/agents.md b/agents.md
new file mode 100644
index 000000000..abeed736e
--- /dev/null
+++ b/agents.md
@@ -0,0 +1,7 @@
+# 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.
+- Keep shared patterns in constants and reference those constants in validation diagnostics and tests.
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/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs
index e3c6e1b4f..7473bce91 100644
--- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs
+++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiComponentsRules.cs
@@ -11,12 +11,19 @@ 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();
+#else
+ private static readonly Regex KeyRegex = new(KeyPattern, RegexOptions.None, TimeSpan.FromMilliseconds(100));
+#endif
///
/// All the fixed fields declared above are objects
@@ -54,12 +61,18 @@ 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 = KeyRegex.IsMatch(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));
}
}
}
+
}
}
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()
{
diff --git a/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs b/test/Microsoft.OpenApi.Tests/Validations/OpenApiComponentsValidationTests.cs
index 68f89a2a2..8993c17a5 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,65 @@ 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", OpenApiComponentsRules.KeyPattern),
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, !errors.Any());
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task LoadAsyncValidatesLongComponentKeys(bool isValid)
+ {
+ var key = new string('a', 1_000_000) + (isValid ? string.Empty : "!");
+ 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", OpenApiComponentsRules.KeyPattern), error.Message);
+ }
+ }
}
}