ce7cb702218693d08ea6d83464d2457c9250027c

This commit is contained in:
ms2k 2026-11-12 20:46:28 +09:00
commit 34c5f8963c
8 changed files with 145336 additions and 0 deletions

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Oci.Tests")]

View file

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,209 @@
using System.Text.Json;
namespace Quorate.Oci;
internal sealed record OciBlob(OciDigest Digest, long Size, string Path);
internal sealed record OciManifest(OciDigest Digest, string MediaType, byte[] Content);
internal sealed record OciPublicationPlan(
OciDigest RootDigest,
string RootMediaType,
byte[] RootContent,
IReadOnlyList<OciBlob> Blobs,
IReadOnlyList<OciManifest> Manifests);
internal static class OciLayout
{
private const string RefName = "org.opencontainers.image.ref.name";
public static async Task<OciPublicationPlan> LoadAsync(
string layout,
OciTag sourceTag,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(layout);
string root = Path.GetFullPath(layout);
string blobRoot = Path.Combine(root, "blobs", "sha256");
byte[] indexContent = await File.ReadAllBytesAsync(
Path.Combine(root, "index.json"),
cancellationToken).ConfigureAwait(false);
OciDocumentModel index = Deserialize(indexContent, "OCI index");
OciDescriptorModel rootDescriptor = SelectRoot(index, sourceTag);
var blobs = new Dictionary<OciDigest, OciBlob>();
var manifests = new List<OciManifest>();
var complete = new HashSet<OciDigest>();
var active = new HashSet<OciDigest>();
await VisitAsync(
blobRoot,
rootDescriptor,
blobs,
manifests,
complete,
active,
cancellationToken).ConfigureAwait(false);
OciManifest rootManifest = manifests[^1];
return new OciPublicationPlan(
rootManifest.Digest,
rootManifest.MediaType,
rootManifest.Content,
blobs.Values.OrderBy(item => item.Digest.ToString(), StringComparer.Ordinal).ToArray(),
manifests);
}
private static async Task VisitAsync(
string blobRoot,
OciDescriptorModel descriptor,
Dictionary<OciDigest, OciBlob> blobs,
List<OciManifest> manifests,
HashSet<OciDigest> complete,
HashSet<OciDigest> active,
CancellationToken cancellationToken)
{
OciDigest digest = OciDigest.Parse(descriptor.Digest);
if (complete.Contains(digest))
{
return;
}
if (!active.Add(digest))
{
throw new InvalidDataException($"OCI manifest graph contains a cycle at {digest}.");
}
string mediaType = RequireManifestType(descriptor.MediaType);
string path = BlobPath(blobRoot, digest);
byte[] content = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
ValidateSize(descriptor.Size, content.LongLength, digest);
OciDigest actual = OciDigest.Compute(content);
if (actual != digest)
{
throw new InvalidDataException($"Manifest digest {actual} does not match {digest}.");
}
OciDocumentModel document = Deserialize(content, $"OCI manifest {digest}");
if (IsIndex(mediaType))
{
OciDescriptorModel[] children = document.Manifests
?? throw new InvalidDataException($"OCI index {digest} has no manifests.");
foreach (OciDescriptorModel child in children)
{
await VisitAsync(
blobRoot,
child,
blobs,
manifests,
complete,
active,
cancellationToken).ConfigureAwait(false);
}
}
else
{
AddBlob(blobRoot, document.Config, blobs, digest, "config");
OciDescriptorModel[] layers = document.Layers
?? throw new InvalidDataException($"OCI manifest {digest} has no layers.");
foreach (OciDescriptorModel layer in layers)
{
AddBlob(blobRoot, layer, blobs, digest, "layer");
}
}
active.Remove(digest);
complete.Add(digest);
manifests.Add(new OciManifest(digest, mediaType, content));
}
private static void AddBlob(
string blobRoot,
OciDescriptorModel? descriptor,
Dictionary<OciDigest, OciBlob> blobs,
OciDigest manifest,
string kind)
{
if (descriptor is null)
{
throw new InvalidDataException($"OCI manifest {manifest} has no {kind} descriptor.");
}
OciDigest digest = OciDigest.Parse(descriptor.Digest);
var blob = new OciBlob(digest, descriptor.Size, BlobPath(blobRoot, digest));
if (blobs.TryGetValue(digest, out OciBlob? existing) && existing.Size != blob.Size)
{
throw new InvalidDataException($"OCI blob {digest} has conflicting sizes.");
}
blobs.TryAdd(digest, blob);
}
private static OciDescriptorModel SelectRoot(OciDocumentModel index, OciTag tag)
{
OciDescriptorModel[] manifests = index.Manifests
?? throw new InvalidDataException("OCI index has no manifests.");
OciDescriptorModel[] named = manifests.Where(item =>
item.Annotations?.TryGetValue(RefName, out string? name) == true &&
StringComparer.Ordinal.Equals(name, tag.ToString())).ToArray();
return named.Length switch
{
1 => named[0],
0 when manifests.Length == 1 => manifests[0],
_ => throw new InvalidDataException($"OCI layout does not contain one root for {tag}.")
};
}
private static OciDocumentModel Deserialize(byte[] content, string name)
{
try
{
OciDocumentModel value = JsonSerializer.Deserialize(
content,
OciJsonContext.Default.OciDocumentModel)
?? throw new InvalidDataException($"{name} is empty.");
if (value.SchemaVersion != 2)
{
throw new InvalidDataException($"{name} has unsupported schema version {value.SchemaVersion}.");
}
return value;
}
catch (JsonException error)
{
throw new InvalidDataException($"{name} is invalid.", error);
}
}
private static string RequireManifestType(string? mediaType) =>
mediaType switch
{
"application/vnd.docker.distribution.manifest.v2+json" => mediaType,
"application/vnd.docker.distribution.manifest.list.v2+json" => mediaType,
"application/vnd.oci.image.manifest.v1+json" => mediaType,
"application/vnd.oci.image.index.v1+json" => mediaType,
null => throw new InvalidDataException("Manifest media type is unavailable."),
_ => throw new InvalidDataException($"Unsupported manifest media type {mediaType}.")
};
private static bool IsIndex(string mediaType) =>
mediaType is "application/vnd.docker.distribution.manifest.list.v2+json" or
"application/vnd.oci.image.index.v1+json";
private static void ValidateSize(long expected, long actual, OciDigest digest)
{
if (expected < 0 || expected != actual)
{
throw new InvalidDataException($"OCI content {digest} has size {actual}, expected {expected}.");
}
}
private static string BlobPath(string root, OciDigest digest) => Path.Combine(root, digest.Hex);
}

