Skip to main content

macula_rust/
manifest.rs

1//! Content manifests as macula 12's macula_manifest builds them, byte for
2//! byte: fixed-size chunks (256 KiB by default), SHA-384 hashes, a 50-byte
3//! content id `<<2, Codec, SHA-384>>` (tag 2 names SHA-384, D24; codec 0x55 a
4//! raw block, 0x56 a manifest), and a Merkle fold that pairs an odd last hash
5//! with itself.
6//!
7//! A manifest's name has two encodings that must not be confused: its
8//! content id hashes the name as CBOR text, while the wire form a manifest
9//! travels in carries it as a byte string.
10
11use std::fmt;
12
13use sha2::{Digest, Sha384};
14
15use crate::cbor::{self, Value};
16
17/// 256 KiB, macula_manifest's default chunk size.
18pub const DEFAULT_CHUNK_SIZE: u64 = 262_144;
19
20/// A SHA-384 digest's length.
21pub const HASH_SIZE: usize = 48;
22
23/// A content id: `<<Tag:8, Codec:8, Hash:48/binary>>`.
24pub type Mcid = [u8; 50];
25
26/// A SHA-384 digest.
27pub type Hash = [u8; HASH_SIZE];
28
29/// The one hash algorithm a manifest names.
30pub const SHA384: &str = "sha384";
31
32const VERSION: u32 = 1;
33const TAG_SHA384: u8 = 2;
34const CODEC_RAW: u8 = 0x55;
35const CODEC_MANIFEST: u8 = 0x56;
36
37/// One chunk of a manifest.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ChunkInfo {
40    pub index: u64,
41    pub offset: u64,
42    pub size: u64,
43    pub hash: Hash,
44}
45
46/// A chunked content's manifest. `name` is bytes, as the wire carries it, and
47/// `hash_algorithm` the wire's own text: a manifest read from a peer may name
48/// anything, and [`verify_mcid`] refuses what is not a UTF-8 name and sha384.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Manifest {
51    pub mcid: Mcid,
52    pub version: u32,
53    pub name: Vec<u8>,
54    pub size: u64,
55    /// Unix seconds.
56    pub created: u64,
57    pub chunk_size: u64,
58    pub chunk_count: u64,
59    pub hash_algorithm: String,
60    pub root_hash: Hash,
61    pub chunks: Vec<ChunkInfo>,
62}
63
64/// Why a manifest was refused.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum ManifestError {
67    /// A chunk size of zero.
68    ChunkSizeZero,
69    /// Content that does not match the manifest, and how.
70    ContentMismatch(String),
71    /// A manifest that does not describe the content id it was asked for by.
72    McidMismatch,
73    /// A manifest whose chunks do not describe its content whole, and where.
74    NotWhole(String),
75    /// A manifest whose chunk hashes do not make its root hash.
76    ChunkHashes,
77    /// A wire form that is not a manifest, and why.
78    Malformed(String),
79}
80
81impl fmt::Display for ManifestError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            ManifestError::ChunkSizeZero => f.write_str("a chunk size of zero"),
85            ManifestError::ContentMismatch(why) => {
86                write!(f, "the content does not match the manifest: {why}")
87            }
88            ManifestError::McidMismatch => {
89                f.write_str("the manifest does not describe the content id")
90            }
91            ManifestError::NotWhole(why) => write!(
92                f,
93                "the manifest's chunks do not describe its content whole: {why}"
94            ),
95            ManifestError::ChunkHashes => {
96                f.write_str("the manifest's chunk hashes do not make its root hash")
97            }
98            ManifestError::Malformed(why) => write!(f, "not a manifest: {why}"),
99        }
100    }
101}
102
103impl std::error::Error for ManifestError {}
104
105fn sha384(data: &[u8]) -> Hash {
106    Sha384::digest(data).into()
107}
108
109fn make_mcid(codec: u8, hash: &Hash) -> Mcid {
110    let mut out = [0u8; 50];
111    out[0] = TAG_SHA384;
112    out[1] = codec;
113    out[2..].copy_from_slice(hash);
114    out
115}
116
117/// Splits `data` into chunks of `chunk_size` bytes and builds its manifest,
118/// created now; returns it with the chunks in order.
119pub fn create(
120    data: &[u8],
121    name: &str,
122    chunk_size: u64,
123) -> Result<(Manifest, Vec<Vec<u8>>), ManifestError> {
124    let now = std::time::SystemTime::now()
125        .duration_since(std::time::UNIX_EPOCH)
126        .map(|d| d.as_secs())
127        .unwrap_or(0);
128    create_at(data, name, chunk_size, now)
129}
130
131/// [`create`] with the manifest's creation time given, in unix seconds.
132pub fn create_at(
133    data: &[u8],
134    name: &str,
135    chunk_size: u64,
136    created: u64,
137) -> Result<(Manifest, Vec<Vec<u8>>), ManifestError> {
138    if chunk_size == 0 {
139        return Err(ManifestError::ChunkSizeZero);
140    }
141    let chunks: Vec<Vec<u8>> = data
142        .chunks(chunk_size as usize)
143        .map(<[u8]>::to_vec)
144        .collect();
145    let infos = chunk_infos(&chunks);
146    let root_hash = root_hash_for(&infos);
147    let mut m = Manifest {
148        mcid: [0; 50],
149        version: VERSION,
150        name: name.as_bytes().to_vec(),
151        size: data.len() as u64,
152        created,
153        chunk_size,
154        chunk_count: infos.len() as u64,
155        hash_algorithm: SHA384.into(),
156        root_hash,
157        chunks: infos,
158    };
159    m.mcid = mcid_for(&m);
160    Ok((m, chunks))
161}
162
163/// The content id chunk `index` is stored and fetched under: the block id of
164/// its bytes, which a sharer derives from the manifest alone.
165pub fn chunk_mcid(m: &Manifest, index: usize) -> Option<Mcid> {
166    m.chunks.get(index).map(|c| make_mcid(CODEC_RAW, &c.hash))
167}
168
169/// The content id of a single block: `<<2, 0x55, SHA-384(data)>>`.
170pub fn block_mcid(data: &[u8]) -> Mcid {
171    make_mcid(CODEC_RAW, &sha384(data))
172}
173
174/// Whether a content id names a manifest (chunked content) rather than a
175/// single block, read from its codec byte.
176pub fn mcid_is_chunked(mcid: &Mcid) -> bool {
177    mcid[1] == CODEC_MANIFEST
178}
179
180/// The content id the manifest's canonical fields describe: its name, size,
181/// chunk size and count, hash algorithm and root hash. Its creation time and
182/// chunk list are not part of it.
183pub fn mcid_for(m: &Manifest) -> Mcid {
184    let canonical = Value::Map(vec![
185        (
186            Value::text("name"),
187            Value::text(String::from_utf8_lossy(&m.name)),
188        ),
189        (Value::text("size"), Value::Int(i128::from(m.size))),
190        (
191            Value::text("chunk_size"),
192            Value::Int(i128::from(m.chunk_size)),
193        ),
194        (
195            Value::text("chunk_count"),
196            Value::Int(i128::from(m.chunk_count)),
197        ),
198        (
199            Value::text("hash_algorithm"),
200            Value::text(m.hash_algorithm.clone()),
201        ),
202        (Value::text("root_hash"), Value::Bytes(m.root_hash.to_vec())),
203    ]);
204    let encoded = cbor::encode(&canonical).expect("a manifest's canonical fields always encode");
205    make_mcid(CODEC_MANIFEST, &sha384(&encoded))
206}
207
208/// Whether `m` describes `mcid`, as macula_manifest's verify_mcid/2 checks:
209/// a UTF-8 name, sha384, and the content id its canonical fields recompute
210/// to. The manifest's own `mcid` field is not consulted.
211pub fn verify_mcid(m: &Manifest, mcid: &Mcid) -> Result<(), ManifestError> {
212    if std::str::from_utf8(&m.name).is_err() || m.hash_algorithm != SHA384 || mcid_for(m) != *mcid {
213        return Err(ManifestError::McidMismatch);
214    }
215    Ok(())
216}
217
218/// Checks reassembled `data` against `m`: its size, then a root hash over
219/// `data` cut the same way.
220pub fn verify(m: &Manifest, data: &[u8]) -> Result<(), ManifestError> {
221    if m.chunk_size == 0 {
222        return Err(ManifestError::ChunkSizeZero);
223    }
224    if data.len() as u64 != m.size {
225        return Err(ManifestError::ContentMismatch(format!(
226            "{} bytes, the manifest's {}",
227            data.len(),
228            m.size
229        )));
230    }
231    let chunks: Vec<Vec<u8>> = data
232        .chunks(m.chunk_size as usize)
233        .map(<[u8]>::to_vec)
234        .collect();
235    if root_hash_for(&chunk_infos(&chunks)) != m.root_hash {
236        return Err(ManifestError::ContentMismatch("another root hash".into()));
237    }
238    Ok(())
239}
240
241/// Checks that `m`'s chunks describe its content whole, cut as [`create`]
242/// cuts it: a positive chunk size, ceil(size / chunk size) chunks, which is
243/// its chunk count, chunk i at offset i × chunk size and chunk size long but
244/// for the last, which holds what is left, between 1 and chunk size bytes.
245pub fn check_whole(m: &Manifest) -> Result<(), ManifestError> {
246    let wanted = if m.chunk_size == 0 {
247        None
248    } else {
249        Some(m.size.div_ceil(m.chunk_size))
250    };
251    if wanted != Some(m.chunk_count) || m.chunk_count != m.chunks.len() as u64 {
252        return Err(ManifestError::NotWhole(format!(
253            "chunk size {}, size {}, {} chunks counted, {} listed",
254            m.chunk_size,
255            m.size,
256            m.chunk_count,
257            m.chunks.len()
258        )));
259    }
260    for (i, c) in m.chunks.iter().enumerate() {
261        let offset = i as u64 * m.chunk_size;
262        let size = m.chunk_size.min(m.size - offset);
263        if c.index != i as u64 || c.offset != offset || c.size != size {
264            return Err(ManifestError::NotWhole(format!("chunk {i}")));
265        }
266    }
267    Ok(())
268}
269
270/// Checks that `m`'s chunk hashes make its root hash. The root hash is part
271/// of the content id and the chunk hashes are not, so after [`verify_mcid`]
272/// this is what ties each chunk, fetched by its hash, to the content id.
273pub fn check_chunk_hashes(m: &Manifest) -> Result<(), ManifestError> {
274    if root_hash_for(&m.chunks) != m.root_hash {
275        return Err(ManifestError::ChunkHashes);
276    }
277    Ok(())
278}
279
280fn chunk_infos(chunks: &[Vec<u8>]) -> Vec<ChunkInfo> {
281    let mut offset = 0u64;
282    chunks
283        .iter()
284        .enumerate()
285        .map(|(i, chunk)| {
286            let info = ChunkInfo {
287                index: i as u64,
288                offset,
289                size: chunk.len() as u64,
290                hash: sha384(chunk),
291            };
292            offset += chunk.len() as u64;
293            info
294        })
295        .collect()
296}
297
298/// The Merkle root: pairs from the front, hash(left || right), an odd last
299/// hash paired with itself, until one is left; SHA-384 of nothing for no
300/// chunks.
301fn root_hash_for(infos: &[ChunkInfo]) -> Hash {
302    let mut level: Vec<Hash> = infos.iter().map(|c| c.hash).collect();
303    if level.is_empty() {
304        return sha384(&[]);
305    }
306    while level.len() > 1 {
307        level = level
308            .chunks(2)
309            .map(|pair| {
310                let right = pair.get(1).unwrap_or(&pair[0]);
311                sha384(&[pair[0].as_slice(), right.as_slice()].concat())
312            })
313            .collect();
314    }
315    level[0]
316}
317
318/// The manifest as it travels: the name as a byte string.
319pub fn to_wire(m: &Manifest) -> Value {
320    let chunks = m
321        .chunks
322        .iter()
323        .map(|c| {
324            Value::Map(vec![
325                (Value::text("index"), Value::Int(i128::from(c.index))),
326                (Value::text("offset"), Value::Int(i128::from(c.offset))),
327                (Value::text("size"), Value::Int(i128::from(c.size))),
328                (Value::text("hash"), Value::Bytes(c.hash.to_vec())),
329            ])
330        })
331        .collect();
332    Value::Map(vec![
333        (Value::text("mcid"), Value::Bytes(m.mcid.to_vec())),
334        (Value::text("version"), Value::Int(i128::from(m.version))),
335        (Value::text("name"), Value::Bytes(m.name.clone())),
336        (Value::text("size"), Value::Int(i128::from(m.size))),
337        (Value::text("created"), Value::Int(i128::from(m.created))),
338        (
339            Value::text("chunk_size"),
340            Value::Int(i128::from(m.chunk_size)),
341        ),
342        (
343            Value::text("chunk_count"),
344            Value::Int(i128::from(m.chunk_count)),
345        ),
346        (
347            Value::text("hash_algorithm"),
348            Value::text(m.hash_algorithm.clone()),
349        ),
350        (Value::text("root_hash"), Value::Bytes(m.root_hash.to_vec())),
351        (Value::text("chunks"), Value::List(chunks)),
352    ])
353}
354
355/// A manifest read from its wire form, as macula_manifest's from_wire/1
356/// reads it. One that names another hash algorithm than sha384 (as text or
357/// bytes), whose chunks do not describe its content whole, or holding a
358/// number outside its field, is refused. Nothing is allocated from the size
359/// or count it claims: only the chunks it lists are read.
360pub fn from_wire(v: &Value) -> Result<Manifest, ManifestError> {
361    let chunks = match v.get("chunks") {
362        Some(Value::List(items)) => items
363            .iter()
364            .map(chunk_from_wire)
365            .collect::<Result<Vec<_>, _>>()?,
366        _ => return Err(malformed("chunks")),
367    };
368    let m = Manifest {
369        mcid: bytes_exact(v, "mcid")?,
370        version: u32::try_from(uint(v, "version")?).map_err(|_| malformed("version"))?,
371        name: match v.get("name") {
372            Some(Value::Bytes(b)) => b.clone(),
373            _ => return Err(malformed("name")),
374        },
375        size: uint(v, "size")?,
376        created: uint(v, "created")?,
377        chunk_size: uint(v, "chunk_size")?,
378        chunk_count: uint(v, "chunk_count")?,
379        hash_algorithm: match v.get("hash_algorithm") {
380            Some(Value::Text(t)) if t == SHA384 => SHA384.into(),
381            Some(Value::Bytes(b)) if b == SHA384.as_bytes() => SHA384.into(),
382            _ => return Err(malformed("hash_algorithm")),
383        },
384        root_hash: bytes_exact(v, "root_hash")?,
385        chunks,
386    };
387    check_whole(&m)?;
388    Ok(m)
389}
390
391fn chunk_from_wire(v: &Value) -> Result<ChunkInfo, ManifestError> {
392    Ok(ChunkInfo {
393        index: uint(v, "index")?,
394        offset: uint(v, "offset")?,
395        size: uint(v, "size")?,
396        hash: bytes_exact(v, "hash")?,
397    })
398}
399
400fn malformed(field: &str) -> ManifestError {
401    ManifestError::Malformed(format!("field {field:?} is missing or of the wrong type"))
402}
403
404/// A field that is an integer between 0 and 2^63 - 1.
405fn uint(v: &Value, field: &str) -> Result<u64, ManifestError> {
406    match v.get(field) {
407        Some(Value::Int(n)) if (0..=i128::from(i64::MAX)).contains(n) => Ok(*n as u64),
408        _ => Err(malformed(field)),
409    }
410}
411
412fn bytes_exact<const N: usize>(v: &Value, field: &str) -> Result<[u8; N], ManifestError> {
413    match v.get(field) {
414        Some(Value::Bytes(b)) => b.as_slice().try_into().map_err(|_| malformed(field)),
415        _ => Err(malformed(field)),
416    }
417}