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(() => 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 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 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(() => 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(() => registry.PushAsync("quorate/test", layout.Path, "test", "test")).ConfigureAwait(false); } [TestMethod] public void RejectsBadChallenges() { Assert.ThrowsExactly(() => BearerChallenge.Parse( new AuthenticationHeaderValue("Bearer", "realm=\"http://auth.example/token\""))); Assert.ThrowsExactly(() => 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(() => 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(() => 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 Protocols { get; } = []; public int RedirectHits { get; private set; } public int TokenRequests => Volatile.Read(ref tokenRequests); public static async Task 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(); string address = server.Features.Get()!.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 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 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 { ["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 UploadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Uri? UploadLocation { get; init; } public ConcurrentBag Uploaded { get; } = []; public ConcurrentQueue Manifests { get; } = []; protected override void Dispose(bool disposing) { Volatile.Write(ref disposed, disposing); base.Dispose(disposing); } protected override async Task 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 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 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 ReadAsync( Memory 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(); }