Skip to content

Commit 4bd63e6

Browse files
committed
Add package source mapping
1 parent 6d3aa38 commit 4bd63e6

File tree

4 files changed

+282
-16
lines changed

4 files changed

+282
-16
lines changed

src/NuGet.Core/NuGet.CommandLine.XPlat/Commands/Package/Download/PackageDownloadRunner.cs

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
#nullable enable
5+
46
using System;
57
using System.Collections.Generic;
68
using System.Globalization;
@@ -48,28 +50,45 @@ public static async Task<int> RunAsync(PackageDownloadArgs args, CancellationTok
4850

4951
public static async Task<int> RunAsync(PackageDownloadArgs args, ILoggerWithColor logger, IReadOnlyList<PackageSource> packageSources, ISettings settings, CancellationToken token)
5052
{
51-
// Check for insecure sources
52-
if (DetectAndReportInsecureSources(args.AllowInsecureConnections, packageSources, logger))
53+
var packageSourceMapping = PackageSourceMapping.GetPackageSourceMapping(settings);
54+
var hasSourcesArg = args.Sources != null && args.Sources.Count > 0;
55+
var mappingDisabled = (packageSourceMapping != null && !packageSourceMapping.IsEnabled) || packageSourceMapping == null;
56+
if ((mappingDisabled || hasSourcesArg) && DetectAndReportInsecureSources(args.AllowInsecureConnections, packageSources, logger))
5357
{
5458
return ExitCodeError;
5559
}
5660

5761
string outputDirectory = args.OutputDirectory ?? Directory.GetCurrentDirectory();
5862
var cache = new SourceCacheContext();
59-
IReadOnlyList<SourceRepository> sourceRepositories = GetSourceRepositories(packageSources);
63+
64+
IReadOnlyDictionary<string, SourceRepository> sourceRepositoriesMap = GetSourceRepositories(packageSources);
65+
6066
bool downloadedAllSuccessfully = true;
6167

62-
foreach (var package in args.Packages)
68+
foreach (var package in args.Packages ?? [])
6369
{
6470
logger.LogMinimal(string.Format(
6571
CultureInfo.CurrentCulture,
6672
Strings.PackageDownloadCommand_Starting,
6773
package.Id,
6874
string.IsNullOrEmpty(package.NuGetVersion?.ToNormalizedString()) ? Strings.PackageDownloadCommand_LatestVersion : package.NuGetVersion.ToNormalizedString()));
6975

76+
// Resolve which repositories to use for this package
77+
if (!TryGetRepositoriesForPackage(
78+
package.Id,
79+
args,
80+
packageSources,
81+
packageSourceMapping,
82+
sourceRepositoriesMap,
83+
logger,
84+
out List<SourceRepository> sourceRepositories))
85+
{
86+
return ExitCodeError;
87+
}
88+
7089
try
7190
{
72-
(NuGetVersion version, SourceRepository downloadRepository) =
91+
(NuGetVersion? version, SourceRepository? downloadRepository) =
7392
await ResolvePackageDownloadVersion(
7493
package,
7594
sourceRepositories,
@@ -88,7 +107,7 @@ await ResolvePackageDownloadVersion(
88107
bool success = await DownloadPackageAsync(
89108
package.Id,
90109
version,
91-
downloadRepository,
110+
downloadRepository!,
92111
cache,
93112
settings,
94113
outputDirectory,
@@ -127,16 +146,16 @@ await ResolvePackageDownloadVersion(
127146
return downloadedAllSuccessfully ? ExitCodeSuccess : ExitCodeError;
128147
}
129148

130-
internal static async Task<(NuGetVersion, SourceRepository)> ResolvePackageDownloadVersion(
149+
internal static async Task<(NuGetVersion?, SourceRepository?)> ResolvePackageDownloadVersion(
131150
PackageWithNuGetVersion packageWithNuGetVersion,
132151
IEnumerable<SourceRepository> sourceRepositories,
133152
SourceCacheContext cache,
134153
ILoggerWithColor logger,
135154
bool includePrerelease,
136155
CancellationToken token)
137156
{
138-
NuGetVersion versionToDownload = null;
139-
SourceRepository downloadSourceRepository = null;
157+
NuGetVersion? versionToDownload = null;
158+
SourceRepository? downloadSourceRepository = null;
140159
bool versionSpecified = packageWithNuGetVersion.NuGetVersion != null;
141160

142161
foreach (var repo in sourceRepositories)
@@ -188,6 +207,67 @@ await ResolvePackageDownloadVersion(
188207
return (versionToDownload, downloadSourceRepository);
189208
}
190209

210+
/// <summary>
211+
/// Builds the set of SourceRepository objects to use for a given package,
212+
/// applying package source mapping (when --source is not provided) and
213+
/// validating HTTP usage only on the *effective* sources.
214+
/// </summary>
215+
private static bool TryGetRepositoriesForPackage(
216+
string packageId,
217+
PackageDownloadArgs args,
218+
IReadOnlyList<PackageSource> allPackageSources,
219+
PackageSourceMapping? packageSourceMapping,
220+
IReadOnlyDictionary<string, SourceRepository> sourceRepositoriesMap,
221+
ILoggerWithColor logger,
222+
out List<SourceRepository> repositories)
223+
{
224+
List<PackageSource> effectiveSources;
225+
226+
var sourceExplicitlyProvided = args.Sources?.Count > 0;
227+
if (sourceExplicitlyProvided || (packageSourceMapping != null && !packageSourceMapping.IsEnabled))
228+
{
229+
// --source given OR mapping disabled: use all provided sources as-is
230+
effectiveSources = [.. allPackageSources];
231+
}
232+
else
233+
{
234+
// Mapping enabled, no --source: try mapped names first
235+
var mappedNames = packageSourceMapping == null ? [] : packageSourceMapping.GetConfiguredPackageSources(packageId);
236+
237+
// Build effective sources in the same order as mappedNames
238+
var mapped = mappedNames
239+
.Select(n => allPackageSources.FirstOrDefault(ps =>
240+
string.Equals(ps.Name, n, StringComparison.OrdinalIgnoreCase)))
241+
.ToList();
242+
243+
// Only validate insecure sources when mapping produced something
244+
if (mapped.Count > 0)
245+
{
246+
if (DetectAndReportInsecureSources(args.AllowInsecureConnections, mapped!, logger))
247+
{
248+
repositories = [];
249+
return false;
250+
}
251+
252+
effectiveSources = mapped!;
253+
}
254+
else
255+
{
256+
// No mapping for this package: fall back to all sources
257+
effectiveSources = [.. allPackageSources];
258+
}
259+
}
260+
261+
// Convert effective sources to repositories
262+
repositories = new List<SourceRepository>(effectiveSources.Count);
263+
foreach (var src in effectiveSources)
264+
{
265+
repositories.Add(sourceRepositoriesMap[src.Name]);
266+
}
267+
268+
return true;
269+
}
270+
191271
private static async Task<bool> DownloadPackageAsync(
192272
string id,
193273
NuGetVersion version,
@@ -239,7 +319,7 @@ private static async Task<bool> DownloadPackageAsync(
239319
return success;
240320
}
241321

242-
private static IReadOnlyList<PackageSource> GetPackageSources(IList<string> sources, IPackageSourceProvider sourceProvider)
322+
private static IReadOnlyList<PackageSource> GetPackageSources(IList<string>? sources, IPackageSourceProvider sourceProvider)
243323
{
244324
IEnumerable<PackageSource> configuredSources = sourceProvider.LoadPackageSources()
245325
.Where(s => s.IsEnabled);
@@ -271,13 +351,13 @@ private static bool DetectAndReportInsecureSources(
271351
return false;
272352
}
273353

274-
private static IReadOnlyList<SourceRepository> GetSourceRepositories(IReadOnlyList<PackageSource> packageSources)
354+
private static IReadOnlyDictionary<string, SourceRepository> GetSourceRepositories(IReadOnlyList<PackageSource> packageSources)
275355
{
276356
IEnumerable<Lazy<INuGetResourceProvider>> providers = Repository.Provider.GetCoreV3();
277-
List<SourceRepository> sourceRepositories = [];
357+
Dictionary<string, SourceRepository> sourceRepositories = [];
278358
foreach (var source in packageSources)
279359
{
280-
sourceRepositories.Add(Repository.CreateSource(providers, source, FeedType.Undefined));
360+
sourceRepositories[source.Name] = Repository.CreateSource(providers, source, FeedType.Undefined);
281361
}
282362

283363
return sourceRepositories;

test/NuGet.Core.FuncTests/NuGet.XPlat.FuncTest/Package/Download/PackageDownloadRunnerTests.cs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Threading;
99
using System.Threading.Tasks;
1010
using FluentAssertions;
11+
using global::Test.Utility;
1112
using Moq;
1213
using NuGet.CommandLine.XPlat;
1314
using NuGet.CommandLine.XPlat.Commands.Package;
@@ -426,4 +427,178 @@ [new PackageSource(context.PackageSource)],
426427
File.Exists(Path.Combine(context.WorkingDirectory, $"{id.ToLowerInvariant()}.{v}.nupkg"))
427428
.Should().BeFalse("Package does not exist in sources");
428429
}
430+
431+
public static IEnumerable<object[]> Cases()
432+
{
433+
// Parameters:
434+
// A-packages, B-packages, sourceMappings, sourcesArgs, downloadId, downloadVersion,
435+
// allowInsecureConnections, expectSuccess, expectedInstalled
436+
437+
// --source specified, mapping ignored, package only in A -> success
438+
yield return new object[]
439+
{
440+
new List<(string,string)> { ("Contoso.Lib", "1.0.0") }, // A
441+
new List<(string,string)>(), // B
442+
new List<(string,string)> { ("B", "Contoso.*") }, // mapping ignored
443+
new List<string> { "A" }, // --source A
444+
"Contoso.Lib", "1.0.0", // downloadId, downloadVersion
445+
true, // allow insecure
446+
true, // expect success
447+
("Contoso.Lib", "1.0.0") // expectedInstalled
448+
};
449+
450+
// no --source, mapping -> B, package only in B -> success
451+
yield return new object[]
452+
{
453+
new List<(string,string)>(), // A
454+
new List<(string,string)> { ("Contoso.Mapped", "2.0.0") }, // B
455+
new List<(string,string)> { ("B", "Contoso.*") }, // mapping -> B
456+
null, // no --source
457+
"Contoso.Mapped", "2.0.0", // downloadId, downloadVersion
458+
true, // allow insecure
459+
true, // expect success
460+
("Contoso.Mapped", "2.0.0") // expectedInstalled
461+
};
462+
463+
// no --source, mapping -> A, package only in B -> fail
464+
yield return new object[]
465+
{
466+
new List<(string,string)>(), // A
467+
new List<(string,string)> { ("Contoso.Mapped", "2.0.0") },
468+
new List<(string,string)> { ("A", "Contoso.*") }, // mapped to A
469+
null,
470+
"Contoso.Mapped", "2.0.0",
471+
true,
472+
false,
473+
null!
474+
};
475+
476+
// --source specified, no source mapping with an insecure source
477+
yield return new object[]
478+
{
479+
new List<(string,string)> { ("Contoso.Lib", "1.0.0") }, // A
480+
new List<(string,string)>(),
481+
new List<(string,string)> { ("A", "Contoso.*") },
482+
new List<string> { "A" }, // --source
483+
"Contoso.Lib", "1.0.0",
484+
false, // allow insecure connections false / not set to true
485+
false,
486+
null!
487+
};
488+
489+
// no --source, mapping -> B, allow insecure not enabled -> fail
490+
yield return new object[]
491+
{
492+
new List<(string,string)>(), // A
493+
new List<(string,string)> { ("Contoso.Mapped", "1.0.0") },
494+
new List<(string,string)> { ("B", "Contoso.*") },
495+
null,
496+
"Contoso.Mapped", "1.0.0",
497+
false, // allow insecure connections false / not set to true
498+
false,
499+
null!
500+
};
501+
}
502+
503+
[Theory]
504+
[MemberData(nameof(Cases))]
505+
public async Task RunAsync_WithSourceMapping_ListDriven_UsingCleanSetup(
506+
IReadOnlyList<(string id, string version)> sourceAPackages,
507+
IReadOnlyList<(string id, string version)> sourceBPackages,
508+
IReadOnlyList<(string source, string pattern)> sourceMappings,
509+
IReadOnlyList<string> sourcesArgs,
510+
string downloadId,
511+
string downloadVersion,
512+
bool allowInsecureConnections,
513+
bool expectSuccess,
514+
(string id, string version)? expectedInstalled)
515+
{
516+
// Arrange
517+
using var context = new SimpleTestPathContext();
518+
string srcADirectory = Path.Combine(context.PackageSource, "SourceA");
519+
string srcBDirectory = Path.Combine(context.PackageSource, "SourceB");
520+
521+
using var serverA = new FileSystemBackedV3MockServer(srcADirectory);
522+
using var serverB = new FileSystemBackedV3MockServer(srcBDirectory);
523+
524+
foreach (var (id, ver) in sourceAPackages)
525+
{
526+
await SimpleTestPackageUtility.CreateFullPackageAsync(srcADirectory, id, ver);
527+
}
528+
529+
foreach (var (id, ver) in sourceBPackages)
530+
{
531+
await SimpleTestPackageUtility.CreateFullPackageAsync(srcBDirectory, id, ver);
532+
}
533+
534+
serverA.Start();
535+
serverB.Start();
536+
537+
// sources
538+
context.Settings.AddSource("A", serverA.ServiceIndexUri);
539+
context.Settings.AddSource("B", serverB.ServiceIndexUri);
540+
541+
// mapping
542+
foreach (var (src, pattern) in sourceMappings)
543+
{
544+
context.Settings.AddPackageSourceMapping(src, pattern);
545+
}
546+
547+
var settings = Settings.LoadSettingsGivenConfigPaths([context.Settings.ConfigPath]);
548+
549+
var packageSources = new List<PackageSource>
550+
{
551+
new(serverA.ServiceIndexUri, "A"),
552+
new(serverB.ServiceIndexUri, "B")
553+
};
554+
555+
// args
556+
var args = new PackageDownloadArgs
557+
{
558+
Packages =
559+
[
560+
new PackageWithNuGetVersion
561+
{
562+
Id = downloadId,
563+
NuGetVersion = downloadVersion is null ? null : NuGetVersion.Parse(downloadVersion)
564+
}
565+
],
566+
OutputDirectory = context.WorkingDirectory,
567+
AllowInsecureConnections = allowInsecureConnections,
568+
Sources = sourcesArgs == null ? [] : sourcesArgs.ToList()
569+
};
570+
571+
string capturedLogs = string.Empty;
572+
var logger = new Mock<ILoggerWithColor>(MockBehavior.Loose);
573+
logger
574+
.Setup(l => l.LogError(It.IsAny<string>()))
575+
.Callback<string>(msg => capturedLogs += msg + Environment.NewLine);
576+
577+
// Act
578+
var exit = await PackageDownloadRunner.RunAsync(
579+
args,
580+
logger.Object,
581+
packageSources,
582+
settings,
583+
CancellationToken.None);
584+
585+
serverA.Stop();
586+
serverB.Stop();
587+
588+
// Assert
589+
if (expectSuccess)
590+
{
591+
exit.Should().Be(PackageDownloadRunner.ExitCodeSuccess, because: capturedLogs);
592+
expectedInstalled.Should().NotBeNull();
593+
594+
var (expId, expVer) = expectedInstalled!.Value;
595+
var installDir = Path.Combine(context.WorkingDirectory, expId.ToLowerInvariant(), expVer);
596+
Directory.Exists(installDir).Should().BeTrue();
597+
File.Exists(Path.Combine(installDir, $"{expId.ToLowerInvariant()}.{expVer}.nupkg")).Should().BeTrue();
598+
}
599+
else
600+
{
601+
exit.Should().Be(PackageDownloadRunner.ExitCodeError);
602+
}
603+
}
429604
}

test/NuGet.Core.Tests/NuGet.CommandLine.Xplat.Tests/Commands/Package/Download/PackageDownloadRunnerTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,5 +200,4 @@ public async Task ResolvePackageDownloadVersion_UnlistedPackage_BehavesAsExpecte
200200
foundRepo.Should().BeNull();
201201
}
202202
}
203-
204203
}

test/TestUtilities/Test.Utility/FileSystemBackedV3MockServer.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,21 @@ private Action<HttpListenerResponse> ServerHandlerV3(HttpListenerRequest request
9999
}
100100
else if (path.StartsWith("/flat/") && path.EndsWith(".nupkg"))
101101
{
102-
var file = new FileInfo(Path.Combine(_packageDirectory, parts.Last()));
102+
var requestedFileName = parts.Last();
103+
var directory = new DirectoryInfo(_packageDirectory);
104+
FileInfo file = null;
103105

104-
if (file.Exists)
106+
// Scan for file and ignore case to make sure this works in linux too
107+
foreach (var candidate in directory.EnumerateFiles("*.nupkg", SearchOption.TopDirectoryOnly))
108+
{
109+
if (string.Equals(candidate.Name, requestedFileName, StringComparison.OrdinalIgnoreCase))
110+
{
111+
file = candidate;
112+
break;
113+
}
114+
}
115+
116+
if (file != null && file.Exists)
105117
{
106118
return new Action<HttpListenerResponse>(response =>
107119
{

0 commit comments

Comments
 (0)