Skip to main content

ahu/
manifest.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4/// Bundle type discriminator.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[repr(u8)]
7pub enum BundleType {
8    Full = 0,
9    Delta = 1,
10}
11
12/// Completeness assertion for a CA scope.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[repr(u8)]
15pub enum Completeness {
16    Partial = 0,
17    AuthoritativeComplete = 1,
18}
19
20/// ResponderID type.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[repr(u8)]
23pub enum ResponderIdType {
24    ByName = 0,
25    ByKey = 1,
26}
27
28/// ResponderID — type plus DER-encoded value.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ResponderId {
31    pub id_type: ResponderIdType,
32    pub value: Vec<u8>,
33}
34
35/// One CA scope within a bundle manifest.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CaScope {
38    pub hash_algorithm: Vec<u8>,
39    pub issuer_name_hash: Vec<u8>,
40    pub issuer_key_hash: Vec<u8>,
41    pub epoch: u64,
42    pub responder_id: ResponderId,
43    pub responder_chain: Option<Vec<Vec<u8>>>,
44    pub signature_algorithm: Vec<u8>,
45    pub completeness: Completeness,
46}
47
48/// Time window for the bundle.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Window {
51    pub produced_at: u64,
52    pub this_update_min: u64,
53    pub next_update_min: u64,
54    pub next_update_max: u64,
55}
56
57/// Integrity digests covering the index and data sections.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Integrity {
60    pub index_digest: [u8; 32],
61    pub data_digest: [u8; 32],
62}
63
64/// Continuity chain for rollback and fork detection.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Continuity {
67    pub prev_manifest_digest: Option<[u8; 32]>,
68    pub base_manifest_digest: Option<[u8; 32]>,
69    pub chain_length: u64,
70}
71
72/// Shard descriptor for partitioned bundles.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Shard {
75    pub shard_index: u64,
76    pub shard_count: u64,
77    pub shard_fn: u64,
78}
79
80/// Compression settings.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Compression {
83    pub algorithm: CompressionAlgorithm,
84    pub dictionary_digest: Option<[u8; 32]>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[repr(u8)]
89pub enum CompressionAlgorithm {
90    None = 0,
91    Zstd = 1,
92}
93
94/// The full manifest, as specified in §2.2 of the ahu format spec.
95#[derive(Debug, Clone, PartialEq)]
96pub struct Manifest {
97    pub format_version: u64,
98    pub bundle_id: Uuid,
99    pub producer_id: String,
100    pub created_at: u64,
101    pub bundle_type: BundleType,
102    pub ca_scopes: Vec<CaScope>,
103    pub window: Window,
104    pub integrity: Integrity,
105    pub entry_count: u64,
106    pub continuity: Continuity,
107    pub shard: Option<Shard>,
108    pub compression: Option<Compression>,
109    pub extensions: Option<Vec<(String, ciborium::Value)>>,
110}
111
112// ── Deterministic CBOR encoding ────────────────────────────────────
113//
114// We encode manually rather than deriving serde for CBOR because
115// RFC 8949 §4.2 requires deterministic encoding: definite lengths,
116// canonical integer key ordering, shortest-form integers.
117// ciborium's serde path doesn't guarantee this.
118
119impl Manifest {
120    pub fn to_cbor(&self) -> Vec<u8> {
121        use ciborium::Value;
122
123        let responder_id_to_val = |rid: &ResponderId| -> Value {
124            Value::Map(vec![
125                (
126                    Value::Integer(1.into()),
127                    Value::Integer((rid.id_type as u8).into()),
128                ),
129                (Value::Integer(2.into()), Value::Bytes(rid.value.clone())),
130            ])
131        };
132
133        let ca_scopes: Vec<Value> = self
134            .ca_scopes
135            .iter()
136            .map(|s| {
137                let mut entries = vec![
138                    (
139                        Value::Integer(1.into()),
140                        Value::Bytes(s.hash_algorithm.clone()),
141                    ),
142                    (
143                        Value::Integer(2.into()),
144                        Value::Bytes(s.issuer_name_hash.clone()),
145                    ),
146                    (
147                        Value::Integer(3.into()),
148                        Value::Bytes(s.issuer_key_hash.clone()),
149                    ),
150                    (Value::Integer(4.into()), Value::Integer((s.epoch).into())),
151                    (
152                        Value::Integer(5.into()),
153                        responder_id_to_val(&s.responder_id),
154                    ),
155                ];
156                if let Some(chain) = &s.responder_chain {
157                    entries.push((
158                        Value::Integer(6.into()),
159                        Value::Array(chain.iter().map(|c| Value::Bytes(c.clone())).collect()),
160                    ));
161                }
162                entries.push((
163                    Value::Integer(7.into()),
164                    Value::Bytes(s.signature_algorithm.clone()),
165                ));
166                entries.push((
167                    Value::Integer(8.into()),
168                    Value::Integer((s.completeness as u8).into()),
169                ));
170                Value::Map(entries)
171            })
172            .collect();
173
174        let window = Value::Map(vec![
175            (
176                Value::Integer(1.into()),
177                Value::Integer((self.window.produced_at).into()),
178            ),
179            (
180                Value::Integer(2.into()),
181                Value::Integer((self.window.this_update_min).into()),
182            ),
183            (
184                Value::Integer(3.into()),
185                Value::Integer((self.window.next_update_min).into()),
186            ),
187            (
188                Value::Integer(4.into()),
189                Value::Integer((self.window.next_update_max).into()),
190            ),
191        ]);
192
193        let integrity = Value::Map(vec![
194            (
195                Value::Integer(1.into()),
196                Value::Bytes(self.integrity.index_digest.to_vec()),
197            ),
198            (
199                Value::Integer(2.into()),
200                Value::Bytes(self.integrity.data_digest.to_vec()),
201            ),
202        ]);
203
204        let mut continuity_entries = Vec::new();
205        if let Some(prev) = &self.continuity.prev_manifest_digest {
206            continuity_entries.push((Value::Integer(1.into()), Value::Bytes(prev.to_vec())));
207        }
208        if let Some(base) = &self.continuity.base_manifest_digest {
209            continuity_entries.push((Value::Integer(2.into()), Value::Bytes(base.to_vec())));
210        }
211        continuity_entries.push((
212            Value::Integer(3.into()),
213            Value::Integer((self.continuity.chain_length).into()),
214        ));
215        let continuity = Value::Map(continuity_entries);
216
217        let mut root = vec![
218            (
219                Value::Integer(1.into()),
220                Value::Integer((self.format_version).into()),
221            ),
222            (
223                Value::Integer(2.into()),
224                Value::Bytes(self.bundle_id.as_bytes().to_vec()),
225            ),
226            (
227                Value::Integer(3.into()),
228                Value::Text(self.producer_id.clone()),
229            ),
230            (
231                Value::Integer(4.into()),
232                Value::Integer((self.created_at).into()),
233            ),
234            (
235                Value::Integer(5.into()),
236                Value::Integer((self.bundle_type as u8).into()),
237            ),
238            (Value::Integer(6.into()), Value::Array(ca_scopes)),
239            (Value::Integer(7.into()), window),
240            (Value::Integer(8.into()), integrity),
241            (
242                Value::Integer(9.into()),
243                Value::Integer((self.entry_count).into()),
244            ),
245            (Value::Integer(10.into()), continuity),
246        ];
247
248        if let Some(shard) = &self.shard {
249            root.push((
250                Value::Integer(11.into()),
251                Value::Map(vec![
252                    (
253                        Value::Integer(1.into()),
254                        Value::Integer((shard.shard_index).into()),
255                    ),
256                    (
257                        Value::Integer(2.into()),
258                        Value::Integer((shard.shard_count).into()),
259                    ),
260                    (
261                        Value::Integer(3.into()),
262                        Value::Integer((shard.shard_fn).into()),
263                    ),
264                ]),
265            ));
266        }
267
268        if let Some(comp) = &self.compression {
269            let mut comp_entries = vec![(
270                Value::Integer(1.into()),
271                Value::Integer((comp.algorithm as u8).into()),
272            )];
273            if let Some(dict) = &comp.dictionary_digest {
274                comp_entries.push((Value::Integer(2.into()), Value::Bytes(dict.to_vec())));
275            }
276            root.push((Value::Integer(12.into()), Value::Map(comp_entries)));
277        }
278
279        if let Some(exts) = &self.extensions {
280            let ext_map: Vec<(Value, Value)> = exts
281                .iter()
282                .map(|(k, v)| (Value::Text(k.clone()), v.clone()))
283                .collect();
284            root.push((Value::Integer(13.into()), Value::Map(ext_map)));
285        }
286
287        let root_val = Value::Map(root);
288        let mut buf = Vec::new();
289        ciborium::into_writer(&root_val, &mut buf).expect("CBOR encoding cannot fail for Value");
290        buf
291    }
292
293    pub fn from_cbor(data: &[u8]) -> crate::error::Result<Self> {
294        let val: ciborium::Value = ciborium::from_reader(data)
295            .map_err(|e| crate::error::AhuError::ManifestDecode(e.to_string()))?;
296
297        let map = val
298            .as_map()
299            .ok_or_else(|| crate::error::AhuError::ManifestDecode("root is not a map".into()))?;
300
301        fn get_uint(
302            map: &[(ciborium::Value, ciborium::Value)],
303            key: i128,
304        ) -> crate::error::Result<u64> {
305            for (k, v) in map {
306                if let Some(ki) = k.as_integer() {
307                    if i128::from(ki) == key {
308                        if let Some(vi) = v.as_integer() {
309                            let n: i128 = vi.into();
310                            return u64::try_from(n).map_err(|_| {
311                                crate::error::AhuError::ManifestField(format!(
312                                    "key {key}: integer out of u64 range"
313                                ))
314                            });
315                        }
316                        return Err(crate::error::AhuError::ManifestField(format!(
317                            "key {key}: expected integer"
318                        )));
319                    }
320                }
321            }
322            Err(crate::error::AhuError::ManifestField(format!(
323                "key {key}: missing"
324            )))
325        }
326
327        fn get_bytes(
328            map: &[(ciborium::Value, ciborium::Value)],
329            key: i128,
330        ) -> crate::error::Result<Vec<u8>> {
331            for (k, v) in map {
332                if let Some(ki) = k.as_integer() {
333                    if i128::from(ki) == key {
334                        if let Some(b) = v.as_bytes() {
335                            return Ok(b.to_vec());
336                        }
337                        return Err(crate::error::AhuError::ManifestField(format!(
338                            "key {key}: expected bytes"
339                        )));
340                    }
341                }
342            }
343            Err(crate::error::AhuError::ManifestField(format!(
344                "key {key}: missing"
345            )))
346        }
347
348        fn get_text(
349            map: &[(ciborium::Value, ciborium::Value)],
350            key: i128,
351        ) -> crate::error::Result<String> {
352            for (k, v) in map {
353                if let Some(ki) = k.as_integer() {
354                    if i128::from(ki) == key {
355                        if let Some(s) = v.as_text() {
356                            return Ok(s.to_string());
357                        }
358                        return Err(crate::error::AhuError::ManifestField(format!(
359                            "key {key}: expected text"
360                        )));
361                    }
362                }
363            }
364            Err(crate::error::AhuError::ManifestField(format!(
365                "key {key}: missing"
366            )))
367        }
368
369        fn get_map(
370            map: &[(ciborium::Value, ciborium::Value)],
371            key: i128,
372        ) -> crate::error::Result<&[(ciborium::Value, ciborium::Value)]> {
373            for (k, v) in map {
374                if let Some(ki) = k.as_integer() {
375                    if i128::from(ki) == key {
376                        if let Some(m) = v.as_map() {
377                            return Ok(m);
378                        }
379                        return Err(crate::error::AhuError::ManifestField(format!(
380                            "key {key}: expected map"
381                        )));
382                    }
383                }
384            }
385            Err(crate::error::AhuError::ManifestField(format!(
386                "key {key}: missing"
387            )))
388        }
389
390        fn get_array(
391            map: &[(ciborium::Value, ciborium::Value)],
392            key: i128,
393        ) -> crate::error::Result<&[ciborium::Value]> {
394            for (k, v) in map {
395                if let Some(ki) = k.as_integer() {
396                    if i128::from(ki) == key {
397                        if let Some(a) = v.as_array() {
398                            return Ok(a);
399                        }
400                        return Err(crate::error::AhuError::ManifestField(format!(
401                            "key {key}: expected array"
402                        )));
403                    }
404                }
405            }
406            Err(crate::error::AhuError::ManifestField(format!(
407                "key {key}: missing"
408            )))
409        }
410
411        fn get_optional_bytes(
412            map: &[(ciborium::Value, ciborium::Value)],
413            key: i128,
414        ) -> crate::error::Result<Option<Vec<u8>>> {
415            for (k, v) in map {
416                if let Some(ki) = k.as_integer() {
417                    if i128::from(ki) == key {
418                        if let Some(b) = v.as_bytes() {
419                            return Ok(Some(b.to_vec()));
420                        }
421                        return Err(crate::error::AhuError::ManifestField(format!(
422                            "key {key}: expected bytes"
423                        )));
424                    }
425                }
426            }
427            Ok(None)
428        }
429
430        fn get_optional_map(
431            map: &[(ciborium::Value, ciborium::Value)],
432            key: i128,
433        ) -> Option<&[(ciborium::Value, ciborium::Value)]> {
434            for (k, v) in map {
435                if let Some(ki) = k.as_integer() {
436                    if i128::from(ki) == key {
437                        return v.as_map().map(|v| v.as_slice());
438                    }
439                }
440            }
441            None
442        }
443
444        fn to_fixed_32(v: &[u8]) -> crate::error::Result<[u8; 32]> {
445            v.try_into().map_err(|_| {
446                crate::error::AhuError::ManifestField("expected 32-byte digest".into())
447            })
448        }
449
450        let format_version = get_uint(map, 1)?;
451        let bundle_id_bytes = get_bytes(map, 2)?;
452        let bundle_id = Uuid::from_slice(&bundle_id_bytes)
453            .map_err(|e| crate::error::AhuError::ManifestField(format!("bundle_id: {e}")))?;
454        let producer_id = get_text(map, 3)?;
455        let created_at = get_uint(map, 4)?;
456        let bundle_type_raw = get_uint(map, 5)?;
457        let bundle_type = match bundle_type_raw {
458            0 => BundleType::Full,
459            1 => BundleType::Delta,
460            _ => {
461                return Err(crate::error::AhuError::ManifestField(format!(
462                    "unknown bundle_type: {bundle_type_raw}"
463                )));
464            }
465        };
466
467        let ca_scope_arr = get_array(map, 6)?;
468        let mut ca_scopes = Vec::with_capacity(ca_scope_arr.len());
469        for scope_val in ca_scope_arr {
470            let scope_map = scope_val.as_map().ok_or_else(|| {
471                crate::error::AhuError::ManifestField("ca_scope entry is not a map".into())
472            })?;
473
474            let rid_map = get_map(scope_map, 5)?;
475            let responder_id = ResponderId {
476                id_type: match get_uint(rid_map, 1)? {
477                    0 => ResponderIdType::ByName,
478                    1 => ResponderIdType::ByKey,
479                    n => {
480                        return Err(crate::error::AhuError::ManifestField(format!(
481                            "unknown responder_id type: {n}"
482                        )));
483                    }
484                },
485                value: get_bytes(rid_map, 2)?,
486            };
487
488            let responder_chain = {
489                let mut chain = None;
490                for (k, v) in scope_map {
491                    if let Some(ki) = k.as_integer() {
492                        if i128::from(ki) == 6 {
493                            if let Some(arr) = v.as_array() {
494                                let certs: crate::error::Result<Vec<Vec<u8>>> = arr
495                                    .iter()
496                                    .map(|c| {
497                                        c.as_bytes().map(|b| b.to_vec()).ok_or_else(|| {
498                                            crate::error::AhuError::ManifestField(
499                                                "responder chain entry is not bytes".into(),
500                                            )
501                                        })
502                                    })
503                                    .collect();
504                                chain = Some(certs?);
505                            }
506                        }
507                    }
508                }
509                chain
510            };
511
512            ca_scopes.push(CaScope {
513                hash_algorithm: get_bytes(scope_map, 1)?,
514                issuer_name_hash: get_bytes(scope_map, 2)?,
515                issuer_key_hash: get_bytes(scope_map, 3)?,
516                epoch: get_uint(scope_map, 4)?,
517                responder_id,
518                responder_chain,
519                signature_algorithm: get_bytes(scope_map, 7)?,
520                completeness: match get_uint(scope_map, 8)? {
521                    0 => Completeness::Partial,
522                    1 => Completeness::AuthoritativeComplete,
523                    n => {
524                        return Err(crate::error::AhuError::ManifestField(format!(
525                            "unknown completeness: {n}"
526                        )));
527                    }
528                },
529            });
530        }
531
532        let window_map = get_map(map, 7)?;
533        let window = Window {
534            produced_at: get_uint(window_map, 1)?,
535            this_update_min: get_uint(window_map, 2)?,
536            next_update_min: get_uint(window_map, 3)?,
537            next_update_max: get_uint(window_map, 4)?,
538        };
539
540        let integrity_map = get_map(map, 8)?;
541        let integrity = Integrity {
542            index_digest: to_fixed_32(&get_bytes(integrity_map, 1)?)?,
543            data_digest: to_fixed_32(&get_bytes(integrity_map, 2)?)?,
544        };
545
546        let entry_count = get_uint(map, 9)?;
547
548        let continuity_map = get_map(map, 10)?;
549        let continuity = Continuity {
550            prev_manifest_digest: get_optional_bytes(continuity_map, 1)?
551                .map(|b| to_fixed_32(&b))
552                .transpose()?,
553            base_manifest_digest: get_optional_bytes(continuity_map, 2)?
554                .map(|b| to_fixed_32(&b))
555                .transpose()?,
556            chain_length: get_uint(continuity_map, 3)?,
557        };
558
559        let shard = get_optional_map(map, 11)
560            .map(|sm| -> crate::error::Result<Shard> {
561                Ok(Shard {
562                    shard_index: get_uint(sm, 1)?,
563                    shard_count: get_uint(sm, 2)?,
564                    shard_fn: get_uint(sm, 3)?,
565                })
566            })
567            .transpose()?;
568
569        let compression = get_optional_map(map, 12)
570            .map(|cm| -> crate::error::Result<Compression> {
571                let algo = match get_uint(cm, 1)? {
572                    0 => CompressionAlgorithm::None,
573                    1 => CompressionAlgorithm::Zstd,
574                    n => {
575                        return Err(crate::error::AhuError::ManifestField(format!(
576                            "unknown compression algorithm: {n}"
577                        )));
578                    }
579                };
580                let dict = get_optional_bytes(cm, 2)?
581                    .map(|b| to_fixed_32(&b))
582                    .transpose()?;
583                Ok(Compression {
584                    algorithm: algo,
585                    dictionary_digest: dict,
586                })
587            })
588            .transpose()?;
589
590        let extensions = get_optional_map(map, 13).map(|ext_map| {
591            ext_map
592                .iter()
593                .filter_map(|(k, v)| k.as_text().map(|key| (key.to_string(), v.clone())))
594                .collect::<Vec<_>>()
595        });
596
597        Ok(Manifest {
598            format_version,
599            bundle_id,
600            producer_id,
601            created_at,
602            bundle_type,
603            ca_scopes,
604            window,
605            integrity,
606            entry_count,
607            continuity,
608            shard,
609            compression,
610            extensions,
611        })
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    fn sample_manifest() -> Manifest {
620        Manifest {
621            format_version: 1,
622            bundle_id: Uuid::nil(),
623            producer_id: "test-producer".into(),
624            created_at: 1700000000,
625            bundle_type: BundleType::Full,
626            ca_scopes: vec![CaScope {
627                hash_algorithm: vec![0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01], // SHA-256 OID DER
628                issuer_name_hash: vec![0xAA; 32],
629                issuer_key_hash: vec![0xBB; 32],
630                epoch: 1,
631                responder_id: ResponderId {
632                    id_type: ResponderIdType::ByKey,
633                    value: vec![0xCC; 20],
634                },
635                responder_chain: None,
636                signature_algorithm: vec![0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02], // ECDSA-SHA256
637                completeness: Completeness::AuthoritativeComplete,
638            }],
639            window: Window {
640                produced_at: 1700000000,
641                this_update_min: 1700000000,
642                next_update_min: 1700086400,
643                next_update_max: 1700093600,
644            },
645            integrity: Integrity {
646                index_digest: [0x11; 32],
647                data_digest: [0x22; 32],
648            },
649            entry_count: 100,
650            continuity: Continuity {
651                prev_manifest_digest: None,
652                base_manifest_digest: None,
653                chain_length: 0,
654            },
655            shard: None,
656            compression: None,
657            extensions: None,
658        }
659    }
660
661    #[test]
662    fn cbor_round_trip() {
663        let manifest = sample_manifest();
664        let cbor = manifest.to_cbor();
665        let decoded = Manifest::from_cbor(&cbor).unwrap();
666
667        assert_eq!(manifest.format_version, decoded.format_version);
668        assert_eq!(manifest.bundle_id, decoded.bundle_id);
669        assert_eq!(manifest.producer_id, decoded.producer_id);
670        assert_eq!(manifest.bundle_type, decoded.bundle_type);
671        assert_eq!(manifest.entry_count, decoded.entry_count);
672        assert_eq!(manifest.ca_scopes.len(), decoded.ca_scopes.len());
673        assert_eq!(manifest.window, decoded.window);
674        assert_eq!(manifest.integrity, decoded.integrity);
675        assert_eq!(manifest.continuity, decoded.continuity);
676    }
677
678    #[test]
679    fn deterministic_encoding() {
680        let manifest = sample_manifest();
681        let cbor1 = manifest.to_cbor();
682        let cbor2 = manifest.to_cbor();
683        assert_eq!(cbor1, cbor2, "CBOR encoding must be deterministic");
684    }
685}