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); + } + } } }