oci_client/
manifest.rs

1//! OCI Manifest
2use std::collections::BTreeMap;
3
4use crate::{
5    client::{Config, ImageLayer},
6    sha256_digest,
7};
8
9/// The mediatype for WASM layers.
10pub const WASM_LAYER_MEDIA_TYPE: &str = "application/vnd.wasm.content.layer.v1+wasm";
11/// The mediatype for a WASM image config.
12pub const WASM_CONFIG_MEDIA_TYPE: &str = "application/vnd.wasm.config.v1+json";
13/// The mediatype for an docker v2 schema 2 manifest.
14pub const IMAGE_MANIFEST_MEDIA_TYPE: &str = "application/vnd.docker.distribution.manifest.v2+json";
15/// The mediatype for an docker v2 shema 2 manifest list.
16pub const IMAGE_MANIFEST_LIST_MEDIA_TYPE: &str =
17    "application/vnd.docker.distribution.manifest.list.v2+json";
18/// The mediatype for an OCI image index manifest.
19pub const OCI_IMAGE_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
20/// The mediatype for an OCI image manifest.
21pub const OCI_IMAGE_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
22/// The mediatype for an image config (manifest).
23pub const IMAGE_CONFIG_MEDIA_TYPE: &str = "application/vnd.oci.image.config.v1+json";
24/// The mediatype that Docker uses for image configs.
25pub const IMAGE_DOCKER_CONFIG_MEDIA_TYPE: &str = "application/vnd.docker.container.image.v1+json";
26/// The mediatype for a layer.
27pub const IMAGE_LAYER_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar";
28/// The mediatype for a layer that is gzipped.
29pub const IMAGE_LAYER_GZIP_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+gzip";
30/// The mediatype that Docker uses for a layer that is tarred.
31pub const IMAGE_DOCKER_LAYER_TAR_MEDIA_TYPE: &str = "application/vnd.docker.image.rootfs.diff.tar";
32/// The mediatype that Docker uses for a layer that is gzipped.
33pub const IMAGE_DOCKER_LAYER_GZIP_MEDIA_TYPE: &str =
34    "application/vnd.docker.image.rootfs.diff.tar.gzip";
35/// The mediatype for a layer that is nondistributable.
36pub const IMAGE_LAYER_NONDISTRIBUTABLE_MEDIA_TYPE: &str =
37    "application/vnd.oci.image.layer.nondistributable.v1.tar";
38/// The mediatype for a layer that is nondistributable and gzipped.
39pub const IMAGE_LAYER_NONDISTRIBUTABLE_GZIP_MEDIA_TYPE: &str =
40    "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip";
41
42/// An image, or image index, OCI manifest
43#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
44#[serde(untagged)]
45pub enum OciManifest {
46    /// An OCI image manifest
47    Image(OciImageManifest),
48    /// An OCI image index manifest
49    ImageIndex(OciImageIndex),
50}
51
52impl OciManifest {
53    /// Returns the appropriate content-type for each variant.
54    pub fn content_type(&self) -> &str {
55        match self {
56            OciManifest::Image(image) => {
57                image.media_type.as_deref().unwrap_or(OCI_IMAGE_MEDIA_TYPE)
58            }
59            OciManifest::ImageIndex(image) => image
60                .media_type
61                .as_deref()
62                .unwrap_or(IMAGE_MANIFEST_LIST_MEDIA_TYPE),
63        }
64    }
65}
66
67/// The OCI image manifest describes an OCI image.
68///
69/// It is part of the OCI specification, and is defined [here](https://github.com/opencontainers/image-spec/blob/main/manifest.md)
70#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct OciImageManifest {
73    /// This is a schema version.
74    ///
75    /// The specification does not specify the width of this integer.
76    /// However, the only version allowed by the specification is `2`.
77    /// So we have made this a u8.
78    pub schema_version: u8,
79
80    /// This is an optional media type describing this manifest.
81    ///
82    /// This property SHOULD be used and [remain compatible](https://github.com/opencontainers/image-spec/blob/main/media-types.md#compatibility-matrix)
83    /// with earlier versions of this specification and with other similar external formats.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub media_type: Option<String>,
86
87    /// The image configuration.
88    ///
89    /// This object is required.
90    pub config: OciDescriptor,
91
92    /// The OCI image layers
93    ///
94    /// The specification is unclear whether this is required. We have left it
95    /// required, assuming an empty vector can be used if necessary.
96    pub layers: Vec<OciDescriptor>,
97
98    /// This is an optional subject linking this manifest to another manifest
99    /// forming an association between the image manifest and the other manifest.
100    ///
101    /// NOTE: The responsibility of implementing the fall back mechanism when encountering
102    /// a registry with an [unavailable referrers API](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#referrers-tag-schema)
103    /// falls on the consumer of the client.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub subject: Option<OciDescriptor>,
106
107    /// The OCI artifact type
108    ///
109    /// This OPTIONAL property contains the type of an artifact when the manifest is used for an
110    /// artifact. This MUST be set when config.mediaType is set to the empty value. If defined,
111    /// the value MUST comply with RFC 6838, including the naming requirements in its section 4.2,
112    /// and MAY be registered with IANA. Implementations storing or copying image manifests
113    /// MUST NOT error on encountering an artifactType that is unknown to the implementation.
114    ///
115    /// Introduced in OCI Image Format spec v1.1
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub artifact_type: Option<String>,
118
119    /// The annotations for this manifest
120    ///
121    /// The specification says "If there are no annotations then this property
122    /// MUST either be absent or be an empty map."
123    /// TO accomodate either, this is optional.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub annotations: Option<BTreeMap<String, String>>,
126}
127
128impl Default for OciImageManifest {
129    fn default() -> Self {
130        OciImageManifest {
131            schema_version: 2,
132            media_type: None,
133            config: OciDescriptor::default(),
134            layers: vec![],
135            subject: None,
136            artifact_type: None,
137            annotations: None,
138        }
139    }
140}
141
142impl OciImageManifest {
143    /// Create a new OciImageManifest using the given parameters
144    ///
145    /// This can be useful to create an OCI Image Manifest with
146    /// custom annotations.
147    pub fn build(
148        layers: &[ImageLayer],
149        config: &Config,
150        annotations: Option<BTreeMap<String, String>>,
151    ) -> Self {
152        let mut manifest = OciImageManifest::default();
153
154        manifest.config.media_type = config.media_type.to_string();
155        manifest.config.size = config.data.len() as i64;
156        manifest.config.digest = sha256_digest(&config.data);
157        manifest.annotations = annotations;
158
159        for layer in layers {
160            let digest = sha256_digest(&layer.data);
161
162            let descriptor = OciDescriptor {
163                size: layer.data.len() as i64,
164                digest,
165                media_type: layer.media_type.to_string(),
166                annotations: layer.annotations.clone(),
167                ..Default::default()
168            };
169
170            manifest.layers.push(descriptor);
171        }
172
173        manifest
174    }
175}
176
177impl From<OciImageIndex> for OciManifest {
178    fn from(m: OciImageIndex) -> Self {
179        Self::ImageIndex(m)
180    }
181}
182impl From<OciImageManifest> for OciManifest {
183    fn from(m: OciImageManifest) -> Self {
184        Self::Image(m)
185    }
186}
187
188impl std::fmt::Display for OciManifest {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        match self {
191            OciManifest::Image(oci_image_manifest) => write!(f, "{}", oci_image_manifest),
192            OciManifest::ImageIndex(oci_image_index) => write!(f, "{}", oci_image_index),
193        }
194    }
195}
196
197impl std::fmt::Display for OciImageIndex {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        let media_type = self
200            .media_type
201            .clone()
202            .unwrap_or_else(|| String::from("N/A"));
203        let manifests: Vec<String> = self.manifests.iter().map(|m| m.to_string()).collect();
204        write!(
205            f,
206            "OCI Image Index( schema-version: '{}', media-type: '{}', manifests: '{}' )",
207            self.schema_version,
208            media_type,
209            manifests.join(","),
210        )
211    }
212}
213
214impl std::fmt::Display for OciImageManifest {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        let media_type = self
217            .media_type
218            .clone()
219            .unwrap_or_else(|| String::from("N/A"));
220        let annotations = self.annotations.clone().unwrap_or_default();
221        let layers: Vec<String> = self.layers.iter().map(|l| l.to_string()).collect();
222
223        write!(
224            f,
225            "OCI Image Manifest( schema-version: '{}', media-type: '{}', config: '{}', artifact-type: '{:?}', layers: '{:?}', annotations: '{:?}' )",
226            self.schema_version,
227            media_type,
228            self.config,
229            self.artifact_type,
230            layers,
231            annotations,
232        )
233    }
234}
235
236/// Versioned provides a struct with the manifest's schemaVersion and mediaType.
237/// Incoming content with unknown schema versions can be decoded against this
238/// struct to check the version.
239#[derive(Clone, Debug, serde::Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct Versioned {
242    /// schema_version is the image manifest schema that this image follows
243    pub schema_version: i32,
244
245    /// media_type is the media type of this schema.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub media_type: Option<String>,
248}
249
250/// The OCI descriptor is a generic object used to describe other objects.
251///
252/// It is defined in the [OCI Image Specification](https://github.com/opencontainers/image-spec/blob/main/descriptor.md#properties):
253#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
254#[serde(rename_all = "camelCase")]
255pub struct OciDescriptor {
256    /// The media type of this descriptor.
257    ///
258    /// Layers, config, and manifests may all have descriptors. Each
259    /// is differentiated by its mediaType.
260    ///
261    /// This REQUIRED property contains the media type of the referenced
262    /// content. Values MUST comply with RFC 6838, including the naming
263    /// requirements in its section 4.2.
264    pub media_type: String,
265    /// The SHA 256 or 512 digest of the object this describes.
266    ///
267    /// This REQUIRED property is the digest of the targeted content, conforming
268    /// to the requirements outlined in Digests. Retrieved content SHOULD be
269    /// verified against this digest when consumed via untrusted sources.
270    pub digest: String,
271    /// The size, in bytes, of the object this describes.
272    ///
273    /// This REQUIRED property specifies the size, in bytes, of the raw
274    /// content. This property exists so that a client will have an expected
275    /// size for the content before processing. If the length of the retrieved
276    /// content does not match the specified length, the content SHOULD NOT be
277    /// trusted.
278    pub size: i64,
279    /// This OPTIONAL property specifies a list of URIs from which this
280    /// object MAY be downloaded. Each entry MUST conform to RFC 3986.
281    /// Entries SHOULD use the http and https schemes, as defined in RFC 7230.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub urls: Option<Vec<String>>,
284
285    /// This OPTIONAL property contains arbitrary metadata for this descriptor.
286    /// This OPTIONAL property MUST use the annotation rules.
287    /// <https://github.com/opencontainers/image-spec/blob/main/annotations.md#rules>
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub annotations: Option<BTreeMap<String, String>>,
290}
291
292impl std::fmt::Display for OciDescriptor {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        let urls = self.urls.clone().unwrap_or_default();
295        let annotations = self.annotations.clone().unwrap_or_default();
296
297        write!(
298            f,
299            "( media-type: '{}', digest: '{}', size: '{}', urls: '{:?}', annotations: '{:?}' )",
300            self.media_type, self.digest, self.size, urls, annotations,
301        )
302    }
303}
304
305impl Default for OciDescriptor {
306    fn default() -> Self {
307        OciDescriptor {
308            media_type: IMAGE_CONFIG_MEDIA_TYPE.to_owned(),
309            digest: "".to_owned(),
310            size: 0,
311            urls: None,
312            annotations: None,
313        }
314    }
315}
316
317/// The image index is a higher-level manifest which points to specific image manifest.
318///
319/// It is part of the OCI specification, and is defined [here](https://github.com/opencontainers/image-spec/blob/main/image-index.md):
320#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
321#[serde(rename_all = "camelCase")]
322pub struct OciImageIndex {
323    /// This is a schema version.
324    ///
325    /// The specification does not specify the width of this integer.
326    /// However, the only version allowed by the specification is `2`.
327    /// So we have made this a u8.
328    pub schema_version: u8,
329
330    /// This is an optional media type describing this manifest.
331    ///
332    /// It is reserved for compatibility, but the specification does not seem
333    /// to recommend setting it.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub media_type: Option<String>,
336
337    /// This property contains a list of manifests for specific platforms.
338    /// The spec says this field must be present but the value may be an empty array.
339    pub manifests: Vec<ImageIndexEntry>,
340
341    /// This property contains the type of an artifact when the manifest is used for an artifact.
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub artifact_type: Option<String>,
344
345    /// The annotations for this manifest
346    ///
347    /// The specification says "If there are no annotations then this property
348    /// MUST either be absent or be an empty map."
349    /// TO accomodate either, this is optional.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub annotations: Option<BTreeMap<String, String>>,
352}
353
354/// The manifest entry of an `ImageIndex`.
355///
356/// It is part of the OCI specification, and is defined in the `manifests`
357/// section [here](https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions):
358#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
359#[serde(rename_all = "camelCase")]
360pub struct ImageIndexEntry {
361    /// The media type of this descriptor.
362    ///
363    /// Layers, config, and manifests may all have descriptors. Each
364    /// is differentiated by its mediaType.
365    ///
366    /// This REQUIRED property contains the media type of the referenced
367    /// content. Values MUST comply with RFC 6838, including the naming
368    /// requirements in its section 4.2.
369    pub media_type: String,
370    /// The SHA 256 or 512 digest of the object this describes.
371    ///
372    /// This REQUIRED property is the digest of the targeted content, conforming
373    /// to the requirements outlined in Digests. Retrieved content SHOULD be
374    /// verified against this digest when consumed via untrusted sources.
375    pub digest: String,
376    /// The size, in bytes, of the object this describes.
377    ///
378    /// This REQUIRED property specifies the size, in bytes, of the raw
379    /// content. This property exists so that a client will have an expected
380    /// size for the content before processing. If the length of the retrieved
381    /// content does not match the specified length, the content SHOULD NOT be
382    /// trusted.
383    pub size: i64,
384    /// This OPTIONAL property describes the minimum runtime requirements of the image.
385    /// This property SHOULD be present if its target is platform-specific.
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub platform: Option<Platform>,
388
389    /// This OPTIONAL property contains arbitrary metadata for the image index.
390    /// This OPTIONAL property MUST use the [annotation rules](https://github.com/opencontainers/image-spec/blob/main/annotations.md#rules).
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub annotations: Option<BTreeMap<String, String>>,
393}
394
395impl std::fmt::Display for ImageIndexEntry {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        let platform = self
398            .platform
399            .clone()
400            .map(|p| p.to_string())
401            .unwrap_or_else(|| String::from("N/A"));
402        let annotations = self.annotations.clone().unwrap_or_default();
403
404        write!(
405            f,
406            "(media-type: '{}', digest: '{}', size: '{}', platform: '{}', annotations: {:?})",
407            self.media_type, self.digest, self.size, platform, annotations,
408        )
409    }
410}
411
412/// Platform specific fields of an Image Index manifest entry.
413///
414/// It is part of the OCI specification, and is in the `platform`
415/// section [here](https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions):
416#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
417#[serde(rename_all = "camelCase")]
418pub struct Platform {
419    /// This REQUIRED property specifies the CPU architecture.
420    /// Image indexes SHOULD use, and implementations SHOULD understand, values
421    /// listed in the Go Language document for [`GOARCH`](https://golang.org/doc/install/source#environment).
422    pub architecture: String,
423    /// This REQUIRED property specifies the operating system.
424    /// Image indexes SHOULD use, and implementations SHOULD understand, values
425    /// listed in the Go Language document for [`GOOS`](https://golang.org/doc/install/source#environment).
426    pub os: String,
427    /// This OPTIONAL property specifies the version of the operating system
428    /// targeted by the referenced blob.
429    /// Implementations MAY refuse to use manifests where `os.version` is not known
430    /// to work with the host OS version.
431    /// Valid values are implementation-defined. e.g. `10.0.14393.1066` on `windows`.
432    #[serde(rename = "os.version")]
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub os_version: Option<String>,
435    /// This OPTIONAL property specifies an array of strings, each specifying a mandatory OS feature.
436    /// When `os` is `windows`, image indexes SHOULD use, and implementations SHOULD understand the following values:
437    /// - `win32k`: image requires `win32k.sys` on the host (Note: `win32k.sys` is missing on Nano Server)
438    ///
439    /// When `os` is not `windows`, values are implementation-defined and SHOULD be submitted to this specification for standardization.
440    #[serde(rename = "os.features")]
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub os_features: Option<Vec<String>>,
443    /// This OPTIONAL property specifies the variant of the CPU.
444    /// Image indexes SHOULD use, and implementations SHOULD understand, `variant` values listed in the [Platform Variants](#platform-variants) table.
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub variant: Option<String>,
447    /// This property is RESERVED for future versions of the specification.
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub features: Option<Vec<String>>,
450}
451
452impl std::fmt::Display for Platform {
453    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454        let os_version = self
455            .os_version
456            .clone()
457            .unwrap_or_else(|| String::from("N/A"));
458        let os_features = self.os_features.clone().unwrap_or_default();
459        let variant = self.variant.clone().unwrap_or_else(|| String::from("N/A"));
460        let features = self.os_features.clone().unwrap_or_default();
461        write!(f, "( architecture: '{}', os: '{}', os-version: '{}', os-features: '{:?}', variant: '{}', features: '{:?}' )",
462            self.architecture,
463            self.os,
464            os_version,
465            os_features,
466            variant,
467            features,
468        )
469    }
470}
471
472#[cfg(test)]
473mod test {
474    use super::*;
475
476    const TEST_MANIFEST: &str = r#"{
477        "schemaVersion": 2,
478        "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
479        "config": {
480            "mediaType": "application/vnd.docker.container.image.v1+json",
481            "size": 2,
482            "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
483        },
484        "artifactType": "application/vnd.wasm.component.v1+wasm",
485        "layers": [
486            {
487                "mediaType": "application/vnd.wasm.content.layer.v1+wasm",
488                "size": 1615998,
489                "digest": "sha256:f9c91f4c280ab92aff9eb03b279c4774a80b84428741ab20855d32004b2b983f",
490                "annotations": {
491                    "org.opencontainers.image.title": "module.wasm"
492                }
493            }
494        ]
495    }
496    "#;
497
498    #[test]
499    fn test_manifest() {
500        let manifest: OciImageManifest =
501            serde_json::from_str(TEST_MANIFEST).expect("parsed manifest");
502        assert_eq!(2, manifest.schema_version);
503        assert_eq!(
504            Some(IMAGE_MANIFEST_MEDIA_TYPE.to_owned()),
505            manifest.media_type
506        );
507        let config = manifest.config;
508        // Note that this is the Docker config media type, not the OCI one.
509        assert_eq!(IMAGE_DOCKER_CONFIG_MEDIA_TYPE.to_owned(), config.media_type);
510        assert_eq!(2, config.size);
511        assert_eq!(
512            "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a".to_owned(),
513            config.digest
514        );
515        assert_eq!(
516            "application/vnd.wasm.component.v1+wasm".to_owned(),
517            manifest.artifact_type.unwrap()
518        );
519
520        assert_eq!(1, manifest.layers.len());
521        let wasm_layer = &manifest.layers[0];
522        assert_eq!(1_615_998, wasm_layer.size);
523        assert_eq!(WASM_LAYER_MEDIA_TYPE.to_owned(), wasm_layer.media_type);
524        assert_eq!(
525            1,
526            wasm_layer
527                .annotations
528                .as_ref()
529                .expect("annotations map")
530                .len()
531        );
532    }
533}