1use crate::cbor::Value;
26
27pub const DEFAULT_CHUNK_SIZE: usize = 262_144;
29
30const VERSION: u8 = 1;
31const CODEC_RAW: u8 = 0x55;
32const CODEC_MANIFEST: u8 = 0x56;
33
34pub 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 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
118pub 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
162pub 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
172pub fn block_mcid(data: &[u8]) -> Mcid {
178 make_mcid(CODEC_RAW, Algorithm::Blake3.hash(data))
179}
180
181pub 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
208pub 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 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
261fn 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
278fn 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
305pub 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 ZeroChunkSize,
362 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
389pub 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 #[test]
495 fn even_chunk_count_matches_the_reference() {
496 let data = b"AAAABBBBCCCCD"; 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 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 #[test]
540 fn odd_chunk_count_matches_the_reference() {
541 let data = b"AAAABBBBCCCC"; 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 #[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); 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 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 #[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 #[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}