Skip to main content

holographic_memory/core/provenance/
jumbf.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{anyhow, Result};
5use serde::{Deserialize, Serialize};
6
7/// JUMBF box type identifiers per ISO 19566-5.
8pub const JUMBF_BOX_TYPE: [u8; 4] = *b"jumb";
9pub const DESCRIPTION_BOX_TYPE: [u8; 4] = *b"jumd";
10pub const JSON_BOX_TYPE: [u8; 4] = *b"json";
11pub const CBOR_BOX_TYPE: [u8; 4] = *b"cbor";
12pub const UUID_BOX_TYPE: [u8; 4] = *b"uuid";
13pub const EMBEDDED_FILE_BOX_TYPE: [u8; 4] = *b"bfdb";
14pub const CODESTREAM_BOX_TYPE: [u8; 4] = *b"jp2c";
15
16/// C2PA manifest JUMBF content type UUID (per C2PA spec 2.1).
17pub const C2PA_MANIFEST_UUID: [u8; 16] = [
18    0x63, 0x32, 0x70, 0x61, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
19];
20
21/// C2PA assertion store content type UUID.
22pub const C2PA_ASSERTION_STORE_UUID: [u8; 16] = [
23    0x63, 0x32, 0x61, 0x73, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
24];
25
26/// C2PA claim content type UUID.
27pub const C2PA_CLAIM_UUID: [u8; 16] = [
28    0x63, 0x32, 0x63, 0x6C, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
29];
30
31/// C2PA claim signature content type UUID.
32pub const C2PA_SIGNATURE_UUID: [u8; 16] = [
33    0x63, 0x32, 0x63, 0x73, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
34];
35
36/// A JUMBF superbox or leaf box.
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub struct JumbfBox {
39    pub box_type: [u8; 4],
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub description: Option<DescriptionBox>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub content: Option<BoxContent>,
44    #[serde(skip_serializing_if = "Vec::is_empty")]
45    pub children: Vec<JumbfBox>,
46}
47
48/// JUMBF description box (jumd) metadata.
49#[derive(Clone, Debug, Serialize, Deserialize)]
50pub struct DescriptionBox {
51    pub uuid: [u8; 16],
52    pub label: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub requestable: Option<bool>,
55}
56
57/// Content payload of a leaf JUMBF box.
58#[derive(Clone, Debug, Serialize, Deserialize)]
59pub enum BoxContent {
60    Json(serde_json::Value),
61    Cbor(Vec<u8>),
62    Binary(Vec<u8>),
63}
64
65impl JumbfBox {
66    pub fn superbox(label: &str, uuid: [u8; 16]) -> Self {
67        Self {
68            box_type: JUMBF_BOX_TYPE,
69            description: Some(DescriptionBox {
70                uuid,
71                label: label.to_string(),
72                requestable: Some(true),
73            }),
74            content: None,
75            children: Vec::new(),
76        }
77    }
78
79    pub fn json_box(label: &str, content: serde_json::Value) -> Self {
80        Self {
81            box_type: JUMBF_BOX_TYPE,
82            description: Some(DescriptionBox {
83                uuid: [0; 16],
84                label: label.to_string(),
85                requestable: None,
86            }),
87            content: Some(BoxContent::Json(content)),
88            children: Vec::new(),
89        }
90    }
91
92    pub fn cbor_box(label: &str, content: Vec<u8>) -> Self {
93        Self {
94            box_type: JUMBF_BOX_TYPE,
95            description: Some(DescriptionBox {
96                uuid: [0; 16],
97                label: label.to_string(),
98                requestable: None,
99            }),
100            content: Some(BoxContent::Cbor(content)),
101            children: Vec::new(),
102        }
103    }
104
105    pub fn add_child(&mut self, child: JumbfBox) {
106        self.children.push(child);
107    }
108
109    pub fn find_by_label(&self, label: &str) -> Option<&JumbfBox> {
110        if self.description.as_ref().is_some_and(|d| d.label == label) {
111            return Some(self);
112        }
113        for child in &self.children {
114            if let Some(found) = child.find_by_label(label) {
115                return Some(found);
116            }
117        }
118        None
119    }
120
121    /// Encode this box hierarchy to JUMBF binary format (ISO 19566-5).
122    pub fn encode(&self) -> Result<Vec<u8>> {
123        let mut buf = Vec::new();
124        encode_box(self, &mut buf)?;
125        Ok(buf)
126    }
127
128    /// Decode a JUMBF box hierarchy from binary data.
129    pub fn decode(data: &[u8]) -> Result<Self> {
130        let (jbox, consumed) = decode_box(data)?;
131        if consumed != data.len() {
132            return Err(anyhow!(
133                "trailing data: consumed {consumed}, total {}",
134                data.len()
135            ));
136        }
137        Ok(jbox)
138    }
139}
140
141fn encode_box(jbox: &JumbfBox, buf: &mut Vec<u8>) -> Result<()> {
142    let start = buf.len();
143    buf.extend_from_slice(&[0u8; 4]);
144    buf.extend_from_slice(&jbox.box_type);
145
146    if let Some(desc) = &jbox.description {
147        encode_description_box(desc, buf)?;
148    }
149
150    match &jbox.content {
151        Some(BoxContent::Json(val)) => {
152            let json_bytes =
153                serde_json::to_vec(val).map_err(|e| anyhow!("JSON serialization failed: {e}"))?;
154            let content_len = (8 + json_bytes.len()) as u32;
155            buf.extend_from_slice(&content_len.to_be_bytes());
156            buf.extend_from_slice(&JSON_BOX_TYPE);
157            buf.extend_from_slice(&json_bytes);
158        }
159        Some(BoxContent::Cbor(data)) => {
160            let content_len = (8 + data.len()) as u32;
161            buf.extend_from_slice(&content_len.to_be_bytes());
162            buf.extend_from_slice(&CBOR_BOX_TYPE);
163            buf.extend_from_slice(data);
164        }
165        Some(BoxContent::Binary(data)) => {
166            let content_len = (8 + data.len()) as u32;
167            buf.extend_from_slice(&content_len.to_be_bytes());
168            buf.extend_from_slice(&EMBEDDED_FILE_BOX_TYPE);
169            buf.extend_from_slice(data);
170        }
171        None => {}
172    }
173
174    for child in &jbox.children {
175        encode_box(child, buf)?;
176    }
177
178    let total_len = (buf.len() - start) as u32;
179    buf[start..start + 4].copy_from_slice(&total_len.to_be_bytes());
180
181    Ok(())
182}
183
184fn encode_description_box(desc: &DescriptionBox, buf: &mut Vec<u8>) -> Result<()> {
185    let start = buf.len();
186    buf.extend_from_slice(&[0u8; 4]);
187    buf.extend_from_slice(&DESCRIPTION_BOX_TYPE);
188    buf.extend_from_slice(&desc.uuid);
189
190    let toggle = if desc.requestable.unwrap_or(false) {
191        0x03
192    } else {
193        0x00
194    };
195    buf.push(toggle);
196
197    buf.extend_from_slice(desc.label.as_bytes());
198    buf.push(0);
199
200    let total_len = (buf.len() - start) as u32;
201    buf[start..start + 4].copy_from_slice(&total_len.to_be_bytes());
202
203    Ok(())
204}
205
206/// Maximum JUMBF box nesting depth. Bounds recursion in `decode_box` so a
207/// crafted deeply-nested superbox (a few KB) cannot overflow the stack.
208const MAX_JUMBF_DEPTH: usize = 64;
209
210fn decode_box(data: &[u8]) -> Result<(JumbfBox, usize)> {
211    decode_box_depth(data, 0)
212}
213
214fn decode_box_depth(data: &[u8], depth: usize) -> Result<(JumbfBox, usize)> {
215    if depth > MAX_JUMBF_DEPTH {
216        return Err(anyhow!(
217            "JUMBF nesting exceeds maximum depth of {MAX_JUMBF_DEPTH}"
218        ));
219    }
220    if data.len() < 8 {
221        return Err(anyhow!("insufficient data for box header"));
222    }
223    let box_len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
224    if box_len < 8 || box_len > data.len() {
225        return Err(anyhow!("invalid box length: {box_len}"));
226    }
227    let box_type: [u8; 4] = [data[4], data[5], data[6], data[7]];
228    let box_data = &data[8..box_len];
229
230    let mut offset = 0;
231    let mut description = None;
232    let mut content = None;
233    let mut children = Vec::new();
234
235    while offset < box_data.len() {
236        if box_data.len() - offset < 8 {
237            break;
238        }
239        let inner_len = u32::from_be_bytes([
240            box_data[offset],
241            box_data[offset + 1],
242            box_data[offset + 2],
243            box_data[offset + 3],
244        ]) as usize;
245        if inner_len < 8 || offset + inner_len > box_data.len() {
246            break;
247        }
248        let inner_type: [u8; 4] = [
249            box_data[offset + 4],
250            box_data[offset + 5],
251            box_data[offset + 6],
252            box_data[offset + 7],
253        ];
254        let inner_data = &box_data[offset + 8..offset + inner_len];
255
256        match inner_type {
257            DESCRIPTION_BOX_TYPE => {
258                description = Some(decode_description_box(inner_data)?);
259            }
260            JSON_BOX_TYPE => {
261                let val = serde_json::from_slice(inner_data)
262                    .map_err(|e| anyhow!("JSON decode failed: {e}"))?;
263                content = Some(BoxContent::Json(val));
264            }
265            CBOR_BOX_TYPE => {
266                content = Some(BoxContent::Cbor(inner_data.to_vec()));
267            }
268            JUMBF_BOX_TYPE => {
269                let (child, _) = decode_box_depth(&box_data[offset..], depth + 1)?;
270                children.push(child);
271            }
272            _ => {
273                content = Some(BoxContent::Binary(inner_data.to_vec()));
274            }
275        }
276
277        offset += inner_len;
278    }
279
280    Ok((
281        JumbfBox {
282            box_type,
283            description,
284            content,
285            children,
286        },
287        box_len,
288    ))
289}
290
291fn decode_description_box(data: &[u8]) -> Result<DescriptionBox> {
292    if data.len() < 17 {
293        return Err(anyhow!("description box too short"));
294    }
295    let mut uuid = [0u8; 16];
296    uuid.copy_from_slice(&data[..16]);
297    let toggle = data[16];
298    let requestable = Some(toggle & 0x01 != 0);
299
300    let label_data = &data[17..];
301    let label_end = label_data
302        .iter()
303        .position(|&b| b == 0)
304        .unwrap_or(label_data.len());
305    let label = String::from_utf8(label_data[..label_end].to_vec())
306        .map_err(|e| anyhow!("invalid label UTF-8: {e}"))?;
307
308    Ok(DescriptionBox {
309        uuid,
310        label,
311        requestable,
312    })
313}
314
315/// Build a C2PA manifest store JUMBF superbox containing claim, signature, and assertions.
316pub fn build_c2pa_manifest_box(
317    label: &str,
318    claim_json: serde_json::Value,
319    signature_cbor: Vec<u8>,
320    assertions: Vec<(&str, serde_json::Value)>,
321) -> JumbfBox {
322    let mut manifest = JumbfBox::superbox(label, C2PA_MANIFEST_UUID);
323
324    let mut assertion_store = JumbfBox::superbox("c2pa.assertions", C2PA_ASSERTION_STORE_UUID);
325    for (assertion_label, assertion_value) in assertions {
326        assertion_store.add_child(JumbfBox::json_box(assertion_label, assertion_value));
327    }
328    manifest.add_child(assertion_store);
329    manifest.add_child(JumbfBox::json_box("c2pa.claim", claim_json));
330    manifest.add_child(JumbfBox::cbor_box("c2pa.signature", signature_cbor));
331
332    manifest
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn superbox_with_children() {
341        let mut parent = JumbfBox::superbox("test-manifest", C2PA_MANIFEST_UUID);
342        parent.add_child(JumbfBox::json_box(
343            "assertion",
344            serde_json::json!({"type": "test"}),
345        ));
346        assert_eq!(parent.children.len(), 1);
347        assert!(parent.find_by_label("assertion").is_some());
348        assert!(parent.find_by_label("nonexistent").is_none());
349    }
350
351    #[test]
352    fn encode_decode_roundtrip() {
353        let mut manifest = JumbfBox::superbox("test-manifest", C2PA_MANIFEST_UUID);
354        manifest.add_child(JumbfBox::json_box(
355            "c2pa.claim",
356            serde_json::json!({"title": "test"}),
357        ));
358        manifest.add_child(JumbfBox::cbor_box("c2pa.signature", vec![0xA0]));
359
360        let encoded = manifest.encode().unwrap();
361        let decoded = JumbfBox::decode(&encoded).unwrap();
362
363        assert_eq!(decoded.box_type, JUMBF_BOX_TYPE);
364        let desc = decoded.description.as_ref().unwrap();
365        assert_eq!(desc.label, "test-manifest");
366        assert_eq!(desc.uuid, C2PA_MANIFEST_UUID);
367    }
368
369    #[test]
370    fn c2pa_manifest_box_structure() {
371        let claim = serde_json::json!({
372            "dc:title": "HMS Knowledge Store",
373            "claim_generator": "HMS/0.1"
374        });
375        let signature = vec![0xD2, 0x84, 0x43];
376        let assertions = vec![
377            (
378                "c2pa.hash.data",
379                serde_json::json!({"alg": "sha256", "hash": "abc123"}),
380            ),
381            (
382                "c2pa.actions",
383                serde_json::json!({"actions": [{"action": "c2pa.created"}]}),
384            ),
385        ];
386
387        let manifest = build_c2pa_manifest_box("urn:hms:manifest:1", claim, signature, assertions);
388
389        assert!(manifest.find_by_label("c2pa.assertions").is_some());
390        assert!(manifest.find_by_label("c2pa.claim").is_some());
391        assert!(manifest.find_by_label("c2pa.signature").is_some());
392        assert!(manifest.find_by_label("c2pa.hash.data").is_some());
393        assert!(manifest.find_by_label("c2pa.actions").is_some());
394    }
395
396    #[test]
397    fn binary_encode_valid_structure() {
398        let jbox = JumbfBox::json_box("test", serde_json::json!({"k": "v"}));
399        let mut parent = JumbfBox::superbox("parent", [0; 16]);
400        parent.add_child(jbox);
401
402        let encoded = parent.encode().unwrap();
403        assert!(encoded.len() > 16);
404        assert_eq!(&encoded[4..8], &JUMBF_BOX_TYPE);
405
406        let decoded = JumbfBox::decode(&encoded).unwrap();
407        assert!(decoded.find_by_label("test").is_some());
408    }
409
410    #[test]
411    fn nested_encode_decode() {
412        let claim = serde_json::json!({"title": "roundtrip test"});
413        let manifest = build_c2pa_manifest_box("urn:test:1", claim, vec![0xA0, 0xB1], vec![]);
414
415        let encoded = manifest.encode().unwrap();
416        let decoded = JumbfBox::decode(&encoded).unwrap();
417
418        let found_claim = decoded.find_by_label("c2pa.claim").unwrap();
419        match &found_claim.content {
420            Some(BoxContent::Json(v)) => assert_eq!(v["title"], "roundtrip test"),
421            _ => panic!("expected JSON content in claim box"),
422        }
423    }
424}