mkit_core/pack_shard.rs
1//! Erasure-coded pack delivery via Reed-Solomon shards.
2//!
3//! This module is the in-process encode/reconstruct core for issue #159:
4//! it wraps
5//! `commonware_coding::ReedSolomon<Blake3>` so a producer can split a
6//! pack into `N + K` shards and a consumer can reconstruct the pack
7//! from any `N` of those shards. (Issue #661 cut this over from
8//! `ReedSolomon<Sha256>`; see the `RsScheme` type alias below.)
9//!
10//! The wire format and motivation are normatively documented in
11//! `docs/specs/SPEC-PACK-SHARDS.md`. The implementation here matches the v0
12//! spec; transport-level shard fetch (HTTP, S3) is **out of scope** and
13//! lands later under `mkit-transport-*`.
14//!
15//! # Threat model
16//!
17//! * Each [`Shard`] is a self-describing envelope carrying the
18//! commonware `Chunk` (shard payload + index + Merkle proof).
19//! * Before passing a shard to the decoder, the receiver compares
20//! `BLAKE3(shard.bytes)` against the manifest entry in
21//! [`ShardSet::shard_hashes`]. A mismatch means the shard was
22//! tampered with in transit; the shard is rejected without ever
23//! reaching the Reed-Solomon decoder.
24//! * After reconstruction, the recovered pack bytes are hashed with
25//! BLAKE3 and compared against [`ShardSet::pack_hash`]. This catches
26//! the (cryptographically unlikely) case where a coordinated attacker
27//! crafted shards that pass the Merkle check but reconstruct a
28//! different pack.
29//!
30//! # Feature gate
31//!
32//! This module is compiled only when `--features pack-shards` is set.
33//! The default `mkit-core` build does **not** pull in the
34//! `commonware-*` dep stack.
35//!
36//! # Defaults
37//!
38//! `Config { minimum_shards: 16, extra_shards: 4 }` — 20 total shards,
39//! 25% redundancy. Any 16 of 20 shards reconstruct the pack. Tuning
40//! lives in `docs/specs/SPEC-PACK-SHARDS.md` §6.
41
42use std::num::{NonZeroU16, NonZeroUsize};
43use std::sync::OnceLock;
44
45use commonware_codec::{Decode, Encode};
46use commonware_coding::{CodecConfig, Scheme as _};
47use commonware_cryptography::Blake3;
48use commonware_parallel::{Rayon, Sequential, Strategy};
49
50use crate::hash::{self, HASH_LEN, Hash};
51
52// Re-exports so callers don't need to depend on `commonware-coding` directly.
53pub use commonware_coding::Config;
54// Re-export so callers building an explicit strategy (e.g. via the
55// `_with_strategy` entry points below) don't need a direct
56// `commonware-parallel` dependency of their own.
57pub use commonware_parallel::{Rayon as ParallelStrategy, Sequential as SequentialStrategy};
58
59// Issue #653 evaluated swapping this to `ReedSolomon<Blake3>` to match
60// the hash primitive mkit uses elsewhere (`history.rs`) and drop a
61// redundant per-shard hash pass (the internal Merkle-tree build in
62// `commonware-coding::reed_solomon::{encode, decode}` hashes every
63// shard with `H::new()` — previously SHA-256 — completely separately
64// from this module's own BLAKE3 `shard_hashes` envelope check), but
65// deferred it: `H` determines `Commitment` (the BMT root stored in
66// `ShardSet::commitment`), and a hasher swap changes the wire-visible
67// commitment value, breaking interop between a producer and consumer
68// on different mkit versions unless the manifest format itself
69// versions that change.
70//
71// Issue #661 landed the cutover: `MANIFEST_VERSION` bumped `0x01` →
72// `0x02`, `RsScheme` now wraps `Blake3`, and `decode_manifest` rejects
73// a `0x01` manifest with a version-specific error instead of the
74// generic "unsupported version" message, since a `0x01` manifest's
75// commitment can never check out against a Blake3-based `RsScheme`.
76// This is a **hard cutover, not dual-hasher support** — a `0x01`
77// producer and a `0x02`+ consumer (or vice versa) simply cannot
78// interoperate; the old peer must re-shard with a current mkit. See
79// SPEC-PACK-SHARDS.md §4 and
80// https://github.com/officialunofficial/mkit/issues/661.
81type RsScheme = commonware_coding::ReedSolomon<Blake3>;
82type Commitment = <RsScheme as commonware_coding::Scheme>::Commitment;
83type RsChunk = <RsScheme as commonware_coding::Scheme>::Shard;
84
85/// Pack length at (or above) which [`encode_pack_to_shards`] and
86/// [`decode_pack_from_shards`] default to a parallel, `Rayon`-backed
87/// `commonware-parallel` strategy instead of [`Sequential`].
88///
89/// Below this size the encode/decode core still does real per-shard
90/// work (hashing each of `config.total_shards()` shards, and — on
91/// decode — re-hashing any reconstructed shards to rebuild the BMT
92/// consistency check), but a `rayon` thread pool's per-call dispatch
93/// overhead (partitioning + joining ~20 small closures) is not
94/// reliably smaller than just doing that work on the current thread.
95/// 4 MiB is comfortably above [`SHARD_SIZE_THRESHOLD`] (1 MiB, below
96/// which producers should not shard at all per SPEC-PACK-SHARDS §6),
97/// so any pack that actually gets sharded and clears this threshold
98/// has multi-hundred-KiB shards where the parallel win is real.
99pub const PARALLEL_STRATEGY_THRESHOLD: usize = 4 * 1024 * 1024;
100
101/// Returns `true` when a pack of `pack_len` bytes should default to
102/// the parallel strategy. Kept as its own (private) function — rather
103/// than inlined into the two call sites — so a unit test can pin the
104/// threshold decision as a plain value comparison, independent of
105/// whether a `Rayon` thread pool can actually be built in the test
106/// environment.
107fn should_use_parallel_strategy(pack_len: usize) -> bool {
108 pack_len >= PARALLEL_STRATEGY_THRESHOLD
109}
110
111/// Lazily builds a single process-wide `Rayon` strategy, reused by
112/// every encode/decode call that clears [`PARALLEL_STRATEGY_THRESHOLD`].
113///
114/// Building a `rayon::ThreadPool` spins up OS threads and initializes
115/// its work-stealing queues, so we pay that cost once per process
116/// rather than once per call. `Rayon` wraps an `Arc<ThreadPool>`, so
117/// the clone returned to each caller is cheap. Returns `None` if the
118/// pool could not be built (e.g. the OS refuses to spawn threads);
119/// callers fall back to [`Sequential`] in that case.
120fn shared_parallel_strategy() -> Option<Rayon> {
121 static POOL: OnceLock<Option<Rayon>> = OnceLock::new();
122 POOL.get_or_init(|| {
123 let threads = std::thread::available_parallelism().map_or(1, NonZeroUsize::get);
124 NonZeroUsize::new(threads).and_then(|n| Rayon::new(n).ok())
125 })
126 .clone()
127}
128
129/// Resolves the default strategy for a pack of `pack_len` bytes.
130/// `None` means "use [`Sequential`]" — either because `pack_len` is
131/// below [`PARALLEL_STRATEGY_THRESHOLD`], or because a parallel
132/// strategy could not be built.
133fn default_parallel_strategy_for_len(pack_len: usize) -> Option<Rayon> {
134 if should_use_parallel_strategy(pack_len) {
135 shared_parallel_strategy()
136 } else {
137 None
138 }
139}
140
141/// Cap on the per-shard codec payload size accepted at decode time.
142/// 4 GiB matches the existing packfile size cap (see
143/// `crate::pack::MAX_TOTAL_PAYLOAD`); anything bigger could not have
144/// originated from a valid mkit pack.
145const MAX_SHARD_BYTES: usize = 4 * 1024 * 1024 * 1024;
146
147/// Size below which a producer SHOULD NOT shard a pack.
148///
149/// Per SPEC-PACK-SHARDS §6 the per-shard Merkle-proof overhead
150/// dominates for small packs, so producers serve them monolithically.
151/// 1 MiB is the v0 cutoff; the constant is exported so transports and
152/// CLI tooling agree on a single number.
153pub const SHARD_SIZE_THRESHOLD: u64 = 1024 * 1024;
154
155/// Wire-format magic for a serialised [`ShardSet`]. Spells "MKSH" —
156/// "mkit-shards" — and lets a parser refuse to treat random bytes as a
157/// manifest.
158pub const MANIFEST_MAGIC: [u8; 4] = *b"MKSH";
159
160/// Wire-format version for a serialised [`ShardSet`]. Bumped whenever
161/// the on-the-wire layout — or, as with the issue #661 `Sha256` →
162/// `Blake3` hasher cutover, the meaning of an existing field —
163/// changes in a non-backwards-compatible way.
164///
165/// `0x01` is retired (it identified the pre-#661 `ReedSolomon<Sha256>`
166/// scheme) and MUST NOT be reused for a different wire meaning: an old
167/// cached manifest or peer stuck on `0x01` must always be recognised
168/// and rejected with [`decode_manifest`]'s version-specific error,
169/// never silently misread under a new scheme.
170pub const MANIFEST_VERSION: u8 = 0x02;
171
172/// Total prologue size: magic (4) + version (1).
173const MANIFEST_PROLOGUE_LEN: usize = 5;
174
175/// Per SPEC-PACK-SHARDS §6, a manifest with the v0 default config is
176/// `~ 32 * (T + 2)` bytes plus the prologue and config. We cap at
177/// 1 MiB so a hostile peer can not stream gigabytes through the
178/// deserialiser.
179pub const MANIFEST_MAX_BYTES: usize = 1024 * 1024;
180
181/// Default config: `(minimum_shards = 16, extra_shards = 4)`.
182///
183/// 20 total shards, any 16 of which reconstruct. See SPEC-PACK-SHARDS §6
184/// for the rationale and when callers may want to tune these.
185///
186/// # Panics
187///
188/// Infallible — both `16` and `4` are nonzero. The `expect` calls
189/// document intent; they cannot fire.
190#[must_use]
191pub fn default_config() -> Config {
192 Config {
193 minimum_shards: NonZeroU16::new(16).expect("16 != 0"),
194 extra_shards: NonZeroU16::new(4).expect("4 != 0"),
195 }
196}
197
198/// A single shard of an erasure-coded pack.
199///
200/// `bytes` is the codec-serialised commonware `Chunk` (shard payload +
201/// index + Merkle proof). The receiver hashes these bytes with BLAKE3
202/// and matches them against [`ShardSet::shard_hashes`] before decoding.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct Shard {
205 /// Shard index in `[0, minimum_shards + extra_shards)`.
206 pub index: u16,
207 /// Codec-serialised commonware `Chunk` payload. Opaque at this
208 /// layer; the only operations performed against it are hashing and
209 /// decoding via the commonware codec.
210 pub bytes: Vec<u8>,
211}
212
213/// Manifest describing a set of shards encoding one pack.
214///
215/// In the wire protocol this is published alongside the shards under
216/// `/packs/<pack_hash>/shards.manifest` (see SPEC-PACK-SHARDS §2). A
217/// consumer fetches the manifest first, then fetches up to
218/// `config.total_shards()` shards in parallel, rejecting any whose
219/// BLAKE3 hash does not match.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ShardSet {
222 /// BLAKE3 of the original pack bytes. Verified after reconstruction
223 /// as the final defence against shard-set forgery.
224 pub pack_hash: Hash,
225 /// Reed-Solomon `(minimum_shards, extra_shards)` configuration used
226 /// to produce this shard set. The decoder MUST use the same
227 /// configuration.
228 pub config: Config,
229 /// BLAKE3 of each shard's `bytes`, indexed by shard index.
230 /// `shard_hashes.len()` MUST equal `config.total_shards()`.
231 pub shard_hashes: Vec<Hash>,
232 /// Commonware BMT root committing to all shards. Required by the
233 /// commonware decoder for per-shard Merkle-proof checks. Stored
234 /// here so the manifest is self-contained — a receiver does not
235 /// need a second round-trip to fetch the commitment.
236 pub commitment: Hash,
237}
238
239/// Errors produced by [`encode_pack_to_shards`] / [`decode_pack_from_shards`].
240#[derive(Debug, thiserror::Error)]
241pub enum ShardError {
242 /// The Reed-Solomon encoder rejected the input. Typically means
243 /// the pack is larger than `u32::MAX` bytes (commonware's limit).
244 #[error("reed-solomon encode failed: {0}")]
245 EncodeFailed(String),
246 /// The Reed-Solomon decoder rejected the supplied shards. Usually
247 /// triggered by too few shards, duplicate indices, or a Merkle
248 /// proof that no longer matches the commitment.
249 #[error("reed-solomon decode failed: {0}")]
250 DecodeFailed(String),
251 /// The codec layer could not parse a shard's `bytes`. Means the
252 /// shard envelope is malformed — distinct from a BLAKE3 mismatch.
253 #[error("shard codec decode failed at index {index}: {source}")]
254 ShardCodecFailed {
255 index: u16,
256 #[source]
257 source: commonware_codec::Error,
258 },
259 /// A shard's BLAKE3 hash does not match the manifest entry for its
260 /// index. The shard is corrupt or maliciously substituted.
261 #[error("shard {index} BLAKE3 mismatch (manifest tampered or shard corrupted)")]
262 ShardHashMismatch { index: u16 },
263 /// Manifest claims an index outside `0..total_shards`.
264 #[error("shard index {index} is out of range for config (total = {total})")]
265 IndexOutOfRange { index: u16, total: u32 },
266 /// Duplicate shard index supplied to the decoder.
267 #[error("duplicate shard index {index}")]
268 DuplicateIndex { index: u16 },
269 /// Manifest carries the wrong number of `shard_hashes` for the
270 /// declared config.
271 #[error(
272 "manifest has {actual} shard_hashes, expected {expected} \
273 (config.total_shards())"
274 )]
275 ManifestShardCountMismatch { actual: usize, expected: usize },
276 /// Reconstruction produced bytes whose BLAKE3 does not match
277 /// `manifest.pack_hash`. Cryptographically the manifest was forged.
278 #[error("reconstructed pack hash does not match manifest.pack_hash")]
279 PackHashMismatch,
280 /// Caller passed fewer than `config.minimum_shards` shards.
281 #[error("insufficient shards: {provided} < {minimum}")]
282 InsufficientShards { provided: usize, minimum: u16 },
283 /// The manifest wire bytes are shorter than the v0 prologue, do not
284 /// begin with [`MANIFEST_MAGIC`], or carry an unrecognised
285 /// [`MANIFEST_VERSION`].
286 #[error("invalid manifest prologue: {0}")]
287 InvalidManifestPrologue(&'static str),
288 /// The manifest wire bytes are truncated — a length-prefixed field
289 /// claims more bytes than remain in the buffer.
290 #[error("unexpected eof while decoding manifest")]
291 ManifestUnexpectedEof,
292 /// The manifest carries trailing bytes after the last expected
293 /// field. Most likely a producer / consumer version mismatch.
294 #[error("trailing bytes after manifest body")]
295 ManifestTrailingBytes,
296 /// The manifest declares a `(minimum_shards, extra_shards)` pair
297 /// whose components are zero — illegal at the SPEC level.
298 #[error("manifest declares zero shard count (min={minimum}, extra={extra})")]
299 ManifestZeroShardCount { minimum: u16, extra: u16 },
300 /// The manifest exceeds [`MANIFEST_MAX_BYTES`].
301 #[error("manifest is too large: {actual} > {max}")]
302 ManifestTooLarge { actual: usize, max: usize },
303}
304
305/// Encode a pack into shards.
306///
307/// Produces `config.minimum_shards + config.extra_shards` shards and a
308/// manifest committing to them. The pack itself is not modified.
309///
310/// # Errors
311///
312/// Returns [`ShardError::EncodeFailed`] if the underlying Reed-Solomon
313/// encoder rejects the input (e.g. the pack exceeds `u32::MAX` bytes,
314/// or `total_shards()` exceeds `u16::MAX`).
315///
316/// # Panics
317///
318/// Infallible — the only `expect` in the body asserts that commonware
319/// never emits more than `u16::MAX` shards, which it enforces in
320/// `ReedSolomon::encode` (`Error::TooManyTotalShards`).
321pub fn encode_pack_to_shards(
322 pack: &[u8],
323 config: Config,
324) -> Result<(Vec<Shard>, ShardSet), ShardError> {
325 match default_parallel_strategy_for_len(pack.len()) {
326 Some(strategy) => encode_pack_to_shards_with_strategy(pack, config, &strategy),
327 None => encode_pack_to_shards_with_strategy(pack, config, &Sequential),
328 }
329}
330
331/// Like [`encode_pack_to_shards`], but with an explicit
332/// `commonware-parallel` [`Strategy`] instead of the size-based
333/// default. Exists so callers (and tests / benches) can force a
334/// specific strategy — e.g. to compare `Sequential` against a
335/// `Rayon` pool of a given width — without going through the
336/// pack-length heuristic.
337///
338/// # Errors
339///
340/// Same as [`encode_pack_to_shards`].
341///
342/// # Panics
343///
344/// Infallible — same as [`encode_pack_to_shards`]; the only `expect`
345/// in the body asserts that commonware never emits more than
346/// `u16::MAX` shards, which it enforces in `ReedSolomon::encode`
347/// (`Error::TooManyTotalShards`).
348pub fn encode_pack_to_shards_with_strategy<S: Strategy>(
349 pack: &[u8],
350 config: Config,
351 strategy: &S,
352) -> Result<(Vec<Shard>, ShardSet), ShardError> {
353 let (commitment, chunks) = RsScheme::encode(&config, pack, strategy)
354 .map_err(|e| ShardError::EncodeFailed(format!("{e:?}")))?;
355
356 let total = config.total_shards() as usize;
357 debug_assert_eq!(chunks.len(), total);
358
359 // Per-shard codec-serialise + BLAKE3 hash. Each iteration only
360 // touches its own chunk and produces its own output triple, so —
361 // unlike the RS math above, which commonware parallelises
362 // internally — this loop is ours to parallelise. We reuse the
363 // same `strategy` so a caller who opts into a parallel strategy
364 // gets the benefit here too, not just inside `RsScheme::encode`.
365 // `map_collect_vec` preserves input order for every `Strategy`
366 // impl (see commonware-parallel docs), so `results[i]` still
367 // corresponds to shard index `i`.
368 let results: Vec<(u16, Vec<u8>, Hash)> =
369 strategy.map_collect_vec(chunks.into_iter().enumerate(), |(i, chunk)| {
370 // `i < total <= u16::MAX` by commonware's own bound
371 // (`Chunk::index: u16`), so the conversion is infallible.
372 let index = u16::try_from(i).expect("commonware emits <= u16::MAX shards");
373 let bytes = chunk.encode().to_vec();
374 let h = hash::hash(&bytes);
375 (index, bytes, h)
376 });
377
378 let mut shards = Vec::with_capacity(total);
379 let mut shard_hashes = Vec::with_capacity(total);
380 for (index, bytes, h) in results {
381 shards.push(Shard { index, bytes });
382 shard_hashes.push(h);
383 }
384
385 let manifest = ShardSet {
386 pack_hash: hash::hash(pack),
387 config,
388 shard_hashes,
389 commitment: digest_to_bytes(&commitment),
390 };
391
392 Ok((shards, manifest))
393}
394
395/// Decode a pack from a (possibly partial) set of shards.
396///
397/// The decoder:
398///
399/// 1. Verifies each shard's BLAKE3 against the manifest entry for its
400/// index. Mismatched shards are dropped before they reach the
401/// Reed-Solomon decoder.
402/// 2. Deserialises each surviving shard as a commonware `Chunk`.
403/// 3. Calls `ReedSolomon::check` on each chunk (Merkle-proof check
404/// against `manifest.commitment`).
405/// 4. Calls `ReedSolomon::decode` on the checked set.
406/// 5. Verifies the reconstructed pack's BLAKE3 against
407/// `manifest.pack_hash`.
408///
409/// # Errors
410///
411/// See [`ShardError`] for the full taxonomy. Any step's failure
412/// short-circuits.
413pub fn decode_pack_from_shards(
414 shards: &[Shard],
415 manifest: &ShardSet,
416) -> Result<Vec<u8>, ShardError> {
417 // The manifest doesn't carry the original pack length, so we use
418 // the total wire size of the supplied shards (envelope + proof
419 // overhead included) as a same-order-of-magnitude proxy for it.
420 // Good enough for a coarse "is this worth a thread pool" gate.
421 let size_hint: usize = shards.iter().map(|s| s.bytes.len()).sum();
422 match default_parallel_strategy_for_len(size_hint) {
423 Some(strategy) => decode_pack_from_shards_with_strategy(shards, manifest, &strategy),
424 None => decode_pack_from_shards_with_strategy(shards, manifest, &Sequential),
425 }
426}
427
428/// Like [`decode_pack_from_shards`], but with an explicit
429/// `commonware-parallel` [`Strategy`] instead of the size-based
430/// default. See [`encode_pack_to_shards_with_strategy`] for why this
431/// exists.
432///
433/// # Errors
434///
435/// Same as [`decode_pack_from_shards`].
436pub fn decode_pack_from_shards_with_strategy<S: Strategy>(
437 shards: &[Shard],
438 manifest: &ShardSet,
439 strategy: &S,
440) -> Result<Vec<u8>, ShardError> {
441 let total = manifest.config.total_shards();
442 if manifest.shard_hashes.len() != total as usize {
443 return Err(ShardError::ManifestShardCountMismatch {
444 actual: manifest.shard_hashes.len(),
445 expected: total as usize,
446 });
447 }
448
449 let minimum = manifest.config.minimum_shards.get();
450 let commitment = bytes_to_digest(&manifest.commitment);
451 let codec_cfg = CodecConfig {
452 maximum_shard_size: MAX_SHARD_BYTES,
453 };
454
455 let mut seen = vec![false; total as usize];
456 let mut checked = Vec::with_capacity(shards.len());
457
458 for shard in shards {
459 // (1) Range + duplicate index check.
460 if u32::from(shard.index) >= total {
461 return Err(ShardError::IndexOutOfRange {
462 index: shard.index,
463 total,
464 });
465 }
466 let slot = &mut seen[shard.index as usize];
467 if *slot {
468 return Err(ShardError::DuplicateIndex { index: shard.index });
469 }
470 *slot = true;
471
472 // (2) BLAKE3 tamper check against the manifest.
473 let expected = &manifest.shard_hashes[shard.index as usize];
474 if &hash::hash(&shard.bytes) != expected {
475 return Err(ShardError::ShardHashMismatch { index: shard.index });
476 }
477
478 // (3) Codec decode → commonware `Chunk`.
479 let chunk = RsChunk::decode_cfg(shard.bytes.as_slice(), &codec_cfg).map_err(|e| {
480 ShardError::ShardCodecFailed {
481 index: shard.index,
482 source: e,
483 }
484 })?;
485
486 // (4) Merkle-proof check against the commitment.
487 let checked_shard = RsScheme::check(&manifest.config, &commitment, shard.index, &chunk)
488 .map_err(|e| ShardError::DecodeFailed(format!("check({}): {e:?}", shard.index)))?;
489 checked.push(checked_shard);
490 }
491
492 if checked.len() < usize::from(minimum) {
493 return Err(ShardError::InsufficientShards {
494 provided: checked.len(),
495 minimum,
496 });
497 }
498
499 // (5) Reed-Solomon decode.
500 let pack = RsScheme::decode(&manifest.config, &commitment, checked.iter(), strategy)
501 .map_err(|e| ShardError::DecodeFailed(format!("{e:?}")))?;
502
503 // (6) Final BLAKE3 check.
504 if hash::hash(&pack) != manifest.pack_hash {
505 return Err(ShardError::PackHashMismatch);
506 }
507
508 Ok(pack)
509}
510
511/// Extract the raw 32 bytes from a `RsScheme` [`Commitment`] digest.
512///
513/// Deliberately written against `Commitment` — i.e.
514/// `<RsScheme as commonware_coding::Scheme>::Commitment` — rather than
515/// a concrete hasher's digest type, so a future `RsScheme` hasher swap
516/// (as #661 did to this function's previous `Sha256`-typed signature)
517/// only needs `Commitment` to keep satisfying the same two bounds:
518/// `AsRef<[u8]>` and a fixed size equal to [`HASH_LEN`].
519fn digest_to_bytes(d: &Commitment) -> [u8; HASH_LEN] {
520 // We avoid relying on a specific accessor name by going through
521 // `AsRef<[u8]>`, which every commonware digest type implements.
522 let slice: &[u8] = d.as_ref();
523 let mut out = [0u8; HASH_LEN];
524 out.copy_from_slice(slice);
525 out
526}
527
528/// Inverse of [`digest_to_bytes`]: reconstruct a `Commitment` digest
529/// from the 32 bytes stored in the manifest. See [`digest_to_bytes`]
530/// for why this is typed against `Commitment` rather than a concrete
531/// hasher's digest type.
532fn bytes_to_digest(b: &[u8; HASH_LEN]) -> Commitment {
533 // Every commonware digest type is a fixed-size `Array` exposing
534 // `From<[u8; N]>` but not `TryFrom<&[u8]>`. Copy through a fixed
535 // array to keep the bound surface narrow.
536 use commonware_codec::FixedSize;
537 debug_assert_eq!(<Commitment as FixedSize>::SIZE, HASH_LEN);
538 Commitment::from(*b)
539}
540
541// ---------------------------------------------------------------------
542// Manifest wire format (v0)
543// ---------------------------------------------------------------------
544//
545// Layout (all multi-byte integers are little-endian):
546//
547// offset size field
548// ------ ---- -----------------------------------------
549// 0 4 magic = b"MKSH"
550// 4 1 version = 0x02
551// 5 32 pack_hash
552// 37 2 config.minimum_shards
553// 39 2 config.extra_shards
554// 41 32 commitment
555// 73 4 shard_hashes_len (== minimum + extra)
556// 77 32*T shard_hashes
557//
558// Total size for the v0 default `(16, 4)` config:
559// 5 + 32 + 2 + 2 + 32 + 4 + 32*20 = 717 bytes.
560//
561// Rationale for adding a new format here rather than reusing
562// `mkit_core::serialize`:
563// * `serialize.rs` is hard-coded to the [`Object`] enum and its
564// `MAGIC = "MKT1"` / `SCHEMA_VERSION` prologue. Shoehorning a
565// non-`Object` payload into that path would require widening its
566// public API and re-encoding every golden vector.
567// * The shard manifest is a transport artifact, not an object on
568// disk. Keeping its wire format colocated with the rest of the
569// pack-shard module keeps transport-integration changes scoped to one file.
570
571/// Serialise a [`ShardSet`] into its v0 wire bytes.
572///
573/// The format is documented above and in SPEC-PACK-SHARDS §2. The
574/// caller takes ownership of the returned `Vec`.
575///
576/// # Errors
577///
578/// Returns [`ShardError::ManifestShardCountMismatch`] if
579/// `manifest.shard_hashes.len()` does not equal
580/// `manifest.config.total_shards()` — we refuse to encode a manifest
581/// whose vectors disagree with its config.
582///
583/// # Panics
584///
585/// Infallible: `config.total_shards()` is `u32` by commonware's own
586/// bound and the `expect` documents intent. It cannot fire.
587pub fn encode_manifest(manifest: &ShardSet) -> Result<Vec<u8>, ShardError> {
588 let total = manifest.config.total_shards() as usize;
589 if manifest.shard_hashes.len() != total {
590 return Err(ShardError::ManifestShardCountMismatch {
591 actual: manifest.shard_hashes.len(),
592 expected: total,
593 });
594 }
595
596 let body_len = MANIFEST_PROLOGUE_LEN + HASH_LEN + 2 + 2 + HASH_LEN + 4 + total * HASH_LEN;
597 let mut out = Vec::with_capacity(body_len);
598 out.extend_from_slice(&MANIFEST_MAGIC);
599 out.push(MANIFEST_VERSION);
600 out.extend_from_slice(&manifest.pack_hash);
601 out.extend_from_slice(&manifest.config.minimum_shards.get().to_le_bytes());
602 out.extend_from_slice(&manifest.config.extra_shards.get().to_le_bytes());
603 out.extend_from_slice(&manifest.commitment);
604 // Length-prefix the shard_hashes vector as u32 so the parser can
605 // bail before allocating attacker-controlled capacity.
606 out.extend_from_slice(
607 &u32::try_from(total)
608 .expect("total_shards fits in u32")
609 .to_le_bytes(),
610 );
611 for h in &manifest.shard_hashes {
612 out.extend_from_slice(h);
613 }
614 debug_assert_eq!(out.len(), body_len);
615 Ok(out)
616}
617
618/// Deserialise a [`ShardSet`] from its v0 wire bytes.
619///
620/// Validates the prologue, the length-prefixed shard-hashes vector,
621/// the per-config bounds, and rejects trailing bytes.
622///
623/// # Errors
624///
625/// * [`ShardError::ManifestTooLarge`] — input exceeds
626/// [`MANIFEST_MAX_BYTES`].
627/// * [`ShardError::InvalidManifestPrologue`] — magic / version
628/// mismatch or input shorter than the prologue.
629/// * [`ShardError::ManifestUnexpectedEof`] — any field claims more
630/// bytes than remain in the buffer.
631/// * [`ShardError::ManifestZeroShardCount`] — manifest declares
632/// `(0, _)` or `(_, 0)`.
633/// * [`ShardError::ManifestShardCountMismatch`] — declared
634/// `shard_hashes_len` does not equal `minimum + extra`.
635/// * [`ShardError::ManifestTrailingBytes`] — input has bytes after
636/// the last hash.
637pub fn decode_manifest(bytes: &[u8]) -> Result<ShardSet, ShardError> {
638 if bytes.len() > MANIFEST_MAX_BYTES {
639 return Err(ShardError::ManifestTooLarge {
640 actual: bytes.len(),
641 max: MANIFEST_MAX_BYTES,
642 });
643 }
644 if bytes.len() < MANIFEST_PROLOGUE_LEN {
645 return Err(ShardError::InvalidManifestPrologue(
646 "input shorter than prologue",
647 ));
648 }
649 if bytes[..4] != MANIFEST_MAGIC {
650 return Err(ShardError::InvalidManifestPrologue("bad magic"));
651 }
652 if bytes[4] != MANIFEST_VERSION {
653 // 0x01 is not just "some other unsupported version" — it's the
654 // specific, retired pre-#661 Sha256-era scheme. A commitment
655 // computed under that scheme can never check out against the
656 // current Blake3-based `RsScheme`, so give the caller a
657 // pointed, actionable message instead of the generic one.
658 if bytes[4] == 0x01 {
659 return Err(ShardError::InvalidManifestPrologue(
660 "manifest version 0x01 (Sha256-era) — re-shard with a current mkit",
661 ));
662 }
663 return Err(ShardError::InvalidManifestPrologue("unsupported version"));
664 }
665 let mut pos = MANIFEST_PROLOGUE_LEN;
666
667 // pack_hash
668 if bytes.len() - pos < HASH_LEN {
669 return Err(ShardError::ManifestUnexpectedEof);
670 }
671 let mut pack_hash = [0u8; HASH_LEN];
672 pack_hash.copy_from_slice(&bytes[pos..pos + HASH_LEN]);
673 pos += HASH_LEN;
674
675 // config
676 if bytes.len() - pos < 4 {
677 return Err(ShardError::ManifestUnexpectedEof);
678 }
679 let minimum = u16::from_le_bytes([bytes[pos], bytes[pos + 1]]);
680 let extra = u16::from_le_bytes([bytes[pos + 2], bytes[pos + 3]]);
681 pos += 4;
682 let minimum_nz =
683 NonZeroU16::new(minimum).ok_or(ShardError::ManifestZeroShardCount { minimum, extra })?;
684 let extra_nz =
685 NonZeroU16::new(extra).ok_or(ShardError::ManifestZeroShardCount { minimum, extra })?;
686 let config = Config {
687 minimum_shards: minimum_nz,
688 extra_shards: extra_nz,
689 };
690 let total = config.total_shards();
691
692 // commitment
693 if bytes.len() - pos < HASH_LEN {
694 return Err(ShardError::ManifestUnexpectedEof);
695 }
696 let mut commitment = [0u8; HASH_LEN];
697 commitment.copy_from_slice(&bytes[pos..pos + HASH_LEN]);
698 pos += HASH_LEN;
699
700 // shard_hashes_len
701 if bytes.len() - pos < 4 {
702 return Err(ShardError::ManifestUnexpectedEof);
703 }
704 let declared_len =
705 u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]]);
706 pos += 4;
707 if declared_len != total {
708 return Err(ShardError::ManifestShardCountMismatch {
709 actual: declared_len as usize,
710 expected: total as usize,
711 });
712 }
713 // Cheap upper bound — reject impossible counts before allocating.
714 if (declared_len as usize).saturating_mul(HASH_LEN) > bytes.len() - pos {
715 return Err(ShardError::ManifestUnexpectedEof);
716 }
717 let mut shard_hashes = Vec::with_capacity(declared_len as usize);
718 for _ in 0..declared_len {
719 let mut h = [0u8; HASH_LEN];
720 h.copy_from_slice(&bytes[pos..pos + HASH_LEN]);
721 pos += HASH_LEN;
722 shard_hashes.push(h);
723 }
724
725 if pos != bytes.len() {
726 return Err(ShardError::ManifestTrailingBytes);
727 }
728
729 Ok(ShardSet {
730 pack_hash,
731 config,
732 shard_hashes,
733 commitment,
734 })
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 /// A deterministic 1-MiB pack-like payload. Not a real packfile —
742 /// the shard layer treats its input as opaque bytes, so any byte
743 /// stream with enough entropy exercises the encoder.
744 fn synthetic_pack(bytes: usize) -> Vec<u8> {
745 // Xorshift-style PRNG seeded with a fixed constant so the
746 // tests are reproducible.
747 let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
748 let mut out = Vec::with_capacity(bytes);
749 while out.len() < bytes {
750 x ^= x << 13;
751 x ^= x >> 7;
752 x ^= x << 17;
753 out.extend_from_slice(&x.to_le_bytes());
754 }
755 out.truncate(bytes);
756 out
757 }
758
759 // ---- Strategy is a runtime parameter, not a hardcoded const ----
760 //
761 // Issue #653: `pack_shard.rs` used to pin
762 // `const STRATEGY: Sequential = Sequential;` and pass `&STRATEGY`
763 // into every `RsScheme::encode` / `RsScheme::decode` call — no
764 // caller, test, or config could ever supply a different
765 // `commonware_parallel::Strategy` impl. The tests below prove two
766 // separate things:
767 //
768 // 1. `encode_pack_to_shards_with_strategy` /
769 // `decode_pack_from_shards_with_strategy` are generic over
770 // `S: Strategy` — a caller-supplied strategy compiles at all,
771 // which a hardcoded const could never allow.
772 // 2. The supplied strategy is actually *invoked* by the encode /
773 // decode core (via a spy that counts calls into
774 // `Strategy::fold_init`), not merely accepted and discarded.
775
776 /// A `Strategy` that counts how many times `fold_init` is invoked
777 /// and otherwise behaves exactly like [`Sequential`]. Lets a test
778 /// assert the supplied strategy was genuinely exercised by the
779 /// encode/decode core, rather than silently ignored in favour of
780 /// some other, hidden strategy.
781 #[derive(Clone, Debug)]
782 struct CountingStrategy {
783 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
784 }
785
786 impl CountingStrategy {
787 fn new() -> Self {
788 Self {
789 calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
790 }
791 }
792
793 fn calls(&self) -> usize {
794 self.calls.load(std::sync::atomic::Ordering::SeqCst)
795 }
796 }
797
798 impl Strategy for CountingStrategy {
799 fn manual(&self) -> commonware_parallel::Manual<Self> {
800 commonware_parallel::Manual::new(self.clone(), std::num::NonZeroUsize::new(1).unwrap())
801 }
802
803 fn spawn<F, T>(&self, f: F) -> impl core::future::Future<Output = T> + Send + 'static
804 where
805 F: FnOnce(Self) -> T + Send + 'static,
806 T: Send + 'static,
807 {
808 let result = f(self.clone());
809 async move { result }
810 }
811
812 fn run<R, SEQ, PAR>(&self, _len: usize, serial: SEQ, _parallel: PAR) -> R
813 where
814 R: Send,
815 SEQ: FnOnce() -> R + Send,
816 PAR: FnOnce() -> R + Send,
817 {
818 serial()
819 }
820
821 fn try_run<R, E, SEQ, PAR>(&self, _len: usize, serial: SEQ, _parallel: PAR) -> Result<R, E>
822 where
823 R: Send,
824 E: Send,
825 SEQ: FnOnce() -> Result<R, E> + Send,
826 PAR: FnOnce() -> Result<R, E> + Send,
827 {
828 serial()
829 }
830
831 fn fold_init<I, INIT, T, R, ID, F, RD>(
832 &self,
833 iter: I,
834 init: INIT,
835 identity: ID,
836 fold_op: F,
837 reduce_op: RD,
838 ) -> R
839 where
840 I: IntoIterator<IntoIter: Send, Item: Send> + Send,
841 INIT: Fn() -> T + Send + Sync,
842 T: Send,
843 R: Send,
844 ID: Fn() -> R + Send + Sync,
845 F: Fn(R, &mut T, I::Item) -> R + Send + Sync,
846 RD: Fn(R, R) -> R + Send + Sync,
847 {
848 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
849 Sequential.fold_init(iter, init, identity, fold_op, reduce_op)
850 }
851
852 fn try_fold<I, R, E, ID, F, RD>(
853 &self,
854 iter: I,
855 identity: ID,
856 fold_op: F,
857 reduce_op: RD,
858 ) -> Result<R, E>
859 where
860 I: IntoIterator<IntoIter: Send, Item: Send> + Send,
861 R: Send,
862 E: Send,
863 ID: Fn() -> R + Send + Sync,
864 F: Fn(R, I::Item) -> Result<R, E> + Send + Sync,
865 RD: Fn(R, R) -> R + Send + Sync,
866 {
867 Sequential.try_fold(iter, identity, fold_op, reduce_op)
868 }
869
870 fn join<A, B, RA, RB>(&self, a: A, b: B) -> (RA, RB)
871 where
872 A: FnOnce() -> RA + Send,
873 B: FnOnce() -> RB + Send,
874 RA: Send,
875 RB: Send,
876 {
877 Sequential.join(a, b)
878 }
879
880 fn sort_by<T, C>(&self, items: &mut [T], compare: C)
881 where
882 T: Send,
883 C: Fn(&T, &T) -> std::cmp::Ordering + Send + Sync,
884 {
885 Sequential.sort_by(items, compare);
886 }
887 }
888
889 #[test]
890 fn explicit_strategy_is_actually_exercised_by_encode_and_decode() {
891 let pack = synthetic_pack(64 * 1024);
892 let config = default_config();
893 let spy = CountingStrategy::new();
894
895 let (shards, manifest) = encode_pack_to_shards_with_strategy(&pack, config, &spy).unwrap();
896 let calls_after_encode = spy.calls();
897 assert!(
898 calls_after_encode > 0,
899 "encode_pack_to_shards_with_strategy never invoked the supplied strategy"
900 );
901
902 let subset: Vec<Shard> = shards.into_iter().take(16).collect();
903 let recovered = decode_pack_from_shards_with_strategy(&subset, &manifest, &spy).unwrap();
904 assert_eq!(recovered, pack);
905 assert!(
906 spy.calls() > calls_after_encode,
907 "decode_pack_from_shards_with_strategy never invoked the supplied strategy"
908 );
909 }
910
911 #[test]
912 fn round_trip_with_explicit_parallel_strategy() {
913 // A pack well under `PARALLEL_STRATEGY_THRESHOLD` so this
914 // stays a fast unit test, but still multi-shard: exercises a
915 // genuine `Rayon` pool (not the spy above) end-to-end through
916 // both the RS math and the per-shard hash loop.
917 let pack = synthetic_pack(256 * 1024);
918 let config = default_config();
919 let strategy = Rayon::new(NonZeroUsize::new(2).unwrap()).expect("build rayon pool");
920
921 let (shards, manifest) =
922 encode_pack_to_shards_with_strategy(&pack, config, &strategy).unwrap();
923 let subset: Vec<Shard> = shards.into_iter().take(16).collect();
924 let recovered =
925 decode_pack_from_shards_with_strategy(&subset, &manifest, &strategy).unwrap();
926 assert_eq!(recovered, pack);
927 }
928
929 #[test]
930 fn default_strategy_selection_is_a_runtime_threshold_not_a_const() {
931 assert!(!should_use_parallel_strategy(0));
932 assert!(!should_use_parallel_strategy(
933 PARALLEL_STRATEGY_THRESHOLD - 1
934 ));
935 assert!(should_use_parallel_strategy(PARALLEL_STRATEGY_THRESHOLD));
936 assert!(should_use_parallel_strategy(
937 PARALLEL_STRATEGY_THRESHOLD + 1
938 ));
939 }
940
941 #[test]
942 fn default_encode_decode_round_trip_at_parallel_threshold() {
943 // Exercises the size-based default (`encode_pack_to_shards` /
944 // `decode_pack_from_shards`, no explicit strategy) at exactly
945 // the threshold, so the parallel branch in
946 // `default_parallel_strategy_for_len` actually runs.
947 let pack = synthetic_pack(PARALLEL_STRATEGY_THRESHOLD);
948 let config = default_config();
949 let (shards, manifest) = encode_pack_to_shards(&pack, config).unwrap();
950 let subset: Vec<Shard> = shards.into_iter().take(16).collect();
951 let recovered = decode_pack_from_shards(&subset, &manifest).unwrap();
952 assert_eq!(recovered, pack);
953 }
954
955 #[test]
956 fn round_trip_default_config_1_mib_first_n_shards() {
957 let pack = synthetic_pack(1024 * 1024);
958 let config = default_config();
959 let (shards, manifest) = encode_pack_to_shards(&pack, config).unwrap();
960
961 assert_eq!(shards.len(), 20);
962 assert_eq!(manifest.shard_hashes.len(), 20);
963 assert_eq!(manifest.pack_hash, hash::hash(&pack));
964
965 // Decode using shards 0..16 (the first `minimum_shards`).
966 let subset: Vec<Shard> = shards.into_iter().take(16).collect();
967 let recovered = decode_pack_from_shards(&subset, &manifest).unwrap();
968 assert_eq!(recovered, pack);
969 }
970
971 #[test]
972 fn lossy_round_trip_drops_shards_0_5_10_17() {
973 let pack = synthetic_pack(1024 * 1024);
974 let config = default_config();
975 let (shards, manifest) = encode_pack_to_shards(&pack, config).unwrap();
976
977 let dropped = [0u16, 5, 10, 17];
978 let subset: Vec<Shard> = shards
979 .into_iter()
980 .filter(|s| !dropped.contains(&s.index))
981 .collect();
982
983 // Should be exactly 16 = minimum_shards remaining.
984 assert_eq!(subset.len(), 16);
985
986 let recovered = decode_pack_from_shards(&subset, &manifest).unwrap();
987 assert_eq!(recovered, pack);
988 }
989
990 #[test]
991 fn tampered_shard_is_rejected_before_decode() {
992 let pack = synthetic_pack(256 * 1024);
993 let config = default_config();
994 let (mut shards, manifest) = encode_pack_to_shards(&pack, config).unwrap();
995
996 // Flip a bit deep inside shard 0's bytes. The manifest entry
997 // for shard 0 still reflects the *original* BLAKE3 (we did
998 // not update it), so the tamper detection MUST fire.
999 let last = shards[0].bytes.len() - 1;
1000 shards[0].bytes[last] ^= 0x01;
1001
1002 let subset: Vec<Shard> = shards.into_iter().take(16).collect();
1003 let err = decode_pack_from_shards(&subset, &manifest).unwrap_err();
1004 assert!(
1005 matches!(err, ShardError::ShardHashMismatch { index: 0 }),
1006 "expected ShardHashMismatch{{index: 0}}, got {err:?}"
1007 );
1008 }
1009
1010 #[test]
1011 fn index_out_of_range_is_rejected() {
1012 let pack = synthetic_pack(64 * 1024);
1013 let (_, manifest) = encode_pack_to_shards(&pack, default_config()).unwrap();
1014 let total = manifest.config.total_shards();
1015
1016 // A shard claiming an index at (or beyond) the manifest's total
1017 // shard count. Its bytes never need to be real — the range
1018 // check fires before anything is hashed or decoded.
1019 let bogus = Shard {
1020 index: u16::try_from(total).unwrap(),
1021 bytes: vec![0u8; 32],
1022 };
1023 let err = decode_pack_from_shards(&[bogus], &manifest).unwrap_err();
1024 assert!(
1025 matches!(
1026 err,
1027 ShardError::IndexOutOfRange { index, total: t } if index == u16::try_from(total).unwrap() && t == total
1028 ),
1029 "expected IndexOutOfRange, got {err:?}"
1030 );
1031 }
1032
1033 #[test]
1034 fn duplicate_index_is_rejected() {
1035 let pack = synthetic_pack(64 * 1024);
1036 let (shards, manifest) = encode_pack_to_shards(&pack, default_config()).unwrap();
1037
1038 // Two entries claiming the SAME index: the first is the real,
1039 // correctly-hashed shard 0; the second is garbage. The
1040 // duplicate-index check on the second entry must fire before
1041 // its (bogus) bytes are ever hashed or decoded.
1042 let real_shard_0 = shards[0].clone();
1043 let impostor = Shard {
1044 index: 0,
1045 bytes: vec![0xFFu8; real_shard_0.bytes.len()],
1046 };
1047 let err = decode_pack_from_shards(&[real_shard_0, impostor], &manifest).unwrap_err();
1048 assert!(
1049 matches!(err, ShardError::DuplicateIndex { index: 0 }),
1050 "expected DuplicateIndex{{index: 0}}, got {err:?}"
1051 );
1052 }
1053
1054 #[test]
1055 fn pack_hash_mismatch_on_forged_but_consistent_shard_set() {
1056 // A "forged-but-consistent" shard set: every per-shard hash,
1057 // the Merkle commitment, and the Reed-Solomon reconstruction
1058 // all check out — the manifest's final `pack_hash` is the only
1059 // thing that lies. This is the last line of defence (step 6)
1060 // after every other cross-check in `decode_pack_from_shards`
1061 // has already passed.
1062 let pack = synthetic_pack(256 * 1024);
1063 let (shards, mut manifest) = encode_pack_to_shards(&pack, default_config()).unwrap();
1064 manifest.pack_hash = hash::hash(b"not the real pack");
1065
1066 let subset: Vec<Shard> = shards.into_iter().take(16).collect();
1067 let err = decode_pack_from_shards(&subset, &manifest).unwrap_err();
1068 assert!(
1069 matches!(err, ShardError::PackHashMismatch),
1070 "expected PackHashMismatch, got {err:?}"
1071 );
1072 }
1073
1074 // ---- Manifest wire-format tests --------------------------------
1075
1076 #[test]
1077 fn manifest_wire_format_round_trip_default_config() {
1078 let pack = synthetic_pack(64 * 1024);
1079 let (_, manifest) = encode_pack_to_shards(&pack, default_config()).unwrap();
1080
1081 let bytes = encode_manifest(&manifest).unwrap();
1082 // Pin the v0 size for the default (16, 4) config.
1083 // 5 (prologue) + 32 (pack_hash) + 4 (config) + 32 (commitment)
1084 // + 4 (len) + 32 * 20 (hashes) = 717.
1085 assert_eq!(bytes.len(), 717);
1086 assert_eq!(&bytes[..4], &MANIFEST_MAGIC);
1087 assert_eq!(bytes[4], MANIFEST_VERSION);
1088
1089 let decoded = decode_manifest(&bytes).unwrap();
1090 assert_eq!(decoded, manifest);
1091 }
1092
1093 #[test]
1094 fn manifest_decode_rejects_bad_magic() {
1095 let pack = synthetic_pack(32 * 1024);
1096 let (_, manifest) = encode_pack_to_shards(&pack, default_config()).unwrap();
1097 let mut bytes = encode_manifest(&manifest).unwrap();
1098 bytes[0] = b'X';
1099 let err = decode_manifest(&bytes).unwrap_err();
1100 assert!(
1101 matches!(err, ShardError::InvalidManifestPrologue("bad magic")),
1102 "expected InvalidManifestPrologue(bad magic), got {err:?}"
1103 );
1104 }
1105
1106 #[test]
1107 fn manifest_decode_rejects_unsupported_version() {
1108 let pack = synthetic_pack(32 * 1024);
1109 let (_, manifest) = encode_pack_to_shards(&pack, default_config()).unwrap();
1110 let mut bytes = encode_manifest(&manifest).unwrap();
1111 bytes[4] = 0xFF;
1112 let err = decode_manifest(&bytes).unwrap_err();
1113 assert!(
1114 matches!(
1115 err,
1116 ShardError::InvalidManifestPrologue("unsupported version")
1117 ),
1118 "expected InvalidManifestPrologue(unsupported version), got {err:?}"
1119 );
1120 }
1121
1122 #[test]
1123 fn manifest_decode_rejects_trailing_bytes() {
1124 let pack = synthetic_pack(32 * 1024);
1125 let (_, manifest) = encode_pack_to_shards(&pack, default_config()).unwrap();
1126 let mut bytes = encode_manifest(&manifest).unwrap();
1127 bytes.push(0xAB);
1128 let err = decode_manifest(&bytes).unwrap_err();
1129 assert!(
1130 matches!(err, ShardError::ManifestTrailingBytes),
1131 "expected ManifestTrailingBytes, got {err:?}"
1132 );
1133 }
1134
1135 #[test]
1136 fn manifest_decode_rejects_truncated_body() {
1137 let pack = synthetic_pack(32 * 1024);
1138 let (_, manifest) = encode_pack_to_shards(&pack, default_config()).unwrap();
1139 let mut bytes = encode_manifest(&manifest).unwrap();
1140 bytes.truncate(bytes.len() - 1);
1141 let err = decode_manifest(&bytes).unwrap_err();
1142 assert!(
1143 matches!(err, ShardError::ManifestUnexpectedEof),
1144 "expected ManifestUnexpectedEof, got {err:?}"
1145 );
1146 }
1147
1148 #[test]
1149 fn manifest_decode_rejects_oversize_input() {
1150 // Construct a buffer that *claims* to be a valid manifest by
1151 // shape but exceeds the cap. We don't need a real manifest;
1152 // the size check fires before prologue parsing.
1153 let bytes = vec![0u8; MANIFEST_MAX_BYTES + 1];
1154 let err = decode_manifest(&bytes).unwrap_err();
1155 assert!(
1156 matches!(err, ShardError::ManifestTooLarge { .. }),
1157 "expected ManifestTooLarge, got {err:?}"
1158 );
1159 }
1160
1161 #[test]
1162 fn manifest_decode_rejects_zero_config() {
1163 // Hand-craft a manifest with minimum_shards = 0.
1164 let mut bytes = Vec::new();
1165 bytes.extend_from_slice(&MANIFEST_MAGIC);
1166 bytes.push(MANIFEST_VERSION);
1167 bytes.extend_from_slice(&[0u8; HASH_LEN]); // pack_hash
1168 bytes.extend_from_slice(&0u16.to_le_bytes()); // minimum_shards = 0
1169 bytes.extend_from_slice(&4u16.to_le_bytes()); // extra_shards
1170 bytes.extend_from_slice(&[0u8; HASH_LEN]); // commitment
1171 bytes.extend_from_slice(&0u32.to_le_bytes()); // shard_hashes_len
1172 let err = decode_manifest(&bytes).unwrap_err();
1173 assert!(
1174 matches!(err, ShardError::ManifestZeroShardCount { .. }),
1175 "expected ManifestZeroShardCount, got {err:?}"
1176 );
1177 }
1178
1179 #[test]
1180 fn insufficient_shards_returns_error() {
1181 let pack = synthetic_pack(64 * 1024);
1182 let config = default_config();
1183 let (shards, manifest) = encode_pack_to_shards(&pack, config).unwrap();
1184
1185 // Only 15 of the 16 required shards.
1186 let subset: Vec<Shard> = shards.into_iter().take(15).collect();
1187 let err = decode_pack_from_shards(&subset, &manifest).unwrap_err();
1188 assert!(
1189 matches!(
1190 err,
1191 ShardError::InsufficientShards {
1192 provided: 15,
1193 minimum: 16,
1194 }
1195 ),
1196 "expected InsufficientShards{{15, 16}}, got {err:?}"
1197 );
1198 }
1199
1200 // ---- Issue #661: hard cutover from ReedSolomon<Sha256> to
1201 // ReedSolomon<Blake3> --------------------------------------------
1202
1203 #[test]
1204 fn manifest_version_is_0x02_and_v01_is_rejected() {
1205 assert_eq!(
1206 MANIFEST_VERSION, 0x02,
1207 "MANIFEST_VERSION must be bumped to 0x02 for the Blake3 cutover"
1208 );
1209
1210 // A validly-shaped manifest, but with the prologue version byte
1211 // forced back to the retired 0x01 (Sha256-era) value — as if a
1212 // pre-#661 producer (or a stale cache) handed it to a current
1213 // decoder.
1214 let pack = synthetic_pack(32 * 1024);
1215 let (_, manifest) = encode_pack_to_shards(&pack, default_config()).unwrap();
1216 let mut bytes = encode_manifest(&manifest).unwrap();
1217 assert_eq!(
1218 bytes[4], 0x02,
1219 "encode_manifest must emit the current MANIFEST_VERSION"
1220 );
1221 bytes[4] = 0x01;
1222
1223 let err = decode_manifest(&bytes).unwrap_err();
1224 match err {
1225 ShardError::InvalidManifestPrologue(msg) => {
1226 assert!(
1227 msg.contains("0x01"),
1228 "expected the version-specific message to name 0x01, got {msg:?}"
1229 );
1230 assert!(
1231 msg.to_ascii_lowercase().contains("sha256"),
1232 "expected the version-specific message to call out the \
1233 retired Sha256-era scheme, got {msg:?}"
1234 );
1235 }
1236 other => panic!("expected InvalidManifestPrologue, got {other:?}"),
1237 }
1238 }
1239
1240 #[test]
1241 fn blake3_scheme_roundtrips() {
1242 // Same shape as `lossy_round_trip_drops_shards_0_5_10_17`, but
1243 // named to pin down that the post-#661 `RsScheme =
1244 // ReedSolomon<Blake3>` swap round-trips correctly end to end:
1245 // encode, drop `extra_shards` (4) shards, decode from the
1246 // remaining `minimum_shards` (16), and confirm the reconstructed
1247 // pack's BLAKE3 matches `manifest.pack_hash`.
1248 let pack = synthetic_pack(1024 * 1024);
1249 let config = default_config();
1250 let (shards, manifest) = encode_pack_to_shards(&pack, config).unwrap();
1251 assert_eq!(shards.len(), 20);
1252
1253 let dropped = [1u16, 6, 11, 18];
1254 let subset: Vec<Shard> = shards
1255 .into_iter()
1256 .filter(|s| !dropped.contains(&s.index))
1257 .collect();
1258 assert_eq!(subset.len(), 16);
1259
1260 let recovered = decode_pack_from_shards(&subset, &manifest).unwrap();
1261 assert_eq!(recovered, pack);
1262 assert_eq!(hash::hash(&recovered), manifest.pack_hash);
1263 }
1264
1265 #[test]
1266 fn commitment_from_a_different_scheme_fails_the_merkle_check_not_silently() {
1267 // Emulates a producer stuck on the pre-#661 Sha256-era scheme
1268 // handing a manifest to a consumer decoding with the
1269 // post-cutover Blake3 `RsScheme`. The manifest's version byte
1270 // could read 0x02 (e.g. corrupted or forged) without the
1271 // commitment actually being a Blake3 BMT root — the real
1272 // defense is `RsScheme::check` at the Merkle-proof step, which
1273 // must fail loudly rather than let `decode` silently reconstruct
1274 // (or fail to reconstruct) without a typed error.
1275 use commonware_cryptography::Sha256;
1276 type OldRsScheme = commonware_coding::ReedSolomon<Sha256>;
1277
1278 let pack = synthetic_pack(256 * 1024);
1279 let config = default_config();
1280 let (shards, mut manifest) = encode_pack_to_shards(&pack, config).unwrap();
1281
1282 // Compute what the commitment would have been under the retired
1283 // Sha256-era scheme, for the exact same pack + config.
1284 let (old_commitment, _old_chunks) =
1285 OldRsScheme::encode(&config, pack.as_slice(), &Sequential)
1286 .expect("old-scheme (Sha256) encode must still succeed");
1287 let old_bytes: &[u8] = old_commitment.as_ref();
1288 let mut forged = [0u8; HASH_LEN];
1289 forged.copy_from_slice(old_bytes);
1290 manifest.commitment = forged;
1291
1292 let subset: Vec<Shard> = shards.into_iter().take(16).collect();
1293 let err = decode_pack_from_shards(&subset, &manifest).unwrap_err();
1294 assert!(
1295 matches!(err, ShardError::DecodeFailed(_)),
1296 "expected a typed DecodeFailed error at the Merkle-proof check \
1297 step, got {err:?}"
1298 );
1299 }
1300}