File: NuGet\NuGetConfigurationBuilder.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.Globalization;
using Aspire.Cli.Configuration;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
 
namespace Aspire.Cli.NuGet;
 
/// <summary>
/// Combines evaluated ambient NuGet settings with a selected package-source policy.
/// </summary>
internal static class NuGetConfigurationBuilder
{
    internal const string IntegrationSourceKeyPrefix = "aspire-integration-";
 
    public static NuGetConfiguration BuildStableAppHostConfiguration(
        NuGetSettingsInfo settings,
        string workloadId,
        PackageChannel channel)
    {
        var preservedSourceIdentities = (channel.Mappings ?? [])
            .Select(mapping => NuGetSourceIdentity.Compute(mapping.Source, settings.SourceIdentityKey))
            .Append(NuGetSourceIdentity.Compute(PackageSources.NuGetOrg, settings.SourceIdentityKey))
            .ToHashSet(StringComparer.Ordinal);
        // Shared .NET feeds are identified by their CLI aliases, including aliases left
        // behind when an AppHost moves and its path-derived workload identifier changes.
        var channelSources = settings.Sources
            .Where(source => !preservedSourceIdentities.Contains(source.Identity) &&
                IsCliManagedSource(source, workloadId))
            .ToDictionary(static source => source.Name, StringComparer.OrdinalIgnoreCase);
 
        // Stable returns to existing NuGet policy, not a prescribed public endpoint. Remove
        // channel-owned Aspire mappings while retaining user feeds and unrelated mappings.
        var ambientMappings = ResolveAmbientMappingAliases(settings);
        var mappings = ambientMappings
            .Select(mapping => channelSources.TryGetValue(mapping.SourceKey, out var source)
                ? mapping with
                {
                    Patterns = [.. mapping.Patterns.Where(pattern =>
                        !IsObsoleteStableChannelPattern(source, pattern))]
                }
                : mapping)
            .Where(static mapping => mapping.Patterns.Count > 0)
            .ToArray();
        foreach (var source in channelSources.Values.Where(static source => source.IsIntegrationSource))
        {
            if (!ambientMappings.Any(mapping =>
                string.Equals(mapping.SourceKey, source.Name, StringComparison.OrdinalIgnoreCase) &&
                mapping.Patterns.Any(pattern => IsObsoleteStableChannelPattern(source, pattern))))
            {
                continue;
            }
 
            // Neither discovery filters nor surviving narrower mappings establish the
            // full private dependency graph. Require manual cleanup before removing
            // broad routing from a shared SDK/integration feed.
            throw new ProjectUpdaterException(string.Format(
                CultureInfo.CurrentCulture, UpdateCommandStrings.ChannelFeedAlreadyConfiguredFormat,
                string.Join(", ", settings.ConfigPaths), source.Name));
        }
        var retiredSourceKeys = channelSources.Values
            .Where(source => !source.IsIntegrationSource && !mappings.Any(mapping =>
                string.Equals(mapping.SourceKey, source.Name, StringComparison.OrdinalIgnoreCase)))
            .Select(static source => source.Name)
            .ToArray();
        var overlay = new NuGetConfigOverlay(
            Sources: [],
            PackageSourceMappings: mappings,
            ClearDisabledPackageSources: retiredSourceKeys.Length > 0,
            DisabledPackageSourceKeys: [.. settings.DisabledPackageSourceKeys
                .Concat(retiredSourceKeys).Distinct(StringComparer.OrdinalIgnoreCase)],
            GlobalPackagesFolder: null)
        {
            ClearPackageSourceMappings = true,
            RetiredSourceKeys = retiredSourceKeys
        };
 
        return new NuGetConfiguration(settings, [], overlay);
    }
 
    public static NuGetConfiguration Build(
        NuGetSettingsInfo settings,
        string workloadId,
        PackageMapping[]? selectedMappings,
        bool restrictToSelectedSources,
        string? packageScopedAppendSource,
        bool hasAuthoritativeAspirePolicy)
    {
        ArgumentNullException.ThrowIfNull(settings);
        ArgumentException.ThrowIfNullOrWhiteSpace(workloadId);
        if (restrictToSelectedSources && selectedMappings is not { Length: > 0 })
        {
            throw new ArgumentException("Source-restricted configuration requires a selected source.", nameof(selectedMappings));
        }
 
        var configSources = ResolveSourceAliases(
            selectedMappings,
            workloadId,
            settings.Sources,
            settings.ReservedPackageSourceKeys,
            settings.SourceIdentityKey);
 
        var overlay = selectedMappings is null
            ? null
            : ComposeOverlay(
                selectedMappings,
                settings,
                configSources,
                restrictToSelectedSources,
                globalPackagesFolder: null,
                packageScopedAppendSource,
                hasAuthoritativeAspirePolicy);
 
        return new NuGetConfiguration(
            settings,
            configSources,
            overlay);
    }
 