View file

@ -0,0 +1,218 @@
using System.Buffers;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace Quorate.Oci;
internal readonly record struct OciDigest
{
private const int EncodedLength = 64;
private const string Prefix = "sha256:";
private readonly string value;
private OciDigest(string value)
{
this.value = value;
}
public string Hex => ToString()[Prefix.Length..];
public static OciDigest Parse(string? value)
{
if (value is null || value.Length != Prefix.Length + EncodedLength ||
!value.StartsWith(Prefix, StringComparison.Ordinal))
{
throw new InvalidDataException($"Invalid OCI digest {value}.");
}
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
OperationStatus status = Convert.FromHexString(
value.AsSpan(Prefix.Length),
hash,
out int consumed,
out int written);
if (status != OperationStatus.Done || consumed != EncodedLength || written != SHA256.HashSizeInBytes)
{
throw new InvalidDataException($"Invalid OCI digest {value}.");
}
foreach (char character in value.AsSpan(Prefix.Length))
{
if (char.IsAsciiLetterUpper(character))
{
throw new InvalidDataException($"Invalid OCI digest {value}.");
}
}
return new OciDigest(value);
}
public static OciDigest Compute(ReadOnlySpan<byte> content)
{
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
if (!SHA256.TryHashData(content, hash, out int written) || written != hash.Length)
{
throw new UnreachableException("SHA-256 output has an unexpected length.");
}
return FromHash(hash);
}
public static async Task<OciDigest> ComputeFileAsync(string path, CancellationToken cancellationToken)
{
using FileStream stream = new(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
131072,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return await ComputeAsync(stream, cancellationToken).ConfigureAwait(false);
}
internal static async Task<OciDigest> ComputeAsync(Stream content, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(content);
byte[] hash = await SHA256.HashDataAsync(content, cancellationToken).ConfigureAwait(false);
return FromHash(hash);
}
public override string ToString() =>
value ?? throw new InvalidOperationException("OCI digest is uninitialized.");
private static OciDigest FromHash(ReadOnlySpan<byte> hash)
{
const string hex = "0123456789abcdef";
Span<char> encoded = stackalloc char[Prefix.Length + EncodedLength];
Prefix.CopyTo(encoded);
for (int index = 0; index < hash.Length; index++)
{
byte item = hash[index];
encoded[Prefix.Length + index * 2] = hex[item >> 4];
encoded[Prefix.Length + index * 2 + 1] = hex[item & 0x0f];
}
return new OciDigest(new string(encoded));
}
}
internal readonly record struct OciTag
{
private readonly string value;
private OciTag(string value)
{
this.value = value;
}
public static OciTag Parse(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 128 || !IsFirst(value[0]))
{
throw new ArgumentException("OCI tag is invalid.", nameof(value));
}
foreach (char character in value.AsSpan(1))
{
if (!char.IsAsciiLetterOrDigit(character) && character is not ('.' or '_' or '-'))
{
throw new ArgumentException("OCI tag is invalid.", nameof(value));
}
}
return new OciTag(value);
}
public override string ToString() =>
value ?? throw new InvalidOperationException("OCI tag is uninitialized.");
private static bool IsFirst(char character) => char.IsAsciiLetterOrDigit(character) || character == '_';
}
internal readonly partial record struct RepositoryName
{
private readonly string value;
private RepositoryName(string value)
{
this.value = value;
}
public static RepositoryName Parse(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 255 || !Pattern().IsMatch(value))
{
throw new ArgumentException("Registry repository is invalid.", nameof(value));
}
return new RepositoryName(value);
}
public override string ToString() =>
value ?? throw new InvalidOperationException("Repository name is uninitialized.");
[GeneratedRegex("^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$", RegexOptions.CultureInvariant)]
private static partial Regex Pattern();
}
internal sealed record OciDescriptorModel
{
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("size")]
public long Size { get; init; }
[JsonPropertyName("annotations")]
public Dictionary<string, string>? Annotations { get; init; }
}
internal sealed record OciDocumentModel
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
[JsonPropertyName("config")]
public OciDescriptorModel? Config { get; init; }
[JsonPropertyName("layers")]
public OciDescriptorModel[]? Layers { get; init; }
[JsonPropertyName("manifests")]
public OciDescriptorModel[]? Manifests { get; init; }
}
internal sealed record DockerConfigModel
{
[JsonPropertyName("auths")]
public Dictionary<string, DockerAuthModel>? Auths { get; init; }
}
internal sealed record DockerAuthModel
{
[JsonPropertyName("auth")]
public string? Auth { get; init; }
}
internal sealed record BearerTokenModel
{
[JsonPropertyName("token")]
public string? Token { get; init; }
[JsonPropertyName("access_token")]
public string? AccessToken { get; init; }
}
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = false)]
[JsonSerializable(typeof(OciDocumentModel))]
[JsonSerializable(typeof(DockerConfigModel))]
[JsonSerializable(typeof(BearerTokenModel))]
internal sealed partial class OciJsonContext : JsonSerializerContext;

View file

@ -0,0 +1,227 @@
using System.Net;
using System.Net.Http.Headers;
namespace Quorate.Oci;
internal sealed class OciPublisher(RegistryTransport transport, RepositoryName repository)
{
private static readonly string[] ManifestTypes =
[
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.oci.image.index.v1+json"
];
private static readonly TimeSpan UploadCancelTimeout = TimeSpan.FromSeconds(5);
public async Task<OciDigest> PushAsync(
OciPublicationPlan plan,
OciTag target,
CancellationToken cancellationToken)
{
var parallel = new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = 4
};
await Parallel.ForEachAsync(plan.Blobs, parallel, async (blob, token) =>
{
await VerifyBlobAsync(blob, token).ConfigureAwait(false);
if (!await ExistsAsync(blob, token).ConfigureAwait(false))
{
await UploadAsync(blob, token).ConfigureAwait(false);
}
}).ConfigureAwait(false);
foreach (OciManifest manifest in plan.Manifests)
{
await PutManifestAsync(
manifest.Digest.ToString(),
manifest.MediaType,
manifest.Content,
manifest.Digest,
cancellationToken).ConfigureAwait(false);
}
await PutManifestAsync(
target.ToString(),
plan.RootMediaType,
plan.RootContent,
plan.RootDigest,
cancellationToken).ConfigureAwait(false);
OciDigest published = await GetDigestAsync(target, cancellationToken).ConfigureAwait(false);
if (published != plan.RootDigest)
{
throw new InvalidDataException($"Published digest {published} does not match {plan.RootDigest}.");
}
return published;
}
private async Task<bool> ExistsAsync(OciBlob blob, CancellationToken cancellationToken)
{
using HttpResponseMessage response = await transport.SendAsync(
repository,
() => RegistryTransport.Request(HttpMethod.Head, transport.Api(repository, $"blobs/{blob.Digest}")),
cancellationToken,
HttpStatusCode.NotFound).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return false;
}
OciDigest actual = HeaderDigest(response);
long? size = response.Content.Headers.ContentLength;
if (actual != blob.Digest || size != blob.Size)
{
throw new InvalidDataException($"Existing blob {blob.Digest} has invalid metadata.");
}
return true;
}
private async Task UploadAsync(OciBlob blob, CancellationToken cancellationToken)
{
using HttpResponseMessage start = await transport.SendAsync(
repository,
() => RegistryTransport.Request(HttpMethod.Post, transport.Api(repository, "blobs/uploads/")),
cancellationToken).ConfigureAwait(false);
Uri location = RegistryTransport.ResolveLocation(start);
try
{
using HttpResponseMessage completed = await transport.SendAsync(
repository,
() =>
{
HttpRequestMessage request = RegistryTransport.Request(
HttpMethod.Put,
AddDigest(location, blob.Digest));
request.Content = new StreamContent(new FileStream(
blob.Path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
131072,
FileOptions.Asynchronous | FileOptions.SequentialScan));
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
return request;
},
cancellationToken).ConfigureAwait(false);
OciDigest actual = HeaderDigest(completed);
if (actual != blob.Digest)
{
throw new InvalidDataException($"Uploaded digest {actual} does not match {blob.Digest}.");
}
}
catch (Exception)
{
await CancelUploadAsync(location).ConfigureAwait(false);
throw;
}
}
private async Task CancelUploadAsync(Uri location)
{
using var timeout = new CancellationTokenSource(UploadCancelTimeout);
try
{
using HttpResponseMessage response = await transport.SendAsync(
repository,
() => RegistryTransport.Request(HttpMethod.Delete, location),
timeout.Token,
HttpStatusCode.NotFound).ConfigureAwait(false);
}
catch (Exception error) when (error is HttpRequestException or InvalidDataException or OperationCanceledException)
{
}
}
private async Task PutManifestAsync(
string reference,
string mediaType,
byte[] content,
OciDigest expected,
CancellationToken cancellationToken)
{
using HttpResponseMessage response = await transport.SendAsync(
repository,
() =>
{
HttpRequestMessage request = RegistryTransport.Request(
HttpMethod.Put,
transport.Api(repository, $"manifests/{reference}"));
request.Content = new ByteArrayContent(content);
request.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
return request;
},
cancellationToken).ConfigureAwait(false);
OciDigest actual = HeaderDigest(response);
if (actual != expected)
{
throw new InvalidDataException($"Published digest {actual} does not match {expected}.");
}
}
private async Task<OciDigest> GetDigestAsync(OciTag reference, CancellationToken cancellationToken)
{
using HttpResponseMessage response = await transport.SendAsync(
repository,
() =>
{
HttpRequestMessage request = RegistryTransport.Request(
HttpMethod.Head,
transport.Api(repository, $"manifests/{reference}"));
foreach (string mediaType in ManifestTypes)
{
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(mediaType));
}
return request;
},
cancellationToken).ConfigureAwait(false);
return HeaderDigest(response);
}
private static async Task VerifyBlobAsync(OciBlob blob, CancellationToken cancellationToken)
{
FileInfo info = new(blob.Path);
if (!info.Exists || info.Length != blob.Size)
{
throw new InvalidDataException($"OCI blob {blob.Digest} has an invalid size.");
}
OciDigest actual = await OciDigest.ComputeFileAsync(blob.Path, cancellationToken).ConfigureAwait(false);
if (actual != blob.Digest)
{
throw new InvalidDataException($"Blob digest {actual} does not match {blob.Digest}.");
}
}
private static Uri AddDigest(Uri location, OciDigest digest)
{
var builder = new UriBuilder(location);
string pair = "digest=" + Uri.EscapeDataString(digest.ToString());
builder.Query = string.IsNullOrEmpty(builder.Query)
? pair
: builder.Query.TrimStart('?') + "&" + pair;
return builder.Uri;
}
private static OciDigest HeaderDigest(HttpResponseMessage response) =>
response.Headers.TryGetValues("Docker-Content-Digest", out IEnumerable<string>? values)
? OciDigest.Parse(values.Single())
: throw new InvalidDataException("Registry response lacks Docker-Content-Digest.");
}

