diff --git a/src/DemaConsulting.NuGetInstaller/DemaConsulting.NuGetInstaller.csproj b/src/DemaConsulting.NuGetInstaller/DemaConsulting.NuGetInstaller.csproj index 3277ead..83e4a0a 100644 --- a/src/DemaConsulting.NuGetInstaller/DemaConsulting.NuGetInstaller.csproj +++ b/src/DemaConsulting.NuGetInstaller/DemaConsulting.NuGetInstaller.csproj @@ -48,7 +48,7 @@ - + diff --git a/src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs b/src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs index 02aa0b4..70f24de 100644 --- a/src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs +++ b/src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs @@ -36,6 +36,12 @@ internal static class PackageInstaller /// The list of packages to install. /// The output directory for package extraction. /// When , use {Id}/ folder naming instead of {Id}.{Version}/. + /// + /// The directory from which to discover the applicable nuget.config (typically the + /// directory containing the packages.config file). When , + /// + /// falls back to the current working directory. + /// /// A task representing the asynchronous operation. /// Thrown when is . /// Thrown when is empty. @@ -43,7 +49,8 @@ public static async Task InstallAsync( Context context, IReadOnlyList packages, string outputDirectory, - bool excludeVersion) + bool excludeVersion, + string? configRoot = null) { ArgumentNullException.ThrowIfNull(outputDirectory); ArgumentException.ThrowIfNullOrEmpty(outputDirectory); @@ -53,7 +60,7 @@ public static async Task InstallAsync( // Install all packages in parallel await Task.WhenAll(packages.Select(entry => - InstallPackageAsync(context, entry, outputDirectory, excludeVersion))).ConfigureAwait(false); + InstallPackageAsync(context, entry, outputDirectory, excludeVersion, configRoot))).ConfigureAwait(false); } /// @@ -63,15 +70,22 @@ await Task.WhenAll(packages.Select(entry => /// The package entry to install. /// The output directory for package extraction. /// When , use {Id}/ folder naming instead of {Id}.{Version}/. + /// + /// The directory from which to discover the applicable nuget.config, forwarded to + /// . + /// /// A task representing the asynchronous operation. private static async Task InstallPackageAsync( Context context, PackageEntry entry, string outputDirectory, - bool excludeVersion) + bool excludeVersion, + string? configRoot) { - // Ensure the package is in the local NuGet cache - var cacheFolder = await NuGetCache.EnsureCachedAsync(entry.Id, entry.Version).ConfigureAwait(false); + // Ensure the package is in the local NuGet cache. Passing configRoot (typically the + // directory containing packages.config) allows a project/repo-local nuget.config to be + // discovered the same way `dotnet restore` discovers it. + var cacheFolder = await NuGetCache.EnsureCachedAsync(entry.Id, entry.Version, configRoot).ConfigureAwait(false); // Build paths - NuGet global package cache uses lowercase for filenames var nupkgPath = Path.Combine(cacheFolder, $"{entry.Id}.{entry.Version}.nupkg".ToLowerInvariant()); diff --git a/src/DemaConsulting.NuGetInstaller/Program.cs b/src/DemaConsulting.NuGetInstaller/Program.cs index d8cae59..5e5f9ce 100644 --- a/src/DemaConsulting.NuGetInstaller/Program.cs +++ b/src/DemaConsulting.NuGetInstaller/Program.cs @@ -241,8 +241,12 @@ private static void RunToolLogic(Context context) // Resolve output directory var outputDirectory = context.OutputDirectory ?? Directory.GetCurrentDirectory(); + // Resolve the directory containing packages.config so a project/repo-local nuget.config + // is discovered the same way `dotnet restore` discovers it + var configRoot = Path.GetDirectoryName(Path.GetFullPath(context.PackagesConfigFile)); + // Install - PackageInstaller.InstallAsync(context, packages, outputDirectory, context.ExcludeVersion) + PackageInstaller.InstallAsync(context, packages, outputDirectory, context.ExcludeVersion, configRoot) .GetAwaiter().GetResult(); } } diff --git a/test/DemaConsulting.NuGetInstaller.Tests/ProgramConfigRootTests.cs b/test/DemaConsulting.NuGetInstaller.Tests/ProgramConfigRootTests.cs new file mode 100644 index 0000000..f906ac0 --- /dev/null +++ b/test/DemaConsulting.NuGetInstaller.Tests/ProgramConfigRootTests.cs @@ -0,0 +1,238 @@ +// Copyright (c) DEMA Consulting +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.IO.Compression; +using DemaConsulting.NuGetInstaller.Cli; + +namespace DemaConsulting.NuGetInstaller.Tests; + +/// +/// End-to-end tests proving that derives a configRoot from +/// the packages.config location and forwards it through to NuGet settings discovery, +/// so a project/repo-local nuget.config is found the same way dotnet restore +/// finds it - regardless of the process's actual current working directory. +/// +/// +/// This is a regression test for GitHub issue #37: NuGetCache.EnsureCachedAsync +/// previously ignored any repo-local nuget.config because no root directory was +/// forwarded to Settings.LoadDefaultSettings. Each test here builds an isolated +/// temporary "repository" containing a nuget.config at its root (defining a local +/// folder package source and a private global packages folder, so no real machine state +/// is touched) and a nested packages.config in a subdirectory - mirroring the +/// HiArc.LaunchCode.HardwareSim layout where nuget.config lives several +/// directories above the project's packages.config. +/// +public class ProgramConfigRootTests +{ + /// + /// Test that Program.Run installs a package from a source defined only in a + /// repo-local nuget.config located in an ancestor of the packages.config + /// directory, proving the derived configRoot is honored end-to-end. + /// + [Fact] + public void Program_Run_WithRepoLocalNugetConfigAboveNestedPackagesConfig_InstallsFromLocalSource() + { + // Arrange - build an isolated "repository" layout: + // repoRoot/ + // nuget.config <- defines the only source that can resolve the package + // feed/{id}.{version}.nupkg <- the package content + // project/packages.config <- references the package; several levels of walk-up + // from here reach nuget.config, matching the real + // HardwareSim repo layout + // packages/ <- installer output directory + // global-packages/ <- private global packages folder (keeps this test + // from touching the real machine-wide NuGet cache) + var repoRoot = Path.Combine(Path.GetTempPath(), $"nuget_installer_config_root_test_{Guid.NewGuid():N}"); + var feedDir = Path.Combine(repoRoot, "feed"); + var projectDir = Path.Combine(repoRoot, "project"); + var outputDir = Path.Combine(repoRoot, "packages"); + var globalPackagesDir = Path.Combine(repoRoot, "global-packages"); + var packageId = $"Test.ConfigRootDiscovery.{Guid.NewGuid():N}"; + const string version = "1.0.0"; + + try + { + Directory.CreateDirectory(feedDir); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(globalPackagesDir); + + // Write the package into the local folder feed + File.WriteAllBytes( + Path.Combine(feedDir, $"{packageId}.{version}.nupkg"), + CreateMinimalNupkgBytes(packageId, version)); + + // Write a repo-local nuget.config at the repo root defining the local feed as the + // only source and a private global packages folder + File.WriteAllText(Path.Combine(repoRoot, "nuget.config"), $""" + + + + + + + + + + + """); + + // Write packages.config nested below the repo root, matching how HardwareSim's + // packages.config sits several directories below its repo-root nuget.config + var configPath = Path.Combine(projectDir, "packages.config"); + File.WriteAllText(configPath, $""" + + + + + """); + + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + using var context = Context.Create([configPath, "-o", outputDir]); + + // Act - run the program; the process's actual working directory is unrelated to + // repoRoot, so success can only be explained by Program deriving configRoot from + // the packages.config location and NuGetCache honoring it + Program.Run(context); + + // Assert - installation succeeded and the package was extracted + Assert.Equal(0, context.ExitCode); + var expectedFolder = Path.Combine(outputDir, $"{packageId}.{version}"); + Assert.True( + Directory.Exists(expectedFolder), + $"Expected package folder to exist at: {expectedFolder}"); + } + finally + { + Console.SetOut(originalOut); + } + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + + /// + /// Test that Program.Run fails to resolve a package whose only source is defined + /// in a nuget.config that does not exist anywhere above the packages.config + /// directory, proving the previous test's success is due to genuine discovery of the + /// repo-local config rather than an unrelated fallback. + /// + [Fact] + public void Program_Run_WithoutRepoLocalNugetConfig_PackageNotFound_ThrowsInvalidOperationException() + { + // Arrange - same nested packages.config as above, but with no nuget.config anywhere in + // the temp tree, so the package (which does not exist on any real source) cannot resolve + var repoRoot = Path.Combine(Path.GetTempPath(), $"nuget_installer_config_root_test_{Guid.NewGuid():N}"); + var projectDir = Path.Combine(repoRoot, "project"); + var outputDir = Path.Combine(repoRoot, "packages"); + var packageId = $"Test.ConfigRootDiscovery.{Guid.NewGuid():N}"; + const string version = "1.0.0"; + + try + { + Directory.CreateDirectory(projectDir); + + var configPath = Path.Combine(projectDir, "packages.config"); + File.WriteAllText(configPath, $""" + + + + + """); + + var originalOut = Console.Out; + try + { + using var outWriter = new StringWriter(); + Console.SetOut(outWriter); + using var context = Context.Create([configPath, "-o", outputDir]); + + // Act & Assert - with no repo-local source defining this package, resolution must + // fail; NuGetCache.EnsureCachedAsync surfaces this as InvalidOperationException, + // which Program.Run (called directly, bypassing Main's try/catch) propagates + Assert.Throws(() => Program.Run(context)); + } + finally + { + Console.SetOut(originalOut); + } + } + finally + { + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + + /// + /// Builds a minimal valid .nupkg byte array for the given package identity using only + /// (no NuGet SDK package-building types are referenced by this + /// test project). NuGet's local-folder feed resolution only requires a top-level + /// *.nuspec entry containing valid package metadata; full OPC parts + /// ([Content_Types].xml, relationship parts) are not required for reading. + /// + /// The NuGet package identifier. + /// The package version string. + /// A byte array containing a minimal .nupkg archive. + private static byte[] CreateMinimalNupkgBytes(string packageId, string version) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + // Top-level nuspec entry - required by NuGet's package reader to identify the package + var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec"); + using (var entryStream = nuspecEntry.Open()) + using (var writer = new StreamWriter(entryStream)) + { + writer.Write($""" + + + + {packageId} + {version} + Test + Minimal test package for configRoot discovery tests. + + + """); + } + + // A placeholder content file so the extracted package folder is non-empty + var contentEntry = archive.CreateEntry("lib/net8.0/_placeholder.txt"); + using (var entryStream = contentEntry.Open()) + using (var writer = new StreamWriter(entryStream)) + { + writer.Write("placeholder"); + } + } + + return stream.ToArray(); + } +}