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