// 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.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Aspire.Cli.DotNet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
using NuGet.ProjectModel;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
namespace Aspire.Cli.NuGet;
internal sealed record NuGetSourceInfo(
string Name,
string Identity,
bool IsEnabled,
bool HasCredentials,
bool HasClientCertificates)
{
public bool IsCliManaged { get; init; }
}
internal sealed record NuGetPackageSourceMapping(
string SourceKey,
IReadOnlyList<string> Patterns);
internal sealed record NuGetSettingsInfo(
IReadOnlyList<string> ConfigPaths,
string CacheIdentity,
IReadOnlyList<NuGetSourceInfo> Sources,
IReadOnlyList<string> SensitiveSourceValues,
IReadOnlyList<NuGetPackageSourceMapping> PackageSourceMappings,
IReadOnlyList<string> DisabledPackageSourceKeys,
IReadOnlyList<string> ReservedPackageSourceKeys,
byte[] SourceIdentityKey);
/// <summary>
/// Restores integration packages and creates package probe manifests.
/// </summary>
internal interface INuGetService
{
/// <summary>
/// Restores packages to the cache and creates a package probe manifest.
/// </summary>
/// <param name="packages">The packages to restore.</param>
/// <param name="targetFramework">The target framework.</param>
/// <param name="runtimeIdentifier">The runtime identifier used to prefer runtime-specific assets in the generated layout.</param>
/// <param name="sources">Additional NuGet sources.</param>
/// <param name="workingDirectory">Working directory for NuGet.config discovery and for resolving the workspace-local restore cache.</param>
/// <param name="nugetConfigPaths">NuGet.config paths ordered from highest to lowest precedence.</param>
/// <param name="nugetSettingsCacheIdentity">The cache identity computed from NuGet's effective ambient settings.</param>
/// <param name="nugetConfigOverlayCacheIdentity">A stable cache identity for an invocation-scoped overlay.</param>
/// <param name="additionalSensitiveSources">Additional source values that must be redacted from restore output.</param>
/// <param name="globalPackagesFolderOverride">An optional global packages folder override.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The path to the package probe manifest.</returns>
Task<string> RestorePackagesAsync(
IEnumerable<(string Id, string Version)> packages,
string workingDirectory,
string targetFramework = "net10.0",
string? runtimeIdentifier = null,
IEnumerable<string>? sources = null,
IReadOnlyList<string>? nugetConfigPaths = null,
string? nugetSettingsCacheIdentity = null,
string? nugetConfigOverlayCacheIdentity = null,
IEnumerable<string>? additionalSensitiveSources = null,
string? globalPackagesFolderOverride = null,
CancellationToken ct = default);
}
/// <summary>
/// Orchestrates NuGet configuration and in-process package restore with reusable restore caches.
/// </summary>
internal sealed class BundleNuGetService : INuGetService
{
private readonly ILogger<BundleNuGetService> _logger;
private readonly INuGetClient _nuGetClient;
internal Func<byte[]> SourceIdentityKeyFactory { get; init; }
= static () => RandomNumberGenerator.GetBytes(NuGetSourceIdentity.KeySizeInBytes);
public BundleNuGetService(
ILogger<BundleNuGetService> logger,
INuGetClient nuGetClient)
{
_logger = logger;
_nuGetClient = nuGetClient;
}
public async Task<string> RestorePackagesAsync(
IEnumerable<(string Id, string Version)> packages,
string workingDirectory,
string targetFramework = "net10.0",
string? runtimeIdentifier = null,
IEnumerable<string>? sources = null,
IReadOnlyList<string>? nugetConfigPaths = null,
string? nugetSettingsCacheIdentity = null,
string? nugetConfigOverlayCacheIdentity = null,
IEnumerable<string>? additionalSensitiveSources = null,
string? globalPackagesFolderOverride = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
var packageList = packages.ToList();
if (packageList.Count == 0)
{
throw new ArgumentException("At least one package is required", nameof(packages));
}
var sourceList = sources?.ToArray();
var sensitiveSources = (sourceList ?? [])
.Concat(additionalSensitiveSources ?? [])
.Where(PackageSourceOverrideMappings.HasCredentialMaterial)
.Distinct(StringComparer.Ordinal)
.ToArray();
var nugetConfigCacheIdentity = ComputeNuGetConfigCacheIdentity(
nugetSettingsCacheIdentity,
nugetConfigOverlayCacheIdentity);
var packageHash = ComputePackageHash(
packageList,
targetFramework,
runtimeIdentifier,
GetRestoreToolPath(),
sourceList,
nugetConfigCacheIdentity,
globalPackagesFolderOverride);
var restoreDirectory = Path.Combine(
GetPackageRestoreCacheDirectory(workingDirectory),
packageHash);
var objectDirectory = Path.Combine(restoreDirectory, "obj");
var manifestPath = Path.Combine(restoreDirectory, IntegrationPackageProbeManifest.FileName);
var lockPath = Path.Combine(restoreDirectory, "restore.lock");
// Reusable package caches are shared by every AppHost in the workspace and must remain
// serialized while their manifest or project.assets.json file is being written.
using var fileLock = await FileLock.AcquireAsync(lockPath, ct).ConfigureAwait(false);
if (File.Exists(manifestPath) && TryValidatePackageManifest(manifestPath, _logger))
{
_logger.LogDebug("Using cached package manifest at {Path}", manifestPath);
return manifestPath;
}
Directory.CreateDirectory(objectDirectory);
_logger.LogDebug("Restoring {Count} integration packages in-process", packageList.Count);
try
{
await _nuGetClient.RestoreAsync(
packageList,
targetFramework,
runtimeIdentifier,
objectDirectory,
sourceList ?? [],
nugetConfigPaths ?? [],
workingDirectory,
globalPackagesFolderOverride,
sensitiveSources,
ct).ConfigureAwait(false);
}
catch (NuGetOperationException ex)
{
var redactedOutput = PackageSourceRedactor.RedactOccurrences(ex.Output, sensitiveSources);
_logger.LogError("Package restore failed");
_logger.LogError("Package restore stderr: {Error}", redactedOutput);
throw new InvalidOperationException($"Package restore failed: {redactedOutput}", ex);
}
try
{
await _nuGetClient.WriteManifestAsync(
Path.Combine(objectDirectory, LockFileFormat.AssetsFileName),
manifestPath,
targetFramework,
runtimeIdentifier,
ct).ConfigureAwait(false);
}
catch (NuGetOperationException ex)
{
_logger.LogError("Manifest creation failed");
_logger.LogError("Manifest creation stderr: {Error}", ex.Output);
throw new InvalidOperationException($"Manifest creation failed: {ex.Output}", ex);
}
_logger.LogDebug("Package manifest created at {Path}", manifestPath);
return manifestPath;
}
internal NuGetSettingsInfo GetNuGetSettings(
string workingDirectory,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
cancellationToken.ThrowIfCancellationRequested();
var sourceIdentityKey = SourceIdentityKeyFactory();
if (sourceIdentityKey.Length != NuGetSourceIdentity.KeySizeInBytes)
{
throw new InvalidOperationException(
$"The NuGet source identity key must be {NuGetSourceIdentity.KeySizeInBytes} bytes.");
}
return _nuGetClient.GetSettings(workingDirectory, sourceIdentityKey);
}
internal NuGetConfiguration BuildConfiguration(
DirectoryInfo workingDirectory,
string workloadId,
PackageMapping[]? selectedMappings,
bool restrictToSelectedSources,
string? packageScopedAppendSource = null,
bool hasAuthoritativeAspirePolicy = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(workingDirectory);
var settings = GetNuGetSettings(workingDirectory.FullName, cancellationToken);
selectedMappings = ResolvePackageMappings(workingDirectory, settings, selectedMappings);
return NuGetConfigurationBuilder.Build(
settings,
workloadId,
selectedMappings,
restrictToSelectedSources,
packageScopedAppendSource,
hasAuthoritativeAspirePolicy);
}
private static PackageMapping[]? ResolvePackageMappings(
DirectoryInfo workingDirectory, NuGetSettingsInfo settings, PackageMapping[]? selectedMappings)
=> selectedMappings?
.Select(mapping => new PackageMapping(
mapping.PackageFilter,
PackageSourceIdentity.IsNamedSourceReference(mapping.Source) &&
settings.Sources.Any(source => string.Equals(source.Name, mapping.Source, StringComparison.OrdinalIgnoreCase))
? mapping.Source
: PackageSourceOverrideMappings.ResolveForWorkingDirectory(mapping.Source, workingDirectory)))
.ToArray();
internal bool IsPackageSourceMappingEnabled(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(workingDirectory);
return GetNuGetSettings(workingDirectory.FullName, cancellationToken).PackageSourceMappings.Count > 0;
}
internal NuGetConfiguration BuildAppHostConfiguration(
DirectoryInfo workingDirectory,
string workloadId,
PackageMapping[]? selectedMappings,
CancellationToken cancellationToken)
{
var settings = GetNuGetSettings(workingDirectory.FullName, cancellationToken);
return DotNetAppHostNuGetConfigBuilder.Build(
settings, workloadId, ResolvePackageMappings(workingDirectory, settings, selectedMappings));
}
internal NuGetConfiguration BuildChannelConfiguration(
DirectoryInfo workingDirectory,
string workloadId,
PackageChannel? channel,
string? packageSourceOverride,
string? nugetServiceIndexOverride,
CancellationToken cancellationToken)
{
if (channel?.Name == PackageChannelNames.Stable &&
string.IsNullOrWhiteSpace(packageSourceOverride) &&
nugetServiceIndexOverride is null &&
(channel.Mappings ?? []).All(static mapping =>
PackageSourceIdentity.Comparer.Equals(mapping.Source, PackageSources.NuGetOrg)))
{
return DotNetAppHostNuGetConfigBuilder.BuildStable(
GetNuGetSettings(workingDirectory.FullName, cancellationToken), workloadId, channel);
}
var mappings = string.IsNullOrWhiteSpace(packageSourceOverride)
? channel?.Mappings
: PackageSourceOverrideMappings.Create(packageSourceOverride, channel);
if (mappings is { Length: > 0 } && mappings.All(static mapping => mapping.PackageFilter == PackageMapping.AllPackages))
{
mappings = [.. mappings.Select(static mapping => new PackageMapping(
PackageSourceOverrideMappings.DefaultPackagePattern, mapping.Source))];
}
return BuildAppHostConfiguration(
workingDirectory,
workloadId,
mappings is { Length: > 0 } ? mappings : null,
cancellationToken);
}
/// <summary>
/// Materializes the shared desired configuration without changing ambient configuration.
/// </summary>
internal async Task<NuGetPackageOperationConfiguration> CreateConfigurationPreviewAsync(
DirectoryInfo workingDirectory,
NuGetConfiguration configuration,
string? globalPackagesFolder,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(workingDirectory);
ArgumentNullException.ThrowIfNull(configuration);
var overlay = await WriteTemporaryOverlayAsync(
configuration,
new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire")),
globalPackagesFolder,
cancellationToken).ConfigureAwait(false);
return overlay is null
? NuGetPackageOperationConfiguration.Ambient(workingDirectory, configuration.Settings.CacheIdentity)
: NuGetPackageOperationConfiguration.FromTemporaryOverlay(
workingDirectory,
overlay,
configuration.Settings.CacheIdentity);
}
internal string GetGlobalPackagesFolder(DirectoryInfo workingDirectory)
=> _nuGetClient.GetGlobalPackagesFolder(workingDirectory.FullName);
/// <summary>
/// Acquires an exact package without reusing an asset-only restore manifest.
/// </summary>
internal async Task AcquirePackageAsync(
(string Id, string Version) package,
NuGetPackageOperationConfiguration configuration,
string globalPackagesFolder,
CancellationToken cancellationToken)
{
var directory = Directory.CreateTempSubdirectory("aspire-package-acquisition-");
try
{
var settings = GetNuGetSettings(configuration.EffectiveWorkingDirectory.FullName, cancellationToken);
await _nuGetClient.AcquirePackageAsync(
package, directory.FullName,
configuration.EffectiveWorkingDirectory.FullName, globalPackagesFolder,
settings.SensitiveSourceValues, cancellationToken);
}
catch (NuGetOperationException exception)
{
// Native NuGet diagnostics already redact the selected policy's sensitive sources.
_logger.LogError("Package acquisition failed: {Output}", exception.Output);
throw new InvalidOperationException($"Package acquisition failed: {exception.Output}", exception);
}
finally
{
try
{
directory.Delete(recursive: true);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(exception, "Could not remove package acquisition directory {Directory}", directory.FullName);
}
}
}
/// <summary>
/// Previews one configuration-file replacement in the caller's native hierarchy.
/// </summary>
internal async Task<NuGetPackageOperationConfiguration> CreateConfigurationPreviewAsync(
DirectoryInfo workingDirectory, FileInfo targetFile, FileInfo replacementFile,
CancellationToken cancellationToken)
{
var settings = GetNuGetSettings(workingDirectory.FullName, cancellationToken);
var paths = settings.ConfigPaths.ToList();
var targetPath = PathNormalizer.ResolveToFilesystemPath(targetFile.FullName);
var index = paths.FindIndex(path => string.Equals(
PathNormalizer.ResolveToFilesystemPath(path), targetPath, StringComparisons.FileSystemPath));
if (index >= 0)
{
paths[index] = replacementFile.FullName;
}
else
{
if (!IsAncestorDirectory(targetFile.DirectoryName!, workingDirectory.FullName))
{
throw new InvalidOperationException(
$"NuGet configuration '{targetFile.FullName}' is not in the hierarchy of '{workingDirectory.FullName}'.");
}
index = paths.FindIndex(path =>
IsAncestorDirectory(Path.GetDirectoryName(path)!, targetFile.DirectoryName!) ||
!IsAncestorDirectory(Path.GetDirectoryName(path)!, workingDirectory.FullName));
paths.Insert(index < 0 ? paths.Count : index, replacementFile.FullName);
}
var overlay = await TemporaryNuGetConfigFile.CreateAsync(
new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire")),
path => _nuGetClient.WriteNuGetConfig(paths, path));
return NuGetPackageOperationConfiguration.FromTemporaryOverlay(
workingDirectory, overlay, settings.CacheIdentity);
}
internal static bool IsAncestorDirectory(string ancestor, string directory)
{
var relativePath = Path.GetRelativePath(
PathNormalizer.ResolveToFilesystemPath(ancestor), PathNormalizer.ResolveToFilesystemPath(directory));
return !Path.IsPathRooted(relativePath) && relativePath != ".." &&
!relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparisons.FileSystemPath);
}
internal async Task<NuGetPackageOperationConfiguration> CreatePackageOperationConfigurationAsync(
DirectoryInfo workingDirectory,
IReadOnlyList<PackageMapping>? mappings,
bool restrictToSelectedSources,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(workingDirectory);
if (restrictToSelectedSources && mappings is not { Count: > 0 })
{
throw new ArgumentException("Source-restricted discovery requires a selected source.", nameof(mappings));
}
// A channel's synthetic '*' fallback belongs to standalone configuration generation,
// not an inherited operation. Explicit-source operations retain their selected catch-all.
var selectedMappings = mappings?
.Where(mapping => restrictToSelectedSources || mapping.PackageFilter != PackageMapping.AllPackages)
.ToArray() ?? [];
var configuration = BuildConfiguration(
workingDirectory,
workloadId: "package-search",
selectedMappings.Length == 0 ? null : selectedMappings,
restrictToSelectedSources,
hasAuthoritativeAspirePolicy: true,
cancellationToken: cancellationToken);
if (configuration.Overlay is null)
{
return NuGetPackageOperationConfiguration.Ambient(workingDirectory, configuration.Settings.CacheIdentity);
}
var temporaryConfig = await WriteTemporaryOverlayAsync(
configuration,
workingDirectory,
globalPackagesFolder: null,
cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("The selected package mappings did not produce a NuGet policy overlay.");
return NuGetPackageOperationConfiguration.FromTemporaryOverlay(
workingDirectory,
temporaryConfig,
configuration.Settings.CacheIdentity);
}
internal IReadOnlyList<NuGetPackage> FilterPackageSearchResults(
IReadOnlyList<NuGetPackage> packages,
string? nugetConfigPath,
string workingDirectory,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return _nuGetClient.FilterPackageSearchResults(packages, nugetConfigPath, workingDirectory);
}
internal async Task<TemporaryNuGetConfigFile?> WriteTemporaryOverlayAsync(
NuGetConfiguration configuration,
DirectoryInfo? parentDirectory,
string? globalPackagesFolder,
CancellationToken cancellationToken)
{
if (configuration.Overlay is not { } overlay)
{
return null;
}
overlay = overlay with { GlobalPackagesFolder = globalPackagesFolder };
return parentDirectory is null
? await TemporaryNuGetConfigFile.CreateAsync(
path => WriteNuGetConfig(overlay, path, cancellationToken)).ConfigureAwait(false)
: await TemporaryNuGetConfigFile.CreateAsync(
parentDirectory,
path => WriteNuGetConfig(overlay, path, cancellationToken)).ConfigureAwait(false);
}
internal void WriteNuGetConfig(
NuGetConfiguration configuration,
string outputPath,
string? globalPackagesFolder,
CancellationToken cancellationToken)
{
var overlay = configuration.Overlay
?? throw new InvalidOperationException("The resolved configuration does not require a NuGet policy overlay.");
WriteNuGetConfig(overlay with { GlobalPackagesFolder = globalPackagesFolder }, outputPath, cancellationToken);
}
internal void WriteNuGetConfig(
NuGetConfigOverlay overlay,
string outputPath,
ReadOnlyMemory<byte>? originalContent,
bool omitRedundantDisabledSources,
IReadOnlyList<string> inheritedConfigPaths,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(overlay);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
cancellationToken.ThrowIfCancellationRequested();
_nuGetClient.WriteNuGetConfig(
overlay, outputPath, originalContent, omitRedundantDisabledSources, inheritedConfigPaths);
}
internal static string CombineCacheIdentities(string configurationIdentity, string overlayIdentity)
{
var builder = new StringBuilder();
foreach (var value in new[] { "SOURCE_POLICY", configurationIdentity, "OVERLAY", overlayIdentity })
{
builder.Append(value.Length);
builder.Append(':');
builder.Append(value);
}
return builder.ToString();
}
internal void WriteNuGetConfig(
NuGetConfigOverlay overlay,
string outputPath,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(overlay);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
cancellationToken.ThrowIfCancellationRequested();
_nuGetClient.WriteNuGetConfig(overlay, outputPath);
}
private static bool TryValidatePackageManifest(string manifestPath, ILogger logger)
{
try
{
_ = IntegrationPackageProbeManifest.Load(manifestPath);
return true;
}
catch (Exception ex) when (ex is InvalidOperationException or JsonException or IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Cached package manifest {ManifestPath} is invalid and will be regenerated.", manifestPath);
return false;
}
}
/// <summary>
/// Gets the file containing the NuGet implementation that performs restores, for the restore cache key.
/// </summary>
/// <remarks>
/// Native AOT compiles the implementation into the executable. A managed launch such as <c>dotnet aspire.dll</c>
/// runs it from the CLI assembly instead, and <see cref="Environment.ProcessPath"/> is then the <c>dotnet</c> host,
/// which does not change when the CLI is updated.
/// </remarks>
internal static string? GetRestoreToolPath()
{
if (!RuntimeFeature.IsDynamicCodeSupported)
{
return Environment.ProcessPath;
}
var assemblyPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(BundleNuGetService).Assembly.GetName().Name}.dll");
return File.Exists(assemblyPath) ? assemblyPath : Environment.ProcessPath;
}
internal static string ComputePackageHash(
List<(string Id, string Version)> packages,
string tfm,
string? runtimeIdentifier,
string? toolPath = null,
IEnumerable<string>? sources = null,
string? nugetConfigCacheIdentity = null,
string? nugetPackagesPath = null)
{
var content = string.Join(";", packages.OrderBy(package => package.Id).Select(package => $"{package.Id}:{package.Version}"));
content += $";tfm:{tfm}";
content += $";rid:{runtimeIdentifier ?? "<none>"}";
content += $";tool:{GetToolFingerprint(toolPath)}";
if (sources is not null)
{
foreach (var source in sources.OrderBy(static source => source, StringComparer.OrdinalIgnoreCase))
{
content += $";source:{source.Length}:{source}";
}
}
if (nugetConfigCacheIdentity is not null)
{
content += $";config:{nugetConfigCacheIdentity}";
}
if (nugetPackagesPath is not null)
{
content += $";global-packages:{nugetPackagesPath.Length}:{nugetPackagesPath}";
}
return XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(content)).ToString("X16", CultureInfo.InvariantCulture);
}
private static string? ComputeNuGetConfigCacheIdentity(
string? nugetSettingsCacheIdentity,
string? nugetConfigOverlayCacheIdentity)
{
if (nugetSettingsCacheIdentity is null && nugetConfigOverlayCacheIdentity is null)
{
return null;
}
var hash = new XxHash3();
if (nugetSettingsCacheIdentity is not null)
{
hash.Append("\0NUGET_SETTINGS\0"u8);
hash.Append(Encoding.UTF8.GetBytes(nugetSettingsCacheIdentity));
}
if (nugetConfigOverlayCacheIdentity is not null)
{
hash.Append("\0NUGET_CONFIG_OVERLAY\0"u8);
hash.Append(Encoding.UTF8.GetBytes(nugetConfigOverlayCacheIdentity));
}
return Convert.ToHexString(hash.GetCurrentHash());
}
private static string GetToolFingerprint(string? toolPath)
{
if (string.IsNullOrEmpty(toolPath))
{
return "<none>";
}
try
{
var fileInfo = new FileInfo(toolPath);
return fileInfo.Exists
? $"{fileInfo.Length}|{fileInfo.LastWriteTimeUtc.Ticks}"
: "<missing>";
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
return "<error>";
}
}
private static string GetPackageRestoreCacheDirectory(string workingDirectory)
{
var integrationCacheDirectory = ConfigurationHelper.GetIntegrationCacheDirectory(
new DirectoryInfo(Path.GetFullPath(workingDirectory)));
return Path.Combine(integrationCacheDirectory.FullName, "package-restore");
}
}