From b06e039acb52fddc6bfd750c59414a14c9e37cef Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 10 Jul 2026 10:03:13 -0400 Subject: [PATCH 1/3] Fix EnsureCachedAsync to discover repo-local nuget.config (#37) The public EnsureCachedAsync overload called Settings.LoadDefaultSettings(null), which never scans the working directory (or any ancestor) for a project/repo-local nuget.config - only machine/user-wide settings are loaded. Any repository that defines its real package sources in a repo-local nuget.config (with only packageSourceCredentials living in the user-level config) had those sources silently invisible to callers, unlike the dotnet CLI / MSBuild restore pipeline. Add an optional root parameter to EnsureCachedAsync, forwarded to Settings.LoadDefaultSettings(root ?? Directory.GetCurrentDirectory()), so callers can point the settings lookup at their project/repo directory and have nuget.config discovered the same way dotnet restore discovers it. Add a WireMock-backed regression test proving a repo-local nuget.config is honored when root is supplied, and update existing tests/design docs accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/nuget-caching/nuget-cache.md | 27 ++++--- .../NuGetCache.cs | 34 +++++--- .../NuGetCacheServerTests.cs | 80 +++++++++++++++++++ .../NuGetCacheTests.cs | 16 ++-- .../NuGetCachingTests.cs | 8 +- 5 files changed, 134 insertions(+), 31 deletions(-) diff --git a/docs/design/nuget-caching/nuget-cache.md b/docs/design/nuget-caching/nuget-cache.md index dbd997b..5e11540 100644 --- a/docs/design/nuget-caching/nuget-cache.md +++ b/docs/design/nuget-caching/nuget-cache.md @@ -47,10 +47,16 @@ library cooperative in concurrent or UI-driven environments. #### NuGet Settings Integration Rather than accepting source URIs directly, `NuGetCache` reads from the default -NuGet settings on the local machine via `Settings.LoadDefaultSettings(null)`. This -ensures the library respects the same `nuget.config` hierarchy (machine-wide, user, -and project-level) as the `dotnet` CLI and Visual Studio, including authenticated -feeds, proxy settings, and package source mapping. +NuGet settings via `Settings.LoadDefaultSettings(root)`, where `root` is a caller-supplied +directory (typically the directory containing the caller's project or `packages.config` +file) defaulting to the current working directory when omitted. Rooting the lookup this +way - rather than passing a literal `null` root - ensures the library respects the same +`nuget.config` hierarchy (machine-wide, user, and project-level, discovered by walking up +from `root`) as the `dotnet` CLI and Visual Studio, including authenticated feeds, proxy +settings, and package source mapping. A literal `null` root skips the walk-up entirely and +silently loses any repo-local package sources - this was the root cause of GitHub issue +#37, where `nuget-installer` could not see sources defined only in a repo-local +`nuget.config`. #### Early-Exit on Cache Hit @@ -127,7 +133,7 @@ misconfiguration, without requiring additional logging infrastructure. #### Testability via Injected Settings The public `EnsureCachedAsync` method is a thin wrapper that calls the internal overload -with `Settings.LoadDefaultSettings(null)`. The internal overload accepts an `ISettings` +with `Settings.LoadDefaultSettings(root)`. The internal overload accepts an `ISettings` parameter that replaces the call to `LoadDefaultSettings`. This design gives tests full control over the NuGet source and global packages folder without touching the developer's real machine configuration: @@ -149,13 +155,14 @@ without depending on shared, process-wide static state. ### Method Descriptions -#### `EnsureCachedAsync(string packageId, string version, CancellationToken)` (public) +#### `EnsureCachedAsync(string packageId, string version, string? root, CancellationToken)` (public) Thin wrapper that delegates immediately to the internal `EnsureCachedAsync` overload, -passing `Settings.LoadDefaultSettings(null)` as the `settings` argument. This ensures -the public API continues to behave identically to the pre-testability implementation -while the full logic lives in one place. See the internal overload description below -for the complete processing steps. +passing `Settings.LoadDefaultSettings(root ?? Directory.GetCurrentDirectory())` as the +`settings` argument. Callers should pass the directory containing their project or +`packages.config` file as `root` so that a repo-local `nuget.config` is discovered the +same way `dotnet restore` discovers it; omitting `root` falls back to the current working +directory. See the internal overload description below for the complete processing steps. Returns the absolute path to the cached package folder. diff --git a/src/DemaConsulting.NuGet.Caching/NuGetCache.cs b/src/DemaConsulting.NuGet.Caching/NuGetCache.cs index e1dcfd4..fad46b8 100644 --- a/src/DemaConsulting.NuGet.Caching/NuGetCache.cs +++ b/src/DemaConsulting.NuGet.Caching/NuGetCache.cs @@ -32,8 +32,9 @@ namespace DemaConsulting.NuGet.Caching; /// /// /// This class reads NuGet configuration (sources and global packages folder) from -/// the default NuGet settings on the local machine, mirroring the behavior of -/// the dotnet CLI and Visual Studio package restore. +/// the default NuGet settings, rooted at a caller-supplied (or current working) directory, +/// mirroring the behavior of the dotnet CLI and Visual Studio package restore - +/// including discovery of a project/repo-local nuget.config. /// This class is stateless — all state is local to each EnsureCachedAsync call — and is /// safe for concurrent use. /// @@ -43,13 +44,25 @@ public static class NuGetCache /// Ensures a specific NuGet package version is available in the local global packages cache. /// /// - /// Reads NuGet configuration from the default machine settings via - /// Settings.LoadDefaultSettings, mirroring the behavior of the dotnet - /// CLI and Visual Studio package restore. All caching logic is delegated to the internal - /// overload that accepts an explicit instance. + /// Reads NuGet configuration from the default settings via Settings.LoadDefaultSettings, + /// rooted at (or the current working directory when + /// is ). Rooting the settings lookup this way + /// mirrors the behavior of the dotnet CLI and Visual Studio package restore, which + /// both discover a project/repo-local nuget.config by walking up from an ambient + /// working directory - passing a literal root (as earlier versions of + /// this method did) skips that walk entirely and silently loses any repo-local package + /// sources. All caching logic is delegated to the internal overload that accepts an explicit + /// instance. /// /// The NuGet package identifier (e.g. Newtonsoft.Json). /// The exact version string (e.g. 13.0.3). + /// + /// The directory from which to begin discovering nuget.config files (walking up + /// through ancestor directories, then falling back to machine/user-wide settings), matching + /// the dotnet CLI's behavior. Typically the directory containing the caller's project + /// or packages.config file. When , defaults to + /// . + /// /// Optional cancellation token for the async operation. /// /// The absolute path to the cached package folder inside the global packages folder. @@ -66,11 +79,14 @@ public static class NuGetCache public static async Task EnsureCachedAsync( string packageId, string version, + string? root = null, CancellationToken cancellationToken = default) { - // Delegate to the overload that accepts an explicit ISettings instance, using the - // default machine / user NuGet configuration as the settings source - return await EnsureCachedAsync(packageId, version, Settings.LoadDefaultSettings(null), cancellationToken); + // Delegate to the overload that accepts an explicit ISettings instance, rooting the + // default settings lookup at the provided directory (or the current working directory) + // so that a project/repo-local nuget.config is discovered the same way `dotnet restore` does + return await EnsureCachedAsync( + packageId, version, Settings.LoadDefaultSettings(root ?? Directory.GetCurrentDirectory()), cancellationToken); } /// diff --git a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheServerTests.cs b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheServerTests.cs index f30d92b..13baea0 100644 --- a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheServerTests.cs +++ b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheServerTests.cs @@ -849,6 +849,86 @@ public async Task NuGetCache_EnsureCachedAsync_DefaultRegistrar_RegistersRealCre } } + /// + /// Tests that the public NuGetCache.EnsureCachedAsync(packageId, version, root, ...) + /// overload discovers a project/repo-local nuget.config located at its root + /// argument, rather than only reading machine/user-wide settings. + /// + /// + /// This is a regression test for the bug reported in GitHub issue #37: the public overload + /// previously called Settings.LoadDefaultSettings(null), which never scans the + /// working directory (or any ancestor) for a repo-local nuget.config - identical + /// package sources placed in such a file were silently invisible. Here the WireMock source + /// is defined *only* in a real nuget.config file written to an isolated temp + /// directory that is not an ancestor of the process's actual working directory, and is + /// passed in via root: had the settings load ignored root (as before the fix), + /// no source would resolve the package and the call would throw + /// instead of succeeding. + /// + [Fact] + [Trait("Category", "LocalIntegration")] + public async Task NuGetCache_EnsureCachedAsync_RootPointsToDirectoryWithRepoLocalNugetConfig_UsesThatConfigSources() + { + // Arrange - isolated global packages folder and a separate "repo root" directory that + // holds only a project-local nuget.config (simulating a repo checkout) + var globalPackagesFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var repoRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(globalPackagesFolder); + Directory.CreateDirectory(repoRoot); + + const string packageId = "TestPackage.RepoLocalConfig"; + const string version = "1.0.0"; + + await using var server = new NuGetTestServer(); + var nupkgBytes = NuGetPackageBuilder.CreateMinimalPackage(packageId, version); + server.RegisterV3Package(packageId, version, nupkgBytes); + + // Write a real nuget.config directly into the repo root, mirroring how a repository + // scopes its own package sources independently of machine/user-wide settings + await File.WriteAllTextAsync( + Path.Combine(repoRoot, "nuget.config"), + $""" + + + + + + + + + + + """, + TestContext.Current.CancellationToken); + + // Act - call the public overload, passing the repo root explicitly so the + // repo-local nuget.config is discovered exactly as `dotnet restore` would + var packagePath = await NuGetCache.EnsureCachedAsync( + packageId, version, root: repoRoot, cancellationToken: TestContext.Current.CancellationToken); + + // Assert - the package was found and cached via the repo-local source, proving + // the root parameter was honored when loading default settings + Assert.NotNull(packagePath); + Assert.True( + File.Exists(Path.Combine(packagePath, ".nupkg.metadata")), + $"Expected .nupkg.metadata in: {packagePath}"); + } + finally + { + if (Directory.Exists(globalPackagesFolder)) + { + Directory.Delete(globalPackagesFolder, recursive: true); + } + + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + /// /// A test double implementing that /// simply counts invocations of , letting tests assert that diff --git a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheTests.cs b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheTests.cs index d915db5..085f9e6 100644 --- a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheTests.cs +++ b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheTests.cs @@ -41,7 +41,7 @@ public async Task NuGetCache_EnsureCachedAsync_ValidPackageId_ReturnsPackageFold const string version = "1.5.0"; // Act - ensure the package is present in the local NuGet global packages cache - var packageFolder = await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None); + var packageFolder = await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None); // Assert - the returned path must point to a real directory on disk Assert.NotNull(packageFolder); @@ -77,7 +77,7 @@ public async Task NuGetCache_EnsureCachedAsync_PackageAbsentFromAllSources_Throw // Act & Assert - calling with a non-existent package must throw InvalidOperationException _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None)); } /// @@ -96,7 +96,7 @@ public async Task NuGetCache_EnsureCachedAsync_NullPackageId_ThrowsArgumentNullE // Act & Assert - calling with null packageId must throw ArgumentNullException _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(null!, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(null!, version, cancellationToken: CancellationToken.None)); } /// @@ -115,7 +115,7 @@ public async Task NuGetCache_EnsureCachedAsync_NullVersion_ThrowsArgumentNullExc // Act & Assert - calling with null version must throw ArgumentNullException _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, null!, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, null!, cancellationToken: CancellationToken.None)); } /// @@ -131,7 +131,7 @@ public async Task NuGetCache_EnsureCachedAsync_InvalidVersion_ThrowsArgumentExce // Act & Assert: calling with an invalid version must throw ArgumentException _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None)); } /// @@ -155,7 +155,7 @@ public async Task NuGetCache_EnsureCachedAsync_PackageAbsentFromAllSources_Excep // Act - calling with a non-existent package must throw InvalidOperationException var exception = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None)); // Assert - the exception message must identify both the package ID and the version // so that callers have enough context to diagnose the problem @@ -180,8 +180,8 @@ public async Task NuGetCache_EnsureCachedAsync_CalledTwiceWithSamePackage_Return const string version = "1.5.0"; // Act - call EnsureCachedAsync twice with the same package identity - var firstPath = await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None); - var secondPath = await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None); + var firstPath = await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None); + var secondPath = await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None); // Assert - both calls must return identical paths, proving the method is idempotent // and does not change the cache location on subsequent calls diff --git a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCachingTests.cs b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCachingTests.cs index a2e7d56..8b866fd 100644 --- a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCachingTests.cs +++ b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCachingTests.cs @@ -42,7 +42,7 @@ public async Task NuGetCaching_EnsureCachedAsync_WhenKnownPackageAndVersionProvi const string version = "1.5.0"; // Act: invoke the library's package caching capability - var packageFolder = await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None); + var packageFolder = await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None); // Assert: the library returned a valid path to the cached package on disk Assert.NotNull(packageFolder); @@ -80,7 +80,7 @@ public async Task NuGetCaching_EnsureCachedAsync_WhenPackageNotFound_ThrowsInval // Act & Assert: the library should throw when the package cannot be found var ex = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None)); // Assert: the exception message must identify the package ID and version Assert.Contains(packageId, ex.Message); @@ -103,7 +103,7 @@ public async Task NuGetCaching_EnsureCachedAsync_WhenPackageIdIsNull_ThrowsArgum // Act & Assert: the library should throw ArgumentNullException for a null package ID _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(null!, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(null!, version, cancellationToken: CancellationToken.None)); } /// @@ -122,6 +122,6 @@ public async Task NuGetCaching_EnsureCachedAsync_WhenVersionIsNull_ThrowsArgumen // Act & Assert: the library should throw ArgumentNullException for a null version _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, null!, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, null!, cancellationToken: CancellationToken.None)); } } From 6acf8c53cedc01de10565968222ec12e77b21316 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 10 Jul 2026 10:39:40 -0400 Subject: [PATCH 2/3] Fix markdownlint MD018 violation in nuget-cache design doc Rewrap paragraph so GitHub issue reference '#37' no longer starts a line, which markdownlint-cli2 (MD018) misinterprets as a malformed ATX heading. --- docs/design/nuget-caching/nuget-cache.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design/nuget-caching/nuget-cache.md b/docs/design/nuget-caching/nuget-cache.md index 5e11540..6d8aca3 100644 --- a/docs/design/nuget-caching/nuget-cache.md +++ b/docs/design/nuget-caching/nuget-cache.md @@ -54,9 +54,9 @@ way - rather than passing a literal `null` root - ensures the library respects t `nuget.config` hierarchy (machine-wide, user, and project-level, discovered by walking up from `root`) as the `dotnet` CLI and Visual Studio, including authenticated feeds, proxy settings, and package source mapping. A literal `null` root skips the walk-up entirely and -silently loses any repo-local package sources - this was the root cause of GitHub issue -#37, where `nuget-installer` could not see sources defined only in a repo-local -`nuget.config`. +silently loses any repo-local package sources - this was the root cause of GitHub +issue #37, where `nuget-installer` could not see sources defined only in a +repo-local `nuget.config`. #### Early-Exit on Cache Hit From 314875ab45d59e349fbedd8b9f6ce8d23e2e5c2c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Fri, 10 Jul 2026 10:49:34 -0400 Subject: [PATCH 3/3] Fix stale user guide code sample after root parameter addition The user guide's EnsureCachedAsync signature block and Example 2 still showed CancellationToken as the 3rd positional parameter, but the fix in b06e039 inserted an optional root parameter before it. The documented example would no longer compile (CancellationToken has no implicit conversion to string). Updated the signature block, parameter list, and Example 2 to use the root parameter and pass cancellationToken by name, matching the pattern already used in the test files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user_guide/introduction.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 9f4882d..de1cc07 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -57,8 +57,9 @@ Console.WriteLine($"Package cached at: {packagePath}"); ``` The method reads NuGet configuration (sources and global packages folder) from the default NuGet -settings on the local machine, mirroring the behavior of the `dotnet` CLI. Package source mapping -is fully supported. +settings, rooted at a caller-supplied (or current working) directory, mirroring the behavior of +the `dotnet` CLI - including discovery of a project/repo-local `nuget.config`. Package source +mapping is fully supported. ## API Reference @@ -75,6 +76,7 @@ the local global packages cache. public static async Task EnsureCachedAsync( string packageId, string version, + string? root = null, CancellationToken cancellationToken = default) ``` @@ -86,6 +88,11 @@ Ensures a specific NuGet package version is available in the local global packag - `version` (string): The exact version string (e.g. `13.0.3`). Must not be null. +- `root` (string?): The directory from which to begin discovering `nuget.config` files (walking + up through ancestor directories, then falling back to machine/user-wide settings), matching the + `dotnet` CLI's behavior. Typically the directory containing the caller's project or + `packages.config` file. When null, defaults to the current working directory. + - `cancellationToken` (CancellationToken): Optional cancellation token for the async operation. **Returns:** @@ -129,7 +136,7 @@ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); string packagePath = await NuGetCache.EnsureCachedAsync( "Newtonsoft.Json", "13.0.3", - cts.Token); + cancellationToken: cts.Token); Console.WriteLine($"Package available at: {packagePath}"); ```