// 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 Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Caching;
/// <summary>
/// Content-keyed disk cache for the AppHost MSBuild project inspection.
/// </summary>
/// <remarks>
/// 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 <c>$(ProjectAssetsFile)</c>, <c>$(ProjectAssetsCacheFile)</c>, and
/// <c>$(MSBuildAllProjects)</c>. 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 <see cref="ComputeKeyAsync(FileInfo, IEnvironment, in DotNetAppHostProject.AppHostImportDependencies)"/>):
/// <list type="bullet">
/// <item>The .csproj absolute path and its last-write time.</item>
/// <item>The mtime of <c>obj/project.assets.json</c> 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.</item>
/// <item>The mtimes of <c>Directory.Build.props</c>, <c>Directory.Build.targets</c>,
/// <c>Directory.Packages.props</c>, and <c>Directory.Packages.targets</c> found by
/// walking up from the project directory to the filesystem root. This matches MSBuild's
/// own Directory.Build.* discovery range (which has no <c>.git</c> boundary) and keeps
/// the fingerprint in parity with <c>DotNetAppHostProject.IsLikelyAppHost</c>. This catches
/// transitive .props edits that the user has not yet restored against.</item>
/// <item>The mtime of <c>global.json</c> walking up the same path, to catch SDK pin
/// changes that do not trigger a restore.</item>
/// <item>The mtimes of the custom files that the AppHost prefilter's imports resolve to
/// (see <c>DotNetAppHostProject.CollectImportDependencies</c>): walk-up import targets such as
/// <c>$([MSBuild]::GetPathOfFileAbove('Aspire.Common.props'))</c>, statted at every ancestor
/// level, and exact static import targets such as
/// <c>$(MSBuildThisFileDirectory)../shared/Directory.Build.props</c>, statted by full path.
/// Those imports can set <c>IsAspireHost</c> without touching any conventional file above.</item>
/// <item>A schema version constant, bumped when the set of cached properties changes.</item>
/// </list>
///
/// When the prefilter honors an import whose target no filesystem walk can predict (an MSBuild expression
/// in place of the file name, a walk-up rooted outside the project's ancestor chain, an appended path that
/// does not name a single file in the resolved directory, or a wildcard), the disk cache is bypassed
/// entirely for that project rather than risking a permanently stale entry.
///
/// 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, user-secrets ID, or AppHost launch metadata. The known stale-hit
/// cases are the inputs MSBuild can see but this pre-MSBuild fingerprint cannot reliably discover
/// without doing another evaluation:
/// <list type="bullet">
/// <item>Edits to <c>.targets</c> or <c>.props</c> files imported from OUTSIDE the project
/// directory tree (e.g. <c><Import Project="..\..\shared.targets"/></c>).</item>
/// <item>Custom imports whose path changes are not reflected by one of the conventional
/// walk-up files tracked above.</item>
/// <item>External manipulation of <c>project.assets.json</c> mtime, or a restore/package graph
/// change that does not update that file's timestamp.</item>
/// </list>
///
/// Users with such setups can recover by touching the .csproj, running <c>dotnet restore</c>,
/// running <c>aspire cache clear</c>, or running
/// <c>aspire config set dotnetAppHostInfoCacheDisabled true</c>.
/// </remarks>
internal sealed class AppHostInfoDiskCache : IAppHostInfoDiskCache
{
// Bump this after shipping if the cached property set changes so old entries are ignored.
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<AppHostInfoCacheEntry> EntryTypeInfo =>
JsonSourceGenerationContext.Default.AppHostInfoCacheEntry;
private readonly ILogger<AppHostInfoDiskCache> _logger;
private readonly DirectoryInfo _cacheDirectory;
private readonly IConfigurationService _configurationService;
private readonly IEnvironment _environment;
public AppHostInfoDiskCache(ILogger<AppHostInfoDiskCache> logger, CliExecutionContext executionContext, IConfigurationService configurationService, IEnvironment environment)
{
_logger = logger;
_cacheDirectory = new DirectoryInfo(Path.Combine(executionContext.CacheDirectory.FullName, SubDirectoryName));
_configurationService = configurationService;
_environment = environment;
}
public async Task<AppHostInfoCacheEntry?> TryGetAsync(FileInfo projectFile, CancellationToken cancellationToken)
{
if (await IsDisabledAsync(projectFile, cancellationToken).ConfigureAwait(false))
{
return null;
}
// Collect once and reuse for both the bypass decision and the key: the collector parses the project
// and the ancestor Directory.Build.* files, which is the most expensive part of a cache hit.
var importDependencies = DotNetAppHostProject.CollectImportDependencies(projectFile);
if (HasUnfingerprintableImports(projectFile, importDependencies))
{
return null;
}
try
{
var key = ComputeKeyAsync(projectFile, _environment, importDependencies);
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;
}
if (entry.ExitCode == 0 && entry.IsAspireHost && string.IsNullOrWhiteSpace(entry.RunCommand))
{
// Treat entries that successfully inspected an AppHost but are missing RunCommand
// as schema-incompatible misses, so the direct launch path can refresh the SDK run
// command instead of permanently falling back. The schema marker stays stable
// because adding RunCommand is backward compatible for non-AppHost entries.
_logger.LogTrace("AppHost info cache entry for {Project} is missing RunCommand", 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;
}
var importDependencies = DotNetAppHostProject.CollectImportDependencies(projectFile);
if (HasUnfingerprintableImports(projectFile, importDependencies))
{
return;
}
string? tempPath = null;
try
{
if (!_cacheDirectory.Exists)
{
_cacheDirectory.Create();
}
var key = ComputeKeyAsync(projectFile, _environment, importDependencies);
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);
}
}
/// <summary>
/// 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.
/// </summary>
public string GetCacheKey(FileInfo projectFile) => ComputeKeyAsync(projectFile, _environment);
private async Task<bool> 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);
}
private bool HasUnfingerprintableImports(FileInfo projectFile, in DotNetAppHostProject.AppHostImportDependencies importDependencies)
{
// The AppHost prefilter honors custom imports such as
// <Import Project="$([MSBuild]::GetPathOfFileAbove('Aspire.Common.props', '$(ExternalRoot)'))" />
// <Import Project="$(RepoRoot)Directory.Build.props" />
// Most of them are fingerprinted in ComputeKeyAsync, but some resolve to a file no filesystem walk
// can predict (an MSBuild expression where the file name belongs, a walk-up rooted outside the
// project's ancestor chain, an appended path that does not name a single file in the resolved
// directory, a wildcard). Editing such a file to set <IsAspireHost>true</IsAspireHost> would leave
// every fingerprinted mtime untouched, so a cached "not an AppHost" answer would survive forever and
// the AppHost would stay undiscoverable. Skip the disk cache entirely for those projects and let
// MSBuild answer each time; the in-process cache in AppHostInfoResolver still de-duplicates work
// inside a single CLI invocation.
if (!importDependencies.HasUnfingerprintableImport)
{
return false;
}
_logger.LogTrace(
"Skipping AppHost info disk cache for {Project}; it depends on an import that cannot be fingerprinted",
projectFile.FullName);
return true;
}
internal static string ComputeKeyAsync(FileInfo projectFile, IEnvironment environment)
=> ComputeKeyAsync(projectFile, environment, DotNetAppHostProject.CollectImportDependencies(projectFile));
internal static string ComputeKeyAsync(FileInfo projectFile, IEnvironment environment, in DotNetAppHostProject.AppHostImportDependencies importDependencies)
{
// 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(CliPathHelper.NormalizePathCasing(projectFile.FullName, environment));
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");
// Beyond the conventional files, fingerprint the custom files that DotNetAppHostProject's
// prefilter lets decide whether this project is an AppHost. Without this, setting
// <IsAspireHost>true</IsAspireHost> in such a file leaves every tracked mtime unchanged and the
// CLI keeps serving the stale "not an AppHost" entry.
//
// Walk-up imports (e.g. a shared Aspire.Common.props pulled in with
// $([MSBuild]::GetPathOfFileAbove(...))) bind to the nearest file of that name at or above the
// importing file, so each name is statted at every ancestor level inside the loop below. Static
// imports (e.g. "$(MSBuildThisFileDirectory)../shared/Directory.Build.props") name exactly one
// file, which may sit outside the ancestor chain entirely, so those are statted by full path.
var customImportFileNames = importDependencies.AncestorSearchFileNames;
foreach (var exactPath in importDependencies.ExactFilePaths)
{
// The path is part of the tag here — unlike the per-directory entries below, these are not
// positionally tied to a walk step, so two different imported files must not be
// interchangeable in the fingerprint.
AppendMtime(sb, exactPath, CliPathHelper.NormalizePathCasing(exactPath, environment));
}
// Walk up to the filesystem root and stat any Directory.Build.* / Directory.Packages.* /
// global.json we find along the way. This matches MSBuild's own Directory.Build.* discovery,
// which has no .git boundary and walks to the root of the drive
// (https://learn.microsoft.com/visualstudio/msbuild/customize-by-directory), and keeps this
// fingerprint in parity with DotNetAppHostProject's IsLikelyAppHost ancestor walk. If the two
// walks disagreed, a Directory.Build.props above a nested .git could promote a project in the
// classifier while an edit to that same file failed to invalidate this cache — a stale hit.
// 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");
// Ordinal-sorted by the collector, so the same import set always produces the same key.
foreach (var customName in customImportFileNames)
{
AppendMtime(sb, Path.Combine(dir.FullName, customName), customName);
}
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<AppHostInfoCacheEntry?> 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; }
[JsonPropertyName("runCommand")]
public string? RunCommand { get; init; }
[JsonPropertyName("targetPath")]
public string? TargetPath { get; init; }
[JsonPropertyName("runWorkingDirectory")]
public string? RunWorkingDirectory { get; init; }
[JsonPropertyName("runArguments")]
public string? RunArguments { get; init; }
[JsonPropertyName("targetFramework")]
public string? TargetFramework { get; init; }
[JsonPropertyName("targetFrameworks")]
public string? TargetFrameworks { get; init; }
}