quorate/packages/fsharp-runner/oci/OciLayout.cs

209 lines
7.3 KiB
C#
Raw Normal View History

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);
}