View file

@ -0,0 +1,41 @@
using System.Security.Cryptography.X509Certificates;
namespace Quorate.Oci;
public sealed class Registry : IDisposable
{
private readonly RegistryTransport transport;
public Registry(Uri endpoint, RegistryAuth auth, X509Certificate2 root)
{
transport = new RegistryTransport(endpoint, auth, root);
}
internal Registry(Uri endpoint, RegistryAuth auth, HttpMessageHandler handler)
{
transport = new RegistryTransport(endpoint, auth, handler);
}
public static X509Certificate2 LoadRoot(string path) =>
X509CertificateLoader.LoadCertificateFromFile(path);
public async Task<string> PushAsync(
string repository,
string layout,
string sourceTag,
string targetTag,
CancellationToken cancellationToken = default)
{
RepositoryName destination = RepositoryName.Parse(repository);
OciTag source = OciTag.Parse(sourceTag);
OciTag target = OciTag.Parse(targetTag);
OciPublicationPlan plan = await OciLayout.LoadAsync(layout, source, cancellationToken)
.ConfigureAwait(false);
var publisher = new OciPublisher(transport, destination);
OciDigest published = await publisher.PushAsync(plan, target, cancellationToken)
.ConfigureAwait(false);
return published.ToString();
}
public void Dispose() => transport.Dispose();
}

