File: src\Compilers\Core\Portable\DiagnosticAnalyzer\AnalyzerAssemblyLoader.cs
Web Access
Project: src\src\Workspaces\Core\Portable\Microsoft.CodeAnalysis.Workspaces.csproj (Microsoft.CodeAnalysis.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis
{
    internal interface IAnalyzerAssemblyLoaderInternal : IAnalyzerAssemblyLoader, IDisposable
    {
        /// <summary>
        /// Is this an <see cref="Assembly"/> that the loader considers to be part of the hosting 
        /// process. Either part of the compiler itself or the process hosting the compiler.
        /// </summary>
        bool IsHostAssembly(Assembly assembly);
 
        /// <summary>
        /// For a given <see cref="AssemblyName"/> return the location it was originally added 
        /// from. This will return null for any value that was not directly added through the 
        /// loader.
        /// </summary>
        string? GetOriginalDependencyLocation(AssemblyName assembly);
    }
 
    /// <summary>
    /// The implementation for <see cref="IAnalyzerAssemblyLoader"/>. This type provides caching and tracking of inputs given
    /// to <see cref="AddDependencyLocation(string)"/>.
    /// </summary>
    /// <remarks>
    /// This type generally assumes that files on disk aren't changing, since it ensure that two calls to <see cref="LoadFromPath(string)"/>
    /// will always return the same thing, per that interface's contract.
    ///
    /// A given analyzer can have two paths that represent it: the original path of the analyzer passed into this type
    /// and the path returned after calling <see cref="IAnalyzerPathResolver.GetResolvedAnalyzerPath(string)"/>. In the 
    /// places where differentiating between the two is important, the original path will be referred to as the "original" and
    /// the latter is referred to as "resolved".
    /// </remarks>
    internal sealed partial class AnalyzerAssemblyLoader : IAnalyzerAssemblyLoaderInternal
    {
        private readonly object _guard = new();
 
        /// <summary>
        /// The original paths are compared ordinally as the expectation is the host needs to handle normalization,
        /// if necessary. That means if the host passes in a.dll and a.DLL the loader will treat them as different
        /// even if the underlying file system is case insensitive.
        /// </summary>
        internal static readonly StringComparer OriginalPathComparer = StringComparer.Ordinal;
 
        /// <summary>
        /// These are paths generated by the loader, or one of its plugins. They need no normalization and hence
        /// should be compared ordinally.
        /// </summary>
        internal static readonly StringComparer GeneratedPathComparer = StringComparer.Ordinal;
 
        /// <summary>
        /// Simple names are not case sensitive
        /// </summary>
        internal static readonly (StringComparer Comparer, StringComparison Comparison) SimpleNameComparer = (StringComparer.OrdinalIgnoreCase, StringComparison.OrdinalIgnoreCase);
 
        /// <summary>
        /// This is a map between the original full path and how it is represented in this loader. Specifically
        /// the key is the original path before it is considered by <see cref="IAnalyzerPathResolver.GetResolvedAnalyzerPath(string)"/>.
        /// </summary>
        /// <remarks>
        /// Access must be guarded by <see cref="_guard"/>
        /// </remarks>
        private readonly Dictionary<string, (IAnalyzerPathResolver? Resolver, string ResolvedPath, AssemblyName? AssemblyName)> _originalPathInfoMap = new(OriginalPathComparer);
 
        /// <summary>
        /// This is a map between assembly simple names and the collection of original paths that map to them.
        /// </summary>
        /// <remarks>
        /// Access must be guarded by <see cref="_guard"/>
        ///
        /// Simple names are not case sensitive
        /// </remarks>
        private readonly Dictionary<string, HashSet<string>> _assemblySimpleNameToOriginalPathListMap = new(SimpleNameComparer.Comparer);
 
        /// <summary>
        /// Map from resolved paths to the original ones
        /// </summary>
        /// <remarks>
        /// Access must be guarded by <see cref="_guard"/>
        ///
        /// The paths are compared ordinally here as these are computed values, not user supplied ones, and the
        /// values should refer to the file on disk with no alteration of its path.
        /// </remarks>
        private readonly Dictionary<string, string> _resolvedToOriginalPathMap = new(GeneratedPathComparer);
 
        /// <summary>
        /// Whether or not we're disposed. Once disposed, all functionality on this type should throw.
        /// </summary>
        private bool _isDisposed;
 
        public ImmutableArray<IAnalyzerPathResolver> AnalyzerPathResolvers { get; }
 
        /// <summary>
        /// The implementation needs to load an <see cref="Assembly"/> with the specified <see cref="AssemblyName"/> from
        /// the specified path.
        /// </summary>
        /// <remarks>
        /// This method should return an <see cref="Assembly"/> instance or throw.
        /// </remarks>
        private partial Assembly Load(AssemblyName assemblyName, string resolvedPath);
 
        /// <summary>
        /// Determines if the <paramref name="candidateName"/> satisfies the request for 
        /// <paramref name="requestedName"/>. This is partial'd out as each runtime has a different 
        /// definition of matching name.
        /// </summary>
        private partial bool IsMatch(AssemblyName requestedName, AssemblyName candidateName);
 
        private void CheckIfDisposed()
        {
#if NET
            ObjectDisposedException.ThrowIf(_isDisposed, this);
#else
            if (_isDisposed)
                throw new ObjectDisposedException(this.GetType().FullName);
#endif
        }
 
        public void Dispose()
        {
            if (_isDisposed)
                return;
 
            _isDisposed = true;
            DisposeWorker();
        }
 
        private partial void DisposeWorker();
 
        public void AddDependencyLocation(string originalPath)
        {
            CheckIfDisposed();
 
            CompilerPathUtilities.RequireAbsolutePath(originalPath, nameof(originalPath));
 
            lock (_guard)
            {
                if (_originalPathInfoMap.ContainsKey(originalPath))
                {
                    return;
                }
            }
 
            var simpleName = PathUtilities.GetFileName(originalPath, includeExtension: false);
            string resolvedPath = originalPath;
            IAnalyzerPathResolver? resolver = null;
            foreach (var current in AnalyzerPathResolvers)
            {
                if (current.IsAnalyzerPathHandled(originalPath))
                {
                    resolver = current;
                    resolvedPath = resolver.GetResolvedAnalyzerPath(originalPath);
                    break;
                }
            }
 
            var assemblyName = readAssemblyName(resolvedPath);
 
            lock (_guard)
            {
                if (_originalPathInfoMap.TryAdd(originalPath, (resolver, resolvedPath, assemblyName)))
                {
                    // In the case multiple original paths map to the same resolved path then the first one
                    // wins.
                    //
                    // An example reason to map multiple original paths to the same real path would be to
                    // unify references.
                    _ = _resolvedToOriginalPathMap.TryAdd(resolvedPath, originalPath);
 
                    if (!_assemblySimpleNameToOriginalPathListMap.TryGetValue(simpleName, out var set))
                    {
                        set = new(OriginalPathComparer);
                        _assemblySimpleNameToOriginalPathListMap[simpleName] = set;
                    }
 
                    _ = set.Add(originalPath);
                }
                else
                {
                    Debug.Assert(GeneratedPathComparer.Equals(_originalPathInfoMap[originalPath].ResolvedPath, resolvedPath));
                }
            }
 
            static AssemblyName? readAssemblyName(string filePath)
            {
                AssemblyName? assemblyName;
                try
                {
                    assemblyName = AssemblyName.GetAssemblyName(filePath);
                }
                catch
                {
                    // The above can fail when the assembly doesn't exist because it's corrupted, 
                    // doesn't exist on disk, or is a native DLL. Those failures are handled when 
                    // the actual load is attempted. Just record the failure now.
                    assemblyName = null;
                }
 
                return assemblyName;
            }
        }
 
        /// <summary>
        /// Called from the consumer of <see cref="AnalyzerAssemblyLoader"/> to load an analyzer assembly from disk. It
        /// should _not_ be called from the implementation.
        /// </summary>
        public Assembly LoadFromPath(string originalPath)
        {
            CheckIfDisposed();
 
            CompilerPathUtilities.RequireAbsolutePath(originalPath, nameof(originalPath));
            var (resolvedPath, assemblyName) = GetResolvedAnalyzerPathAndName(originalPath);
            if (assemblyName is null)
            {
                // Not a managed assembly, nothing else to do
                throw new ArgumentException($"Not a valid assembly: {originalPath}");
            }
 
            try
            {
                return Load(assemblyName, resolvedPath);
            }
            catch (Exception ex)
            {
                throw new InvalidOperationException($"Unable to load {assemblyName.Name}: {ex.Message}", ex);
            }
        }
 
        private (string ResolvedPath, AssemblyName? AssemblyName) GetResolvedAnalyzerPathAndName(string originalPath)
        {
            CheckIfDisposed();
            lock (_guard)
            {
                if (!_originalPathInfoMap.TryGetValue(originalPath, out var info))
                {
                    throw new ArgumentException("Path not registered: " + originalPath, nameof(originalPath));
                }
 
                return (info.ResolvedPath, info.AssemblyName);
            }
        }
 
        public string GetResolvedAnalyzerPath(string originalPath) =>
            GetResolvedAnalyzerPathAndName(originalPath).ResolvedPath;
 
        public string? GetResolvedSatellitePath(string originalPath, CultureInfo cultureInfo)
        {
            CheckIfDisposed();
 
            IAnalyzerPathResolver? resolver;
            lock (_guard)
            {
                if (!_originalPathInfoMap.TryGetValue(originalPath, out var info))
                {
                    throw new ArgumentException("Path not registered: " + originalPath, nameof(originalPath));
                }
 
                resolver = info.Resolver;
            }
 
            if (resolver is not null)
            {
                return resolver.GetResolvedSatellitePath(originalPath, cultureInfo);
            }
 
            return GetSatelliteAssemblyPath(originalPath, cultureInfo);
        }
 
        /// <summary>
        /// Get the path a satellite assembly should be loaded from for the given resolved 
        /// analyzer path and culture
        /// </summary>
        private string? GetSatelliteLoadPath(string resolvedPath, CultureInfo cultureInfo)
        {
            string? originalPath;
 
            lock (_guard)
            {
                if (!_resolvedToOriginalPathMap.TryGetValue(resolvedPath, out originalPath))
                {
                    return null;
                }
            }
 
            return GetResolvedSatellitePath(originalPath, cultureInfo);
        }
 
        /// <summary>
        /// This method mimics the .NET lookup rules for satellite assemblies and will return the ideal
        /// resource assembly for the given culture.
        /// </summary>
        internal static string? GetSatelliteAssemblyPath(string assemblyFilePath, CultureInfo cultureInfo)
        {
            var assemblyFileName = Path.GetFileName(assemblyFilePath);
            var satelliteAssemblyName = Path.ChangeExtension(assemblyFileName, ".resources.dll");
            var path = Path.GetDirectoryName(assemblyFilePath);
            if (path is null)
            {
                return null;
            }
 
            while (cultureInfo != CultureInfo.InvariantCulture)
            {
                var filePath = Path.Combine(path, cultureInfo.Name, satelliteAssemblyName);
                if (File.Exists(filePath))
                {
                    return filePath;
                }
 
                cultureInfo = cultureInfo.Parent;
            }
 
            return null;
        }
 
        public string? GetOriginalDependencyLocation(AssemblyName assemblyName)
        {
            CheckIfDisposed();
 
            return GetBestResolvedPath(assemblyName).BestOriginalPath;
        }
 
        /// <summary>
        /// Return the best (original, resolved) path information for loading an assembly with the specified <see cref="AssemblyName"/>.
        /// </summary>
        private (string? BestOriginalPath, string? BestResolvedPath) GetBestResolvedPath(AssemblyName requestedName)
        {
            CheckIfDisposed();
 
            if (requestedName.Name is null)
            {
                return (null, null);
            }
 
            List<string> originalPaths;
            lock (_guard)
            {
                if (!_assemblySimpleNameToOriginalPathListMap.TryGetValue(requestedName.Name, out var set))
                {
                    return (null, null);
                }
 
                originalPaths = set.OrderBy(x => x).ToList();
            }
 
            string? bestResolvedPath = null;
            string? bestOriginalPath = null;
            AssemblyName? bestName = null;
            foreach (var candidateOriginalPath in originalPaths)
            {
                var (candidateResolvedPath, candidateName) = GetResolvedAnalyzerPathAndName(candidateOriginalPath);
                if (candidateName is null)
                {
                    continue;
                }
 
                if (IsMatch(requestedName, candidateName))
                {
                    if (candidateName.Version == requestedName.Version)
                    {
                        return (candidateOriginalPath, candidateResolvedPath);
                    }
 
                    if (bestName is null || candidateName.Version > bestName.Version)
                    {
                        bestOriginalPath = candidateOriginalPath;
                        bestResolvedPath = candidateResolvedPath;
                        bestName = candidateName;
                    }
                }
            }
 
            return (bestOriginalPath, bestResolvedPath);
        }
 
        internal ImmutableArray<(string OriginalAssemblyPath, string ResolvedAssemblyPath)> GetPathMapSnapshot()
        {
            CheckIfDisposed();
 
            lock (_guard)
            {
                return _resolvedToOriginalPathMap.Select(x => (x.Value, x.Key)).ToImmutableArray();
            }
        }
 
#if NET
        /// <summary>
        /// Return an <see cref="IAnalyzerAssemblyLoader"/> which does not lock assemblies on disk that is
        /// most appropriate for the current platform.
        /// </summary>
        /// <param name="windowsShadowPath">A shadow copy path will be created on Windows and this value 
        /// will be the base directory where shadow copy assemblies are stored. </param>
        internal static IAnalyzerAssemblyLoaderInternal CreateNonLockingLoader(
            string windowsShadowPath,
            ImmutableArray<IAnalyzerPathResolver> pathResolvers = default,
            ImmutableArray<IAnalyzerAssemblyResolver> assemblyResolvers = default,
            System.Runtime.Loader.AssemblyLoadContext? compilerLoadContext = null)
        {
            pathResolvers = pathResolvers.NullToEmpty();
            assemblyResolvers = assemblyResolvers.NullToEmpty();
 
            if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                // The only reason we don't use LoadFromStream on Windows is because the anti-virus checker will amortize
                // the cost of scanning files on disk. When loading from stream there is no such amortization, the 
                // anti-virus checker will scan the file every time it is loaded and the performance hit is significant.
                //
                // On non-Windows OS this is generally not a concern and loading from stream is sufficient to avoid
                // locking the file.
                return new AnalyzerAssemblyLoader(
                    pathResolvers,
                    [.. assemblyResolvers, StreamResolver.Instance],
                    compilerLoadContext);
            }
 
            // The goal here is to avoid locking files on disk that are reasonably expected to be changed by 
            // developers for the lifetime of VBCSCompiler, Visual Studio, VS Code, etc ... Places like 
            // Program Files are not expected to change and so locking is not a concern. But for everything else
            // we want to avoid locking and use shadow copy.
            return new AnalyzerAssemblyLoader(
                [.. pathResolvers, ProgramFilesAnalyzerPathResolver.Instance, new ShadowCopyAnalyzerPathResolver(windowsShadowPath)],
                [.. assemblyResolvers, DiskResolver.Instance],
                compilerLoadContext);
        }
 
#else
 
        /// <summary>
        /// Return an <see cref="IAnalyzerAssemblyLoader"/> which does not lock assemblies on disk that is
        /// most appropriate for the current platform.
        /// </summary>
        /// <param name="windowsShadowPath">A shadow copy path will be created on Windows and this value 
        /// will be the base directory where shadow copy assemblies are stored. </param>
        internal static IAnalyzerAssemblyLoaderInternal CreateNonLockingLoader(
            string windowsShadowPath,
            ImmutableArray<IAnalyzerPathResolver> pathResolvers = default)
        {
            pathResolvers = pathResolvers.NullToEmpty();
 
            // The goal here is to avoid locking files on disk that are reasonably expected to be changed by 
            // developers for the lifetime of VBCSCompiler, Visual Studio, VS Code, etc ... Places like 
            // Program Files are not expected to change and so locking is not a concern. But for everything else
            // we want to avoid locking and use shadow copy.
            return new AnalyzerAssemblyLoader([.. pathResolvers, ProgramFilesAnalyzerPathResolver.Instance, new ShadowCopyAnalyzerPathResolver(windowsShadowPath)]);
        }
#endif
    }
}