From 7af35097a2e045a29808c2abab0b76c009a81430 Mon Sep 17 00:00:00 2001 From: nhu1997 Date: Thu, 17 Oct 2024 10:27:19 -0700 Subject: [PATCH] Changes forissue 39 --- .../InvalidDateTimeFormatException.cs | 36 + .../Exceptions/InvalidMediaTypeException.cs | 36 + .../Exceptions/InvalidPackException.cs | 36 + .../MissingArtifactTypeException.cs | 36 + src/OrasProject.Oras/Pack.cs | 333 +++++++++ tests/OrasProject.Oras.Tests/PackTest.cs | 654 ++++++++++++++++++ 6 files changed, 1131 insertions(+) create mode 100644 src/OrasProject.Oras/Exceptions/InvalidDateTimeFormatException.cs create mode 100644 src/OrasProject.Oras/Exceptions/InvalidMediaTypeException.cs create mode 100644 src/OrasProject.Oras/Exceptions/InvalidPackException.cs create mode 100644 src/OrasProject.Oras/Exceptions/MissingArtifactTypeException.cs create mode 100644 src/OrasProject.Oras/Pack.cs create mode 100644 tests/OrasProject.Oras.Tests/PackTest.cs 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/InvalidPackException.cs b/src/OrasProject.Oras/Exceptions/InvalidPackException.cs new file mode 100644 index 0000000..80146ef --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/InvalidPackException.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; + +/// +/// InvalidPackException is thrown when an exception is thrown when pack manifest. +/// +public class InvalidPackException : FormatException +{ + public InvalidPackException() + { + } + + public InvalidPackException(string? message) + : base(message) + { + } + + public InvalidPackException(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/Pack.cs b/src/OrasProject.Oras/Pack.cs new file mode 100644 index 0000000..cdf2eb6 --- /dev/null +++ b/src/OrasProject.Oras/Pack.cs @@ -0,0 +1,333 @@ +// 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; +using System.Text.RegularExpressions; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace OrasProject.Oras; + +public static class Pack +{ + // MediaTypeUnknownConfig is the default config mediaType used + // - for [Pack] when PackOptions.PackImageManifest is true and + // PackOptions.ConfigDescriptor is not specified. + // - for [PackManifest] when packManifestVersion is PackManifestVersion1_0 + // and PackManifestOptions.ConfigDescriptor is not specified. + public const string MediaTypeUnknownConfig = "application/vnd.unknown.config.v1+json"; + // MediaTypeUnknownArtifact is the default artifactType used for [Pack] + // when PackOptions.PackImageManifest is false and artifactType is + // not specified. + public const string MediaTypeUnknownArtifact = "application/vnd.unknown.artifact.v1"; + // ErrInvalidDateTimeFormat is returned by [Pack] and [PackManifest] 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 + public const string ErrInvalidDateTimeFormat = "invalid date and time format"; + // ErrMissingArtifactType is returned by [PackManifest] when + // packManifestVersion is PackManifestVersion1_1 and artifactType is + // empty and the config media type is set to + // "application/vnd.oci.empty.v1+json". + public const string ErrMissingArtifactType = "missing artifact type"; + + // PackManifestVersion represents the manifest version used for [PackManifest]. + public enum PackManifestVersion + { + // PackManifestVersion1_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 + PackManifestVersion1_0 = 1, + // PackManifestVersion1_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 + PackManifestVersion1_1 = 2 + } + + public struct PackManifestOptions + { + // MediaType SHOULD be used and this field MUST contain the media type + // "application/vnd.oci.image.manifest.v1+json" + [JsonPropertyName("mediaType")] + public string MediaType { get; set; } + + // Config is references a configuration object for a container, by digest + // It is a REQUIRED property + // Following additional restrictions: + // if this media type is unknown, consider the referenced content as arbitrary binary data, and MUST NOT attempt to parse the referenced content + // if this media type is unknown, storing or copying image manifests MUST NOT error + // MUST support at least the following media types: "application/vnd.oci.image.config.v1+json" + // For artifact, config.mediaType value MUST be set to a value specific to the artifact type or the empty value. + // If the config.mediaType is set to the empty value, the artifactType MUST be defined + [JsonPropertyName("config")] + public Descriptor Config { get; set; } + + // Layers is the layers of the manifest + // Each item in the array MUST be a descriptor + // layers SHOULD have at least one entry + // if config.mediaType is set to application/vnd.oci.image.config.v1+json, following restrictions: + // The array MUST have the base layer at index 0 + // Subsequent layers MUST then follow in stack order (i.e. from layers[0] to layers[len(layers)-1]) + // The final filesystem layout MUST match the result of applying the layers to an empty directory + // The ownership, mode, and other attributes of the initial empty directory are unspecified + // mediaType string restrictions: MUST support at least the following media types + // application/vnd.oci.image.layer.v1.tar + // application/vnd.oci.image.layer.v1.tar+gzip + // application/vnd.oci.image.layer.nondistributable.v1.tar + // application/vnd.oci.image.layer.nondistributable.v1.tar+gzip + // Implementations storing or copying image manifests MUST NOT error on encountering a mediaType that is unknown to the implementation + [JsonPropertyName("layers")] + public List Layers { get; set; } + + // Subject is the subject of the manifest. + // This option is only valid when PackManifestVersion is + // NOT PackManifestVersion1_0. + [JsonPropertyName("subject")] + public Descriptor Subject { get; set; } + + // ManifestAnnotations is OPTIONAL property contains arbitrary metadata for the image manifest + // MUST use the annotation rules + [JsonPropertyName("manifestAnnotations")] + public IDictionary ManifestAnnotations { get; set; } + + // ConfigAnnotations is the annotation map of the config descriptor. + // This option is valid only when Config is null. + [JsonPropertyName("configAnnotations")] + public IDictionary ConfigAnnotations { get; set; } + } + + // mediaTypeRegexp 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 + // ^[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}$ + private const string _mediaTypeRegexp = @"^[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}$"; + //private static readonly Regex _mediaTypeRegex = new Regex(_mediaTypeRegexp, RegexOptions.Compiled); + 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 packManifestVersion + // (Recommended value: PackManifestVersion1_1). + // + // - If packManifestVersion is [PackManifestVersion1_1]: + // artifactType MUST NOT be empty unless PackManifestOptions.ConfigDescriptor is specified. + // - If packManifestVersion is [PackManifestVersion1_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. + // + // Note: PackManifest can also pack artifact other than OCI image, but the config.mediaType value + // should not be a known OCI image config media type [PackManifestVersion1_1] + public static async Task PackManifest( + ITarget pusher, + PackManifestVersion version, + string? artifactType, + PackManifestOptions options, + CancellationToken cancellationToken = default) + { + switch (version) + { + case PackManifestVersion.PackManifestVersion1_0: + return await PackManifestV1_0(pusher, artifactType, options, cancellationToken); + case PackManifestVersion.PackManifestVersion1_1: + return await PackManifestV1_1(pusher, artifactType, options, cancellationToken); + default: + throw new NotSupportedException($"PackManifestVersion({version}) is not supported"); + } + } + + private static async Task PackManifestV1_0(ITarget pusher, string? artifactType, PackManifestOptions options, 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 PushCustomEmptyConfig(pusher, artifactType, options.ConfigAnnotations, cancellationToken); + } + + var annotations = EnsureAnnotationCreated(options.ManifestAnnotations, "org.opencontainers.image.created"); + var manifest = new Manifest + { + SchemaVersion = 2, + MediaType = "application/vnd.oci.image.manifest.v1+json", + Config = configDescriptor, + Layers = options.Layers ?? new List(), + Annotations = annotations + }; + + return await PushManifest(pusher, manifest, manifest.MediaType, manifest.Config.MediaType, manifest.Annotations, cancellationToken); + } + + private static async Task PackManifestV1_1(ITarget pusher, string? artifactType, PackManifestOptions options, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(artifactType) && (options.Config == null || options.Config.MediaType == "application/vnd.oci.empty.v1+json")) + { + 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 + { + var expectedConfigBytes = Encoding.UTF8.GetBytes("{}"); + configDescriptor = new Descriptor + { + MediaType = "application/vnd.oci.empty.v1+json", + Digest = Digest.ComputeSHA256(expectedConfigBytes), + Size = expectedConfigBytes.Length, + Data = expectedConfigBytes + }; + options.Config = configDescriptor; + var configBytes = JsonSerializer.SerializeToUtf8Bytes(new { }); + await PushIfNotExist(pusher, configDescriptor, configBytes, cancellationToken); + } + + if (options.Layers == null || options.Layers.Count == 0) + { + options.Layers ??= new List(); + // use the empty descriptor as the single layer + var expectedConfigBytes = Encoding.UTF8.GetBytes("{}"); + var emptyLayer = new Descriptor { + MediaType = "application/vnd.oci.empty.v1+json", + Digest = Digest.ComputeSHA256(expectedConfigBytes), + Data = expectedConfigBytes, + Size = expectedConfigBytes.Length + }; + options.Layers.Add(emptyLayer); + } + + var annotations = EnsureAnnotationCreated(options.ManifestAnnotations, "org.opencontainers.image.created"); + + var manifest = new Manifest + { + SchemaVersion = 2, + MediaType = "application/vnd.oci.image.manifest.v1+json", + ArtifactType = artifactType, + Subject = options.Subject, + Config = options.Config, + Layers = options.Layers, + Annotations = annotations + }; + + return await PushManifest(pusher, manifest, manifest.MediaType, manifest.ArtifactType, manifest.Annotations, cancellationToken); + } + + private static async Task PushManifest(ITarget pusher, object manifest, string mediaType, string? artifactType, IDictionary? annotations, CancellationToken cancellationToken = default) + { + var manifestJson = JsonSerializer.SerializeToUtf8Bytes(manifest); + var manifestDesc = new Descriptor { + MediaType = mediaType, + Digest = Digest.ComputeSHA256(manifestJson), + Size = manifestJson.Length + }; + manifestDesc.ArtifactType = artifactType; + manifestDesc.Annotations = annotations; + + await pusher.PushAsync(manifestDesc, new MemoryStream(manifestJson), cancellationToken); + return manifestDesc; + } + + private static void ValidateMediaType(string mediaType) + { + if (!_mediaTypeRegex.IsMatch(mediaType)) + { + throw new InvalidMediaTypeException($"{mediaType} is an invalid media type"); + } + } + +private static async Task PushCustomEmptyConfig(ITarget pusher, string mediaType, IDictionary annotations, CancellationToken cancellationToken = default) + { + var configBytes = JsonSerializer.SerializeToUtf8Bytes(new { }); + var configDescriptor = new Descriptor + { + MediaType = mediaType, + Digest = Digest.ComputeSHA256(configBytes), + Size = configBytes.Length + }; + configDescriptor.Annotations = annotations; + + await PushIfNotExist(pusher, configDescriptor, configBytes, cancellationToken); + return configDescriptor; + } + + private static async Task PushIfNotExist(ITarget pusher, Descriptor descriptor, byte[] data, CancellationToken cancellationToken = default) + { + await pusher.PushAsync(descriptor, new MemoryStream(data), cancellationToken); + } + + private static IDictionary? EnsureAnnotationCreated(IDictionary annotations, string key) + { + if (annotations is null) + { + annotations = new Dictionary(); + } + if (annotations.ContainsKey(key)) + { + if (!DateTime.TryParse(annotations[key], out _)) + { + throw new InvalidDateTimeFormatException(ErrInvalidDateTimeFormat); + } + + return annotations; + } + + var copiedAnnotations = new Dictionary(annotations); + copiedAnnotations[key] = DateTime.UtcNow.ToString("o"); + + return copiedAnnotations; + } +} \ No newline at end of file diff --git a/tests/OrasProject.Oras.Tests/PackTest.cs b/tests/OrasProject.Oras.Tests/PackTest.cs new file mode 100644 index 0000000..7b7df9a --- /dev/null +++ b/tests/OrasProject.Oras.Tests/PackTest.cs @@ -0,0 +1,654 @@ +// 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 PackTest +{ + [Fact] + public async Task TestPackManifestImageV1_0() + { + var memoryTarget = new MemoryStore(); + + // Test PackManifest + var cancellationToken = new CancellationToken(); + var manifestVersion = Pack.PackManifestVersion.PackManifestVersion1_0; + var artifactType = "application/vnd.test"; + var manifestDesc = await Pack.PackManifest(memoryTarget, manifestVersion, artifactType, new Pack.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_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(MediaType.ImageManifest, manifestBytes); + }; + var getBytes = (string data) => Encoding.UTF8.GetBytes(data); + appendBlob(MediaType.ImageConfig, getBytes("config")); // blob 0 + appendBlob(MediaType.ImageLayer, getBytes("hello world")); // blob 1 + appendBlob(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 Pack.PackManifestOptions + { + Config = configDesc, + Layers = layers, + ManifestAnnotations = annotations, + ConfigAnnotations = configAnnotations + }; + var manifestDesc = await Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifestOptions + { + Layers = layers, + ManifestAnnotations = annotations, + ConfigAnnotations = configAnnotations + }; + manifestDesc = await Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_0, artifactType, opts, cancellationToken); + + var expectedConfigDesc = new Descriptor + { + MediaType = artifactType, + Digest = Digest.ComputeSHA256(configBytes), + Size = configBytes.Length, + Annotations = configAnnotations + }; + 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); + 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 Pack.PackManifestOptions + { + Subject = subjectDesc + }; + + var exception = await Assert.ThrowsAsync(async () => + { + await Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_0, "", new Pack.PackManifestOptions(), cancellationToken); + + var rc = await memoryTarget.FetchAsync(manifestDesc, cancellationToken); + Manifest manifest = await JsonSerializer.DeserializeAsync(rc); + + // Verify artifact type and config media type + const string MediaTypeUnknownConfig = "application/vnd.unknown.config.v1+json"; + + Assert.Equal(MediaTypeUnknownConfig, manifestDesc.ArtifactType); + Assert.Equal(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 Pack.PackManifestOptions + { + Config = configDesc + }; + + try + { + var manifestDesc = Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifestOptions + { + Config = configDesc + }; + + try + { + var manifestDesc = Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifestOptions + { + ManifestAnnotations = new Dictionary + { + { "org.opencontainers.image.created", "2000/01/01 00:00:00" } + } + }; + + try + { + var manifestDesc = Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_1, artifactType, new Pack.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 = 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 Pack.PackManifestOptions + { + Subject = subjectDesc, + Layers = layers, + Config = configDesc, + ConfigAnnotations = configAnnotations, + ManifestAnnotations = annotations + }; + var manifestDesc = await Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifestOptions + { + Subject = subjectDesc, + Layers = layers, + Config = configDesc, + ConfigAnnotations = configAnnotations, + ManifestAnnotations = annotations + }; + + manifestDesc = await Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifestOptions + { + Subject = subjectDesc, + Layers = layers, + ConfigAnnotations = configAnnotations, + ManifestAnnotations = annotations + }; + + manifestDesc = await Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_1, "", new Pack.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 Pack.PackManifestOptions + { + Config = new Descriptor + { + MediaType = "application/vnd.oci.empty.v1+json", + Digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + Size = emptyConfigBytes.Length, + Data = emptyConfigBytes + } + }; + try + { + var manifestDesc = await Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifestOptions + { + Config = configDesc + }; + + try + { + var manifestDesc = Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifestOptions + { + Config = configDesc + }; + + try + { + var manifestDesc = Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 Pack.PackManifestOptions + { + ManifestAnnotations = new Dictionary + { + { "org.opencontainers.image.created", "2000/01/01 00:00:00" } + } + }; + + var artifactType = "application/vnd.test"; + try + { + var manifestDesc = Pack.PackManifest(memoryTarget, Pack.PackManifestVersion.PackManifestVersion1_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 = Pack.PackManifest(memoryTarget, (Pack.PackManifestVersion)(-1), "", new Pack.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}"); + } + } +}