209 lines
7.3 KiB
C#
209 lines
7.3 KiB
C#
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);
|
|
}
|