File: DotNet\DotNetAppHostNuGetConfigMerger.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.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Xml;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Hosting.Utils;
using NuGet.Configuration;
 
namespace Aspire.Cli.DotNet;
 
/// <summary>
/// Maintains channel-owned entries and additive integration mappings without replacing user policy.
/// </summary>
internal sealed class DotNetAppHostNuGetConfigMerger(BundleNuGetService nuGetService)
{
    internal async Task CreateOrUpdateAsync(
        DirectoryInfo targetDirectory,
        NuGetConfiguration configuration,
        bool createIfMissing,
        string? globalPackagesFolder,
        Func<FileInfo, XmlDocument?, XmlDocument, CancellationToken, Task<bool>>? confirmationCallback,
        CancellationToken cancellationToken)
    {
        var candidate = await PrepareAsync(
            targetDirectory, configuration, createIfMissing, globalPackagesFolder, cancellationToken);
        if (candidate is null ||
            confirmationCallback is not null &&
            !await confirmationCallback(
                candidate.TargetFile,
                candidate.GetOriginalDocument(),
                candidate.GetProposedDocument(),
                cancellationToken))
        {
            return;
        }
 
        await ApplyAsync(candidate, cancellationToken);
    }
 
    internal async Task<DotNetAppHostNuGetConfigMergerCandidate?> PrepareAsync(
        DirectoryInfo targetDirectory,
        NuGetConfiguration configuration,
        bool createIfMissing,
        string? globalPackagesFolder,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(targetDirectory);
        ArgumentNullException.ThrowIfNull(configuration);
        cancellationToken.ThrowIfCancellationRequested();
        if (configuration.Overlay is null)
        {
            return null;
        }
 
        FileInfo? targetFile = null;
        var targetExists = targetDirectory.Exists &&
            TryFindNuGetConfigInDirectory(targetDirectory, out targetFile);
        if (!targetExists && !createIfMissing)
        {
            return null;
        }
 
        targetFile ??= new FileInfo(Path.Combine(targetDirectory.FullName, "nuget.config"));
        byte[]? originalContent = targetExists
            ? await File.ReadAllBytesAsync(targetFile.FullName, cancellationToken)
            : null;
        if (originalContent is not null && globalPackagesFolder == CliPathHelper.StagingNuGetPackagesFolderName)
        {
            var document = new XmlDocument();
            using var stream = new MemoryStream(originalContent, writable: false);
            document.Load(stream);
            if (document.SelectNodes("/configuration/config/add")!.OfType<XmlElement>()
                .Any(static element => string.Equals(element.GetAttribute("key"), "globalPackagesFolder", StringComparison.OrdinalIgnoreCase)))
            {
                globalPackagesFolder = null;
            }
        }
 
        // Discovery can narrow an already sufficient mapping policy without needing any
        // persisted edits. Do not materialize an empty child configuration in that case.
        var reusesExistingMappings = configuration.Settings.PackageSourceMappings.Count > 0 &&
            configuration.AppHostConfig is
            {
                SourceChanges.Sources.Count: 0,
                SourceChanges.RetiredSourceKeys.Count: 0,
                PackageSourceMappings.Count: 0,
                RemovedPackageSourceMappings.Count: 0
            };
        if ((!configuration.HasSourcePolicyChanges || reusesExistingMappings) && globalPackagesFolder is null)
        {
            return null;
        }
 
        var proposedContent = await CreatePersistentNuGetConfigContentAsync(
            configuration,
            targetFile,
            originalContent is null ? default(ReadOnlyMemory<byte>?) : new ReadOnlyMemory<byte>(originalContent),
            globalPackagesFolder,
            cancellationToken);
        if (originalContent is not null && proposedContent.AsSpan().SequenceEqual(originalContent))
        {
            return null;
        }
 
        return new DotNetAppHostNuGetConfigMergerCandidate(targetFile, originalContent, proposedContent);
    }
 
