1use std::collections::BTreeMap;
36
37use serde::{Deserialize, Serialize};
38use sha2::{Digest, Sha256};
39
40use crate::{AionError, Result};
41
42pub const OCI_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
44
45pub const AION_CONTEXT_ARTIFACT_TYPE: &str = "application/vnd.aion.context.v2";
48
49pub const AION_CONTEXT_LAYER_MEDIA_TYPE: &str = "application/vnd.aion.context.v2+binary";
51
52pub const AION_CONFIG_MEDIA_TYPE: &str = "application/vnd.aion.context.config.v1+json";
54
55pub const OCI_EMPTY_CONFIG_MEDIA_TYPE: &str = "application/vnd.oci.empty.v1+json";
59
60pub const OCI_EMPTY_CONFIG_DIGEST: &str =
63 "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a";
64
65pub const OCI_EMPTY_CONFIG_SIZE: u64 = 2;
67
68#[must_use]
71pub fn sha256_digest(bytes: &[u8]) -> String {
72 let mut hasher = Sha256::new();
73 hasher.update(bytes);
74 let digest = hasher.finalize();
75 format!("sha256:{}", hex::encode(digest))
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct OciDescriptor {
81 #[serde(rename = "mediaType")]
83 pub media_type: String,
84 pub digest: String,
86 pub size: u64,
88 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90 pub annotations: BTreeMap<String, String>,
91}
92
93impl OciDescriptor {
94 #[must_use]
97 pub fn of(bytes: &[u8], media_type: impl Into<String>) -> Self {
98 Self {
99 media_type: media_type.into(),
100 digest: sha256_digest(bytes),
101 size: bytes.len() as u64,
102 annotations: BTreeMap::new(),
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct OciArtifactManifest {
111 #[serde(rename = "schemaVersion")]
113 pub schema_version: u32,
114 #[serde(rename = "mediaType")]
116 pub media_type: String,
117 #[serde(
120 rename = "artifactType",
121 default,
122 skip_serializing_if = "Option::is_none"
123 )]
124 pub artifact_type: Option<String>,
125 pub config: OciDescriptor,
127 pub layers: Vec<OciDescriptor>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub subject: Option<OciDescriptor>,
133 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
135 pub annotations: BTreeMap<String, String>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct AionConfig {
143 pub schema_version: String,
145 pub format_version: u32,
147 pub file_id: u64,
149 pub created_at_version: u64,
151 pub created_at: String,
153}
154
155impl AionConfig {
156 pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
162 serde_json::to_vec(self).map_err(|e| AionError::InvalidFormat {
163 reason: format!("AionConfig serialize failed: {e}"),
164 })
165 }
166}
167
168impl OciArtifactManifest {
169 pub fn to_json(&self) -> Result<String> {
175 serde_json::to_string(self).map_err(|e| AionError::InvalidFormat {
176 reason: format!("OCI manifest serialize failed: {e}"),
177 })
178 }
179
180 pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
186 serde_json::to_vec(self).map_err(|e| AionError::InvalidFormat {
187 reason: format!("OCI manifest canonical bytes failed: {e}"),
188 })
189 }
190
191 pub fn to_jcs_bytes(&self) -> Result<Vec<u8>> {
200 crate::jcs::to_jcs_bytes(self)
201 }
202
203 pub fn from_json(s: &str) -> Result<Self> {
209 serde_json::from_str(s).map_err(|e| AionError::InvalidFormat {
210 reason: format!("OCI manifest parse failed: {e}"),
211 })
212 }
213
214 pub fn digest(&self) -> Result<String> {
221 Ok(sha256_digest(&self.canonical_bytes()?))
222 }
223
224 pub fn as_subject(&self) -> Result<OciDescriptor> {
231 let bytes = self.canonical_bytes()?;
232 Ok(OciDescriptor {
233 media_type: OCI_MANIFEST_MEDIA_TYPE.to_string(),
234 digest: sha256_digest(&bytes),
235 size: bytes.len() as u64,
236 annotations: BTreeMap::new(),
237 })
238 }
239}
240
241pub fn build_aion_manifest(
248 aion_bytes: &[u8],
249 file_title: &str,
250 config: &AionConfig,
251) -> Result<OciArtifactManifest> {
252 let config_bytes = config.canonical_bytes()?;
253 let config_desc = OciDescriptor {
254 media_type: AION_CONFIG_MEDIA_TYPE.to_string(),
255 digest: sha256_digest(&config_bytes),
256 size: config_bytes.len() as u64,
257 annotations: BTreeMap::new(),
258 };
259 let mut layer = OciDescriptor::of(aion_bytes, AION_CONTEXT_LAYER_MEDIA_TYPE);
260 layer.annotations.insert(
261 "org.opencontainers.image.title".to_string(),
262 file_title.to_string(),
263 );
264 let mut annotations = BTreeMap::new();
265 annotations.insert(
266 "dev.aion.format.version".to_string(),
267 config.format_version.to_string(),
268 );
269 annotations.insert("dev.aion.file.id".to_string(), config.file_id.to_string());
270 Ok(OciArtifactManifest {
271 schema_version: 2,
272 media_type: OCI_MANIFEST_MEDIA_TYPE.to_string(),
273 artifact_type: Some(AION_CONTEXT_ARTIFACT_TYPE.to_string()),
274 config: config_desc,
275 layers: vec![layer],
276 subject: None,
277 annotations,
278 })
279}
280
281pub fn build_attestation_manifest(
293 envelope_json: &[u8],
294 attestation_media_type: &str,
295 subject_manifest: &OciArtifactManifest,
296) -> Result<OciArtifactManifest> {
297 let layer = OciDescriptor::of(envelope_json, attestation_media_type);
298 let config_desc = OciDescriptor {
299 media_type: OCI_EMPTY_CONFIG_MEDIA_TYPE.to_string(),
300 digest: OCI_EMPTY_CONFIG_DIGEST.to_string(),
301 size: OCI_EMPTY_CONFIG_SIZE,
302 annotations: BTreeMap::new(),
303 };
304 let subject = subject_manifest.as_subject()?;
305 Ok(OciArtifactManifest {
306 schema_version: 2,
307 media_type: OCI_MANIFEST_MEDIA_TYPE.to_string(),
308 artifact_type: Some(attestation_media_type.to_string()),
309 config: config_desc,
310 layers: vec![layer],
311 subject: Some(subject),
312 annotations: BTreeMap::new(),
313 })
314}
315
316#[cfg(test)]
317#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
318mod tests {
319 use super::*;
320
321 fn sample_config() -> AionConfig {
322 AionConfig {
323 schema_version: "aion.oci.config.v1".to_string(),
324 format_version: 2,
325 file_id: 42,
326 created_at_version: 1,
327 created_at: "2026-04-23T12:00:00Z".to_string(),
328 }
329 }
330
331 #[test]
332 fn sha256_digest_known_vector() {
333 assert_eq!(
335 sha256_digest(b""),
336 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
337 );
338 }
339
340 #[test]
341 fn empty_config_constants_consistent() {
342 assert_eq!(sha256_digest(b"{}"), OCI_EMPTY_CONFIG_DIGEST);
343 }
344
345 #[test]
346 fn aion_manifest_has_expected_shape() {
347 let bytes = vec![0xABu8; 128];
348 let m = build_aion_manifest(&bytes, "rules.aion", &sample_config()).unwrap();
349 assert_eq!(m.schema_version, 2);
350 assert_eq!(m.media_type, OCI_MANIFEST_MEDIA_TYPE);
351 assert_eq!(m.artifact_type.as_deref(), Some(AION_CONTEXT_ARTIFACT_TYPE));
352 assert_eq!(m.layers.len(), 1);
353 assert_eq!(m.layers[0].media_type, AION_CONTEXT_LAYER_MEDIA_TYPE);
354 assert_eq!(m.layers[0].size, 128);
355 assert_eq!(
356 m.layers[0]
357 .annotations
358 .get("org.opencontainers.image.title"),
359 Some(&"rules.aion".to_string())
360 );
361 assert!(m.subject.is_none());
362 }
363
364 #[test]
365 fn attestation_manifest_links_subject() {
366 let aion_bytes = vec![0u8; 64];
367 let primary = build_aion_manifest(&aion_bytes, "rules.aion", &sample_config()).unwrap();
368 let envelope = br#"{"payloadType":"application/vnd.aion.aibom.v1+json"}"#;
369 let referrer =
370 build_attestation_manifest(envelope, "application/vnd.aion.aibom.v1+json", &primary)
371 .unwrap();
372 let subject = referrer.subject.as_ref().unwrap();
373 assert_eq!(subject.media_type, OCI_MANIFEST_MEDIA_TYPE);
374 assert_eq!(subject.digest, primary.digest().unwrap());
375 }
376
377 #[test]
378 fn manifest_json_round_trip() {
379 let bytes = vec![1u8, 2, 3, 4];
380 let m = build_aion_manifest(&bytes, "rules.aion", &sample_config()).unwrap();
381 let json = m.to_json().unwrap();
382 let parsed = OciArtifactManifest::from_json(&json).unwrap();
383 assert_eq!(parsed, m);
384 }
385
386 #[test]
387 fn manifest_digest_is_deterministic() {
388 let bytes = vec![0xCCu8; 16];
389 let m = build_aion_manifest(&bytes, "rules.aion", &sample_config()).unwrap();
390 let d1 = m.digest().unwrap();
391 let d2 = m.digest().unwrap();
392 assert_eq!(d1, d2);
393 }
394
395 #[test]
396 fn tampering_json_changes_digest() {
397 let bytes = vec![0u8; 8];
398 let mut m = build_aion_manifest(&bytes, "rules.aion", &sample_config()).unwrap();
399 let d1 = m.digest().unwrap();
400 m.annotations.insert("foo".to_string(), "bar".to_string());
401 let d2 = m.digest().unwrap();
402 assert_ne!(d1, d2);
403 }
404
405 mod properties {
406 use super::*;
407 use hegel::generators as gs;
408
409 fn draw_config(tc: &hegel::TestCase) -> AionConfig {
410 AionConfig {
411 schema_version: "aion.oci.config.v1".to_string(),
412 format_version: 2,
413 file_id: tc.draw(gs::integers::<u64>()),
414 created_at_version: tc.draw(gs::integers::<u64>()),
415 created_at: "2026-04-23T12:00:00Z".to_string(),
416 }
417 }
418
419 #[hegel::test]
420 fn prop_oci_manifest_json_roundtrip(tc: hegel::TestCase) {
421 let aion_bytes = tc.draw(gs::binary().max_size(512));
422 let config = draw_config(&tc);
423 let m = build_aion_manifest(&aion_bytes, "rules.aion", &config)
424 .unwrap_or_else(|_| std::process::abort());
425 let json = m.to_json().unwrap_or_else(|_| std::process::abort());
426 let parsed =
427 OciArtifactManifest::from_json(&json).unwrap_or_else(|_| std::process::abort());
428 assert_eq!(parsed, m);
429 }
430
431 #[hegel::test]
432 fn prop_oci_manifest_digest_deterministic(tc: hegel::TestCase) {
433 let aion_bytes = tc.draw(gs::binary().max_size(512));
434 let config = draw_config(&tc);
435 let m = build_aion_manifest(&aion_bytes, "rules.aion", &config)
436 .unwrap_or_else(|_| std::process::abort());
437 let a = m.digest().unwrap_or_else(|_| std::process::abort());
438 let b = m.digest().unwrap_or_else(|_| std::process::abort());
439 assert_eq!(a, b);
440 }
441
442 #[hegel::test]
443 fn prop_aion_primary_has_expected_media_types(tc: hegel::TestCase) {
444 let aion_bytes = tc.draw(gs::binary().max_size(256));
445 let config = draw_config(&tc);
446 let m = build_aion_manifest(&aion_bytes, "rules.aion", &config)
447 .unwrap_or_else(|_| std::process::abort());
448 assert_eq!(m.artifact_type.as_deref(), Some(AION_CONTEXT_ARTIFACT_TYPE));
449 let layer = m.layers.first().unwrap_or_else(|| std::process::abort());
450 assert_eq!(layer.media_type, AION_CONTEXT_LAYER_MEDIA_TYPE);
451 assert_eq!(m.config.media_type, AION_CONFIG_MEDIA_TYPE);
452 }
453
454 #[hegel::test]
455 fn prop_aion_layer_size_matches_payload(tc: hegel::TestCase) {
456 let aion_bytes = tc.draw(gs::binary().max_size(1024));
457 let config = draw_config(&tc);
458 let m = build_aion_manifest(&aion_bytes, "rules.aion", &config)
459 .unwrap_or_else(|_| std::process::abort());
460 let layer = m.layers.first().unwrap_or_else(|| std::process::abort());
461 assert_eq!(layer.size as usize, aion_bytes.len());
462 }
463
464 #[hegel::test]
465 fn prop_aion_layer_digest_matches_payload_sha256(tc: hegel::TestCase) {
466 let aion_bytes = tc.draw(gs::binary().max_size(1024));
467 let config = draw_config(&tc);
468 let m = build_aion_manifest(&aion_bytes, "rules.aion", &config)
469 .unwrap_or_else(|_| std::process::abort());
470 let layer = m.layers.first().unwrap_or_else(|| std::process::abort());
471 assert_eq!(layer.digest, sha256_digest(&aion_bytes));
472 }
473
474 #[hegel::test]
475 fn prop_attestation_manifest_subject_links_to_primary(tc: hegel::TestCase) {
476 let aion_bytes = tc.draw(gs::binary().max_size(256));
477 let config = draw_config(&tc);
478 let primary = build_aion_manifest(&aion_bytes, "rules.aion", &config)
479 .unwrap_or_else(|_| std::process::abort());
480 let envelope = tc.draw(gs::binary().min_size(1).max_size(512));
481 let referrer = build_attestation_manifest(
482 &envelope,
483 "application/vnd.aion.aibom.v1+json",
484 &primary,
485 )
486 .unwrap_or_else(|_| std::process::abort());
487 let subject = referrer
488 .subject
489 .as_ref()
490 .unwrap_or_else(|| std::process::abort());
491 let primary_digest = primary.digest().unwrap_or_else(|_| std::process::abort());
492 assert_eq!(subject.digest, primary_digest);
493 assert_eq!(subject.media_type, OCI_MANIFEST_MEDIA_TYPE);
494 }
495
496 #[hegel::test]
497 fn prop_oci_manifest_tamper_rejects_digest(tc: hegel::TestCase) {
498 let aion_bytes = tc.draw(gs::binary().max_size(256));
499 let config = draw_config(&tc);
500 let m = build_aion_manifest(&aion_bytes, "rules.aion", &config)
501 .unwrap_or_else(|_| std::process::abort());
502 let original_digest = m.digest().unwrap_or_else(|_| std::process::abort());
503 let mut tampered = m;
504 tampered
505 .annotations
506 .insert("dev.aion.mutation".to_string(), "yes".to_string());
507 let tampered_digest = tampered.digest().unwrap_or_else(|_| std::process::abort());
508 assert_ne!(original_digest, tampered_digest);
509 }
510
511 #[hegel::test]
512 fn prop_oci_manifest_to_jcs_bytes_matches_helper(tc: hegel::TestCase) {
513 let aion_bytes = tc.draw(gs::binary().max_size(256));
514 let config = draw_config(&tc);
515 let m = build_aion_manifest(&aion_bytes, "rules.aion", &config)
516 .unwrap_or_else(|_| std::process::abort());
517 let from_method = m.to_jcs_bytes().unwrap_or_else(|_| std::process::abort());
518 let from_helper =
519 crate::jcs::to_jcs_bytes(&m).unwrap_or_else(|_| std::process::abort());
520 assert_eq!(from_method, from_helper);
521 }
522 }
523}