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
50 changes: 50 additions & 0 deletions src/Tests/PackageFeedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,54 @@ public async Task App_writes_download_progress_to_host_Progress()
Assert.Contains(" B", ui, StringComparison.Ordinal);
}

[Fact]
public async Task Config_credentials_authenticate_feed_requests_with_source_override()
{
using var packages = new TempNupkgs();
var handler = MapFeed(packages);
var config = Path.Combine(packages.Root, "auth.config");
File.WriteAllText(config, $"""
<configuration>
<packageSources>
<add key="Private Feed" value="{Source}" />
</packageSources>
<packageSourceCredentials>
<Private_x0020_Feed>
<add key="Username" value="user" />
<add key="ClearTextPassword" value="%NDX_TEST_FEED_PAT%" />
</Private_x0020_Feed>
</packageSourceCredentials>
</configuration>
""");
Environment.SetEnvironmentVariable("NDX_TEST_FEED_PAT", "s3cret");
using var error = new StringWriter();
var host = new NdxHost
{
HttpHandler = handler,
Error = error,
Out = new StringWriter(),
StoreDirectory = NewStore(),
ProcessRunner = new RecordingProcessRunner { ExitCode = 0 },
WorkingDirectory = packages.Root,
};

try
{
var code = await App.RunAsync(
[PackageId + "@" + VersionText, "--yes", "--source", Source, "--configfile", config], host);

Assert.Equal(0, code);
Assert.Equal("", error.ToString());
var expected = "Basic " + Convert.ToBase64String("user:s3cret"u8.ToArray());
Assert.Equal(expected, handler.Authorizations[Source]);
Assert.Equal(expected, handler.Authorizations[NupkgUrl(PackageId)]);
}
finally
{
Environment.SetEnvironmentVariable("NDX_TEST_FEED_PAT", null);
}
}

