Skip to main content

aion_context/
oci.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! OCI artifact packaging — RFC-0030.
3//!
4//! Emits OCI Image Manifest v1.1 JSON so `.aion` files and their
5//! attached attestations (RFC-0023 DSSE, RFC-0024 SLSA, RFC-0029
6//! AIBOM) can ride through the standard container-registry supply
7//! chain. Phase A is pure data model — push/pull is the caller's
8//! job using ORAS, cosign, `oras push`, or plain HTTP.
9//!
10//! OCI mandates SHA-256 for layer and config digests, distinct
11//! from aion's internal BLAKE3 content hashing (RFC-0002). They
12//! coexist: BLAKE3 for content addressing inside `.aion` files,
13//! SHA-256 for OCI transport.
14//!
15//! # Example
16//!
17//! ```
18//! use aion_context::oci::{AionConfig, build_aion_manifest};
19//!
20//! let aion_bytes: Vec<u8> = vec![0u8; 256];
21//! let config = AionConfig {
22//!     schema_version: "aion.oci.config.v1".into(),
23//!     format_version: 2,
24//!     file_id: 42,
25//!     created_at_version: 1,
26//!     created_at: "2026-04-23T12:00:00Z".into(),
27//! };
28//! let manifest = build_aion_manifest(&aion_bytes, "rules.aion", &config).unwrap();
29//! assert_eq!(
30//!     manifest.artifact_type.as_deref(),
31//!     Some("application/vnd.aion.context.v2")
32//! );
33//! ```
34
35use std::collections::BTreeMap;
36
37use serde::{Deserialize, Serialize};
38use sha2::{Digest, Sha256};
39
40use crate::{AionError, Result};
41
42/// OCI Image Manifest v1.1 media type.
43pub const OCI_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
44
45/// Discriminator advertised on the manifest `artifactType` field
46/// for aion-context primary artifacts.
47pub const AION_CONTEXT_ARTIFACT_TYPE: &str = "application/vnd.aion.context.v2";
48
49/// Media type for the `.aion` file payload layer.
50pub const AION_CONTEXT_LAYER_MEDIA_TYPE: &str = "application/vnd.aion.context.v2+binary";
51
52/// Media type for the aion-specific JSON config blob.
53pub const AION_CONFIG_MEDIA_TYPE: &str = "application/vnd.aion.context.config.v1+json";
54
55/// OCI's sentinel empty-config media type. Used for referrer
56/// manifests that carry their payload in `layers` and need no
57/// per-artifact config.
58pub const OCI_EMPTY_CONFIG_MEDIA_TYPE: &str = "application/vnd.oci.empty.v1+json";
59
60/// Pre-computed SHA-256 digest of the 2-byte `{}` empty-config
61/// payload, per the OCI spec.
62pub const OCI_EMPTY_CONFIG_DIGEST: &str =
63    "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a";
64
65/// Pre-computed size of `{}`.
66pub const OCI_EMPTY_CONFIG_SIZE: u64 = 2;
67
68/// Compute an OCI digest string (`sha256:<lowercase-hex>`) over
69/// `bytes`.
70#[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/// An OCI content descriptor.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct OciDescriptor {
81    /// IANA media type of the referenced content.
82    #[serde(rename = "mediaType")]
83    pub media_type: String,
84    /// `sha256:<hex>` digest of the referenced content.
85    pub digest: String,
86    /// Size of the referenced content in bytes.
87    pub size: u64,
88    /// Annotations on this descriptor — omitted when empty.
89    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90    pub annotations: BTreeMap<String, String>,
91}
92
93impl OciDescriptor {
94    /// Build a descriptor for `bytes` with the given `media_type`.
95    /// The SHA-256 digest is computed here.
96    #[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/// An OCI Image Manifest v1.1 shaped for use as an artifact or
108/// attestation referrer.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct OciArtifactManifest {
111    /// Always `2` for OCI Image Manifest v1.1.
112    #[serde(rename = "schemaVersion")]
113    pub schema_version: u32,
114    /// Always [`OCI_MANIFEST_MEDIA_TYPE`].
115    #[serde(rename = "mediaType")]
116    pub media_type: String,
117    /// Artifact-type discriminator — missing for pure image
118    /// manifests, set for aion artifacts and referrers.
119    #[serde(
120        rename = "artifactType",
121        default,
122        skip_serializing_if = "Option::is_none"
123    )]
124    pub artifact_type: Option<String>,
125    /// Config-blob descriptor.
126    pub config: OciDescriptor,
127    /// Layer descriptors. aion manifests always have exactly one.
128    pub layers: Vec<OciDescriptor>,
129    /// When present, this artifact is a referrer for the
130    /// subject — enumerable via the OCI Referrers API.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub subject: Option<OciDescriptor>,
133    /// Manifest-level annotations.
134    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
135    pub annotations: BTreeMap<String, String>,
136}
137
138/// Aion-specific config blob, embedded as a layer-style object
139/// whose JSON bytes become the manifest's `config` descriptor
140/// target.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct AionConfig {
143    /// Discriminator — always `"aion.oci.config.v1"`.
144    pub schema_version: String,
145    /// The `.aion` binary format version (currently 2).
146    pub format_version: u32,
147    /// Mirrors `AionFile.file_id`.
148    pub file_id: u64,
149    /// aion version at artifact creation time.
150    pub created_at_version: u64,
151    /// Informational RFC 3339 timestamp.
152    pub created_at: String,
153}
154
155impl AionConfig {
156    /// Serialize to canonical JSON bytes.
157    ///
158    /// # Errors
159    ///
160    /// Propagates `serde_json` errors.
161    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    /// Serialize to JSON string.
170    ///
171    /// # Errors
172    ///
173    /// Propagates `serde_json` errors.
174    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    /// Canonical bytes used to compute the manifest digest.
181    ///
182    /// # Errors
183    ///
184    /// Propagates `serde_json` errors.
185    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    /// RFC 8785 (JCS) canonical bytes — use when cross-implementation
192    /// byte stability matters (Phase B of RFC-0031). Opt-in;
193    /// [`Self::canonical_bytes`] remains the hash-stable form used
194    /// by [`Self::digest`].
195    ///
196    /// # Errors
197    ///
198    /// Propagates serialization errors from [`crate::jcs`].
199    pub fn to_jcs_bytes(&self) -> Result<Vec<u8>> {
200        crate::jcs::to_jcs_bytes(self)
201    }
202
203    /// Parse from JSON.
204    ///
205    /// # Errors
206    ///
207    /// Returns `Err` on malformed JSON or schema mismatch.
208    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    /// SHA-256 digest of the canonical manifest bytes — what
215    /// referrers point at via `subject.digest`.
216    ///
217    /// # Errors
218    ///
219    /// Propagates canonical-bytes errors.
220    pub fn digest(&self) -> Result<String> {
221        Ok(sha256_digest(&self.canonical_bytes()?))
222    }
223
224    /// Descriptor suitable for use as another manifest's
225    /// `subject`.
226    ///
227    /// # Errors
228    ///
229    /// Propagates canonical-bytes errors.
230    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
241/// Build a primary aion OCI artifact manifest carrying `aion_bytes`
242/// as its one layer plus `config` as the config blob.
243///
244/// # Errors
245///
246/// Propagates config-serialization errors.
247pub 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
281/// Build a referrer manifest that attaches `envelope_json` bytes
282/// (typically a DSSE envelope) as an attestation for
283/// `subject_manifest`.
284///
285/// The caller passes the attestation's media type — for instance
286/// [`crate::aibom::AIBOM_PAYLOAD_TYPE`] or the in-toto payload
287/// type constant from RFC-0024.
288///
289/// # Errors
290///
291/// Propagates canonical-bytes errors from the subject manifest.
292pub 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        // SHA-256 of empty string is the well-known constant.
334        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}