    internal async Task<byte[]> CreatePersistentNuGetConfigContentAsync(
        NuGetConfiguration configuration,
        FileInfo targetFile,
        ReadOnlyMemory<byte>? originalContent,
        string? globalPackagesFolder,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(configuration);
        ArgumentNullException.ThrowIfNull(targetFile);
        cancellationToken.ThrowIfCancellationRequested();
 
        var targetPath = PathNormalizer.ResolveToFilesystemPath(targetFile.FullName);
        var paths = configuration.Settings.ConfigPaths.ToList();
        var targetIndex = paths.FindIndex(path => string.Equals(
            PathNormalizer.ResolveToFilesystemPath(path), targetPath, StringComparisons.FileSystemPath));
        var inheritedPaths = targetIndex >= 0
            ? paths.Skip(targetIndex + 1).ToArray()
            : paths.Where(path => !BundleNuGetService.IsAncestorDirectory(
                targetFile.DirectoryName!, Path.GetDirectoryName(path)!)).ToArray();
 
        // NuGet's typed settings writer is file-backed. Persistence derives local edits
        // against the target's remaining hierarchy, rather than copying projection masks.
        string? conflictingSourceKey = null;
        using var file = await TemporaryNuGetConfigFile.CreateAsync(
            path => conflictingSourceKey = WriteConfig(
                configuration, targetFile, path, originalContent, globalPackagesFolder, inheritedPaths, cancellationToken)).ConfigureAwait(false);
 
        var proposedContent = await File.ReadAllBytesAsync(file.ConfigFile.FullName, cancellationToken).ConfigureAwait(false);
        // A discovery overlay can change without requiring any persisted configuration edit.
        var hasChanges = originalContent is not { } original ||
            !proposedContent.AsSpan().SequenceEqual(original.Span);
        if (hasChanges && conflictingSourceKey is not null)
        {
            throw new ProjectUpdaterException(string.Format(
                CultureInfo.CurrentCulture,
                UpdateCommandStrings.ChannelFeedAlreadyConfiguredFormat,
                targetFile.FullName,
                conflictingSourceKey));
        }
 
        return proposedContent;
    }
 
