File: Mvc\DetectAmbiguousActionRoutes.cs
Web Access
Project: src\src\Framework\AspNetCoreAnalyzers\src\Analyzers\Microsoft.AspNetCore.App.Analyzers.csproj (Microsoft.AspNetCore.App.Analyzers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Analyzers.Infrastructure;
using Microsoft.AspNetCore.Analyzers.Infrastructure.RoutePattern;
using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
 
namespace Microsoft.AspNetCore.Analyzers.Mvc;
 
using WellKnownType = WellKnownTypeData.WellKnownType;
 
public partial class MvcAnalyzer
{
    private static void DetectAmbiguousActionRoutes(SymbolAnalysisContext context, WellKnownTypes wellKnownTypes, RoutePatternTree? controllerRoutePattern, List<ActionRoute> actionRoutes)
    {
        var controllerHasActionToken = controllerRoutePattern != null ? HasActionToken(controllerRoutePattern) : false;
 
        // Ambiguous action route detection is conservative in what it detects to avoid false positives.
        //
        // Successfully matched action routes must:
        // 1. Be in the same controller.
        // 2. Have an equivalent route.
        // 3. Have a matching HTTP method.
        // 4. Route either be the on the same action or the actions only have known safe attributes that don't impact matching.
        if (actionRoutes.Count > 0)
        {
            // Group action routes together. When multiple match in a group, then report action routes to diagnostics.
            var groupedByParent = actionRoutes
                .GroupBy(ar => new ActionRouteGroupKey(ar.ActionSymbol, ar.RouteUsageModel.RoutePattern, ar.HttpMethods, controllerHasActionToken, wellKnownTypes));
 
            foreach (var ambiguousGroup in groupedByParent.Where(g => g.Count() >= 2))
            {
                foreach (var ambiguousActionRoute in ambiguousGroup)
                {
                    context.ReportDiagnostic(Diagnostic.Create(
                        DiagnosticDescriptors.AmbiguousActionRoute,
                        ambiguousActionRoute.RouteUsageModel.UsageContext.RouteToken.GetLocation(),
                        ambiguousActionRoute.RouteUsageModel.RoutePattern.Root.ToString()));
                }
            }
        }
    }
 
    /// <summary>
    /// Search route pattern for:
    /// 1. Action replacement tokens, [action]
    /// 2. Action parameter tokens, {action}
    /// </summary>
    private static bool HasActionToken(RoutePatternTree routePattern)
    {
        for (var i = 0; i < routePattern.Root.Parts.Length; i++)
        {
            if (routePattern.Root.Parts[i] is RoutePatternSegmentNode segment)
            {
                for (var j = 0; j < segment.Children.Length; j++)
                {
                    if (segment.Children[j] is RoutePatternReplacementNode replacementNode)
                    {
                        if (!replacementNode.TextToken.IsMissing)
                        {
                            var name = replacementNode.TextToken.Value!.ToString();
                            if (string.Equals(name, "action", StringComparison.OrdinalIgnoreCase))
                            {
                                return true;
                            }
                        }
                    }
                    else if (segment.Children[j] is RoutePatternParameterNode parameterNode)
                    {
                        for (var k = 0; k < parameterNode.ParameterParts.Length; k++)
                        {
                            if (parameterNode.ParameterParts[k] is RoutePatternNameParameterPartNode namePartNode)
                            {
                                if (!namePartNode.ParameterNameToken.IsMissing)
                                {
                                    var name = namePartNode.ParameterNameToken.Value!.ToString();
                                    if (string.Equals(name, "action", StringComparison.OrdinalIgnoreCase))
                                    {
                                        return true;
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
 
        return false;
    }
 
    private readonly struct ActionRouteGroupKey : IEquatable<ActionRouteGroupKey>
    {
        public IMethodSymbol ActionSymbol { get; }
        public RoutePatternTree RoutePattern { get; }
        public ImmutableArray<string> HttpMethods { get; }
        public string ActionName { get; }
        public bool HasActionToken { get; }
        private readonly WellKnownTypes _wellKnownTypes;
 
        public ActionRouteGroupKey(IMethodSymbol actionSymbol, RoutePatternTree routePattern, ImmutableArray<string> httpMethods, bool controllerHasActionToken, WellKnownTypes wellKnownTypes)
        {
            Debug.Assert(!httpMethods.IsDefault);
 
            ActionSymbol = actionSymbol;
            RoutePattern = routePattern;
            HttpMethods = httpMethods;
            _wellKnownTypes = wellKnownTypes;
            ActionName = GetActionName(ActionSymbol, _wellKnownTypes);
            HasActionToken = controllerHasActionToken || HasActionToken(RoutePattern);
        }
 
        private static string GetActionName(IMethodSymbol actionSymbol, WellKnownTypes wellKnownTypes)
        {
            var actionNameAttribute = actionSymbol.GetAttributes(wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Mvc_ActionNameAttribute), inherit: true).FirstOrDefault();
            if (actionNameAttribute != null && actionNameAttribute.ConstructorArguments.Length > 0 && actionNameAttribute.ConstructorArguments[0].Value is string name)
            {
                return name;
            }
            return actionSymbol.Name;
        }
 
        public override bool Equals(object obj)
        {
            if (obj is ActionRouteGroupKey key)
            {
                return Equals(key);
            }
            return false;
        }
 
        public bool Equals(ActionRouteGroupKey other)
        {
            return
                AmbiguousRoutePatternComparer.Instance.Equals(RoutePattern, other.RoutePattern) &&
                (!HasActionToken || string.Equals(ActionName, other.ActionName, StringComparison.OrdinalIgnoreCase)) &&
                HasMatchingHttpMethods(HttpMethods, other.HttpMethods) &&
                CanMatchActions(_wellKnownTypes, ActionSymbol, other.ActionSymbol);
        }
 
        private static bool CanMatchActions(WellKnownTypes wellKnownTypes, IMethodSymbol actionSymbol1, IMethodSymbol actionSymbol2)
        {
            // Only match routes if either they are on the same action.
            if (SymbolEqualityComparer.Default.Equals(actionSymbol1, actionSymbol2))
            {
                return true;
            }
 
            // Or all attributes on the actions are known to have no impact on routing.
            // This ensures we don't detect routes that might have metadata added that impacts routing.
            if (!HasUnknownAttribute(actionSymbol1, wellKnownTypes) && !HasUnknownAttribute(actionSymbol2, wellKnownTypes))
            {
                return true;
            }
 
            return false;
        }
 
        // A collection of attributes in ASP.NET Core that don't have any impact on route matching and are safe.
        // Note that route attributes such as [HttpGet] and friends are safe because we compare the route and HTTP method explicitly.
        private static readonly WellKnownType[] KnownMethodAttributeTypes = new[]
        {
            WellKnownType.Microsoft_AspNetCore_Mvc_RouteAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_HttpDeleteAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_HttpGetAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_HttpHeadAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_HttpOptionsAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_HttpPatchAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_HttpPostAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_HttpPutAttribute,
            WellKnownType.Microsoft_AspNetCore_Http_EndpointDescriptionAttribute,
            WellKnownType.Microsoft_AspNetCore_Http_EndpointSummaryAttribute,
            WellKnownType.Microsoft_AspNetCore_Http_TagsAttribute,
            WellKnownType.Microsoft_AspNetCore_Routing_EndpointGroupNameAttribute,
            WellKnownType.Microsoft_AspNetCore_Routing_EndpointNameAttribute,
            WellKnownType.Microsoft_AspNetCore_Routing_ExcludeFromDescriptionAttribute,
            WellKnownType.Microsoft_AspNetCore_Cors_DisableCorsAttribute,
            WellKnownType.Microsoft_AspNetCore_Cors_EnableCorsAttribute,
            WellKnownType.Microsoft_AspNetCore_OutputCaching_OutputCacheAttribute,
            WellKnownType.Microsoft_AspNetCore_RateLimiting_DisableRateLimitingAttribute,
            WellKnownType.Microsoft_AspNetCore_RateLimiting_EnableRateLimitingAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ActionNameAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_DisableRequestSizeLimitAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_FormatFilterAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ProducesAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ProducesDefaultResponseTypeAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ProducesErrorResponseTypeAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ProducesResponseTypeAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_RequestFormLimitsAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_RequestSizeLimitAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_RequireHttpsAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ResponseCacheAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ServiceFilterAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_TypeFilterAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ApiExplorer_ApiConventionNameMatchAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_Filters_ResultFilterAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_Infrastructure_DefaultStatusCodeAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_AutoValidateAntiforgeryTokenAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_IgnoreAntiforgeryTokenAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ViewFeatures_SaveTempDataAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_SkipStatusCodePagesAttribute,
            WellKnownType.Microsoft_AspNetCore_Mvc_ValidateAntiForgeryTokenAttribute,
            WellKnownType.Microsoft_AspNetCore_Authorization_AllowAnonymousAttribute,
            WellKnownType.Microsoft_AspNetCore_Authorization_AuthorizeAttribute
        };
 
        private static bool HasUnknownAttribute(IMethodSymbol actionSymbol, WellKnownTypes wellKnownTypes)
        {
            foreach (var attribute in actionSymbol.GetAttributes())
            {
                if (attribute.AttributeClass is null)
                {
                    return true;
                }
 
                if (!wellKnownTypes.IsType(attribute.AttributeClass, KnownMethodAttributeTypes))
                {
                    return true;
                }
            }
 
            return false;
        }
 
        private static bool HasMatchingHttpMethods(ImmutableArray<string> httpMethods1, ImmutableArray<string> httpMethods2)
        {
            if (httpMethods1.IsEmpty || httpMethods2.IsEmpty)
            {
                return true;
            }
 
            foreach (var item1 in httpMethods1)
            {
                foreach (var item2 in httpMethods2)
                {
                    if (item2 == item1)
                    {
                        return true;
                    }
                }
            }
 
            return false;
        }
 
        public override int GetHashCode()
        {
            var hashCode = 0;
            foreach (var method in HttpMethods)
            {
                hashCode ^= StringComparer.OrdinalIgnoreCase.GetHashCode(method);
            }
            return hashCode ^ AmbiguousRoutePatternComparer.Instance.GetHashCode(RoutePattern);
        }
    }
}