|
// 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
}
}
|