File: SharedAnalyzerHelpers.cs
Web Access
Project: TaskAnalyzer.csproj (Microsoft.Build.TaskAuthoring.Analyzer)
// 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 Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace Microsoft.Build.TaskAuthoring.Analyzer
{
    /// <summary>
    /// Shared helpers used by both <see cref="MultiThreadableTaskAnalyzer"/> and
    /// <see cref="TransitiveCallChainAnalyzer"/> for path safety analysis and banned API resolution.
    /// </summary>
    internal static class SharedAnalyzerHelpers
    {
        /// <summary>
        /// The analyzer configuration key that enables MT migration diagnostics for all tasks.
        /// </summary>
        internal const string AnalyzeAllTasksOptionKey = "msbuild_task_analyzer.run_mt_analyzers_on_all_tasks";

        /// <summary>
        /// Reads the effective option for a source tree from the analyzer config options provider.
        /// Returns true only when all-task migration analysis is explicitly enabled.
        /// </summary>
        internal static bool ReadAnalyzeAllTasksOption(
            AnalyzerConfigOptionsProvider optionsProvider,
            SyntaxTree? syntaxTree)
        {
            AnalyzerConfigOptions options = syntaxTree is null
                ? optionsProvider.GlobalOptions
                : optionsProvider.GetOptions(syntaxTree);

            return options.TryGetValue(AnalyzeAllTasksOptionKey, out string? optionValue) &&
                bool.TryParse(optionValue, out bool enabled) &&
                enabled;
        }

        /// <summary>
        /// Reports whether the type opts into MT migration analysis, through
        /// <c>[MSBuildMultiThreadableTask]</c>, <c>[MSBuildMultiThreadableTaskAnalyzed]</c>, or by declaring
        /// <c>IMultiThreadableTask</c> in its own base list.
        /// <para>
        /// The declared interface is a migration-intent signal for the analyzer only. It does not change
        /// runtime routing: <c>[MSBuildMultiThreadableTask]</c> remains the attribute that routes a task to
        /// the MT environment.
        /// </para>
        /// </summary>
        internal static bool IsMtAnalysisOptIn(
            INamedTypeSymbol type,
            INamedTypeSymbol? multiThreadableTaskType,
            out bool hasAnalyzedAttribute)
        {
            bool hasMultiThreadableAttribute = false;
            hasAnalyzedAttribute = false;

            foreach (AttributeData attribute in type.GetAttributes())
            {
                string? attributeName = attribute.AttributeClass?.ToDisplayString();
                if (string.Equals(
                    attributeName,
                    WellKnownTypeNames.MultiThreadableTaskAttributeFullName,
                    StringComparison.Ordinal))
                {
                    hasMultiThreadableAttribute = true;
                }

                if (string.Equals(
                    attributeName,
                    WellKnownTypeNames.AnalyzedAttributeFullName,
                    StringComparison.Ordinal))
                {
                    hasAnalyzedAttribute = true;
                }
            }

            return hasMultiThreadableAttribute ||
                hasAnalyzedAttribute ||
                DeclaresMultiThreadableTaskInterface(type, multiThreadableTaskType);
        }

        /// <summary>
        /// Reports whether the type opts into <c>IMultiThreadableTask</c> in its own base list, rather
        /// than merely inheriting it.
        /// <para>
        /// <c>ToolTask</c> implements <c>IMultiThreadableTask</c>, so every <c>ToolTask</c>-derived task in
        /// the ecosystem satisfies the interface without its author having declared anything. Treating an
        /// inherited implementation as intent would report thousands of untouched tasks, which is the same
        /// reason <c>TaskRouter</c> cannot use the interface as a routing signal. Detection therefore
        /// inspects <see cref="INamedTypeSymbol.Interfaces"/> and never
        /// <see cref="INamedTypeSymbol.AllInterfaces"/>.
        /// </para>
        /// </summary>
        internal static bool DeclaresMultiThreadableTaskInterface(
            INamedTypeSymbol type,
            INamedTypeSymbol? multiThreadableTaskType)
        {
            if (multiThreadableTaskType is null)
            {
                return false;
            }

            foreach (INamedTypeSymbol declaredInterface in type.Interfaces)
            {
                if (SymbolEqualityComparer.Default.Equals(declaredInterface.OriginalDefinition, multiThreadableTaskType))
                {
                    return true;
                }
            }

            return false;
        }

        internal static bool IsDirectlyAnalyzedType(
            INamedTypeSymbol type,
            INamedTypeSymbol iTaskType,
            INamedTypeSymbol? multiThreadableTaskType,
            ImmutableHashSet<INamedTypeSymbol> contributingMultiThreadableTaskBaseTypes,
            out bool analyzeAsMultiThreadable)
        {
            bool isTask = ImplementsInterface(type, iTaskType);
            bool hasMultiThreadableOptIn = IsMtAnalysisOptIn(
                type,
                multiThreadableTaskType,
                out bool hasAnalyzedAttribute);
            bool contributesToMultiThreadableTask =
                contributingMultiThreadableTaskBaseTypes.Contains(type.OriginalDefinition);

            analyzeAsMultiThreadable = hasMultiThreadableOptIn || contributesToMultiThreadableTask;
            return isTask || hasAnalyzedAttribute || contributesToMultiThreadableTask;
        }

        internal static ImmutableHashSet<INamedTypeSymbol> FindContributingMultiThreadableTaskBaseTypes(
            Compilation compilation,
            INamedTypeSymbol iTaskType,
            INamedTypeSymbol? multiThreadableTaskType)
        {
            var builder = ImmutableHashSet.CreateBuilder<INamedTypeSymbol>(SymbolEqualityComparer.Default);
            CollectContributingMultiThreadableTaskBaseTypes(
                compilation.Assembly.GlobalNamespace,
                builder,
                iTaskType,
                multiThreadableTaskType);
            return builder.ToImmutable();
        }

        private static void CollectContributingMultiThreadableTaskBaseTypes(
            INamespaceOrTypeSymbol container,
            ImmutableHashSet<INamedTypeSymbol>.Builder result,
            INamedTypeSymbol iTaskType,
            INamedTypeSymbol? multiThreadableTaskType)
        {
            foreach (ISymbol member in container.GetMembers())
            {
                if (member is INamespaceSymbol childNamespace)
                {
                    CollectContributingMultiThreadableTaskBaseTypes(
                        childNamespace,
                        result,
                        iTaskType,
                        multiThreadableTaskType);
                    continue;
                }

                if (member is not INamedTypeSymbol type)
                {
                    continue;
                }

                if (ImplementsInterface(type, iTaskType) &&
                    IsMtAnalysisOptIn(
                        type,
                        multiThreadableTaskType,
                        out bool hasAnalyzedAttribute) &&
                    (!type.IsAbstract || hasAnalyzedAttribute))
                {
                    for (INamedTypeSymbol? baseType = type.BaseType;
                         baseType is not null && baseType.SpecialType != SpecialType.System_Object;
                         baseType = baseType.BaseType)
                    {
                        result.Add(baseType.OriginalDefinition);
                    }
                }

                CollectContributingMultiThreadableTaskBaseTypes(
                    type,
                    result,
                    iTaskType,
                    multiThreadableTaskType);
            }
        }

        /// <summary>
        /// Represents a resolved banned API entry for O(1) lookup during analysis.
        /// </summary>
        internal readonly struct BannedApiEntry
        {
            public BannedApiDefinitions.ApiCategory Category { get; }
            public string Message { get; }

            public BannedApiEntry(BannedApiDefinitions.ApiCategory category, string message)
            {
                Category = category;
                Message = message;
            }
        }

        /// <summary>
        /// Determines if a parameter name suggests it represents a file system path.
        /// Excludes XML namespace parameters (namespaceURI, etc.) and non-path names.
        /// </summary>
        internal static bool IsPathParameterName(string paramName)
        {
            // Exclude known non-path parameter names that contain path-like substrings
            if (paramName.IndexOf("namespace", StringComparison.OrdinalIgnoreCase) >= 0
                || paramName.IndexOf("xpath", StringComparison.OrdinalIgnoreCase) >= 0
                || paramName.IndexOf("profile", StringComparison.OrdinalIgnoreCase) >= 0
                || string.Equals(paramName, "source", StringComparison.OrdinalIgnoreCase))
            {
                return false;
            }

            // Common path parameter names in System.IO and other BCL APIs
            return paramName.IndexOf("path", StringComparison.OrdinalIgnoreCase) >= 0
                || paramName.IndexOf("file", StringComparison.OrdinalIgnoreCase) >= 0
                || paramName.IndexOf("dir", StringComparison.OrdinalIgnoreCase) >= 0
                || paramName.IndexOf("folder", StringComparison.OrdinalIgnoreCase) >= 0
                || paramName.IndexOf("source", StringComparison.OrdinalIgnoreCase) >= 0
                || paramName.IndexOf("dest", StringComparison.OrdinalIgnoreCase) >= 0
                || paramName.IndexOf("uri", StringComparison.OrdinalIgnoreCase) >= 0
                || paramName.IndexOf("url", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        /// <summary>
        /// Checks whether ANY path-typed string parameter of a file API call is NOT wrapped with a safe pattern.
        /// Only checks parameters whose names suggest they are file paths (e.g., "path", "fileName", "sourceFileName").
        /// Non-path string parameters (e.g., "contents", "searchPattern") are skipped.
        /// </summary>
        internal static bool HasUnwrappedPathArgument(
            ImmutableArray<IArgumentOperation> arguments,
            INamedTypeSymbol? taskEnvironmentType,
            INamedTypeSymbol? absolutePathType,
            INamedTypeSymbol? iTaskItemType)
        {
            for (int i = 0; i < arguments.Length; i++)
            {
                var arg = arguments[i];
                var param = arg.Parameter;
                if (param is null || param.Type.SpecialType != SpecialType.System_String)
                {
                    continue;
                }

                if (!IsPathParameterName(param.Name))
                {
                    continue;
                }

                if (!IsWrappedSafely(arg.Value, taskEnvironmentType, absolutePathType, iTaskItemType))
                {
                    return true;
                }
            }

            return false;
        }

        /// <summary>
        /// Recognizes the normalization operation used by AbsolutePath.GetCanonicalForm and its polyfills.
        /// Unlike arbitrary strings, AbsolutePath.Value is already fully qualified.
        /// </summary>
        internal static bool IsAbsolutePathCanonicalization(IOperation operation, INamedTypeSymbol? absolutePathType)
        {
            if (absolutePathType is null ||
                operation is not IInvocationOperation invocation ||
                invocation.TargetMethod.Name != "GetFullPath" ||
                invocation.TargetMethod.ReturnType.SpecialType != SpecialType.System_String ||
                invocation.Arguments.Length != 1)
            {
                return false;
            }

            var argument = invocation.Arguments[0].Value;
            while (argument is IConversionOperation { Conversion.IsIdentity: true } conversion)
            {
                argument = conversion.Operand;
            }

            if (argument is not IPropertyReferenceOperation
                {
                    Property: { Name: "Value", IsStatic: false, Type.SpecialType: SpecialType.System_String } property
                })
            {
                return false;
            }

            if (!SymbolEqualityComparer.Default.Equals(property.ContainingType, absolutePathType))
            {
                return false;
            }

            // Resolve Path from the intrinsic string's assembly, not a source or referenced lookalike.
            var pathType = invocation.TargetMethod.ReturnType.ContainingAssembly?.GetTypeByMetadataName("System.IO.Path");
            return SymbolEqualityComparer.Default.Equals(invocation.TargetMethod.ContainingType, pathType);
        }

        /// <summary>
        /// Recursively checks if an operation represents a safely-wrapped path.
        /// </summary>
        internal static bool IsWrappedSafely(
            IOperation operation,
            INamedTypeSymbol? taskEnvironmentType,
            INamedTypeSymbol? absolutePathType,
            INamedTypeSymbol? iTaskItemType)
        {
            // Unwrap conversions (implicit AbsolutePath -> string, etc.)
            while (operation is IConversionOperation conversion)
            {
                // Check if converting from AbsolutePath or Nullable<AbsolutePath>
                if (absolutePathType is not null &&
                    IsAbsolutePathType(conversion.Operand.Type, absolutePathType))
                {
                    return true;
                }

                operation = conversion.Operand;
            }

            // Null literals and default values are safe — they don't represent relative paths
            if (operation is ILiteralOperation lit && lit.ConstantValue.HasValue && lit.ConstantValue.Value is null)
            {
                return true;
            }

            if (operation is IDefaultValueOperation)
            {
                return true;
            }

            // Check: TaskEnvironment.GetAbsolutePath(...), ITaskItem.GetMetadata("FullPath"), Path.* helpers
            if (operation is IInvocationOperation invocation)
            {
                if (invocation.TargetMethod.Name == "GetAbsolutePath" &&
                    taskEnvironmentType is not null &&
                    SymbolEqualityComparer.Default.Equals(invocation.TargetMethod.ContainingType, taskEnvironmentType))
                {
                    return true;
                }

                // Check: ITaskItem.GetMetadata("FullPath") / GetMetadataValue("FullPath")
                if ((invocation.TargetMethod.Name == "GetMetadata" || invocation.TargetMethod.Name == "GetMetadataValue") && iTaskItemType is not null)
                {
                    var receiverType = invocation.TargetMethod.ContainingType;
                    if (receiverType is not null && (SymbolEqualityComparer.Default.Equals(receiverType, iTaskItemType) || ImplementsInterface(receiverType, iTaskItemType)))
                    {
                        // Check if the argument is the literal string "FullPath"
                        if (invocation.Arguments.Length > 0 &&
                            invocation.Arguments[0].Value is ILiteralOperation literal &&
                            literal.ConstantValue.HasValue &&
                            literal.ConstantValue.Value is string metadataName &&
                            string.Equals(metadataName, "FullPath", StringComparison.OrdinalIgnoreCase))
                        {
                            return true;
                        }
                    }
                }

                // Check: Path.GetDirectoryName(safe) — directory of an absolute path is absolute
                if (invocation.TargetMethod.Name == "GetDirectoryName" &&
                    invocation.TargetMethod.ContainingType?.ToDisplayString() == "System.IO.Path" &&
                    invocation.Arguments.Length >= 1 &&
                    IsWrappedSafely(invocation.Arguments[0].Value, taskEnvironmentType, absolutePathType, iTaskItemType))
                {
                    return true;
                }

                // Check: Path.Combine(safe, ...) — result is absolute when first arg is absolute
                if (invocation.TargetMethod.Name == "Combine" &&
                    invocation.TargetMethod.ContainingType?.ToDisplayString() == "System.IO.Path" &&
                    invocation.Arguments.Length >= 2 &&
                    IsWrappedSafely(invocation.Arguments[0].Value, taskEnvironmentType, absolutePathType, iTaskItemType))
                {
                    return true;
                }

                // Check: Path.GetFullPath(safe) — safe only when input is already absolute.
                // If input is relative, GetFullPath resolves against CWD (wrong in MT).
                // Only the specific AbsolutePath.Value normalization also exempts the GetFullPath call.
                if (IsAbsolutePathCanonicalization(invocation, absolutePathType))
                {
                    return true;
                }

                if (invocation.TargetMethod.Name == "GetFullPath" &&
                    invocation.TargetMethod.ContainingType?.ToDisplayString() == "System.IO.Path" &&
                    invocation.Arguments.Length >= 1 &&
                    IsWrappedSafely(invocation.Arguments[0].Value, taskEnvironmentType, absolutePathType, iTaskItemType))
                {
                    return true;
                }
            }

            // Check: .FullName property (FileSystemInfo.FullName - base class of FileInfo/DirectoryInfo)
            if (operation is IPropertyReferenceOperation propRef && propRef.Property.Name == "FullName")
            {
                var containingTypeName = propRef.Property.ContainingType?.ToDisplayString();
                if (containingTypeName is WellKnownTypeNames.FileSystemInfoFullName or WellKnownTypeNames.FileInfoFullName or WellKnownTypeNames.DirectoryInfoFullName)
                {
                    return true;
                }
            }

            // Check: argument type is AbsolutePath or Nullable<AbsolutePath>
            if (absolutePathType is not null &&
                operation.Type is not null &&
                IsAbsolutePathType(operation.Type, absolutePathType))
            {
                return true;
            }

            // Trace through local variable assignments: if `string dir = Path.GetDirectoryName(abs);`
            // then `dir` is safe because its initializer is safe.
            if (operation is ILocalReferenceOperation localRef)
            {
                var local = localRef.Local;
                if (local.DeclaringSyntaxReferences.Length == 1)
                {
                    var syntax = local.DeclaringSyntaxReferences[0].GetSyntax();
                    var semanticModel = operation.SemanticModel;
                    if (semanticModel is not null)
                    {
                        var declOp = semanticModel.GetOperation(syntax);
                        if (declOp is IVariableDeclaratorOperation declarator &&
                            declarator.Initializer?.Value is IOperation initValue)
                        {
                            return IsWrappedSafely(initValue, taskEnvironmentType, absolutePathType, iTaskItemType);
                        }
                    }
                }
            }

            return false;
        }

        /// <summary>
        /// Checks if a type is AbsolutePath or Nullable&lt;AbsolutePath&gt;.
        /// </summary>
        internal static bool IsAbsolutePathType(ITypeSymbol? type, INamedTypeSymbol absolutePathType)
        {
            if (type is null)
            {
                return false;
            }

            // Direct match: AbsolutePath
            if (SymbolEqualityComparer.Default.Equals(type, absolutePathType))
            {
                return true;
            }

            // Nullable<AbsolutePath> — the type is Nullable<T> where T is AbsolutePath
            if (type is INamedTypeSymbol namedType &&
                namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
                namedType.TypeArguments.Length == 1 &&
                SymbolEqualityComparer.Default.Equals(namedType.TypeArguments[0], absolutePathType))
            {
                return true;
            }

            return false;
        }

        /// <summary>
        /// Builds a dictionary mapping resolved ISymbols to their banned API entry for O(1) lookup.
        /// </summary>
        internal static Dictionary<ISymbol, BannedApiEntry> BuildBannedApiLookup(Compilation compilation)
        {
            var result = new Dictionary<ISymbol, BannedApiEntry>(SymbolEqualityComparer.Default);
            var definitions = BannedApiDefinitions.GetAll();

            foreach (var def in definitions)
            {
                ImmutableArray<ISymbol> symbols;
                try
                {
                    var resolved = DocumentationCommentId.GetSymbolsForDeclarationId(def.DeclarationId, compilation);
                    symbols = resolved.IsDefault ? ImmutableArray<ISymbol>.Empty : resolved;
                }
                catch
                {
                    continue;
                }

                foreach (var symbol in symbols)
                {
                    if (!result.ContainsKey(symbol))
                    {
                        result[symbol] = new BannedApiEntry(def.Category, def.Message);
                    }
                }
            }

            return result;
        }

        /// <summary>
        /// Resolves the set of types whose methods take path parameters that need absolutization.
        /// </summary>
        internal static ImmutableHashSet<INamedTypeSymbol> ResolveFilePathTypes(Compilation compilation)
        {
            var typeNames = new[]
            {
                // Core System.IO file/directory types
                "System.IO.File",
                "System.IO.Directory",
                WellKnownTypeNames.FileInfoFullName,
                WellKnownTypeNames.DirectoryInfoFullName,
                "System.IO.FileStream",
                "System.IO.StreamReader",
                "System.IO.StreamWriter",
                "System.IO.FileSystemWatcher",
                "System.IO.BinaryReader",
                "System.IO.BinaryWriter",

                // XML types — only include types where the majority of string
                // parameters are file paths. XmlDocument is excluded because methods
                // like CreateElement, CreateAttribute etc. take non-path strings.
                "System.Xml.Linq.XDocument",
                "System.Xml.Linq.XElement",
                "System.Xml.XmlReader",
                "System.Xml.XmlWriter",
                "System.Xml.XmlTextReader",
                "System.Xml.XmlTextWriter",
                "System.Xml.Xsl.XslCompiledTransform",
                "System.Xml.Schema.XmlSchema",

                // Compression types that accept file paths
                "System.IO.Compression.ZipFile",

                // Memory-mapped files
                "System.IO.MemoryMappedFiles.MemoryMappedFile",

                // Security / certificates
                "System.Security.Cryptography.X509Certificates.X509Certificate",
                "System.Security.Cryptography.X509Certificates.X509Certificate2",

                // Diagnostics
                "System.Diagnostics.FileVersionInfo",
                "System.Diagnostics.TextWriterTraceListener",

                // Resources
                "System.Resources.ResourceReader",
                "System.Resources.ResourceWriter",

                // Assembly loading (supplements the banned-API list for path-based overloads)
                "System.Runtime.Loader.AssemblyLoadContext",
            };

            var builder = ImmutableHashSet.CreateBuilder<INamedTypeSymbol>(SymbolEqualityComparer.Default);
            foreach (var name in typeNames)
            {
                var type = compilation.GetTypeByMetadataName(name);
                if (type is not null)
                {
                    builder.Add(type);
                }
            }

            return builder.ToImmutable();
        }

        /// <summary>
        /// Checks if a type implements a given interface.
        /// </summary>
        internal static bool ImplementsInterface(INamedTypeSymbol type, INamedTypeSymbol interfaceType)
        {
            foreach (var iface in type.AllInterfaces)
            {
                if (SymbolEqualityComparer.Default.Equals(iface, interfaceType))
                {
                    return true;
                }
            }

            return false;
        }

        /// <summary>
        /// Enumerates the properties declared on <paramref name="type"/> and all of its base types,
        /// most-derived first. A property hidden or overridden in a more derived type is yielded only
        /// once, via its most-derived declaration (matched by name). The <see cref="object"/> base is
        /// skipped since it declares no task-relevant properties.
        /// </summary>
        internal static IEnumerable<IPropertySymbol> GetPropertiesIncludingBaseTypes(INamedTypeSymbol type)
        {
            var seen = new HashSet<string>(StringComparer.Ordinal);
            for (INamedTypeSymbol? current = type;
                 current is not null && current.SpecialType != SpecialType.System_Object;
                 current = current.BaseType)
            {
                foreach (var member in current.GetMembers())
                {
                    if (member is IPropertySymbol property && seen.Add(property.Name))
                    {
                        yield return property;
                    }
                }
            }
        }
    }
}