    private string? WriteConfig(
        NuGetConfiguration sourceConfiguration,
        FileInfo targetFile,
        string outputPath,
        ReadOnlyMemory<byte>? originalContent,
        string? globalPackagesFolder,
        IReadOnlyList<string> inheritedConfigPaths,
        CancellationToken cancellationToken)
    {
        var configuration = sourceConfiguration.AppHostConfig
            ?? throw new InvalidOperationException("The resolved configuration does not require a NuGet configuration change.");
        nuGetService.WriteNuGetConfig(
            configuration.SourceChanges with { GlobalPackagesFolder = globalPackagesFolder },
            outputPath, originalContent, omitRedundantDisabledSources: false, inheritedConfigPaths, cancellationToken);
 
        var outputFile = new FileInfo(outputPath);
        var settings = new Settings(outputFile.DirectoryName!, outputFile.Name, isMachineWide: false);
        var localSources = settings.GetSection(ConfigurationConstants.PackageSources)?.Items.OfType<SourceItem>().ToArray() ?? [];
        var localMappingItems = settings.GetSection(ConfigurationConstants.PackageSourceMapping)?.Items.ToArray() ?? [];
        ISettings inheritedSettings = inheritedConfigPaths.Count == 0
            ? NullSettings.Instance
            : Settings.LoadSettingsGivenConfigPaths(inheritedConfigPaths.ToList());
        var inheritedMappings = inheritedSettings.GetSection(ConfigurationConstants.PackageSourceMapping)?.Items
            .OfType<PackageSourceMappingSourceItem>().ToArray() ?? [];
        string? conflictingSourceKey = null;
        foreach (var source in sourceConfiguration.ConfigSources.Where(static source => source.IsAmbient))
        {
            if (!configuration.SelectedSourceKeys.Contains(source.Key, StringComparer.OrdinalIgnoreCase))
            {
                continue;
            }
 
            if (!source.IsEnabled)
            {
                conflictingSourceKey = source.Key;
                break;
            }
            if (!configuration.PackageSourceMappings.Any(mapping =>
                string.Equals(mapping.SourceKey, source.Key, StringComparison.OrdinalIgnoreCase)))
            {
                continue;
            }
 
            var ambientSource = sourceConfiguration.Settings.Sources.Single(ambient =>
                string.Equals(ambient.Name, source.Key, StringComparison.OrdinalIgnoreCase));
            // Exact integration selections may extend local user mappings without taking
            // ownership of the alias or its existing patterns. NuGet replaces mapping entries
            // by key, so a local source definition alone cannot make inherited mappings safe
            // to extend.
            var canExtendIntegrationMapping = ambientSource.IsIntegrationSource &&
                (source.IsExplicitlyNamed ||
                 configuration.PackageSourceMappings
                     .Where(mapping => string.Equals(mapping.SourceKey, source.Key, StringComparison.OrdinalIgnoreCase))
                     .All(static mapping => mapping.Patterns.All(PackageSourceOverrideMappings.IsExactPackagePattern)));
            var inheritsMapping = !localMappingItems.Any(static item => item is ClearItem) &&
                !localMappingItems.OfType<PackageSourceMappingSourceItem>().Any(mapping =>
                    string.Equals(mapping.Key, source.Key, StringComparison.OrdinalIgnoreCase)) &&
                inheritedMappings.Any(mapping =>
                    string.Equals(mapping.Key, source.Key, StringComparison.OrdinalIgnoreCase));
            if (inheritsMapping ||
                !(NuGetConfigurationBuilder.IsCliManagedSourceKey(source.Key, configuration.WorkloadId) ||
                  canExtendIntegrationMapping) ||
                !localSources.Any(local =>
                    string.Equals(local.Key, source.Key, StringComparison.OrdinalIgnoreCase) &&
                    string.Equals(
                        NuGetSourceIdentity.Compute(
                            PackageSourceOverrideMappings.ResolveForWorkingDirectory(local.Value, targetFile.Directory!),
                            sourceConfiguration.Settings.SourceIdentityKey),
                        ambientSource.Identity,
                        StringComparison.Ordinal)))
            {
                conflictingSourceKey = source.Key;
                break;
            }
        }
 
        foreach (var removed in configuration.RemovedPackageSourceMappings)
        {
            if (!sourceConfiguration.Settings.Sources.Any(source =>
                source.IsIntegrationSource &&
                string.Equals(source.Name, removed.SourceKey, StringComparison.OrdinalIgnoreCase)))
            {
                continue;
            }
 
            var localPatterns = localMappingItems.OfType<PackageSourceMappingSourceItem>()
                .Where(mapping => string.Equals(mapping.Key, removed.SourceKey, StringComparison.OrdinalIgnoreCase))
                .SelectMany(static mapping => mapping.Patterns)
                .Select(static pattern => pattern.Pattern)
                .ToArray();
            var retainsLocalMapping = localPatterns.Any(pattern =>
                !removed.Patterns.Contains(pattern, StringComparer.OrdinalIgnoreCase)) ||
                configuration.PackageSourceMappings.Any(mapping =>
                    string.Equals(mapping.SourceKey, removed.SourceKey, StringComparison.OrdinalIgnoreCase));
            // Deleting the last local entry would expose the parent's whole mapping entry,
            // potentially restoring the old exact route or changing unrelated eligibility.
            var exposesInheritedMapping = !retainsLocalMapping &&
                !localMappingItems.Any(static item => item is ClearItem) &&
                inheritedMappings.Any(mapping =>
                    string.Equals(mapping.Key, removed.SourceKey, StringComparison.OrdinalIgnoreCase));
            if (!localPatterns.Any(pattern => removed.Patterns.Contains(pattern, StringComparer.OrdinalIgnoreCase)) ||
                exposesInheritedMapping)
            {
                throw new ProjectUpdaterException(string.Format(
                    CultureInfo.CurrentCulture,
                    UpdateCommandStrings.ChannelFeedAlreadyConfiguredFormat,
                    targetFile.FullName,
                    removed.SourceKey));
            }
        }
 
        var changedKeys = configuration.PackageSourceMappings.Select(static mapping => mapping.SourceKey)
            .Concat(configuration.RemovedPackageSourceMappings.Select(static mapping => mapping.SourceKey))
            .Concat(configuration.SourceChanges.RetiredSourceKeys)
            .Distinct(StringComparer.OrdinalIgnoreCase);
        foreach (var key in changedKeys)
        {
            var existing = settings.GetSection(ConfigurationConstants.PackageSourceMapping)?.Items
                .OfType<PackageSourceMappingSourceItem>()
                .Where(mapping => string.Equals(mapping.Key, key, StringComparison.OrdinalIgnoreCase))
                .ToArray() ?? [];
            var patterns = configuration.SourceChanges.RetiredSourceKeys.Contains(key, StringComparer.OrdinalIgnoreCase)
                ? []
                : existing.SelectMany(static mapping => mapping.Patterns)
                    .Where(pattern => !configuration.RemovedPackageSourceMappings.Any(removed =>
                        string.Equals(removed.SourceKey, key, StringComparison.OrdinalIgnoreCase) &&
                        removed.Patterns.Contains(pattern.Pattern, StringComparer.OrdinalIgnoreCase)))
                    .Concat(configuration.PackageSourceMappings
                        .Where(mapping => string.Equals(mapping.SourceKey, key, StringComparison.OrdinalIgnoreCase))
                        .SelectMany(static mapping => mapping.Patterns)
                        .Select(static pattern => new PackagePatternItem(pattern)))
                    .DistinctBy(static pattern => pattern.Pattern, StringComparer.OrdinalIgnoreCase)
                    .ToArray();
            if (existing.SelectMany(static mapping => mapping.Patterns).Select(static pattern => pattern.Pattern)
                .SequenceEqual(patterns.Select(static pattern => pattern.Pattern), StringComparer.OrdinalIgnoreCase))
            {
                continue;
            }
 
            foreach (var mapping in existing)
            {
                settings.Remove(ConfigurationConstants.PackageSourceMapping, mapping);
            }
            if (patterns.Length > 0)
            {
                settings.AddOrUpdate(ConfigurationConstants.PackageSourceMapping,
                    new PackageSourceMappingSourceItem(key, patterns));
            }
        }
 
        // Removing local records may expose inherited policy. Retirement does not mask
        // or modify that policy, and its presence does not prevent local removal.
        settings.SaveToDisk();
 
        return conflictingSourceKey;
    }
 
