Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Implement rule
  • Loading branch information
Alex-Sob committed Sep 25, 2025
commit c00179cbc4d37e1af9d4103863597e33ce5e2b76
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Runtime.CompilerServices;
using Microsoft.CodeAnalysis;

namespace Microsoft.AspNetCore.Analyzers;
Expand Down Expand Up @@ -248,4 +249,18 @@ internal static class DiagnosticDescriptors
DiagnosticSeverity.Info,
isEnabledByDefault: true,
helpLinkUri: AnalyzersLink);

internal static readonly DiagnosticDescriptor InvalidRouteConstraintForParameterType = CreateDescriptor(
"ASP0029",
Usage,
DiagnosticSeverity.Info);

private static DiagnosticDescriptor CreateDescriptor(string id, string category, DiagnosticSeverity defaultSeverity, bool isEnabledByDefault = true, [CallerMemberName] string? name = null) => new(
id,
CreateLocalizableResourceString($"Analyzer_{name}_Title"),
CreateLocalizableResourceString($"Analyzer_{name}_Message"),
category,
defaultSeverity,
isEnabledByDefault,
helpLinkUri: AnalyzersLink);
}
Original file line number Diff line number Diff line change
Expand Up @@ -333,4 +333,10 @@
<data name="Analyzer_KestrelShouldListenOnIPv6AnyInsteadOfIpAny_Message" xml:space="preserve">
<value>If the server does not specifically reject IPv6, IPAddress.IPv6Any is preferred over IPAddress.Any usage for safety and performance reasons. See https://aka.ms/aspnetcore-warnings/ASP0028 for more details.</value>
</data>
<data name="Analyzer_InvalidRouteConstraintForParameterType_Title" xml:space="preserve">
<value>Invalid constraint for parameter type</value>
</data>
<data name="Analyzer_InvalidRouteConstraintForParameterType_Message" xml:space="preserve">
<value>The constraint '{0}' on parameter '{1}' can't be used with type '{2}'</value>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.AspNetCore.Analyzers.RouteHandlers;

Expand All @@ -20,16 +21,18 @@ public partial class RouteHandlerAnalyzer : DiagnosticAnalyzer
{
private const int DelegateParameterOrdinal = 2;

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
[
DiagnosticDescriptors.DoNotUseModelBindingAttributesOnRouteHandlerParameters,
DiagnosticDescriptors.DoNotReturnActionResultsFromRouteHandlers,
DiagnosticDescriptors.DetectMisplacedLambdaAttribute,
DiagnosticDescriptors.DetectMismatchedParameterOptionality,
DiagnosticDescriptors.RouteParameterComplexTypeIsNotParsable,
DiagnosticDescriptors.BindAsyncSignatureMustReturnValueTaskOfT,
DiagnosticDescriptors.AmbiguousRouteHandlerRoute,
DiagnosticDescriptors.AtMostOneFromBodyAttribute
);
DiagnosticDescriptors.AtMostOneFromBodyAttribute,
DiagnosticDescriptors.InvalidRouteConstraintForParameterType
];

