Skip to main content

qualia_core_db/bundle/
writer.rs

1//! Build a `.hmc` bundle from a set of intact files.
2
3use sha2::{Digest, Sha256};
4
5use crate::container_10d::crc32c::crc32c;
6
7use super::format::{
8    align_up, BundleEntry, BundleError, BUNDLE_ENTRY_ALIGN, BUNDLE_HEADER_SIZE, BUNDLE_MAGIC,
9    BUNDLE_VERSION, OFF_CRC32C, OFF_ENTRY_COUNT, OFF_INDEX_LENGTH, OFF_INDEX_OFFSET, OFF_MAGIC,
10    OFF_TOTAL_LENGTH, OFF_VERSION,
11};
12
13/// Accumulates intact files and serialises them into a `.hmc` bundle.
14///
15/// Files are stored **verbatim** — no compression, no transformation — each
16/// page-aligned so the interior alignment (and thus zero-copy segment access)
17/// of embedded `.q42` / `.p64` / `.10d` files is preserved. Entry order in the
18/// index follows insertion order (deterministic bundles for a given input).
19#[derive(Default)]
20pub struct BundleWriter {
21    // (key, kind, bytes, meta)
22    entries: Vec<(String, String, Vec<u8>, Option<Vec<u8>>)>,
23}
24
25impl BundleWriter {
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Number of files added so far.
31    pub fn len(&self) -> usize {
32        self.entries.len()
33    }
34
35    pub fn is_empty(&self) -> bool {
36        self.entries.is_empty()
37    }
38
39    /// Add one intact file. `key` must be unique and non-empty. `kind` names the
40    /// embedded format (`"10d"`, `"q42"`, `"p64"`, `"manifest"`, …). `meta` is an
41    /// optional opaque per-entry CBOR blob (domain-specific).
42    pub fn add_file(
43        &mut self,
44        key: impl Into<String>,
45        kind: impl Into<String>,
46        bytes: Vec<u8>,
47        meta: Option<Vec<u8>>,
48    ) -> Result<&mut Self, BundleError> {
49        let key = key.into();
50        if key.is_empty() {
51            return Err(BundleError::EmptyKey);
52        }
53        if self.entries.iter().any(|(k, _, _, _)| *k == key) {
54            return Err(BundleError::DuplicateKey(key));
55        }
56        self.entries.push((key, kind.into(), bytes, meta));
57        Ok(self)
58    }
59
60    /// Serialise the bundle to a `Vec<u8>`.
61    pub fn build(&self) -> Result<Vec<u8>, BundleError> {
62        let mut out = vec![0u8; BUNDLE_HEADER_SIZE];
63        let mut index: Vec<BundleEntry> = Vec::with_capacity(self.entries.len());
64
65        for (key, kind, bytes, meta) in &self.entries {
66            // Page-align the entry so its interior stays aligned.
67            let padded = align_up(out.len(), BUNDLE_ENTRY_ALIGN);
68            out.resize(padded, 0);
69            let offset = out.len() as u64;
70            out.extend_from_slice(bytes);
71
72            let mut hasher = Sha256::new();
73            hasher.update(bytes);
74            let sha256 = hasher.finalize().to_vec();
75
76            index.push(BundleEntry {
77                key: key.clone(),
78                kind: kind.clone(),
79                offset,
80                length: bytes.len() as u64,
81                sha256,
82                meta: meta.clone(),
83            });
84        }
85
86        // Page-align the index footer too (tidy range fetches of the directory).
87        let padded = align_up(out.len(), BUNDLE_ENTRY_ALIGN);
88        out.resize(padded, 0);
89        let index_offset = out.len() as u64;
90
91        let mut index_bytes = Vec::new();
92        ciborium::into_writer(&index, &mut index_bytes)
93            .map_err(|e| BundleError::Cbor(e.to_string()))?;
94        out.extend_from_slice(&index_bytes);
95        let index_length = index_bytes.len() as u64;
96        let total_length = out.len() as u64;
97
98        // Fill the header (CRC field stays zero for the CRC computation).
99        out[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&BUNDLE_MAGIC);
100        out[OFF_VERSION..OFF_VERSION + 2].copy_from_slice(&BUNDLE_VERSION.to_le_bytes());
101        out[OFF_ENTRY_COUNT..OFF_ENTRY_COUNT + 4]
102            .copy_from_slice(&(self.entries.len() as u32).to_le_bytes());
103        out[OFF_INDEX_OFFSET..OFF_INDEX_OFFSET + 8].copy_from_slice(&index_offset.to_le_bytes());
104        out[OFF_INDEX_LENGTH..OFF_INDEX_LENGTH + 8].copy_from_slice(&index_length.to_le_bytes());
105        out[OFF_TOTAL_LENGTH..OFF_TOTAL_LENGTH + 8].copy_from_slice(&total_length.to_le_bytes());
106
107        // Whole-file CRC-32C over the buffer with the CRC field == 0.
108        let crc = crc32c(&out);
109        out[OFF_CRC32C..OFF_CRC32C + 4].copy_from_slice(&crc.to_le_bytes());
110
111        Ok(out)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn rejects_empty_and_duplicate_keys() {
121        let mut w = BundleWriter::new();
122        assert!(matches!(
123            w.add_file("", "10d", vec![1], None),
124            Err(BundleError::EmptyKey)
125        ));
126        w.add_file("a", "10d", vec![1, 2], None).unwrap();
127        assert!(matches!(
128            w.add_file("a", "q42", vec![3], None),
129            Err(BundleError::DuplicateKey(_))
130        ));
131    }
132
133    #[test]
134    fn entries_are_page_aligned() {
135        let mut w = BundleWriter::new();
136        w.add_file("a", "10d", vec![0xAB; 7], None).unwrap();
137        w.add_file("b", "10d", vec![0xCD; 130], None).unwrap();
138        let bytes = w.build().unwrap();
139        let reader = super::super::reader::BundleReader::parse(&bytes).unwrap();
140        for e in reader.entries() {
141            assert_eq!(
142                e.offset % BUNDLE_ENTRY_ALIGN as u64,
143                0,
144                "entry {} unaligned",
145                e.key
146            );
147        }
148    }
149}