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