File: NuGet\NuGetClient.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Immutable;
using System.Text;
using Aspire.Cli.Configuration;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.Extensions.Logging;
using NuGet.Commands;
using NuGet.Configuration;
using NuGet.Credentials;
using NuGet.Frameworks;
using NuGet.LibraryModel;
using NuGet.Packaging.Signing;
using NuGet.ProjectModel;
using NuGet.Protocol;
using NuGet.Protocol.Core.Types;
using NuGet.RuntimeModel;
using NuGet.Versioning;
using INuGetLogger = NuGet.Common.ILogger;
using NuGetLogLevel = NuGet.Common.LogLevel;
using NuGetLogMessage = NuGet.Common.ILogMessage;
 
namespace Aspire.Cli.NuGet;
 
/// <summary>
/// Runs the NuGet operations that the <c>aspire-managed nuget</c> helper used to run out of process.
/// </summary>
/// <remarks>
/// Each operation mirrors one helper subcommand (<c>restore</c>, <c>manifest</c>, and <c>search</c>) so bundled CLIs
/// keep the same restore results, search results, and failure text. Failures are reported as
/// <see cref="NuGetOperationException"/>, whose <see cref="NuGetOperationException.Output"/> is the text the helper
/// wrote to stderr.
/// </remarks>
internal interface INuGetClient
{
    Task RestoreAsync(
        IReadOnlyList<(string Id, string Version)> packages,
        string framework,
        string? runtimeIdentifier,
        string outputPath,
        IReadOnlyList<string> sources,
        string? nugetConfigPath,
        string workingDirectory,
        CancellationToken cancellationToken);
 
    Task WriteManifestAsync(
        string assetsFilePath,
        string outputPath,
        string framework,
        string? runtimeIdentifier,
        CancellationToken cancellationToken);
 
    Task<IReadOnlyList<NuGetSearchResult>> SearchAsync(
        string query,
        bool prerelease,
        int take,
        IReadOnlyList<string> explicitSources,
        string? nugetConfigPath,
        string workingDirectory,
        CancellationToken cancellationToken);
}
 
internal sealed record NuGetSearchResult(
    string Id,
    string Version,
    string Source,
    IReadOnlyList<string> AllVersions);
 
/// <summary>
/// Reports a failed in-process NuGet operation.
/// </summary>
/// <param name="output">The diagnostic text the <c>aspire-managed nuget</c> helper would have written to stderr.</param>
/// <param name="innerException">The exception that caused the failure, if any.</param>
internal sealed class NuGetOperationException(string output, Exception? innerException = null)
    : Exception("NuGet operation failed.", innerException)
{
    /// <summary>
    /// Gets the text the helper would have written to stderr. Callers surface it exactly as they surfaced the
    /// helper's stderr, so user-visible failure messages are unchanged.
    /// </summary>
    public string Output { get; } = output;
}
 
