Skip to main content

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}