Skip to content
Open
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
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.Error);

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 @@ -4,12 +4,14 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
using Microsoft.CodeAnalysis;
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 +22,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 +78,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 +98,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 +172,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 +211,133 @@ 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 !IsIntegerType(type) && !IsNullableIntegerType(type) => true,
_ => false
};
}
else // Simple constraint
{
var constraint = policy.AsSpan(1);

return constraint switch
{
"int" when !IsIntegerType(type) && !IsNullableIntegerType(type) => true,
"bool" when !IsValueTypeOrNullableValueType(type, SpecialType.System_Boolean) => true,
"datetime" when !IsValueTypeOrNullableValueType(type, SpecialType.System_DateTime) => true,
"double" when !IsValueTypeOrNullableValueType(type, SpecialType.System_Double) => true,
"guid" when !IsGuidType(type, wellKnownTypes) && !IsNullableGuidType(type, wellKnownTypes) => true,
"long" when !IsLongType(type) && !IsNullableLongType(type) => true,
"decimal" when !IsValueTypeOrNullableValueType(type, SpecialType.System_Decimal) => true,
"float" when !IsValueTypeOrNullableValueType(type, 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 static bool IsGuidType(ITypeSymbol type, WellKnownTypes wellKnownTypes)
{
return type.Equals(wellKnownTypes.Get(WellKnownType.System_Guid), SymbolEqualityComparer.Default);
}

private static bool IsIntegerType(ITypeSymbol type)
{
return type.SpecialType >= SpecialType.System_SByte && type.SpecialType <= SpecialType.System_UInt64;
}

private static bool IsLongType(ITypeSymbol type)
{
return type.SpecialType is SpecialType.System_Int64 or SpecialType.System_UInt64;
}

private static bool IsNullableGuidType(ITypeSymbol type, WellKnownTypes wellKnownTypes)
{
return IsNullableType(type, out var namedType) && IsGuidType(namedType.TypeArguments[0], wellKnownTypes);
}

private static bool IsNullableIntegerType(ITypeSymbol type)
{
return IsNullableType(type, out var namedType) && IsIntegerType(namedType.TypeArguments[0]);
}

private static bool IsNullableLongType(ITypeSymbol type)
{
return IsNullableType(type, out var namedType) && IsLongType(namedType.TypeArguments[0]);
}

public static bool IsNullableType(ITypeSymbol type, [NotNullWhen(true)] out INamedTypeSymbol? namedType)
{
namedType = type as INamedTypeSymbol;
return namedType != null && namedType.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T;
}

private static bool IsNullableValueType(ITypeSymbol type, SpecialType specialType)
{
return IsNullableType(type, out var namedType) && namedType.TypeArguments[0].SpecialType == specialType;
}

private static bool IsValueTypeOrNullableValueType(ITypeSymbol type, SpecialType specialType)
{
return type.SpecialType == specialType || IsNullableValueType(type, specialType);
}

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,15 @@ public async Task OptionalRouteParamRequiredArgument_WithRegexConstraint_Produce
using Microsoft.AspNetCore.Builder;

var app = WebApplication.Create();
app.MapGet(""/hello/{age:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)?}"", ({|#0:int age|}) => $""Age: {age}"");
app.MapGet(""/hello/{age:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)?}"", ({|#0:string age|}) => $""Age: {age}"");
";

var fixedSource = @"
#nullable enable
using Microsoft.AspNetCore.Builder;

var app = WebApplication.Create();
app.MapGet(""/hello/{age:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)?}"", (int? age) => $""Age: {age}"");
app.MapGet(""/hello/{age:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)?}"", (string? age) => $""Age: {age}"");
";
var expectedDiagnostic = new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("age").WithLocation(0);

Expand Down
Loading
Loading