    internal static bool IsCliManagedSource(NuGetSourceInfo source, string workloadId)
        => source.IsCliManaged || IsCliManagedSourceKey(source.Name, workloadId);
 
    internal static bool IsObsoleteStableChannelPattern(NuGetSourceInfo source, string pattern)
        // Integration aliases own their mappings, not SDK policy. Shared channel
        // wildcards require manual cleanup because private dependencies may use them.
        => source.IsIntegrationSource
            ? !source.Name.StartsWith(IntegrationSourceKeyPrefix, StringComparison.OrdinalIgnoreCase) &&
                (pattern == PackageMapping.AllPackages ||
                 string.Equals(pattern, PackageSourceOverrideMappings.DefaultPackagePattern, StringComparison.OrdinalIgnoreCase))
            : pattern == PackageMapping.AllPackages ||
                PackageSourceOverrideMappings.CompetesWithAuthoritativePattern(
                    pattern, PackageSourceOverrideMappings.DefaultPackagePattern);
 
    internal static bool IsCliManagedSourceKey(string sourceKey, string workloadId)
        => sourceKey.StartsWith(IntegrationSourceKeyPrefix, StringComparison.OrdinalIgnoreCase) ||
            string.Equals(sourceKey, $"aspire-{PackageChannelNames.Daily}", StringComparison.OrdinalIgnoreCase) ||
            string.Equals(sourceKey, $"aspire-{PackageChannelNames.Staging}", StringComparison.OrdinalIgnoreCase) ||
            sourceKey.StartsWith("aspire-pr-", StringComparison.OrdinalIgnoreCase) ||
            sourceKey.StartsWith("aspire-apphost-", StringComparison.OrdinalIgnoreCase) ||
            string.Equals(sourceKey, $"aspire-{workloadId}", StringComparison.OrdinalIgnoreCase) ||
            sourceKey.StartsWith($"aspire-{workloadId}-", StringComparison.OrdinalIgnoreCase);
 
    public static NuGetConfigSource[] ResolveSourceAliases(
        PackageMapping[]? mappings,
        string workloadId,
        IReadOnlyList<NuGetSourceInfo> ambientSources,
        IReadOnlyList<string> reservedPackageSourceKeys,
        ReadOnlySpan<byte> sourceIdentityKey)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(workloadId);
 
        if (mappings is null)
        {
            return [];
        }
 
        var usedKeys = reservedPackageSourceKeys
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
        var generatedSourceKeyPrefix = $"aspire-{workloadId}";
        var sourceIndex = 0;
        var nextAdditionalSourceKey = 0;
 
