diff --git a/src/OrasProject.Oras/Exceptions/InvalidDateTimeFormatException.cs b/src/OrasProject.Oras/Exceptions/InvalidDateTimeFormatException.cs new file mode 100644 index 0000000..76f9b34 --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/InvalidDateTimeFormatException.cs @@ -0,0 +1,36 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace OrasProject.Oras.Exceptions; + +/// +/// InvalidDateTimeFormatException is thrown when a time format is invalid. +/// +public class InvalidDateTimeFormatException : FormatException +{ + public InvalidDateTimeFormatException() + { + } + + public InvalidDateTimeFormatException(string? message) + : base(message) + { + } + + public InvalidDateTimeFormatException(string? message, Exception? inner) + : base(message, inner) + { + } +} diff --git a/src/OrasProject.Oras/Exceptions/InvalidMediaTypeException.cs b/src/OrasProject.Oras/Exceptions/InvalidMediaTypeException.cs new file mode 100644 index 0000000..3e34b42 --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/InvalidMediaTypeException.cs @@ -0,0 +1,36 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace OrasProject.Oras.Exceptions; + +/// +/// InvalidMediaTypeException is thrown when media type is invalid. +/// +public class InvalidMediaTypeException : FormatException +{ + public InvalidMediaTypeException() + { + } + + public InvalidMediaTypeException(string? message) + : base(message) + { + } + + public InvalidMediaTypeException(string? message, Exception? inner) + : base(message, inner) + { + } +} diff --git a/src/OrasProject.Oras/Exceptions/MissingArtifactTypeException.cs b/src/OrasProject.Oras/Exceptions/MissingArtifactTypeException.cs new file mode 100644 index 0000000..61b5627 --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/MissingArtifactTypeException.cs @@ -0,0 +1,36 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace OrasProject.Oras.Exceptions; + +/// +/// MissingArtifactTypeException is thrown when artifactType is not found in manifest. +/// +public class MissingArtifactTypeException : FormatException +{ + public MissingArtifactTypeException() + { + } + + public MissingArtifactTypeException(string? message) + : base(message) + { + } + + public MissingArtifactTypeException(string? message, Exception? inner) + : base(message, inner) + { + } +} diff --git a/src/OrasProject.Oras/Oci/Descriptor.cs b/src/OrasProject.Oras/Oci/Descriptor.cs index 9720e15..12fe348 100644 --- a/src/OrasProject.Oras/Oci/Descriptor.cs +++ b/src/OrasProject.Oras/Oci/Descriptor.cs @@ -11,7 +11,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; using System.Text.Json.Serialization; namespace OrasProject.Oras.Oci; @@ -47,5 +50,24 @@ public class Descriptor [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public string? ArtifactType { get; set; } + public static Descriptor Create(Span data, string mediaType) + { + byte[] byteData = data.ToArray(); + return new Descriptor + { + MediaType = mediaType, + Digest = Content.Digest.ComputeSHA256(byteData), + Size = byteData.Length + }; + } + + public static Descriptor Empty => new() + { + MediaType = Oci.MediaType.EmptyJson, + Digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + Size = 2, + Data = [0x7B, 0x7D] + }; + internal BasicDescriptor BasicDescriptor => new BasicDescriptor(MediaType, Digest, Size); } diff --git a/src/OrasProject.Oras/PackManifestOptions.cs b/src/OrasProject.Oras/PackManifestOptions.cs new file mode 100644 index 0000000..cd859c8 --- /dev/null +++ b/src/OrasProject.Oras/PackManifestOptions.cs @@ -0,0 +1,55 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using OrasProject.Oras.Oci; + +using System.Collections.Generic; +using System.Threading; + +namespace OrasProject.Oras; + +public struct PackManifestOptions +{ + public static PackManifestOptions None { get; } + + /// + /// Config references a configuration object for a container, by digest + /// For more details: https://github.com/opencontainers/image-spec/blob/v1.1.0/manifest.md#image-manifest-property-descriptions. + /// + public Descriptor? Config { get; set; } + + /// + /// Layers is an array of objects, and each object id a Content Descriptor (or simply Descriptor) + /// For more details: https://github.com/opencontainers/image-spec/blob/v1.1.0/manifest.md#image-manifest-property-descriptions. + /// + public IList? Layers { get; set; } + + /// + /// Subject is the subject of the manifest. + /// This option is invalid when PackManifestVersion is PackManifestVersion1_0. + /// + public Descriptor? Subject { get; set; } + + /// + /// ManifestAnnotations is OPTIONAL property. It contains arbitrary metadata for the image manifest + /// and MUST use the annotation rules + /// + public IDictionary? ManifestAnnotations { get; set; } + + /// + /// ConfigAnnotations is the annotation map of the config descriptor. + /// This option is valid only when Config is null. + /// + public IDictionary? ConfigAnnotations { get; set; } +} + diff --git a/src/OrasProject.Oras/Packer.cs b/src/OrasProject.Oras/Packer.cs new file mode 100644 index 0000000..f4816c2 --- /dev/null +++ b/src/OrasProject.Oras/Packer.cs @@ -0,0 +1,317 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using OrasProject.Oras.Oci; +using OrasProject.Oras.Content; +using OrasProject.Oras.Exceptions; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + + +namespace OrasProject.Oras; + +public static class Packer +{ + /// + /// ErrInvalidDateTimeFormat is returned + /// when "org.opencontainers.artifact.created" or "org.opencontainers.image.created" is provided, + /// but its value is not in RFC 3339 format. + /// Reference: https://www.rfc-editor.org/rfc/rfc3339#section-5.6 + /// + private const string _errInvalidDateTimeFormat = "invalid date and time format"; + + /// + /// ErrMissingArtifactType is returned + /// when ManifestVersion is Version1_1 and artifactType is empty + /// and the config media type is set to "application/vnd.oci.empty.v1+json". + /// + private const string _errMissingArtifactType = "missing artifact type"; + + public const string MediaTypeUnknownConfig = "application/vnd.unknown.config.v1+json"; + + public const string MediaTypeUnknownArtifact = "application/vnd.unknown.artifact.v1"; + + /// + /// ManifestVersion represents the manifest version used for PackManifest + /// + public enum ManifestVersion + { + // Version1_0 represents the OCI Image Manifest defined in image-spec v1.0.2. + // Reference: https://github.com/opencontainers/image-spec/blob/v1.0.2/manifest.md + Version1_0 = 1, + // Version1_1 represents the OCI Image Manifest defined in image-spec v1.1.0. + // Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0/manifest.md + Version1_1 = 2 + } + + /// + /// mediaTypeRegex checks the format of media types. + /// References: + /// - https://github.com/opencontainers/image-spec/blob/v1.1.0/schema/defs-descriptor.json#L7 + /// - https://datatracker.ietf.org/doc/html/rfc6838#section-4.2 + /// + private static readonly Regex _mediaTypeRegex = new Regex(@"^[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}(\+json)?$", RegexOptions.Compiled); + + /// + /// PackManifest generates an OCI Image Manifestbased on the given parameters + /// and pushes the packed manifest to a content storage/registry using pusher. + /// The version of the manifest to be packed is determined by manifestVersion + /// (Recommended value: Version1_1). + /// - If manifestVersion is Version1_1 + /// artifactType MUST NOT be empty unless PackManifestOptions.ConfigDescriptor is specified. + /// - If manifestVersion is Version1_0 + /// if PackManifestOptions.ConfigDescriptor is null, artifactType will be used as the + /// config media type; if artifactType is empty, + /// "application/vnd.unknown.config.v1+json" will be used. + /// if PackManifestOptions.ConfigDescriptor is NOT null, artifactType will be ignored. + /// + /// artifactType and PackManifestOptions.ConfigDescriptor.MediaType MUST comply with RFC 6838. + /// + /// Each time when PackManifest is called, if a time stamp is not specified, a new time + /// stamp is generated in the manifest annotations with the key ocispec.AnnotationCreated + /// (i.e. "org.opencontainers.image.created"). To make PackManifest reproducible, + /// set the key ocispec.AnnotationCreated to a fixed value in + /// opts.Annotations. The value MUST conform to RFC 3339. + /// + /// If succeeded, returns a descriptor of the packed manifest. + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task PackManifestAsync( + IPushable pusher, + ManifestVersion version, + string? artifactType, + PackManifestOptions options = default, + CancellationToken cancellationToken = default) + { + switch (version) + { + case ManifestVersion.Version1_0: + return await PackManifestV1_0Async(pusher, artifactType, options, cancellationToken); + case ManifestVersion.Version1_1: + return await PackManifestV1_1Async(pusher, artifactType, options, cancellationToken); + default: + throw new NotSupportedException($"ManifestVersion({version}) is not supported"); + } + } + + /// + /// Pack version 1.0 manifest + /// + /// + /// + /// + /// + /// + /// + private static async Task PackManifestV1_0Async(IPushable pusher, string? artifactType, PackManifestOptions options = default, CancellationToken cancellationToken = default) + { + if (options.Subject != null) + { + throw new NotSupportedException("Subject is not supported for manifest version 1.0."); + } + + Descriptor configDescriptor; + + if (options.Config != null) + { + ValidateMediaType(options.Config.MediaType); + configDescriptor = options.Config; + } + else + { + if (string.IsNullOrEmpty(artifactType)) + { + artifactType = MediaTypeUnknownConfig; + } + ValidateMediaType(artifactType); + configDescriptor = await PushCustomEmptyConfigAsync(pusher, artifactType, options.ConfigAnnotations, cancellationToken); + } + + var annotations = EnsureAnnotationCreated(options.ManifestAnnotations, "org.opencontainers.image.created"); + var manifest = new Manifest + { + SchemaVersion = 2, + MediaType = Oci.MediaType.ImageManifest, + Config = configDescriptor, + Layers = options.Layers ?? new List(), + Annotations = annotations + }; + + return await PushManifestAsync(pusher, manifest, manifest.MediaType, manifest.Config.MediaType, manifest.Annotations, cancellationToken); + } + + /// + /// Pack version 1.1 manifest + /// + /// + /// + /// + /// + /// + /// + private static async Task PackManifestV1_1Async(IPushable pusher, string? artifactType, PackManifestOptions options = default, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(artifactType) && (options.Config == null || options.Config.MediaType == MediaType.EmptyJson)) + { + throw new MissingArtifactTypeException(_errMissingArtifactType); + } else if (!string.IsNullOrEmpty(artifactType)) { + ValidateMediaType(artifactType); + } + + Descriptor configDescriptor; + + if (options.Config != null) + { + ValidateMediaType(options.Config.MediaType); + configDescriptor = options.Config; + } + else + { + configDescriptor = Descriptor.Empty; + options.Config = configDescriptor; + var configBytes = new byte[] { 0x7B, 0x7D }; + await PushIfNotExistAsync(pusher, configDescriptor, configBytes, cancellationToken); + } + + if (options.Layers == null || options.Layers.Count == 0) + { + options.Layers ??= new List(); + // use the empty descriptor as the single layer + options.Layers.Add(Descriptor.Empty); + } + + var annotations = EnsureAnnotationCreated(options.ManifestAnnotations, "org.opencontainers.image.created"); + + var manifest = new Manifest + { + SchemaVersion = 2, + MediaType = MediaType.ImageManifest, + ArtifactType = artifactType, + Subject = options.Subject, + Config = options.Config, + Layers = options.Layers, + Annotations = annotations + }; + + return await PushManifestAsync(pusher, manifest, manifest.MediaType, manifest.ArtifactType, manifest.Annotations, cancellationToken); + } + + /// + /// Save manifest to local or remote storage + /// + /// + /// + /// + /// + /// + /// + /// + private static async Task PushManifestAsync(IPushable pusher, object manifest, string mediaType, string? artifactType, IDictionary? annotations, CancellationToken cancellationToken = default) + { + var manifestJson = JsonSerializer.SerializeToUtf8Bytes(manifest); + var manifestDesc = Descriptor.Create(manifestJson, mediaType); + manifestDesc.ArtifactType = artifactType; + manifestDesc.Annotations = annotations; + + await pusher.PushAsync(manifestDesc, new MemoryStream(manifestJson), cancellationToken); + return manifestDesc; + } + + /// + /// Validate manifest media type + /// + /// + /// + private static void ValidateMediaType(string mediaType) + { + if (!_mediaTypeRegex.IsMatch(mediaType)) + { + throw new InvalidMediaTypeException($"{mediaType} is an invalid media type"); + } + } + + /// + /// Push an empty configure with unknown media type to storage + /// + /// + /// + /// + /// + /// + private static async Task PushCustomEmptyConfigAsync(IPushable pusher, string mediaType, IDictionary? annotations, CancellationToken cancellationToken = default) + { + var configBytes = JsonSerializer.SerializeToUtf8Bytes(new { }); + var configDescriptor = Descriptor.Create(configBytes, mediaType); + configDescriptor.Annotations = annotations; + + await PushIfNotExistAsync(pusher, configDescriptor, configBytes, cancellationToken); + return configDescriptor; + } + + /// + /// Push data to local or remote storage + /// + /// + /// + /// + /// + /// + private static async Task PushIfNotExistAsync(IPushable pusher, Descriptor descriptor, byte[] data, CancellationToken cancellationToken = default) + { + await pusher.PushAsync(descriptor, new MemoryStream(data), cancellationToken); + } + + /// + /// Validate the value of the key in annotations should have correct timestamp format. + /// If the key is missed, the key and current timestamp is added to the annotations + /// + /// + /// + /// + /// + private static IDictionary EnsureAnnotationCreated(IDictionary? annotations, string key) + { + if (annotations == null) + { + annotations = new Dictionary(); + } + + string? value; + if (annotations.TryGetValue(key, out value)) + { + if (!DateTime.TryParse(value, out _)) + { + throw new InvalidDateTimeFormatException(_errInvalidDateTimeFormat); + } + + return annotations; + } + + var copiedAnnotations = new Dictionary(annotations); + copiedAnnotations[key] = DateTime.UtcNow.ToString("o"); + + return copiedAnnotations; + } +} diff --git a/tests/OrasProject.Oras.Tests/PackerTest.cs b/tests/OrasProject.Oras.Tests/PackerTest.cs new file mode 100644 index 0000000..c656d87 --- /dev/null +++ b/tests/OrasProject.Oras.Tests/PackerTest.cs @@ -0,0 +1,725 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using OrasProject.Oras.Content; +using OrasProject.Oras.Oci; +using OrasProject.Oras.Exceptions; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace OrasProject.Oras.Tests; + +public class PackerTest +{ + [Fact] + public async Task TestPackManifestImageV1_0() + { + var memoryTarget = new MemoryStore(); + + // Test PackManifest + var cancellationToken = new CancellationToken(); + var manifestVersion = Packer.ManifestVersion.Version1_0; + var artifactType = "application/vnd.test"; + var manifestDesc = await Packer.PackManifestAsync(memoryTarget, manifestVersion, artifactType, new PackManifestOptions(), cancellationToken); + Assert.NotNull(manifestDesc); + + Manifest? manifest; + var rc = await memoryTarget.FetchAsync(manifestDesc, cancellationToken); + Assert.NotNull(rc); + using (rc) + { + manifest = await JsonSerializer.DeserializeAsync(rc!); + } + Assert.NotNull(manifest); + + // Verify media type + var got = manifest?.MediaType; + Assert.Equal("application/vnd.oci.image.manifest.v1+json", got); + + // Verify config + var expectedConfigData = System.Text.Encoding.UTF8.GetBytes("{}"); + var expectedConfig = new Descriptor + { + MediaType = artifactType, + Digest = Digest.ComputeSHA256(expectedConfigData), + Size = expectedConfigData.Length + }; + var expectedConfigBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(expectedConfig)); + var incomingConfig = manifest?.Config; + var incomingConfigBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(incomingConfig)); + //Assert.True(manifest.Config.Equals(expectedConfig), $"got config = {manifest.Config}, want {expectedConfig}"); + Assert.Equal(incomingConfigBytes, expectedConfigBytes); + + // Verify layers + var expectedLayers = new List(); + Assert.True(manifest!.Layers.SequenceEqual(expectedLayers), $"got layers = {manifest.Layers}, want {expectedLayers}"); + + // Verify created time annotation + Assert.True(manifest.Annotations!.TryGetValue("org.opencontainers.image.created", out var createdTime), $"Annotation \"org.opencontainers.image.created\" not found"); + Assert.True(DateTime.TryParse(createdTime, out _), $"Error parsing created time: {createdTime}"); + + // Verify descriptor annotations + Assert.True(manifestDesc.Annotations!.SequenceEqual(manifest?.Annotations!), $"got descriptor annotations = {manifestDesc.Annotations}, want {manifest!.Annotations}"); + } + + [Fact] + public async Task TestPackManifestImageV1_0WithoutPassingOptions() + { + var memoryTarget = new MemoryStore(); + + // Test PackManifest + var manifestVersion = Packer.ManifestVersion.Version1_0; + var artifactType = "application/vnd.test"; + var manifestDesc = await Packer.PackManifestAsync(memoryTarget, manifestVersion, artifactType); + Assert.NotNull(manifestDesc); + + Manifest? manifest; + var rc = await memoryTarget.FetchAsync(manifestDesc); + Assert.NotNull(rc); + using (rc) + { + manifest = await JsonSerializer.DeserializeAsync(rc!); + } + Assert.NotNull(manifest); + + // Verify media type + var got = manifest?.MediaType; + Assert.Equal("application/vnd.oci.image.manifest.v1+json", got); + + // Verify config + var expectedConfigData = System.Text.Encoding.UTF8.GetBytes("{}"); + var expectedConfig = Descriptor.Create(expectedConfigData, artifactType); + var expectedConfigBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(expectedConfig)); + var incomingConfig = manifest?.Config; + var incomingConfigBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(incomingConfig)); + //Assert.True(manifest.Config.Equals(expectedConfig), $"got config = {manifest.Config}, want {expectedConfig}"); + Assert.Equal(incomingConfigBytes, expectedConfigBytes); + + // Verify layers + var expectedLayers = new List(); + Assert.True(manifest!.Layers.SequenceEqual(expectedLayers), $"got layers = {manifest.Layers}, want {expectedLayers}"); + + // Verify created time annotation + Assert.True(manifest.Annotations!.TryGetValue("org.opencontainers.image.created", out var createdTime), $"Annotation \"org.opencontainers.image.created\" not found"); + Assert.True(DateTime.TryParse(createdTime, out _), $"Error parsing created time: {createdTime}"); + + // Verify descriptor annotations + Assert.True(manifestDesc.Annotations!.SequenceEqual(manifest?.Annotations!), $"got descriptor annotations = {manifestDesc.Annotations}, want {manifest!.Annotations}"); + } + + [Fact] + public async Task TestPackManifestImageV1_0_WithOptions() + { + var memoryTarget = new MemoryStore(); + + // Prepare test content + var cancellationToken = new CancellationToken(); + var blobs = new List(); + var descs = new List(); + var appendBlob = (string mediaType, byte[] blob) => + { + blobs.Add(blob); + var desc = new Descriptor + { + MediaType = mediaType, + Digest = Digest.ComputeSHA256(blob), + Size = blob.Length + }; + descs.Add(desc); + }; + var generateManifest = (Descriptor config, List layers) => + { + var manifest = new Manifest + { + Config = config, + Layers = layers + }; + var manifestBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifest)); + appendBlob(Oci.MediaType.ImageManifest, manifestBytes); + }; + var getBytes = (string data) => Encoding.UTF8.GetBytes(data); + appendBlob(Oci.MediaType.ImageConfig, getBytes("config")); // blob 0 + appendBlob(Oci.MediaType.ImageLayer, getBytes("hello world")); // blob 1 + appendBlob(Oci.MediaType.ImageLayer, getBytes("goodbye world")); // blob 2 + var layers = descs.GetRange(1, 2); + var configBytes = Encoding.UTF8.GetBytes("{}"); + var configDesc = new Descriptor + { + MediaType = "application/vnd.test.config", + Digest = Digest.ComputeSHA256(configBytes), + Size = configBytes.Length + }; + var configAnnotations = new Dictionary { { "foo", "bar" } }; + var annotations = new Dictionary + { + { "org.opencontainers.image.created", "2000-01-01T00:00:00Z" }, + { "foo", "bar" } + }; + var artifactType = "application/vnd.test"; + + // Test PackManifest with ConfigDescriptor + var opts = new PackManifestOptions + { + Config = configDesc, + Layers = layers, + ManifestAnnotations = annotations, + ConfigAnnotations = configAnnotations + }; + var manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_0, artifactType, opts, cancellationToken); + + var expectedManifest = new Manifest + { + SchemaVersion = 2, + MediaType = "application/vnd.oci.image.manifest.v1+json", + Config = configDesc, + Layers = layers, + Annotations = annotations + }; + var expectedManifestBytes = JsonSerializer.SerializeToUtf8Bytes(expectedManifest); + + using var rc = await memoryTarget.FetchAsync(manifestDesc, cancellationToken); + Assert.NotNull(rc); + var memoryStream = new MemoryStream(); + await rc.CopyToAsync(memoryStream); + var got = memoryStream.ToArray(); + Assert.Equal(expectedManifestBytes, got); + + // Verify descriptor + var expectedManifestDesc = new Descriptor + { + MediaType = expectedManifest.MediaType, + Digest = Digest.ComputeSHA256(expectedManifestBytes), + Size = expectedManifestBytes.Length + }; + expectedManifestDesc.ArtifactType = expectedManifest.Config.MediaType; + expectedManifestDesc.Annotations = expectedManifest.Annotations; + var expectedManifestDescBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(expectedManifestDesc)); + var manifestDescBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifestDesc)); + Assert.Equal(expectedManifestDescBytes, manifestDescBytes); + + // Test PackManifest without ConfigDescriptor + opts = new PackManifestOptions + { + Layers = layers, + ManifestAnnotations = annotations, + ConfigAnnotations = configAnnotations + }; + manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_0, artifactType, opts, cancellationToken); + + var expectedConfigDesc = new Descriptor + { + MediaType = artifactType, + Digest = Digest.ComputeSHA256(configBytes), + Annotations = configAnnotations, + Size = configBytes.Length + }; + expectedManifest = new Manifest + { + SchemaVersion = 2, + MediaType = "application/vnd.oci.image.manifest.v1+json", + Config = expectedConfigDesc, + Layers = layers, + Annotations = annotations + }; + expectedManifestBytes = JsonSerializer.SerializeToUtf8Bytes(expectedManifest); + + using var rc2 = await memoryTarget.FetchAsync(manifestDesc, cancellationToken); + Assert.NotNull(rc2); + Manifest? manifest2 = await JsonSerializer.DeserializeAsync(rc2!); + var got2 = JsonSerializer.SerializeToUtf8Bytes(manifest2); + Assert.Equal(expectedManifestBytes, got2); + + // Verify descriptor + expectedManifestDesc = new Descriptor + { + MediaType = expectedManifest.MediaType, + Digest = Digest.ComputeSHA256(expectedManifestBytes), + Size = expectedManifestBytes.Length + }; + expectedManifestDesc.ArtifactType = expectedManifest.Config.MediaType; + expectedManifestDesc.Annotations = expectedManifest.Annotations; + Assert.Equal(JsonSerializer.SerializeToUtf8Bytes(expectedManifestDesc), JsonSerializer.SerializeToUtf8Bytes(manifestDesc)); + } + + [Fact] + public async Task TestPackManifestImageV1_0_SubjectUnsupported() + { + var memoryTarget = new MemoryStore(); + + // Prepare test content + var artifactType = "application/vnd.test"; + var subjectManifest = Encoding.UTF8.GetBytes(@"{""layers"":[]}"); + var subjectDesc = new Descriptor + { + MediaType = "application/vnd.oci.image.manifest.v1+json", + Digest = Digest.ComputeSHA256(subjectManifest), + Size = subjectManifest.Length + }; + + // Test PackManifest with ConfigDescriptor + var cancellationToken = new CancellationToken(); + var opts = new PackManifestOptions + { + Subject = subjectDesc + }; + + var exception = await Assert.ThrowsAsync(async () => + { + await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_0, artifactType, opts, cancellationToken); + }); + + Assert.Equal("Subject is not supported for manifest version 1.0.", exception.Message); + } + + [Fact] + public async Task TestPackManifestImageV1_0_NoArtifactType() + { + var memoryTarget = new MemoryStore(); + var cancellationToken = new CancellationToken(); + + // Call PackManifest with empty artifact type + var manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_0, "", new PackManifestOptions(), cancellationToken); + + var rc = await memoryTarget.FetchAsync(manifestDesc, cancellationToken); + Assert.NotNull(rc); + Manifest? manifest = await JsonSerializer.DeserializeAsync(rc); + + // Verify artifact type and config media type + + Assert.Equal(Packer.MediaTypeUnknownConfig, manifestDesc.ArtifactType); + Assert.Equal(Packer.MediaTypeUnknownConfig, manifest!.Config.MediaType); + } + + [Fact] + public void TestPackManifestImageV1_0_InvalidMediaType() + { + var memoryTarget = new MemoryStore(); + var cancellationToken = new CancellationToken(); + + // Test invalid artifact type + valid config media type + string artifactType = "random"; + byte[] configBytes = System.Text.Encoding.UTF8.GetBytes("{}"); + var configDesc = new Descriptor + { + MediaType = "application/vnd.test.config", + Digest = Digest.ComputeSHA256(configBytes), + Size = configBytes.Length + }; + var opts = new PackManifestOptions + { + Config = configDesc + }; + + try + { + var manifestDesc = Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_0, artifactType, opts, cancellationToken); + } + catch (Exception ex) + { + Assert.Null(ex); // Expecting no exception + } + + // Test invalid config media type + valid artifact type + artifactType = "application/vnd.test"; + configDesc = new Descriptor + { + MediaType = "random", + Digest = Digest.ComputeSHA256(configBytes), + Size = configBytes.Length + }; + opts = new PackManifestOptions + { + Config = configDesc + }; + + try + { + var manifestDesc = Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_0, artifactType, opts, cancellationToken); + } + catch (Exception ex) + { + Assert.True(ex is InvalidMediaTypeException, $"Expected InvalidMediaTypeException but got {ex.GetType().Name}"); + } + } + + [Fact] + public void TestPackManifestImageV1_0_InvalidDateTimeFormat() + { + var memoryTarget = new MemoryStore(); + var cancellationToken = new CancellationToken(); + + var opts = new PackManifestOptions + { + ManifestAnnotations = new Dictionary + { + { "org.opencontainers.image.created", "2000/01/01 00:00:00" } + } + }; + + try + { + var manifestDesc = Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_0, "", opts, cancellationToken); + } + catch (Exception ex) + { + // Check if the caught exception is of type InvalidDateTimeFormatException + Assert.True(ex is InvalidDateTimeFormatException, $"Expected InvalidDateTimeFormatException but got {ex.GetType().Name}"); + } + } + + [Fact] + public async Task TestPackManifestImageV1_1() + { + var memoryTarget = new MemoryStore(); + var cancellationToken = new CancellationToken(); + + // Test PackManifest + var artifactType = "application/vnd.test"; + var manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, artifactType, new PackManifestOptions(), cancellationToken); + + // Fetch and decode the manifest + var rc = await memoryTarget.FetchAsync(manifestDesc, cancellationToken); + Manifest? manifest; + Assert.NotNull(rc); + using (rc) + { + manifest = await JsonSerializer.DeserializeAsync(rc); + } + + // Verify layers + var emptyConfigBytes = Encoding.UTF8.GetBytes("{}"); + var emptyJSON = Descriptor.Empty; + var expectedLayers = new List { emptyJSON }; + Assert.Equal(JsonSerializer.SerializeToUtf8Bytes(expectedLayers), JsonSerializer.SerializeToUtf8Bytes(manifest!.Layers)); + } + + [Fact] + public async Task TestPackManifestImageV1_1WithoutPassingOptions() + { + var memoryTarget = new MemoryStore(); + + // Test PackManifest + var artifactType = "application/vnd.test"; + var manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, artifactType); + + // Fetch and decode the manifest + var rc = await memoryTarget.FetchAsync(manifestDesc); + Manifest? manifest; + Assert.NotNull(rc); + using (rc) + { + manifest = await JsonSerializer.DeserializeAsync(rc); + } + + // Verify layers + var emptyConfigBytes = Encoding.UTF8.GetBytes("{}"); + var emptyJSON = new Descriptor + { + MediaType = "application/vnd.oci.empty.v1+json", + Digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + Size = emptyConfigBytes.Length, + Data = emptyConfigBytes + }; + var expectedLayers = new List { emptyJSON }; + Assert.Equal(JsonSerializer.SerializeToUtf8Bytes(expectedLayers), JsonSerializer.SerializeToUtf8Bytes(manifest!.Layers)); + } + + [Fact] + public async Task TestPackManifestImageV1_1_WithOptions() + { + var memoryTarget = new MemoryStore(); + var cancellationToken = new CancellationToken(); + + // Prepare test content + byte[] hellogBytes = System.Text.Encoding.UTF8.GetBytes("hello world"); + byte[] goodbyeBytes = System.Text.Encoding.UTF8.GetBytes("goodbye world"); + var layers = new List + { + new Descriptor + { + MediaType = "test", + Data = hellogBytes, + Digest = Digest.ComputeSHA256(hellogBytes), + Size = hellogBytes.Length + }, + new Descriptor + { + MediaType = "test", + Data = goodbyeBytes, + Digest = Digest.ComputeSHA256(goodbyeBytes), + Size = goodbyeBytes.Length + } + }; + var configBytes = System.Text.Encoding.UTF8.GetBytes("config"); + var configDesc = new Descriptor + { + MediaType = "application/vnd.test", + Data = configBytes, + Digest = Digest.ComputeSHA256(configBytes), + Size = configBytes.Length + }; + var configAnnotations = new Dictionary { { "foo", "bar" } }; + var annotations = new Dictionary + { + { "org.opencontainers.image.created", "2000-01-01T00:00:00Z" }, + { "foo", "bar" } + }; + var artifactType = "application/vnd.test"; + var subjectManifest = System.Text.Encoding.UTF8.GetBytes("{\"layers\":[]}"); + var subjectDesc = new Descriptor + { + MediaType = "application/vnd.oci.image.manifest.v1+json", + Digest = Digest.ComputeSHA256(subjectManifest), + Size = subjectManifest.Length + }; + + // Test PackManifest with ConfigDescriptor + var opts = new PackManifestOptions + { + Subject = subjectDesc, + Layers = layers, + Config = configDesc, + ConfigAnnotations = configAnnotations, + ManifestAnnotations = annotations + }; + var manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, artifactType, opts, cancellationToken); + + var expectedManifest = new Manifest + { + SchemaVersion = 2, // Historical value, doesn't pertain to OCI or Docker version + MediaType = "application/vnd.oci.image.manifest.v1+json", + ArtifactType = artifactType, + Subject = subjectDesc, + Config = configDesc, + Layers = layers, + Annotations = annotations + }; + var expectedManifestBytes = JsonSerializer.SerializeToUtf8Bytes(expectedManifest); + using var rc = await memoryTarget.FetchAsync(manifestDesc, cancellationToken); + Manifest? manifest = await JsonSerializer.DeserializeAsync(rc); + var got = JsonSerializer.SerializeToUtf8Bytes(manifest); + Assert.Equal(expectedManifestBytes, got); + + // Verify descriptor + var expectedManifestDesc = new Descriptor + { + MediaType = expectedManifest.MediaType, + Digest = Digest.ComputeSHA256(expectedManifestBytes), + Size = expectedManifestBytes.Length + }; + expectedManifestDesc.ArtifactType = expectedManifest.Config.MediaType; + expectedManifestDesc.Annotations = expectedManifest.Annotations; + var expectedManifestDescBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(expectedManifestDesc)); + var manifestDescBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifestDesc)); + Assert.Equal(expectedManifestDescBytes, manifestDescBytes); + + // Test PackManifest with ConfigDescriptor, but without artifactType + opts = new PackManifestOptions + { + Subject = subjectDesc, + Layers = layers, + Config = configDesc, + ConfigAnnotations = configAnnotations, + ManifestAnnotations = annotations + }; + + manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, null, opts, cancellationToken); + expectedManifest.ArtifactType = null; + expectedManifestBytes = JsonSerializer.SerializeToUtf8Bytes(expectedManifest); + using var rc2 = await memoryTarget.FetchAsync(manifestDesc, cancellationToken); + Manifest? manifest2 = await JsonSerializer.DeserializeAsync(rc2); + var got2 = JsonSerializer.SerializeToUtf8Bytes(manifest2); + Assert.Equal(expectedManifestBytes, got2); + + expectedManifestDesc = new Descriptor + { + MediaType = expectedManifest.MediaType, + Digest = Digest.ComputeSHA256(expectedManifestBytes), + Size = expectedManifestBytes.Length + }; + expectedManifestDesc.Annotations = expectedManifest.Annotations; + Assert.Equal(JsonSerializer.SerializeToUtf8Bytes(expectedManifestDesc), JsonSerializer.SerializeToUtf8Bytes(manifestDesc)); + + // Test Pack without ConfigDescriptor + opts = new PackManifestOptions + { + Subject = subjectDesc, + Layers = layers, + ConfigAnnotations = configAnnotations, + ManifestAnnotations = annotations + }; + + manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, artifactType, opts, cancellationToken); + var emptyConfigBytes = Encoding.UTF8.GetBytes("{}"); + var emptyJSON = new Descriptor + { + MediaType = "application/vnd.oci.empty.v1+json", + Digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + Size = emptyConfigBytes.Length, + Data = emptyConfigBytes + }; + var expectedConfigDesc = emptyJSON; + expectedManifest.ArtifactType = artifactType; + expectedManifest.Config = expectedConfigDesc; + expectedManifestBytes = JsonSerializer.SerializeToUtf8Bytes(expectedManifest); + using var rc3 = await memoryTarget.FetchAsync(manifestDesc, cancellationToken); + Manifest? manifest3 = await JsonSerializer.DeserializeAsync(rc3); + var got3 = JsonSerializer.SerializeToUtf8Bytes(manifest3); + Assert.Equal(expectedManifestBytes, got3); + + expectedManifestDesc = new Descriptor + { + MediaType = expectedManifest.MediaType, + Digest = Digest.ComputeSHA256(expectedManifestBytes), + Size = expectedManifestBytes.Length + }; + expectedManifestDesc.ArtifactType = artifactType; + expectedManifestDesc.Annotations = expectedManifest.Annotations; + Assert.Equal(JsonSerializer.SerializeToUtf8Bytes(expectedManifestDesc), JsonSerializer.SerializeToUtf8Bytes(manifestDesc)); + } + + [Fact] + public async Task TestPackManifestImageV1_1_NoArtifactType() + { + var memoryTarget = new MemoryStore(); + var cancellationToken = new CancellationToken(); + + // Test no artifact type and no config + try + { + var manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, "", new PackManifestOptions(), cancellationToken); + } + catch (Exception ex) + { + Assert.True(ex is MissingArtifactTypeException, $"Expected Artifact found in manifest without config"); + } + // Test no artifact type and config with empty media type + var emptyConfigBytes = Encoding.UTF8.GetBytes("{}"); + var opts = new PackManifestOptions + { + Config = new Descriptor + { + MediaType = "application/vnd.oci.empty.v1+json", + Digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + Size = emptyConfigBytes.Length, + Data = emptyConfigBytes + } + }; + try + { + var manifestDesc = await Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, "", opts, cancellationToken); + } + catch (Exception ex) + { + // Check if the caught exception is of type InvalidDateTimeFormatException + Assert.True(ex is MissingArtifactTypeException, $"Expected Artifact found in manifest with empty config"); + } + } + + [Fact] + public void Test_PackManifestImageV1_1_InvalidMediaType() + { + var memoryTarget = new MemoryStore(); + var cancellationToken = new CancellationToken(); + + // Test invalid artifact type + valid config media type + var artifactType = "random"; + byte[] configBytes = System.Text.Encoding.UTF8.GetBytes("{}"); + var configDesc = new Descriptor + { + MediaType = "application/vnd.test.config", + Digest = Digest.ComputeSHA256(configBytes), + Size = configBytes.Length + }; + var opts = new PackManifestOptions + { + Config = configDesc + }; + + try + { + var manifestDesc = Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, artifactType, opts, cancellationToken); + } + catch (Exception ex) + { + Assert.Null(ex); // Expecting no exception + } + + // Test invalid config media type + valid artifact type + artifactType = "application/vnd.test"; + configDesc = new Descriptor + { + MediaType = "random", + Digest = Digest.ComputeSHA256(configBytes), + Size = configBytes.Length + }; + opts = new PackManifestOptions + { + Config = configDesc + }; + + try + { + var manifestDesc = Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, artifactType, opts, cancellationToken); + } + catch (Exception ex) + { + Assert.True(ex is InvalidMediaTypeException, $"Expected InvalidMediaTypeException but got {ex.GetType().Name}"); + } + } + + [Fact] + public void TestPackManifestImageV1_1_InvalidDateTimeFormat() + { + var memoryTarget = new MemoryStore(); + var cancellationToken = new CancellationToken(); + + var opts = new PackManifestOptions + { + ManifestAnnotations = new Dictionary + { + { "org.opencontainers.image.created", "2000/01/01 00:00:00" } + } + }; + + var artifactType = "application/vnd.test"; + try + { + var manifestDesc = Packer.PackManifestAsync(memoryTarget, Packer.ManifestVersion.Version1_1, artifactType, opts, cancellationToken); + } + catch (Exception ex) + { + // Check if the caught exception is of type InvalidDateTimeFormatException + Assert.True(ex is InvalidDateTimeFormatException, $"Expected InvalidDateTimeFormatException but got {ex.GetType().Name}"); + } + + } + + [Fact] + public void TestPackManifestUnsupportedPackManifestVersion() + { + var memoryTarget = new MemoryStore(); + var cancellationToken = new CancellationToken(); + + try + { + var manifestDesc = Packer.PackManifestAsync(memoryTarget, (Packer.ManifestVersion)(-1), "", new PackManifestOptions(), cancellationToken); + } + catch (Exception ex) + { + // Check if the caught exception is of type InvalidDateTimeFormatException + Assert.True(ex is NotSupportedException, $"Expected InvalidDateTimeFormatException but got {ex.GetType().Name}"); + } + } +}