From d4fe1fa014f21bfe8b0c20401573c3ef76e4c84c Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:22:33 +0000 Subject: [PATCH] feat: in-memory slice repair builder (Repair) and public BlockMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `Repair`, an I/O-free builder over the existing D×D approach: load recovery rows, `feed`/`feed_batch` present slices (short last slices are zero-padded without copying), then `solve` for the missing ones. Errors cover too few recovery rows (InsufficientRecovery), singular matrices, out-of-range/too-long/duplicate slices, and slices neither fed nor missing. Peak memory is rows × slice_size plus the solved output; `truncate_rows` drops rows solve will not use. The file-based repair functions now read intact slices and feed them to `Repair`, so there is one fold. `BlockMap`, `BlockFile`, `SliceLocation` and `build_block_map` are public, with `global_to_file` returning an Option. New `slice` module: `SliceHasher` (IFSC MD5 + CRC32, zero-padded) and `Par2File::slice_matches` for verifying slices before feeding. Tests use a new par2cmdline 1.0.0 fixture (3 files, 12 slices, 8 recovery blocks): 1..=8 missing slices, too many missing, file-based vs builder equivalence, a random-layout property test, and corrupt-feed behaviour. WI-153 Co-Authored-By: Claude Opus 5.5 --- README.md | 51 ++ src/lib.rs | 5 +- src/recovery.rs | 2 +- src/repair.rs | 634 +++++++++++++++++++------ src/slice.rs | 133 ++++++ src/types.rs | 2 +- tests/fixtures/multi/alpha.bin | Bin 0 -> 24329 bytes tests/fixtures/multi/beta.bin | Bin 0 -> 9219 bytes tests/fixtures/multi/gamma.bin | Bin 0 -> 12288 bytes tests/fixtures/multi/multi.par2 | Bin 0 -> 1100 bytes tests/fixtures/multi/multi.vol0+8.par2 | Bin 0 -> 37400 bytes tests/slice_repair.rs | 452 ++++++++++++++++++ 12 files changed, 1120 insertions(+), 159 deletions(-) create mode 100644 src/slice.rs create mode 100644 tests/fixtures/multi/alpha.bin create mode 100644 tests/fixtures/multi/beta.bin create mode 100644 tests/fixtures/multi/gamma.bin create mode 100644 tests/fixtures/multi/multi.par2 create mode 100644 tests/fixtures/multi/multi.vol0+8.par2 create mode 100644 tests/slice_repair.rs diff --git a/README.md b/README.md index 782300c..5d55ce2 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,52 @@ if !result.all_correct() && result.repair_possible { } ``` +### In-memory repair (no file I/O) + +`Repair` rebuilds missing slices from slices you already hold in memory, such +as article bodies from a Usenet download, plus recovery blocks. It reads and +writes no files. Feed each present slice once, in any order, then solve: + +```rust +use rust_par2::recovery::RecoveryBlock; +use rust_par2::{BlockMap, Repair}; +# fn demo(file_set: &rust_par2::Par2FileSet, recovery: Vec, +# present: Vec<(usize, Vec)>, mut missing: Vec) -> Result<(), rust_par2::RepairError> { +// Global slice indices run across files in the Main packet's file order. +let map = BlockMap::new(file_set); +let slice_size = file_set.slice_size as usize; + +let mut repair = Repair::new_owning(recovery, slice_size, map.total_blocks as usize)?; + +for (index, bytes) in &present { + // Verify before feeding: a corrupt slice cannot be taken back out. + let loc = map.global_to_file(*index).unwrap(); + let file = &file_set.files[&loc.file.file_id]; + if file.slice_matches(loc.local_index as usize, bytes, file_set.slice_size) { + repair.feed(*index, bytes)?; // the last slice of a file may be short + } else { + missing.push(*index); + } +} +repair.truncate_rows(missing.len()); // optional: free rows solve won't use + +// Every slice must be either fed or listed as missing. +for (index, slice) in repair.solve(&missing)? { + let loc = map.global_to_file(index).unwrap(); + // `slice` is zero-padded to slice_size; write only loc.len bytes at loc.offset. + let _ = (&loc.file.filename, loc.offset, &slice[..loc.len]); +} +# Ok(()) } +``` + +`solve` fails with `RepairError::InsufficientRecovery` when more slices are +missing than recovery rows are loaded. Peak memory is `rows × slice_size` for +the accumulators plus `missing × slice_size` for the output, plus whatever you +buffer before feeding. `feed_batch` takes several slices at once and uses the +same cache-tiled SIMD path as the file-based repair, which is built on `Repair`. +`SliceHasher` computes the IFSC MD5 + CRC32 pair incrementally if a slice +arrives in pieces. + ## Performance Benchmarked against [par2cmdline-turbo](https://github.com/animetosho/par2cmdline-turbo) @@ -221,6 +267,9 @@ than treating the entire file as lost. | `repair(file_set, dir)` | Verify + repair in one call | | `repair_from_verify(file_set, dir, verify_result)` | Repair with pre-computed verify | | `compute_hash_16k(path)` | MD5 of first 16 KiB (for file identification) | +| `Repair::new_owning(recovery, slice_size, total)` | In-memory repair builder: `feed`, `feed_batch`, `solve` | +| `BlockMap::new(file_set)` | Map global slice indices to (file, offset, length) | +| `SliceHasher::checksum(data, slice_size)` | IFSC MD5 + CRC32 of a slice (zero-padded) | ### Key types @@ -243,6 +292,8 @@ than treating the entire file as lost. | `RepairError::SingularMatrix` | Recovery blocks are linearly dependent | | `RepairError::NoDamage` | Nothing to repair (all files intact) | | `RepairError::VerifyFailed` | Post-repair verification failed | +| `RepairError::SliceOutOfRange` / `SliceTooLong` / `DuplicateSlice` / `IncompleteFeed` | Invalid input to the `Repair` builder | +| `RepairError::InvalidSliceSize` / `InvalidRecoveryBlock` | Bad `Repair` construction arguments | ## Dependencies diff --git a/src/lib.rs b/src/lib.rs index d960be9..b4e2fca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,13 +33,16 @@ pub mod matrix; mod packets; pub mod recovery; pub mod repair; +pub mod slice; pub mod types; mod verify; pub use packets::{ParseError, parse_par2_file as parse, parse_par2_reader}; pub use repair::{ - RepairError, RepairResult, repair, repair_from_verify, repair_from_verify_no_reverify, + BlockFile, BlockMap, Repair, RepairError, RepairResult, SliceLocation, build_block_map, repair, + repair_from_verify, repair_from_verify_no_reverify, }; +pub use slice::SliceHasher; pub use types::{ DamagedFile, Md5Hash, MissingFile, Par2File, Par2FileSet, SliceChecksum, VerifiedFile, VerifyResult, diff --git a/src/recovery.rs b/src/recovery.rs index ddef4ae..fb08b3c 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -14,7 +14,7 @@ use crate::packets::{HEADER_SIZE, MAGIC}; const TYPE_RECOVERY: &[u8; 16] = b"PAR 2.0\x00RecvSlic"; /// A recovery block extracted from a RecoverySlice packet. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct RecoveryBlock { /// The exponent (recovery block index) used in the Vandermonde matrix. pub exponent: u32, diff --git a/src/repair.rs b/src/repair.rs index 05e7f9a..a449c50 100644 --- a/src/repair.rs +++ b/src/repair.rs @@ -10,9 +10,44 @@ //! 5. Compute adjusted recovery: subtract intact-block contributions from recovery data //! 6. Apply the D×D inverse to produce repaired blocks //! 7. Write repaired blocks back to files +//! +//! Steps 3–6 are the [`Repair`] builder, which works on slices in memory and +//! does no I/O. The file-based functions ([`repair`], [`repair_from_verify`], +//! [`repair_from_verify_no_reverify`]) read intact slices from disk, feed them +//! to a [`Repair`], and write the solved slices back. +//! +//! # In-memory repair +//! +//! ``` +//! use rust_par2::recovery::RecoveryBlock; +//! use rust_par2::repair::Repair; +//! # fn demo(recovery: Vec, present: Vec<(usize, Vec)>) -> Result<(), rust_par2::RepairError> { +//! // Global slice indices run across files in the Main packet's file order; +//! // `BlockMap` maps them to (file, offset, length). +//! let (slice_size, total_slices) = (768_000, 1_000); +//! +//! let mut r = Repair::new_owning(recovery, slice_size, total_slices)?; +//! for (index, bytes) in &present { +//! // Check each slice against its IFSC checksum first: see `slice::SliceHasher`. +//! r.feed(*index, bytes)?; +//! } +//! let missing = [17, 18, 404]; +//! let rebuilt = r.solve(&missing)?; // index -> slice_size bytes (zero-padded) +//! # let _ = rebuilt; Ok(()) } +//! ``` +//! +//! # Memory model +//! +//! A [`Repair`] holds one accumulator per recovery row: peak memory is +//! `rows × slice_size`, plus `D × slice_size` for the solved output, plus +//! whatever the caller buffers before feeding. Feeding allocates nothing for +//! [`Repair::feed`]; [`Repair::feed_batch`] allocates one small coefficient +//! vector per row per call. To bound memory, load or keep only as many recovery +//! rows as there are missing slices ([`Repair::truncate_rows`] drops the rest at +//! any time — each row is an independent equation). -use std::collections::HashMap; use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::Path; @@ -24,7 +59,7 @@ use crate::gf; use crate::gf_simd; use crate::matrix::{GfMatrix, par2_input_constants}; use crate::recovery::{RecoveryBlock, load_recovery_blocks}; -use crate::types::{Par2FileSet, VerifyResult}; +use crate::types::{Id16, Par2FileSet, VerifyResult}; use crate::verify; /// Result of a repair operation. @@ -67,8 +102,310 @@ pub enum RepairError { NoDamage, #[error("Verification after repair failed: {0}")] VerifyFailed(String), + #[error("Invalid slice size {0}: must be a non-zero multiple of 4")] + InvalidSliceSize(usize), + #[error("Recovery block {exponent} is {len} bytes, expected the slice size {slice_size}")] + InvalidRecoveryBlock { + exponent: u32, + len: usize, + slice_size: usize, + }, + #[error("Slice index {index} out of range: the set has {total} input slices")] + SliceOutOfRange { index: usize, total: usize }, + #[error("Slice {index} is {len} bytes, longer than the slice size {slice_size}")] + SliceTooLong { + index: usize, + len: usize, + slice_size: usize, + }, + #[error("Slice {index} was already fed (feeding twice cancels it out)")] + DuplicateSlice { index: usize }, + #[error("{unaccounted} input slices were neither fed nor listed as missing")] + IncompleteFeed { unaccounted: usize }, +} + +// --------------------------------------------------------------------------- +// In-memory builder +// --------------------------------------------------------------------------- + +/// Incremental, I/O-free PAR2 repair over slices held in memory. +/// +/// Load recovery rows, [`feed`](Self::feed) every present input slice once, +/// then [`solve`](Self::solve) for the missing ones. Each feed XOR-accumulates +/// `c_i^exp_e × D_i` into every recovery row `e`, so the slices can arrive in +/// any order and need not be kept after they are fed. +/// +/// A fed slice cannot be removed. Feeding a corrupt slice makes every solved +/// slice wrong, so verify slices first (see +/// [`SliceHasher`](crate::slice::SliceHasher) and +/// [`Par2File::slice_matches`](crate::Par2File::slice_matches)). +/// +/// Slice indices are global: they run across the set's files in the Main +/// packet's file order. [`BlockMap`] converts between global indices and file +/// positions. +#[derive(Debug, Clone)] +pub struct Repair { + slice_size: usize, + /// PAR2 input constant for every global slice index. + constants: Vec, + /// Recovery exponent of each row, ascending. + exponents: Vec, + /// Accumulators, one per row: `R_e ⊕ Σ_fed c_i^exp_e × D_i`. + rows: Vec>, + fed: Vec, + fed_count: usize, } +impl Repair { + /// Build from borrowed recovery blocks (their data is copied). + pub fn new( + recovery: &[RecoveryBlock], + slice_size: usize, + total_input_slices: usize, + ) -> Result { + Self::new_owning(recovery.to_vec(), slice_size, total_input_slices) + } + + /// Build from owned recovery blocks, reusing their buffers as the row + /// accumulators. + /// + /// Rows are sorted by exponent and duplicate exponents dropped. Every block + /// must be exactly `slice_size` bytes. + pub fn new_owning( + mut recovery: Vec, + slice_size: usize, + total_input_slices: usize, + ) -> Result { + if slice_size == 0 || slice_size % 4 != 0 { + return Err(RepairError::InvalidSliceSize(slice_size)); + } + if let Some(bad) = recovery.iter().find(|b| b.data.len() != slice_size) { + return Err(RepairError::InvalidRecoveryBlock { + exponent: bad.exponent, + len: bad.data.len(), + slice_size, + }); + } + recovery.sort_by_key(|b| b.exponent); + recovery.dedup_by_key(|b| b.exponent); + + let (exponents, rows) = recovery.into_iter().map(|b| (b.exponent, b.data)).unzip(); + Ok(Self { + slice_size, + constants: par2_input_constants(total_input_slices), + exponents, + rows, + fed: vec![false; total_input_slices], + fed_count: 0, + }) + } + + /// Number of recovery rows loaded: the most missing slices `solve` can + /// rebuild. + pub fn rows(&self) -> usize { + self.rows.len() + } + + /// Recovery exponents of the loaded rows, ascending. + pub fn exponents(&self) -> &[u32] { + &self.exponents + } + + /// Slice size in bytes. + pub fn slice_size(&self) -> usize { + self.slice_size + } + + /// Total number of input slices in the set. + pub fn total_slices(&self) -> usize { + self.fed.len() + } + + /// Number of slices fed so far. + pub fn fed_count(&self) -> usize { + self.fed_count + } + + /// Whether slice `index` has been fed. + pub fn is_fed(&self, index: usize) -> bool { + self.fed.get(index).copied().unwrap_or(false) + } + + /// Keep only the first `n` rows (lowest exponents), freeing the rest. + /// + /// Safe at any point: each row is an independent equation. `solve` only + /// uses as many rows as there are missing slices, so once that number is + /// known the others are dead weight. + pub fn truncate_rows(&mut self, n: usize) { + self.rows.truncate(n); + self.exponents.truncate(n); + } + + /// Feed one present input slice. + /// + /// `data` is the slice as stored: `slice_size` bytes, or fewer for the last + /// slice of a file, which is treated as zero-padded. + pub fn feed(&mut self, index: usize, data: &[u8]) -> Result<(), RepairError> { + self.check_feed(index, data)?; + let c = self.constants[index]; + self.rows + .par_iter_mut() + .zip(self.exponents.par_iter()) + .for_each(|(row, &exp)| mul_add_prefix(row, data, gf::pow(c, exp))); + self.fed[index] = true; + self.fed_count += 1; + Ok(()) + } + + /// Feed several present slices at once. + /// + /// Each row is updated against the whole batch while it is hot in cache, + /// using the pair-batched SIMD path, so batches of a few dozen slices are + /// much faster than the same slices fed one by one. The batch is validated + /// before anything is accumulated: on error, nothing from it was fed. + pub fn feed_batch(&mut self, slices: &[(usize, &[u8])]) -> Result<(), RepairError> { + for (k, &(index, data)) in slices.iter().enumerate() { + if let Err(e) = self.check_feed(index, data) { + for &(earlier, _) in &slices[..k] { + self.fed[earlier] = false; + } + return Err(e); + } + // Marking as we go also rejects duplicates within the batch. + self.fed[index] = true; + } + + let slice_size = self.slice_size; + let (full, short): (Vec<_>, Vec<_>) = slices + .iter() + .copied() + .partition(|(_, data)| data.len() == slice_size); + let full_data: Vec<&[u8]> = full.iter().map(|&(_, d)| d).collect(); + + let constants = &self.constants; + self.rows + .par_iter_mut() + .zip(self.exponents.par_iter()) + .for_each(|(row, &exp)| { + let coeffs: Vec = full + .iter() + .map(|&(i, _)| gf::pow(constants[i], exp)) + .collect(); + gf_simd::mul_add_multi(row, &full_data, &coeffs); + for &(i, data) in &short { + mul_add_prefix(row, data, gf::pow(constants[i], exp)); + } + }); + + self.fed_count += slices.len(); + Ok(()) + } + + /// Rebuild the `missing` slices. + /// + /// Every slice must be either fed or listed in `missing`. Returns each + /// missing index mapped to its full `slice_size` bytes; the last slice of a + /// file comes back zero-padded, and the caller writes only its real length + /// (see [`BlockMap::global_to_file`]). + /// + /// Uses the first `missing.len()` rows. Errors with + /// [`RepairError::InsufficientRecovery`] if there are fewer rows than + /// missing slices, and [`RepairError::SingularMatrix`] if the chosen rows + /// are linearly dependent. + pub fn solve(&self, missing: &[usize]) -> Result>, RepairError> { + let mut missing = missing.to_vec(); + missing.sort_unstable(); + missing.dedup(); + let outputs = self.reconstruct(&missing)?; + Ok(missing.into_iter().zip(outputs).collect()) + } + + fn check_feed(&self, index: usize, data: &[u8]) -> Result<(), RepairError> { + let total = self.fed.len(); + if index >= total { + return Err(RepairError::SliceOutOfRange { index, total }); + } + if data.len() > self.slice_size { + return Err(RepairError::SliceTooLong { + index, + len: data.len(), + slice_size: self.slice_size, + }); + } + if self.fed[index] { + return Err(RepairError::DuplicateSlice { index }); + } + Ok(()) + } + + /// Solve for sorted, deduplicated `missing`; outputs are in the same order. + fn reconstruct(&self, missing: &[usize]) -> Result>, RepairError> { + let total = self.fed.len(); + for &index in missing { + if index >= total { + return Err(RepairError::SliceOutOfRange { index, total }); + } + if self.fed[index] { + return Err(RepairError::DuplicateSlice { index }); + } + } + let unaccounted = total - self.fed_count - missing.len(); + if unaccounted != 0 { + return Err(RepairError::IncompleteFeed { unaccounted }); + } + + let d = missing.len(); + if d == 0 { + return Ok(Vec::new()); + } + if d > self.rows.len() { + return Err(RepairError::InsufficientRecovery { + needed: d as u32, + available: self.rows.len() as u32, + }); + } + + // Rearranging the encoding equation R_e = Σ_i c_i^exp_e × D_i for the + // missing slices only: + // R_e ⊕ Σ_{fed i} c_i^exp_e × D_i = Σ_{missing j} c_j^exp_e × D_j + // The left side is what the rows hold. Invert V[e][j] = c_j^exp_e. + let mut vandermonde = GfMatrix::zeros(d, d); + for (e, &exp) in self.exponents[..d].iter().enumerate() { + for (j, &idx) in missing.iter().enumerate() { + vandermonde.set(e, j, gf::pow(self.constants[idx], exp)); + } + } + let inverse = vandermonde.invert().ok_or(RepairError::SingularMatrix)?; + debug!(d, "D×D decode matrix inverted"); + + // repaired[j] = Σ_e V⁻¹[j][e] × row[e] + let row_refs: Vec<&[u8]> = self.rows[..d].iter().map(Vec::as_slice).collect(); + let mut outputs: Vec> = (0..d).map(|_| vec![0u8; self.slice_size]).collect(); + outputs.par_iter_mut().enumerate().for_each(|(j, dst)| { + let coeffs: Vec = (0..d).map(|e| inverse.get(j, e)).collect(); + gf_simd::mul_add_multi(dst, &row_refs, &coeffs); + }); + Ok(outputs) + } +} + +/// `dst ^= c × src` over `src.len()` bytes, `src` treated as zero-padded to +/// `dst.len()`. Padding contributes nothing, so only the prefix is touched; an +/// odd trailing byte is the low half of a GF(2^16) word whose high byte is 0. +fn mul_add_prefix(dst: &mut [u8], src: &[u8], c: u16) { + let even = src.len() & !1; + gf_simd::mul_add_buffer(&mut dst[..even], &src[..even], c); + if even < src.len() { + let p = gf::mul(c, src[even] as u16); + dst[even] ^= p as u8; + dst[even + 1] ^= (p >> 8) as u8; + } +} + +// --------------------------------------------------------------------------- +// File-based repair +// --------------------------------------------------------------------------- + /// Repair damaged/missing files in a PAR2 set. /// /// This is a blocking operation. For async contexts, wrap in `spawn_blocking`. @@ -139,76 +476,36 @@ fn repair_from_verify_inner( // Step 1: Load recovery blocks from all volume files let recovery_blocks = load_recovery_blocks(dir, &file_set.recovery_set_id, file_set.slice_size); - if (recovery_blocks.len() as u32) < blocks_needed { + // Step 2: Map files to global block indices + let block_map = build_block_map(file_set); + let total_input_blocks = block_map.total_blocks as usize; + let slice_size = file_set.slice_size as usize; + + let mut fold = Repair::new_owning(recovery_blocks, slice_size, total_input_blocks)?; + if (fold.rows() as u32) < blocks_needed { return Err(RepairError::InsufficientRecovery { needed: blocks_needed, - available: recovery_blocks.len() as u32, + available: fold.rows() as u32, }); } - // Step 2: Map files to global block indices - let block_map = build_block_map(file_set); - let total_input_blocks = block_map.total_blocks as usize; - let damaged_indices = find_damaged_block_indices(verify_result, &block_map); - let num_damaged = damaged_indices.len(); info!( - damaged_block_count = num_damaged, + damaged_block_count = damaged_indices.len(), total_input_blocks, "Mapped damaged blocks to global indices" ); - // Select exactly D recovery blocks (one per damaged block) - let recovery_to_use: Vec<&RecoveryBlock> = recovery_blocks.iter().take(num_damaged).collect(); - let recovery_exponents: Vec = recovery_to_use.iter().map(|b| b.exponent).collect(); + // Exactly D recovery rows are needed (one per damaged block). + fold.truncate_rows(damaged_indices.len()); - // Step 3: Build and invert the D×D Vandermonde submatrix. - // - // The full encoding equation for recovery block e is: - // R_e = Σ_i (c_i^exp_e × D_i) for all input blocks i + // Step 3: Fold intact blocks into the recovery rows. // - // Rearranging for the damaged blocks only: - // R_e - Σ_{intact i} (c_i^exp_e × D_i) = Σ_{damaged j} (c_j^exp_e × D_j) - // - // The D×D matrix V[e][j] = c_{damaged_j}^{exp_e} is what we invert. - let constants = par2_input_constants(total_input_blocks); - - let mut vandermonde = GfMatrix::zeros(num_damaged, num_damaged); - for (e, &exp) in recovery_exponents.iter().enumerate() { - for (j, &dmg_idx) in damaged_indices.iter().enumerate() { - vandermonde.set(e, j, gf::pow(constants[dmg_idx], exp)); - } - } - - let inverse = vandermonde.invert().ok_or(RepairError::SingularMatrix)?; - - info!( - "D×D decode matrix inverted ({}×{})", - num_damaged, num_damaged - ); - - // Step 4: Compute adjusted recovery buffers. - // - // adjusted[e] = R_e ⊕ Σ_{intact i} (c_i^exp_e × D_i) - // - // In GF(2^16), subtraction = addition = XOR, so we XOR-accumulate the - // intact-block contributions into the recovery data. - // - // Cache-tiled approach: the D output buffers (D × slice_size) typically - // exceed L3 cache. Iterating source-major (one source at a time against - // all D outputs) thrashes cache on every source. Instead we read batches - // of B source blocks, then process each output buffer against the entire - // batch — keeping the output buffer hot in L1/L2 while applying B sources - // via mul_add_multi's pair-batched AVX2 path. - - let slice_size = file_set.slice_size as usize; - - // Build a fast lookup for damaged indices - let damaged_set: std::collections::HashSet = damaged_indices.iter().copied().collect(); - - // Initialize adjusted recovery from the actual recovery block data - let mut adjusted: Vec> = recovery_to_use.iter().map(|rb| rb.data.clone()).collect(); - - // Collect intact block indices for batch reading + // Cache-tiled: the D row buffers (D × slice_size) typically exceed L3. + // Iterating source-major thrashes cache on every source. Instead we read + // batches of B source blocks and feed_batch processes each row against the + // whole batch, keeping the row hot in L1/L2 while applying B sources via + // mul_add_multi's pair-batched AVX2 path. + let damaged_set: HashSet = damaged_indices.iter().copied().collect(); let intact_indices: Vec = (0..total_input_blocks) .filter(|i| !damaged_set.contains(i)) .collect(); @@ -217,68 +514,48 @@ fn repair_from_verify_inner( // With 768KB slices, 24 blocks = ~18MB. Leaves room for output buffer in L1/L2. const BATCH_SIZE: usize = 24; - // Read and process source blocks in cache-friendly batches. // I/O is sequential so OS readahead keeps the pipeline full. let mut file_handles: HashMap = HashMap::new(); for batch in intact_indices.chunks(BATCH_SIZE) { - // Read this batch of source blocks let batch_data: Vec> = batch .iter() .map(|&idx| read_source_block(dir, &block_map, idx, slice_size, &mut file_handles)) .collect::>>()?; - - let batch_refs: Vec<&[u8]> = batch_data.iter().map(|v| v.as_slice()).collect(); - - // For each output buffer (parallelized across cores), apply all sources - // in this batch at once. The output buffer stays in L1/L2 for the whole - // batch, and mul_add_multi uses pair-batched AVX2 to halve dst bandwidth. - adjusted - .par_iter_mut() - .enumerate() - .for_each(|(e, adj_buf)| { - let coeffs: Vec = batch - .iter() - .map(|&src_idx| gf::pow(constants[src_idx], recovery_exponents[e])) - .collect(); - gf_simd::mul_add_multi(adj_buf, &batch_refs, &coeffs); - }); + let pairs: Vec<(usize, &[u8])> = batch + .iter() + .copied() + .zip(batch_data.iter().map(Vec::as_slice)) + .collect(); + fold.feed_batch(&pairs)?; } info!("Intact-block contributions subtracted from recovery data"); - // Step 5: Apply the D×D inverse to produce repaired blocks. - // - // repaired[j] = Σ_e (V^{-1}[j][e] × adjusted[e]) - // - // This is D × D mul-adds of slice_size — small relative to the streaming phase. - - let adj_refs: Vec<&[u8]> = adjusted.iter().map(|v| v.as_slice()).collect(); - - let mut outputs: Vec> = (0..num_damaged).map(|_| vec![0u8; slice_size]).collect(); - - outputs.par_iter_mut().enumerate().for_each(|(j, dst)| { - let coeffs: Vec = (0..num_damaged).map(|e| inverse.get(j, e)).collect(); - gf_simd::mul_add_multi(dst, &adj_refs, &coeffs); - }); + // Steps 4–5: invert the D×D matrix and apply it. + let mut repaired = fold.solve(&damaged_indices)?; + drop(fold); info!("Repaired blocks reconstructed via D×D inverse"); - let repaired_blocks: Vec<(usize, Vec)> = - damaged_indices.iter().copied().zip(outputs).collect(); - // Step 6: Write repaired blocks back to files - let mut files_touched = std::collections::HashSet::new(); - - for (global_idx, data) in &repaired_blocks { - let (filename, file_offset, write_len) = block_map.global_to_file(*global_idx, slice_size); - - let file_path = dir.join(&filename); + let mut files_touched = HashSet::new(); + + for global_idx in &damaged_indices { + let data = repaired + .remove(global_idx) + .expect("solve returns every missing index"); + let loc = block_map + .global_to_file(*global_idx) + .expect("damaged index comes from the block map"); + let filename = &loc.file.filename; + + let file_path = dir.join(filename); debug!( filename, global_block = global_idx, - offset = file_offset, - len = write_len, + offset = loc.offset, + len = loc.len, "Writing repaired block" ); @@ -289,34 +566,29 @@ fn repair_from_verify_inner( .open(&file_path)?; // Ensure file is the right size (for missing files) - let expected_size = block_map - .files - .iter() - .find(|bf| bf.filename == filename) - .map(|bf| bf.file_size) - .unwrap_or(0); - let current_size = f.metadata()?.len(); - if current_size < expected_size { - f.set_len(expected_size)?; + if f.metadata()?.len() < loc.file.file_size { + f.set_len(loc.file.file_size)?; } - f.seek(SeekFrom::Start(file_offset as u64))?; - f.write_all(&data[..write_len])?; + f.seek(SeekFrom::Start(loc.offset))?; + f.write_all(&data[..loc.len])?; files_touched.insert(filename.clone()); } + let blocks_repaired = damaged_indices.len() as u32; + // Step 7: Optional re-verify if re_verify { let verification = verify::verify(file_set, dir); if verification.all_correct() { info!( - blocks = repaired_blocks.len(), + blocks = blocks_repaired, files = files_touched.len(), "Repair successful — all files verified" ); Ok(RepairResult { success: true, - blocks_repaired: repaired_blocks.len() as u32, + blocks_repaired, files_repaired: files_touched.len(), message: "All files repaired and verified".to_string(), }) @@ -325,13 +597,13 @@ fn repair_from_verify_inner( } } else { info!( - blocks = repaired_blocks.len(), + blocks = blocks_repaired, files = files_touched.len(), "Repair complete (re-verify skipped)" ); Ok(RepairResult { success: true, - blocks_repaired: repaired_blocks.len() as u32, + blocks_repaired, files_repaired: files_touched.len(), message: "All files repaired (re-verify skipped)".to_string(), }) @@ -342,21 +614,51 @@ fn repair_from_verify_inner( // Block mapping // --------------------------------------------------------------------------- -/// Maps between global block indices and per-file block positions. -struct BlockMap { - files: Vec, - total_blocks: u32, +/// Maps between global slice indices and per-file slice positions. +/// +/// Global indices run across the set's files in the Main packet's file order +/// (falling back to File ID order if the Main packet listed none); this is the +/// order the PAR2 input constants are assigned in. +#[derive(Debug, Clone)] +pub struct BlockMap { + /// Files in global order. + pub files: Vec, + /// Total number of input slices across all files. + pub total_blocks: u32, + /// Slice size in bytes. + pub slice_size: u64, } -struct BlockFile { - filename: String, - file_size: u64, - block_count: u32, - /// First global block index for this file. - start_block: u32, +/// One file's place in a [`BlockMap`]. +#[derive(Debug, Clone)] +pub struct BlockFile { + /// File ID from the FileDesc packet. + pub file_id: Id16, + /// Filename from the FileDesc packet. + pub filename: String, + /// File size in bytes. + pub file_size: u64, + /// Number of slices in this file. + pub block_count: u32, + /// First global slice index for this file. + pub start_block: u32, } -fn build_block_map(file_set: &Par2FileSet) -> BlockMap { +/// Where a global slice lives on disk. Returned by [`BlockMap::global_to_file`]. +#[derive(Debug, Clone, Copy)] +pub struct SliceLocation<'a> { + /// The file holding the slice. + pub file: &'a BlockFile, + /// Slice index within the file (0-based). + pub local_index: u32, + /// Byte offset of the slice within the file. + pub offset: u64, + /// Bytes of real data: `slice_size`, or less for the file's last slice. + pub len: usize, +} + +/// Build the global slice map for a file set. +pub fn build_block_map(file_set: &Par2FileSet) -> BlockMap { let slice_size = file_set.slice_size; let mut files = Vec::new(); let mut block_offset = 0u32; @@ -380,6 +682,7 @@ fn build_block_map(file_set: &Par2FileSet) -> BlockMap { f.size.div_ceil(slice_size) as u32 }; files.push(BlockFile { + file_id: f.file_id, filename: f.filename.clone(), file_size: f.size, block_count, @@ -391,24 +694,49 @@ fn build_block_map(file_set: &Par2FileSet) -> BlockMap { BlockMap { files, total_blocks: block_offset, + slice_size, } } impl BlockMap { - /// Convert a global block index to (filename, file_byte_offset, bytes_to_write). - fn global_to_file(&self, global_idx: usize, slice_size: usize) -> (String, usize, usize) { - let global = global_idx as u32; - for f in &self.files { - if global >= f.start_block && global < f.start_block + f.block_count { - let local_block = (global - f.start_block) as usize; - let file_offset = local_block * slice_size; - // Last block may be shorter than slice_size - let remaining = f.file_size as usize - file_offset; - let write_len = remaining.min(slice_size); - return (f.filename.clone(), file_offset, write_len); - } + /// Build the global slice map for a file set. Same as [`build_block_map`]. + pub fn new(file_set: &Par2FileSet) -> Self { + build_block_map(file_set) + } + + /// Locate a global slice index: its file, byte offset and real length. + /// `None` if the index is out of range. + pub fn global_to_file(&self, global_idx: usize) -> Option> { + let global = u32::try_from(global_idx).ok()?; + // Files are in start_block order; empty files end where they start and + // are skipped. + let pos = self + .files + .partition_point(|f| f.start_block + f.block_count <= global); + let file = self.files.get(pos)?; + if global < file.start_block { + return None; } - panic!("Global block index {global_idx} out of range"); + let local_index = global - file.start_block; + let offset = local_index as u64 * self.slice_size; + let len = (file.file_size - offset).min(self.slice_size) as usize; + Some(SliceLocation { + file, + local_index, + offset, + len, + }) + } + + /// Global index of slice `local_index` of the file named `filename`. + pub fn global_index(&self, filename: &str, local_index: u32) -> Option { + let f = self.file(filename)?; + (local_index < f.block_count).then(|| (f.start_block + local_index) as usize) + } + + /// The entry for the file named `filename`. + pub fn file(&self, filename: &str) -> Option<&BlockFile> { + self.files.iter().find(|f| f.filename == filename) } } @@ -416,11 +744,7 @@ fn find_damaged_block_indices(verify_result: &VerifyResult, block_map: &BlockMap let mut indices = Vec::new(); for damaged in &verify_result.damaged { - if let Some(bf) = block_map - .files - .iter() - .find(|f| f.filename == damaged.filename) - { + if let Some(bf) = block_map.file(&damaged.filename) { if damaged.damaged_block_indices.is_empty() { // No per-block info — assume all blocks damaged for i in 0..bf.block_count { @@ -436,11 +760,7 @@ fn find_damaged_block_indices(verify_result: &VerifyResult, block_map: &BlockMap } for missing in &verify_result.missing { - if let Some(bf) = block_map - .files - .iter() - .find(|f| f.filename == missing.filename) - { + if let Some(bf) = block_map.file(&missing.filename) { for i in 0..bf.block_count { indices.push((bf.start_block + i) as usize); } @@ -460,16 +780,18 @@ fn read_source_block( slice_size: usize, file_handles: &mut HashMap, ) -> std::io::Result> { - let (filename, file_offset, _) = block_map.global_to_file(global_idx, slice_size); + let loc = block_map + .global_to_file(global_idx) + .expect("intact index comes from the block map"); - let handle = match file_handles.entry(filename.clone()) { + let handle = match file_handles.entry(loc.file.filename.clone()) { Entry::Occupied(e) => e.into_mut(), Entry::Vacant(e) => { - let path = dir.join(&filename); + let path = dir.join(&loc.file.filename); e.insert(std::fs::File::open(&path)?) } }; - handle.seek(SeekFrom::Start(file_offset as u64))?; + handle.seek(SeekFrom::Start(loc.offset))?; let mut buf = vec![0u8; slice_size]; // zero-initialized for last-block padding let mut total = 0; diff --git a/src/slice.rs b/src/slice.rs new file mode 100644 index 0000000..d8a3c52 --- /dev/null +++ b/src/slice.rs @@ -0,0 +1,133 @@ +//! Per-slice checksums for in-flight verification. +//! +//! The IFSC packet stores an MD5 and a CRC32 for every input slice, computed +//! over the slice zero-padded to the full slice size. [`SliceHasher`] produces +//! the same pair from bytes in memory, so a caller can check a slice before +//! feeding it to a [`Repair`](crate::repair::Repair) builder: a corrupt slice +//! that is fed poisons every recovered slice and cannot be taken back out. + +use md5::{Digest, Md5}; + +use crate::types::{Par2File, SliceChecksum}; + +/// Incremental MD5 + CRC32 over one input slice, zero-padded to `slice_size`. +/// +/// ``` +/// use rust_par2::slice::SliceHasher; +/// +/// let mut h = SliceHasher::new(8); +/// h.update(b"abc"); +/// h.update(b"de"); +/// let sum = h.finish(); // checksums of b"abcde\0\0\0" +/// assert_eq!(sum, SliceHasher::checksum(b"abcde", 8)); +/// ``` +pub struct SliceHasher { + md5: Md5, + crc: crc32fast::Hasher, + slice_size: u64, + len: u64, +} + +impl SliceHasher { + /// Start hashing a slice of a set whose slice size is `slice_size`. + pub fn new(slice_size: u64) -> Self { + Self { + md5: Md5::new(), + crc: crc32fast::Hasher::new(), + slice_size, + len: 0, + } + } + + /// Feed the next bytes of the slice. + /// + /// Bytes beyond `slice_size` are hashed too, so the result will not match + /// any IFSC entry; callers should not pass more than one slice. + pub fn update(&mut self, data: &[u8]) { + self.md5.update(data); + self.crc.update(data); + self.len += data.len() as u64; + } + + /// Bytes fed so far, before padding. + pub fn len(&self) -> u64 { + self.len + } + + /// Whether no bytes have been fed yet. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Zero-pad to `slice_size` and return the checksum pair. + pub fn finish(mut self) -> SliceChecksum { + const ZEROS: [u8; 4096] = [0; 4096]; + let mut pad = self.slice_size.saturating_sub(self.len); + while pad > 0 { + let n = pad.min(ZEROS.len() as u64) as usize; + self.md5.update(&ZEROS[..n]); + self.crc.update(&ZEROS[..n]); + pad -= n as u64; + } + SliceChecksum { + md5: self.md5.finalize().into(), + crc32: self.crc.finalize(), + } + } + + /// Checksum a whole slice in one call. + pub fn checksum(data: &[u8], slice_size: u64) -> SliceChecksum { + let mut h = Self::new(slice_size); + h.update(data); + h.finish() + } +} + +impl Par2File { + /// Whether `data` matches the IFSC checksum of this file's slice + /// `local_index` (0-based within the file). + /// + /// `data` is the slice as it appears in the file: `slice_size` bytes, or + /// fewer for the file's last slice. Returns `false` if the index is out of + /// range or the length is wrong for that slice. + pub fn slice_matches(&self, local_index: usize, data: &[u8], slice_size: u64) -> bool { + let Some(expected) = self.slices.get(local_index) else { + return false; + }; + let offset = local_index as u64 * slice_size; + let expected_len = self.size.saturating_sub(offset).min(slice_size); + if data.len() as u64 != expected_len { + return false; + } + SliceHasher::checksum(data, slice_size) == *expected + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn padding_matches_explicit_zeroes() { + let data = vec![0xABu8; 10_000]; + let mut padded = data.clone(); + padded.resize(16_384, 0); + let a = SliceHasher::checksum(&data, 16_384); + let b = SliceHasher::checksum(&padded, 16_384); + assert_eq!(a, b); + assert_eq!(a.crc32, crc32fast::hash(&padded)); + let md5: [u8; 16] = Md5::digest(&padded).into(); + assert_eq!(a.md5, md5); + } + + #[test] + fn incremental_matches_one_shot() { + let data: Vec = (0..5000u32).map(|i| (i * 7) as u8).collect(); + let mut h = SliceHasher::new(8192); + for chunk in data.chunks(333) { + h.update(chunk); + } + assert_eq!(h.len(), 5000); + assert_eq!(h.finish(), SliceHasher::checksum(&data, 8192)); + } +} diff --git a/src/types.rs b/src/types.rs index e29051f..4607b14 100644 --- a/src/types.rs +++ b/src/types.rs @@ -46,7 +46,7 @@ pub struct Par2File { } /// Checksum data for a single slice (block) of a file. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SliceChecksum { /// MD5 hash of this slice. pub md5: Md5Hash, diff --git a/tests/fixtures/multi/alpha.bin b/tests/fixtures/multi/alpha.bin new file mode 100644 index 0000000000000000000000000000000000000000..afc04f30c4cd18345968508e85b9c76cdf069d8a GIT binary patch literal 24329 zcmV(rK<>Z(eG>^nwApIqm5|Dqj@D86Y~nu8^n!y>^Qf5SG{ryFDXGVmqTKP;b5wrvOv3oEqFUScu&+J`YL< zM@n|DA{!sCm7R1z#}@9V17bPNVFB4(2bJ}fkYo0+y09j^HKG4A$$JDSd(;4Qb}bv< z7?gmInmJH}WTdSqu`p4DqX5Bi`rYk%7iaPpkXcD^7)K*rqj6Z%D%DI zP?}ck&3hpbOHO1|iiJk4gOG)CJI8Hw=;^Lq1*gaZtsVHV>J2G}ixgbb@8n)!%Bku= zYVhNk`{yo>Hp^8xEPyH2QZrYvd>d?mG*$5Qv8MfTTl{Q@2E2QilKISB`8>=#7%cs} z$FrB|VSPFV*Qa@l0$r=gH2wRMBLH2recJv4z&Lat=!Dln=s09LNy@r|SqlaCdB?5z zjtADh3o&)xobz(Yq7EoEM^4*rQ{FL8o-W4}shu6TX;S;ale#pm$77!f=5y!EiR1gt zsICZ#Ikx$JuumZ$8D{wX$bgEb&>-VRWO@=anuMrxX37pOH^!xM_IIaikK~D|J75M& zZ4}U`XlPoK9q=Y4!w(0X;Qx?C`-v=Auuk;?mJHgD<@$>a8qOV*X?QlB2hEZp?5QTmcpn@d=NKeS{N?MO{_J!@koQTqX7BN~?OmV=H~A{xmGd7%^hX`6H#pO0*l#)+M(uEK;P3T^Rm|k^&=Zf2VgyC1GLyoLmmv&A3D_qqwg$3SOwe!5;TJEo5ccI- z)DDn85wHG7*ycASh6v7@ZcCg?E#VVxE}tUrPmqv}wm)1@X2$R&d-(673*0WG!B=<) zcK^9^TXD=$v7dRCZ*SYMeD(J=6bOt{nYECeAEmqTVIvBdbTK7N`HCAe0~NsvxAdgg zaMJ{fOJFLt?@Ndu?YOt~x^$%PehgfLIS8l{oqkfI2dB>fbhJEX;7 z>Jrv#BrT@N_5m49m%p{5Y%akV#jX)~-^PAq2r7fwfl^t$#Lvgal7U_!;2aHfqvewR z_F*wh;c4WwMr+1KoKf*(ejYsD)%rOD;HKx$BCkU6PI-&{JkU@eKvJfFt__wF&TVWX zteyaG{y>o&4z*D*%8j0yqu@pp5b2H0Yh3uX>{)|4nuppe>iDsf0~*aBoAO9npNAJ! z3QX$GJ)m=Ok%w^)Bb@8R#?)9YzpWn#?>*g{ldb`>dc#ZC2)B&EQgPYrLmL%KL|#u5lc=9 zf9-bl%LIX&IS7(F_am`CE=c$e; zlfiu8?#Vkmax}=#Kj%d_O3gwM(fFf7DjT=nbWw#NA&!BJ6M`YcD>s4%z!B~>%^+9^E@CZ&IzAO;8<%uF!YZSt> zxQu-iCX3O1&+=Cl>$iJqCW<9Ib}uKGgRp(vD>Uf#fi9QMl`U}WE`go>fhQ=k+ zBtm%41yMky*(59Sqt9=OfTzC!;Agp5kne}G1DcVY*6v4)wjXI3-G-f-^#s?}RAF=h z;vibCP(; zlH~sEW>$Vdit$zGsd~PVL;xlnWsNyCA{}x{B6Ha?glN4ba%vf&d9o=Ea1h6YWQw^& zL&nsCEB`n8K4nK^$_|7)l~ck|N4gojX-&qoTK^^~1?OgqLr z^3WL5&M1QMec;%c)%iEw4!XtH-4&mwBx^3jIq+qN|CB+XK z2Y~ijpt4&U*;zv<@C-+g0~*Vlk1~%$@^;y|w||Y=`mEgucKyV>+W*cW8sU?W8pe#F zYpD#nmVPw}0sZ@L#ls~C1M7(ylxCTMe^nsI#XmoE>iESQ2T%>tnqp!tBuiH-_32uN zp<0c#U&RB03NY{g5~5r>xIqja^n$?(w7N*7+ZKI2uD^M#>0Rc^Bk!= zv-!b;lv|W1eb~WI2AEkD5PoR*OTaT+`DUab z9Awwk5Q{l?3s-iZ)Up242;@)39jkd!jMUsz1JoEeeq@?Ztz|QfxgjAbj?@n}r) zQ+0X3r?y<#(p9CC$#DBuS#&75p>Bg_7nwCtapokh-zgT^ZBT*c|ES5_<%~w`&!Z3_ z0;o<$$U}nCto&?Y3^~|N9Jr>QFeTx}9h(+qDe_%AT@KxWtnb zPJv8lOaUoT4nN5a5_bK?KO-xN?U~9Dt4&VwH>JX%G)Kqnc1d$D3n@J0k3wyTjL_1Oz$7*HJV83pDHSGLl=Q|m5(QqP}=G}!4G z=f?n9XI4%FraDP4Hw!Crj@yQ6j;j8K(&7`GVcY;m$gt1jCkjTO#TgBpfXLML8m zF0av{4}{cK<34z;VJTY$&C{IQ!o4mfmUgNARgNoCf4(k4VMiA4`iN500@Q(%h#Pt9${94u} zqF73n8MVz*(1Ha*`!Y0aK%GJ!tW7osttsW)$Rpvp{qU^9xYJyfo3J|zgsVdEg42`< z0Wb&O88G9oQSf;AkA0eWLJgiiEpo9!!AOGcE9#0ax77?Pw|DMo6@W zY#Iv}=rvx8GMfar3@~SBnzzZk4=q#{z;Bg8GZeS)>*z;Tt8O2|=Ms7lo1+^cfCmGi zy^(7~EWW2=aF6vMDRe+wOW+iD0dtlXKv82EME4GwFJ*d!g2Pet?RXs$FO!=qAAdo? z(6B95qJr7`lq#!1gus*6l=vY%hP-{Lc?HAdY6beJ1hhnDHE->^J&8B-o*{Jw>>pDE z!%RXWB=1PXgR!Frv0Qk4i#@}EH*n1EQdzink8;Wb>Zo#!z-eHQ5%65i?bIEmjKk}B z9DE#y4H_T?tfB9LAi1Zp#aqnoZNEGdlU%RgM{>ENm|zTSz1AOz-Sc^mC5fU!KA#hnm#?LHh!Yj+?kAE zSO#3i3;%o$S)AZ{lC#Yw@$7e!2ahW+Hz?sTRb{wkP6Oo!QS*RinSs~V7Ld~z=~GXg zzrN#Yk)E6QBkB4--8JF!3vTO&^#K;OEO#nPcUOpO`+j#`X~Z=&{T4}XAY1Z!sk;a= zoS}ptZIEN-%}A4dKxIZ`!`}2Dy82(P-7adm5`X5ODHx1vWP1nb5PI|QWNCH1%w1#t>ud_`k7c^Nj3(*b32GE}`Uk9L6*dSvLZ)zUyI^GO;3oW`HyStM(+& zS|kY({-HoHGxZ(?!&EX`o-UAZeoA1gbYa#=@5TY|Dwd&KeOh>7<&Y|uPJSOA(*6<% z7Ya*~DDgyO$#4Hiw56mn!n?ALCM!E$9962B;)^aNf05Km*4(fi7qu%{6zeKc6exyl z$MG!vX8HK-H3=mfv1!;Lv9`X{3y(Dh${@4yqI{%4tcGfx8YJJ0mD_YA)t`tWw;~W^ z5O;HIp%v1RxM_tcq{rpKs-49yDw41B`rH$Z{{=GB8D=mg-e1OC+MWc2OGsssXLXiU z)eNe%aHiEaJHzZ(bGjj=7~00wfU&oqCo=tn;evv&AP;=7(wE}o+RmW)p0-J>Ri*)c zIIf{bfT)-l7@fVnf~rVbISHV5ji;M#5li?YbT zn-*f!Bi-VE#kOQ-TBn)A8hfXK-kpp1j)*<}QquUXlbvTW6aO_dt)aSAqQ$PWmBW4W zNSj31UvpEtK>nb8UAldgk9MyG&N#HIABCXA9B`A6Fi^tJB4xW@b|Dn{qp9PNu3Q+u zD0@{zp<_mlS~>JrEPk_^wne3rs}Yx*X%BP&EyvqiI0Wv;*1E03Eh|rct7*0wp_wr2 z%Ue;$O<`62cuVp(gvb%mkIcKdj}>_an&Y&%+o#n~Ibu~3u6G*^_Ml7n;aNlTl{v?M z{)tK;ISg+78S<&_^@_A%OY@`2{4z2ls}zjx;Xv+%7-q09IDemsNeoDSJnq!_NXi{R z!*aTJ!>5ty-jdc>7{61Qbuk)Xp}vev+WzgDmVOHDX^?Orn;_P)i83u5uMyqy6jp)evt4~VCCw@`*M3D| zmYaXPMFEEimU9DtYH2@HEPd24ZgW#UTy5T5?llvJ1A7SE1VVsvOU#`<2;2^^3l0bc zcP%;#IN#(1UnmVGnWi0rovkk-xL?ev&@*USk9fU=l zIeG!-tvpP09hTo_@7D}F;f`Pbe(?W&2o3VpWlC?1inM@DSk;%RE|rL;uOutcU$_nl zGO5+;ODmojD(=9Wa~D)3fFbbuC}+mH-CPFX3A9ZpvOa(mzD|a#&>LyDCP3H^X9&A zLV|pw#Db)68JRoxIoKkjCQ4XU>QrDFS_?>4Q*kt9rJ9~N4Wi;URLc_IZOL|nQ#5ib z?Iyq|_Hvz6C?ba8WdCRD^(s<@z6Qw#mJyySd0o zoEx%BVTEqm+_DK^T1EShbk7Jv10pRUxjB3NZcz8)9LBN?&0w87ROmF)?lezy??UQ1 z#i;B5APmhTJ01sYM4df@cU#?*NVovEHaj;#3_SmzOrI@rIX3CeOTFq=ur;5sBy_Kc zMEt%O+rzsDY|aSt<+nKs07>7Z>rhB_2m!MIPMA{qkPJ>^;fiLnmVwCM7cB}eSUzJi zty{)_vov`TD+p29~{HoKl zq{k2OIpIJ1AX726TU>1?vPQD(u+&Mn+*N;Ks{6sK!vPZP{e8jY%*~vMJSzLRS{o`fYQ4-0#IvHX<3sS!oLS^5h3))hQ^-86asWt=| zWJi5aD=NP+=lc4)<)Cb;pw9s_n4LxP`fB1v&!ZRSVoD1^44 z0T+ynzGM5hH>3{WgOkbCQ_7XvZ&D3u|3lW9Clt3)OejL#$|S!{C+>gUD5D~4T1OyZ zM_DkZJjmYW?q8_GhQ2Om%H`IA;54nYzk>2#;vH|tNz25|_p58$V3-T7I+g@cc26{F zOBE2V;;>SdZG4C;SNsacqi$P|cLm?Bs_XkxQicQ-A1Nmj34sb zBz~;a3K-;@^l_VV?`1r`DoEfu2-Ww!aHrq}Fyej(z1&-M=+ZY9PD`p^U&_FGM$ z9L>DUS7U5uvJ#6B1Zx?ah4Fb+|u@+8p`}M1x#SO4WFBSfQmEr1v00H)= z>j@x4x8_z7_|`=--tfY$Vz#`*@vLPEBhbI;GPYy*5v%fs*zHAaLi-vaxuH2s>5DMC ztbV80vQ&)wVHw5eYT}(3gEL;q%GGT)6F#5Z_ygSW?CR_V0t*}#kvfnI8*x5pwVHNZRw3mz z0X}5}#WY}Wo%wv+Aj03#ms00RO8~5}QS7z`A@_p$kUdy^XYn{4t&Oe&J#7sf% z5wYqWXBiRDlQd%-8rc#)aAg=$l|}ehE5kINGaZ}AcN$2EhQYE10Y=`3$=YZQCg0!T zHatuj8u|%HVy|HR8xh?nnH}Uh6;;Ton5Ok4C#TCk9dUJ1 z96$*!n-5D=xJ!8>H+~?y2kS&ygP&w}#a3~#zRj)wigYB6o}xc3VG}`<&*9W^Yp~Cs zz?T!C9_fk5$z(^c4<}` ztg~xy208L9yU7*;jZZ*IR9dubc-?{ypn1m=?pVVS$A+GSkvhAzJOK}9vU0mLoiZ5t zKyAZ!AF!6?M+rwbPvq6Q&%ah%Djla`J|BeW?U_qQLs$&V{s*)gpi7db7wN&^Y$osT zVYp)IR+oR!f;Fr&6G>B>4o_*B2AxQjU_yUQm__Nu0M@ADFtTw63&w{9ud(6LXiCBL z)q~5b>gakGyx=lGm4~FW%IgP%!hX|5D6cls@(+9Hr#+MYtFAttPYBqovk&tO9TcXK z3y%)dW1|?29p6k}ZfI_bdx^oBeLl{jGQDoAUo0s!&+na}38?a_0wi!6!ewA!Eb$%S zEfT>~;KUW3Uoxb}1Qy~{11yVpQdPex7Q%HHHFsgd*lt0?#5{mZXlZl-ZD&E-R? zNd76Q^{L9;hup~PF#TKhoS~B~OhJm>SFH*2wrtJSL@Cc5;4BBZ&qCLeeSoYc^b|I) zHtD+l2j+08Eze)YjNQ06INfGW_~YF2#)MLoDgOuKUvPFfME<0mdm>u#bG5ATr5y?{ zCr;8GW5GJv-TxJWo+nXwNKFYIB=Hg1u*p|D47ktDFkVq)BBSd?#HxMg>F(+!mA;Wi zkULoEl@=MNYJUbQ>m zk6z8T2`CnZ ziwq^3VWVi$Hj2x@(bmJ{#Ky$kWogMV1=0S&!CXS!VB6&%10|YQvM}7sf`H_iQMk;deWlUp~c&I~eYS@UF5UQFm0JuOVgoreeG*qYZ zo{s(*1VX|yG@dDZp%%ryq!~3r$r?oXt$arTRtusLOg8D&=2ffD+&hk~>?&npf9mAt z09AStMYofa;i4kCFz+e!c4%Bres_>N4|aMqPzxd>`#&A5rf&j>m+A|ZFBe`0#NQvL z))q_Q3%IYmE)K4`44n3aK&a<=9FC9`X2a$xD9`pDo|#Ww^(LR3S33CUt=E8sukc2r zy!fpo zdzfkzQ!1zDne%%2cW4E`cAM&-2H7dd`Au}>^v||Ro^Qmz0=wvdnH`s_c*qLeSUUrQ zbIr-Tgpe%?{F7qaDW^_-Vynhdgp~Uckf(TX%$wx>`mqjXd(>fp#n11DgZD@wZ;Qx> zt(QKRl_1AY>7XR}Eu?m^&oK($w^&H2GJGH>^dPo-1-2e9Z#IHS&ica0)hsifbVm>t zh#DA`BrMF%AsL}=#?0j>&s33xP&M6qX5S;W>fjIV3;35 z43@zm5{vMzN{vk|8%`Fi+XUxsCWeX_D8TB{f^p$AHZlfc z!3Qjs!Zbi5-h8oWK<0C4Q{+4)L?k*|;m~b)BfQtlPF3|iL$p3b;!fX*{@qi5+Y%jw z@%=e8Fagm`6)*RoL}gK}mL@wlxBcN$8|Ox|zsf$*!1@XKfFDl&!Op5&RHTo=%Op~% z^I2Mul+Q)1fE9vy(2(EdY8H=9LF8!GWgMO9vn6N@F7T939>7YQj!N2C+qO!V`6r)w zusw*2SL#oUGv*(DO`nZ7&gsB1Vp?$i* z;nCse{xNqOji?n~h1?tp_{m-1dl*zaE7q}o##WZ97b4`e6?qb^4`#d_+8~i%SjXv4 zY$2x2$nJe1U!4^v#52^FXh;A8B9l^#)Pv% zSe`LmnAcf1dFVb}-aBjoc0tS<1Xhz4x15EK4n<{%B+^qB_wXMK{y!Zk zy%kSlY+4A$OVJ`dWrmC=Y8{7@AbJ^qz$ULoK?C9Z4TtzW$9@}p^1;_~JegSo%nNCE z|8p8gE?A(;lKXJLsTiYR#=YKIo7ZC#)ee=&Xeli7I{nSn(5VI~zEH?@icORY$#cg7 z#zM>(1&o*R7T@~htAOH(V)fhfa1Dg(UjZ$mcAsat$Lo!t;vUsJiHJs7*UZ;_$f5f< zZ);;n#DfN?0M$t$1S5H4ofH}iDGo)61=g^8W4)i%>C96P%34pdRGG1n|+ ziH}Tus;)Q;^kM}hoNq&@uIzUHsv>7!gh>9SZr+mN)TerVnrj;_`b5>qInp{5>xZgP z*0T+TzeAKrw^E9?U<6_!qp)cSlNa9N`vBV-)(oL^w?7r6dQGj%7vmQ6^N-7VpB3fL zK8KrY$UKBb2B1uSrdni7O!9`kjarA8Z6L<>@TClgX&xAQrBZq$1#;5X)(i@s#`kJb zx2LMVau=x?lo)CDU%cT?4H9gX=1HnDR4p$0&=RX8LK&o*)yEY;Oc-G59 zIt&2%5>u!f*8vO-FY*G{9R6*pg4{YdSP!mK_Gmh;fEriw#q$@aY_k$K-Btba09@5D z96RgHlojWM5F3_Drt-P^4QRPV>OI`~bV8^$NmIZBnz3^U5xO%W0HA@He?6@)nv9qR z-;bH@&zCWW#|r(D*pPF+r%iNEUXaE*X!-WM=K>V<^V!56YhE0x6ooaI6ab) zOSd0zPbVfSDN&vWegfI9-M?+K*FqNu#7MZHcA)w5HJh@gd`=9@eyh;Af$bS@LnL#> zo`j<~-(BS4&La$w)v{Qn6i}z^}3*SY{MfV;6n9t zn3QDiiMZi z4s{I$TWRbrcG!mfv<38_@qJQp^We#ksyzt zEaj)Nx!Kb&(z(nxJ??lT4`+U<(e~5(VEw2*R^m;(WVLYq>*$CVBRHE+1(hj5kr1jz zACxctc=|W4L;yFD9gZ#RG+*!Jhl%oSl6av1`wrvKp_Oi%QaTHt7v=+@Qi}#)WRMR? z0AU72wHxfXwbazH=vm{G^mf5ykP}Muo$TY| zBN;O=!9nK$`d2Kj8#Cl`THc@gU*XR3o&R#1{47c$N@|JbJm-bp5e@S6cn7+VV4g-0 z@8~#5b#-?mSaE1vo0{4QdiPT)=i6TA-yw_noB6@tAD;^IF_K8LXPp%T`u;(OZr#-B zUam{eTC=pBkiFJFMmA1vm*D_X8QZ>JniEu}{zZvYvZKQZtMA2qD&~o3CkvP!?c_{Qk{8(m%O^40-7tT zWo9)fUKaUNX`1o+oMp}|_@~yCri??NSVgqAb89(Qi~;hvXk=|-Ml)WV4Yk!i<=e-Ib@ur z?50L06tq7qU9icHUyCe)`|=h=9bUn`4ETC$NY0K~c-V2bL__Ax`$fWJ^vOF?2l>gPw&rEVX$uG_X|C}DDW$cbB;_X zS!L{@DAuJ{k~5JLXl9G}kujd%>*mLZWy^FW>3sDy`%$?Mr9l#g!>J3pZNwc^GiE0` zN!ys*q6pd9lSJyz8#vM1JX51PI_?Nc1?K_;;{CK3g{y*3+RJoOeM8}?gRqm3*Av!@ zI%lZe03>LHJvcW_4FKSJ8!;w`jmyoDYH5q1@3g$BBr zNr3gzd4~wP#4CoUY4|@y{11{~i$V3rl;?}|{j1`^lauy)8yy@{LajUMIz(*#(dbyo z-qJPYo%7E!zK7@j7Ke?t9b62fFQ_qV_kJ9^*=*FO^4C|jpsKHHu49HOP*X9{QgR;7x@}{nrAp@EDgWXxRP>A=OyMJHG`%UJ9bvn- zppx{*mAf1nu=Lcozz1g$x7S>cUcX+MPvAM&I=~$>gk~G``z&1H^rYMSe}1bfZ+*xO zIZ{b@=CDwm*1n7H2?C(z7uGdCTMFHO4lXtZz&_lONYohpfW$x+$}0D7LYMD-=Lv*g zv*Y*itI^@8Zjp-W*P+PkH2FmGdvZv9pPm_87k0h_>ojWkNbxPCa0V+)=1on|ofAoQ z74JmffFHrYt+=Xn4kk1VMi7~SXoHo17oMrEn$cFpjINVM<^~9X=z>+J`J%#TFaj9 zG}a$dQ~6$;w8TYq{Dm29e8>$EG!1sZ*~rU{0Jwz+hqu4<@}k2b4jdVh49+IO;N`&) z!@}L`bej_1*-wY3m0gm zVwI|g(Q^$@&_xBE>I81LiG!)n^EOrMMO+;vhqyn)W|Lvhe-ZEVnG3Rg^U#wJuA5q& zak?q-XoF{A*8oT}jW1=(wl)#LNnq#Msx;=&KBcV4j#96J=rEUu@`Ph;?oc*r=?JMDTgB+C#bIixMYN9Gy6TN<|m(2erze$jYQ8 z9P@k2n$@l}6*`rh9=#-&9QRFDgkVYa@d9KLJn+Yjj^@{?x z7*D^3zj>`3bmdor$_?}^CRl)BlZaTDC)ml%L&PXv5AJQwuN+u2%(JIvJ;EY3PTOG2 z9RQn*?ld0mhQ8#smOakkvM=ba`bJlo>&1_P6$Zrms}eCLHtAE#ly>qPcgRqHu``qk z^!g6ZK)PoERh`&f6x_eJHUtdQrHKu$xTr?kJb)JKs358Ex;pgtYuu5JoehMlG(#F! zU@Q6#%OI5&<^s4Q5t@Z^Qn^ZG9?%UU8k|~rUzv4JS(w;@L@}^eNiDRxsB)SqV*DPN zjY%sTVw9Q^$&y!T*LK%$J%w^tJGwg*>Vgi8&bqm5EreuV{#8pq=%l)cWx#?Y?H&`mF_2C6Ydp z)_nl@lJre@Z>tBL@?^t7+{eZ+!uprtMFthIx^FbH6O%l1_Whytmc$vSKp+1OZEYZ% zM6h$e%gwfmV=*Co(=&f;Hpo5JV+^u&^41r7j zwgvX<`?uKgN`ZEI10d@AQWzSZBsgdd+J56i%A#o{O3dtZp(F)%We9W6O4Hpag;mQW1 z@V6YY4iq2xg^}CxxYj?M6jfTMh|I2Na~&k^sw*Ip7L46U@)$~o}X$w;_)lE-^esZumi*p6HiDSnwm+BkpLbUa(`}M}Phiug+=XEI zq_nJBA_$Gwslh6FbIWk@<*%!CA4cxV-UyTOj)1AgTCjyKq=d-v2tK=wlUQ)KIu}k+6lrtNXZv|iGjoc0_dU^6VaPyioHkn=^IX~mx{nyfF zFe==}W^c+-(C#dQr9f9#8k`Rm8ZAj5;3Dm5vq37EmR&6QAePOQ3uyFn>)eSYl=+`z zm5yOdw=Fg!Q=Bbk-duDPhZksK*3#_nPNVksQ6pl`2%IiZ1EjAOh{o=QxH8~`w5==0 zsVHE)7wdg0swspSUUuB~BJU96yQi z`5z&i<*Y+2uk;`;x7g>ZOOK&=s&iYg&I`Djbn--+67B#esEP$5!Awcx_5TIimjb@i zXdu!$cje>HZLy^HZwx$EQs-UuYkUc#&0bk#SCDmO(K_B$&f93Ppr*}i{}3T+mX*WI z^asES#?nVa0HJG3a0>2OscQ6KPbR79gC$O!5bIxgc9wpr>kR0PnTeT0 zQ_x^u3%*6-(Y&dU6`$e4awuW-9cUk{nXqdUF>~4icc%wHNWznCab?`^$FcSU$E?%S zlf#|8$1Sp={0_vHYjOU0YPzGV%@PKM6Y%s`SQOS7w_zn9OgjrW3a6#&QVB4=*kCzX zpHO1Un5zwKwnk_WVu!)pji!x%l;cl&JeyiwV{48KY ze2BWae#Se62V5eR>t)2~h>J-(wtIY|!+QXyA6QXJMi?aFxDylyEbWXxNu?>QRp(Q8 z->x!PtLI)5^*Cmzt%thXY~GrG%$FWa9gqT#PnM*sPS22o>#5!*73EwLbG?L2v^c}* zytn8m=wMNsn(bX*rwNco`-_wnP&#&^ZwN*!$eH%Nmaz;swCfoAR2k-WhZFqRQ5-q| zQZtf|zyD0CZh|m;WNP;P>`!X^+mn8!S(Hk=x}8w&c@E5W=#ilC!I^L#z^xTr8DN0a zJU$M^Q^KbQQMwAYQMq;UtP=NN-emtBD5F^oy)hv^L7VME1j217KYZQufwCX)`ck_@=JJqIEj*ZtgW9H{t4T&`RLAqRqaMIpb?oDIf&VCDG zi{qiJ63r(hX<)R-dY#_G|=S0CrED4&kPE?j-4< z=h8OP$J6d1L1{b>w9&!u>$``alub8Yu)PCrvzZM+aalA2htc_>se6+jd4B4ISNlM{ zyLbXMP*vAP2mBl~NbjhH>FOhQP!kc_)U|7S*k{Bb6&K#cz>FH-NT)Yd++@jSf7@iJ zX}nL!E(sDakgr>d9YO>51;a4*G!gyNh>Y(&b?fJv2F07{M8GP)H4;+cxV9l;f}g(2 zc(a7|bsYKkby8kuPdSccC`Mv62%&+x4IE7pa?RmF&Un6pR|GPN%`pO|nvtoru&rZJ z(=r%(yQ7OKMzx(3C08JjR2fL`Ltl9T?h>TRcTp(YnU47pv|P6>Pl%LBY1!b%IoC@N+i6u@*i~x<<%X~QwsJk?% zPp9BU!e}TnIgvq&U+-ati#+!nxowX%c+=oOcoq5hxA+wlO{G~+QfaaM6eh#w&Dn~5J9gB15lpCkRdzE(KN`2;t9=ZlSl z^r;==w6X7pCPDbo(D>bm5_Vch;TftF^UFTae72^61SZhg-G>b1GAZ>8)B_*Xbri>- z$@;2<@)0CE9MdeywFx|--kMWjJ zq#(>Fnd2bE#qIm%kX7ifIW}>vg~xVqpglRc0r(yV;lr%7^}3~mJ<}a}eu`i0B@Zox z0_--ga;MB3m?qgK&j;{OZ-NE~(5@IFCP~1&h}k<l!v`H75YKT6jTAc&dG=$KrcyS~Pg$jxs*%R;Iz?y<8vKsCKj=Ox2|O z&u^soEtEZMMy&9AN{63D*uQd4f%SI&I*I=uLF_)Pf@!mU3jUKX*)x_sC>B!vW)lXI z4FNRJnviz7=2F6hs|;oI;COzlHv-|FA(=W&R7aD}o)A4Od|;R>S)viLKAmeK?1>dR zF;=ryO9jh;jLy9{{Xz_)Ey~5Rf~BjMr*FlYEL|DxNFT*g8CRp2XL76!lJ)e>#Mk61 zw9u-Kg8Fl5OYXi5f~6?rT)P_53muu*sw3S#mvPgaIQtTSE1Z7JARuadtF7b@6y=`dk6^qg;g}z! z9K(2Z!(Iv;wizkbsy7G+yJrw_@OH4q>?$PDlclb(K;UeNWDRhpP!4I?4nR0f1W5}+ z`(EB4CF(`oHs7pi&Gn=Cgi+NZ8&yTHi0}x;#=$FW-0d~m{yL>9nq^kRan8|8#{qSo z!n;@v`Q%S?76wSWc;HGil)Wi*#MQ&e&(z9r!R*;M5>y;PT-rAY8SJ~)XEZVZq!CI^ z$!oB@DwffFL&WwEww`<9e0ew=8_gmDD{9f~mhLe>t~9f)3$Ni^j3vrd?509ez&b6t zP8I%&L=}*{es2whep1gF3I$2NSFhM|nYmQA%U}dxxtORvo096q);81hrIg@F1wS3^ zD{BYzhP=xoM~}gK1c!hvtu^re$u;WP#=Pn+sNlteJydNX$Huu3Q{5X5|2FJ^I}NOw zfOwVluOM|MZXEt<2QX<=6Dm%4!R8=$?%)3JbepQ~_+R54jVe&6TBGWF=~S+o@>z5P~>*GKL* zNZ(_$)JzY2&CdCIpaKASHxI*h7!!j28+c`8tNOme9L(<%rz{U>;Il=c_=|*#Z~2~K zh(9pMK+TmeP_R3%q}{hrBv}@MHM=^ypGbt^&XbccSd|MVe;*klwEb)vfE&BL7USM8}TC22W!+D>Mz__;UopPRBidz+vHC%1t;CTqrr5B_0mVa z?$Gv^4x-R+aE;to2nZ(x*8g!w(q+=&(!wTE?gK`X)Q!zneOB~ zjf{ya`FXq)hPz=_&KI|G^Ol5nNvYpgK}gyHA}w>VpVko!XZ!7uP65RN8z}mr^wrAQ z!;IN>`!E<3;F8x-yuk^$1{1;#cGWxj5qPQVQ7Y3u5BFa|A#^po_FL;;E1WcWocLeK z^V7dKem5nWMWMt$gzYU-{T39mP_w!VDU8En91d#(8fyz^!5-Rsp@0){?(=*PXs`@NTOhqJKT!Rgg^a2!v3ro-hJb)+~h0Va{DrZoU3&22(e$Z(=;HkihRJ(KTPUr(JcKGtndp9-%N92 z@QK=cCnR$gbQM~Bx_`hPf8!9q!XYg)&?hPYfv9YACt;PElatE;Ia}LU%2jdpEG~56 z=sBar;QjJ5a)R@uXLNXg1xbW|YG_(;rT`R)DDJW8Rl7Nd1p8EOXB5z4pkw z#EmZefULl=jj=Xwo=Ui>Ts?9!Tm#jA^qBQ&AMVG@xzT2ML^aAlrpr}bX$eTdAFmG> z!aTIy-O7W4FFUD}d11tn2-(oW*AM^z3hGe{P+m6`I73a+a|OS%)C_1 z=CCgotBoJ$&;en?-YO0X0UiU7V^k9E`XFgCI2-b7l~ip3SX=m-xD?G?8^s52f22+< z^$ah@?qfs8)mh2C@O^JxmTIO1fl``?L)Ff*OsrJIet3%~<|yc|1Vs)JGMOAW@vc(2 zae*@(GDvf+JN+B0a3d?zvf%TQHc!hN5_C2Gg%OR$iSlxwpMUcb`q~kN8eo9)F?7bu zL5^7fhgO_9aE|Mf1a!EtzQ-QuVX*OW=qchRH|YAV{9pf@3_)zemkSFrM{rewC^{)n zNQ(|SYC9moN8R249-}!t`wO9{qYvj)k28pv0X(gx_7@lCwydz;6^>$e<<5k}k%gxc zS4fwBVxyd(Y==dyI03FQhBb$_e=>v)k)-BWbTg8Yri|HG(V~-3^1+p{;JOzwV*tXp zn6m$*oMuI`>&!UiW;=~4j>9i6JBsEUVGdk_(g~ON<5_^?moQA!78$#gbpm_5md?3T zGbC-SqBj?F=f;6>G(S-2#YAGdm0tKO8V6mEF2|l0%xD}Du>Ns}&7tSZ(^18nrnLKs z;?Yz94D?L8Ay>!a=&6T#{rkZ)*KYy{cwTD~9X8gOXoJ=(kTOp;k@5 zc;P@mJ5XxIwuJIQvWkn1%K<-vA^fy42vA9WqkE-{Rt*Mh{9aCjfn8Bj!^)qn2T1?> z?Vo?_up1S+^$Pnv!}29vBysP}Q4V~Eqsh9{kepaP5muH}acZH-KrNLfuqVQo!Q-4# zZe!HzUmTXd==vzrf6j_YTG7JdZ*tsmueI0zJ(X3G`-%E0ZQ#9?K&>qd>O`n<)}Ncl zk3xo=T6z+63z+bV8XbQe6SS1ol7(*?0n-^RP0Fz88LWRp)~JTsifTS`Tps(H#=&Jy z+oBE!32p=c40sPHi?`9->2bp2SNm8M?o~!Le^JlL)ZBZg{%;L@7q0A6Cgr5*?(`&(Spc z$(b$$7e?1%VdkR*l@YY`y{x5&!uw4*As6m~P{waw(kln@UH{HZ(PyfHRAdMO*r|i| zmRLs}nixtF#!1lkAG8U3qFV4z3og0v%lXn_n>`<^H;#06NgHMkXJiK~8vf)eRf#nR&sn*VfyPOgmq* zc!q;BFa+pD#HEy-1c{3+s%kqX5T{jFq>T6Q$p&~60TT&Z9uKss%HAl$i<*CLj4g5a zH>vS5P8F&vxL#G7hQym%xCHCaI0FTey76!7eVfMrHrmgMmluRbgHjgTgUZ)b*X4gN6^M}w?d93ZY-an6<6|D%ywnjzc=<%vBAvq|EUm)i#F6OXV+IRy z(W>oKGoicQ1@I0|jJi0+?;Sl>im4s zpxnJZ=#-=k8CwR#lYcU<9pj=;Fc$7j^Sz^7Fv${FdFG4(K6T+MTcB-%FLLQmkJQA< zP&=B4Bw`C6X5_0?C5C0X&IIPlA1ys&HDo-=QWXVT zm3p&fds9j@xyT$-VcN6mEX1xYJWP1S{kRFeFv*LjISp@MJybTPd{7Wcm$49^xFRm} zw~N#bnLTQGtXpg*3qio;{GtVUgeNCut&M?CwWCqmt05TL^bp3h@%k>mb<|P{A{VbW z5T;2d3l{QG+x9!fJX~alpojL)OLXL=Was*m{<7b&{~KOt&|0k= zDf#}QV4=6WTB*J3ohu*TiZ+w4S;>{uM3azFep{@Q6e&tK&~~_Dp5>R%(Wcf?{3zqU zfO+BC*#X16ZK$F=A}ot@poWoq1RuUqPX3VRx1~bj7h=s-0;$pD`+j^Zc!~6ulcof* z-HDj|W?SQ?xK;}!Aw_nOIgfES+Ya{GozcJnP)NtatcHD#lwcI{!JCGlV9zNT=&Vrd z?5J`o`86mlSe)y?mAW!DN$@bZ$X{zObx;k<80Y1yd^Li1D2~Tq59}7sNP*MjU0xfh z7Il_eXs9l|j*v_gMmJxke53{%(@4OcfJ^@Teo*imu%;G~vo z___8;vS#4c4Q};(9$?1uFTRJ|wbKopJ zuHkC^XoKhoK0^K6s)TG&dH7ljdUBp^H|!MLP!??79EovL(ubLsh|Vl;%GV)!9>zaz z1FcESoV&LQSN6&|L)ecDRnXi_{{nuynN#-k=t83pv7*bQrZc@Zy2^ScZxFqT6>O=@ zHkETnaJY=?%!7^Ds@haNgyRhD39!y^THIAVl(K<{-#g1LE99|;w$A5Jjl_#1^{U;Wu*)hD-8F~?bNE*KTFGcGEV%+QtP7#}m7 z>|}WnY%SV|??z-8tm{eQ(D)sa^r1xTLFFPzFgeb zC4gHw79k_S%!jv)v}AvYU;)M7HNzc717x9Cmw#IRaS*sKjFfO1Jj9)(}V`gEP7%?bb`dTQh(6y3rUF@8WI& zHPkP1eXiWogFWK-0GeGj_y=hBUAI8}E4HV>1y0SX7eL8^Q7EDXb>2D7+DWtEY`(Dw@ubu{Y^z6Wl>h zZiI7tqu^Q>9{0XoxxtvCra#f&9m5>oYJk*tczvzofW=2j8g(3vBD zjEM?QFj2OuaCGk+p?t#{_h6XPzNsj11obg@;R=rC=O`hA?Lp0i*JdVW z3V#D-FLVCSXc;3cIy~;MXBeb%GE{)xf6p8h{IZsB7J2URax0PzoFndT!hh%GX%V} zdRChVhzE3C8yT5zePsqSOuCO`{7~#(Ov#puv-@`-&c_6_hAv8s&+9KO>r034s|RiJ z4jB<*zNgMdsl~RXu}qeoti(Wsu3eTIna~$@hf%*!Fz)yz+O!X9(-gc=+BkrIxBnNK z-shq>W8$XzK2%E@Nk5UPZjitKVhN9+7t51mT#vPyt7}Hr2R0dDHe+sVIJi2=2=t`{ z+t82~EG#k#EjSG&0i^?jQoY_w#+&P|PaD)@SQdZx$Jv`Ljq0#LLbM(;pyd8LJVC0S z%S}s2!%oVi=gcL0*+5Tio)l3mOhDI6v4EgVTXEL`*%nkKbDOdEfs->qX@l&%K z|F3#Nj-V^18~Th!?Y2^$z#^bA0Fda8^H=lzAkGSVHy z1G!kHPF|PBE+5r@BAd9R}dV6En>(BB#i zwrgHqp(cfdQ@9MM5c}`Iu^`t%UPOz-$uXE`s({_`pQ-fyh%v+?UVO+n_1w~k_s@3NDw&T!puX5eO2XKD9wqwWJQ{AB#r?* z)#$=fuT$7A@^b_kAvqPdka2LF!4Vi>^g^(&R~r}5-S=p}=~Sv3nF|3J|0Cxn_e5fj zk__qjc?_t#-z12=KH>8tC3(nSc8d>uzW8aJBJw^kM3jeLt z97BX|xll(^D0dk5nT!Ok%G$vexXU<<+D9qr5UumLo`SOg}wi(;Y4% z&*u%IRS@0!eg_G-R&{{ATvdac8{j;&w3WKr)|1)jBLbpptHr|NgyW6m;>_oGVsnJF?Eq)zHh;mKdyvSLk7rtft zlx0jPvSvCOjaQA`7&`sroQ4YD$hxRq;yxp;o?gdgBYIaX{zypT9Bi8Wj5F_APJWf} zi=|_`EoS@`BtB&HUS&PWZ{}XU&EpqYTaJU^oS!XdTFmEr;vEF|cpQKn&{!ZVG67}+ zw^T(CN1P|&jyH4pgk@4eY!S-fWG=)W>d_MD3FjaoaJ*=o+V#Bl?FL`gApH=IsWaL! zy$Xnz8~AZRiCP+fw4p6yf)}ney8qrQ=kB>W$dZ9Ok5V3moIRcd7C9DT6XnBNPgpFj z9K40`X7&7!6yI6SB0-_ijb_9HJX;|TWUy7nVRwdLt{ zB6J-d%IlZD2&J{#IWq9@8CQ=SGbr!EPI;0h*5wlFEm|7kF_T2*t+GNjgeFXToiwgo zPx%285^)M6-+v~2vEAqQ8{=sdA*G&7P<0w4_+==+em5@$PLEyllof{Zq|v)GxoZX3SG1P-HeI(e%SV z7K^`Q&T@-Blw}%j7#O$jT!|?wBB)~#1EH^rSSU}Tj}w3N=N_gT9@!snI53uad*nYl9b$JsF(pE!qPMrT=^K(O{n2#0UtqoItN3j(&--jh zU{m)9#gqKG>ly#T;+kQlv=2}JvXm~1mZc5GJH8_aI6Z?f1{eAi$1Y)tRm)rA7_1v3 zo2vfx*X5i8vcRt)E^uQNQ2BQnh{;F3swC1+uQ4f!y;xPl7UurPCIKlvCo>8#l(F2k zwRdwbxF(J1GX>WpZl}U`>Cl#6&R|KL+6*_@gO44zcKc0rwF5drD7FyNnWwp3k}GnT z>HkqLhyH)Q)=fVXzXNHru+9{Q9Ii*vP8kq+*I;V@YQc6aEb(Kbg&fU(Ma&fbs*t-O zDF%7nJc_=k7(j3HE)fB9vC<3kKjpOL>cCw|7ZEj25)d|s@zUUl%%vhpdn%ABV&CdnJ~H?2$Tt?`KW{k^tKLL#1sy0j=Qi_E#m2V;rh%z5{B zM~`yFCHHxK&E23CMU77{Cy8w{dApCc<6p5H$OfS1DUm@~x!I0TbY_q9DN&au0keFV z=>cCjWDz#c&R*<+?4`Sn;t&4z$YC_n(#~e7VX3SiOy&on9(Kp2O=#eh4T6ynMWw=B z8O`dZ;Z%fC?SEgRVOun`rT4-y77Nc`7iSC-Fxv0ucJs>I_5s^(wvQT>MbPL!Ku-yr ze*B!;O-hKnq;)MnmTQ(CoX1e?VN$qE=Xxy63-RQe8a;Srs0yQ4Cb`gNei~SUGWnt?9vUPV#vM>-VWR}Kb(d9+njuDXR ziu)y~$2YTe8-oXR*`i3Dlh*f2!HDO@SI0?+d3PK9=zAe*p54wMKZxmq3->L@;Qh*T zxWb=W@hlGXBFMrOQtZcu0jXMnZe=_RMVQI!sjAl-9+F`+j(~9y=&2$7Wo$Yn49F!6 I=x7cpnaWgNZU6uP literal 0 HcmV?d00001 diff --git a/tests/fixtures/multi/beta.bin b/tests/fixtures/multi/beta.bin new file mode 100644 index 0000000000000000000000000000000000000000..6309fc8a7e0ee79171893bc069e9d3ab291870c0 GIT binary patch literal 9219 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 aZ`&v5a=m6c(2S8;?Tq+PrY*3z&RyJ>QV1*n literal 0 HcmV?d00001 diff --git a/tests/fixtures/multi/multi.par2 b/tests/fixtures/multi/multi.par2 new file mode 100644 index 0000000000000000000000000000000000000000..ac0f44474effafe5da958c18b8e4771f6844dbeb GIT binary patch literal 1100 zcmWG=3^HN}@D8bAfPlcotXctYPj)6t9=^SJ3j3+lbJJATI`TBmZU7;aftnKUUBD>Vj*w!*p`xww>je>7A58sWF+b(fjt0oLj%+e z`}SFBw;TKPo9J#Vd+OBVOUMoAew^!7l`my;T)I<5{2O1Hj2}9mSp{NIQfdj<2{8L% zVc;IB{{9Ph@T<#No7M`v?tVbXe)KTi#2~jOz$dOv@6ZwUdE$qcpz|3FAoiyx=H{Y@ z!3k&>ump;)x9x9Yk6gC$QmlsjQ(SKFbPIL{X+e*N_Sm#L@x`G@Jc^b-4(6C`e-*U$ zp49&%o!OUIoS0%$a$m~wls*r%u9OwdzpB@KY}LF^Yk1d$nD41HKXvbplag>*u%w^w zn%jNT9>@wVy&?OOM?!vu<>Y^o6Akv>J=IZhW<%Y|Na?-NqN0-wOZ#Te+Z`;u(KP&E zxdALJd!YW%INl``<)wV~W!lT|tr15B2>Am&F3aLC{q7K5b4q5nQbJ=`>GwU)nI>&N z7}mn@dES)RFRQoTYslZn7Q8-!&1B#7b4xWg|D9^@%EtCgK6%;gNOZTf7*y4CGQIq} z=~wVGft2(5gxrE2uMZ9VZI)bbHhg7~(B7oH;^yMZ7fdYeRd47%?&n&tHsgv#Uf;xc zLB-os?PVC3J(zML&#wQ!c|FG$-aDZ(Ft=nt!(|W4tj*IVoipEV$G80Di|J=@xy89C sHL)bWhyhHerYIy;DikCZ871eYDH!S*03pz90B3ERk^lez literal 0 HcmV?d00001 diff --git a/tests/fixtures/multi/multi.vol0+8.par2 b/tests/fixtures/multi/multi.vol0+8.par2 new file mode 100644 index 0000000000000000000000000000000000000000..e88a3bd62f6ba6b17bd1b2ac4d2309dec19e0951 GIT binary patch literal 37400 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 literal 0 HcmV?d00001 diff --git a/tests/slice_repair.rs b/tests/slice_repair.rs new file mode 100644 index 0000000..67959ea --- /dev/null +++ b/tests/slice_repair.rs @@ -0,0 +1,452 @@ +//! In-memory slice repair (`Repair` builder) tests. +//! +//! The `multi` fixture was made by par2cmdline 1.0.0: +//! `par2 c -s4096 -c8 -n1 multi.par2 alpha.bin beta.bin gamma.bin` +//! — 12 input slices of 4096 bytes (alpha and beta end in short slices) and 8 +//! recovery slices. + +use std::collections::HashMap; +use std::path::Path; + +use rust_par2::matrix::GfMatrix; +use rust_par2::recovery::{RecoveryBlock, load_recovery_blocks}; +use rust_par2::{BlockMap, Par2FileSet, Repair, RepairError, SliceHasher}; + +fn multi_dir() -> &'static Path { + Path::new("tests/fixtures/multi") +} + +/// Small deterministic PRNG (splitmix64) so failures reproduce. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + + /// `k` distinct values from `0..n`, sorted. + fn choose(&mut self, n: usize, k: usize) -> Vec { + let mut all: Vec = (0..n).collect(); + for i in 0..k { + let j = i + self.below(n - i); + all.swap(i, j); + } + let mut out = all[..k].to_vec(); + out.sort_unstable(); + out + } + + fn bytes(&mut self, len: usize) -> Vec { + (0..len).map(|_| self.next() as u8).collect() + } +} + +struct Fixture { + file_set: Par2FileSet, + map: BlockMap, + recovery: Vec, + /// Every input slice as stored in its file (last slice of a file is short). + slices: Vec>, +} + +fn load_multi() -> Fixture { + let dir = multi_dir(); + let file_set = rust_par2::parse(&dir.join("multi.par2")).unwrap(); + let map = BlockMap::new(&file_set); + let recovery = load_recovery_blocks(dir, &file_set.recovery_set_id, file_set.slice_size); + let slices = (0..map.total_blocks as usize) + .map(|g| { + let loc = map.global_to_file(g).unwrap(); + let data = std::fs::read(dir.join(&loc.file.filename)).unwrap(); + data[loc.offset as usize..loc.offset as usize + loc.len].to_vec() + }) + .collect(); + Fixture { + file_set, + map, + recovery, + slices, + } +} + +fn padded(data: &[u8], slice_size: usize) -> Vec { + let mut v = data.to_vec(); + v.resize(slice_size, 0); + v +} + +/// Feed every slice not in `missing`, alternating single feeds and batches. +fn feed_present(r: &mut Repair, slices: &[Vec], missing: &[usize], rng: &mut Rng) { + let present: Vec = (0..slices.len()).filter(|i| !missing.contains(i)).collect(); + let mut rest = present.as_slice(); + while !rest.is_empty() { + let take = 1 + rng.below(rest.len().min(5)); + let (now, later) = rest.split_at(take); + if take == 1 { + r.feed(now[0], &slices[now[0]]).unwrap(); + } else { + let batch: Vec<(usize, &[u8])> = + now.iter().map(|&i| (i, slices[i].as_slice())).collect(); + r.feed_batch(&batch).unwrap(); + } + rest = later; + } +} + +#[test] +fn fixture_layout() { + let fx = load_multi(); + assert_eq!(fx.file_set.slice_size, 4096); + assert_eq!(fx.map.total_blocks, 12); + assert_eq!(fx.recovery.len(), 8); + let short: Vec = (0..12) + .filter(|&g| fx.map.global_to_file(g).unwrap().len < 4096) + .collect(); + assert!(!short.is_empty(), "fixture should have short last slices"); + assert!(fx.map.global_to_file(12).is_none()); +} + +#[test] +fn slices_match_ifsc_checksums() { + let fx = load_multi(); + for (g, data) in fx.slices.iter().enumerate() { + let loc = fx.map.global_to_file(g).unwrap(); + let file = &fx.file_set.files[&loc.file.file_id]; + assert!(file.slice_matches(loc.local_index as usize, data, 4096)); + assert_eq!( + SliceHasher::checksum(data, 4096), + file.slices[loc.local_index as usize] + ); + let mut bad = data.clone(); + bad[0] ^= 1; + assert!(!file.slice_matches(loc.local_index as usize, &bad, 4096)); + } +} + +/// Every damage count from 1 to the recovery count, several sets each, solved +/// from par2cmdline's recovery data. +#[test] +fn par2cmdline_fixture_round_trip() { + let fx = load_multi(); + let n = fx.slices.len(); + let mut rng = Rng(153); + for d in 1..=fx.recovery.len() { + for _ in 0..6 { + let missing = rng.choose(n, d); + let mut r = Repair::new(&fx.recovery, 4096, n).unwrap(); + assert_eq!(r.rows(), 8); + feed_present(&mut r, &fx.slices, &missing, &mut rng); + let out = r.solve(&missing).unwrap(); + assert_eq!(out.len(), d); + for &g in &missing { + let loc = fx.map.global_to_file(g).unwrap(); + assert_eq!( + out[&g], + padded(&fx.slices[g], 4096), + "slice {g} with missing {missing:?}" + ); + let file = &fx.file_set.files[&loc.file.file_id]; + assert!(file.slice_matches(loc.local_index as usize, &out[&g][..loc.len], 4096)); + } + } + } +} + +#[test] +fn par2cmdline_fixture_too_many_missing() { + let fx = load_multi(); + let n = fx.slices.len(); + let missing: Vec = (0..9).collect(); + let mut r = Repair::new(&fx.recovery, 4096, n).unwrap(); + feed_present(&mut r, &fx.slices, &missing, &mut Rng(1)); + match r.solve(&missing) { + Err(RepairError::InsufficientRecovery { + needed: 9, + available: 8, + }) => {} + other => panic!("expected InsufficientRecovery, got {other:?}"), + } +} + +#[test] +fn truncated_rows_still_solve_and_bound_errors() { + let fx = load_multi(); + let n = fx.slices.len(); + let missing = vec![2, 5, 8]; + let mut r = Repair::new_owning(fx.recovery.clone(), 4096, n).unwrap(); + r.truncate_rows(3); + assert_eq!(r.rows(), 3); + feed_present(&mut r, &fx.slices, &missing, &mut Rng(2)); + let out = r.solve(&missing).unwrap(); + for g in missing { + assert_eq!(out[&g], padded(&fx.slices[g], 4096)); + } + + let mut r = Repair::new(&fx.recovery, 4096, n).unwrap(); + r.truncate_rows(2); + let missing = vec![0, 1, 2]; + feed_present(&mut r, &fx.slices, &missing, &mut Rng(3)); + assert!(matches!( + r.solve(&missing), + Err(RepairError::InsufficientRecovery { + needed: 3, + available: 2 + }) + )); +} + +/// Damage files on disk, repair them with the file-based path, and check the +/// builder gives the same bytes for the same damage. +#[test] +fn file_repair_and_builder_agree() { + let fx = load_multi(); + let tmp = tempfile::tempdir().unwrap(); + for e in std::fs::read_dir(multi_dir()).unwrap() { + let e = e.unwrap(); + std::fs::copy(e.path(), tmp.path().join(e.file_name())).unwrap(); + } + + // Corrupt one slice in alpha and the short last slice of beta, and delete + // gamma entirely. + let damage = |name: &str, local: u32| { + let g = fx + .map + .global_to_file(fx.map.global_index(name, local).unwrap()) + .unwrap(); + let path = tmp.path().join(name); + let mut data = std::fs::read(&path).unwrap(); + data[g.offset as usize] ^= 0xFF; + std::fs::write(&path, data).unwrap(); + }; + damage("alpha.bin", 2); + let beta_last = fx.map.file("beta.bin").unwrap().block_count - 1; + damage("beta.bin", beta_last); + std::fs::remove_file(tmp.path().join("gamma.bin")).unwrap(); + + let gamma = fx.map.file("gamma.bin").unwrap(); + let mut missing = vec![ + fx.map.global_index("alpha.bin", 2).unwrap(), + fx.map.global_index("beta.bin", beta_last).unwrap(), + ]; + missing.extend((0..gamma.block_count).map(|i| (gamma.start_block + i) as usize)); + + let pre = rust_par2::verify(&fx.file_set, tmp.path()); + assert_eq!(pre.blocks_needed() as usize, missing.len()); + let result = rust_par2::repair_from_verify(&fx.file_set, tmp.path(), &pre).unwrap(); + assert!(result.success); + assert_eq!(result.blocks_repaired as usize, missing.len()); + + let mut r = Repair::new(&fx.recovery, 4096, fx.slices.len()).unwrap(); + feed_present(&mut r, &fx.slices, &missing, &mut Rng(4)); + let out = r.solve(&missing).unwrap(); + + let mut on_disk: HashMap> = HashMap::new(); + for &g in &missing { + let loc = fx.map.global_to_file(g).unwrap(); + let file = on_disk + .entry(loc.file.filename.clone()) + .or_insert_with(|| std::fs::read(tmp.path().join(&loc.file.filename)).unwrap()); + let start = loc.offset as usize; + assert_eq!(&file[start..start + loc.len], &out[&g][..loc.len]); + assert!(out[&g][loc.len..].iter().all(|&b| b == 0)); + } + for name in ["alpha.bin", "beta.bin", "gamma.bin"] { + assert_eq!( + std::fs::read(tmp.path().join(name)).unwrap(), + std::fs::read(multi_dir().join(name)).unwrap() + ); + } +} + +/// Same equivalence on the original single-file `damaged` fixture. +#[test] +fn file_repair_and_builder_agree_on_damaged_fixture() { + let src = Path::new("tests/fixtures/damaged"); + let tmp = tempfile::tempdir().unwrap(); + for e in std::fs::read_dir(src).unwrap() { + let e = e.unwrap(); + std::fs::copy(e.path(), tmp.path().join(e.file_name())).unwrap(); + } + let file_set = rust_par2::parse(&tmp.path().join("testdata.bin.par2")).unwrap(); + let map = BlockMap::new(&file_set); + let slice_size = file_set.slice_size as usize; + let pre = rust_par2::verify(&file_set, tmp.path()); + let damaged = &pre.damaged[0]; + let missing: Vec = damaged + .damaged_block_indices + .iter() + .map(|&l| map.global_index(&damaged.filename, l).unwrap()) + .collect(); + + let before = std::fs::read(tmp.path().join("testdata.bin")).unwrap(); + let recovery = load_recovery_blocks(tmp.path(), &file_set.recovery_set_id, file_set.slice_size); + let mut r = Repair::new_owning(recovery, slice_size, map.total_blocks as usize).unwrap(); + for g in 0..map.total_blocks as usize { + if !missing.contains(&g) { + let loc = map.global_to_file(g).unwrap(); + r.feed( + g, + &before[loc.offset as usize..loc.offset as usize + loc.len], + ) + .unwrap(); + } + } + let out = r.solve(&missing).unwrap(); + + rust_par2::repair_from_verify(&file_set, tmp.path(), &pre).unwrap(); + let after = std::fs::read(tmp.path().join("testdata.bin")).unwrap(); + for g in missing { + let loc = map.global_to_file(g).unwrap(); + let start = loc.offset as usize; + assert_eq!(&after[start..start + loc.len], &out[&g][..loc.len]); + } +} + +/// Encode random file layouts with random recovery exponents, damage up to the +/// recovery count, and check the builder reproduces the original bytes. +#[test] +fn property_random_layouts_and_damage() { + let mut rng = Rng(0x5EED); + for case in 0..60 { + let slice_size = 4 * (1 + rng.below(40)); // 4..=160, exercises SIMD tails + let file_count = 1 + rng.below(4); + let mut slices: Vec> = Vec::new(); + for _ in 0..file_count { + let len = 1 + rng.below(slice_size * 6); + let data = rng.bytes(len); + slices.extend(data.chunks(slice_size).map(<[u8]>::to_vec)); + } + let n = slices.len(); + let k = 1 + rng.below(8); + let mut exponents: Vec = rng.choose(40, k).into_iter().map(|e| e as u32).collect(); + exponents.sort_unstable(); + + let enc = GfMatrix::par2_encoding_matrix(n, &exponents); + let padded_slices: Vec> = slices.iter().map(|s| padded(s, slice_size)).collect(); + let srcs: Vec<&[u8]> = padded_slices.iter().map(Vec::as_slice).collect(); + let recovery: Vec = exponents + .iter() + .enumerate() + .map(|(row, &exponent)| { + let coeffs: Vec = (0..n).map(|col| enc.get(n + row, col)).collect(); + let mut data = vec![0u8; slice_size]; + rust_par2::gf_simd::mul_add_multi(&mut data, &srcs, &coeffs); + RecoveryBlock { exponent, data } + }) + .collect(); + + let d = 1 + rng.below(k.min(n)); + let missing = rng.choose(n, d); + let mut r = Repair::new_owning(recovery, slice_size, n).unwrap(); + feed_present(&mut r, &slices, &missing, &mut rng); + let out = r.solve(&missing).unwrap_or_else(|e| { + panic!("case {case}: n={n} k={k} exps={exponents:?} missing={missing:?}: {e}") + }); + for &g in &missing { + assert_eq!(out[&g], padded_slices[g], "case {case} slice {g}"); + } + } +} + +/// A corrupt slice fed once poisons the result and cannot be taken back: +/// re-feeding the good bytes is refused. Callers must verify before feeding. +#[test] +fn corrupt_feed_cannot_be_undone() { + let fx = load_multi(); + let n = fx.slices.len(); + let missing = vec![3]; + let mut r = Repair::new(&fx.recovery, 4096, n).unwrap(); + + let mut corrupt = fx.slices[0].clone(); + corrupt[100] ^= 0x55; + r.feed(0, &corrupt).unwrap(); + assert!(matches!( + r.feed(0, &fx.slices[0]), + Err(RepairError::DuplicateSlice { index: 0 }) + )); + for g in 1..n { + if g != 3 { + r.feed(g, &fx.slices[g]).unwrap(); + } + } + let out = r.solve(&missing).unwrap(); + assert_ne!(out[&3], padded(&fx.slices[3], 4096)); + + // The slice hasher would have caught it. + let loc = fx.map.global_to_file(0).unwrap(); + let file = &fx.file_set.files[&loc.file.file_id]; + assert!(!file.slice_matches(0, &corrupt, 4096)); +} + +#[test] +fn input_validation() { + let fx = load_multi(); + let n = fx.slices.len(); + let mut r = Repair::new(&fx.recovery, 4096, n).unwrap(); + + assert!(matches!( + r.feed(n, &fx.slices[0]), + Err(RepairError::SliceOutOfRange { index, total }) if index == n && total == n + )); + assert!(matches!( + r.feed(0, &[0u8; 4097]), + Err(RepairError::SliceTooLong { len: 4097, .. }) + )); + + // A bad batch feeds nothing, including the valid entries before the error. + let bad_batch: Vec<(usize, &[u8])> = + vec![(0, &fx.slices[0]), (1, &fx.slices[1]), (1, &fx.slices[1])]; + assert!(matches!( + r.feed_batch(&bad_batch), + Err(RepairError::DuplicateSlice { index: 1 }) + )); + assert_eq!(r.fed_count(), 0); + assert!(!r.is_fed(0)); + + // Slices neither fed nor missing. + r.feed(0, &fx.slices[0]).unwrap(); + assert!(matches!( + r.solve(&[1]), + Err(RepairError::IncompleteFeed { unaccounted }) if unaccounted == n - 2 + )); + // A fed slice cannot also be missing. + assert!(matches!( + r.solve(&[0]), + Err(RepairError::DuplicateSlice { index: 0 }) + )); + + assert!(matches!( + Repair::new(&fx.recovery, 4098, n), + Err(RepairError::InvalidSliceSize(4098)) + )); + assert!(matches!( + Repair::new(&fx.recovery, 8192, n), + Err(RepairError::InvalidRecoveryBlock { len: 4096, .. }) + )); +} + +#[test] +fn duplicate_recovery_exponents_are_dropped() { + let fx = load_multi(); + let mut doubled = fx.recovery.clone(); + doubled.extend(fx.recovery.iter().cloned()); + let r = Repair::new_owning(doubled, 4096, fx.slices.len()).unwrap(); + assert_eq!(r.rows(), 8); + assert!(r.exponents().windows(2).all(|w| w[0] < w[1])); +} + +#[test] +fn nothing_missing_solves_to_empty() { + let fx = load_multi(); + let mut r = Repair::new(&fx.recovery, 4096, fx.slices.len()).unwrap(); + feed_present(&mut r, &fx.slices, &[], &mut Rng(5)); + assert!(r.solve(&[]).unwrap().is_empty()); +}