Rust implementation of OCI Distribution Spec with granular access control
1use regex::Regex;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Deserialize, Serialize)]
5#[serde(rename_all = "camelCase")]
6pub struct OciImageManifest {
7 pub schema_version: u32,
8 pub media_type: Option<String>,
9 pub config: Descriptor,
10 pub layers: Vec<Descriptor>,
11 #[serde(default)]
12 pub annotations: std::collections::HashMap<String, String>,
13}
14
15#[derive(Debug, Deserialize, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub struct OciImageIndex {
18 pub schema_version: u32,
19 pub media_type: Option<String>,
20 pub manifests: Vec<Descriptor>,
21 #[serde(default)]
22 pub annotations: std::collections::HashMap<String, String>,
23}
24
25#[derive(Debug, Deserialize, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct Descriptor {
28 pub media_type: String,
29 pub size: u64,
30 pub digest: String,
31 #[serde(default)]
32 pub urls: Vec<String>,
33 #[serde(default)]
34 pub annotations: std::collections::HashMap<String, String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub platform: Option<Platform>,
37}
38
39#[derive(Debug, Deserialize, Serialize)]
40pub struct Platform {
41 pub architecture: String,
42 pub os: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub os_version: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub os_features: Option<Vec<String>>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub variant: Option<String>,
49}
50
51#[derive(Debug)]
52pub enum ValidationError {
53 InvalidJson(String),
54 InvalidSchema(String),
55 InvalidDigest(String),
56 InvalidMediaType(String),
57 MissingRequiredField(String),
58 InvalidSize(String),
59}
60
61impl std::fmt::Display for ValidationError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 ValidationError::InvalidJson(msg) => write!(f, "Invalid JSON: {}", msg),
65 ValidationError::InvalidSchema(msg) => write!(f, "Invalid schema: {}", msg),
66 ValidationError::InvalidDigest(msg) => write!(f, "Invalid digest: {}", msg),
67 ValidationError::InvalidMediaType(msg) => write!(f, "Invalid media type: {}", msg),
68 ValidationError::MissingRequiredField(msg) => {
69 write!(f, "Missing required field: {}", msg)
70 }
71 ValidationError::InvalidSize(msg) => write!(f, "Invalid size: {}", msg),
72 }
73 }
74}
75
76impl std::error::Error for ValidationError {}
77
78/// Validate manifest JSON and return the detected media type
79pub fn validate_manifest(manifest_bytes: &[u8]) -> Result<String, ValidationError> {
80 // Parse as generic JSON first
81 let manifest_str = std::str::from_utf8(manifest_bytes)
82 .map_err(|e| ValidationError::InvalidJson(e.to_string()))?;
83
84 let value: serde_json::Value = serde_json::from_str(manifest_str)
85 .map_err(|e| ValidationError::InvalidJson(e.to_string()))?;
86
87 // Check schema version
88 let schema_version = value
89 .get("schemaVersion")
90 .and_then(|v| v.as_u64())
91 .ok_or_else(|| ValidationError::MissingRequiredField("schemaVersion".to_string()))?;
92
93 if schema_version != 2 {
94 return Err(ValidationError::InvalidSchema(format!(
95 "Unsupported schema version: {}",
96 schema_version
97 )));
98 }
99
100 // Detect manifest type by mediaType
101 let media_type = value
102 .get("mediaType")
103 .and_then(|v| v.as_str())
104 .unwrap_or(""); // Some manifests omit mediaType
105
106 match media_type {
107 "application/vnd.oci.image.manifest.v1+json" => {
108 validate_oci_image_manifest(manifest_str)?;
109 Ok(media_type.to_string())
110 }
111 "application/vnd.oci.image.index.v1+json" => {
112 validate_oci_image_index(manifest_str)?;
113 Ok(media_type.to_string())
114 }
115 "application/vnd.docker.distribution.manifest.v2+json" => {
116 validate_docker_manifest_v2(manifest_str)?;
117 Ok(media_type.to_string())
118 }
119 "application/vnd.docker.distribution.manifest.list.v2+json" => {
120 validate_docker_manifest_list(manifest_str)?;
121 Ok(media_type.to_string())
122 }
123 "" => {
124 // Try to infer type from content
125 if value.get("config").is_some() {
126 validate_oci_image_manifest(manifest_str)?;
127 Ok("application/vnd.oci.image.manifest.v1+json".to_string())
128 } else if value.get("manifests").is_some() {
129 validate_oci_image_index(manifest_str)?;
130 Ok("application/vnd.oci.image.index.v1+json".to_string())
131 } else {
132 Err(ValidationError::InvalidSchema(
133 "Cannot determine manifest type".to_string(),
134 ))
135 }
136 }
137 _ => Err(ValidationError::InvalidMediaType(format!(
138 "Unsupported media type: {}",
139 media_type
140 ))),
141 }
142}
143
144fn validate_oci_image_manifest(manifest_str: &str) -> Result<(), ValidationError> {
145 let manifest: OciImageManifest = serde_json::from_str(manifest_str)
146 .map_err(|e| ValidationError::InvalidSchema(e.to_string()))?;
147
148 // Validate config descriptor
149 validate_descriptor(&manifest.config)?;
150
151 // Validate layer descriptors
152 if manifest.layers.is_empty() {
153 return Err(ValidationError::InvalidSchema(
154 "Manifest must have at least one layer".to_string(),
155 ));
156 }
157
158 for layer in &manifest.layers {
159 validate_descriptor(layer)?;
160 }
161
162 Ok(())
163}
164
165fn validate_oci_image_index(manifest_str: &str) -> Result<(), ValidationError> {
166 let index: OciImageIndex = serde_json::from_str(manifest_str)
167 .map_err(|e| ValidationError::InvalidSchema(e.to_string()))?;
168
169 // Validate manifest descriptors
170 if index.manifests.is_empty() {
171 return Err(ValidationError::InvalidSchema(
172 "Image index must have at least one manifest".to_string(),
173 ));
174 }
175
176 for manifest_desc in &index.manifests {
177 validate_descriptor(manifest_desc)?;
178 }
179
180 Ok(())
181}
182
183fn validate_docker_manifest_v2(manifest_str: &str) -> Result<(), ValidationError> {
184 // Docker v2 schema is similar to OCI
185 validate_oci_image_manifest(manifest_str)
186}
187
188fn validate_docker_manifest_list(manifest_str: &str) -> Result<(), ValidationError> {
189 // Docker manifest list is similar to OCI image index
190 validate_oci_image_index(manifest_str)
191}
192
193fn validate_descriptor(desc: &Descriptor) -> Result<(), ValidationError> {
194 // Validate digest format (algorithm:hex)
195 validate_digest(&desc.digest)?;
196
197 // Validate size is non-zero
198 if desc.size == 0 {
199 return Err(ValidationError::InvalidSize(
200 "Descriptor size must be greater than 0".to_string(),
201 ));
202 }
203
204 // Validate media type is not empty
205 if desc.media_type.is_empty() {
206 return Err(ValidationError::InvalidMediaType(
207 "Descriptor media type cannot be empty".to_string(),
208 ));
209 }
210
211 Ok(())
212}
213
214fn validate_digest(digest: &str) -> Result<(), ValidationError> {
215 lazy_static::lazy_static! {
216 static ref DIGEST_REGEX: Regex = Regex::new(r"^[a-z0-9]+:[a-f0-9]{32,}$").unwrap();
217 }
218
219 if !DIGEST_REGEX.is_match(digest) {
220 return Err(ValidationError::InvalidDigest(format!(
221 "Invalid digest format: {}",
222 digest
223 )));
224 }
225
226 // Check common algorithms
227 if !digest.starts_with("sha256:") && !digest.starts_with("sha512:") {
228 return Err(ValidationError::InvalidDigest(format!(
229 "Unsupported digest algorithm in: {}",
230 digest
231 )));
232 }
233
234 Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_valid_oci_manifest() {
243 let manifest = r#"{
244 "schemaVersion": 2,
245 "mediaType": "application/vnd.oci.image.manifest.v1+json",
246 "config": {
247 "mediaType": "application/vnd.oci.image.config.v1+json",
248 "size": 123,
249 "digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
250 },
251 "layers": [
252 {
253 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
254 "size": 456,
255 "digest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
256 }
257 ]
258 }"#;
259
260 assert!(validate_manifest(manifest.as_bytes()).is_ok());
261 }
262
263 #[test]
264 fn test_invalid_schema_version() {
265 let manifest = r#"{"schemaVersion": 1}"#;
266 assert!(validate_manifest(manifest.as_bytes()).is_err());
267 }
268
269 #[test]
270 fn test_invalid_digest() {
271 let manifest = r#"{
272 "schemaVersion": 2,
273 "mediaType": "application/vnd.oci.image.manifest.v1+json",
274 "config": {
275 "mediaType": "application/vnd.oci.image.config.v1+json",
276 "size": 123,
277 "digest": "invalid-digest"
278 },
279 "layers": []
280 }"#;
281
282 assert!(validate_manifest(manifest.as_bytes()).is_err());
283 }
284
285 #[test]
286 fn test_empty_layers() {
287 let manifest = r#"{
288 "schemaVersion": 2,
289 "mediaType": "application/vnd.oci.image.manifest.v1+json",
290 "config": {
291 "mediaType": "application/vnd.oci.image.config.v1+json",
292 "size": 123,
293 "digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
294 },
295 "layers": []
296 }"#;
297
298 assert!(validate_manifest(manifest.as_bytes()).is_err());
299 }
300
301 #[test]
302 fn test_valid_oci_index() {
303 let manifest = r#"{
304 "schemaVersion": 2,
305 "mediaType": "application/vnd.oci.image.index.v1+json",
306 "manifests": [
307 {
308 "mediaType": "application/vnd.oci.image.manifest.v1+json",
309 "size": 123,
310 "digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
311 }
312 ]
313 }"#;
314
315 assert!(validate_manifest(manifest.as_bytes()).is_ok());
316 }
317
318 #[test]
319 fn test_inferred_type() {
320 let manifest = r#"{
321 "schemaVersion": 2,
322 "config": {
323 "mediaType": "application/vnd.oci.image.config.v1+json",
324 "size": 123,
325 "digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
326 },
327 "layers": [
328 {
329 "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
330 "size": 456,
331 "digest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
332 }
333 ]
334 }"#;
335
336 let result = validate_manifest(manifest.as_bytes());
337 assert!(result.is_ok());
338 assert_eq!(
339 result.unwrap(),
340 "application/vnd.oci.image.manifest.v1+json"
341 );
342 }
343}