ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
2 changed files with 145336 additions and 0 deletions
14
packages/fsharp-runner/oci-tests/Oci.Tests.csproj
Normal file
14
packages/fsharp-runner/oci-tests/Oci.Tests.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="MSTest.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>$(NoWarn);CA1515;NETSDK1188</NoWarn>
|
||||
<RunnerSourceRoot Condition="'$(RunnerSourceRoot)' == ''">..</RunnerSourceRoot>
|
||||
<TestingExtensionsProfile>None</TestingExtensionsProfile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="$(RunnerSourceRoot)/oci/Oci.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
764
packages/fsharp-runner/oci-tests/Program.cs
Normal file
764
packages/fsharp-runner/oci-tests/Program.cs
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
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();
|
||||
}
|
||||
Loading…
Reference in a new issue