Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions agents.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 16 additions & 3 deletions src/Microsoft.OpenApi/Expressions/CompositeExpression.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,10 +11,17 @@ namespace Microsoft.OpenApi
/// <summary>
/// String literal with embedded expressions
/// </summary>
public class CompositeExpression : RuntimeExpression
public partial class CompositeExpression : RuntimeExpression
{
private readonly string template;
private readonly Regex expressionPattern = new(@"{(?<exp>\$[^}]*)");
private const string ExpressionPattern = @"{(?<exp>\$[^}]*)";

#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

/// <summary>
/// Expressions embedded into string literal
Expand All @@ -24,12 +32,17 @@ public class CompositeExpression : RuntimeExpression
/// Create a composite expression from a string literal with an embedded expression
/// </summary>
/// <param name="expression"></param>
/// <exception cref="RegexMatchTimeoutException">Extracting embedded expressions exceeds the regex match timeout.</exception>
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<Match>())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@ namespace Microsoft.OpenApi
/// The validation rules for <see cref="OpenApiComponents"/>.
/// </summary>
[OpenApiRule]
public static class OpenApiComponentsRules
public static partial class OpenApiComponentsRules
{
/// <summary>
/// The key regex.
/// The key regex pattern.
/// </summary>
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

/// <summary>
/// All the fixed fields declared above are objects
Expand Down Expand Up @@ -54,12 +61,18 @@ private static void ValidateKeys(IValidationContext context, IEnumerable<string>

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

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,28 @@
Assert.Equal(runtimeExpression1, runtimeExpression2);
}

[Fact]
public void CompositeRuntimeExpressionPreservesMultilineCaptures()
{
const string expression = "prefix {$request.header.foo\nbar} {$url} suffix";

var composite = Assert.IsType<CompositeExpression>(RuntimeExpression.Build(expression));

Assert.Equal(expression, composite.Expression);
Assert.Equal(new[] { "$request.header.foo\nbar", "$url" },

Check warning on line 156 in test/Microsoft.OpenApi.Tests/Expressions/RuntimeExpressionTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array

See more on https://sonarcloud.io/project/issues?id=microsoft_OpenAPI.NET&issues=AaEhxCBqtTVbIwfVsthW&open=AaEhxCBqtTVbIwfVsthW&pullRequest=3112
composite.ContainedExpressions.Select(static item => item.Expression));
}

[Fact]
public void CompositeRuntimeExpressionPreservesUnterminatedCapture()
{
const string expression = "prefix {$url";

var composite = Assert.IsType<CompositeExpression>(RuntimeExpression.Build(expression));

Assert.IsType<UrlExpression>(Assert.Single(composite.ContainedExpressions));
}

[Fact]
public void CompositeRuntimeExpressionContainsExpression()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, IOpenApiSchema>
{
{ 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);
}
}
}
}
Loading