View file

@ -0,0 +1,120 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace Quorate.Oci;
public sealed class RegistryAuth
{
internal RegistryAuth(string user, string password)
{
User = user;
Password = password;
}
internal string User { get; }
internal string Password { get; }
public static RegistryAuth Load(string path, string registry)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentException.ThrowIfNullOrWhiteSpace(registry);
try
{
using FileStream stream = File.OpenRead(path);
DockerConfigModel config = JsonSerializer.Deserialize(stream, OciJsonContext.Default.DockerConfigModel)
?? throw new InvalidDataException("Registry credential document is empty.");
Dictionary<string, DockerAuthModel> auths = config.Auths
?? throw new InvalidDataException("Registry credential document has no auths.");
if (!auths.TryGetValue(registry, out DockerAuthModel? entry) &&
!auths.TryGetValue($"https://{registry}", out entry))
{
throw new InvalidDataException($"Registry credentials are unavailable for {registry}.");
}
string encoded = entry.Auth
?? throw new InvalidDataException("Registry credentials are invalid.");
string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
int separator = decoded.IndexOf(':', StringComparison.Ordinal);
if (separator < 1)
{
throw new InvalidDataException("Registry credentials are invalid.");
}
return new RegistryAuth(decoded[..separator], decoded[(separator + 1)..]);
}
catch (Exception error) when (error is JsonException or FormatException)
{
throw new InvalidDataException("Registry credentials are invalid.", error);
}
}
public override string ToString() => nameof(RegistryAuth);
internal AuthenticationHeaderValue Basic() => new(
"Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{User}:{Password}")));
}
internal sealed record BearerChallenge(Uri Realm, string? Service, string? Scope)
{
public static BearerChallenge Parse(AuthenticationHeaderValue value)
{
if (!value.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(value.Parameter))
{
throw new InvalidDataException("Registry bearer challenge is invalid.");
}
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
int offset = 0;
string parameter = value.Parameter;
while (offset < parameter.Length)
{
while (offset < parameter.Length && (parameter[offset] == ',' || char.IsWhiteSpace(parameter[offset])))
{
offset++;
}
int equals = parameter.IndexOf('=', offset);
if (equals <= offset)
{
throw new InvalidDataException("Registry bearer challenge is invalid.");
}
string name = parameter[offset..equals].Trim();
offset = equals + 1;
if (offset >= parameter.Length || parameter[offset] != '"')
{
throw new InvalidDataException("Registry bearer challenge is invalid.");
}
int end = parameter.IndexOf('"', ++offset);
if (end < 0)
{
throw new InvalidDataException("Registry bearer challenge is invalid.");
}
values.Add(name, parameter[offset..end]);
offset = end + 1;
}
if (!values.TryGetValue("realm", out string? realmValue) ||
!Uri.TryCreate(realmValue, UriKind.Absolute, out Uri? realm) ||
realm.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidDataException("Registry token endpoint must use HTTPS.");
}
values.TryGetValue("service", out string? service);
values.TryGetValue("scope", out string? scope);
return new BearerChallenge(realm, service, scope);
}
}