        var resolvedSources = new List<NuGetConfigSource>();
        foreach (var source in mappings
            .Select(static mapping => mapping.Source)
            .Distinct(PackageSourceIdentity.Comparer))
        {
            // Source order still determines the primary source when its ambient alias is reused.
            var isPrimarySource = sourceIndex++ == 0;
            var sourceIdentity = NuGetSourceIdentity.Compute(source, sourceIdentityKey);
            var configuredKey = mappings.First(mapping => PackageSourceIdentity.Comparer.Equals(mapping.Source, source)).SourceKey;
            var preferredKey = configuredKey is null ? null : $"{IntegrationSourceKeyPrefix}{configuredKey}";
            if (configuredKey is not null)
            {
                SourceNameValidator.Validate(configuredKey, $"{IntegrationSourceConfiguration.SectionName}.{configuredKey}");
            }
            var ambientMatches = ambientSources
                .Where(candidate =>
                    string.Equals(candidate.Identity, sourceIdentity, StringComparison.Ordinal) ||
                    PackageSourceIdentity.IsNamedSourceReference(source) &&
                    string.Equals(candidate.Name, source, StringComparison.OrdinalIgnoreCase))
                .ToArray();
            if (ambientMatches.Length > 0)
            {
                var enabledMatches = ambientMatches
                    .Where(static ambientSource => ambientSource.IsEnabled)
                    .ToArray();
                var selectedMatches = enabledMatches;
                if (selectedMatches.Length == 0)
                {
                    // Credentials and client certificates are attached to source aliases. When an
                    // equivalent source must be re-enabled, preserve the authenticated alias.
                    var preferredDisabledMatch = ambientMatches.FirstOrDefault(static ambientSource =>
                        ambientSource.HasCredentials || ambientSource.HasClientCertificates);
                    selectedMatches = [preferredDisabledMatch ?? ambientMatches[0]];
                }
 
                foreach (var ambientSource in selectedMatches)
                {
                    resolvedSources.Add(new NuGetConfigSource(
                        ambientSource.Name,
                        source,
                        IsAmbient: true,
                        ambientSource.IsEnabled)
                    {
                        IsExplicitlyNamed = string.Equals(ambientSource.Name, preferredKey, StringComparison.OrdinalIgnoreCase) ||
                            string.Equals(ambientSource.Name, configuredKey, StringComparison.OrdinalIgnoreCase)
                    });
                }
 
                continue;
            }
 
            string key;
            if (preferredKey is not null)
            {
                key = preferredKey;
                if (!usedKeys.Add(key))
                {
                    throw new InvalidOperationException(string.Format(
                        CultureInfo.CurrentCulture, ErrorStrings.NuGetSourceKeyAlreadyInUse, key));
                }
            }
            else if (isPrimarySource)
            {
                key = generatedSourceKeyPrefix;
                if (!usedKeys.Add(key))
                {
                    do
                    {
                        key = $"{generatedSourceKeyPrefix}-{nextAdditionalSourceKey++}";
                    }
                    while (!usedKeys.Add(key));
                }
            }
            else
            {
                do
                {
                    key = $"{generatedSourceKeyPrefix}-{nextAdditionalSourceKey++}";
                }
                while (!usedKeys.Add(key));
            }
 
            resolvedSources.Add(new NuGetConfigSource(key, source, IsAmbient: false, IsEnabled: true)
            {
                IsExplicitlyNamed = preferredKey is not null
            });
        }
 
