// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.IO.Hashing;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using Aspire.Cli.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Projects;
/// <summary>
/// Shared MSBuild-side closure contract used by generated integration projects.
/// Owns the cache-directory layout, file-name constants, project-file XML emission
/// (properties + AfterBuild targets), and the post-build closure file reader.
/// </summary>
/// <remarks>
/// The MSBuild contract is intentionally narrow: a small set of <c>AspireClosure*File</c>
/// path properties tell the project where to write the closure files, and two
/// <c>AfterTargets=Build</c> targets do the writing. Both consumers must speak the same
/// contract or one of them will silently miss entries — keeping the emission and reading
/// code centralized prevents that drift.
/// </remarks>
internal static class IntegrationClosureBuilder
{
internal const string ClosureMetadataFileName = "closure-metadata.txt";
internal const string ClosureSourcesFileName = "closure-sources.txt";
internal const string ClosureTargetsFileName = "closure-targets.txt";
internal const string ProjectRefAssemblyNamesFileName = "project-ref-assemblies.txt";
internal const string IntegrationRestoreFolderName = "integration-restore";
internal const string ProjectAssetsFileName = "project.assets.json";
/// <summary>
/// Creates the common closure project document shared by generated integration projects.
/// </summary>
public static CSharpProjectFile CreateClosureProjectFile(
string restoreDir,
IEnumerable<string>? additionalSources = null,
string? restoreConfigFile = null)
{
ArgumentException.ThrowIfNullOrEmpty(restoreDir);
var projectFile = new CSharpProjectFile();
// Generated project defaults.
projectFile.AddProperty("TargetFramework", DotNetBasedAppHostServerProject.TargetFramework);
projectFile.AddProperty("EnableDefaultItems", "false");
projectFile.AddProperty("EnableNETAnalyzers", "false");
projectFile.AddProperty("GenerateDocumentationFile", "false");
projectFile.AddProperty("IsPackable", "false");
projectFile.AddProperty("IsPublishable", "false");
// Closure output properties consumed by the post-build reader.
projectFile.AddProperty("CopyLocalLockFileAssemblies", "true");
projectFile.AddProperty("ProduceReferenceAssembly", "false");
projectFile.AddProperty("AspireClosureMetadataFile", Path.Combine(restoreDir, ClosureMetadataFileName));
projectFile.AddProperty("AspireClosureSourcesFile", Path.Combine(restoreDir, ClosureSourcesFileName));
projectFile.AddProperty("AspireClosureTargetsFile", Path.Combine(restoreDir, ClosureTargetsFileName));
projectFile.AddProperty("AspireProjectRefAssemblyNamesFile", Path.Combine(restoreDir, ProjectRefAssemblyNamesFileName));
// Restore source/config overrides from the caller.
if (additionalSources is not null)
{
var sourceList = string.Join(";", additionalSources);
if (sourceList.Length > 0)
{
projectFile.AddProperty("RestoreAdditionalProjectSources", sourceList);
}
}
if (!string.IsNullOrWhiteSpace(restoreConfigFile))
{
projectFile.AddProperty("RestoreConfigFile", restoreConfigFile);
}
// Keep closure targets centralized so every generated integration project emits the
// same inputs consumed by the post-build reader.
projectFile.Targets.Add(
new XElement("Target",
new XAttribute("Name", "_WriteAspireProjectRefAssemblyNames"),
new XAttribute("AfterTargets", "Build"),
new XAttribute("Condition", "'$(AspireProjectRefAssemblyNamesFile)' != ''"),
new XElement("WriteLinesToFile",
new XAttribute("File", "$(AspireProjectRefAssemblyNamesFile)"),
new XAttribute("Lines", "@(_ResolvedProjectReferencePaths->'%(Filename)')"),
new XAttribute("Overwrite", "true"),
new XAttribute("WriteOnlyWhenDifferent", "true"))));
projectFile.Targets.Add(
new XElement("Target",
new XAttribute("Name", "_WriteAspireClosureManifest"),
new XAttribute("AfterTargets", "Build"),
new XAttribute("Condition", "'$(AspireClosureSourcesFile)' != ''"),
new XAttribute("DependsOnTargets", "ResolveLockFileCopyLocalFiles"),
new XElement("WriteLinesToFile",
new XAttribute("File", "$(AspireClosureSourcesFile)"),
new XAttribute("Lines", "@(ReferenceCopyLocalPaths->'%(FullPath)')"),
new XAttribute("Overwrite", "true"),
new XAttribute("WriteOnlyWhenDifferent", "true")),
new XElement("WriteLinesToFile",
new XAttribute("File", "$(AspireClosureMetadataFile)"),
new XAttribute("Lines", "@(ReferenceCopyLocalPaths->'%(NuGetPackageId)|%(NuGetPackageVersion)|%(PathInPackage)|%(AssetType)')"),
new XAttribute("Overwrite", "true"),
new XAttribute("WriteOnlyWhenDifferent", "true")),
new XElement("WriteLinesToFile",
new XAttribute("File", "$(AspireClosureTargetsFile)"),
new XAttribute("Lines", "@(ReferenceCopyLocalPaths->'%(DestinationSubDirectory)%(Filename)%(Extension)')"),
new XAttribute("Overwrite", "true"),
new XAttribute("WriteOnlyWhenDifferent", "true"))));
return projectFile;
}
/// <summary>
/// Creates the early-imported props document that redirects generated integration project
/// outputs into the shared integration restore directory.
/// </summary>
public static XDocument CreateClosureDirectoryBuildProps(string restoreDir)
{
ArgumentException.ThrowIfNullOrEmpty(restoreDir);
var propertyGroup = new XElement("PropertyGroup",
new XElement("BaseOutputPath", CliPathHelper.EnsureTrailingSlash(Path.Combine(restoreDir, "bin"))),
new XElement("BaseIntermediateOutputPath", CliPathHelper.EnsureTrailingSlash(Path.Combine(restoreDir, "obj"))),
new XElement("MSBuildProjectExtensionsPath", "$(BaseIntermediateOutputPath)"));
return new XDocument(new XElement("Project", propertyGroup));
}
/// <summary>
/// Computes the per-AppHost cache directory under <c>.aspire/integrations/apphosts/</c>.
/// </summary>
public static DirectoryInfo GetAppHostIntegrationCacheDirectory(DirectoryInfo appHostDirectory)
{
ArgumentNullException.ThrowIfNull(appHostDirectory);
// Workspace ownership follows the lexical path used for configuration discovery, while the
// hash uses the physical AppHost identity so aliases within a workspace share one cache.
var integrationCacheDirectory = ConfigurationHelper.GetIntegrationCacheDirectory(appHostDirectory);
var integrationCacheFullPath = PathNormalizer.ResolveToFilesystemPath(integrationCacheDirectory.FullName);
var appHostFullPath = PathNormalizer.ResolveToFilesystemPath(appHostDirectory.FullName);
var hash = XxHash3.Hash(Encoding.UTF8.GetBytes(appHostFullPath));
var hashFragment = Convert.ToHexString(hash)[..12].ToLowerInvariant();
var path = Path.Combine(integrationCacheFullPath, "apphosts", hashFragment);
return new DirectoryInfo(path);
}
/// <summary>
/// Reads the closure files MSBuild emitted under <paramref name="restoreDir"/>, joins them
/// with NuGet package fingerprints from <paramref name="assetsFilePath"/>, and constructs an
/// <see cref="AppHostServerClosureManifest"/>.
/// </summary>
/// <param name="restoreDir">Directory containing <c>closure-sources.txt</c>, <c>closure-metadata.txt</c>, <c>closure-targets.txt</c>, and optionally <c>project-ref-assemblies.txt</c>.</param>
/// <param name="assetsFilePath">Absolute path to NuGet's <c>project.assets.json</c> for the restore project. Used to fingerprint package-backed entries.</param>
/// <param name="appSettingsContent">Content used as the manifest's <c>appsettings.json</c> hash input. Both consumers contribute their own variant.</param>
/// <param name="missingFileBehavior">Controls how missing closure files are handled.</param>
/// <param name="logger">Optional logger for diagnostics.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The materialized manifest, or <c>null</c> when <see cref="ClosureFileMissingBehavior.ReturnNull"/> is used and a required file is absent.</returns>
public static async Task<AppHostServerClosureManifest?> ReadClosureManifestAsync(
string restoreDir,
string assetsFilePath,
string appSettingsContent,
ClosureFileMissingBehavior missingFileBehavior,
ILogger? logger,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(restoreDir);
ArgumentException.ThrowIfNullOrEmpty(assetsFilePath);
ArgumentNullException.ThrowIfNull(appSettingsContent);
var sourcesPath = Path.Combine(restoreDir, ClosureSourcesFileName);
var metadataPath = Path.Combine(restoreDir, ClosureMetadataFileName);
var targetsPath = Path.Combine(restoreDir, ClosureTargetsFileName);
if (!File.Exists(sourcesPath) || !File.Exists(metadataPath) || !File.Exists(targetsPath))
{
var message = $"Integration closure manifest files were not produced under '{restoreDir}'. The integration project may not have built successfully.";
if (missingFileBehavior == ClosureFileMissingBehavior.Throw)
{
throw new InvalidOperationException(message);
}
logger?.LogWarning("{Message}", message);
return null;
}
var sourcePaths = await ReadManifestLinesAsync(sourcesPath, cancellationToken).ConfigureAwait(false);
var metadataLines = await ReadManifestLinesAsync(metadataPath, cancellationToken).ConfigureAwait(false);
var targetPaths = await ReadManifestLinesAsync(targetsPath, cancellationToken).ConfigureAwait(false);
if (sourcePaths.Count != metadataLines.Count || sourcePaths.Count != targetPaths.Count)
{
throw new InvalidOperationException(
$"Integration closure manifest is inconsistent. Sources: {sourcePaths.Count}, metadata: {metadataLines.Count}, targets: {targetPaths.Count}.");
}
// project-ref-assemblies.txt is read separately by callers that need it for their own
// appsettings derivation (the closure manifest itself doesn't reference these names —
// they flow into the appsettings content hash that callers pass in).
// Fingerprints reference NuGet's content-addressed package id+version+sha512 triple so
// the closure manifest can detect package-cache drift independently of file timestamps.
var packageFingerprints = await ReadPackageFingerprintsAsync(
assetsFilePath,
missingFileBehavior,
cancellationToken).ConfigureAwait(false);
var entries = new List<AppHostServerClosureSource>(sourcePaths.Count);
for (var i = 0; i < sourcePaths.Count; i++)
{
var metadata = ParseClosureMetadata(metadataLines[i]);
var packageSha512 = TryGetPackageFingerprint(packageFingerprints, metadata);
entries.Add(new AppHostServerClosureSource(
sourcePaths[i],
targetPaths[i],
metadata.NuGetPackageId,
metadata.NuGetPackageVersion,
metadata.PathInPackage,
packageSha512,
metadata.AssetType));
}
return AppHostServerClosureManifest.Create(entries, appSettingsContent, cancellationToken);
}
/// <summary>
/// Reads <c>project-ref-assemblies.txt</c> (the assembly names contributed by project
/// references). Returns an empty list when the file is absent.
/// </summary>
public static async Task<List<string>> ReadProjectRefAssemblyNamesAsync(string restoreDir, ILogger? logger, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(restoreDir);
var path = Path.Combine(restoreDir, ProjectRefAssemblyNamesFileName);
if (!File.Exists(path))
{
logger?.LogWarning("Project reference assembly names file not found at {Path}", path);
return [];
}
return await ReadManifestLinesAsync(path, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Parses one line of <c>closure-metadata.txt</c>. The MSBuild emitter writes:
/// <code>NuGetPackageId|NuGetPackageVersion|PathInPackage|AssetType</code>
/// Empty segments are legal — project-ref entries have no package id/version/path.
/// </summary>
internal static ClosureMetadata ParseClosureMetadata(string line)
{
ArgumentNullException.ThrowIfNull(line);
var parts = line.Split('|', 4);
if (parts.Length != 4)
{
throw new InvalidOperationException($"Integration closure metadata line '{line}' is invalid.");
}
return new ClosureMetadata(
NormalizeMetadataValue(parts[0]),
NormalizeMetadataValue(parts[1]),
NormalizeMetadataValue(parts[2]),
NormalizeMetadataValue(parts[3]));
static string? NormalizeMetadataValue(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal static string? TryGetPackageFingerprint(IReadOnlyDictionary<string, string> fingerprints, ClosureMetadata metadata)
{
if (metadata.NuGetPackageId is null ||
metadata.NuGetPackageVersion is null ||
metadata.PathInPackage is null)
{
return null;
}
return fingerprints.TryGetValue(
CreatePackageFingerprintKey(metadata.NuGetPackageId, metadata.NuGetPackageVersion),
out var sha512)
? sha512
: null;
}
private static string CreatePackageFingerprintKey(string packageId, string packageVersion)
=> $"{packageId}/{packageVersion}";
private static async Task<List<string>> ReadManifestLinesAsync(string filePath, CancellationToken cancellationToken)
{
var lines = await File.ReadAllLinesAsync(filePath, cancellationToken).ConfigureAwait(false);
return lines.Where(static line => !string.IsNullOrWhiteSpace(line)).ToList();
}
private static async Task<Dictionary<string, string>> ReadPackageFingerprintsAsync(
string assetsFilePath,
ClosureFileMissingBehavior missingFileBehavior,
CancellationToken cancellationToken)
{
var fingerprints = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(assetsFilePath))
{
if (missingFileBehavior == ClosureFileMissingBehavior.Throw)
{
throw new InvalidOperationException($"Integration assets file '{assetsFilePath}' was not found after build.");
}
return fingerprints;
}
await using var stream = File.OpenRead(assetsFilePath);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!document.RootElement.TryGetProperty("libraries", out var libraries))
{
return fingerprints;
}
// project.assets.json shape (excerpt):
// "libraries": {
// "Aspire.Hosting.Redis/13.2.1": { "type": "package", "sha512": "sha512-...", ... },
// "ProjectName/1.0.0": { "type": "project", ... }
// }
// Only "package" entries carry a sha512 — project entries are skipped.
foreach (var library in libraries.EnumerateObject())
{
cancellationToken.ThrowIfCancellationRequested();
if (!library.Value.TryGetProperty("type", out var typeElement) ||
!string.Equals(typeElement.GetString(), "package", StringComparison.OrdinalIgnoreCase) ||
!library.Value.TryGetProperty("sha512", out var sha512Element))
{
continue;
}
var sha512 = sha512Element.GetString();
if (string.IsNullOrWhiteSpace(sha512))
{
continue;
}
var separatorIndex = library.Name.IndexOf('/');
if (separatorIndex <= 0 || separatorIndex == library.Name.Length - 1)
{
continue;
}
var packageId = library.Name[..separatorIndex];
var packageVersion = library.Name[(separatorIndex + 1)..];
fingerprints[CreatePackageFingerprintKey(packageId, packageVersion)] = sha512;
}
return fingerprints;
}
internal readonly record struct ClosureMetadata(
string? NuGetPackageId,
string? NuGetPackageVersion,
string? PathInPackage,
string? AssetType);
}
/// <summary>
/// Controls how <see cref="IntegrationClosureBuilder.ReadClosureManifestAsync"/> reacts to
/// missing input files. Some restore paths treat missing files as hard errors, while others
/// return <c>null</c> so the caller can surface the failure with its own diagnostics.
/// </summary>
internal enum ClosureFileMissingBehavior
{
Throw,
ReturnNull,
}