|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Xml.Linq;
using System.Diagnostics.CodeAnalysis;
namespace Aspire.Cli.Packaging;
internal class NuGetConfigMerger
{
private sealed record NuGetConfigContext
{
public required XDocument Document { get; init; }
public required XElement Configuration { get; init; }
public required XElement PackageSources { get; init; }
public XElement? PackageSourceMapping { get; init; }
public required PackageMapping[] Mappings { get; init; }
public required string[] RequiredSources { get; init; }
public required XElement[] ExistingAdds { get; init; }
public required Dictionary<string, string> UrlToExistingKey { get; init; }
}
/// <summary>
/// Creates or updates a NuGet.config file in the specified directory based on the provided <see cref="PackageChannel"/>.
/// For implicit channels (no explicit mappings) this method is a no-op.
/// </summary>
/// <param name="targetDirectory">The directory where the NuGet.config should be created or updated.</param>
/// <param name="channel">The package channel providing mapping information.</param>
public static async Task CreateOrUpdateAsync(DirectoryInfo targetDirectory, PackageChannel channel)
{
ArgumentNullException.ThrowIfNull(targetDirectory);
ArgumentNullException.ThrowIfNull(channel);
// Only explicit channels (with mappings) require a NuGet.config merge/write.
var mappings = channel.Mappings;
if (channel.Type is not PackageChannelType.Explicit || mappings is null || mappings.Length == 0)
{
return;
}
if (!targetDirectory.Exists)
{
targetDirectory.Create();
}
if (!TryFindNuGetConfigInDirectory(targetDirectory, out var nugetConfigFile))
{
await CreateNewNuGetConfigAsync(targetDirectory, mappings);
}
else
{
await UpdateExistingNuGetConfigAsync(nugetConfigFile, mappings);
}
}
private static async Task CreateNewNuGetConfigAsync(DirectoryInfo targetDirectory, PackageMapping[] mappings)
{
if (mappings.Length == 0)
{
return;
}
var targetPath = Path.Combine(targetDirectory.FullName, "NuGet.config");
using var tmpConfig = await TemporaryNuGetConfig.CreateAsync(mappings);
File.Copy(tmpConfig.ConfigFile.FullName, targetPath, overwrite: true);
}
private static async Task UpdateExistingNuGetConfigAsync(FileInfo nugetConfigFile, PackageMapping[]? mappings)
{
if (mappings is null || mappings.Length == 0)
{
return;
}
var configContext = await LoadAndValidateConfigAsync(nugetConfigFile, mappings);
AddMissingPackageSources(configContext);
if (configContext.PackageSourceMapping is not null)
{
UpdateExistingPackageSourceMapping(configContext);
}
else
{
CreateNewPackageSourceMapping(configContext);
}
await SaveConfigAsync(nugetConfigFile, configContext.Document);
}
private static async Task<NuGetConfigContext> LoadAndValidateConfigAsync(FileInfo nugetConfigFile, PackageMapping[] mappings)
{
// Get the required sources from mappings
var requiredSources = mappings
.Select(m => m.Source)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// Load the existing NuGet.config
XDocument doc;
await using (var stream = nugetConfigFile.OpenRead())
{
doc = XDocument.Load(stream);
}
var configuration = doc.Root ?? new XElement("configuration");
if (doc.Root is null)
{
doc.Add(configuration);
}
var packageSources = configuration.Element("packageSources");
if (packageSources is null)
{
packageSources = new XElement("packageSources");
configuration.Add(packageSources);
}
var existingAdds = packageSources.Elements("add").ToArray();
var urlToExistingKey = BuildExistingSourceMappings(existingAdds);
return new NuGetConfigContext
{
Document = doc,
Configuration = configuration,
PackageSources = packageSources,
PackageSourceMapping = configuration.Element("packageSourceMapping"),
Mappings = mappings,
RequiredSources = requiredSources,
ExistingAdds = existingAdds,
UrlToExistingKey = urlToExistingKey
};
}
private static Dictionary<string, string> BuildExistingSourceMappings(XElement[] existingAdds)
{
var urlToExistingKey = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var addElement in existingAdds)
{
var key = (string?)addElement.Attribute("key");
var value = (string?)addElement.Attribute("value");
if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value))
{
urlToExistingKey[value] = key;
}
}
return urlToExistingKey;
}
private static void AddMissingPackageSources(NuGetConfigContext context)
{
var existingValues = new HashSet<string>(context.ExistingAdds
.Select(e => (string?)e.Attribute("value") ?? string.Empty), StringComparer.OrdinalIgnoreCase);
var existingKeys = new HashSet<string>(context.ExistingAdds
.Select(e => (string?)e.Attribute("key") ?? string.Empty), StringComparer.OrdinalIgnoreCase);
var missingSources = context.RequiredSources
.Where(s => !existingValues.Contains(s) && !existingKeys.Contains(s))
.ToArray();
// Add missing sources
foreach (var source in missingSources)
{
// Use the source URL as both key and value for consistency with our temporary config
var add = new XElement("add");
add.SetAttributeValue("key", source);
add.SetAttributeValue("value", source);
context.PackageSources.Add(add);
}
}
private static void UpdateExistingPackageSourceMapping(NuGetConfigContext context)
{
var packageSourceMapping = context.PackageSourceMapping!;
// Create a lookup of patterns to new sources from the mappings
var patternToNewSource = context.Mappings.ToDictionary(m => m.PackageFilter, m => m.Source, StringComparer.OrdinalIgnoreCase);
// Track sources that still have packages after remapping
var sourcesInUse = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var patternsToAdd = RemapExistingPatterns(packageSourceMapping, patternToNewSource, context.UrlToExistingKey, sourcesInUse);
AddRemappedPatterns(packageSourceMapping, patternsToAdd, context.UrlToExistingKey, sourcesInUse);
AddNewPatterns(packageSourceMapping, context, sourcesInUse);
FixUrlBasedPackageSourceKeys(packageSourceMapping, context.UrlToExistingKey, sourcesInUse);
HandleWildcardMappingForExistingSources(packageSourceMapping, context, sourcesInUse);
RemoveEmptyPackageSourceElements(packageSourceMapping, context.PackageSources, context.UrlToExistingKey, sourcesInUse);
}
private static List<(string pattern, string newSource)> RemapExistingPatterns(
XElement packageSourceMapping,
Dictionary<string, string> patternToNewSource,
Dictionary<string, string> urlToExistingKey,
HashSet<string> sourcesInUse)
{
// First pass: Remove patterns that need to be remapped and track what needs to be added
var patternsToAdd = new List<(string pattern, string newSource)>();
var packageSourceElements = packageSourceMapping.Elements("packageSource").ToArray();
foreach (var packageSourceElement in packageSourceElements)
{
var sourceKey = (string?)packageSourceElement.Attribute("key");
if (string.IsNullOrEmpty(sourceKey))
{
continue;
}
var packageElements = packageSourceElement.Elements("package").ToArray();
var elementsToRemove = new List<XElement>();
foreach (var packageElement in packageElements)
{
var pattern = (string?)packageElement.Attribute("pattern");
if (string.IsNullOrEmpty(pattern))
{
continue;
}
// Check if this pattern needs to be remapped to a new source
if (patternToNewSource.TryGetValue(pattern, out var newSource))
{
// Determine the key that will be used for the new source
var expectedKey = urlToExistingKey.TryGetValue(newSource, out var existingKey) ? existingKey : newSource;
if (!string.Equals(sourceKey, expectedKey, StringComparison.OrdinalIgnoreCase))
{
// This pattern needs to be moved to the new source
elementsToRemove.Add(packageElement);
patternsToAdd.Add((pattern, newSource));
}
}
// If the pattern is not defined in the new mappings, only remove it in specific cases
else if (!patternToNewSource.ContainsKey(pattern))
{
// Get the source URL to check if this source should keep obsolete patterns
var sourceElement = urlToExistingKey.FirstOrDefault(kvp => string.Equals(kvp.Value, sourceKey, StringComparison.OrdinalIgnoreCase));
var sourceValue = sourceElement.Key ?? sourceKey;
// Only remove patterns that are not in the new mappings if:
// 1. The source is safe to remove (like a PR hive) AND the pattern is Aspire-related, OR
// 2. The source is Microsoft-controlled AND the pattern is Aspire-related AND not a wildcard
// This preserves user-defined patterns like "Microsoft.Extensions.SpecialPackage*"
var isAspireRelatedPattern = IsAspireRelatedPattern(pattern);
if ((IsSourceSafeToRemove(sourceKey, sourceValue) && isAspireRelatedPattern) ||
(IsMicrosoftControlledSource(sourceKey, sourceValue) && isAspireRelatedPattern && pattern != "*"))
{
elementsToRemove.Add(packageElement);
}
}
}
// Remove patterns that need to be moved
foreach (var element in elementsToRemove)
{
element.Remove();
}
// If this source still has packages after removal, mark it as in use
if (packageSourceElement.Elements("package").Any())
{
sourcesInUse.Add(sourceKey);
}
}
return patternsToAdd;
}
private static void AddRemappedPatterns(
XElement packageSourceMapping,
List<(string pattern, string newSource)> patternsToAdd,
Dictionary<string, string> urlToExistingKey,
HashSet<string> sourcesInUse)
{
// Second pass: Group patterns by source and add them all to the same packageSource element
var patternsBySource = patternsToAdd.GroupBy(x => x.newSource, StringComparer.OrdinalIgnoreCase);
foreach (var sourceGroup in patternsBySource)
{
var newSource = sourceGroup.Key;
// Use existing key if available, otherwise use the source URL as key
var keyToUse = urlToExistingKey.TryGetValue(newSource, out var existingKey) ? existingKey : newSource;
// Find or create the packageSource element for this source using the appropriate key
var targetSourceElement = packageSourceMapping.Elements("packageSource")
.FirstOrDefault(ps => string.Equals((string?)ps.Attribute("key"), keyToUse, StringComparison.OrdinalIgnoreCase));
if (targetSourceElement is null)
{
// Create new packageSource element for this source using the appropriate key
targetSourceElement = new XElement("packageSource");
targetSourceElement.SetAttributeValue("key", keyToUse);
packageSourceMapping.Add(targetSourceElement);
}
// Add all patterns for this source
foreach (var (pattern, _) in sourceGroup)
{
// Check if this pattern already exists in the target source
var existingPattern = targetSourceElement.Elements("package")
.FirstOrDefault(p => string.Equals((string?)p.Attribute("pattern"), pattern, StringComparison.OrdinalIgnoreCase));
if (existingPattern is null)
{
// Add the package pattern to the target source
var packageElement = new XElement("package");
packageElement.SetAttributeValue("pattern", pattern);
targetSourceElement.Add(packageElement);
}
}
sourcesInUse.Add(keyToUse);
}
}
private static void AddNewPatterns(
XElement packageSourceMapping,
NuGetConfigContext context,
HashSet<string> sourcesInUse)
{
// Find patterns from mappings that don't exist anywhere in the current packageSourceMapping
var existingPatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var packageSourceElement in packageSourceMapping.Elements("packageSource"))
{
foreach (var packageElement in packageSourceElement.Elements("package"))
{
var pattern = (string?)packageElement.Attribute("pattern");
if (!string.IsNullOrEmpty(pattern))
{
existingPatterns.Add(pattern);
}
}
}
// Group new patterns by their target source
var newPatternsBySource = context.Mappings
.Where(m => !existingPatterns.Contains(m.PackageFilter))
.GroupBy(m => m.Source, StringComparer.OrdinalIgnoreCase);
foreach (var sourceGroup in newPatternsBySource)
{
var targetSource = sourceGroup.Key;
// Use existing key if available, otherwise use the source URL as key
var keyToUse = context.UrlToExistingKey.TryGetValue(targetSource, out var existingKey) ? existingKey : targetSource;
// Find or create the packageSource element for this source
var targetSourceElement = packageSourceMapping.Elements("packageSource")
.FirstOrDefault(ps => string.Equals((string?)ps.Attribute("key"), keyToUse, StringComparison.OrdinalIgnoreCase));
if (targetSourceElement is null)
{
// Create new packageSource element for this source
targetSourceElement = new XElement("packageSource");
targetSourceElement.SetAttributeValue("key", keyToUse);
packageSourceMapping.Add(targetSourceElement);
}
// Add all new patterns for this source
foreach (var mapping in sourceGroup)
{
// Check if this pattern already exists in the target source (just in case)
var existingPattern = targetSourceElement.Elements("package")
.FirstOrDefault(p => string.Equals((string?)p.Attribute("pattern"), mapping.PackageFilter, StringComparison.OrdinalIgnoreCase));
if (existingPattern is null)
{
// Add the package pattern to the target source
var packageElement = new XElement("package");
packageElement.SetAttributeValue("pattern", mapping.PackageFilter);
targetSourceElement.Add(packageElement);
}
}
sourcesInUse.Add(keyToUse);
}
}
private static void FixUrlBasedPackageSourceKeys(
XElement packageSourceMapping,
Dictionary<string, string> urlToExistingKey,
HashSet<string> sourcesInUse)
{
// Fourth pass: Fix packageSource elements that use URLs as keys when proper keys exist
var packageSourceElementsToFix = packageSourceMapping.Elements("packageSource")
.Where(ps => {
var key = (string?)ps.Attribute("key");
return !string.IsNullOrEmpty(key) && urlToExistingKey.TryGetValue(key, out var properKey) &&
!string.Equals(key, properKey, StringComparison.OrdinalIgnoreCase);
})
.ToArray();
foreach (var elementToFix in packageSourceElementsToFix)
{
var urlKey = (string?)elementToFix.Attribute("key");
if (urlToExistingKey.TryGetValue(urlKey!, out var properKey))
{
// Find if there's already a packageSource with the proper key
var existingProperElement = packageSourceMapping.Elements("packageSource")
.FirstOrDefault(ps => string.Equals((string?)ps.Attribute("key"), properKey, StringComparison.OrdinalIgnoreCase));
if (existingProperElement is not null)
{
// Move all packages from URL-based element to proper key element
var packagesToMove = elementToFix.Elements("package").ToArray();
foreach (var packageToMove in packagesToMove)
{
// Check if the pattern already exists in the target element
var pattern = (string?)packageToMove.Attribute("pattern");
var existingPattern = existingProperElement.Elements("package")
.FirstOrDefault(p => string.Equals((string?)p.Attribute("pattern"), pattern, StringComparison.OrdinalIgnoreCase));
if (existingPattern is null)
{
packageToMove.Remove();
existingProperElement.Add(packageToMove);
}
}
// Remove the URL-based element if it's now empty
if (!elementToFix.Elements("package").Any())
{
elementToFix.Remove();
}
}
else
{
// Just update the key to use the proper key
elementToFix.SetAttributeValue("key", properKey);
sourcesInUse.Add(properKey);
}
}
}
}
private static void HandleWildcardMappingForExistingSources(
XElement packageSourceMapping,
NuGetConfigContext context,
HashSet<string> sourcesInUse)
{
// Check if we have a wildcard pattern being added - if so, add it to unmapped existing sources
var hasWildcardMapping = context.Mappings.Any(m => m.PackageFilter == "*");
if (hasWildcardMapping)
{
// Find all existing sources
var existingSourceKeys = context.ExistingAdds
.Select(add => (string?)add.Attribute("key"))
.Where(key => !string.IsNullOrEmpty(key))
.Cast<string>()
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Find sources that have any package patterns (after all the processing above)
var sourcesWithPatterns = packageSourceMapping.Elements("packageSource")
.Where(ps => ps.Elements("package").Any())
.Select(ps => (string?)ps.Attribute("key"))
.Where(key => !string.IsNullOrEmpty(key))
.Cast<string>()
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var sourcesWithoutAnyPatterns = existingSourceKeys.Except(sourcesWithPatterns, StringComparer.OrdinalIgnoreCase).ToArray();
// Only add wildcard patterns to sources that originally had NO patterns at all
// Sources that had patterns but lost them due to remapping should be removed entirely
// Check the original packageSourceMapping to see which sources had patterns originally
var originalSourcesWithPatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var originalPsm = context.PackageSourceMapping;
if (originalPsm != null)
{
foreach (var ps in originalPsm.Elements("packageSource"))
{
var originalSourceKey = (string?)ps.Attribute("key");
if (!string.IsNullOrEmpty(originalSourceKey) && ps.Elements("package").Any())
{
// Add the original key
originalSourcesWithPatterns.Add(originalSourceKey);
// Also add the proper key if this was a URL-based key
if (context.UrlToExistingKey.TryGetValue(originalSourceKey, out var properKey))
{
originalSourcesWithPatterns.Add(properKey);
}
}
}
}
// Only give wildcard patterns to sources that:
// 1. Have no patterns now
// 2. Originally had no patterns either (were unmapped before) OR still have some patterns left
// 3. Are not safe to remove (user-defined sources)
// 4. Are required by the current channel OR are not Microsoft-controlled sources
foreach (var sourceKey in sourcesWithoutAnyPatterns)
{
// Get the source URL to check if it's safe to give it a wildcard pattern
var sourceElement = context.ExistingAdds
.FirstOrDefault(add => string.Equals((string?)add.Attribute("key"), sourceKey, StringComparison.OrdinalIgnoreCase));
var sourceValue = (string?)sourceElement?.Attribute("value");
// Check if this source is required by the current channel
var isRequiredByCurrentChannel = context.RequiredSources.Contains(sourceKey, StringComparer.OrdinalIgnoreCase) ||
context.RequiredSources.Contains(sourceValue ?? "", StringComparer.OrdinalIgnoreCase);
// For user-defined sources, give them wildcard patterns to remain functional
// Only skip this for sources that we would remove anyway (like PR hives) OR
// Microsoft-controlled sources that are not required by the current channel
if (!IsSourceSafeToRemove(sourceKey, sourceValue) &&
(isRequiredByCurrentChannel || !IsMicrosoftControlledSource(sourceKey, sourceValue)))
{
var packageSourceElement = new XElement("packageSource");
packageSourceElement.SetAttributeValue("key", sourceKey);
var wildcardPackage = new XElement("package");
wildcardPackage.SetAttributeValue("pattern", "*");
packageSourceElement.Add(wildcardPackage);
packageSourceMapping.Add(packageSourceElement);
sourcesInUse.Add(sourceKey);
}
}
// Also give wildcard patterns to sources that still have some patterns left but should remain fully functional
// when there's a wildcard mapping that could interfere with their ability to serve packages
// But only for user-defined sources, not Microsoft-controlled feeds
var sourcesWithPatternsLeft = packageSourceMapping.Elements("packageSource")
.Where(ps => ps.Elements("package").Any() && !ps.Elements("package").Any(p => (string?)p.Attribute("pattern") == "*"))
.Select(ps => (string?)ps.Attribute("key"))
.Where(key => !string.IsNullOrEmpty(key))
.Cast<string>()
.ToArray();
foreach (var sourceKey in sourcesWithPatternsLeft)
{
// Get the source URL to check if it's a user-defined source
var sourceElement = context.ExistingAdds
.FirstOrDefault(add => string.Equals((string?)add.Attribute("key"), sourceKey, StringComparison.OrdinalIgnoreCase));
var sourceValue = (string?)sourceElement?.Attribute("value");
// For user-defined sources that still have patterns, also give them wildcard patterns
// to ensure they can serve other packages too. But skip Microsoft-controlled sources
// that have specific patterns as they are intended to serve specific packages only.
if (!IsSourceSafeToRemove(sourceKey, sourceValue) && !IsMicrosoftControlledSource(sourceKey, sourceValue))
{
var packageSourceElement = packageSourceMapping.Elements("packageSource")
.FirstOrDefault(ps => string.Equals((string?)ps.Attribute("key"), sourceKey, StringComparison.OrdinalIgnoreCase));
if (packageSourceElement != null)
{
var wildcardPackage = new XElement("package");
wildcardPackage.SetAttributeValue("pattern", "*");
packageSourceElement.Add(wildcardPackage);
sourcesInUse.Add(sourceKey);
}
}
}
}
}
private static bool IsMicrosoftControlledSource(string sourceKey, string? sourceValue)
{
var urlToCheck = sourceValue ?? sourceKey;
if (string.IsNullOrEmpty(urlToCheck))
{
return false;
}
// Check if this is a Microsoft/Azure DevOps feed
if (urlToCheck.Contains("pkgs.dev.azure.com"))
{
return true;
}
// Check if this is an official NuGet.org feed
if (urlToCheck.Contains("api.nuget.org"))
{
return true;
}
return false;
}
private static bool IsAspireRelatedPattern(string pattern)
{
if (string.IsNullOrEmpty(pattern))
{
return false;
}
// Patterns that start with "Aspire" or are exactly "Microsoft.Extensions.ServiceDiscovery*" are Aspire-related
// Wildcard patterns are not Aspire-specific
// Other Microsoft.Extensions.* patterns (like "Microsoft.Extensions.SpecialPackage*") are NOT Aspire-related
return pattern.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase) ||
pattern.Equals("Microsoft.Extensions.ServiceDiscovery*", StringComparison.OrdinalIgnoreCase);
}
private static bool IsSourceSafeToRemove(string sourceKey, string? sourceValue)
{
// Only remove sources that we know are tied to Aspire channels or PR hives
if (string.IsNullOrEmpty(sourceKey) && string.IsNullOrEmpty(sourceValue))
{
return false;
}
var urlToCheck = sourceValue ?? sourceKey;
// Check if this is an Aspire PR hive
if (!string.IsNullOrEmpty(urlToCheck) && urlToCheck.Contains(".aspire") && urlToCheck.Contains("hives"))
{
return true;
}
// Only remove very specific Azure DevOps feeds that we know are temporary (like aspire PR feeds)
// Don't remove official .NET feeds or other potentially permanent feeds
if (!string.IsNullOrEmpty(urlToCheck) && urlToCheck.Contains("pkgs.dev.azure.com"))
{
// Only remove if it's specifically an Aspire-related feed
if (urlToCheck.Contains("aspire", StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Be conservative - don't remove other Azure DevOps feeds as they might be official
return false;
}
// Don't remove other sources - they may be user-defined
return false;
}
private static void RemoveEmptyPackageSourceElements(
XElement packageSourceMapping,
XElement packageSources,
Dictionary<string, string> urlToExistingKey,
HashSet<string> sourcesInUse)
{
// Fifth pass: Remove empty packageSource elements and their corresponding sources from packageSources
var emptyPackageSourceElements = packageSourceMapping.Elements("packageSource")
.Where(ps => !ps.Elements("package").Any())
.ToArray();
foreach (var emptyElement in emptyPackageSourceElements)
{
var sourceKey = (string?)emptyElement.Attribute("key");
emptyElement.Remove();
// Remove the corresponding source from packageSources if it's not in use elsewhere
// For empty package source elements, we remove the source regardless of whether it's "safe to remove"
// because an empty package source element means the source is no longer serving any patterns
if (!string.IsNullOrEmpty(sourceKey) && !sourcesInUse.Contains(sourceKey))
{
// Also check if any existing source key maps to this URL (for URL->key mapping scenario)
var isUsedByExistingKey = urlToExistingKey.Any(kvp =>
string.Equals(kvp.Key, sourceKey, StringComparison.OrdinalIgnoreCase) &&
sourcesInUse.Contains(kvp.Value));
if (!isUsedByExistingKey)
{
var sourceToRemove = packageSources.Elements("add")
.FirstOrDefault(add => string.Equals((string?)add.Attribute("key"), sourceKey, StringComparison.OrdinalIgnoreCase) ||
string.Equals((string?)add.Attribute("value"), sourceKey, StringComparison.OrdinalIgnoreCase));
sourceToRemove?.Remove();
}
}
}
}
private static void CreateNewPackageSourceMapping(NuGetConfigContext context)
{
// Create package source mapping section if it doesn't exist
var packageSourceMapping = new XElement("packageSourceMapping");
context.Configuration.Add(packageSourceMapping);
// Group patterns by their target source and add them
var patternsBySource = context.Mappings.GroupBy(m => m.Source, StringComparer.OrdinalIgnoreCase);
foreach (var sourceGroup in patternsBySource)
{
var sourceUrl = sourceGroup.Key;
// Use existing key if available, otherwise use the source URL as key
var keyToUse = context.UrlToExistingKey.TryGetValue(sourceUrl, out var existingKey) ? existingKey : sourceUrl;
var packageSource = new XElement("packageSource");
packageSource.SetAttributeValue("key", keyToUse);
foreach (var mapping in sourceGroup)
{
var packageElement = new XElement("package");
packageElement.SetAttributeValue("pattern", mapping.PackageFilter);
packageSource.Add(packageElement);
}
packageSourceMapping.Add(packageSource);
}
PreserveOriginalSourceFunctionality(packageSourceMapping, context);
}
private static void PreserveOriginalSourceFunctionality(XElement packageSourceMapping, NuGetConfigContext context)
{
// Since we're creating packageSourceMapping for the first time, we need to preserve the original behavior
// where all existing sources could serve all packages. Any existing source that doesn't get specific
// patterns from our mappings should get a wildcard pattern to remain functional.
var existingSourceKeys = context.ExistingAdds
.Select(add => (string?)add.Attribute("key"))
.Where(key => !string.IsNullOrEmpty(key))
.Cast<string>()
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Find sources that have mappings from our new packageSourceMapping entries
var sourcesWithNewMappings = packageSourceMapping.Elements("packageSource")
.Select(ps => (string?)ps.Attribute("key"))
.Where(key => !string.IsNullOrEmpty(key))
.Cast<string>()
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var sourcesWithoutAnyPatterns = existingSourceKeys.Except(sourcesWithNewMappings, StringComparer.OrdinalIgnoreCase).ToArray();
// Add wildcard pattern to existing sources that don't have any patterns to preserve their original functionality
// Only exclude PR hives that are not the current target
foreach (var sourceKey in sourcesWithoutAnyPatterns)
{
// Get the source URL to check if it should get a wildcard pattern
var sourceElement = context.ExistingAdds
.FirstOrDefault(add => string.Equals((string?)add.Attribute("key"), sourceKey, StringComparison.OrdinalIgnoreCase));
var sourceValue = (string?)sourceElement?.Attribute("value");
// Only exclude PR hives and Aspire-specific feeds that are not the current target
if (!IsSourceSafeToRemove(sourceKey, sourceValue))
{
var packageSourceElement = new XElement("packageSource");
packageSourceElement.SetAttributeValue("key", sourceKey);
var wildcardPackage = new XElement("package");
wildcardPackage.SetAttributeValue("pattern", "*");
packageSourceElement.Add(wildcardPackage);
packageSourceMapping.Add(packageSourceElement);
}
}
}
private static async Task SaveConfigAsync(FileInfo nugetConfigFile, XDocument document)
{
await using (var writeStream = nugetConfigFile.Open(FileMode.Create, FileAccess.Write, FileShare.None))
{
document.Save(writeStream);
}
}
/// <summary>
/// Checks if any sources from the mappings are missing from the existing NuGet.config
/// or if package source mappings need to be updated.
/// </summary>
/// <param name="targetDirectory">The directory to check for NuGet.config.</param>
/// <param name="channel">The package channel whose mappings are checked.</param>
/// <returns>True if sources are missing or mappings need updates, false if all sources and mappings are correctly configured.</returns>
public static bool HasMissingSources(DirectoryInfo targetDirectory, PackageChannel channel)
{
ArgumentNullException.ThrowIfNull(targetDirectory);
ArgumentNullException.ThrowIfNull(channel);
var mappings = channel.Mappings;
if (channel.Type is not PackageChannelType.Explicit || mappings is null || mappings.Length == 0)
{
return false; // Implicit channels or empty mappings never require config changes.
}
if (!TryFindNuGetConfigInDirectory(targetDirectory, out var nugetConfigFile))
{
return true; // No config exists, so sources are "missing"
}
var requiredSources = mappings
.Select(m => m.Source)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
try
{
using var stream = nugetConfigFile.OpenRead();
var doc = XDocument.Load(stream);
var packageSources = doc.Root?.Element("packageSources");
if (packageSources is null)
{
return true;
}
var existingAdds = packageSources.Elements("add").ToArray();
var existingValues = new HashSet<string>(existingAdds
.Select(e => (string?)e.Attribute("value") ?? string.Empty), StringComparer.OrdinalIgnoreCase);
var existingKeys = new HashSet<string>(existingAdds
.Select(e => (string?)e.Attribute("key") ?? string.Empty), StringComparer.OrdinalIgnoreCase);
// Create a mapping from source URLs to their existing keys for reuse in package source mappings
var urlToExistingKey = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var addElement in existingAdds)
{
var key = (string?)addElement.Attribute("key");
var value = (string?)addElement.Attribute("value");
if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value))
{
urlToExistingKey[value] = key;
}
}
var missingSources = requiredSources
.Where(s => !existingValues.Contains(s) && !existingKeys.Contains(s))
.ToArray();
// Check if any sources are missing
if (missingSources.Length > 0)
{
return true;
}
// Check if package source mappings need to be updated
var packageSourceMapping = doc.Root?.Element("packageSourceMapping");
if (packageSourceMapping is not null)
{
// Create a lookup of patterns to required sources from the mappings
var patternToRequiredSource = mappings.ToDictionary(m => m.PackageFilter, m => m.Source, StringComparer.OrdinalIgnoreCase);
// Check if any patterns are mapped to the wrong source
var packageSourceElements = packageSourceMapping.Elements("packageSource");
foreach (var packageSourceElement in packageSourceElements)
{
var sourceKey = (string?)packageSourceElement.Attribute("key");
if (string.IsNullOrEmpty(sourceKey))
{
continue;
}
var packageElements = packageSourceElement.Elements("package");
foreach (var packageElement in packageElements)
{
var pattern = (string?)packageElement.Attribute("pattern");
if (string.IsNullOrEmpty(pattern))
{
continue;
}
// Check if this pattern should be mapped to a different source
if (patternToRequiredSource.TryGetValue(pattern, out var requiredSourceUrl))
{
// Use existing key if available, otherwise use the source URL as key
var expectedKey = urlToExistingKey.TryGetValue(requiredSourceUrl, out var existingKey) ? existingKey : requiredSourceUrl;
if (!string.Equals(sourceKey, expectedKey, StringComparison.OrdinalIgnoreCase))
{
return true; // This pattern needs to be remapped
}
}
}
}
}
return false; // All sources and mappings are correctly configured
}
catch
{
// If we can't read the file, assume sources are missing
return true;
}
}
internal static bool TryFindNuGetConfigInDirectory(DirectoryInfo directory, [NotNullWhen(true)] out FileInfo? nugetConfigFile)
{
ArgumentNullException.ThrowIfNull(directory);
// Find all files whose name matches "nuget.config" ignoring case in the top-level directory only
var matches = directory
.EnumerateFiles("*", SearchOption.TopDirectoryOnly)
.Where(f => string.Equals(f.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;
}
}
|