764 lines
29 KiB
C#
764 lines
29 KiB
C#
|
|
using System.Collections.Concurrent;
|
||
|
|
using System.Net;
|
||
|
|
using System.Net.Http.Headers;
|
||
|
|
using System.Security.Cryptography;
|
||
|
|
using System.Security.Cryptography.X509Certificates;
|
||
|
|
using System.Text;
|
||
|
|
using System.Text.Json;
|
||
|
|
using Microsoft.AspNetCore.Builder;
|
||
|
|
using Microsoft.AspNetCore.Hosting;
|
||
|
|
using Microsoft.AspNetCore.Hosting.Server;
|
||
|
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||
|
|
using Microsoft.AspNetCore.Http;
|
||
|
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||
|
|
using Microsoft.Extensions.DependencyInjection;
|
||
|
|
using Microsoft.Extensions.Logging;
|
||
|
|
using Microsoft.Net.Http.Headers;
|
||
|
|
using Quorate.Oci;
|
||
|
|
|
||
|
|
namespace Quorate.Oci.Tests;
|
||
|
|
|
||
|
|
[TestClass]
|
||
|
|
public sealed class RegistryTests
|
||
|
|
{
|
||
|
|
[TestMethod]
|
||
|
|
public async Task PublishesLayoutWithConcurrentAuthentication()
|
||
|
|
{
|
||
|
|
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||
|
|
using var handler = new RegistryHandler(layout.ManifestDigest);
|
||
|
|
using var registry = new Registry(
|
||
|
|
new Uri("https://registry.example"),
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
handler);
|
||
|
|
|
||
|
|
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
|
||
|
|
Assert.AreEqual(layout.ManifestDigest, published);
|
||
|
|
Assert.AreEqual(2, handler.TokenRequests);
|
||
|
|
Assert.HasCount(2, handler.Uploaded);
|
||
|
|
Assert.HasCount(2, handler.Manifests);
|
||
|
|
Assert.AreEqual(layout.ManifestDigest, handler.Manifests.Last());
|
||
|
|
Assert.AreEqual(nameof(RegistryAuth), new RegistryAuth("writer", "password").ToString());
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public async Task RejectsCancelledPublication()
|
||
|
|
{
|
||
|
|
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||
|
|
using var handler = new RegistryHandler(layout.ManifestDigest);
|
||
|
|
using var registry = new Registry(
|
||
|
|
new Uri("https://registry.example"),
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
handler);
|
||
|
|
using var cancelled = new CancellationTokenSource();
|
||
|
|
await cancelled.CancelAsync().ConfigureAwait(false);
|
||
|
|
|
||
|
|
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||
|
|
registry.PushAsync("quorate/test", layout.Path, "test", "test", cancelled.Token))
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public void KeepsInjectedHandler()
|
||
|
|
{
|
||
|
|
using var handler = new RegistryHandler("sha256:" + new string('0', 64));
|
||
|
|
|
||
|
|
using (var registry = new Registry(
|
||
|
|
new Uri("https://registry.example"),
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
handler))
|
||
|
|
{
|
||
|
|
}
|
||
|
|
|
||
|
|
Assert.IsFalse(handler.Disposed);
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public async Task CancelsActiveHash()
|
||
|
|
{
|
||
|
|
var stream = new BlockingStream();
|
||
|
|
var cancelled = new CancellationTokenSource();
|
||
|
|
try
|
||
|
|
{
|
||
|
|
Task<OciDigest> hashing = OciDigest.ComputeAsync(stream, cancelled.Token);
|
||
|
|
await stream.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||
|
|
await cancelled.CancelAsync().ConfigureAwait(false);
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await hashing.ConfigureAwait(false);
|
||
|
|
Assert.Fail("hashing ignored cancellation");
|
||
|
|
}
|
||
|
|
catch (OperationCanceledException)
|
||
|
|
{
|
||
|
|
}
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
cancelled.Dispose();
|
||
|
|
await stream.DisposeAsync().ConfigureAwait(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public async Task CancelsActiveUpload()
|
||
|
|
{
|
||
|
|
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||
|
|
using var handler = new RegistryHandler(layout.ManifestDigest) { BlockUpload = true };
|
||
|
|
using var registry = new Registry(
|
||
|
|
new Uri("https://registry.example"),
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
handler);
|
||
|
|
using var cancelled = new CancellationTokenSource();
|
||
|
|
Task<string> publication = registry.PushAsync(
|
||
|
|
"quorate/test",
|
||
|
|
layout.Path,
|
||
|
|
"test",
|
||
|
|
"test",
|
||
|
|
cancelled.Token);
|
||
|
|
await handler.UploadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||
|
|
await cancelled.CancelAsync().ConfigureAwait(false);
|
||
|
|
|
||
|
|
await Assert.ThrowsAsync<OperationCanceledException>(() => publication).ConfigureAwait(false);
|
||
|
|
Assert.IsTrue(handler.Cancelled > 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public async Task AcceptsUploadUris()
|
||
|
|
{
|
||
|
|
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||
|
|
|
||
|
|
foreach (Uri location in new[]
|
||
|
|
{
|
||
|
|
new Uri("/upload/fixed", UriKind.Relative),
|
||
|
|
new Uri("https://registry.example/upload/fixed")
|
||
|
|
})
|
||
|
|
{
|
||
|
|
using var handler = new RegistryHandler(layout.ManifestDigest) { UploadLocation = location };
|
||
|
|
using var registry = new Registry(
|
||
|
|
new Uri("https://registry.example"),
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
handler);
|
||
|
|
|
||
|
|
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
Assert.AreEqual(layout.ManifestDigest, published);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public async Task RejectsForeignUpload()
|
||
|
|
{
|
||
|
|
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||
|
|
using var handler = new RegistryHandler(layout.ManifestDigest)
|
||
|
|
{
|
||
|
|
UploadLocation = new Uri("https://other.example/upload/fixed")
|
||
|
|
};
|
||
|
|
using var registry = new Registry(
|
||
|
|
new Uri("https://registry.example"),
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
handler);
|
||
|
|
|
||
|
|
await Assert.ThrowsAsync<InvalidDataException>(() =>
|
||
|
|
registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public void RejectsBadChallenges()
|
||
|
|
{
|
||
|
|
Assert.ThrowsExactly<InvalidDataException>(() => BearerChallenge.Parse(
|
||
|
|
new AuthenticationHeaderValue("Bearer", "realm=\"http://auth.example/token\"")));
|
||
|
|
Assert.ThrowsExactly<InvalidDataException>(() => BearerChallenge.Parse(
|
||
|
|
new AuthenticationHeaderValue("Bearer", "realm=https://auth.example/token")));
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public async Task NegotiatesHttp2WithPrivateRoot()
|
||
|
|
{
|
||
|
|
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||
|
|
LoopbackRegistry server = await LoopbackRegistry.StartAsync(layout.ManifestDigest)
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
await using (server.ConfigureAwait(false))
|
||
|
|
{
|
||
|
|
using var registry = new Registry(
|
||
|
|
server.Endpoint,
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
server.Root);
|
||
|
|
|
||
|
|
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
|
||
|
|
Assert.AreEqual(layout.ManifestDigest, published);
|
||
|
|
Assert.IsFalse(server.Protocols.IsEmpty);
|
||
|
|
Assert.IsTrue(server.Protocols.All(protocol => protocol == "HTTP/2"));
|
||
|
|
Assert.AreEqual(2, server.TokenRequests);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public async Task FallsBackToHttp1()
|
||
|
|
{
|
||
|
|
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||
|
|
LoopbackRegistry server = await LoopbackRegistry.StartAsync(
|
||
|
|
layout.ManifestDigest,
|
||
|
|
HttpProtocols.Http1).ConfigureAwait(false);
|
||
|
|
await using (server.ConfigureAwait(false))
|
||
|
|
{
|
||
|
|
using var registry = new Registry(
|
||
|
|
server.Endpoint,
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
server.Root);
|
||
|
|
|
||
|
|
string published = await registry.PushAsync("quorate/test", layout.Path, "test", "test")
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
|
||
|
|
Assert.AreEqual(layout.ManifestDigest, published);
|
||
|
|
Assert.IsFalse(server.Protocols.IsEmpty);
|
||
|
|
Assert.IsTrue(server.Protocols.All(protocol => protocol == "HTTP/1.1"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public async Task RejectsWrongHost()
|
||
|
|
{
|
||
|
|
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||
|
|
LoopbackRegistry server = await LoopbackRegistry.StartAsync(layout.ManifestDigest)
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
await using (server.ConfigureAwait(false))
|
||
|
|
{
|
||
|
|
var endpoint = new UriBuilder(server.Endpoint) { Host = "localhost" };
|
||
|
|
using var registry = new Registry(
|
||
|
|
endpoint.Uri,
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
server.Root);
|
||
|
|
|
||
|
|
await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||
|
|
registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
[TestMethod]
|
||
|
|
public async Task RejectsRedirects()
|
||
|
|
{
|
||
|
|
using LayoutFixture layout = await LayoutFixture.CreateAsync().ConfigureAwait(false);
|
||
|
|
LoopbackRegistry server = await LoopbackRegistry.StartAsync(
|
||
|
|
layout.ManifestDigest,
|
||
|
|
HttpProtocols.Http2,
|
||
|
|
true).ConfigureAwait(false);
|
||
|
|
await using (server.ConfigureAwait(false))
|
||
|
|
{
|
||
|
|
using var registry = new Registry(
|
||
|
|
server.Endpoint,
|
||
|
|
new RegistryAuth("writer", "password"),
|
||
|
|
server.Root);
|
||
|
|
|
||
|
|
await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||
|
|
registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false);
|
||
|
|
Assert.AreEqual(0, server.RedirectHits);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
internal sealed class LoopbackRegistry : IAsyncDisposable
|
||
|
|
{
|
||
|
|
private readonly WebApplication application;
|
||
|
|
private readonly bool redirectUploads;
|
||
|
|
private readonly X509Certificate2 serverCertificate;
|
||
|
|
private readonly string manifestDigest;
|
||
|
|
private int tokenRequests;
|
||
|
|
|
||
|
|
private LoopbackRegistry(
|
||
|
|
WebApplication application,
|
||
|
|
X509Certificate2 root,
|
||
|
|
X509Certificate2 serverCertificate,
|
||
|
|
string manifestDigest,
|
||
|
|
bool redirectUploads)
|
||
|
|
{
|
||
|
|
this.application = application;
|
||
|
|
Root = root;
|
||
|
|
this.serverCertificate = serverCertificate;
|
||
|
|
this.manifestDigest = manifestDigest;
|
||
|
|
this.redirectUploads = redirectUploads;
|
||
|
|
}
|
||
|
|
|
||
|
|
public Uri Endpoint { get; private set; } = null!;
|
||
|
|
public X509Certificate2 Root { get; }
|
||
|
|
public ConcurrentQueue<string> Protocols { get; } = [];
|
||
|
|
public int RedirectHits { get; private set; }
|
||
|
|
public int TokenRequests => Volatile.Read(ref tokenRequests);
|
||
|
|
|
||
|
|
public static async Task<LoopbackRegistry> StartAsync(
|
||
|
|
string manifestDigest,
|
||
|
|
HttpProtocols protocols = HttpProtocols.Http2,
|
||
|
|
bool redirectUploads = false)
|
||
|
|
{
|
||
|
|
(X509Certificate2 root, X509Certificate2 serverCertificate) = CreateCertificates();
|
||
|
|
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder();
|
||
|
|
builder.Logging.ClearProviders();
|
||
|
|
builder.WebHost.ConfigureKestrel(options => options.Listen(
|
||
|
|
IPAddress.Loopback,
|
||
|
|
0,
|
||
|
|
endpoint =>
|
||
|
|
{
|
||
|
|
endpoint.Protocols = protocols;
|
||
|
|
endpoint.UseHttps(serverCertificate);
|
||
|
|
}));
|
||
|
|
WebApplication application = builder.Build();
|
||
|
|
var registry = new LoopbackRegistry(
|
||
|
|
application,
|
||
|
|
root,
|
||
|
|
serverCertificate,
|
||
|
|
manifestDigest,
|
||
|
|
redirectUploads);
|
||
|
|
application.Run(registry.HandleAsync);
|
||
|
|
await application.StartAsync().ConfigureAwait(false);
|
||
|
|
IServer server = application.Services.GetRequiredService<IServer>();
|
||
|
|
string address = server.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
|
||
|
|
registry.Endpoint = new Uri(address);
|
||
|
|
return registry;
|
||
|
|
}
|
||
|
|
|
||
|
|
public async ValueTask DisposeAsync()
|
||
|
|
{
|
||
|
|
await application.StopAsync().ConfigureAwait(false);
|
||
|
|
await application.DisposeAsync().ConfigureAwait(false);
|
||
|
|
serverCertificate.Dispose();
|
||
|
|
Root.Dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task HandleAsync(HttpContext context)
|
||
|
|
{
|
||
|
|
Protocols.Enqueue(context.Request.Protocol);
|
||
|
|
string path = context.Request.Path.Value ?? "";
|
||
|
|
string authorization = context.Request.Headers.Authorization.ToString();
|
||
|
|
|
||
|
|
if (path == "/redirected")
|
||
|
|
{
|
||
|
|
RedirectHits++;
|
||
|
|
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (path == "/token")
|
||
|
|
{
|
||
|
|
Interlocked.Increment(ref tokenRequests);
|
||
|
|
|
||
|
|
if (!authorization.StartsWith("Basic ", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
string token = context.Request.Query["scope"].ToString().EndsWith(
|
||
|
|
":pull,push",
|
||
|
|
StringComparison.Ordinal) ? "push-token" : "pull-token";
|
||
|
|
context.Response.ContentType = "application/json";
|
||
|
|
await context.Response.WriteAsync($"{{\"token\":\"{token}\"}}", context.RequestAborted)
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (context.Request.Method == HttpMethods.Head &&
|
||
|
|
path.Contains("/blobs/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
if (!authorization.StartsWith("Bearer ", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
Deny(context, "pull");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (context.Request.Method == HttpMethods.Post &&
|
||
|
|
path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
if (authorization != "Bearer push-token")
|
||
|
|
{
|
||
|
|
Deny(context, "pull,push");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||
|
|
context.Response.Headers.Append(HeaderNames.Location, "/upload/" + Guid.NewGuid().ToString("N"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (context.Request.Method == HttpMethods.Put &&
|
||
|
|
path.StartsWith("/upload/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
if (redirectUploads)
|
||
|
|
{
|
||
|
|
context.Response.StatusCode = StatusCodes.Status307TemporaryRedirect;
|
||
|
|
context.Response.Headers.Location = "/redirected";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
byte[] content = await ReadBodyAsync(context).ConfigureAwait(false);
|
||
|
|
string digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content));
|
||
|
|
|
||
|
|
if (digest != context.Request.Query["digest"])
|
||
|
|
{
|
||
|
|
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
Created(context, digest);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (context.Request.Method == HttpMethods.Delete &&
|
||
|
|
path.StartsWith("/upload/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
context.Response.StatusCode = StatusCodes.Status202Accepted;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (context.Request.Method == HttpMethods.Put &&
|
||
|
|
path.Contains("/manifests/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
byte[] content = await ReadBodyAsync(context).ConfigureAwait(false);
|
||
|
|
Created(context, "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content)));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (context.Request.Method == HttpMethods.Head &&
|
||
|
|
path.EndsWith("/manifests/test", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
context.Response.StatusCode = StatusCodes.Status200OK;
|
||
|
|
context.Response.Headers.Append("Docker-Content-Digest", manifestDigest);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async Task<byte[]> ReadBodyAsync(HttpContext context)
|
||
|
|
{
|
||
|
|
using var content = new MemoryStream();
|
||
|
|
await context.Request.Body.CopyToAsync(content, context.RequestAborted).ConfigureAwait(false);
|
||
|
|
return content.ToArray();
|
||
|
|
}
|
||
|
|
|
||
|
|
private void Deny(HttpContext context, string actions)
|
||
|
|
{
|
||
|
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||
|
|
context.Response.Headers.Append(
|
||
|
|
HeaderNames.WWWAuthenticate,
|
||
|
|
$"Bearer realm=\"{Endpoint}token\",service=\"loopback\",scope=\"repository:quorate/test:{actions}\"");
|
||
|
|
}
|
||
|
|
|
||
|
|
private static void Created(HttpContext context, string digest)
|
||
|
|
{
|
||
|
|
context.Response.StatusCode = StatusCodes.Status201Created;
|
||
|
|
context.Response.Headers.Append("Docker-Content-Digest", digest);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static (X509Certificate2 Root, X509Certificate2 Server) CreateCertificates()
|
||
|
|
{
|
||
|
|
DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||
|
|
DateTimeOffset notAfter = notBefore.AddHours(1);
|
||
|
|
using RSA rootKey = RSA.Create(2048);
|
||
|
|
var rootRequest = new CertificateRequest(
|
||
|
|
"CN=Quorate OCI Test Root",
|
||
|
|
rootKey,
|
||
|
|
HashAlgorithmName.SHA256,
|
||
|
|
RSASignaturePadding.Pkcs1);
|
||
|
|
rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||
|
|
rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||
|
|
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
|
||
|
|
true));
|
||
|
|
rootRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(rootRequest.PublicKey, false));
|
||
|
|
using X509Certificate2 rootWithKey = rootRequest.CreateSelfSigned(notBefore, notAfter);
|
||
|
|
X509Certificate2 root = X509CertificateLoader.LoadCertificate(
|
||
|
|
rootWithKey.Export(X509ContentType.Cert));
|
||
|
|
using RSA serverKey = RSA.Create(2048);
|
||
|
|
var serverRequest = new CertificateRequest(
|
||
|
|
"CN=127.0.0.1",
|
||
|
|
serverKey,
|
||
|
|
HashAlgorithmName.SHA256,
|
||
|
|
RSASignaturePadding.Pkcs1);
|
||
|
|
serverRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||
|
|
serverRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||
|
|
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
|
||
|
|
true));
|
||
|
|
var usages = new OidCollection { new("1.3.6.1.5.5.7.3.1") };
|
||
|
|
serverRequest.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(usages, true));
|
||
|
|
var names = new SubjectAlternativeNameBuilder();
|
||
|
|
names.AddIpAddress(IPAddress.Loopback);
|
||
|
|
serverRequest.CertificateExtensions.Add(names.Build());
|
||
|
|
byte[] serial = RandomNumberGenerator.GetBytes(16);
|
||
|
|
using X509Certificate2 publicServer = serverRequest.Create(
|
||
|
|
rootWithKey,
|
||
|
|
notBefore,
|
||
|
|
notAfter,
|
||
|
|
serial);
|
||
|
|
X509Certificate2 server = publicServer.CopyWithPrivateKey(serverKey);
|
||
|
|
return (root, server);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
internal sealed class LayoutFixture : IDisposable
|
||
|
|
{
|
||
|
|
private LayoutFixture(string path, string manifestDigest)
|
||
|
|
{
|
||
|
|
Path = path;
|
||
|
|
ManifestDigest = manifestDigest;
|
||
|
|
}
|
||
|
|
|
||
|
|
public string Path { get; }
|
||
|
|
public string ManifestDigest { get; }
|
||
|
|
|
||
|
|
public static async Task<LayoutFixture> CreateAsync()
|
||
|
|
{
|
||
|
|
string root = System.IO.Path.Combine(
|
||
|
|
System.IO.Path.GetTempPath(),
|
||
|
|
"oci-tests-" + Guid.NewGuid().ToString("N"));
|
||
|
|
Directory.CreateDirectory(System.IO.Path.Combine(root, "blobs", "sha256"));
|
||
|
|
byte[] config = Encoding.UTF8.GetBytes("{}");
|
||
|
|
byte[] layer = Encoding.UTF8.GetBytes("layer");
|
||
|
|
string configDigest = AddBlob(config);
|
||
|
|
string layerDigest = AddBlob(layer);
|
||
|
|
byte[] manifest = JsonSerializer.SerializeToUtf8Bytes(new
|
||
|
|
{
|
||
|
|
schemaVersion = 2,
|
||
|
|
mediaType = "application/vnd.oci.image.manifest.v1+json",
|
||
|
|
config = new
|
||
|
|
{
|
||
|
|
mediaType = "application/vnd.oci.image.config.v1+json",
|
||
|
|
digest = configDigest,
|
||
|
|
size = config.Length
|
||
|
|
},
|
||
|
|
layers = new[]
|
||
|
|
{
|
||
|
|
new
|
||
|
|
{
|
||
|
|
mediaType = "application/vnd.oci.image.layer.v1.tar+gzip",
|
||
|
|
digest = layerDigest,
|
||
|
|
size = layer.Length
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
string manifestDigest = AddBlob(manifest);
|
||
|
|
byte[] index = JsonSerializer.SerializeToUtf8Bytes(new
|
||
|
|
{
|
||
|
|
schemaVersion = 2,
|
||
|
|
manifests = new[]
|
||
|
|
{
|
||
|
|
new
|
||
|
|
{
|
||
|
|
mediaType = "application/vnd.oci.image.manifest.v1+json",
|
||
|
|
digest = manifestDigest,
|
||
|
|
size = manifest.Length,
|
||
|
|
annotations = new Dictionary<string, string>
|
||
|
|
{
|
||
|
|
["org.opencontainers.image.ref.name"] = "test"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
await File.WriteAllBytesAsync(System.IO.Path.Combine(root, "index.json"), index)
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
return new LayoutFixture(root, manifestDigest);
|
||
|
|
|
||
|
|
string AddBlob(byte[] content)
|
||
|
|
{
|
||
|
|
string digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content));
|
||
|
|
File.WriteAllBytes(System.IO.Path.Combine(root, "blobs", "sha256", digest[7..]), content);
|
||
|
|
return digest;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public void Dispose() => Directory.Delete(Path, true);
|
||
|
|
}
|
||
|
|
|
||
|
|
internal sealed class RegistryHandler(string manifestDigest) : HttpMessageHandler
|
||
|
|
{
|
||
|
|
private int cancelled;
|
||
|
|
private bool disposed;
|
||
|
|
private int tokenRequests;
|
||
|
|
|
||
|
|
public bool BlockUpload { get; init; }
|
||
|
|
public int Cancelled => Volatile.Read(ref cancelled);
|
||
|
|
public bool Disposed => Volatile.Read(ref disposed);
|
||
|
|
public int TokenRequests => Volatile.Read(ref tokenRequests);
|
||
|
|
public TaskCompletionSource<bool> UploadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
|
|
public Uri? UploadLocation { get; init; }
|
||
|
|
public ConcurrentBag<string> Uploaded { get; } = [];
|
||
|
|
public ConcurrentQueue<string> Manifests { get; } = [];
|
||
|
|
|
||
|
|
protected override void Dispose(bool disposing)
|
||
|
|
{
|
||
|
|
Volatile.Write(ref disposed, disposing);
|
||
|
|
base.Dispose(disposing);
|
||
|
|
}
|
||
|
|
|
||
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||
|
|
HttpRequestMessage request,
|
||
|
|
CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
Assert.AreEqual(HttpVersion.Version20, request.Version);
|
||
|
|
Assert.AreEqual(HttpVersionPolicy.RequestVersionOrLower, request.VersionPolicy);
|
||
|
|
Uri uri = request.RequestUri!;
|
||
|
|
|
||
|
|
if (uri.Host == "auth.example")
|
||
|
|
{
|
||
|
|
Interlocked.Increment(ref tokenRequests);
|
||
|
|
Assert.AreEqual("Basic", request.Headers.Authorization!.Scheme);
|
||
|
|
string token = Query(uri, "scope").EndsWith(":pull,push", StringComparison.Ordinal)
|
||
|
|
? "push-token"
|
||
|
|
: "pull-token";
|
||
|
|
return Reply(HttpStatusCode.OK, request, $"{{\"token\":\"{token}\"}}");
|
||
|
|
}
|
||
|
|
|
||
|
|
string path = uri.AbsolutePath;
|
||
|
|
|
||
|
|
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal) &&
|
||
|
|
request.Headers.Authorization?.Scheme != "Bearer")
|
||
|
|
{
|
||
|
|
return Denied(request, "pull");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal) &&
|
||
|
|
request.Headers.Authorization?.Parameter != "push-token")
|
||
|
|
{
|
||
|
|
return Denied(request, "pull,push");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
return Reply(HttpStatusCode.NotFound, request);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
HttpResponseMessage started = Reply(HttpStatusCode.Accepted, request);
|
||
|
|
started.Headers.Location = UploadLocation
|
||
|
|
?? new Uri("/upload/" + Guid.NewGuid().ToString("N"), UriKind.Relative);
|
||
|
|
return started;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.Method == HttpMethod.Put && path.StartsWith("/upload/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
UploadStarted.TrySetResult(true);
|
||
|
|
|
||
|
|
if (BlockUpload)
|
||
|
|
{
|
||
|
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
string digest = Query(uri, "digest");
|
||
|
|
byte[] content = await request.Content!.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||
|
|
Assert.AreEqual(digest, "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content)));
|
||
|
|
Uploaded.Add(digest);
|
||
|
|
HttpResponseMessage completed = Reply(HttpStatusCode.Created, request);
|
||
|
|
completed.Headers.Add("Docker-Content-Digest", digest);
|
||
|
|
return completed;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.Method == HttpMethod.Delete && path.StartsWith("/upload/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
Interlocked.Increment(ref cancelled);
|
||
|
|
return Reply(HttpStatusCode.Accepted, request);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
byte[] content = await request.Content!.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||
|
|
string digest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(content));
|
||
|
|
Manifests.Enqueue(digest);
|
||
|
|
HttpResponseMessage completed = Reply(HttpStatusCode.Created, request);
|
||
|
|
completed.Headers.Add("Docker-Content-Digest", digest);
|
||
|
|
return completed;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/test", StringComparison.Ordinal))
|
||
|
|
{
|
||
|
|
HttpResponseMessage completed = Reply(HttpStatusCode.OK, request);
|
||
|
|
completed.Headers.Add("Docker-Content-Digest", manifestDigest);
|
||
|
|
return completed;
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new InvalidOperationException($"Unexpected request {request.Method} {uri}");
|
||
|
|
}
|
||
|
|
|
||
|
|
private static HttpResponseMessage Reply(
|
||
|
|
HttpStatusCode status,
|
||
|
|
HttpRequestMessage request,
|
||
|
|
string? content = null) => new(status)
|
||
|
|
{
|
||
|
|
RequestMessage = request,
|
||
|
|
Content = new StringContent(content ?? "")
|
||
|
|
};
|
||
|
|
|
||
|
|
private static HttpResponseMessage Denied(HttpRequestMessage request, string actions)
|
||
|
|
{
|
||
|
|
HttpResponseMessage denied = Reply(HttpStatusCode.Unauthorized, request);
|
||
|
|
denied.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue(
|
||
|
|
"Bearer",
|
||
|
|
$"realm=\"https://auth.example/token\",service=\"registry.example\",scope=\"repository:quorate/test:{actions}\""));
|
||
|
|
return denied;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string Query(Uri uri, string name)
|
||
|
|
{
|
||
|
|
foreach (string pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||
|
|
{
|
||
|
|
string[] fields = pair.Split('=', 2);
|
||
|
|
|
||
|
|
if (Uri.UnescapeDataString(fields[0]) == name)
|
||
|
|
{
|
||
|
|
return Uri.UnescapeDataString(fields[1]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new InvalidOperationException($"Missing query parameter {name}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
internal sealed class BlockingStream : Stream
|
||
|
|
{
|
||
|
|
public TaskCompletionSource<bool> Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
|
|
public override bool CanRead => true;
|
||
|
|
public override bool CanSeek => false;
|
||
|
|
public override bool CanWrite => false;
|
||
|
|
public override long Length => throw new NotSupportedException();
|
||
|
|
|
||
|
|
public override long Position
|
||
|
|
{
|
||
|
|
get => throw new NotSupportedException();
|
||
|
|
set => throw new NotSupportedException();
|
||
|
|
}
|
||
|
|
|
||
|
|
public override void Flush()
|
||
|
|
{
|
||
|
|
}
|
||
|
|
|
||
|
|
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||
|
|
|
||
|
|
public override async Task<int> ReadAsync(
|
||
|
|
byte[] buffer,
|
||
|
|
int offset,
|
||
|
|
int count,
|
||
|
|
CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
Started.TrySetResult(true);
|
||
|
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
public override async ValueTask<int> ReadAsync(
|
||
|
|
Memory<byte> buffer,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
Started.TrySetResult(true);
|
||
|
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||
|
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||
|
|
}
|