File: NuGet\NuGetClientTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Compression;
using Aspire.Cli.NuGet;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Hosting;
using Microsoft.DotNet.RemoteExecutor;
using Microsoft.Extensions.Logging.Abstractions;
using NuGet.Configuration;
using NuGet.ProjectModel;
using NuGet.Packaging;
using NuGet.Protocol;
 
namespace Aspire.Cli.Tests.NuGet;
 
public class NuGetClientTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task SearchAsync_ReturnsOnlyTheFirstPage()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        CreatePackage(feedDirectory.FullName, "Aspire.Test.Package.One");
        CreatePackage(feedDirectory.FullName, "Aspire.Test.Package.Two");
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        // The aspire-managed helper requested one page per source and never paged further.
        var results = await client.SearchAsync(
            "Aspire.Test.Package",
            prerelease: false,
            take: 1,
            [feedDirectory.FullName],
            nugetConfigPath: null,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
 
        Assert.Single(results);
    }
 
    [Fact]
    public async Task SearchAsync_KeepsOneEntryPerPackageAcrossSources()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var firstFeedDirectory = workspace.CreateDirectory("first-feed");
        var secondFeedDirectory = workspace.CreateDirectory("second-feed");
        CreatePackage(firstFeedDirectory.FullName, "Aspire.Test.Package");
        CreatePackage(secondFeedDirectory.FullName, "Aspire.Test.Package", version: "2.0.0");
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        var results = await client.SearchAsync(
            "Aspire.Test.Package",
            prerelease: false,
            take: 1000,
            [firstFeedDirectory.FullName, secondFeedDirectory.FullName],
            nugetConfigPath: null,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
 
        // Only the winning source's entry survives, so its versions are not merged with the other source's.
        var package = Assert.Single(results);
        Assert.Equal("Aspire.Test.Package", package.Id);
        Assert.Equal("2.0.0", package.Version);
        Assert.Equal(secondFeedDirectory.FullName, package.Source);
        Assert.Equal(["2.0.0"], package.AllVersions);
    }
 
    [Fact]
    public async Task SearchAsync_ComparesVersionsAsStringsAcrossSources()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var firstFeedDirectory = workspace.CreateDirectory("first-feed");
        var secondFeedDirectory = workspace.CreateDirectory("second-feed");
        CreatePackage(firstFeedDirectory.FullName, "Aspire.Test.Package", version: "9.0.0");
        CreatePackage(secondFeedDirectory.FullName, "Aspire.Test.Package", version: "10.0.0");
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        var results = await client.SearchAsync(
            "Aspire.Test.Package",
            prerelease: false,
            take: 1000,
            [firstFeedDirectory.FullName, secondFeedDirectory.FullName],
            nugetConfigPath: null,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
 
        // The aspire-managed helper picked the entry with the highest version *string*, so "9.0.0" wins over
        // "10.0.0". This pins that behavior so a change to it is a deliberate decision rather than a side effect.
        var package = Assert.Single(results);
        Assert.Equal("9.0.0", package.Version);
        Assert.Equal(firstFeedDirectory.FullName, package.Source);
    }
 
    [Fact]
    public async Task SearchAsync_FallsBackToDiscoveryWhenConfigFileIsMissing()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        CreatePackage(feedDirectory.FullName, "Aspire.Test.Package");
        File.WriteAllText(
            Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"),
            $"""
            <configuration>
              <packageSources>
                <clear />
                <add key="local" value="{feedDirectory.FullName}" />
              </packageSources>
            </configuration>
            """);
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        var results = await client.SearchAsync(
            "Aspire.Test.Package",
            prerelease: false,
            take: 1000,
            [],
            nugetConfigPath: Path.Combine(workspace.WorkspaceRoot.FullName, "missing", "nuget.config"),
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
 
        var package = Assert.Single(results);
        Assert.Equal("Aspire.Test.Package", package.Id);
        Assert.Equal(feedDirectory.FullName, package.Source);
    }
 
    [Fact]
    public async Task SearchAsync_ReturnsResultsWhenOneSourceFails()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        CreatePackage(feedDirectory.FullName, "Aspire.Test.Package");
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        var results = await client.SearchAsync(
            "Aspire.Test.Package",
            prerelease: false,
            take: 100,
            ["https://127.0.0.1:1/v3/index.json", feedDirectory.FullName],
            nugetConfigPath: null,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
 
        var package = Assert.Single(results);
        Assert.Equal("Aspire.Test.Package", package.Id);
    }
 
    [Fact]
    public async Task RestoreAsync_FailureReportsHelperOutput()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Missing.{Guid.NewGuid():N}";
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(
            nugetConfigPath,
            $"""
            <configuration>
              <config>
                <add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
              </config>
              <packageSources>
                <clear />
                <add key="local" value="{feedDirectory.FullName}" />
              </packageSources>
            </configuration>
            """);
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        var exception = await Assert.ThrowsAsync<NuGetOperationException>(() => client.RestoreAsync(
            [(packageId, "[1.0.0]")],
            "net10.0",
            runtimeIdentifier: null,
            restoreDirectory.FullName,
            [],
            nugetConfigPath,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken));
 
        // Without debug logging the helper wrote only NuGet's warnings and errors, prefixed the way its logger
        // prefixed them, followed by its own summary of the failed restore.
        var lines = exception.Output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
        Assert.Contains(lines, line => line.StartsWith("ERROR: ", StringComparison.Ordinal) && line.Contains(packageId, StringComparison.OrdinalIgnoreCase));
        Assert.Contains(lines, line => line.StartsWith("Error: Restore failed: ", StringComparison.Ordinal));
    }
 
    // The tests below observe process-wide state -- the real environment and NuGet's static credential service -- so
    // each runs in its own process, where no other test's operation can overlap it.
 
    [Fact]
    public void RestoreAsync_RestoresSignatureVerificationVariableAfterSuccess()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(feedDirectory.FullName, packageId);
        var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, workspace.CreateDirectory("packages"));
 
        RemoteExecutor.Invoke(
            static async (packageId, configPath, restorePath, workingDirectory) =>
            {
                Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null);
                var client = new NuGetClient(new TestFeatures(), TestEnvironment.CreateLinux(), NullLogger<NuGetClient>.Instance);
 
                await client.RestoreAsync(
                    [(packageId, "[1.0.0]")],
                    "net10.0",
                    runtimeIdentifier: null,
                    restorePath,
                    [],
                    configPath,
                    workingDirectory,
                    CancellationToken.None);
 
                Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification));
            },
            packageId,
            nugetConfigPath,
            workspace.CreateDirectory("restore").FullName,
            workspace.WorkspaceRoot.FullName).Dispose();
    }
 
    [Fact]
    public void RestoreAsync_RestoresSignatureVerificationVariableAfterFailure()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var nugetConfigPath = CreateLocalFeedConfig(workspace, workspace.CreateDirectory("feed"), workspace.CreateDirectory("packages"));
 
        RemoteExecutor.Invoke(
            static async (configPath, restorePath, workingDirectory) =>
            {
                Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null);
                var client = new NuGetClient(new TestFeatures(), TestEnvironment.CreateLinux(), NullLogger<NuGetClient>.Instance);
 
                await Assert.ThrowsAsync<NuGetOperationException>(() => client.RestoreAsync(
                    [($"Aspire.Test.Missing.{Guid.NewGuid():N}", "[1.0.0]")],
                    "net10.0",
                    runtimeIdentifier: null,
                    restorePath,
                    [],
                    configPath,
                    workingDirectory,
                    CancellationToken.None));
 
                Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification));
            },
            nugetConfigPath,
            workspace.CreateDirectory("restore").FullName,
            workspace.WorkspaceRoot.FullName).Dispose();
    }
 
    [Fact]
    public void RestoreAsync_RestoresSignatureVerificationVariableAfterCancellation()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(feedDirectory.FullName, packageId);
        var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, workspace.CreateDirectory("packages"));
 
        RemoteExecutor.Invoke(
            static async (packageId, configPath, restorePath, workingDirectory) =>
            {
                Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null);
                var client = new NuGetClient(new TestFeatures(), TestEnvironment.CreateLinux(), NullLogger<NuGetClient>.Instance);
                using var cancellationSource = new CancellationTokenSource();
                cancellationSource.Cancel();
 
                await Assert.ThrowsAnyAsync<OperationCanceledException>(() => client.RestoreAsync(
                    [(packageId, "[1.0.0]")],
                    "net10.0",
                    runtimeIdentifier: null,
                    restorePath,
                    [],
                    configPath,
                    workingDirectory,
                    cancellationSource.Token));
 
                Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification));
            },
            packageId,
            nugetConfigPath,
            workspace.CreateDirectory("restore").FullName,
            workspace.WorkspaceRoot.FullName).Dispose();
    }
 
    [Fact]
    public void BeginOperation_ResetsNuGetStateWhenLastOverlappingOperationEnds()
    {
        RemoteExecutor.Invoke(static () =>
        {
            var client = new NuGetClient(new TestFeatures(), new TestEnvironment(), NullLogger<NuGetClient>.Instance);
 
            var first = client.BeginOperation();
            var second = client.BeginOperation();
            Assert.NotNull(HttpHandlerResourceV3.CredentialService);
 
            // Ending one of two overlapping operations, even twice, must not reset state the other is still using.
            first.Dispose();
            first.Dispose();
            Assert.NotNull(HttpHandlerResourceV3.CredentialService);
 
            second.Dispose();
            Assert.Null(HttpHandlerResourceV3.CredentialService);
 
            // The next operation sets the credential service up again after the reset.
            using (client.BeginOperation())
            {
                Assert.NotNull(HttpHandlerResourceV3.CredentialService);
            }
 
            Assert.Null(HttpHandlerResourceV3.CredentialService);
        }).Dispose();
    }
 
    [Fact]
    public void SearchAsync_ResetsNuGetStateWhenComplete()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        CreatePackage(feedDirectory.FullName, "Aspire.Test.Package");
 
        RemoteExecutor.Invoke(
            static async (feedPath, workingDirectory) =>
            {
                var client = new NuGetClient(new TestFeatures(), new TestEnvironment(), NullLogger<NuGetClient>.Instance);
 
                var results = await client.SearchAsync(
                    "Aspire.Test.Package",
                    prerelease: false,
                    take: 1000,
                    [feedPath],
                    nugetConfigPath: null,
                    workingDirectory,
                    CancellationToken.None);
 
                Assert.Single(results);
                Assert.Null(HttpHandlerResourceV3.CredentialService);
            },
            feedDirectory.FullName,
            workspace.WorkspaceRoot.FullName).Dispose();
    }
 
    [Fact]
    public async Task RestoreAndWriteManifestAsync_UsesLocalPackageRuntimeAssets()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(feedDirectory.FullName, packageId);
 
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(
            nugetConfigPath,
            $"""
            <configuration>
              <config>
                <add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
              </config>
              <packageSources>
                <clear />
                <add key="local" value="{feedDirectory.FullName}" />
              </packageSources>
            </configuration>
            """);
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        string? packageRoot = null;
        try
        {
            await client.RestoreAsync(
                [(packageId, "[1.0.0]")],
                "net10.0",
                "win-x64",
                restoreDirectory.FullName,
                [],
                nugetConfigPath,
                workspace.WorkspaceRoot.FullName,
                TestContext.Current.CancellationToken);
            var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
                nugetConfigPath, workspace.WorkspaceRoot.FullName);
            packageRoot = Path.GetDirectoryName(restoredPackages[0].InstallPath);
            var manifestPath = Path.Combine(restoreDirectory.FullName, IntegrationPackageProbeManifest.FileName);
            await client.WriteManifestAsync(
                Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName),
                manifestPath,
                "net10.0",
                "win-x64",
                TestContext.Current.CancellationToken);
 
            // The manifest points at assets in the global packages folder; older CLIs copied them into a libs
            // directory beside it instead.
            Assert.False(Directory.Exists(Path.Combine(restoreDirectory.FullName, "libs")));
            var manifest = IntegrationPackageProbeManifest.Load(manifestPath);
            Assert.Equal(
                Path.Combine(
                    restoredPackages[0].InstallPath,
                    "runtimes",
                    "win-x64",
                    "lib",
                    "net10.0",
                    "Aspire.Test.Package.dll"),
                manifest.TryGetManagedAssemblyPath(new("Aspire.Test.Package")));
            Assert.EndsWith(
                Path.Combine("runtimes", "win-x64", "lib", "net10.0", "RuntimeOnly.dll"),
                manifest.TryGetManagedAssemblyPath(new("RuntimeOnly")),
                StringComparison.OrdinalIgnoreCase);
            Assert.EndsWith(
                Path.Combine("runtimes", "win-x64", "lib", "net10.0", "Neutral.resources.dll"),
                manifest.TryGetManagedAssemblyPath(new("Neutral.resources")),
                StringComparison.OrdinalIgnoreCase);
            Assert.EndsWith(
                Path.Combine("runtimes", "win-x64", "lib", "net10.0", "fr", "RuntimeOnly.resources.dll"),
                manifest.TryGetManagedAssemblyPath(new("RuntimeOnly.resources, Culture=fr")),
                StringComparison.OrdinalIgnoreCase);
            Assert.EndsWith(
                Path.Combine("runtimes", "win-x64", "lib", "net10.0", "de", "RuntimeOnly.resources.dll"),
                manifest.TryGetManagedAssemblyPath(new("RuntimeOnly.resources, Culture=de")),
                StringComparison.OrdinalIgnoreCase);
            Assert.Single(manifest.GetNativeLibraryPaths("native-test"));
        }
        finally
        {
            if (packageRoot is not null)
            {
                Directory.Delete(packageRoot, recursive: true);
            }
        }
    }
 
    [Fact]
    public void RestoreAsync_RespectsNuGetPackagesEnvironmentVariable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var environmentPackagesDirectory = workspace.CreateDirectory("env-packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(feedDirectory.FullName, packageId);
 
        // No globalPackagesFolder entry: this covers the environment override specifically, which is
        // resolved by NuGet's settings rather than by anything this client passes explicitly.
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(
            nugetConfigPath,
            $"""
            <configuration>
              <packageSources>
                <clear />
                <add key="local" value="{feedDirectory.FullName}" />
              </packageSources>
            </configuration>
            """);
 
        // The restore runs in a child process because NUGET_PACKAGES is read once when NuGet's
        // settings are first loaded, and the test host has its own value pointing at a shared cache.
        // The variable is set both on the child's start info and again inside the child: passing it
        // through start info alone did not reach the child reliably when other classes ran alongside
        // this one.
        var options = new RemoteInvokeOptions();
        options.StartInfo.Environment["NUGET_PACKAGES"] = environmentPackagesDirectory.FullName;
 
        RemoteExecutor.Invoke(
            static async (packageId, configPath, restorePath, workingDirectory, packagesDirectory) =>
            {
                Environment.SetEnvironmentVariable("NUGET_PACKAGES", packagesDirectory);
 
                var client = new NuGetClient(
                    new TestFeatures(),
                    new TestEnvironment(),
                    NullLogger<NuGetClient>.Instance);
 
                await client.RestoreAsync(
                    [(packageId, "[1.0.0]")],
                    "net10.0",
                    runtimeIdentifier: null,
                    restorePath,
                    [],
                    configPath,
                    workingDirectory,
                    CancellationToken.None);
            },
            packageId,
            nugetConfigPath,
            restoreDirectory.FullName,
            workspace.WorkspaceRoot.FullName,
            environmentPackagesDirectory.FullName,
            options).Dispose();
 
        // NuGet records the resolved packages folder in the assets file, which is also what feeds
        // the restore cache key, so assert on it rather than only on the extracted files.
        var assets = new LockFileFormat().Read(Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName));
        Assert.Contains(
            assets.PackageFolders,
            folder => string.Equals(
                Path.TrimEndingDirectorySeparator(folder.Path),
                Path.TrimEndingDirectorySeparator(environmentPackagesDirectory.FullName),
                StringComparison.OrdinalIgnoreCase));
 
        // The package must actually land under the override, not merely be referenced from it.
        Assert.True(
            Directory.Exists(Path.Combine(environmentPackagesDirectory.FullName, packageId.ToLowerInvariant(), "1.0.0")),
            $"Expected '{packageId}' to be installed under the NUGET_PACKAGES directory.");
    }
 
    [Fact]
    public void RestoreAsync_RespectsNuGetConfigGlobalPackagesFolder()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var configPackagesDirectory = workspace.CreateDirectory("config-packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(feedDirectory.FullName, packageId);
        var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, configPackagesDirectory);
 
        // NUGET_PACKAGES takes precedence over globalPackagesFolder, and the test host often sets it to a shared
        // cache, so the restore runs in a child process without it. The variable is cleared inside the child
        // as well, for the same reason the NUGET_PACKAGES test sets it there.
        var options = new RemoteInvokeOptions();
        options.StartInfo.Environment.Remove("NUGET_PACKAGES");
 
        RemoteExecutor.Invoke(
            static async (packageId, configPath, restorePath, workingDirectory) =>
            {
                Environment.SetEnvironmentVariable("NUGET_PACKAGES", null);
 
                var client = new NuGetClient(
                    new TestFeatures(),
                    new TestEnvironment(),
                    NullLogger<NuGetClient>.Instance);
 
                await client.RestoreAsync(
                    [(packageId, "[1.0.0]")],
                    "net10.0",
                    runtimeIdentifier: null,
                    restorePath,
                    [],
                    configPath,
                    workingDirectory,
                    CancellationToken.None);
            },
            packageId,
            nugetConfigPath,
            restoreDirectory.FullName,
            workspace.WorkspaceRoot.FullName,
            options).Dispose();
 
        var assets = new LockFileFormat().Read(Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName));
        Assert.Contains(
            assets.PackageFolders,
            folder => string.Equals(
                Path.TrimEndingDirectorySeparator(folder.Path),
                Path.TrimEndingDirectorySeparator(configPackagesDirectory.FullName),
                StringComparison.OrdinalIgnoreCase));
        Assert.True(
            Directory.Exists(Path.Combine(configPackagesDirectory.FullName, packageId.ToLowerInvariant(), "1.0.0")),
            $"Expected '{packageId}' to be installed under the configured globalPackagesFolder.");
    }
 
    [Fact]
    public async Task RestoreAndWriteManifestAsync_UsesRuntimeGraphFallbackAssets()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(
            feedDirectory.FullName,
            packageId,
            additionalEntries: new Dictionary<string, string>
            {
                ["runtimes/unix-x64/lib/net10.0/UnixFallback.dll"] = "unix-fallback"
            });
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
        using var restoredPackageScope = new RestoredPackageScope(
            GetEffectiveGlobalPackagesFolder(nugetConfigPath: null, workspace.WorkspaceRoot.FullName),
            packageId);
 
        await client.RestoreAsync(
            [(packageId, "1.0.0")],
            "net10.0",
            "linux-x64",
            restoreDirectory.FullName,
            [feedDirectory.FullName],
            nugetConfigPath: null,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
        var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
            nugetConfigPath: null, workspace.WorkspaceRoot.FullName);
        Assert.NotEmpty(restoredPackages);
        var manifestPath = Path.Combine(restoreDirectory.FullName, IntegrationPackageProbeManifest.FileName);
        await client.WriteManifestAsync(
            Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName),
            manifestPath,
            "net10.0",
            "linux-x64",
            TestContext.Current.CancellationToken);
 
        var manifest = IntegrationPackageProbeManifest.Load(manifestPath);
        Assert.EndsWith(
            Path.Combine("runtimes", "unix-x64", "lib", "net10.0", "UnixFallback.dll"),
            manifest.TryGetManagedAssemblyPath(new("UnixFallback")),
            StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task RestoreAndWriteManifestAsync_WritesCanonicalPackageIdForLowercaseRequest()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(
            feedDirectory.FullName,
            packageId,
            additionalEntries: new Dictionary<string, string>
            {
                ["lib/net10.0/Aspire.Test.Package.xml"] = "<doc />"
            });
        var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, packagesDirectory);
        using var restoredPackageScope = new RestoredPackageScope(
            GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
            packageId);
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        // Package IDs are case-insensitive, so a request may use any casing. The manifest's package ID must
        // come from the restored package, not from the request.
        await client.RestoreAsync(
            [(packageId.ToLowerInvariant(), "1.0.0")],
            "net10.0",
            runtimeIdentifier: null,
            restoreDirectory.FullName,
            [],
            nugetConfigPath,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
        var restoredPackage = Assert.Single(ReadRestoredPackages(restoreDirectory.FullName,
            nugetConfigPath, workspace.WorkspaceRoot.FullName));
        var manifestPath = Path.Combine(restoreDirectory.FullName, IntegrationPackageProbeManifest.FileName);
        await client.WriteManifestAsync(
            Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName),
            manifestPath,
            "net10.0",
            runtimeIdentifier: null,
            TestContext.Current.CancellationToken);
 
        // Without a runtime identifier, the assets selected depend on the RID of the machine running the test,
        // so check every entry rather than a fixed list.
        var manifest = IntegrationPackageProbeManifest.Load(manifestPath);
        Assert.NotEmpty(manifest.ManagedAssemblies);
        Assert.All(manifest.ManagedAssemblies, assembly =>
        {
            Assert.Equal(packageId, assembly.PackageId);
            Assert.StartsWith(restoredPackage.InstallPath, assembly.Path, StringComparison.OrdinalIgnoreCase);
            Assert.EndsWith(".dll", assembly.Path, StringComparison.OrdinalIgnoreCase);
        });
    }
 
    [Fact]
    public async Task RestoreAsync_AppendsExplicitSourcesToConfiguredSources()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var configuredFeed = workspace.CreateDirectory("configured-feed");
        var explicitFeed = workspace.CreateDirectory("explicit-feed");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var configuredPackageId = $"Aspire.Test.Configured.{Guid.NewGuid():N}";
        var explicitPackageId = $"Aspire.Test.Explicit.{Guid.NewGuid():N}";
        CreatePackage(configuredFeed.FullName, configuredPackageId);
        CreatePackage(explicitFeed.FullName, explicitPackageId);
        var nugetConfigPath = CreateLocalFeedConfig(workspace, configuredFeed, packagesDirectory);
        using var restoredPackageScope = new RestoredPackageScope(
            GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
            configuredPackageId,
            explicitPackageId);
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        // Each package exists on only one feed, so the restore succeeds only if the explicit source is used in
        // addition to the configured one rather than in place of it.
        await client.RestoreAsync(
            [(configuredPackageId, "1.0.0"), (explicitPackageId, "1.0.0")],
            "net10.0",
            runtimeIdentifier: null,
            restoreDirectory.FullName,
            [explicitFeed.FullName],
            nugetConfigPath,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
        var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
            nugetConfigPath, workspace.WorkspaceRoot.FullName);
 
        Assert.Equal(
            [configuredPackageId, explicitPackageId],
            restoredPackages.Select(package => package.Id).Order(StringComparer.Ordinal).ToArray());
    }
 
    [Fact]
    public async Task RestoreAsync_HonorsPackageSourceMapping()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var firstFeed = workspace.CreateDirectory("first-feed");
        var mappedFeed = workspace.CreateDirectory("mapped-feed");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(firstFeed.FullName, packageId, "wrong-source");
        CreatePackage(mappedFeed.FullName, packageId, "mapped-source");
 
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(
            nugetConfigPath,
            $"""
            <configuration>
              <config>
                <add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
              </config>
              <packageSources>
                <clear />
                <add key="first" value="{firstFeed.FullName}" />
                <add key="mapped" value="{mappedFeed.FullName}" />
              </packageSources>
              <packageSourceMapping>
                <packageSource key="first">
                  <package pattern="Other.*" />
                </packageSource>
                <packageSource key="mapped">
                  <package pattern="Aspire.Test.Package.*" />
                </packageSource>
              </packageSourceMapping>
            </configuration>
            """);
        using var restoredPackageScope = new RestoredPackageScope(
            GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
            packageId);
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        await client.RestoreAsync(
            [(packageId, "1.0.0")],
            "net10.0",
            runtimeIdentifier: null,
            restoreDirectory.FullName,
            [],
            nugetConfigPath,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
        var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
            nugetConfigPath, workspace.WorkspaceRoot.FullName);
 
        var restoredPackage = Assert.Single(restoredPackages);
        Assert.Equal(
            "mapped-source",
            await File.ReadAllTextAsync(
                Path.Combine(restoredPackage.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll"),
                TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task RestoreAsync_PrefersAvailableMappedSourceOverExplicitSource()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var mappedFeed = workspace.CreateDirectory("mapped-feed");
        var explicitFeed = workspace.CreateDirectory("explicit-feed");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(mappedFeed.FullName, packageId, "mapped-source");
        CreatePackage(explicitFeed.FullName, packageId, "explicit-source");
 
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(
            nugetConfigPath,
            $"""
            <configuration>
              <config>
                <add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
              </config>
              <packageSources>
                <clear />
                <add key="mapped" value="{mappedFeed.FullName}" />
              </packageSources>
              <packageSourceMapping>
                <packageSource key=".">
                  <package pattern="Aspire.Test.Package.*" />
                </packageSource>
                <packageSource key="mapped">
                  <package pattern="Aspire.Test.Package.*" />
                </packageSource>
              </packageSourceMapping>
            </configuration>
            """);
        using var restoredPackageScope = new RestoredPackageScope(
            GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
            packageId);
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        await client.RestoreAsync(
            [(packageId, "1.0.0")],
            "net10.0",
            runtimeIdentifier: null,
            restoreDirectory.FullName,
            [explicitFeed.FullName],
            nugetConfigPath,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
        var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
            nugetConfigPath, workspace.WorkspaceRoot.FullName);
 
        var restoredPackage = Assert.Single(restoredPackages);
        Assert.Equal(
            "mapped-source",
            await File.ReadAllTextAsync(
                Path.Combine(restoredPackage.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll"),
                TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task RestoreAsync_RejectsExplicitSourceWhenPackageHasNoMapping()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var explicitFeed = workspace.CreateDirectory("explicit-feed");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(explicitFeed.FullName, packageId);
 
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(
            nugetConfigPath,
            $"""
            <configuration>
              <config>
                <add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
              </config>
              <packageSources>
                <clear />
              </packageSources>
              <packageSourceMapping>
                <packageSource key=".">
                  <package pattern="Other.*" />
                </packageSource>
              </packageSourceMapping>
            </configuration>
            """);
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        var exception = await Assert.ThrowsAsync<NuGetOperationException>(() => client.RestoreAsync(
            [(packageId, "1.0.0")],
            "net10.0",
            runtimeIdentifier: null,
            restoreDirectory.FullName,
            [explicitFeed.FullName],
            nugetConfigPath,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken));
 
        Assert.Contains(packageId, exception.Output, StringComparison.Ordinal);
        Assert.Contains("PackageSourceMapping", exception.Output, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task RestoreAsync_SelectsLowestSatisfyingDependencyVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var rootPackage = $"Aspire.Test.Package.Root.{Guid.NewGuid():N}";
        var dependencyPackage = $"Aspire.Test.Package.Dep.{Guid.NewGuid():N}";
        var missingPackage = $"Aspire.Test.Package.Missing.{Guid.NewGuid():N}";
 
        CreatePackage(
            feedDirectory.FullName,
            rootPackage,
            dependencies: [(dependencyPackage, "1.0.0")]);
        CreatePackage(feedDirectory.FullName, dependencyPackage, version: "1.0.0");
 
        // Newer versions of the dependency are not selectable under DependencyBehavior.Lowest.
        // Expanding them anyway is what made the pre-walk fan out across the whole version history
        // of every transitive package and stall restore (#19847), so they declare a dependency that
        // does not exist to keep them clearly off the selected path.
        CreatePackage(
            feedDirectory.FullName,
            dependencyPackage,
            version: "2.0.0",
            dependencies: [(missingPackage, "1.0.0")]);
        CreatePackage(
            feedDirectory.FullName,
            dependencyPackage,
            version: "3.0.0",
            dependencies: [(missingPackage, "1.0.0")]);
        var nugetConfigPath = CreateWorkspaceGlobalPackagesConfig(workspace, packagesDirectory);
        using var restoredPackageScope = new RestoredPackageScope(
            GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
            rootPackage,
            dependencyPackage);
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        await client.RestoreAsync(
            [(rootPackage, "1.0.0")],
            "net10.0",
            runtimeIdentifier: null,
            restoreDirectory.FullName,
            [feedDirectory.FullName],
            nugetConfigPath,
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
        var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
            nugetConfigPath, workspace.WorkspaceRoot.FullName);
 
        Assert.Collection(
            restoredPackages.OrderBy(package => package.Id, StringComparer.OrdinalIgnoreCase),
            package =>
            {
                Assert.Equal(dependencyPackage, package.Id);
                Assert.Equal("1.0.0", package.Version);
            },
            package =>
            {
                Assert.Equal(rootPackage, package.Id);
                Assert.Equal("1.0.0", package.Version);
            });
    }
 
    [Fact]
    public async Task RestoreAsync_ReplacesIncompleteGlobalPackage()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feedDirectory = workspace.CreateDirectory("feed");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var restoreDirectory = workspace.CreateDirectory("restore");
        var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
        CreatePackage(feedDirectory.FullName, packageId);
 
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(
            nugetConfigPath,
            $"""
            <configuration>
              <config>
                <add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
              </config>
              <packageSources>
                <clear />
                <add key="local" value="{feedDirectory.FullName}" />
              </packageSources>
            </configuration>
            """);
        var settings = Settings.LoadSpecificSettings(
            Path.GetDirectoryName(nugetConfigPath)!,
            Path.GetFileName(nugetConfigPath));
        var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(settings);
        var incompleteInstallPath = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant(), "1.0.0");
        Directory.CreateDirectory(incompleteInstallPath);
        File.WriteAllText(Path.Combine(incompleteInstallPath, "partial.txt"), "incomplete");
 
        var client = new NuGetClient(
            new TestFeatures(),
            new TestEnvironment(),
            NullLogger<NuGetClient>.Instance);
 
        try
        {
            await client.RestoreAsync(
                [(packageId, "1.0.0")],
                "net10.0",
                runtimeIdentifier: null,
                restoreDirectory.FullName,
                [],
                nugetConfigPath,
                workspace.WorkspaceRoot.FullName,
                TestContext.Current.CancellationToken);
        var package = Assert.Single(ReadRestoredPackages(restoreDirectory.FullName, nugetConfigPath, workspace.WorkspaceRoot.FullName));
 
            Assert.Equal(incompleteInstallPath, package.InstallPath, ignoreCase: true);
            Assert.True(File.Exists(Path.Combine(package.InstallPath, ".nupkg.metadata")));
            Assert.True(File.Exists(Path.Combine(package.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll")));
        }
        finally
        {
            Directory.Delete(Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant()), recursive: true);
        }
    }
 
    /// <summary>
    /// Writes a NuGet config that redirects the global packages folder into the temporary workspace
    /// so restored packages are removed with the workspace instead of accumulating in the machine's
    /// real global packages folder.
    /// </summary>
    private static string CreateWorkspaceGlobalPackagesConfig(TemporaryWorkspace workspace, DirectoryInfo packagesDirectory)
    {
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(
            nugetConfigPath,
            $"""
            <configuration>
              <config>
                <add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
              </config>
            </configuration>
            """);
 
        return nugetConfigPath;
    }
 
    /// <summary>
    /// Writes a nuget.config with a single local feed and a workspace-scoped global packages folder, so restores
    /// neither read the machine's configured feeds nor add packages to its real global packages folder.
    /// </summary>
    private static string CreateLocalFeedConfig(TemporaryWorkspace workspace, DirectoryInfo feedDirectory, DirectoryInfo packagesDirectory)
    {
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        File.WriteAllText(
            nugetConfigPath,
            $"""
            <configuration>
              <config>
                <add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
              </config>
              <packageSources>
                <clear />
                <add key="local" value="{feedDirectory.FullName}" />
              </packageSources>
            </configuration>
            """);
 
        return nugetConfigPath;
    }
 
    /// <summary>
    /// Resolves the global packages folder the client will actually restore into, using the same
    /// settings lookup. NUGET_PACKAGES takes precedence over the <c>globalPackagesFolder</c> config
    /// value, so a workspace-scoped config alone does not keep restores out of the machine-wide
    /// folder on developer machines or CI agents that set the variable.
    /// </summary>
    private static string GetEffectiveGlobalPackagesFolder(string? nugetConfigPath, string workingDirectory)
    {
        var settings = nugetConfigPath is not null
            ? Settings.LoadSpecificSettings(Path.GetDirectoryName(nugetConfigPath)!, Path.GetFileName(nugetConfigPath))
            : Settings.LoadDefaultSettings(workingDirectory);
 
        return SettingsUtility.GetGlobalPackagesFolder(settings);
    }
 
    /// <summary>
    /// Reads the packages NuGet restored from the assets file it wrote, mirroring how the manifest
    /// step consumes restore output.
    /// </summary>
    private static IReadOnlyList<(string Id, string Version, string InstallPath)> ReadRestoredPackages(
        string restoreDirectory,
        string? nugetConfigPath,
        string workingDirectory)
    {
        var lockFile = new LockFileFormat().Read(Path.Combine(restoreDirectory, LockFileFormat.AssetsFileName));
        var packagesFolder = lockFile.PackageFolders.FirstOrDefault()?.Path
            ?? GetEffectiveGlobalPackagesFolder(nugetConfigPath, workingDirectory);
        var pathResolver = new VersionFolderPathResolver(packagesFolder);
 
        return lockFile.Libraries
            .Where(library => string.Equals(library.Type, "package", StringComparison.OrdinalIgnoreCase))
            .Select(library => (
                library.Name,
                library.Version.ToNormalizedString(),
                pathResolver.GetInstallPath(library.Name, library.Version)))
            .ToArray();
    }
 
    private static void CreatePackage(
        string feedDirectory,
        string packageId,
        string baseAssemblyContents = "base",
        string version = "1.0.0",
        IReadOnlyDictionary<string, string>? additionalEntries = null,
        IReadOnlyList<(string Id, string Version)>? dependencies = null)
    {
        var packagePath = Path.Combine(feedDirectory, $"{packageId}.{version}.nupkg");
        using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
        var dependencyElements = dependencies is null
            ? string.Empty
            : $"""
                  <dependencies>
                    <group targetFramework="net10.0">
                {string.Join(
                    Environment.NewLine,
                    dependencies.Select(dependency => $"      <dependency id=\"{dependency.Id}\" version=\"{dependency.Version}\" />"))}
                    </group>
                  </dependencies>
                """;
        WriteEntry(
            archive,
            $"{packageId}.nuspec",
            $"""
            <?xml version="1.0" encoding="utf-8"?>
            <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
              <metadata>
                <id>{packageId}</id>
                <version>{version}</version>
                <authors>Aspire</authors>
                <description>Package used to validate in-process NuGet restore.</description>
            {dependencyElements}
              </metadata>
            </package>
            """);
        WriteEntry(archive, "lib/net10.0/Aspire.Test.Package.dll", baseAssemblyContents);
        WriteEntry(archive, "runtimes/win-x64/lib/net10.0/Aspire.Test.Package.dll", "runtime");
        WriteEntry(archive, "runtimes/win-x64/lib/net10.0/RuntimeOnly.dll", "runtime-only");
        WriteEntry(archive, "runtimes/win-x64/lib/net10.0/Neutral.resources.dll", "neutral");
        WriteEntry(archive, "runtimes/win-x64/lib/net10.0/fr/RuntimeOnly.resources.dll", "french");
        WriteEntry(archive, "runtimes/win-x64/lib/net10.0/de/RuntimeOnly.resources.dll", "german");
        WriteEntry(archive, "runtimes/win-x64/native/native-test.dll", "native");
 
        if (additionalEntries is not null)
        {
            foreach (var (path, contents) in additionalEntries)
            {
                WriteEntry(archive, path, contents);
            }
        }
    }
 
    private static void WriteEntry(ZipArchive archive, string path, string contents)
    {
        using var writer = new StreamWriter(archive.CreateEntry(path).Open());
        writer.Write(contents);
    }
 
    /// <summary>
    /// Removes the packages a restore test installed from the global packages folder. Disposal runs
    /// even when an assertion fails, so a failing test cannot leave packages behind in a folder that
    /// outlives the temporary workspace.
    /// </summary>
    private sealed class RestoredPackageScope(string globalPackagesFolder, params string[] packageIds) : IDisposable
    {
        public void Dispose()
        {
            foreach (var packageId in packageIds)
            {
                var packageDirectory = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant());
                if (!Directory.Exists(packageDirectory))
                {
                    continue;
                }
 
                try
                {
                    Directory.Delete(packageDirectory, recursive: true);
                }
                catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
                {
                    // Another test process may hold the extracted files open. Leaving a stray
                    // package behind must not fail an otherwise passing test.
                }
            }
        }
    }
}