Skip to main content

containerregistry_image/
descriptor.rs

1//! OCI content descriptor type.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::{Digest, MediaType};
8
9/// An OCI content descriptor.
10///
11/// Descriptors are used to reference content by digest, and include
12/// the media type and size for validation.
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct Descriptor {
16    /// The media type of the referenced content.
17    pub media_type: MediaType,
18
19    /// The digest of the referenced content.
20    pub digest: Digest,
21
22    /// The size in bytes of the referenced content.
23    pub size: u64,
24
25    /// Optional URLs for downloading the content (OCI extension).
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub urls: Vec<String>,
28
29    /// Optional annotations (uses BTreeMap for deterministic serialization).
30    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31    pub annotations: BTreeMap<String, String>,
32
33    /// Optional embedded data (base64-encoded, OCI extension).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub data: Option<String>,
36
37    /// Optional platform specification (used in index manifests).
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub platform: Option<Platform>,
40}
41
42/// Platform specification for multi-arch images.
43#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Platform {
45    /// The CPU architecture.
46    pub architecture: String,
47
48    /// The operating system.
49    pub os: String,
50
51    /// Optional OS version.
52    #[serde(
53        default,
54        rename = "os.version",
55        skip_serializing_if = "Option::is_none"
56    )]
57    pub os_version: Option<String>,
58
59    /// Optional OS features.
60    #[serde(default, rename = "os.features", skip_serializing_if = "Vec::is_empty")]
61    pub os_features: Vec<String>,
62
63    /// Optional architecture variant.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub variant: Option<String>,
66
67    /// Optional features (Docker manifest list extension).
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub features: Vec<String>,
70}
71
72impl Descriptor {
73    /// Creates a new descriptor with the given media type, digest, and size.
74    pub fn new(media_type: MediaType, digest: Digest, size: u64) -> Self {
75        Self {
76            media_type,
77            digest,
78            size,
79            urls: Vec::new(),
80            annotations: BTreeMap::new(),
81            data: None,
82            platform: None,
83        }
84    }
85
86    /// Adds an annotation to the descriptor.
87    pub fn with_annotation(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
88        self.annotations.insert(key.into(), value.into());
89        self
90    }
91
92    /// Adds a URL for content download.
93    pub fn with_url(mut self, url: impl Into<String>) -> Self {
94        self.urls.push(url.into());
95        self
96    }
97
98    /// Sets the platform for the descriptor.
99    pub fn with_platform(mut self, platform: Platform) -> Self {
100        self.platform = Some(platform);
101        self
102    }
103}
104
105impl Platform {
106    /// Creates a new platform specification.
107    pub fn new(architecture: impl Into<String>, os: impl Into<String>) -> Self {
108        Self {
109            architecture: architecture.into(),
110            os: os.into(),
111            os_version: None,
112            os_features: Vec::new(),
113            variant: None,
114            features: Vec::new(),
115        }
116    }
117
118    /// Sets the variant for the platform.
119    pub fn with_variant(mut self, variant: impl Into<String>) -> Self {
120        self.variant = Some(variant.into());
121        self
122    }
123
124    /// Sets the OS version.
125    pub fn with_os_version(mut self, version: impl Into<String>) -> Self {
126        self.os_version = Some(version.into());
127        self
128    }
129
130    /// Adds an OS feature.
131    pub fn with_os_feature(mut self, feature: impl Into<String>) -> Self {
132        self.os_features.push(feature.into());
133        self
134    }
135
136    /// Adds a feature (Docker extension).
137    pub fn with_feature(mut self, feature: impl Into<String>) -> Self {
138        self.features.push(feature.into());
139        self
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_descriptor_serde() {
149        let desc = Descriptor::new(
150            MediaType::OciLayerGzip,
151            "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
152                .parse()
153                .unwrap(),
154            1234,
155        );
156
157        let json = serde_json::to_string_pretty(&desc).unwrap();
158        let parsed: Descriptor = serde_json::from_str(&json).unwrap();
159        assert_eq!(desc, parsed);
160    }
161
162    #[test]
163    fn test_descriptor_with_platform() {
164        let desc = Descriptor::new(
165            MediaType::OciManifest,
166            "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
167                .parse()
168                .unwrap(),
169            5678,
170        )
171        .with_platform(Platform::new("amd64", "linux"));
172
173        let json = serde_json::to_string(&desc).unwrap();
174        assert!(json.contains("amd64"));
175        assert!(json.contains("linux"));
176    }
177
178    #[test]
179    fn test_descriptor_with_urls() {
180        let desc = Descriptor::new(
181            MediaType::OciLayerGzip,
182            "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
183                .parse()
184                .unwrap(),
185            1234,
186        )
187        .with_url("https://example.com/blob");
188
189        let json = serde_json::to_string(&desc).unwrap();
190        assert!(json.contains("urls"));
191        assert!(json.contains("https://example.com/blob"));
192    }
193
194    #[test]
195    fn test_annotations_deterministic_order() {
196        // Add annotations in different orders, verify serialization is the same
197        let desc1 = Descriptor::new(
198            MediaType::OciLayerGzip,
199            "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
200                .parse()
201                .unwrap(),
202            1234,
203        )
204        .with_annotation("z-key", "value1")
205        .with_annotation("a-key", "value2");
206
207        let desc2 = Descriptor::new(
208            MediaType::OciLayerGzip,
209            "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
210                .parse()
211                .unwrap(),
212            1234,
213        )
214        .with_annotation("a-key", "value2")
215        .with_annotation("z-key", "value1");
216
217        let json1 = serde_json::to_string(&desc1).unwrap();
218        let json2 = serde_json::to_string(&desc2).unwrap();
219
220        assert_eq!(
221            json1, json2,
222            "annotations should serialize in deterministic order"
223        );
224    }
225}