ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
8 changed files with 145336 additions and 0 deletions
3
packages/fsharp-runner/oci/AssemblyInfo.cs
Normal file
3
packages/fsharp-runner/oci/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Oci.Tests")]
|
||||
7
packages/fsharp-runner/oci/Oci.csproj
Normal file
7
packages/fsharp-runner/oci/Oci.csproj
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
209
packages/fsharp-runner/oci/OciLayout.cs
Normal file
209
packages/fsharp-runner/oci/OciLayout.cs
Normal 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);
|
||||
}
|
||||
218
packages/fsharp-runner/oci/OciTypes.cs
Normal file
218
packages/fsharp-runner/oci/OciTypes.cs
Normal 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;
|
||||
227
packages/fsharp-runner/oci/Publisher.cs
Normal file
227
packages/fsharp-runner/oci/Publisher.cs
Normal 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.");
|
||||
}
|
||||
41
packages/fsharp-runner/oci/Registry.cs
Normal file
41
packages/fsharp-runner/oci/Registry.cs
Normal 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();
|
||||
}
|
||||
120
packages/fsharp-runner/oci/RegistryAuth.cs
Normal file
120
packages/fsharp-runner/oci/RegistryAuth.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
220
packages/fsharp-runner/oci/Transport.cs
Normal file
220
packages/fsharp-runner/oci/Transport.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue