// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
namespace ILLink.Tasks;
internal sealed class ILLinkCache
{
private const int FormatVersion = 1;
private readonly TaskLoggingHelper _log;
private static readonly StringComparison s_pathComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
internal string CacheDirectory { get; }
private ILLinkCache(string cacheDirectory, TaskLoggingHelper log)
{
CacheDirectory = cacheDirectory;
_log = log;
}
internal static ILLinkCache? TryCreateFromEnvironment(TaskLoggingHelper log)
{
string? setting = Environment.GetEnvironmentVariable("ILLINK_EXPERIMENTAL_CACHE");
if (string.IsNullOrEmpty(setting))
return null;
if (!bool.TryParse(setting, out bool enabled))
{
log.LogMessage(MessageImportance.Low, "ILLink caching is disabled because ILLINK_EXPERIMENTAL_CACHE must be 'true' or 'false'.");
return null;
}
return enabled ? TryCreate(Environment.GetEnvironmentVariable("ILLINK_EXPERIMENTAL_CACHE_PATH"), log) : null;
}
internal static ILLinkCache? TryCreate(string? cacheDirectory, TaskLoggingHelper log)
{
if (string.IsNullOrEmpty(cacheDirectory))
cacheDirectory = GetDefaultCacheDirectory();
if (cacheDirectory is null)
{
log.LogMessage(MessageImportance.Low, "ILLink caching is disabled because no default cache directory is available.");
return null;
}
try
{
return new ILLinkCache(Path.GetFullPath(cacheDirectory), log);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or IOException or UnauthorizedAccessException)
{
log.LogMessage(MessageImportance.Low, $"ILLink caching is disabled because the cache directory '{cacheDirectory}' could not be resolved: {ex.Message}");
return null;
}
}
private static string? GetDefaultCacheDirectory()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.DoNotVerify);
return string.IsNullOrEmpty(localAppData) || !Path.IsPathRooted(localAppData)
? null
: Path.Combine(localAppData, "illink");
}
bool isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
if (!isMacOS)
{
string? cacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME");
if (!string.IsNullOrEmpty(cacheHome) && Path.IsPathRooted(cacheHome))
return Path.Combine(cacheHome, "illink");
}
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrEmpty(home) || !Path.IsPathRooted(home))
return null;
return isMacOS
? Path.Combine(home, "Library", "Caches", "illink")
: Path.Combine(home, ".cache", "illink");
}
internal bool TryRestore(string inputHash, string outputDirectory, DateTime outputTimestampUtc)
{
string entryDirectory = GetEntryDirectory(inputHash);
outputDirectory = ValidateOutputDirectory(outputDirectory);
if (outputTimestampUtc.Kind != DateTimeKind.Utc)
throw new ArgumentException("The output timestamp must be UTC.", nameof(outputTimestampUtc));
if (!Directory.Exists(entryDirectory))
{
_log.LogMessage(MessageImportance.Low, $"ILLink cache miss: {inputHash}");
return false;
}
try
{
List<string> files = ReadManifest(entryDirectory);
// Check the entire entry before modifying outputs. Copies can still fail if the cache is wiped concurrently.
if (Directory.Exists(outputDirectory))
Directory.Delete(outputDirectory, recursive: true);
Directory.CreateDirectory(outputDirectory);
foreach (string relativePath in files)
{
string destination = Path.Combine(outputDirectory, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(Path.Combine(entryDirectory, "outputs", relativePath), destination, overwrite: true);
File.SetLastWriteTimeUtc(destination, outputTimestampUtc);
}
UpdateLastUsed(entryDirectory);
_log.LogMessage(MessageImportance.Low, $"ILLink cache hit: {inputHash}");
return true;
}
catch (Exception ex) when (IsCacheFailure(ex))
{
_log.LogMessage(MessageImportance.Low, $"ILLink cache restore failed for '{inputHash}'; running ILLink normally: {ex.Message}");
return false;
}
}
internal void Store(string inputHash, string outputDirectory)
{
string entryDirectory = GetEntryDirectory(inputHash);
outputDirectory = ValidateOutputDirectory(outputDirectory);
string? stagingDirectory = null;
try
{
using var mutex = new Mutex(initiallyOwned: false, GetWriterMutexName(inputHash));
bool acquired;
try
{
acquired = mutex.WaitOne(0);
}
catch (AbandonedMutexException)
{
acquired = true;
_log.LogMessage(MessageImportance.Low, $"ILLink cache writer lock was abandoned: {inputHash}");
}
if (!acquired)
{
_log.LogMessage(MessageImportance.Low, $"ILLink cache store skipped; another writer owns '{inputHash}'.");
return;
}
try
{
if (Directory.Exists(entryDirectory))
{
_log.LogMessage(MessageImportance.Low, $"ILLink cache store skipped; entry '{inputHash}' already exists.");
return;
}
var files = new List<string>();
CollectOutputFiles(outputDirectory, "", files);
files.Sort(StringComparer.Ordinal);
string parentDirectory = Path.GetDirectoryName(entryDirectory)!;
Directory.CreateDirectory(parentDirectory);
stagingDirectory = Path.Combine(parentDirectory, inputHash + "." + Guid.NewGuid().ToString("N") + ".tmp");
Directory.CreateDirectory(stagingDirectory);
using (var manifest = new BinaryWriter(File.Create(Path.Combine(stagingDirectory, "manifest"))))
{
manifest.Write(FormatVersion);
manifest.Write(files.Count);
foreach (string relativePath in files)
{
string cachedFile = Path.Combine(stagingDirectory, "outputs", relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(cachedFile)!);
File.Copy(Path.Combine(outputDirectory, relativePath), cachedFile);
manifest.Write(relativePath);
manifest.Write(new FileInfo(cachedFile).Length);
manifest.Write(HashFile(cachedFile));
}
}
ILLinkCacheEntry.WriteLastUsed(Path.Combine(stagingDirectory, ILLinkCacheEntry.LastUsedFileName));
Directory.Move(stagingDirectory, entryDirectory);
stagingDirectory = null;
_log.LogMessage(MessageImportance.Low, $"ILLink cache stored: {inputHash}");
}
finally
{
mutex.ReleaseMutex();
}
}
catch (Exception ex) when (IsCacheFailure(ex))
{
_log.LogMessage(MessageImportance.Low, $"ILLink cache store failed for '{inputHash}': {ex.Message}");
}
finally
{
if (stagingDirectory is not null)
{
try
{
if (Directory.Exists(stagingDirectory))
Directory.Delete(stagingDirectory, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.LogMessage(MessageImportance.Low, $"ILLink cache staging cleanup failed for '{stagingDirectory}': {ex.Message}");
}
}
}
}
private void UpdateLastUsed(string entryDirectory)
{
try
{
ILLinkCacheEntry.WriteLastUsed(Path.Combine(entryDirectory, ILLinkCacheEntry.LastUsedFileName));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.LogMessage(MessageImportance.Low, $"ILLink cache last-used update failed for '{entryDirectory}': {ex.Message}");
}
}
private string GetEntryDirectory(string inputHash)
{
if (!ILLinkCacheEntry.IsEntryName(inputHash))
throw new ArgumentException("The input hash must be a lowercase SHA-256 hex string.", nameof(inputHash));
return Path.Combine(CacheDirectory, ILLinkCacheEntry.VersionDirectory, inputHash);
}
private string GetWriterMutexName(string inputHash)
{
string directory = CacheDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
directory = directory.ToUpperInvariant();
#if NET
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(directory + "\n" + inputHash));
return @"Global\ILLinkCache-" + Convert.ToHexString(hash);
#else
using var sha256 = SHA256.Create();
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(directory + "\n" + inputHash));
return @"Global\ILLinkCache-" + BitConverter.ToString(hash).Replace("-", "");
#endif
}
private string ValidateOutputDirectory(string outputDirectory)
{
string outputPath = Path.GetFullPath(outputDirectory);
string cachePrefix = CacheDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
string outputPrefix = outputPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
if (cachePrefix.StartsWith(outputPrefix, s_pathComparison) || outputPrefix.StartsWith(cachePrefix, s_pathComparison))
throw new ArgumentException("The cache and output directories must not contain each other.", nameof(outputDirectory));
return outputPath;
}
private static void CollectOutputFiles(string directory, string relativeDirectory, List<string> files)
{
foreach (string path in Directory.EnumerateFileSystemEntries(directory))
{
string relativePath = Path.Combine(relativeDirectory, Path.GetFileName(path));
if (Directory.Exists(path))
CollectOutputFiles(path, relativePath, files);
else
files.Add(relativePath);
}
}
private static List<string> ReadManifest(string entryDirectory)
{
using var manifest = new BinaryReader(File.OpenRead(Path.Combine(entryDirectory, "manifest")));
if (manifest.ReadInt32() != FormatVersion)
throw new InvalidDataException("Unsupported ILLink cache format.");
int fileCount = manifest.ReadInt32();
if (fileCount < 0 || fileCount > manifest.BaseStream.Length - manifest.BaseStream.Position)
throw new InvalidDataException("Invalid ILLink cache file count.");
var files = new List<string>();
var paths = new HashSet<string>(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
string outputsDirectory = Path.Combine(entryDirectory, "outputs");
for (int i = 0; i < fileCount; i++)
{
string relativePath = manifest.ReadString();
ValidateRelativePath(relativePath);
if (!paths.Add(relativePath))
throw new InvalidDataException("Invalid ILLink cache output inventory.");
long length = manifest.ReadInt64();
byte[] expectedHash = manifest.ReadBytes(32);
string cachedFile = Path.Combine(outputsDirectory, relativePath);
if (expectedHash.Length != 32 || new FileInfo(cachedFile).Length != length ||
!expectedHash.SequenceEqual(HashFile(cachedFile)))
throw new InvalidDataException($"Invalid ILLink cache output: '{relativePath}'.");
files.Add(relativePath);
}
if (manifest.BaseStream.Position != manifest.BaseStream.Length)
throw new InvalidDataException("Unexpected data in ILLink cache manifest.");
return files;
}
private static void ValidateRelativePath(string path)
{
if (string.IsNullOrEmpty(path) || Path.IsPathRooted(path))
throw new InvalidDataException("Invalid ILLink cache output path.");
foreach (string component in path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
{
if (component is "" or "." or ".." || component.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && component[component.Length - 1] is '.' or ' '))
throw new InvalidDataException("Invalid ILLink cache output path.");
}
}
private static byte[] HashFile(string path)
{
using var sha256 = SHA256.Create();
using var stream = File.OpenRead(path);
return sha256.ComputeHash(stream);
}
private static bool IsCacheFailure(Exception ex) => ex is IOException or UnauthorizedAccessException or InvalidDataException or FormatException;
}