// 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.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Hashing;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;
using Roslyn.Utilities;
namespace Microsoft.CodeAnalysis
{
internal sealed class ShadowCopyAnalyzerPathResolver : IAnalyzerPathResolver
{
private const string DirectoryVersion = "v1";
private enum DirectoryCleanupState
{
InProgress,
Completed
}
private static readonly ConcurrentDictionary<string, DirectoryCleanupState> s_directoryCleanupStates = new(AnalyzerAssemblyLoader.OriginalPathComparer);
/// <summary>
/// The base directory for shadow copies.
/// Layout:
/// baseDirectory/
/// └── v1/
/// ├── shadow/
/// │ └── (per-session directories)
/// └── cache/
/// └── (cached dlls)
/// </summary>
internal string BaseDirectory { get; }
/// <summary>Directory denoted by '$BaseDirectory/$DirectoryVersion/shadow/'.</summary>
internal string ShadowDirectory { get; }
/// <summary>Directory for the current session. Directory denoted by '$BaseDirectory/$DirectoryVersion/shadow/$sessionId/'.</summary>
internal string SessionDirectory { get; }
/// <summary>
/// Shared cache for shadow copied assemblies.
/// Used to amortize cost of antivirus scans.
/// Denoted by '$BaseDirectory/$DirectoryVersion/cache/'.
/// </summary>
internal string CacheDirectory { get; }
/// <summary>
/// As long as this mutex is alive, other instances of this type will not try to clean
/// up the shadow directory.
/// </summary>
private Mutex Mutex { get; }
internal Task DeleteLeftoverDirectoriesTask { get; }
/// <summary>
/// This is a counter that is incremented each time a new shadow sub directory is created to ensure they
/// have unique names.
/// </summary>
private int _directoryCount;
/// <summary>
/// This is a map from the original directory name to the numbered directory name it
/// occupies in the shadow directory.
/// </summary>
private ConcurrentDictionary<string, int> OriginalDirectoryMap { get; } = new(AnalyzerAssemblyLoader.OriginalPathComparer);
/// <summary>
/// This interface can be called from multiple threads for the same original assembly path. This
/// is a map between the original path and the Task that completes when the shadow copy for that
/// original path completes.
/// </summary>
private ConcurrentDictionary<string, Task<string>> CopyMap { get; } = new(AnalyzerAssemblyLoader.OriginalPathComparer);
/// <summary>Whether to hard link assemblies to/from <see cref="CacheDirectory"/>.</summary>
/// <remarks>Internal for testing only.</remarks>
internal bool EnableHardLinks = true;
/// <summary>
/// This is the number of shadow copies that have occurred in this instance.
/// </summary>
/// <remarks>
/// This is used for testing, it should not be used for any other purpose.
/// </remarks>
internal int CopyCount => CopyMap.Count;
#if NET
[SupportedOSPlatform("windows")]
#endif
public ShadowCopyAnalyzerPathResolver(string baseDirectory)
{
if (!PlatformInformation.IsWindows)
{
throw new InvalidOperationException("ShadowCopyAnalyzerPathResolver is only supported on Windows.");
}
if (baseDirectory is null)
{
throw new ArgumentNullException(nameof(baseDirectory));
}
if (!Path.IsPathRooted(baseDirectory))
{
throw new ArgumentException($"Must be a full path: {baseDirectory}", nameof(baseDirectory));
}
// The directories are deliberately _not_ created at this point. They will only be created when the first
// request comes in. This avoids creating unnecessary directories when no analyzers are loaded
// via the shadow layer.
BaseDirectory = baseDirectory;
var versionDirectory = Path.Combine(baseDirectory, DirectoryVersion);
ShadowDirectory = Path.Combine(versionDirectory, "shadow");
CacheDirectory = Path.Combine(versionDirectory, "cache");
var sessionDirectoryName = Guid.NewGuid().ToString("N").ToLowerInvariant();
SessionDirectory = Path.Combine(ShadowDirectory, sessionDirectoryName);
Mutex = new Mutex(initiallyOwned: false, name: sessionDirectoryName);
DeleteLeftoverDirectoriesTask = Task.Run(DeleteLeftoverDirectories);
}
internal static Task CleanLegacyShadowDirectoryAsync(string legacyShadowDirectory)
{
if (Directory.Exists(legacyShadowDirectory))
{
return Task.Run(() =>
{
DeleteLeftoverDirectories(baseDirectory: legacyShadowDirectory, shadowDirectory: legacyShadowDirectory, cacheDirectory: null);
// Delete the legacyShadowDirectory if empty, otherwise do nothing.
try { Directory.Delete(legacyShadowDirectory, recursive: false); }
catch { }
});
}
return Task.CompletedTask;
}
private void DeleteLeftoverDirectories()
{
DeleteLeftoverDirectories(BaseDirectory, ShadowDirectory, CacheDirectory);
}
private static void DeleteLeftoverDirectories(string baseDirectory, string shadowDirectory, string? cacheDirectory)
{
if (!s_directoryCleanupStates.TryAdd(baseDirectory, DirectoryCleanupState.InProgress))
{
// Someone else is already cleaning up this directory. Wait until it's completed
SpinWait.SpinUntil(() => s_directoryCleanupStates[baseDirectory] == DirectoryCleanupState.Completed, millisecondsTimeout: -1);
return;
}
try
{
// Avoid first chance exception
if (!Directory.Exists(shadowDirectory))
return;
IEnumerable<string> subDirectories;
try
{
subDirectories = Directory.EnumerateDirectories(shadowDirectory);
}
catch (DirectoryNotFoundException)
{
return;
}
foreach (var subDirectory in subDirectories)
{
string name = Path.GetFileName(subDirectory).ToLowerInvariant();
Mutex? mutex = null;
try
{
// We only want to try deleting the directory if no-one else is currently
// using it. That is, if there is no corresponding mutex.
if (!Mutex.TryOpenExisting(name, out mutex))
{
try
{
// Avoid calling ClearReadOnlyFlagOnFiles before calling Directory.Delete. In general, files
// created by the shadow copy should not be marked read-only (CopyFile also clears the
// read-only flag), and clearing the read-only flag for the entire directory requires
// significant disk access.
//
// If the deletion fails for an IOException, it may have been the result of a file being
// marked read-only. We catch that exception and perform an explicit clear before trying
// again.
//
// It's possible for us to race with multiple shadow copy instances trying to delete the
// same directory. If that happens, we may get a DirectoryNotFoundException. We
// explicitly ignore that exception as it means our work is already done.
// This isn't a perfect check, as two processes could race here, but it's close enough.
if (Directory.Exists(subDirectory))
{
try
{
Directory.Delete(subDirectory, recursive: true);
}
catch (DirectoryNotFoundException)
{
// Another process beat us to it. Nothing to do.
}
}
}
catch (IOException)
{
// Retry after clearing the read-only flag
ClearReadOnlyFlagOnFiles(subDirectory);
Directory.Delete(subDirectory, recursive: true);
}
}
}
catch
{
// If something goes wrong we will leave it to the next run to clean up.
// Just swallow the exception and move on.
}
finally
{
mutex?.Dispose();
}
}
pruneCacheIfNeeded();
}
finally
{
s_directoryCleanupStates[baseDirectory] = DirectoryCleanupState.Completed;
}
void pruneCacheIfNeeded()
{
// Avoid first chance exception
if (!Directory.Exists(cacheDirectory))
return;
using var cacheMutex = new Mutex(initiallyOwned: false, name: $"RoslynShadowCopyCache-{HashToHex(cacheDirectory)}");
bool lockTaken = false;
try
{
try
{
lockTaken = cacheMutex.WaitOne(millisecondsTimeout: 0);
}
catch (AbandonedMutexException)
{
lockTaken = true;
}
if (lockTaken)
{
// Permit up to 200 unlinked files (not hard-linked to a specific shadow loader directory).
// Delete the oldest files which exceed this limit.
// Note: this value was chosen arbitrarily, based on speculation that ordinary solutions use perhaps a few dozen analyzer assemblies,
// and that a user would likely be working on several different solutions/worktrees regularly.
// The value can and should be adjusted in future based on empirical measurements.
const int maxUnlinkedCount = 200;
var filesToEvict = Directory.EnumerateFiles(cacheDirectory)
.Select(static file => (file, fileInformationOpt: TryGetWindowsFileInformation(file)))
.Where(static pair => pair.fileInformationOpt is { NumberOfLinks: 1 })
.OrderByDescending(static pair =>
{
var creationTime = pair.fileInformationOpt!.Value.CreationTime;
return (long)creationTime.dwHighDateTime << 32 | (uint)creationTime.dwLowDateTime;
})
.Skip(maxUnlinkedCount);
// Note: it's expected that 'pruneCacheIfNeeded()' and 'linkFromCacheOrFallbackToCopy()' can run concurrently.
// If pruning deletes a file that linking was attempting to use, linking is expected to fall back gracefully to copying.
foreach (var pair in filesToEvict)
{
File.Delete(pair.file);
}
}
}
catch
{
// If something goes wrong we will leave it to the next run to clean up.
// Just swallow the exception and move on.
}
finally
{
if (lockTaken)
cacheMutex.ReleaseMutex();
}
}
}
public bool IsAnalyzerPathHandled(string analyzerFilePath) => true;
public string GetResolvedAnalyzerPath(string originalAnalyzerPath)
{
var analyzerShadowDir = GetAnalyzerShadowDirectory(originalAnalyzerPath);
var analyzerShadowPath = Path.Combine(analyzerShadowDir, Path.GetFileName(originalAnalyzerPath));
ShadowCopyFile(originalAnalyzerPath, analyzerShadowPath);
return analyzerShadowPath;
}
public string? GetResolvedSatellitePath(string originalAnalyzerPath, CultureInfo cultureInfo)
{
var satelliteFilePath = AnalyzerAssemblyLoader.GetSatelliteAssemblyPath(originalAnalyzerPath, cultureInfo);
if (satelliteFilePath is null)
{
return null;
}
var analyzerShadowDir = GetAnalyzerShadowDirectory(originalAnalyzerPath);
var satelliteFileName = Path.GetFileName(satelliteFilePath);
var satelliteDirectoryName = Path.GetFileName(Path.GetDirectoryName(satelliteFilePath));
var shadowSatellitePath = Path.Combine(analyzerShadowDir, satelliteDirectoryName!, satelliteFileName);
ShadowCopyFile(satelliteFilePath, shadowSatellitePath);
return shadowSatellitePath;
}
/// <summary>
/// Get the shadow directory for the given original analyzer file path.
/// </summary>
private string GetAnalyzerShadowDirectory(string analyzerFilePath)
{
var originalDirName = Path.GetDirectoryName(analyzerFilePath)!;
var shadowDirName = OriginalDirectoryMap.GetOrAdd(originalDirName, _ => Interlocked.Increment(ref _directoryCount)).ToString(System.Globalization.CultureInfo.InvariantCulture);
return Path.Combine(SessionDirectory, shadowDirName);
}
/// <summary>
/// This type has to account for multiple threads calling into the various resolver APIs. To avoid two threads
/// writing at the same time this method is used to ensure only one thread _wins_ and both can wait for
/// that thread to complete the copy.
/// </summary>
private void ShadowCopyFile(string originalFilePath, string shadowCopyPath)
{
if (CopyMap.TryGetValue(originalFilePath, out var copyTask))
{
copyTask.Wait();
return;
}
var tcs = new TaskCompletionSource<string>();
var task = CopyMap.GetOrAdd(originalFilePath, tcs.Task);
if (object.ReferenceEquals(task, tcs.Task))
{
// This thread won and we need to do the copy.
try
{
copyFile(this, originalFilePath, shadowCopyPath);
tcs.SetResult(shadowCopyPath);
}
catch (Exception ex)
{
tcs.SetException(ex);
throw;
}
}
else
{
// This thread lost and we need to wait for the winner to finish the copy.
task.Wait();
Debug.Assert(AnalyzerAssemblyLoader.GeneratedPathComparer.Equals(shadowCopyPath, task.Result));
}
static void copyFile(ShadowCopyAnalyzerPathResolver @this, string originalPath, string shadowCopyPath)
{
var directory = Path.GetDirectoryName(shadowCopyPath);
if (directory is null)
{
throw new ArgumentException($"Shadow copy path '{shadowCopyPath}' must not be the root directory");
}
_ = Directory.CreateDirectory(directory);
// The shadow copy should only copy files that exist. For files that don't exist, this best
// emulates not having the shadow copy layer
if (File.Exists(originalPath))
{
linkFromCacheOrFallbackToCopy(@this, originalPath, shadowCopyPath);
ClearReadOnlyFlagOnFile(new FileInfo(shadowCopyPath));
}
}
// Optimization for antivirus scanning on Windows:
// - Shadow copied files are hard-linked to/from a cache directory if possible.
// - We continue to load from 'SessionDirectory' for ease of implementing correct loading semantics and cleanup.
// - Hard linking a file from the cache instead of copying it is empirically observed to reduce time spent running AV scans when loading assemblies.
static void linkFromCacheOrFallbackToCopy(ShadowCopyAnalyzerPathResolver @this, string originalPath, string shadowCopyPath)
{
var cachePath = TryGetCacheKey(originalPath) is { } cacheKey
? Path.Combine(@this.CacheDirectory, cacheKey)
: null;
if (File.Exists(cachePath))
{
// File is already present in cache. If it matches original, then hard-link from cache to shadow copy path. Failing that just copy from the original path.
if (!cacheEntryMatches(originalPath, cachePath))
{
try { File.Delete(cachePath); } catch { }
File.Copy(originalPath, shadowCopyPath);
}
else if (!@this.TryCreateHardLink(cachePath, shadowCopyPath))
{
File.Copy(originalPath, shadowCopyPath);
}
}
else
{
// File not in cache. Copy it to the shadow copy path, then try to hard link it to the cache.
// If the hard linking fails for some reason, that isn't a functional problem.
// It usually means we lost a race to cache the same file, or that the current volume doesn't support hard links (e.g. Dev Drive).
File.Copy(originalPath, shadowCopyPath);
if (cachePath is not null)
{
Directory.CreateDirectory(@this.CacheDirectory);
@this.TryCreateHardLink(shadowCopyPath, cachePath);
}
}
}
static bool cacheEntryMatches(string originalPath, string cachePath)
{
var originalInfo = new FileInfo(originalPath);
var cacheInfo = new FileInfo(cachePath);
try
{
// Sometimes differing assemblies will have same mvid, e.g. before and after Ready2Run compilation.
// To defend against this, we require both length and mvid match in order to use the cached file.
if (originalInfo.Length != cacheInfo.Length)
return false;
return AssemblyUtilities.ReadMvid(originalPath) == AssemblyUtilities.ReadMvid(cachePath);
}
catch
{
return false;
}
}
}
private static string HashToHex(ReadOnlySpan<char> value)
{
Span<byte> hash = stackalloc byte[16];
int bytesWritten = XxHash128.Hash(MemoryMarshal.AsBytes(value), hash);
Debug.Assert(bytesWritten == hash.Length);
return HexUtilities.ToHexStringLower(hash);
}
private static string? TryGetCacheKey(string originalPath)
{
// Key format: (original filename) + (file path hash) + (mvid) + (original file length) + (original file extension)
var hexHash = HashToHex(originalPath);
try
{
var mvid = AssemblyUtilities.ReadMvid(originalPath);
var length = new FileInfo(originalPath).Length;
return $"{Path.GetFileNameWithoutExtension(originalPath)}-{hexHash}-{mvid:N}-{length}{Path.GetExtension(originalPath)}";
}
catch
{
return null;
}
}
private static void ClearReadOnlyFlagOnFiles(string directoryPath)
{
DirectoryInfo directory = new DirectoryInfo(directoryPath);
foreach (var file in directory.EnumerateFiles(searchPattern: "*", searchOption: SearchOption.AllDirectories))
{
ClearReadOnlyFlagOnFile(file);
}
}
private static void ClearReadOnlyFlagOnFile(FileInfo fileInfo)
{
try
{
if (fileInfo.IsReadOnly)
{
fileInfo.IsReadOnly = false;
}
}
catch
{
// There are many reasons this could fail. Ignore it and keep going.
}
}
/// <summary>Create a hard link to a file.</summary>
/// <seealso href="https://learn.microsoft.com/en-us/dotnet/api/system.io.file.createhardlink?view=net-11.0" />
private bool TryCreateHardLink(string path, string pathToTarget)
{
return EnableHardLinks && CreateHardLink(pathToTarget, path, IntPtr.Zero);
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createhardlinkw
[DllImport("Kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes);
}
/// <summary>Get number of hard links to a file.</summary>
private static ByHandleFileInformation? TryGetWindowsFileInformation(string path)
{
// https://learn.microsoft.com/en-us/windows/win32/fileio/file-access-rights-constants
const uint FILE_READ_ATTRIBUTES = 0x0080;
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
const uint FILE_SHARE_READ = 0x00000001;
const uint FILE_SHARE_WRITE = 0x00000002;
const uint FILE_SHARE_DELETE = 0x00000004;
const uint OPEN_EXISTING = 3;
using var handle = CreateFileW(
lpFileName: path,
dwDesiredAccess: FILE_READ_ATTRIBUTES,
dwShareMode: FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
lpSecurityAttributes: IntPtr.Zero,
dwCreationDisposition: OPEN_EXISTING,
dwFlagsAndAttributes: 0,
hTemplateFile: IntPtr.Zero);
if (!GetFileInformationByHandle(handle, out var fileInformation))
return null;
return fileInformation;
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern SafeFileHandle CreateFileW(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfileinformationbyhandle
[DllImport("Kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool GetFileInformationByHandle(SafeFileHandle handle, out ByHandleFileInformation fileInformation);
}
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public uint FileAttributes;
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
}
}