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 Blobs, IReadOnlyList Manifests); internal static class OciLayout { private const string RefName = "org.opencontainers.image.ref.name"; public static async Task 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(); var manifests = new List(); var complete = new HashSet(); var active = new HashSet(); 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 blobs, List manifests, HashSet complete, HashSet 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 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); }