    internal static Task ApplyAsync(
        DotNetAppHostNuGetConfigMergerCandidate candidate,
        CancellationToken cancellationToken)
        => ApplyAsync(candidate, static () => { }, cancellationToken);
 
    internal static async Task ApplyAsync(
        DotNetAppHostNuGetConfigMergerCandidate candidate,
        Action writeStarting,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(candidate);
        cancellationToken.ThrowIfCancellationRequested();
        candidate.TargetFile.Directory!.Create();
 
        // Check the baseline and write the approved snapshot under the same exclusive handle.
        // CreateNew also prevents overwriting a config created after preparation.
        await using var stream = candidate.TargetFile.Open(
            candidate.OriginalContent is null ? FileMode.CreateNew : FileMode.Open,
            FileAccess.ReadWrite,
            FileShare.None);
        if (candidate.OriginalContent is { } originalContent)
        {
            using var currentContent = new MemoryStream();
            await stream.CopyToAsync(currentContent, cancellationToken);
            if (!currentContent.ToArray().AsSpan().SequenceEqual(originalContent.Span))
            {
                throw new InvalidOperationException(
                    string.Format(CultureInfo.CurrentCulture, UpdateCommandStrings.UpdateCandidateFileChangedFormat, candidate.TargetFile.FullName));
            }
            cancellationToken.ThrowIfCancellationRequested();
        }
 
        writeStarting();
        stream.Position = 0;
        stream.SetLength(0);
        // Observe cancellation before committing, never after truncating the destination.
        await stream.WriteAsync(candidate.ProposedContent, CancellationToken.None);
    }
 
    internal static DirectoryInfo GetRecommendedNuGetConfigDirectory(IReadOnlyList<string> configPaths, DirectoryInfo appHostDirectory)
        => configPaths.Count > 0 && !IsGlobalNuGetConfig(configPaths[0])
            ? new DirectoryInfo(Path.GetDirectoryName(configPaths[0])!)
            : appHostDirectory;
 
    internal static bool TryFindNuGetConfigInDirectory(
        DirectoryInfo directory,
        [NotNullWhen(true)] out FileInfo? nugetConfigFile)
    {
        ArgumentNullException.ThrowIfNull(directory);
        var matches = directory.EnumerateFiles("*", SearchOption.TopDirectoryOnly)
            .Where(file => string.Equals(file.Name, "nuget.config", StringComparison.OrdinalIgnoreCase))
            .ToArray();
        if (matches.Length > 1)
        {
            throw new InvalidOperationException($"Multiple NuGet.config files found in '{directory.FullName}' differing only by case.");
        }
 
        nugetConfigFile = matches.SingleOrDefault();
        return matches.Length == 1;
    }
 
    private static bool IsGlobalNuGetConfig(string path)
    {
        if (Environment.OSVersion.Platform == PlatformID.Win32NT)
        {
            return path.StartsWith(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), StringComparison.Ordinal);
        }
        else
        {
            var globalNuGetFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget");
            return path.StartsWith(globalNuGetFolder, StringComparison.Ordinal);
        }
    }
}