File: Analyzers\ValidationsDiagnosticAnalyzer.cs
Web Access
Project: src\aspnetcore\src\Validation\gen\Microsoft.Extensions.Validation.ValidationsGenerator.csproj (Microsoft.Extensions.Validation.ValidationsGenerator)
// 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.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
using Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
 
namespace Microsoft.Extensions.Validation;
 
/// <summary>
/// Reports cases where the validation source generator silently skips validation that the developer
/// likely intended to run.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
internal sealed class ValidationsDiagnosticAnalyzer : DiagnosticAnalyzer
{
    private const string Usage = "Usage";
 
    private static string GetHelpLinkUri(string id) => $"https://learn.microsoft.com/aspnet/core/diagnostics/{id.ToLowerInvariant()}";
 
    internal static readonly DiagnosticDescriptor ValidatableTypeIsNotAccessible = new(
        "ASP0033",
        "[ValidatableType] is applied to an inaccessible type",
        "The type '{0}' is marked with [ValidatableType] but is not accessible from the generated validation code. The type must be public or internal and must not be a file-local type, otherwise its validation is silently skipped.",
        Usage,
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: null,
        helpLinkUri: GetHelpLinkUri("ASP0033"));
 
    internal static readonly DiagnosticDescriptor EndpointParameterTypeIsNotAccessible = new(
        "ASP0034",
        "Endpoint parameter type is inaccessible from generated code",
        "The type '{1}' of endpoint parameter '{0}' is not accessible from the generated validation code. The type must be public or internal and must not be a file-local type, otherwise its validation is silently skipped.",
        Usage,
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: null,
        helpLinkUri: GetHelpLinkUri("ASP0034"),
        WellKnownDiagnosticTags.CompilationEnd);
 
    internal static readonly DiagnosticDescriptor ValidatablePropertyIsNotAccessible = new(
        "ASP0035",
        "Validatable property or its type on a [ValidatableType] is not accessible",
        "The property '{0}' on the [ValidatableType] '{1}' declares validation but is not public or its type isn't accessible in generated code, so it is silently skipped by the validation source generator",
        Usage,
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: null,
        helpLinkUri: GetHelpLinkUri("ASP0035"));
 
    internal static readonly DiagnosticDescriptor ValidatablePropertyIsNotAccessibleCompilationEnd = new(
        "ASP0036",
        "Validatable property or its type on an endpoint parameter type is not accessible",
        "The property '{0}' on the endpoint parameter type '{1}' declares validation but is not public or its type isn't accessible in generated code, so it is silently skipped by the validation source generator",
        Usage,
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: null,
        helpLinkUri: GetHelpLinkUri("ASP0036"),
        WellKnownDiagnosticTags.CompilationEnd);
 
