Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@

<!-- Runtime Dependencies -->
<ItemGroup>
<PackageReference Include="DemaConsulting.NuGet.Caching" Version="1.3.0" />
<PackageReference Include="DemaConsulting.NuGet.Caching" Version="1.4.0" />
<PackageReference Include="DemaConsulting.TestResults" Version="1.10.0" />
</ItemGroup>

Expand Down
24 changes: 19 additions & 5 deletions src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,21 @@ internal static class PackageInstaller
/// <param name="packages">The list of packages to install.</param>
/// <param name="outputDirectory">The output directory for package extraction.</param>
/// <param name="excludeVersion">When <see langword="true"/>, use {Id}/ folder naming instead of {Id}.{Version}/.</param>
/// <param name="configRoot">
/// The directory from which to discover the applicable <c>nuget.config</c> (typically the
/// directory containing the <c>packages.config</c> file). When <see langword="null"/>,
/// <see cref="NuGetCache.EnsureCachedAsync(string, string, string?, System.Threading.CancellationToken)"/>
/// falls back to the current working directory.
/// </param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="outputDirectory"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="outputDirectory"/> is empty.</exception>
public static async Task InstallAsync(
Context context,
IReadOnlyList<PackageEntry> packages,
string outputDirectory,
bool excludeVersion)
bool excludeVersion,
string? configRoot = null)
{
ArgumentNullException.ThrowIfNull(outputDirectory);
ArgumentException.ThrowIfNullOrEmpty(outputDirectory);
Expand All @@ -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);
}

/// <summary>
Expand All @@ -63,15 +70,22 @@ await Task.WhenAll(packages.Select(entry =>
/// <param name="entry">The package entry to install.</param>
/// <param name="outputDirectory">The output directory for package extraction.</param>
/// <param name="excludeVersion">When <see langword="true"/>, use {Id}/ folder naming instead of {Id}.{Version}/.</param>
/// <param name="configRoot">
/// The directory from which to discover the applicable <c>nuget.config</c>, forwarded to
/// <see cref="NuGetCache.EnsureCachedAsync(string, string, string?, System.Threading.CancellationToken)"/>.
/// </param>
/// <returns>A task representing the asynchronous operation.</returns>
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());
Expand Down
6 changes: 5 additions & 1 deletion src/DemaConsulting.NuGetInstaller/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
Malcolmnixon marked this conversation as resolved.
.GetAwaiter().GetResult();
}
}
238 changes: 238 additions & 0 deletions test/DemaConsulting.NuGetInstaller.Tests/ProgramConfigRootTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// End-to-end tests proving that <see cref="Program.Run"/> derives a <c>configRoot</c> from
/// the <c>packages.config</c> location and forwards it through to NuGet settings discovery,
/// so a project/repo-local <c>nuget.config</c> is found the same way <c>dotnet restore</c>
/// finds it - regardless of the process's actual current working directory.
/// </summary>
/// <remarks>
/// This is a regression test for GitHub issue #37: <c>NuGetCache.EnsureCachedAsync</c>
/// previously ignored any repo-local <c>nuget.config</c> because no root directory was
/// forwarded to <c>Settings.LoadDefaultSettings</c>. Each test here builds an isolated
/// temporary "repository" containing a <c>nuget.config</c> 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 <c>packages.config</c> in a subdirectory - mirroring the
/// <c>HiArc.LaunchCode.HardwareSim</c> layout where <c>nuget.config</c> lives several
/// directories above the project's <c>packages.config</c>.
/// </remarks>
public class ProgramConfigRootTests
{
/// <summary>
/// Test that <c>Program.Run</c> installs a package from a source defined only in a
/// repo-local <c>nuget.config</c> located in an ancestor of the <c>packages.config</c>
/// directory, proving the derived <c>configRoot</c> is honored end-to-end.
/// </summary>
[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"), $"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="globalPackagesFolder" value="{globalPackagesDir}" />
</config>
<packageSources>
<clear />
<add key="repo-local-feed" value="{feedDir}" />
</packageSources>
</configuration>
""");

// 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, $"""
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="{packageId}" version="{version}" />
</packages>
""");

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);
}
}
}

/// <summary>
/// Test that <c>Program.Run</c> fails to resolve a package whose only source is defined
/// in a <c>nuget.config</c> that does not exist anywhere above the <c>packages.config</c>
/// directory, proving the previous test's success is due to genuine discovery of the
/// repo-local config rather than an unrelated fallback.
/// </summary>
[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, $"""
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="{packageId}" version="{version}" />
</packages>
""");

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<InvalidOperationException>(() => Program.Run(context));
}
finally
{
Console.SetOut(originalOut);
}
}
finally
{
if (Directory.Exists(repoRoot))
{
Directory.Delete(repoRoot, recursive: true);
}
}
}

/// <summary>
/// Builds a minimal valid .nupkg byte array for the given package identity using only
/// <see cref="ZipArchive"/> (no NuGet SDK package-building types are referenced by this
/// test project). NuGet's local-folder feed resolution only requires a top-level
/// <c>*.nuspec</c> entry containing valid package metadata; full OPC parts
/// (<c>[Content_Types].xml</c>, relationship parts) are not required for reading.
/// </summary>
/// <param name="packageId">The NuGet package identifier.</param>
/// <param name="version">The package version string.</param>
/// <returns>A byte array containing a minimal .nupkg archive.</returns>
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($"""
<?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>Test</authors>
<description>Minimal test package for configRoot discovery tests.</description>
</metadata>
</package>
""");
}

// 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();
}
}
Loading