public override void Initialize(AnalysisContext context)
{
Expand Down Expand Up @@ -74,15 +77,9 @@ void DoOperationAnalysis(OperationAnalysisContext context, ConcurrentDictionary<
return;
}

IDelegateCreationOperation? delegateCreation = null;
foreach (var argument in invocation.Arguments)
{
if (argument.Parameter?.Ordinal == DelegateParameterOrdinal)
{
delegateCreation = argument.Descendants().OfType<IDelegateCreationOperation>().FirstOrDefault();
break;
}
}
// Already checked there are 3 arguments
var deleateArg = invocation.Arguments[DelegateParameterOrdinal];
var delegateCreation = (IDelegateCreationOperation?)deleateArg.Descendants().FirstOrDefault(static d => d is IDelegateCreationOperation);

if (delegateCreation is null)
{
Expand All @@ -100,6 +97,8 @@ void DoOperationAnalysis(OperationAnalysisContext context, ConcurrentDictionary<
return;
}

AnalyzeRouteConstraints(routeUsage, wellKnownTypes, context);

mapOperations.TryAdd(MapOperation.Create(invocation, routeUsage), value: default);

if (delegateCreation.Target.Kind == OperationKind.AnonymousFunction)
Expand Down Expand Up @@ -172,23 +171,16 @@ void DoOperationAnalysis(OperationAnalysisContext context, ConcurrentDictionary<

private static bool TryGetStringToken(IInvocationOperation invocation, out SyntaxToken token)
{
IArgumentOperation? argumentOperation = null;
foreach (var argument in invocation.Arguments)
{
if (argument.Parameter?.Ordinal == 1)
{
argumentOperation = argument;
}
}
var argumentOperation = invocation.Arguments[1];

if (argumentOperation?.Syntax is not ArgumentSyntax routePatternArgumentSyntax ||
routePatternArgumentSyntax.Expression is not LiteralExpressionSyntax routePatternArgumentLiteralSyntax)
if (argumentOperation.Value is not ILiteralOperation literal)
{
token = default;
return false;
}

token = routePatternArgumentLiteralSyntax.Token;
var syntax = (LiteralExpressionSyntax)literal.Syntax;
token = syntax.Token;
return true;
}

Expand Down Expand Up @@ -218,6 +210,87 @@ static bool IsCompatibleDelegateType(WellKnownTypes wellKnownTypes, IMethodSymbo
}
}

private static void AnalyzeRouteConstraints(RouteUsageModel routeUsage, WellKnownTypes wellKnownTypes, OperationAnalysisContext context)
{
foreach (var routeParam in routeUsage.RoutePattern.RouteParameters)
{
var handlerParam = GetHandlerParam(routeParam.Name, routeUsage);

if (handlerParam is null)
{
continue;
}

foreach (var policy in routeParam.Policies)
{
if (IsConstraintInvalidForType(policy, handlerParam.Type, wellKnownTypes))
{
var descriptor = DiagnosticDescriptors.InvalidRouteConstraintForParameterType;
var start = routeParam.Span.Start + routeParam.Name.Length + 2; // including '{' and ':'
var textSpan = new TextSpan(start, routeParam.Span.End - start - 1); // excluding '}'
var location = Location.Create(context.FilterTree, textSpan);
var diagnostic = Diagnostic.Create(descriptor, location, policy.AsMemory(1), routeParam.Name, handlerParam.Type.ToString());

context.ReportDiagnostic(diagnostic);
}
}
}
}

private static bool IsConstraintInvalidForType(string policy, ITypeSymbol type, WellKnownTypes wellKnownTypes)
{
if (policy.EndsWith(")", StringComparison.Ordinal)) // Parameterized constraint
{
var braceIndex = policy.IndexOf('(');

if (braceIndex == -1)
{
return false;
}

var constraint = policy.AsSpan(1, braceIndex - 1);

return constraint switch
{
"length" or "minlength" or "maxlength" or "regex" when type.SpecialType is not SpecialType.System_String => true,
"min" or "max" or "range" when type.SpecialType < SpecialType.System_SByte || type.SpecialType > SpecialType.System_UInt64 => true,
_ => false
};
}
else // Simple constraint
{
var constraint = policy.AsSpan(1);

return constraint switch
{
"int" when type.SpecialType < SpecialType.System_SByte || type.SpecialType > SpecialType.System_UInt64 => true,
"bool" when type.SpecialType is not SpecialType.System_Boolean => true,
"datetime" when type.SpecialType is not SpecialType.System_DateTime => true,
"double" when type.SpecialType is not SpecialType.System_Double => true,
"guid" when !type.Equals(wellKnownTypes.Get(WellKnownType.System_Guid), SymbolEqualityComparer.Default) => true,
"long" when type.SpecialType is not SpecialType.System_Int64 and not SpecialType.System_UInt64 => true,
"decimal" when type.SpecialType is not SpecialType.System_Decimal => true,
"float" when type.SpecialType is not SpecialType.System_Single => true,
"alpha" when type.SpecialType is not SpecialType.System_String => true,
"file" or "nonfile" when type.SpecialType is not SpecialType.System_String => true,
_ => false
};
}
}

private static IParameterSymbol? GetHandlerParam(string name, RouteUsageModel routeUsage)
{
foreach (var param in routeUsage.UsageContext.Parameters)
{
if (param.Name.Equals(name, StringComparison.Ordinal))
{
return (IParameterSymbol)param;
}
}

return null;
}

private record struct MapOperation(IOperation? Builder, IInvocationOperation Operation, RouteUsageModel RouteUsageModel)
{
public static MapOperation Create(IInvocationOperation operation, RouteUsageModel routeUsageModel)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Analyzers.Verifiers;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;

namespace Microsoft.AspNetCore.Analyzers;

public static class CSharpAnalyzerTestExtensions
{
extension<TAnalyzer, TVerifier>(CSharpAnalyzerTest<TAnalyzer, TVerifier>)
where TAnalyzer : DiagnosticAnalyzer, new()
where TVerifier : IVerifier, new()
{
public static CSharpAnalyzerTest<TAnalyzer, TVerifier> Create([StringSyntax("C#-test")] string source, params ReadOnlySpan<DiagnosticResult> expectedDiagnostics)
{
var test = new CSharpAnalyzerTest<TAnalyzer, TVerifier>
{
TestCode = source.ReplaceLineEndings(),
// We need to set the output type to an exe to properly
// support top-level programs in the tests. Otherwise,
// the test infra will assume we are trying to build a library.
TestState = { OutputKind = OutputKind.ConsoleApplication },
ReferenceAssemblies = CSharpAnalyzerVerifier<TAnalyzer>.GetReferenceAssemblies(),
};

test.ExpectedDiagnostics.AddRange(expectedDiagnostics);
return test;
}
}

public static CSharpAnalyzerTest<TAnalyzer, TVerifier> WithSource<TAnalyzer, TVerifier>(this CSharpAnalyzerTest<TAnalyzer, TVerifier> test, [StringSyntax("C#-test")] string source)
where TAnalyzer : DiagnosticAnalyzer, new()
where TVerifier : IVerifier, new()
{
test.TestState.Sources.Add(source);
return test;
}
}
Loading