internal sealed class NuGetClient(
    IFeatures features,
    IEnvironment environment,
    ILogger<NuGetClient> logger) : INuGetClient
{
    private const string NuGetOrgUrl = "https://api.nuget.org/v3/index.json";
    private const string RuntimeIdentifierGraphResourceName = "Aspire.Cli.RuntimeIdentifierGraph.json";
    private static readonly Lock s_operationLock = new();
    private static int s_activeOperationCount;
 
    // Output the helper never produced -- credential provider and trust store diagnostics -- only goes to the debug
    // log. Keeping it out of each operation's captured output keeps failure messages identical to the helper's.
    private readonly DiagnosticNuGetLogger _diagnosticLogger = new(logger);
 
    public async Task RestoreAsync(
        IReadOnlyList<(string Id, string Version)> packages,
        string framework,
        string? runtimeIdentifier,
        string outputPath,
        IReadOnlyList<string> sources,
        string? nugetConfigPath,
        string workingDirectory,
        CancellationToken cancellationToken)
    {
        using var operation = BeginOperation();
        var output = new NuGetOperationOutput(logger);
 
        // The helper received DOTNET_NUGET_SIGNATURE_VERIFICATION only in its own environment. NuGet reads it from the
        // process environment, so it has to be set here, but only for the duration of the restore.
        using var signatureVerification = NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(features, environment);
        try
        {
            Directory.CreateDirectory(outputPath);
 
            // Restore is delegated to NuGet's RestoreRunner so the CLI resolves packages exactly the way
            // the aspire-managed helper did. Reimplementing the graph walk here previously diverged from
            // NuGet on RID-specific dependencies, placeholder assets, and version selection.
            var machineWideSettings = new XPlatMachineWideSetting();
            var settings = Settings.LoadDefaultSettings(workingDirectory, nugetConfigPath, machineWideSettings);
 
            var packageSources = ResolvePackageSources(settings, sources);
            var targetFramework = NuGetFramework.Parse(framework);
            var packageSpec = BuildPackageSpec(
                packages,
                targetFramework,
                runtimeIdentifier,
                outputPath,
                packageSources,
                settings);
 
            var dgSpec = new DependencyGraphSpec();
            dgSpec.AddProject(packageSpec);
            dgSpec.AddRestore(packageSpec.RestoreMetadata.ProjectUniqueName);
 
            var providerCache = new RestoreCommandProvidersCache();
            var dgProvider = new DependencyGraphSpecRequestProvider(providerCache, dgSpec, settings);
 
            using var cacheContext = new SourceCacheContext();
            var restoreArgs = new RestoreArgs
            {
                CacheContext = cacheContext,
                Log = output,
                PreLoadedRequestProviders = [dgProvider],
                DisableParallel = Environment.ProcessorCount == 1,
                AllowNoOp = false,
                MachineWideSettings = machineWideSettings,
            };
 
            NativeAotNuGetTrustStore.Initialize(output, _diagnosticLogger, environment);
 
            var results = await RestoreRunner.RunAsync(restoreArgs, cancellationToken).ConfigureAwait(false);
            var summary = results.Count > 0 ? results[0] : null;
 
            if (summary is null)
            {
                output.WriteLine("Error: Restore returned no results");
                throw new NuGetOperationException(output.Text);
            }
 
            if (!summary.Success)
            {
                var errors = string.Join(
                    Environment.NewLine,
                    summary.Errors?.Select(error => error.Message) ?? ["Unknown error"]);
                output.WriteLine($"Error: Restore failed: {errors}");
                throw new NuGetOperationException(output.Text);
            }
        }
        catch (Exception ex) when (ex is not OperationCanceledException and not NuGetOperationException)
        {
            output.WriteLine($"Error: {ex.Message}");
            if (output.Verbose)
            {
                output.WriteLine(ex.ToString());
            }
 
            throw new NuGetOperationException(output.Text, ex);
        }
    }
 
    private static PackageSpec BuildPackageSpec(
        IReadOnlyList<(string Id, string Version)> packages,
        NuGetFramework framework,
        string? runtimeIdentifier,
        string outputPath,
        List<PackageSource> sources,
        ISettings settings)
    {
        var projectName = "AspireRestore";
        var projectPath = Path.Combine(outputPath, "project.json");
        var tfmShort = framework.GetShortFolderName();
        var runtimeIdentifierGraphPath = !string.IsNullOrWhiteSpace(runtimeIdentifier)
            ? EnsureRuntimeIdentifierGraphPath(outputPath)
            : null;
 
        var dependencies = packages.Select(package => new LibraryDependency
        {
            LibraryRange = new LibraryRange(
                package.Id,
                VersionRange.Parse(package.Version),
                LibraryDependencyTarget.Package)
        }).ToImmutableArray();
 
        var tfInfo = new TargetFrameworkInformation
        {
            FrameworkName = framework,
            TargetAlias = tfmShort,
            Dependencies = dependencies,
            RuntimeIdentifierGraphPath = runtimeIdentifierGraphPath
        };
 
        var restoreMetadata = new ProjectRestoreMetadata
        {
            ProjectUniqueName = projectName,
            ProjectName = projectName,
            ProjectPath = projectPath,
            ProjectStyle = ProjectStyle.PackageReference,
            OutputPath = outputPath,
            PackagesPath = SettingsUtility.GetGlobalPackagesFolder(settings),
            OriginalTargetFrameworks = [tfmShort],
            ConfigFilePaths = settings.GetConfigFilePaths().ToList(),
        };
 
        foreach (var source in sources)
        {
            restoreMetadata.Sources.Add(source);
        }
 
        restoreMetadata.TargetFrameworks.Add(new ProjectRestoreMetadataFrameworkInfo(framework)
        {
            TargetAlias = tfmShort
        });
 
        var packageSpec = new PackageSpec([tfInfo])
        {
            Name = projectName,
            FilePath = projectPath,
            RestoreMetadata = restoreMetadata,
        };
 
        if (!string.IsNullOrWhiteSpace(runtimeIdentifier))
        {
            packageSpec.RuntimeGraph = new RuntimeGraph([new RuntimeDescription(runtimeIdentifier)]);
        }
 
        return packageSpec;
    }
 
    /// <summary>
    /// Writes the SDK runtime identifier graph next to the restore output. NuGet reads it from disk
    /// to expand RID-specific dependencies, and the Native AOT CLI has no SDK layout to point at.
    /// </summary>
    private static string EnsureRuntimeIdentifierGraphPath(string outputPath)
    {
        var graphPath = Path.Combine(outputPath, "RuntimeIdentifierGraph.json");
        if (File.Exists(graphPath))
        {
            return graphPath;
        }
 
        using var resourceStream = typeof(NuGetClient).Assembly.GetManifestResourceStream(RuntimeIdentifierGraphResourceName)
            ?? throw new InvalidOperationException(
                $"Embedded runtime identifier graph '{RuntimeIdentifierGraphResourceName}' was not found.");
        using var fileStream = File.Create(graphPath);
        resourceStream.CopyTo(fileStream);
 
        return graphPath;
    }
 
    public async Task WriteManifestAsync(
        string assetsFilePath,
        string outputPath,
        string framework,
        string? runtimeIdentifier,
        CancellationToken cancellationToken)
    {
        using var operation = BeginOperation();
        var output = new NuGetOperationOutput(logger);
        try
        {
            // Asset selection is delegated to NuGet's own restore output. The assets file already
            // records which assemblies, resources, and native libraries apply to this target, so the
            // manifest reflects NuGet's RID fallback, `_._` placeholder, and locale semantics instead
            // of a reimplementation of them.
            var resolution = NuGetPackageAssetResolver.Resolve(
                assetsFilePath,
                framework,
                runtimeIdentifier,
                output.Verbose ? output.WriteDiagnostic : null);
 
            var managedAssemblies = new List<IntegrationPackageManagedAssembly>();
            var nativeLibraries = new List<IntegrationPackageNativeLibrary>();
 
            foreach (var asset in resolution.Assets)
            {
                if (asset.IsManagedAssembly)
                {
                    managedAssemblies.Add(new IntegrationPackageManagedAssembly
                    {
                        PackageId = asset.PackageId,
                        PackageVersion = asset.PackageVersion,
                        Name = Path.GetFileNameWithoutExtension(asset.RelativePath),
                        Culture = asset.Culture,
                        Path = asset.SourcePath
                    });
                }
 
                if (asset.IsNativeLibrary)
                {
                    nativeLibraries.Add(new IntegrationPackageNativeLibrary
                    {
                        FileName = Path.GetFileName(asset.RelativePath),
                        Path = asset.SourcePath
                    });
                }
            }
 
            var outputDirectory = Path.GetDirectoryName(outputPath);
            if (!string.IsNullOrEmpty(outputDirectory))
            {
                Directory.CreateDirectory(outputDirectory);
            }
 
            var manifest = IntegrationPackageProbeManifest.Create(managedAssemblies, nativeLibraries);
            await IntegrationPackageProbeManifest.WriteAsync(outputPath, manifest, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            output.WriteLine($"Error: {ex.Message}");
            if (output.Verbose)
            {
                output.WriteLine(ex.StackTrace ?? string.Empty);
            }
 
            throw new NuGetOperationException(output.Text, ex);
        }
    }
 
    public async Task<IReadOnlyList<NuGetSearchResult>> SearchAsync(
        string query,
        bool prerelease,
        int take,
        IReadOnlyList<string> explicitSources,
        string? nugetConfigPath,
        string workingDirectory,
        CancellationToken cancellationToken)
    {
        using var operation = BeginOperation();
        var output = new NuGetOperationOutput(logger);
        try
        {
            var settings = LoadSettings(nugetConfigPath, workingDirectory);
            var packageSources = LoadPackageSources(settings, explicitSources, output);
            var searchFilter = new global::NuGet.Protocol.Core.Types.SearchFilter(prerelease);
 
            var searchResults = await Task.WhenAll(packageSources.Select(source => SearchSourceSafelyAsync(
                source,
                query,
                searchFilter,
                take,
                output,
                cancellationToken))).ConfigureAwait(false);
 
            // Shape the results exactly as the helper did, including its comparers. Versions are compared as strings
            // rather than as NuGet versions because that is what decided which source's entry survived deduplication.
            return searchResults
                .SelectMany(packages => packages)
                .GroupBy(package => package.Id, StringComparer.OrdinalIgnoreCase)
                .Select(group => group.OrderByDescending(package => package.Version).First())
                .OrderBy(package => package.Id)
                .Take(take)
                .ToArray();
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            output.WriteLine($"Error: {ex.Message}");
            if (output.Verbose)
            {
                output.WriteLine(ex.StackTrace ?? string.Empty);
            }
 
            throw new NuGetOperationException(output.Text, ex);
        }
    }
 
    private static async Task<IReadOnlyList<NuGetSearchResult>> SearchSourceSafelyAsync(
        PackageSource source,
        string query,
        global::NuGet.Protocol.Core.Types.SearchFilter filter,
        int take,
        NuGetOperationOutput output,
        CancellationToken cancellationToken)
    {
        try
        {
            return await SearchSourceAsync(source, query, filter, take, output, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            // Like the helper, report the failed source and keep the results from the others. The helper wrote the
            // exception message, but NuGet protocol failures format the feed URL into it -- often a derived resource
            // URL rather than the configured source -- which would leak UserInfo/SAS credentials into ~/.aspire/logs.
            output.WriteLine($"Warning: Failed to search {PackageSourceRedactor.RedactForDisplay(source.Name)}: {ex.GetType().Name}");
            return [];
        }
    }
 
    /// <summary>
    /// Starts a NuGet operation and returns the scope that ends it.
    /// </summary>
    /// <remarks>
    /// NuGet keeps process-wide state between operations: the credential service with its cached credentials,
    /// credential provider plugin processes, the HTTP throttle, and other caches. The helper discarded all of it by
    /// exiting after every operation. The CLI can live much longer -- for example for an entire <c>aspire run</c> --
    /// so when the last overlapping operation ends, NuGet's own end-of-build reset is raised to discard that state the
    /// same way. Operations are counted so one ending cannot reset state another is still using.
    /// </remarks>
    internal IDisposable BeginOperation()
    {
        lock (s_operationLock)
        {
            s_activeOperationCount++;
 
            // Credential providers are a deliberate addition over the aspire-managed helper, which never set up NuGet's
            // credential service and so could only authenticate with credentials stored in nuget.config. The service
            // is set up per operation because the reset at the end of the previous one discards it; this is a no-op
            // while an overlapping operation still has it set up.
            DefaultCredentialServiceUtility.SetupDefaultCredentialService(_diagnosticLogger, nonInteractive: true);
        }
 
        return new OperationScope(_diagnosticLogger);
    }
 
    private sealed class OperationScope(INuGetLogger diagnosticLogger) : IDisposable
    {
        private int _disposed;
 
        public void Dispose()
        {
            if (Interlocked.Exchange(ref _disposed, 1) != 0)
            {
                return;
            }
 
            lock (s_operationLock)
            {
                if (--s_activeOperationCount != 0)
                {
                    return;
                }
 
                // Raised under the lock so an operation starting concurrently cannot set up state that this reset
                // then discards.
                try
                {
                    global::NuGet.Common.StaticState.RaiseBuildEnded();
                }
                catch (Exception ex)
                {
                    // Reset handlers tear down plugin processes. A failure there must not turn a completed operation
                    // into a failed one.
                    diagnosticLogger.LogDebug($"Failed to reset NuGet process state: {ex}");
                }
            }
        }
    }
 
    private static async Task<IReadOnlyList<NuGetSearchResult>> SearchSourceAsync(
        PackageSource source,
        string query,
        global::NuGet.Protocol.Core.Types.SearchFilter filter,
        int take,
        INuGetLogger nuGetLogger,
        CancellationToken cancellationToken)
    {
        var repository = Repository.Factory.GetCoreV3(source);
        var searchResource = await repository.GetResourceAsync<PackageSearchResource>(cancellationToken).ConfigureAwait(false);
        if (searchResource is null)
        {
            return [];
        }
 
        // The helper requested a single page starting at the first result; it never paged further.
        var results = await searchResource.SearchAsync(
            query,
            filter,
            skip: 0,
            take,
            nuGetLogger,
            cancellationToken).ConfigureAwait(false);
 
        var packages = new List<NuGetSearchResult>();
        foreach (var result in results)
        {
            var versions = await result.GetVersionsAsync().ConfigureAwait(false);
            packages.Add(new NuGetSearchResult(
                result.Identity.Id,
                result.Identity.Version.ToString(),
                source.Source,
                versions?.Select(version => version.Version.ToString()).ToArray() ?? []));
        }
 
        return packages;
    }
 
    private static ISettings LoadSettings(string? nugetConfigPath, string workingDirectory)
    {
        // A config path that does not exist falls back to normal discovery, as it did in the helper.
        if (!string.IsNullOrEmpty(nugetConfigPath) && File.Exists(nugetConfigPath))
        {
            return Settings.LoadSpecificSettings(
                Path.GetDirectoryName(nugetConfigPath)!,
                Path.GetFileName(nugetConfigPath));
        }
 
        return Settings.LoadDefaultSettings(workingDirectory);
    }
 
    private static List<PackageSource> LoadPackageSources(
        ISettings settings,
        IReadOnlyList<string> explicitSources,
        NuGetOperationOutput output)
    {
        var sources = explicitSources.Select(source => new PackageSource(source)).ToList();
 
        if (sources.Count == 0)
        {
            sources.AddRange(new PackageSourceProvider(settings)
                .LoadPackageSources()
                .Where(source => source.IsEnabled));
        }
 
        if (sources.Count == 0)
        {
            sources.Add(new PackageSource(NuGetOrgUrl, "nuget.org"));
            output.WriteLine("Note: No package sources configured, using nuget.org as fallback.");
        }
 
        return sources;
    }
 
    private static List<PackageSource> ResolvePackageSources(
        ISettings settings,
        IReadOnlyList<string> cliSources)
    {
        var sources = new PackageSourceProvider(settings)
            .LoadPackageSources()
            .Where(source => source.IsEnabled)
            .ToList();
 
        foreach (var cliSource in cliSources)
        {
            if (!sources.Any(source => source.Source.Equals(cliSource, StringComparison.OrdinalIgnoreCase)))
            {
                sources.Add(new PackageSource(cliSource));
            }
        }
 
        if (!sources.Any(source => source.Source.Equals(NuGetOrgUrl, StringComparison.OrdinalIgnoreCase)))
        {
            sources.Add(new PackageSource(NuGetOrgUrl, "nuget.org"));
        }
 
        return sources;
    }
 
    /// <summary>
    /// Records one operation's output the way the aspire-managed helper wrote it to stderr.
    /// </summary>
    /// <remarks>
    /// The helper's stderr was the only diagnostic channel back to the CLI: the CLI logged it and put it into the
    /// exception message when the operation failed. This buffers the same text for <see cref="NuGetOperationException"/>
    /// and also streams each entry to the debug log, where the CLI logged successful operations' stderr.
    /// </remarks>
    private sealed class NuGetOperationOutput(ILogger logger) : INuGetLogger
    {
        private readonly Lock _lock = new();
        private readonly StringBuilder _text = new();
 
        /// <summary>
        /// Gets whether the helper would have run with <c>--verbose</c>. The CLI passed that flag exactly when its
        /// logger had debug logging enabled.
        /// </summary>
        public bool Verbose { get; } = logger.IsEnabled(LogLevel.Debug);
 
        public string Text
        {
            get
            {
                lock (_lock)
                {
                    return _text.ToString();
                }
            }
        }
 
        public void WriteLine(string text)
        {
            // The CLI read the helper's stderr line by line and re-joined it with AppendLine, which normalized any
            // line breaks embedded in a message to Environment.NewLine. Split the same way so the text matches.
            lock (_lock)
            {
                foreach (var line in text.ReplaceLineEndings("\n").Split('\n'))
                {
                    _text.AppendLine(line);
                }
            }
 
            logger.LogDebug("{Message}", text);
        }
 
        /// <summary>
        /// Logs text the helper wrote to stdout. The CLI only surfaced stdout when an operation failed, so it goes to
        /// the debug log without becoming part of <see cref="Text"/>.
        /// </summary>
        public void WriteDiagnostic(string text) => logger.LogDebug("{Message}", text);
 
        public void Log(NuGetLogLevel level, string data)
        {
            // Same filtering and prefixes as the helper's NuGet logger.
            if (!Verbose && level < NuGetLogLevel.Warning)
            {
                return;
            }
 
            var prefix = level switch
            {
                NuGetLogLevel.Error => "ERROR: ",
                NuGetLogLevel.Warning => "WARNING: ",
                _ => ""
            };
 
            WriteLine($"{prefix}{data}");
        }
 
        public void Log(NuGetLogMessage message) => Log(message.Level, message.Message);
 
        public Task LogAsync(NuGetLogLevel level, string data)
        {
            Log(level, data);
            return Task.CompletedTask;
        }
 
        public Task LogAsync(NuGetLogMessage message)
        {
            Log(message);
            return Task.CompletedTask;
        }
 
        public void LogDebug(string data) => Log(NuGetLogLevel.Debug, data);
        public void LogError(string data) => Log(NuGetLogLevel.Error, data);
        public void LogInformation(string data) => Log(NuGetLogLevel.Information, data);
        public void LogInformationSummary(string data) => Log(NuGetLogLevel.Information, data);
        public void LogMinimal(string data) => Log(NuGetLogLevel.Minimal, data);
        public void LogVerbose(string data) => Log(NuGetLogLevel.Verbose, data);
        public void LogWarning(string data) => Log(NuGetLogLevel.Warning, data);
    }
 
    /// <summary>
    /// Sends NuGet output that has no helper equivalent to the debug log only.
    /// </summary>
    private sealed class DiagnosticNuGetLogger(ILogger logger) : INuGetLogger
    {
        public void Log(NuGetLogLevel level, string data) => logger.LogDebug("{Message}", data);
        public void Log(NuGetLogMessage message) => Log(message.Level, message.Message);
 
        public Task LogAsync(NuGetLogLevel level, string data)
        {
            Log(level, data);
            return Task.CompletedTask;
        }
 
        public Task LogAsync(NuGetLogMessage message)
        {
            Log(message);
            return Task.CompletedTask;
        }
 
        public void LogDebug(string data) => Log(NuGetLogLevel.Debug, data);
        public void LogError(string data) => Log(NuGetLogLevel.Error, data);
        public void LogInformation(string data) => Log(NuGetLogLevel.Information, data);
        public void LogInformationSummary(string data) => Log(NuGetLogLevel.Information, data);
        public void LogMinimal(string data) => Log(NuGetLogLevel.Minimal, data);
        public void LogVerbose(string data) => Log(NuGetLogLevel.Verbose, data);
        public void LogWarning(string data) => Log(NuGetLogLevel.Warning, data);
    }
 
    private static class NativeAotNuGetTrustStore
    {
        private static readonly object s_lock = new();
        private static bool s_initialized;
 
        public static void Initialize(NuGetOperationOutput output, INuGetLogger diagnosticLogger, IEnvironment environment)
        {
            if (s_initialized ||
                !environment.IsLinux() ||
                !bool.TryParse(environment.GetEnvironmentVariable(
                    NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification), out var enabled) ||
                !enabled)
            {
                return;
            }
 
            lock (s_lock)
            {
                if (s_initialized)
                {
                    return;
                }
 
                try
                {
                    InitializeFromEmbeddedResources(diagnosticLogger);
                    s_initialized = true;
                }
                catch (Exception ex)
                {
                    // Like the helper, report the failure but let the restore continue. NuGet can still succeed when
                    // these packages do not need verification or the system provides its own certificate bundles.
                    output.WriteLine($"WARNING: Failed to initialize NuGet trust store from embedded certificates: {ex}");
                }
            }
        }
 
        private static void InitializeFromEmbeddedResources(INuGetLogger diagnosticLogger)
        {
            var previousSdkRoot = AppContext.GetData("Microsoft.DotNet.Sdk.Root");
            var rootDirectory = Directory.CreateTempSubdirectory("aspire-nuget-trust-");
            try
            {
                var trustedRootsDirectory = Directory.CreateDirectory(
                    Path.Combine(rootDirectory.FullName, "trustedroots"));
                WriteResource("codesignctl.pem", trustedRootsDirectory.FullName);
                WriteResource("timestampctl.pem", trustedRootsDirectory.FullName);
 
                // NuGet resolves its fallback trust bundles under Microsoft.DotNet.Sdk.Root.
                // Point it at the securely extracted embedded SDK bundles only while the factories initialize.
                AppContext.SetData("Microsoft.DotNet.Sdk.Root", rootDirectory.FullName);
                X509TrustStore.InitializeForDotNetSdk(diagnosticLogger);
            }
            finally
            {
                AppContext.SetData("Microsoft.DotNet.Sdk.Root", previousSdkRoot);
 
                // The factories hold the certificates they loaded, so a directory that cannot be removed is only a
                // leftover temp file and must not undo a successful initialization.
                try
                {
                    rootDirectory.Delete(recursive: true);
                }
                catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
                {
                    diagnosticLogger.LogDebug($"Failed to delete temporary trust store directory '{rootDirectory.FullName}': {ex.Message}");
                }
            }
        }
 
        private static void WriteResource(string resourceName, string destinationDirectory)
        {
            using var resourceStream = typeof(NuGetClient).Assembly.GetManifestResourceStream(resourceName)
                ?? throw new InvalidOperationException($"Embedded NuGet trust root resource '{resourceName}' was not found.");
            using var fileStream = File.Create(Path.Combine(destinationDirectory, resourceName));
            resourceStream.CopyTo(fileStream);
        }
    }
}