View file

@ -0,0 +1,220 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
namespace Quorate.Oci;
internal sealed class RegistryTransport : IDisposable
{
private readonly ConcurrentDictionary<RepositoryName, AuthorizationState> authorization = [];
private readonly AuthenticationHeaderValue basic;
private readonly HttpClient client;
private readonly Uri endpoint;
private readonly HttpMessageHandler? ownedHandler;
public RegistryTransport(Uri endpoint, RegistryAuth auth, X509Certificate2 root)
: this(endpoint, auth, CreateHandler(root), true)
{
}
public RegistryTransport(Uri endpoint, RegistryAuth auth, HttpMessageHandler handler)
: this(endpoint, auth, handler, false)
{
}
private RegistryTransport(
Uri endpoint,
RegistryAuth auth,
HttpMessageHandler handler,
bool ownsHandler)
{
ValidateArguments(endpoint, auth);
ArgumentNullException.ThrowIfNull(handler);
this.endpoint = endpoint;
basic = auth.Basic();
ownedHandler = ownsHandler ? handler : null;
client = new HttpClient(handler, false) { Timeout = TimeSpan.FromMinutes(15) };
}
public Uri Api(RepositoryName repository, string path) => new(endpoint, $"/v2/{repository}/{path}");
public static Uri ResolveLocation(HttpResponseMessage response)
{
Uri location = response.Headers.Location
?? throw new InvalidDataException("Registry upload location is unavailable.");
Uri request = response.RequestMessage?.RequestUri
?? throw new InvalidDataException("Registry request URI is unavailable.");
Uri resolved = location.IsAbsoluteUri ? location : new Uri(request, location);
if (resolved.Scheme != request.Scheme || resolved.IdnHost != request.IdnHost || resolved.Port != request.Port)
{
throw new InvalidDataException("Registry upload location changes authority.");
}
return resolved;
}
public async Task<HttpResponseMessage> SendAsync(
RepositoryName repository,
Func<HttpRequestMessage> create,
CancellationToken cancellationToken,
HttpStatusCode? allowed = null)
{
AuthorizationState state = authorization.GetOrAdd(repository, static _ => new AuthorizationState());
for (int attempt = 0; attempt < 3; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
int generation = state.Generation;
AuthenticationHeaderValue current = state.Value ?? basic;
using HttpRequestMessage request = create();
request.Headers.Authorization = current;
HttpResponseMessage response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.Unauthorized)
{
if (!response.IsSuccessStatusCode && response.StatusCode != allowed)
{
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
HttpStatusCode status = response.StatusCode;
response.Dispose();
throw new HttpRequestException($"Registry returned {(int)status}: {body}", null, status);
}
return response;
}
AuthenticationHeaderValue challenge;
using (response)
{
challenge = response.Headers.WwwAuthenticate.Single(header =>
header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase));
}
await state.Gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (state.Generation == generation)
{
AuthenticationHeaderValue value = await GetBearerAsync(
BearerChallenge.Parse(challenge),
cancellationToken).ConfigureAwait(false);
state.Update(value);
}
}
finally
{
state.Gate.Release();
}
}
throw new InvalidDataException($"Registry authentication did not converge for {repository}.");
}
public void Dispose()
{
foreach (AuthorizationState state in authorization.Values)
{
state.Dispose();
}
client.Dispose();
ownedHandler?.Dispose();
}
public static HttpRequestMessage Request(HttpMethod method, Uri uri) => new(method, uri)
{
Version = HttpVersion.Version20,
VersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
private async Task<AuthenticationHeaderValue> GetBearerAsync(
BearerChallenge challenge,
CancellationToken cancellationToken)
{
var query = new List<string>();
if (challenge.Service is not null)
{
query.Add($"service={Uri.EscapeDataString(challenge.Service)}");
}
if (challenge.Scope is not null)
{
query.Add($"scope={Uri.EscapeDataString(challenge.Scope)}");
}
var builder = new UriBuilder(challenge.Realm) { Query = string.Join('&', query) };
using HttpRequestMessage request = Request(HttpMethod.Get, builder.Uri);
request.Headers.Authorization = basic;
using HttpResponseMessage response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
BearerTokenModel token = await JsonSerializer.DeserializeAsync(
content,
OciJsonContext.Default.BearerTokenModel,
cancellationToken).ConfigureAwait(false)
?? throw new InvalidDataException("Registry token response is empty.");
string? value = token.Token ?? token.AccessToken;
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidDataException("Registry token is unavailable.");
}
return new AuthenticationHeaderValue("Bearer", value);
}
private static SocketsHttpHandler CreateHandler(X509Certificate2 root)
{
ArgumentNullException.ThrowIfNull(root);
var policy = new X509ChainPolicy
{
TrustMode = X509ChainTrustMode.CustomRootTrust,
RevocationMode = X509RevocationMode.NoCheck
};
policy.CustomTrustStore.Add(root);
return new SocketsHttpHandler
{
AllowAutoRedirect = false,
SslOptions = new SslClientAuthenticationOptions
{
CertificateChainPolicy = policy
}
};
}
private static void ValidateArguments(Uri endpoint, RegistryAuth auth)
{
ArgumentNullException.ThrowIfNull(endpoint);
ArgumentNullException.ThrowIfNull(auth);
if (!endpoint.IsAbsoluteUri || endpoint.Scheme != Uri.UriSchemeHttps)
{
throw new ArgumentException("Registry endpoint must use HTTPS.", nameof(endpoint));
}
}
private sealed class AuthorizationState : IDisposable
{
private int generation;
private AuthenticationHeaderValue? value;
public SemaphoreSlim Gate { get; } = new(1, 1);
public AuthenticationHeaderValue? Value => Volatile.Read(ref value);
public int Generation => Volatile.Read(ref generation);
public void Update(AuthenticationHeaderValue authorization)
{
Volatile.Write(ref value, authorization);
Interlocked.Increment(ref generation);
}
public void Dispose() => Gate.Dispose();
}
}