        return [.. resolvedSources];
    }
 
    public static NuGetConfigOverlay ComposeOverlay(
        PackageMapping[] selectedMappings,
        NuGetSettingsInfo settings,
        IReadOnlyList<NuGetConfigSource> selectedSources,
        bool restrictToSelectedSources,
        string? globalPackagesFolder,
        string? packageScopedAppendSource = null,
        bool hasAuthoritativeAspirePolicy = false)
    {
        ArgumentNullException.ThrowIfNull(selectedMappings);
        ArgumentNullException.ThrowIfNull(settings);
        ArgumentNullException.ThrowIfNull(selectedSources);
 
        var enabledSourceKeys = selectedSources
            .Where(static source => source.IsAmbient && !source.IsEnabled)
            .Select(static source => source.Key)
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
        var clearDisabledPackageSources = enabledSourceKeys.Count > 0;
        var disabledPackageSourceKeys = clearDisabledPackageSources
            ? settings.DisabledPackageSourceKeys
                .Where(key => !enabledSourceKeys.Contains(key))
                .ToArray()
            : [];
        var ambientMappings = ResolveAmbientMappingAliases(settings);
        var packageSourceMappings = ComposePackageSourceMappings(
            selectedMappings,
            ambientMappings,
            settings.Sources,
            selectedSources,
            packageScopedAppendSource,
            hasAuthoritativeAspirePolicy);
 
        if (restrictToSelectedSources)
        {
            // Search ignores mappings. Source-restricted operations must disable other aliases
            // without replacing the hierarchy that owns authentication and transport settings.
            var selectedKeys = selectedSources
                .Select(static source => source.Key)
                .ToHashSet(StringComparer.OrdinalIgnoreCase);
            clearDisabledPackageSources = true;
            disabledPackageSourceKeys = settings.Sources
                .Select(static source => source.Name)
                .Concat(settings.DisabledPackageSourceKeys)
                .Where(key => !selectedKeys.Contains(key))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToArray();
            // A disabled source's exact mapping can outrank the selected source's '*' mapping.
            // Remove its mappings as well so template installation can resolve from --source.
            packageSourceMappings = packageSourceMappings
                .Where(mapping => selectedKeys.Contains(mapping.SourceKey))
                .ToArray();
        }
 
        var selectedKeysForPolicy = selectedSources.Select(static source => source.Key)
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
        // Selecting an integration package does not replace the AppHost's SDK channel.
        var isExactPackageSelection = selectedMappings.Length > 0 &&
            selectedMappings.All(static mapping => PackageSourceOverrideMappings.IsExactPackagePattern(mapping.PackageFilter));
        var retiredSourceKeys = settings.Sources
            .Where(source =>
                !isExactPackageSelection &&
                !source.IsIntegrationSource &&
                !selectedKeysForPolicy.Contains(source.Name) &&
                !packageSourceMappings.Any(mapping =>
                    string.Equals(mapping.SourceKey, source.Name, StringComparison.OrdinalIgnoreCase) &&
                    mapping.Patterns.Any(static pattern => pattern != PackageMapping.AllPackages)) &&
                (source.IsCliManaged ||
                 !packageSourceMappings.Any(mapping => string.Equals(mapping.SourceKey, source.Name, StringComparison.OrdinalIgnoreCase)) &&
                 ambientMappings.Any(mapping => string.Equals(mapping.SourceKey, source.Name, StringComparison.OrdinalIgnoreCase))))
            .Select(static source => source.Name)
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
        if (retiredSourceKeys.Count > 0)
        {
            // Retired CLI hives must not remain eligible for dependency lookup after a
            // channel change: they can disappear independently of the AppHost.
            packageSourceMappings = packageSourceMappings.Where(mapping => !retiredSourceKeys.Contains(mapping.SourceKey)).ToArray();
            clearDisabledPackageSources = true;
            disabledPackageSourceKeys =
            [
                .. settings.DisabledPackageSourceKeys.Where(key => !enabledSourceKeys.Contains(key)),
                .. disabledPackageSourceKeys,
                .. retiredSourceKeys
            ];
            disabledPackageSourceKeys = disabledPackageSourceKeys.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
        }
 
        return new NuGetConfigOverlay(
            selectedSources
                .Where(static source => !source.IsAmbient)
                .Select(static source => (Key: source.Key, Source: source.Source))
                .ToArray(),
            packageSourceMappings,
            clearDisabledPackageSources,
            disabledPackageSourceKeys,
            globalPackagesFolder)
        {
            RetiredSourceKeys = [.. retiredSourceKeys]
        };
    }
 
    public static NuGetPackageSourceMapping[] ComposePackageSourceMappings(
        IReadOnlyList<PackageMapping> selectedMappings,
        IReadOnlyList<NuGetPackageSourceMapping> ambientMappings,
        IReadOnlyList<NuGetSourceInfo> ambientSources,
        IReadOnlyList<NuGetConfigSource> selectedSources,
        string? packageScopedAppendSource = null,
        bool hasAuthoritativeAspirePolicy = false)
    {
        ArgumentNullException.ThrowIfNull(selectedMappings);
        ArgumentNullException.ThrowIfNull(ambientMappings);
        ArgumentNullException.ThrowIfNull(ambientSources);
        ArgumentNullException.ThrowIfNull(selectedSources);
 
        var patternsBySourceKey = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
        var hasPackageScopedAppendSource = packageScopedAppendSource is not null;
 
        if (ambientMappings.Count == 0)
        {
            // Enabling package-source mapping changes NuGet from "every enabled source can serve
            // every package" to deny-by-default. Reproduce that existing eligibility first.
            foreach (var ambientSource in ambientSources.Where(static source => source.IsEnabled))
            {
                AddPattern(patternsBySourceKey, ambientSource.Name, PackageMapping.AllPackages);
            }
        }
        else
        {
            foreach (var ambientMapping in ambientMappings)
            {
                foreach (var pattern in ambientMapping.Patterns)
                {
                    AddPattern(patternsBySourceKey, ambientMapping.SourceKey, pattern);
                }
            }
        }
 
        if (hasPackageScopedAppendSource && !hasAuthoritativeAspirePolicy)
        {
            // With no selected channel policy, ambient '*' mappings previously made those sources
            // eligible for Aspire packages. Promote that eligibility to the appended Aspire*
            // specificity so --source does not accidentally replace the ambient feeds.
            foreach (var (sourceKey, patterns) in patternsBySourceKey.ToArray())
            {
                if (patterns.Contains(PackageMapping.AllPackages, StringComparer.OrdinalIgnoreCase))
                {
                    AddPattern(
                        patternsBySourceKey,
                        sourceKey,
                        PackageSourceOverrideMappings.DefaultPackagePattern);
                }
            }
        }
 
        var authoritativePatterns = selectedMappings
            .Where(mapping =>
                mapping.PackageFilter != PackageMapping.AllPackages &&
                !(!hasAuthoritativeAspirePolicy &&
                  PackageSourceIdentity.Comparer.Equals(mapping.Source, packageScopedAppendSource) &&
                  string.Equals(
                      mapping.PackageFilter,
                      PackageSourceOverrideMappings.DefaultPackagePattern,
                      StringComparison.OrdinalIgnoreCase)))
            .Select(static mapping => mapping.PackageFilter)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToArray();
        var authoritativeSources = selectedMappings
            .Where(static mapping => mapping.PackageFilter != PackageMapping.AllPackages)
            .Select(static mapping => mapping.Source)
            .ToHashSet(PackageSourceIdentity.Comparer);
        foreach (var (sourceKey, patterns) in patternsBySourceKey)
        {
            var isIntegrationSource = ambientSources.Any(source =>
                source.IsIntegrationSource && string.Equals(source.Name, sourceKey, StringComparison.OrdinalIgnoreCase));
            // Channel changes must not take integration packages away from configured feeds.
            // An exact package selection can still move that package to a different source.
            patterns.RemoveAll(pattern => authoritativePatterns.Any(
                authoritativePattern =>
                    (!isIntegrationSource || PackageSourceOverrideMappings.IsExactPackagePattern(authoritativePattern)) &&
                    PackageSourceOverrideMappings.CompetesWithAuthoritativePattern(pattern, authoritativePattern)));
            if (isIntegrationSource && ambientMappings.Count == 0)
            {
                foreach (var pattern in authoritativePatterns.Where(static pattern =>
                    !PackageSourceOverrideMappings.IsExactPackagePattern(pattern)))
                {
                    AddPattern(patternsBySourceKey, sourceKey, pattern);
                }
            }
        }
 
        foreach (var mapping in selectedMappings)
        {
            if (ambientMappings.Count > 0 &&
                mapping.PackageFilter == PackageMapping.AllPackages &&
                authoritativeSources.Count > 0 &&
                !authoritativeSources.Contains(mapping.Source))
            {
                // Existing mapping policy already owns unrelated package eligibility. Do not
                // broaden it with a channel fallback source.
                continue;
            }
 
            foreach (var source in selectedSources.Where(
                source => PackageSourceIdentity.Comparer.Equals(source.Source, mapping.Source)))
            {
                AddPattern(patternsBySourceKey, source.Key, mapping.PackageFilter);
            }
        }
 
        return patternsBySourceKey
            .Where(static mapping => mapping.Value.Count > 0)
            .Select(static mapping => new NuGetPackageSourceMapping(
                mapping.Key,
                [.. mapping.Value]))
            .ToArray();
    }
 
    private static NuGetPackageSourceMapping[] ResolveAmbientMappingAliases(NuGetSettingsInfo settings)
    {
        return
        [
            .. settings.PackageSourceMappings.SelectMany<NuGetPackageSourceMapping, NuGetPackageSourceMapping>(mapping =>
            {
                if (settings.Sources.Any(source => string.Equals(source.Name, mapping.SourceKey, StringComparison.OrdinalIgnoreCase)))
                {
                    return [mapping];
                }
 
                // Older generated configs used a source URL as a mapping key even when the
                // source itself had a different alias. NuGet only matches mapping keys by alias.
                var identity = NuGetSourceIdentity.Compute(mapping.SourceKey, settings.SourceIdentityKey);
                var aliases = settings.Sources.Where(source => string.Equals(source.Identity, identity, StringComparison.Ordinal)).ToArray();
                return aliases.Length == 0
                    ? [mapping]
                    : aliases.Select(source => new NuGetPackageSourceMapping(source.Name, mapping.Patterns)).ToArray();
            })
        ];
    }
 
    private static void AddPattern(
        Dictionary<string, List<string>> patternsBySourceKey,
        string sourceKey,
        string pattern)
    {
        if (!patternsBySourceKey.TryGetValue(sourceKey, out var patterns))
        {
            patterns = [];
            patternsBySourceKey.Add(sourceKey, patterns);
        }
 
        if (!patterns.Contains(pattern, StringComparer.OrdinalIgnoreCase))
        {
            patterns.Add(pattern);
        }
    }
}