Skip to main content

macula_rust/
manifest.rs

1//! Fixed-size chunking, Merkle-root computation, and manifest
2//! construction for content larger than one storage block. Ported from
3//! macula's own `macula_manifest` (SDK) — see
4//! `plans/PLAN_WIRE_PROTOCOL.md` §12.2.
5//!
6//! Mirrors the reference byte-for-byte: same MCID format, same default
7//! chunk size (256 KiB), same Merkle fold (including the odd-leaf-count
8//! rule — pair the last hash with itself), same canonical-CBOR MCID
9//! derivation. Verified against real `macula_manifest:create/2` /
10//! `chunk_mcid/3` / `verify/2` output (even *and* odd chunk counts, to
11//! exercise both branches of the Merkle fold) — see this module's tests.
12//!
13//! **Two different wire representations of `name`, both verified
14//! separately, not confused with each other:** `compute_mcid`'s
15//! canonical hash input wraps `name` as CBOR *text* (a deliberate,
16//! narrow special case in the reference, just for that hash
17//! computation), while [`to_wire`] — the actual manifest map as sent in
18//! a `_content.put_manifest` CALL payload — encodes `name` as a raw
19//! *byte string*, matching its `binary()` type. Confirmed directly by
20//! encoding a real manifest through the general deterministic-CBOR
21//! codec and inspecting the bytes, not inferred from the type spec
22//! alone — see the CALL/PUBLISH `procedure`/`topic` lesson in
23//! `src/frame.rs` for why that inference alone wasn't trusted here.
24
25use crate::cbor::Value;
26
27/// 256 KiB — matches `macula_manifest:default_chunk_size/0`.
28pub const DEFAULT_CHUNK_SIZE: usize = 262_144;
29
30const VERSION: u8 = 1;
31const CODEC_RAW: u8 = 0x55;
32const CODEC_MANIFEST: u8 = 0x56;
33
34/// `<<Version:8, Codec:8, Hash:32/binary>>` — 34 bytes.
35pub type Mcid = [u8; 34];
36
37fn make_mcid(codec: u8, hash: [u8; 32]) -> Mcid {
38    let mut out = [0u8; 34];
39    out[0] = VERSION;
40    out[1] = codec;
41    out[2..].copy_from_slice(&hash);
42    out
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Algorithm {
47    Blake3,
48    Sha256,
49}
50
51impl Algorithm {
52    fn hash(self, data: &[u8]) -> [u8; 32] {
53        match self {
54            Algorithm::Blake3 => *blake3::hash(data).as_bytes(),
55            Algorithm::Sha256 => {
56                use sha2::{Digest, Sha256};
57                Sha256::digest(data).into()
58            }
59        }
60    }
61
62    pub fn name(self) -> &'static str {
63        match self {
64            Algorithm::Blake3 => "blake3",
65            Algorithm::Sha256 => "sha256",
66        }
67    }
68
69    /// Matches `to_algorithm/1`'s own fallback: anything unrecognized
70    /// defaults to `blake3`, it doesn't error.
71    pub fn from_name(name: &str) -> Algorithm {
72        match name {
73            "sha256" => Algorithm::Sha256,
74            _ => Algorithm::Blake3,
75        }
76    }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ChunkInfo {
81    pub index: usize,
82    pub offset: usize,
83    pub size: usize,
84    pub hash: [u8; 32],
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub struct Manifest {
89    pub mcid: Mcid,
90    pub version: u32,
91    pub name: String,
92    pub size: u64,
93    pub created: u64,
94    pub chunk_size: usize,
95    pub chunk_count: usize,
96    pub hash_algorithm: Algorithm,
97    pub root_hash: [u8; 32],
98    pub chunks: Vec<ChunkInfo>,
99}
100
101#[derive(Debug, Clone)]
102pub struct CreateOptions {
103    pub name: String,
104    pub chunk_size: usize,
105    pub hash_algorithm: Algorithm,
106}
107
108impl Default for CreateOptions {
109    fn default() -> Self {
110        Self {
111            name: "unnamed".to_string(),
112            chunk_size: DEFAULT_CHUNK_SIZE,
113            hash_algorithm: Algorithm::Blake3,
114        }
115    }
116}
117
118/// Split `data` into fixed-size chunks and build its manifest. Returns
119/// the manifest and the chunk bytes in order (index 0 first) — a caller
120/// uploads each chunk (`_content.put_block`) then the manifest itself
121/// (`_content.put_manifest`), per §12.2.
122///
123/// `opts.chunk_size` must be non-zero (matches the reference: it never
124/// guards against zero either, and a zero chunk size is a caller bug,
125/// not a case worth silently tolerating).
126pub fn create(data: &[u8], opts: &CreateOptions) -> (Manifest, Vec<Vec<u8>>) {
127    create_with_created(data, opts, current_unix_secs())
128}
129
130fn create_with_created(
131    data: &[u8],
132    opts: &CreateOptions,
133    created: u64,
134) -> (Manifest, Vec<Vec<u8>>) {
135    let chunks = do_chunk(data, opts.chunk_size);
136    let chunk_infos = chunk_infos(&chunks, opts.hash_algorithm);
137    let root_hash = root_hash_for(&chunk_infos, opts.hash_algorithm);
138    let chunk_count = chunk_infos.len();
139    let mcid = compute_mcid(
140        &opts.name,
141        data.len() as u64,
142        opts.chunk_size,
143        chunk_count,
144        opts.hash_algorithm,
145        &root_hash,
146    );
147    let manifest = Manifest {
148        mcid,
149        version: 1,
150        name: opts.name.clone(),
151        size: data.len() as u64,
152        created,
153        chunk_size: opts.chunk_size,
154        chunk_count,
155        hash_algorithm: opts.hash_algorithm,
156        root_hash,
157        chunks: chunk_infos,
158    };
159    (manifest, chunks)
160}
161
162/// The MCID a chunk at `index` is stored/fetched under — the station
163/// derives this same value independently when serving the chunk, so
164/// both sides agree on its address without exchanging it.
165pub fn chunk_mcid(manifest: &Manifest, index: usize) -> Option<Mcid> {
166    manifest
167        .chunks
168        .get(index)
169        .map(|c| make_mcid(CODEC_RAW, c.hash))
170}
171
172/// The MCID a whole blob is stored/fetched under when it's small enough
173/// to be a single block (no manifest at all). Matches
174/// `macula_content_transfer:put_single_block/3` exactly: **always**
175/// BLAKE3, regardless of any algorithm preference — single-block content
176/// has no algorithm choice, only chunked/manifest content does.
177pub fn block_mcid(data: &[u8]) -> Mcid {
178    make_mcid(CODEC_RAW, Algorithm::Blake3.hash(data))
179}
180
181/// Whether `mcid` addresses a manifest (chunked content) rather than a
182/// single raw block — determined from its own codec byte, no network
183/// round trip needed. Matches `macula_content_transfer:is_chunked/2`'s
184/// get-side check.
185pub fn mcid_is_chunked(mcid: &Mcid) -> bool {
186    mcid[1] == CODEC_MANIFEST
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum VerifyError {
191    SizeMismatch,
192    RootHashMismatch,
193}
194
195impl std::fmt::Display for VerifyError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            VerifyError::SizeMismatch => write!(f, "data size does not match the manifest"),
199            VerifyError::RootHashMismatch => {
200                write!(f, "re-chunked root hash does not match the manifest")
201            }
202        }
203    }
204}
205
206impl std::error::Error for VerifyError {}
207
208/// Verify reassembled `data` against `manifest`: size, then a fresh
209/// Merkle root over `data` re-chunked the same way.
210pub fn verify(manifest: &Manifest, data: &[u8]) -> Result<(), VerifyError> {
211    if data.len() as u64 != manifest.size {
212        return Err(VerifyError::SizeMismatch);
213    }
214    let chunks = do_chunk(data, manifest.chunk_size);
215    let infos = chunk_infos(&chunks, manifest.hash_algorithm);
216    let actual_root = root_hash_for(&infos, manifest.hash_algorithm);
217    if actual_root == manifest.root_hash {
218        Ok(())
219    } else {
220        Err(VerifyError::RootHashMismatch)
221    }
222}
223
224fn do_chunk(data: &[u8], chunk_size: usize) -> Vec<Vec<u8>> {
225    // `[T]::chunks(n)` is exactly equivalent to the reference's
226    // recursive `do_chunk/3` for every case checked, including exact
227    // multiples of chunk_size (no trailing empty chunk) and empty input
228    // (zero chunks, not one empty chunk).
229    data.chunks(chunk_size).map(<[u8]>::to_vec).collect()
230}
231
232fn chunk_infos(chunks: &[Vec<u8>], algorithm: Algorithm) -> Vec<ChunkInfo> {
233    let mut offset = 0usize;
234    chunks
235        .iter()
236        .enumerate()
237        .map(|(index, chunk)| {
238            let info = ChunkInfo {
239                index,
240                offset,
241                size: chunk.len(),
242                hash: algorithm.hash(chunk),
243            };
244            offset += chunk.len();
245            info
246        })
247        .collect()
248}
249
250fn root_hash_for(infos: &[ChunkInfo], algorithm: Algorithm) -> [u8; 32] {
251    if infos.is_empty() {
252        return algorithm.hash(&[]);
253    }
254    let mut hashes: Vec<[u8; 32]> = infos.iter().map(|i| i.hash).collect();
255    while hashes.len() > 1 {
256        hashes = combine(&hashes, algorithm);
257    }
258    hashes[0]
259}
260
261/// One Merkle-fold pass: pairs from the front, `hash(L || R)`. An odd
262/// leftover at the end is paired with itself, `hash(Last || Last)` — the
263/// rule most likely to be implemented wrong; verified against an
264/// odd-chunk-count reference vector specifically (see this module's
265/// tests), not just even counts.
266fn combine(hashes: &[[u8; 32]], algorithm: Algorithm) -> Vec<[u8; 32]> {
267    hashes
268        .chunks(2)
269        .map(|pair| {
270            let mut buf = Vec::with_capacity(64);
271            buf.extend_from_slice(&pair[0]);
272            buf.extend_from_slice(pair.get(1).unwrap_or(&pair[0]));
273            algorithm.hash(&buf)
274        })
275        .collect()
276}
277
278/// The canonical hash input for a manifest's own MCID — deliberately
279/// excludes `created` (timestamp) and `chunks` (already rolled up into
280/// `root_hash`). `name` and `hash_algorithm` are wrapped as CBOR text
281/// here specifically, matching the reference's own special-cased
282/// `compute_mcid/2` — see this module's doc comment for why that's
283/// *not* the same encoding [`to_wire`] uses for `name`.
284fn compute_mcid(
285    name: &str,
286    size: u64,
287    chunk_size: usize,
288    chunk_count: usize,
289    algorithm: Algorithm,
290    root_hash: &[u8; 32],
291) -> Mcid {
292    let canonical = Value::Map(vec![
293        (Value::text("name"), Value::text(name)),
294        (Value::text("size"), Value::Int(size as i128)),
295        (Value::text("chunk_size"), Value::Int(chunk_size as i128)),
296        (Value::text("chunk_count"), Value::Int(chunk_count as i128)),
297        (Value::text("hash_algorithm"), Value::text(algorithm.name())),
298        (Value::text("root_hash"), Value::Bytes(root_hash.to_vec())),
299    ]);
300    let bytes = crate::cbor::encode(&canonical).expect("manifest MCID fields are always encodable");
301    let hash = algorithm.hash(&bytes);
302    make_mcid(CODEC_MANIFEST, hash)
303}
304
305/// Encode `manifest` as it's actually sent in a `_content.put_manifest`
306/// CALL payload — `name` as bytes (its real `binary()` type), NOT the
307/// text-wrapped form `compute_mcid` uses internally. See this module's
308/// doc comment.
309pub fn to_wire(manifest: &Manifest) -> Value {
310    Value::Map(vec![
311        (Value::text("mcid"), Value::Bytes(manifest.mcid.to_vec())),
312        (Value::text("version"), Value::Int(manifest.version as i128)),
313        (
314            Value::text("name"),
315            Value::Bytes(manifest.name.as_bytes().to_vec()),
316        ),
317        (Value::text("size"), Value::Int(manifest.size as i128)),
318        (Value::text("created"), Value::Int(manifest.created as i128)),
319        (
320            Value::text("chunk_size"),
321            Value::Int(manifest.chunk_size as i128),
322        ),
323        (
324            Value::text("chunk_count"),
325            Value::Int(manifest.chunk_count as i128),
326        ),
327        (
328            Value::text("hash_algorithm"),
329            Value::text(manifest.hash_algorithm.name()),
330        ),
331        (
332            Value::text("root_hash"),
333            Value::Bytes(manifest.root_hash.to_vec()),
334        ),
335        (
336            Value::text("chunks"),
337            Value::List(manifest.chunks.iter().map(chunk_info_to_wire).collect()),
338        ),
339    ])
340}
341
342fn chunk_info_to_wire(info: &ChunkInfo) -> Value {
343    Value::Map(vec![
344        (Value::text("index"), Value::Int(info.index as i128)),
345        (Value::text("offset"), Value::Int(info.offset as i128)),
346        (Value::text("size"), Value::Int(info.size as i128)),
347        (Value::text("hash"), Value::Bytes(info.hash.to_vec())),
348    ])
349}
350
351#[derive(Debug, PartialEq, Eq)]
352pub enum FromWireError {
353    MissingField(&'static str),
354    WrongFieldType(&'static str),
355    /// `chunk_size` is 0. Never produced by [`create`] (its own doc
356    /// comment already requires a non-zero `chunk_size`), so this only
357    /// ever rejects a malicious/malformed wire manifest — accepting it
358    /// would panic downstream: `[T]::chunks(0)` (called from
359    /// [`verify`]'s own `do_chunk`) panics unconditionally on a zero
360    /// chunk size, even for empty content.
361    ZeroChunkSize,
362    /// `chunk_count` doesn't match the actual number of entries in the
363    /// `chunks` list. Never produced by [`create`] (`chunk_count` is
364    /// always `chunk_infos.len()`), so this only ever rejects a
365    /// malicious/malformed wire manifest — accepting it would let a
366    /// caller iterate `0..chunk_count` past the real end of `chunks`
367    /// and panic on the resulting `None` from [`chunk_mcid`].
368    InconsistentChunkCount,
369}
370
371impl std::fmt::Display for FromWireError {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        match self {
374            FromWireError::MissingField(name) => write!(f, "missing required field {name:?}"),
375            FromWireError::WrongFieldType(name) => write!(f, "field {name:?} has the wrong type"),
376            FromWireError::ZeroChunkSize => write!(f, "chunk_size is 0"),
377            FromWireError::InconsistentChunkCount => {
378                write!(
379                    f,
380                    "chunk_count does not match the number of entries in chunks"
381                )
382            }
383        }
384    }
385}
386
387impl std::error::Error for FromWireError {}
388
389/// Parse a manifest as received from a `_content.get_manifest` RESULT.
390pub fn from_wire(value: &Value) -> Result<Manifest, FromWireError> {
391    let mcid: Mcid = get_bytes_exact(value, "mcid")?;
392    let version = get_uint(value, "version")? as u32;
393    let name = get_string_bytes(value, "name")?;
394    let size = get_uint(value, "size")?;
395    let created = get_uint(value, "created")?;
396    let chunk_size = get_uint(value, "chunk_size")? as usize;
397    let chunk_count = get_uint(value, "chunk_count")? as usize;
398    let hash_algorithm = Algorithm::from_name(&get_text(value, "hash_algorithm")?);
399    let root_hash: [u8; 32] = get_bytes_exact(value, "root_hash")?;
400    let chunks = match value.get("chunks") {
401        Some(Value::List(items)) => items
402            .iter()
403            .map(chunk_info_from_wire)
404            .collect::<Result<Vec<_>, _>>()?,
405        Some(_) => return Err(FromWireError::WrongFieldType("chunks")),
406        None => return Err(FromWireError::MissingField("chunks")),
407    };
408    if chunk_size == 0 {
409        return Err(FromWireError::ZeroChunkSize);
410    }
411    if chunks.len() != chunk_count {
412        return Err(FromWireError::InconsistentChunkCount);
413    }
414    Ok(Manifest {
415        mcid,
416        version,
417        name,
418        size,
419        created,
420        chunk_size,
421        chunk_count,
422        hash_algorithm,
423        root_hash,
424        chunks,
425    })
426}
427
428fn chunk_info_from_wire(value: &Value) -> Result<ChunkInfo, FromWireError> {
429    Ok(ChunkInfo {
430        index: get_uint(value, "index")? as usize,
431        offset: get_uint(value, "offset")? as usize,
432        size: get_uint(value, "size")? as usize,
433        hash: get_bytes_exact(value, "hash")?,
434    })
435}
436
437fn get_uint(value: &Value, field: &'static str) -> Result<u64, FromWireError> {
438    match value.get(field) {
439        Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64),
440        Some(_) => Err(FromWireError::WrongFieldType(field)),
441        None => Err(FromWireError::MissingField(field)),
442    }
443}
444
445fn get_text(value: &Value, field: &'static str) -> Result<String, FromWireError> {
446    match value.get(field) {
447        Some(Value::Text(t)) => Ok(t.clone()),
448        Some(_) => Err(FromWireError::WrongFieldType(field)),
449        None => Err(FromWireError::MissingField(field)),
450    }
451}
452
453fn get_string_bytes(value: &Value, field: &'static str) -> Result<String, FromWireError> {
454    match value.get(field) {
455        Some(Value::Bytes(b)) => {
456            String::from_utf8(b.clone()).map_err(|_| FromWireError::WrongFieldType(field))
457        }
458        Some(_) => Err(FromWireError::WrongFieldType(field)),
459        None => Err(FromWireError::MissingField(field)),
460    }
461}
462
463fn get_bytes_exact<const N: usize>(
464    value: &Value,
465    field: &'static str,
466) -> Result<[u8; N], FromWireError> {
467    match value.get(field) {
468        Some(Value::Bytes(b)) => b
469            .as_slice()
470            .try_into()
471            .map_err(|_| FromWireError::WrongFieldType(field)),
472        Some(_) => Err(FromWireError::WrongFieldType(field)),
473        None => Err(FromWireError::MissingField(field)),
474    }
475}
476
477fn current_unix_secs() -> u64 {
478    std::time::SystemTime::now()
479        .duration_since(std::time::UNIX_EPOCH)
480        .expect("system clock is after the Unix epoch")
481        .as_secs()
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    fn hex_bytes(s: &str) -> Vec<u8> {
489        ::hex::decode(s).expect("valid hex fixture")
490    }
491
492    /// Even chunk count (4) — captured from a real `macula_manifest:create/2`
493    /// via `rebar3 shell` against `macula-io/macula`.
494    #[test]
495    fn even_chunk_count_matches_the_reference() {
496        let data = b"AAAABBBBCCCCD"; // 13 bytes
497        let opts = CreateOptions {
498            name: "test-file".to_string(),
499            chunk_size: 4,
500            hash_algorithm: Algorithm::Blake3,
501        };
502        let (manifest, chunks) = create_with_created(data, &opts, 0);
503
504        assert_eq!(chunks.len(), 4);
505        assert_eq!(chunks[0], b"AAAA");
506        assert_eq!(chunks[3], b"D");
507
508        assert_eq!(
509            hex::encode_upper(manifest.root_hash),
510            "784F87CDC9C180A21C878FC26703F9E4782F2FD2E6235048299811675E36EAC4"
511        );
512        assert_eq!(
513            hex::encode_upper(manifest.mcid),
514            "01564CC855EF538530393E36DBD4CCD216558B60F87498889890247EEB9B52B8FED7"
515        );
516
517        // Per-chunk hashes and offsets, spot-checked against the same run.
518        assert_eq!(manifest.chunks[0].offset, 0);
519        assert_eq!(
520            hex::encode_upper(manifest.chunks[0].hash),
521            "26C7BB3DAAAA0439EB3E5C5270E7C4DB05218D8892A0258FBD4911CEF5006D23"
522        );
523        assert_eq!(manifest.chunks[3].offset, 12);
524        assert_eq!(manifest.chunks[3].size, 1);
525
526        assert_eq!(
527            chunk_mcid(&manifest, 0).map(hex::encode_upper),
528            Some(
529                "015526C7BB3DAAAA0439EB3E5C5270E7C4DB05218D8892A0258FBD4911CEF5006D23".to_string()
530            )
531        );
532
533        assert_eq!(verify(&manifest, data), Ok(()));
534    }
535
536    /// Odd chunk count (3) — exercises the Merkle fold's "pair the last
537    /// hash with itself" branch, which the even-count test above never
538    /// touches.
539    #[test]
540    fn odd_chunk_count_matches_the_reference() {
541        let data = b"AAAABBBBCCCC"; // 12 bytes, chunk_size 4 -> exactly 3 chunks
542        let opts = CreateOptions {
543            name: "odd-test".to_string(),
544            chunk_size: 4,
545            hash_algorithm: Algorithm::Blake3,
546        };
547        let (manifest, chunks) = create_with_created(data, &opts, 0);
548
549        assert_eq!(chunks.len(), 3);
550        assert_eq!(
551            hex::encode_upper(manifest.root_hash),
552            "50FE839CCDE80B13D7531A9C34FD856DBCBBB87D8FBD241DE6AFF2C86909CD54"
553        );
554        assert_eq!(
555            hex::encode_upper(manifest.mcid),
556            "0156589728C90DB0138CA87E4E500A61812C64D30C3BE325184A761F20CA04BC86FB"
557        );
558
559        assert_eq!(verify(&manifest, data), Ok(()));
560        assert_eq!(
561            verify(&manifest, b"AAAABBBBWRONG"),
562            Err(VerifyError::SizeMismatch)
563        );
564    }
565
566    #[test]
567    fn verify_rejects_tampered_content_of_the_same_size() {
568        let data = b"AAAABBBBCCCC";
569        let opts = CreateOptions {
570            chunk_size: 4,
571            ..Default::default()
572        };
573        let (manifest, _) = create_with_created(data, &opts, 0);
574        assert_eq!(
575            verify(&manifest, b"AAAABBBBCCCX"),
576            Err(VerifyError::RootHashMismatch)
577        );
578    }
579
580    /// The full manifest map as it's actually sent in a
581    /// `_content.put_manifest` CALL payload — captured by encoding a
582    /// real manifest through `macula_cbor_nif:pack_deterministic/1`
583    /// directly (the same general codec CALL payloads go through), not
584    /// through `compute_mcid`'s special canonical path. This is what
585    /// proves `name` really is bytes on the wire, not text.
586    #[test]
587    fn to_wire_matches_the_reference_byte_for_byte() {
588        let data = b"AAAABBBBCCCC";
589        let opts = CreateOptions {
590            name: "odd-test".to_string(),
591            chunk_size: 4,
592            hash_algorithm: Algorithm::Blake3,
593        };
594        let (manifest, _) = create_with_created(data, &opts, 1_787_892_082); // 0x6A911172
595
596        let wire = to_wire(&manifest);
597        let encoded = crate::cbor::encode(&wire).expect("encodable manifest");
598        assert_eq!(
599            encoded,
600            hex_bytes(
601                "AA646D63696458220156589728C90DB0138CA87E4E500A61812C64D30C3BE325184A761F20CA04BC86FB646E616D65486F64642D746573746473697A650C666368756E6B7383A46468617368582026C7BB3DAAAA0439EB3E5C5270E7C4DB05218D8892A0258FBD4911CEF5006D236473697A650465696E64657800666F666673657400A464686173685820255EC90F561EDA98B1E5E3EFA56B7B477086E273CD07CC4F780A646D052726446473697A650465696E64657801666F666673657404A464686173685820A83CE6EC6760EB7F66D3D7BBC84D1AAC3BEF0948074F8ED21423D825AE8821726473697A650465696E64657802666F66667365740867637265617465641A6A9111726776657273696F6E0169726F6F745F68617368582050FE839CCDE80B13D7531A9C34FD856DBCBBB87D8FBD241DE6AFF2C86909CD546A6368756E6B5F73697A65046B6368756E6B5F636F756E74036E686173685F616C676F726974686D66626C616B6533"
602            )
603        );
604
605        // Round-trip through from_wire.
606        let decoded = crate::cbor::decode(&encoded).expect("valid CBOR");
607        let parsed = from_wire(&decoded).expect("well-formed manifest");
608        assert_eq!(parsed, manifest);
609    }
610
611    #[test]
612    fn from_wire_rejects_a_missing_field() {
613        let value = Value::Map(vec![(Value::text("mcid"), Value::Bytes(vec![0; 34]))]);
614        assert_eq!(
615            from_wire(&value),
616            Err(FromWireError::MissingField("version"))
617        );
618    }
619
620    /// A malicious/malformed manifest claiming `chunk_size: 0` must be
621    /// rejected at parse time, not accepted and left to panic later:
622    /// `verify`'s own `do_chunk` calls `[T]::chunks(manifest.chunk_size)`,
623    /// which panics unconditionally when its argument is 0.
624    #[test]
625    fn from_wire_rejects_zero_chunk_size() {
626        let (manifest, _) = create_with_created(b"AAAABBBBCCCC", &CreateOptions::default(), 0);
627        let tampered = to_wire(&manifest).with_field("chunk_size", Value::Int(0));
628        assert_eq!(from_wire(&tampered), Err(FromWireError::ZeroChunkSize));
629    }
630
631    /// A malicious/malformed manifest whose `chunk_count` doesn't match
632    /// the actual number of entries in `chunks` must be rejected at
633    /// parse time: a caller iterating `0..chunk_count` (see
634    /// `content::get`) would otherwise index past the real end of
635    /// `chunks` and panic on the resulting `None`.
636    #[test]
637    fn from_wire_rejects_inconsistent_chunk_count() {
638        let opts = CreateOptions {
639            chunk_size: 4,
640            ..CreateOptions::default()
641        };
642        let (manifest, _) = create_with_created(b"AAAABBBBCCCC", &opts, 0);
643        let tampered = to_wire(&manifest).with_field("chunk_count", Value::Int(1000));
644        assert_eq!(
645            from_wire(&tampered),
646            Err(FromWireError::InconsistentChunkCount)
647        );
648    }
649
650    #[test]
651    fn algorithm_from_name_defaults_to_blake3() {
652        assert_eq!(Algorithm::from_name("blake3"), Algorithm::Blake3);
653        assert_eq!(Algorithm::from_name("sha256"), Algorithm::Sha256);
654        assert_eq!(Algorithm::from_name("something-unknown"), Algorithm::Blake3);
655    }
656
657    #[test]
658    fn empty_data_produces_zero_chunks() {
659        let (manifest, chunks) = create_with_created(b"", &CreateOptions::default(), 0);
660        assert_eq!(chunks.len(), 0);
661        assert_eq!(manifest.chunk_count, 0);
662    }
663}