// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.Packaging;
namespace Aspire.Cli.NuGet;
/// <summary>
/// Combines evaluated ambient NuGet settings with a selected package-source policy.
/// </summary>
internal static class NuGetConfigurationBuilder
{
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);
var sourceKeyPrefix = $"aspire-{workloadId}";
// 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 channelSourceKeys = settings.Sources
.Where(source => !preservedSourceIdentities.Contains(source.Identity) &&
(source.IsCliManaged ||
string.Equals(source.Name, $"aspire-{PackageChannelNames.Daily}", StringComparison.OrdinalIgnoreCase) ||
string.Equals(source.Name, $"aspire-{PackageChannelNames.Staging}", StringComparison.OrdinalIgnoreCase) ||
source.Name.StartsWith("aspire-pr-", StringComparison.OrdinalIgnoreCase) ||
source.Name.StartsWith("aspire-apphost-", StringComparison.OrdinalIgnoreCase) ||
string.Equals(source.Name, sourceKeyPrefix, StringComparison.OrdinalIgnoreCase) ||
source.Name.StartsWith($"{sourceKeyPrefix}-", StringComparison.OrdinalIgnoreCase)))
.Select(static source => source.Name)
.ToHashSet(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 mappings = ResolveAmbientMappingAliases(settings)
.Select(mapping => channelSourceKeys.Contains(mapping.SourceKey)
? mapping with
{
Patterns = [.. mapping.Patterns.Where(static pattern =>
pattern != PackageMapping.AllPackages &&
!PackageSourceOverrideMappings.CompetesWithAuthoritativePattern(
pattern, PackageSourceOverrideMappings.DefaultPackagePattern))]
}
: mapping)
.Where(static mapping => mapping.Patterns.Count > 0)
.ToArray();
var retiredSourceKeys = channelSourceKeys
.Where(key => !mappings.Any(mapping =>
string.Equals(mapping.SourceKey, key, StringComparison.OrdinalIgnoreCase)))
.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 RepairAspireOnlyAppHostMappings(NuGetConfiguration configuration)
{
if (configuration.Overlay is not { PackageSourceMappings.Count: > 0 } overlay ||
configuration.Settings.PackageSourceMappings.Count == 0 ||
configuration.Settings.PackageSourceMappings.SelectMany(static mapping => mapping.Patterns)
.Any(static pattern => !pattern.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase)))
{
return configuration;
}
// Aspire-only mappings also exclude unrelated packages from inherited feeds.
// Repair only unmapped ambient sources for the existing AppHost write approval;
// dynamic restores and explicitly mapped user sources retain their restrictions.
// https://learn.microsoft.com/nuget/consume-packages/package-source-mapping
var excludedKeys = configuration.Settings.PackageSourceMappings.Select(static mapping => mapping.SourceKey)
.Concat(overlay.RetiredSourceKeys)
.Concat(overlay.DisabledPackageSourceKeys)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var fallbackMappings = configuration.Settings.Sources
.Where(source => source.IsEnabled && !source.IsCliManaged && !excludedKeys.Contains(source.Name))
.Select(static source => new NuGetPackageSourceMapping(source.Name, [PackageMapping.AllPackages]))
.ToArray();
if (fallbackMappings.Length == 0)
{
return configuration;
}
return new NuGetConfiguration(configuration.Settings, configuration.ConfigSources, overlay with
{
PackageSourceMappings = [.. overlay.PackageSourceMappings, .. fallbackMappings]
});
}
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);
}
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 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));
}
continue;
}
string key;
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));
}
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);
var retiredSourceKeys = settings.Sources
.Where(source =>
!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 patterns in patternsBySourceKey.Values)
{
patterns.RemoveAll(pattern => authoritativePatterns.Any(
authoritativePattern => PackageSourceOverrideMappings.CompetesWithAuthoritativePattern(
pattern,
authoritativePattern)));
}
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);
}
}
}