| 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.Resources; using Aspire.Cli.Utils; namespace Aspire.Cli.DotNet; /// <summary> /// Prepares and persists local .NET AppHost configuration changes from shared NuGet 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; } } if (!configuration.HasSourcePolicyChanges && globalPackagesFolder is null) { return null; } var proposedContent = await nuGetService.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 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 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; } }