// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using System.IO.Hashing;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Aspire.Cli.Configuration;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Caching;
///
/// Content-keyed disk cache for the AppHost MSBuild project inspection.
///
///
/// Reliable invalidation is achieved by making the inputs that drive the cached MSBuild
/// evaluation part of the cache key itself. Any change to a tracked input produces a different
/// key, which means a cache miss and a fresh re-evaluation. There is no need for a time-based
/// staleness window for correctness — only for janitorial cleanup of orphaned entries.
/// This mirrors the shape of the SDK's own MSBuild-output caches: incremental targets include
/// inputs such as $(ProjectAssetsFile), $(ProjectAssetsCacheFile), and
/// $(MSBuildAllProjects). This cache sits before the MSBuild call it is trying to avoid,
/// so it fingerprints stable filesystem beacons that represent those same inputs instead of
/// asking MSBuild for another evaluation.
///
/// Tracked inputs (see ):
///
/// - The .csproj absolute path and its last-write time.
/// - The mtime of obj/project.assets.json next to the .csproj. NuGet writes this
/// file on every restore, so any package-graph change (Central Package Management
/// version bumps, transitive package updates, SDK changes that affect restore) advances
/// this timestamp.
/// - The mtimes of Directory.Build.props, Directory.Build.targets,
/// Directory.Packages.props, and Directory.Packages.targets found by
/// walking up from the project directory to either a .git boundary or the
/// filesystem root. This catches transitive .props edits that the user has not yet
/// restored against.
/// - The mtime of global.json walking up the same path, to catch SDK pin
/// changes that do not trigger a restore.
/// - A schema version constant, bumped when the set of cached properties changes.
///
///
/// The cache only stores metadata from the AppHost inspection target, not build outputs or runtime
/// state, so a stale hit can only reuse stale answers such as the AppHost marker, Aspire.Hosting
/// version, CLI bundle opt-in, or user-secrets ID. The known stale-hit cases are the inputs MSBuild
/// can see but this pre-MSBuild fingerprint cannot reliably discover without doing another
/// evaluation:
///
/// - Edits to .targets or .props files imported from OUTSIDE the project
/// directory tree (e.g. <Import Project="..\..\shared.targets"/>).
/// - Custom imports whose path changes are not reflected by one of the conventional
/// walk-up files tracked above.
/// - External manipulation of project.assets.json mtime, or a restore/package graph
/// change that does not update that file's timestamp.
///
///
/// Users with such setups can recover by touching the .csproj, running dotnet restore,
/// running aspire cache clear, or running
/// aspire config set dotnetAppHostInfoCacheDisabled true.
///
internal sealed class AppHostInfoDiskCache : IAppHostInfoDiskCache
{
// Bump this when the cached property set changes so old entries are ignored.
// v1 caches: IsAspireHost, AspireHostingVersion, AspireUseCliBundle, UserSecretsId.
private const string SchemaVersion = "v1";
// Keep AppHost inspection entries isolated from other Aspire caches so `aspire cache clear`
// can delete the whole subtree without needing to understand the file naming scheme.
private const string SubDirectoryName = "apphost-info";
// Escape hatch for users whose projects rely on imported files that are not represented in
// the fingerprint. This intentionally goes through IConfigurationService instead of the
// process-wide IConfiguration so only `aspire config set dotnetAppHostInfoCacheDisabled true`
// participates; environment variables with the same name must not disable the cache.
private const string DisableConfigKey = "dotnetAppHostInfoCacheDisabled";
// We cannot rely only on MSBuildAllProjects here because cache hits happen before MSBuild
// runs. The previous evaluation can tell us which files were imported then, but it cannot
// reveal a newly added Directory.Build.* or Directory.Packages.* file that would change the
// next evaluation unless we probe those conventional walk-up locations ourselves.
private static readonly string[] s_trackedSiblingFiles =
[
"Directory.Build.props",
"Directory.Build.targets",
"Directory.Packages.props",
"Directory.Packages.targets",
];
private static JsonTypeInfo EntryTypeInfo =>
JsonSourceGenerationContext.Default.AppHostInfoCacheEntry;
private readonly ILogger _logger;
private readonly DirectoryInfo _cacheDirectory;
private readonly IConfigurationService _configurationService;
public AppHostInfoDiskCache(ILogger logger, CliExecutionContext executionContext, IConfigurationService configurationService)
{
_logger = logger;
_cacheDirectory = new DirectoryInfo(Path.Combine(executionContext.CacheDirectory.FullName, SubDirectoryName));
_configurationService = configurationService;
}
public async Task TryGetAsync(FileInfo projectFile, CancellationToken cancellationToken)
{
if (await IsDisabledAsync(projectFile, cancellationToken).ConfigureAwait(false))
{
return null;
}
try
{
var key = GetCacheKey(projectFile);
var path = Path.Combine(_cacheDirectory.FullName, $"{key}.json");
if (!File.Exists(path))
{
_logger.LogTrace("AppHost info cache miss for {Project} (key {Key})", projectFile.FullName, key);
return null;
}
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
var entry = JsonSerializer.Deserialize(json, EntryTypeInfo);
if (entry is null || !string.Equals(entry.SchemaVersion, SchemaVersion, StringComparison.Ordinal))
{
// Schema mismatch — treat as miss, the new value will overwrite.
_logger.LogTrace("AppHost info cache schema mismatch for {Project}", projectFile.FullName);
return null;
}
_logger.LogTrace("AppHost info cache hit for {Project} (key {Key})", projectFile.FullName, key);
return entry;
}
catch (Exception ex)
{
// Any read or deserialization failure is non-fatal: just miss the cache.
_logger.LogDebug(ex, "Failed to read AppHost info cache for {Project}", projectFile.FullName);
return null;
}
}
public async Task SetAsync(FileInfo projectFile, string expectedCacheKey, AppHostInfoCacheEntry entry, CancellationToken cancellationToken)
{
if (await IsDisabledAsync(projectFile, cancellationToken).ConfigureAwait(false))
{
return;
}
string? tempPath = null;
try
{
if (!_cacheDirectory.Exists)
{
_cacheDirectory.Create();
}
var key = GetCacheKey(projectFile);
if (!string.Equals(key, expectedCacheKey, StringComparison.Ordinal))
{
// The key is captured before MSBuild runs and checked again before publishing.
// Without this guard, a project/import/assets edit that lands during evaluation
// could write stale metadata under the new input key.
_logger.LogTrace(
"Skipping AppHost info cache write for {Project}; cache key changed from {ExpectedKey} to {CurrentKey}",
projectFile.FullName,
expectedCacheKey,
key);
return;
}
var path = Path.Combine(_cacheDirectory.FullName, $"{key}.json");
// Same pattern used by dotnet/sdk's SdkReleaseMetadataCache: write a complete
// payload to a random file in the target directory, then atomically replace the
// stable project/input-scoped file so readers never see partial JSON.
// See https://github.com/dotnet/sdk/blob/main/src/Cli/dotnet/SdkVulnerability/SdkReleaseMetadataCache.cs
tempPath = Path.Combine(_cacheDirectory.FullName, $"{Path.GetRandomFileName()}.tmp");
var payload = JsonSerializer.Serialize(entry with { SchemaVersion = SchemaVersion }, EntryTypeInfo);
await File.WriteAllTextAsync(tempPath, payload, cancellationToken).ConfigureAwait(false);
// File.Move(..., overwrite: true) is atomic on the same volume on POSIX and on
// Windows since .NET 5. If two CLIs race here the loser overwrites with identical
// content (same key → same payload), so the result is consistent either way.
File.Move(tempPath, path, overwrite: true);
_logger.LogTrace("Stored AppHost info cache entry for {Project} (key {Key})", projectFile.FullName, key);
}
catch (Exception ex)
{
if (tempPath is not null)
{
TryDeleteTemporaryFile(tempPath, _logger);
}
_logger.LogDebug(ex, "Failed to write AppHost info cache for {Project}", projectFile.FullName);
}
}
private static void TryDeleteTemporaryFile(string tempPath, ILogger logger)
{
try
{
File.Delete(tempPath);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to delete temporary AppHost info cache file {Path}", tempPath);
}
}
///
/// Computes a stable, content-derived cache key for the supplied project file.
/// The key is a hex-encoded XxHash3 of a delimited string of inputs; it is suitable for
/// use as a filename on all platforms.
///
public string GetCacheKey(FileInfo projectFile) => ComputeKeyAsync(projectFile);
private async Task IsDisabledAsync(FileInfo projectFile, CancellationToken cancellationToken)
{
var startDirectory = projectFile.Directory ?? new DirectoryInfo(Environment.CurrentDirectory);
var value = await _configurationService.GetConfigurationFromDirectoryAsync(DisableConfigKey, startDirectory, cancellationToken: cancellationToken).ConfigureAwait(false);
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
internal static string ComputeKeyAsync(FileInfo projectFile)
{
// Raw fingerprint shape:
// v1|/repo/app/AppHost.csproj|csproj=638831006400000000|assets=638831006410000000|...
// Each file input is represented by a stable tag and its UTC last-write timestamp ticks,
// or '-' when the file is absent/inaccessible. The raw fingerprint intentionally includes full
// paths so two projects with identical mtimes cannot collide, but that makes it too long
// and path-sensitive for a portable filename. Hash it with XxHash3 so the cache file is
// short, filename-safe, and non-cryptographic (this is only cache identity, not security).
var sb = new StringBuilder(512);
sb.Append(SchemaVersion);
sb.Append('|');
sb.Append(projectFile.FullName);
sb.Append('|');
AppendMtime(sb, projectFile.FullName, "csproj");
var projectDir = projectFile.Directory?.FullName;
if (projectDir is not null)
{
// obj/project.assets.json is NuGet's resolved package graph for this project.
// The SDK also treats it as an incremental build input:
// https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.targets
// The AppHost inspection reads PackageReference/PackageVersion-derived items,
// so the cache must notice changes that come from restore inputs outside the
// .csproj itself: Central Package Management edits, transitive updates, SDK
// changes that affect restore, or a fresh restore after package graph changes.
AppendMtime(sb, Path.Combine(projectDir, "obj", "project.assets.json"), "assets");
// Walk up to a .git boundary or filesystem root and stat any
// Directory.Build.* / Directory.Packages.* / global.json we find along the way.
// Files higher up shadow files lower down in MSBuild, but for cache invalidation
// we just need to detect ANY change. AppendMtime records each entry as
// "tag=ticks" (or "tag=-" when absent); the path itself is used only to stat the
// file, not appended to the string. That is sufficient here because (a) the
// project's absolute path is already at the head of the fingerprint, and (b) the
// positional order of these per-directory entries in the walkup sequence
// implicitly identifies which directory each tick belongs to.
var dir = projectFile.Directory;
while (dir is not null)
{
foreach (var siblingName in s_trackedSiblingFiles)
{
AppendMtime(sb, Path.Combine(dir.FullName, siblingName), siblingName);
}
AppendMtime(sb, Path.Combine(dir.FullName, "global.json"), "globaljson");
// Stop at a .git boundary — typically the repo root, which is far enough
// for MSBuild import resolution and avoids walking the entire user profile.
// In a regular checkout `.git` is a directory; in a worktree, submodule, or
// certain tool-managed setups it is a regular file that points at the real
// git dir (e.g. "gitdir: /path/to/parent/.git/worktrees/foo"). Check both so
// the walk terminates in those layouts as well.
// https://git-scm.com/docs/git-worktree#_details
var gitMarker = Path.Combine(dir.FullName, ".git");
if (Directory.Exists(gitMarker) || File.Exists(gitMarker))
{
break;
}
dir = dir.Parent;
}
}
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
var hash = XxHash3.Hash(bytes);
return Convert.ToHexString(hash);
}
// "mtime" is shorthand for modification time: FileInfo.LastWriteTimeUtc converted to
// DateTime ticks. We use UTC ticks instead of formatted timestamps so the fingerprint is
// culture-invariant and stable across processes.
private static void AppendMtime(StringBuilder sb, string path, string tag)
{
sb.Append('|');
sb.Append(tag);
sb.Append('=');
try
{
var info = new FileInfo(path);
if (info.Exists)
{
sb.Append(info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture));
}
else
{
sb.Append('-');
}
}
catch
{
// A stat failure (permission denied, transient IO) collapses to the "missing"
// marker. Worst case we get a cache miss until the situation resolves.
sb.Append('-');
}
}
}
internal interface IAppHostInfoDiskCache
{
string GetCacheKey(FileInfo projectFile);
Task TryGetAsync(FileInfo projectFile, CancellationToken cancellationToken);
Task SetAsync(FileInfo projectFile, string expectedCacheKey, AppHostInfoCacheEntry entry, CancellationToken cancellationToken);
}
internal sealed record AppHostInfoCacheEntry
{
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "v1";
[JsonPropertyName("exitCode")]
public int ExitCode { get; init; }
[JsonPropertyName("isAspireHost")]
public bool IsAspireHost { get; init; }
[JsonPropertyName("aspireHostingVersion")]
public string? AspireHostingVersion { get; init; }
[JsonPropertyName("isUsingCliBundle")]
public bool IsUsingCliBundle { get; init; }
[JsonPropertyName("userSecretsId")]
public string? UserSecretsId { get; init; }
}