[Fact]
public async Task Http_download_succeeds_when_catalog_size_lookup_fails()
{
Expand Down Expand Up @@ -458,11 +506,13 @@ sealed class RecordingHandler : HttpMessageHandler
public Dictionary<string, Func<(HttpStatusCode Status, byte[] Body)>> Override { get; } = new(StringComparer.Ordinal);
public HashSet<string> OmitContentLength { get; } = new(StringComparer.Ordinal);
public List<string> Hits { get; } = [];
public Dictionary<string, string?> Authorizations { get; } = new(StringComparer.Ordinal);

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri?.ToString() ?? "";
Hits.Add(url);
Authorizations[url] = request.Headers.Authorization?.ToString();

if (Override.TryGetValue(url, out var factory))
{
Expand Down
163 changes: 163 additions & 0 deletions src/Tests/SourceCredentialsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using System.Net;
using ndx;

namespace Tests;

public class SourceCredentialsTests
{
[Fact]
public void Config_credentials_are_matched_to_sources_by_key()
{
var config = WriteConfig("""
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="My Feed" value="https://pkgs.test/feed/index.json" />
<add key="NoPassword" value="https://other.test/index.json" />
</packageSources>
<packageSourceCredentials>
<My_x0020_Feed>
<add key="Username" value="me" />
<add key="ClearTextPassword" value="pat" />
</My_x0020_Feed>
<NoPassword>
<add key="Username" value="me" />
</NoPassword>
</packageSourceCredentials>
</configuration>
""");

var credentials = PackageSources.ReadConfig(config).Credentials;

var credential = Assert.Single(credentials);
Assert.Equal(new SourceCredential("https://pkgs.test/feed/index.json", "me", "pat"), credential);
}

[Fact]
public void Config_without_credentials_yields_none()
{
var config = WriteConfig("""
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
""");

Assert.Empty(PackageSources.ReadConfig(config).Credentials);
}

[Fact]
public void Environment_credentials_take_precedence_over_config()
{
var suffix = Guid.NewGuid().ToString("n");
var config = WriteConfig($"""
<configuration>
<packageSources>
<add key="Feed{suffix}" value="https://pkgs.test/feed/index.json" />
<add key="Other{suffix}" value="https://other.test/index.json" />
<add key="Invalid{suffix}" value="https://invalid.test/index.json" />
</packageSources>
<packageSourceCredentials>
<Feed{suffix}>
<add key="Username" value="config" />
<add key="ClearTextPassword" value="config-pat" />
</Feed{suffix}>
</packageSourceCredentials>
</configuration>
""");

Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Feed{suffix}", "Username=env;Password=env-pat");
Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Other{suffix}", "Username=other;Password=other-pat;ValidAuthenticationTypes=basic");
Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Invalid{suffix}", "Password=pat");
try
{
var credentials = PackageSources.ReadConfig(config).Credentials;

Assert.Equal(
[
new SourceCredential("https://pkgs.test/feed/index.json", "env", "env-pat"),
new SourceCredential("https://other.test/index.json", "other", "other-pat"),
],
credentials);
}
finally
{
Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Feed{suffix}", null);
Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Other{suffix}", null);
Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Invalid{suffix}", null);
}
}

[Fact]
public void Config_credentials_expand_environment_variables()
{
var variable = "NDX_TEST_PAT_" + Guid.NewGuid().ToString("n");
var config = WriteConfig($"""
<configuration>
<packageSources>
<add key="Feed" value="https://pkgs.test/feed/index.json" />
</packageSources>
<packageSourceCredentials>
<Feed>
<add key="Username" value="me" />
<add key="ClearTextPassword" value="%{variable}%" />
</Feed>
</packageSourceCredentials>
</configuration>
""");

Environment.SetEnvironmentVariable(variable, "secret");
try
{
var credential = Assert.Single(PackageSources.ReadConfig(config).Credentials);
Assert.Equal("secret", credential.Password);
}
finally
{
Environment.SetEnvironmentVariable(variable, null);
}
}

[Fact]
public async Task Credentials_are_sent_only_to_the_source_host()
{
var inner = new CapturingHandler();
using var http = new HttpClient(new SourceCredentialsHandler(
[new SourceCredential("https://pkgs.test/org/_packaging/feed/nuget/v3/index.json", "me", "pat")])
{
InnerHandler = inner,
});

await http.GetAsync("https://pkgs.test/org/_packaging/feed/nuget/v3/flat2/tool/index.json");
await http.GetAsync("https://api.nuget.org/v3/index.json");
await http.GetAsync("http://pkgs.test/org/_packaging/feed/nuget/v3/index.json");

Assert.Equal(
"Basic " + Convert.ToBase64String("me:pat"u8.ToArray()),
inner.Authorizations["https://pkgs.test/org/_packaging/feed/nuget/v3/flat2/tool/index.json"]);
Assert.Null(inner.Authorizations["https://api.nuget.org/v3/index.json"]);
Assert.Null(inner.Authorizations["http://pkgs.test/org/_packaging/feed/nuget/v3/index.json"]);
}

static string WriteConfig(string xml)
{
var dir = Path.Combine(Path.GetTempPath(), "ndx-credentials", Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(dir);

var path = Path.Combine(dir, "nuget.config");
File.WriteAllText(path, xml);
return path;
}

sealed class CapturingHandler : HttpMessageHandler
{
public Dictionary<string, string?> Authorizations { get; } = new(StringComparer.Ordinal);

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Authorizations[request.RequestUri!.ToString()] = request.Headers.Authorization?.ToString();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request });
}
}
}
23 changes: 14 additions & 9 deletions src/ndx/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,13 @@ public static async Task<int> RunAsync(string[] args, NdxHost? host = null, Canc

try
{
using var http = CreateHttpClient(host);
var (sources, credentials) = PackageSources.Resolve(invocation, host.WorkingDirectory);
using var http = CreateHttpClient(host, credentials);

if (invocation.Update)
return await SelfUpdate.RunAsync(invocation, host, http, cancellationToken).ConfigureAwait(false);

var log = IsDetailed(invocation.Verbosity) ? host.Out : null;
var sources = PackageSources.Resolve(invocation, host.WorkingDirectory);
var progress = host.Progress ?? (host.ShowProgress ? host.Error : null);
var feed = new PackageFeed(http, invocation.IgnoreFailedSources, log, progress);
var muxer = host.DotnetMuxer ?? DotnetMuxer.Resolve();
Expand Down Expand Up @@ -134,13 +134,18 @@ public static async Task<int> RunAsync(string[] args, NdxHost? host = null, Canc
}
}

static HttpClient CreateHttpClient(NdxHost host)
=> host.HttpHandler is { } handler
? new HttpClient(handler, disposeHandler: false)
: new HttpClient(new SocketsHttpHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All,
});
static HttpClient CreateHttpClient(NdxHost host, IReadOnlyList<SourceCredential> credentials)
{
var handler = host.HttpHandler ?? new SocketsHttpHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All,
};

if (credentials.Count > 0)
handler = new SourceCredentialsHandler(credentials) { InnerHandler = handler };

return new HttpClient(handler, disposeHandler: host.HttpHandler is null);
}

static bool IsDetailed(string? verbosity)
=> verbosity?.ToLowerInvariant() is "detailed" or "diagnostic" or "d" or "diag";
Expand Down
Loading
Loading