Rust implementation of OCI Distribution Spec with granular access control
at main 343 lines 11 kB view raw
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}