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