Skip to main content

khora_io/asset/
manifest.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8
9//! Pack-integrity sidecar (`manifest.bin`).
10//!
11//! When the writer enables manifest emission, every entry in `data.pack`
12//! gets a BLAKE3 hash of its **uncompressed** bytes recorded alongside
13//! its UUID. The runtime reads `manifest.bin` at boot (when
14//! `RuntimeConfig::verify_integrity` is set) and re-hashes each asset on
15//! load. Mismatches raise `IntegrityError` so corruption / tampering is
16//! detected before the bytes hit a decoder.
17//!
18//! On-disk layout: bincode-encoded `Vec<ManifestEntry>` — same approach
19//! as `index.bin`, distinct file so the runtime can opt out cheaply.
20
21use anyhow::{anyhow, Context, Result};
22use khora_core::asset::AssetUUID;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25
26/// One row of `manifest.bin` — pre-computed integrity record.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ManifestEntry {
29    pub uuid: AssetUUID,
30    /// BLAKE3 hash of the asset's *uncompressed* bytes, as a 32-byte digest.
31    pub blake3: [u8; 32],
32    /// Uncompressed size in bytes — defensive sanity check (mismatch
33    /// indicates a corrupted index even before the hash is computed).
34    pub size: u64,
35}
36
37/// In-memory manifest for fast `verify(uuid, &bytes)` lookups.
38#[derive(Debug, Default, Clone)]
39pub struct PackManifest {
40    by_uuid: HashMap<AssetUUID, ManifestEntry>,
41}
42
43impl PackManifest {
44    /// Empty manifest — writer accumulates entries through [`Self::insert`].
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Hashes `bytes` with BLAKE3, records the digest under `uuid`.
50    pub fn insert(&mut self, uuid: AssetUUID, bytes: &[u8]) {
51        let hash: [u8; 32] = *blake3::hash(bytes).as_bytes();
52        self.by_uuid.insert(
53            uuid,
54            ManifestEntry {
55                uuid,
56                blake3: hash,
57                size: bytes.len() as u64,
58            },
59        );
60    }
61
62    /// Encodes the manifest into the on-disk byte vector.
63    pub fn encode(&self) -> Result<Vec<u8>> {
64        let mut entries: Vec<ManifestEntry> = self.by_uuid.values().cloned().collect();
65        // Stable order — same property the index relies on for byte
66        // determinism across builds. Sorted by BLAKE3 digest because
67        // `AssetUUID` doesn't implement `Ord` and the digest is already
68        // stable + total.
69        entries.sort_by_key(|a| a.blake3);
70        let cfg = bincode::config::standard();
71        bincode::serde::encode_to_vec(&entries, cfg)
72            .map_err(|e| anyhow!("Failed to encode manifest: {}", e))
73    }
74
75    /// Decodes the on-disk byte vector into a manifest ready for lookup.
76    pub fn decode(bytes: &[u8]) -> Result<Self> {
77        let cfg = bincode::config::standard();
78        let (entries, _): (Vec<ManifestEntry>, _) = bincode::serde::decode_from_slice(bytes, cfg)
79            .context("Failed to decode manifest.bin")?;
80        let by_uuid = entries.into_iter().map(|e| (e.uuid, e)).collect();
81        Ok(Self { by_uuid })
82    }
83
84    /// Returns `true` if the manifest contains no entries.
85    pub fn is_empty(&self) -> bool {
86        self.by_uuid.is_empty()
87    }
88
89    /// Number of recorded entries.
90    pub fn len(&self) -> usize {
91        self.by_uuid.len()
92    }
93
94    /// Retrieve the recorded hash + size for `uuid`.
95    pub fn get(&self, uuid: &AssetUUID) -> Option<&ManifestEntry> {
96        self.by_uuid.get(uuid)
97    }
98
99    /// Verifies `bytes` against the recorded hash for `uuid`.
100    /// Returns `Ok(())` on match, `Err` on mismatch (or if the uuid is
101    /// not in the manifest — the caller chose to verify but the record
102    /// is missing).
103    pub fn verify(&self, uuid: &AssetUUID, bytes: &[u8]) -> Result<()> {
104        let Some(entry) = self.by_uuid.get(uuid) else {
105            return Err(anyhow!("Manifest: no record for asset {:?}", uuid));
106        };
107        if entry.size != bytes.len() as u64 {
108            return Err(anyhow!(
109                "Manifest: size mismatch for {:?} (expected {}, got {})",
110                uuid,
111                entry.size,
112                bytes.len()
113            ));
114        }
115        let actual: [u8; 32] = *blake3::hash(bytes).as_bytes();
116        if actual != entry.blake3 {
117            return Err(anyhow!(
118                "Manifest: BLAKE3 mismatch for {:?} (corrupted or tampered)",
119                uuid
120            ));
121        }
122        Ok(())
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn round_trip_encode_decode() {
132        let mut m = PackManifest::new();
133        m.insert(AssetUUID::new_v5("a.png"), b"AAAA");
134        m.insert(AssetUUID::new_v5("b.png"), b"BBBB");
135        let bytes = m.encode().unwrap();
136        let m2 = PackManifest::decode(&bytes).unwrap();
137        assert_eq!(m.len(), m2.len());
138        for (uuid, entry) in &m.by_uuid {
139            let other = m2.get(uuid).unwrap();
140            assert_eq!(other.blake3, entry.blake3);
141            assert_eq!(other.size, entry.size);
142        }
143    }
144
145    #[test]
146    fn verify_accepts_correct_bytes_and_rejects_corruption() {
147        let mut m = PackManifest::new();
148        let uuid = AssetUUID::new_v5("foo");
149        m.insert(uuid, b"PAYLOAD");
150        m.verify(&uuid, b"PAYLOAD").unwrap();
151        assert!(m.verify(&uuid, b"DIFFERENT").is_err());
152        assert!(m.verify(&uuid, b"PAYLOAD-EXTRA").is_err()); // size mismatch
153    }
154
155    #[test]
156    fn verify_unknown_uuid_errors() {
157        let m = PackManifest::new();
158        let unknown = AssetUUID::new_v5("nope");
159        assert!(m.verify(&unknown, b"x").is_err());
160    }
161}