    internal static readonly DiagnosticDescriptor ValidatableTypeIsUsedWithoutAddValidation = new(
        "ASP0038",
        "[ValidatableType] should not be used without a call to 'AddValidation'",
        "'[ValidatableType]' has no effect if there is no 'AddValidation' call in the current project",
        Usage,
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: null,
        helpLinkUri: GetHelpLinkUri("ASP0038"),
        WellKnownDiagnosticTags.CompilationEnd);
 
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
    [
        ValidatableTypeIsNotAccessible,
        EndpointParameterTypeIsNotAccessible,
        ValidatablePropertyIsNotAccessible,
        ValidatablePropertyIsNotAccessibleCompilationEnd,
        ValidatableTypeIsUsedWithoutAddValidation,
    ];
 
    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterCompilationStartAction(OnCompilationStart);
    }
 
    private static bool IsPropertyIgnoredDueToAccessibility(IPropertySymbol property)
        => property.DeclaredAccessibility != Accessibility.Public ||
            property.GetMethod is not { DeclaredAccessibility: Accessibility.Public } ||
            property.Type.UnwrapType().IsInaccessibleFromGeneratedCode();
 
    private static void AnalyzeType(
        Action<Diagnostic> reportDiagnostic,
        INamedTypeSymbol skipValidationAttributeSymbol,
        INamedTypeSymbol jsonIgnoreAttributeSymbol,
        ITypeSymbol currentType,
        ConcurrentDictionary<ITypeSymbol, byte> allValidatableTypes,
        WellKnownTypes wellKnownTypes,
        bool isCalledFromCompilationEnd)
    {
        if (currentType.SpecialType != SpecialType.None || currentType.IsExemptType(wellKnownTypes))
        {
            return;
        }
 
        if (!allValidatableTypes.TryAdd(currentType, 0))
        {
            return;
        }
 
        foreach (var property in currentType.GetMembers().OfType<IPropertySymbol>())
        {
            if (ValidationsGenerator.ShouldSkipProperty(property, wellKnownTypes, skipValidationAttributeSymbol, jsonIgnoreAttributeSymbol))
            {
                continue;
            }
 
            if (IsPropertyIgnoredDueToAccessibility(property) &&
                !property.DeclaringSyntaxReferences.IsEmpty &&
                (ValidationsGenerator.HasValidationAttributes(property, wellKnownTypes) ||
                TypeHasValidation(property.Type.UnwrapType(), wellKnownTypes)))
            {
                reportDiagnostic(Diagnostic.Create(
                    isCalledFromCompilationEnd ? ValidatablePropertyIsNotAccessibleCompilationEnd : ValidatablePropertyIsNotAccessible,
                    property.Locations.FirstOrDefault(),
                    property.Name,
                    currentType.ToDisplayString()));
            }
        }
    }
 
    private static bool TypeHasValidation(ITypeSymbol type, WellKnownTypes wellKnownTypes)
    {
        if (ValidationsGenerator.HasValidationAttributes(type, wellKnownTypes) ||
            ValidationsGenerator.HasIValidatableObjectInterface(type, wellKnownTypes))
        {
            return true;
        }
 
        foreach (var member in type.GetMembers())
        {
            if (member is not IPropertySymbol property)
            {
                continue;
            }
 
            if (ValidationsGenerator.HasValidationAttributes(property, wellKnownTypes))
            {
                return true;
            }
        }
 
        return false;
    }
 
    private static void OnCompilationStart(CompilationStartAnalysisContext context)
    {
        var compilation = context.Compilation;
        var serviceCollectionExtensionsType = compilation.GetTypeByMetadataName("Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions");
        var validatableTypeAttribute = compilation.GetTypeByMetadataName("Microsoft.Extensions.Validation.ValidatableTypeAttribute");
        var validationEndpointConventionBuilderExtensions = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Builder.ValidationEndpointConventionBuilderExtensions");
 
        var wellKnownTypes = WellKnownTypes.GetOrCreate(compilation);
        var fromServiceMetadata = wellKnownTypes.GetOptional(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_Metadata_IFromServiceMetadata);
        var fromKeyedServiceAttribute = wellKnownTypes.GetOptional(WellKnownTypeData.WellKnownType.Microsoft_Extensions_DependencyInjection_FromKeyedServicesAttribute);
        var skipValidationAttribute = wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_Extensions_Validation_SkipValidationAttribute);
        var jsonIgnoreAttributeSymbol = wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_Text_Json_Serialization_JsonIgnoreAttribute);
 
        var topLevelValidatableTypes = new ConcurrentDictionary<ITypeSymbol, byte>(SymbolEqualityComparer.Default);
        var endpointParameters = new ConcurrentDictionary<IParameterSymbol, byte>(SymbolEqualityComparer.Default);
 
        var validatableTypeAttributeLocations = new ConcurrentDictionary<Location, byte>();
 
        var addValidationFound = false;
 
        if (validatableTypeAttribute is not null)
        {
            // This callback collects types that are attributed with [ValidatableType]
            context.RegisterOperationAction(context =>
            {
                var attributeOperation = (IAttributeOperation)context.Operation;
                if (context.ContainingSymbol is INamedTypeSymbol attributedType &&
                    attributeOperation.Operation is IObjectCreationOperation attributeObjectCreationOperation &&
                    validatableTypeAttribute.Equals(attributeObjectCreationOperation.Constructor?.ContainingType, SymbolEqualityComparer.Default))
                {
                    validatableTypeAttributeLocations.TryAdd(attributeOperation.Syntax.GetLocation(), 0);
 
                    if (attributedType.IsInaccessibleFromGeneratedCode())
                    {
                        context.ReportDiagnostic(Diagnostic.Create(ValidatableTypeIsNotAccessible, attributedType.Locations.FirstOrDefault(), attributedType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)));
                        return;
                    }
 
                    AnalyzeType(context.ReportDiagnostic, skipValidationAttribute, jsonIgnoreAttributeSymbol, attributedType, topLevelValidatableTypes, wellKnownTypes, isCalledFromCompilationEnd: false);
                }
            }, OperationKind.Attribute);
        }
 
        // This callback collects:
        // 1. Whether AddValidation is called.
        // 2. The types of minimal API parameters.
        context.RegisterOperationAction(context =>
        {
            var invocation = (IInvocationOperation)context.Operation;
 
            if (IsAddValidationInvocation(invocation, serviceCollectionExtensionsType))
            {
                addValidationFound = true;
                return;
            }
 
            var semanticModel = context.Operation.SemanticModel!;
            if (InvocationOperationExtensions.KnownMethods.Contains(invocation.TargetMethod.Name) &&
                invocation.TryGetRouteHandlerMethod(semanticModel, needsAccurateSignature: false, out var routeHandlerMethod))
            {
                if (IsValidationDisabledForEndpoint(invocation, validationEndpointConventionBuilderExtensions))
                {
                    return;
                }
 
                foreach (var parameter in routeHandlerMethod.Parameters)
                {
                    if (parameter.IsServiceParameter(fromServiceMetadata, fromKeyedServiceAttribute)
                        || parameter.IsSkippedValidationParameter(skipValidationAttribute))
                    {
                        continue;
                    }
 
                    if (parameter.Type.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T)
                    {
                        // Not very accurate check for services, but fine for the case of the analyzer.
                        // We don't want to report a diagnostic for a service parameter.
                        // IEnumerable<T> isn't guaranteed to be a service though.
                        // If it wasn't a service and we skipped it, we will only have a false negative.
                        continue;
                    }
 
                    endpointParameters.TryAdd(parameter, 0);
                }
            }
        }, OperationKind.Invocation);
 
        context.RegisterCompilationEndAction(context =>
        {
            if (!addValidationFound)
            {
                foreach (var validatableTypeAttributeLocation in validatableTypeAttributeLocations.Keys)
                {
                    context.ReportDiagnostic(Diagnostic.Create(
                        ValidatableTypeIsUsedWithoutAddValidation,
                        validatableTypeAttributeLocation));
                }
 
                return;
            }
 
            foreach (var parameter in endpointParameters.Keys)
            {
                var type = parameter.Type.UnwrapType();
                if (type.IsInaccessibleFromGeneratedCode())
                {
                    context.ReportDiagnostic(Diagnostic.Create(EndpointParameterTypeIsNotAccessible, parameter.Locations.FirstOrDefault(), parameter.Name, type.Name));
                    continue;
                }
 
                AnalyzeType(context.ReportDiagnostic, skipValidationAttribute, jsonIgnoreAttributeSymbol, type, topLevelValidatableTypes, wellKnownTypes, isCalledFromCompilationEnd: true);
            }
        });
    }
 
    private static bool IsAddValidationInvocation(IInvocationOperation invocation, INamedTypeSymbol? extensionsType)
    {
        var method = invocation.TargetMethod;
        return method.Name == "AddValidation" &&
            method.ContainingType.Equals(extensionsType, SymbolEqualityComparer.Default);
    }
 
    private static bool IsValidationDisabledForEndpoint(IInvocationOperation routeHandlerInvocation, INamedTypeSymbol? validationEndpointConventionBuilderExtensions)
    {
        if (validationEndpointConventionBuilderExtensions is null)
        {
            return false;
        }
 
        // Walk up the fluent chain (e.g. app.MapPost(...).WithName(...).DisableValidation())
        // to determine whether DisableValidation() is applied to this endpoint.
        var current = routeHandlerInvocation.Parent;
        while (current is not null && current is not IExpressionStatementOperation)
        {
            if (IsDisableValidationInvocation(current, validationEndpointConventionBuilderExtensions))
            {
                return true;
            }
 
            current = current.Parent;
        }
 
        // Walk down the receiver chain (e.g. app.MapGroup(...).DisableValidation().MapPost(...))
        // to determine whether DisableValidation() is applied to a group the endpoint belongs to.
        var receiver = GetInvocationReceiver(routeHandlerInvocation);
        while (receiver is not null)
        {
            if (receiver is IConversionOperation conversion)
            {
                receiver = conversion.Operand;
                continue;
            }
 
            if (receiver is IInvocationOperation receiverInvocation)
            {
                if (IsDisableValidationInvocation(receiverInvocation, validationEndpointConventionBuilderExtensions))
                {
                    return true;
                }
 
                receiver = GetInvocationReceiver(receiverInvocation);
                continue;
            }
 
            break;
        }
 
        return false;
    }
 
    private static bool IsDisableValidationInvocation(IOperation operation, INamedTypeSymbol validationEndpointConventionBuilderExtensions)
        => operation is IInvocationOperation invocation &&
            invocation.TargetMethod.Name == "DisableValidation" &&
            invocation.TargetMethod.ContainingType.Equals(validationEndpointConventionBuilderExtensions, SymbolEqualityComparer.Default);
 
    private static IOperation? GetInvocationReceiver(IInvocationOperation invocation)
    {
        if (invocation.Instance is not null)
        {
            return invocation.Instance;
        }
 
        // For a reduced extension method invocation (e.g. builder.MapPost(...)), the receiver is
        // passed as the first argument rather than exposed via Instance.
        if (invocation.TargetMethod.IsExtensionMethod && !invocation.Arguments.IsEmpty)
        {
            return invocation.Arguments[0].Value;
        }
 
        return null;
    }
}