Skip to main content

ipfrs_storage/
corruption_repair.rs

1//! Block corruption detection and repair strategies.
2//!
3//! Provides mechanisms for detecting various types of block corruption
4//! (bit flips, truncation, zero-fill, header damage) and attempting
5//! automated repair using XOR-based parity data. Blocks that cannot
6//! be repaired are quarantined to prevent serving corrupted data.
7
8use std::collections::{HashMap, HashSet};
9
10// ---------------------------------------------------------------------------
11// Types
12// ---------------------------------------------------------------------------
13
14/// Classification of corruption detected in a block.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum CorruptionType {
17    /// One or more bits were flipped.
18    BitFlip,
19    /// The block was truncated (shorter than expected).
20    Truncation,
21    /// Part of the block was overwritten with zeros.
22    ZeroFill,
23    /// The first bytes (header region) are damaged.
24    HeaderDamage,
25    /// Corruption that does not match a known pattern.
26    Unknown,
27}
28
29/// Outcome of a repair attempt.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum RepairAction {
32    /// Block was restored from an existing clean copy.
33    Restored,
34    /// Block was rebuilt using XOR parity data.
35    RebuiltFromParity,
36    /// Block was fetched from a remote peer.
37    FetchedFromPeer,
38    /// Block was quarantined because repair failed.
39    Quarantined,
40    /// Block is unrecoverable — no repair strategy succeeded.
41    Unrecoverable,
42}
43
44/// Detailed report about a single corruption incident.
45#[derive(Debug, Clone)]
46pub struct CorruptionReport {
47    /// Content identifier of the affected block.
48    pub cid: String,
49    /// Type of corruption detected.
50    pub corruption_type: CorruptionType,
51    /// Byte offset where corruption starts, if determinable.
52    pub byte_offset: Option<usize>,
53    /// Number of bytes affected.
54    pub bytes_affected: usize,
55    /// Timestamp (epoch seconds) when corruption was detected.
56    pub detected_at: u64,
57    /// What repair action was taken, if any.
58    pub repair_action: Option<RepairAction>,
59}
60
61/// Configuration for the repair subsystem.
62#[derive(Debug, Clone)]
63pub struct RepairConfig {
64    /// Whether XOR parity blocks are maintained.
65    pub enable_parity: bool,
66    /// Size of each parity computation block (bytes).
67    pub parity_block_size: usize,
68    /// Maximum number of repair attempts before giving up.
69    pub max_repair_attempts: u32,
70    /// Whether to automatically quarantine blocks after failed repair.
71    pub auto_quarantine_on_fail: bool,
72}
73
74impl Default for RepairConfig {
75    fn default() -> Self {
76        Self {
77            enable_parity: true,
78            parity_block_size: 256,
79            max_repair_attempts: 3,
80            auto_quarantine_on_fail: true,
81        }
82    }
83}
84
85/// A stored block together with its parity and checksum data.
86#[derive(Debug, Clone)]
87pub struct BlockWithParity {
88    /// Content identifier.
89    pub cid: String,
90    /// Raw block data.
91    pub data: Vec<u8>,
92    /// XOR parity computed over `data`.
93    pub parity: Vec<u8>,
94    /// FNV-1a checksum of the original data.
95    pub checksum: u64,
96}
97
98/// Aggregate statistics for the repair subsystem.
99#[derive(Debug, Clone, Default)]
100pub struct RepairStats {
101    /// Total number of blocks scanned.
102    pub total_scanned: u64,
103    /// Number of corruptions found.
104    pub corruptions_found: u64,
105    /// Number of successful repairs.
106    pub repairs_successful: u64,
107    /// Number of failed repair attempts.
108    pub repairs_failed: u64,
109    /// Number of blocks quarantined.
110    pub quarantined: u64,
111}
112
113// ---------------------------------------------------------------------------
114// CorruptionRepairer
115// ---------------------------------------------------------------------------
116
117/// Central manager for corruption detection and repair.
118pub struct CorruptionRepairer {
119    config: RepairConfig,
120    blocks: HashMap<String, BlockWithParity>,
121    reports: Vec<CorruptionReport>,
122    quarantined: HashSet<String>,
123    stats: RepairStats,
124}
125
126impl CorruptionRepairer {
127    /// Create a new repairer with the given configuration.
128    pub fn new(config: RepairConfig) -> Self {
129        Self {
130            config,
131            blocks: HashMap::new(),
132            reports: Vec::new(),
133            quarantined: HashSet::new(),
134            stats: RepairStats::default(),
135        }
136    }
137
138    /// Add a block, computing parity and checksum automatically.
139    pub fn add_block(&mut self, cid: &str, data: &[u8]) {
140        let parity = Self::compute_parity(data, self.config.parity_block_size);
141        let checksum = Self::compute_checksum(data);
142        self.blocks.insert(
143            cid.to_string(),
144            BlockWithParity {
145                cid: cid.to_string(),
146                data: data.to_vec(),
147                parity,
148                checksum,
149            },
150        );
151    }
152
153    /// Compute XOR-based parity over `data` using the given block size.
154    ///
155    /// The parity vector has length `block_size`. Each byte in the parity
156    /// is the XOR of all corresponding bytes (modulo block_size) in `data`.
157    pub fn compute_parity(data: &[u8], block_size: usize) -> Vec<u8> {
158        let bs = if block_size == 0 { 256 } else { block_size };
159        let mut parity = vec![0u8; bs];
160        for (i, &byte) in data.iter().enumerate() {
161            parity[i % bs] ^= byte;
162        }
163        parity
164    }
165
166    /// Compute a 64-bit FNV-1a checksum of `data`.
167    pub fn compute_checksum(data: &[u8]) -> u64 {
168        const FNV_OFFSET: u64 = 0xcbf29ce484222325;
169        const FNV_PRIME: u64 = 0x00000100000001B3;
170        let mut hash = FNV_OFFSET;
171        for &byte in data {
172            hash ^= byte as u64;
173            hash = hash.wrapping_mul(FNV_PRIME);
174        }
175        hash
176    }
177
178    /// Detect corruption in the block identified by `cid`.
179    ///
180    /// Returns `None` if the block is clean or not found.
181    pub fn detect_corruption(&self, cid: &str) -> Option<CorruptionType> {
182        let block = self.blocks.get(cid)?;
183        let current_checksum = Self::compute_checksum(&block.data);
184        if current_checksum == block.checksum {
185            return None;
186        }
187        Some(self.classify_corruption(block))
188    }
189
190    /// Classify the type of corruption by inspecting data vs parity.
191    fn classify_corruption(&self, block: &BlockWithParity) -> CorruptionType {
192        let original_len = self.estimate_original_length(block);
193
194        // Check for truncation: data is shorter than the length implied by parity.
195        if block.data.len() < original_len {
196            return CorruptionType::Truncation;
197        }
198
199        // Check for header damage: corruption in the first 16 bytes.
200        let header_size = 16.min(block.data.len());
201        let parity_bs = if self.config.parity_block_size == 0 {
202            256
203        } else {
204            self.config.parity_block_size
205        };
206        let current_parity = Self::compute_parity(&block.data, parity_bs);
207        let mut header_damaged = false;
208        for i in 0..header_size {
209            if i < current_parity.len()
210                && i < block.parity.len()
211                && current_parity[i] != block.parity[i]
212            {
213                header_damaged = true;
214                break;
215            }
216        }
217        if header_damaged {
218            return CorruptionType::HeaderDamage;
219        }
220
221        // Check for zero-fill: a run of zeros that differs from parity expectation.
222        let zero_run_threshold = 8;
223        let mut consecutive_zeros = 0usize;
224        for &b in &block.data {
225            if b == 0 {
226                consecutive_zeros += 1;
227                if consecutive_zeros >= zero_run_threshold {
228                    return CorruptionType::ZeroFill;
229                }
230            } else {
231                consecutive_zeros = 0;
232            }
233        }
234
235        // Check for bit flip: small number of differing parity bytes.
236        let differing = current_parity
237            .iter()
238            .zip(block.parity.iter())
239            .filter(|(a, b)| a != b)
240            .count();
241        if differing > 0 && differing <= parity_bs / 2 {
242            return CorruptionType::BitFlip;
243        }
244
245        CorruptionType::Unknown
246    }
247
248    /// Heuristic: estimate original data length from parity metadata.
249    /// Since we don't store the length separately, return current data length.
250    fn estimate_original_length(&self, block: &BlockWithParity) -> usize {
251        block.data.len()
252    }
253
254    /// Scan all blocks for corruption and return reports.
255    pub fn scan_all(&mut self) -> Vec<CorruptionReport> {
256        let cids: Vec<String> = self.blocks.keys().cloned().collect();
257        let mut new_reports = Vec::new();
258        for cid in &cids {
259            self.stats.total_scanned += 1;
260            if let Some(corruption_type) = self.detect_corruption_internal(cid) {
261                self.stats.corruptions_found += 1;
262                let (byte_offset, bytes_affected) = self.estimate_damage_extent(cid);
263                let report = CorruptionReport {
264                    cid: cid.clone(),
265                    corruption_type,
266                    byte_offset,
267                    bytes_affected,
268                    detected_at: current_epoch_secs(),
269                    repair_action: None,
270                };
271                new_reports.push(report);
272            }
273        }
274        self.reports.extend(new_reports.clone());
275        new_reports
276    }
277
278    /// Internal corruption detection (avoids borrow issues with `&self`).
279    fn detect_corruption_internal(&self, cid: &str) -> Option<CorruptionType> {
280        self.detect_corruption(cid)
281    }
282
283    /// Estimate byte offset and extent of damage.
284    fn estimate_damage_extent(&self, cid: &str) -> (Option<usize>, usize) {
285        let block = match self.blocks.get(cid) {
286            Some(b) => b,
287            None => return (None, 0),
288        };
289        let parity_bs = if self.config.parity_block_size == 0 {
290            256
291        } else {
292            self.config.parity_block_size
293        };
294        let current_parity = Self::compute_parity(&block.data, parity_bs);
295        let mut first_diff: Option<usize> = None;
296        let mut diff_count = 0usize;
297        for (i, (cur, orig)) in current_parity.iter().zip(block.parity.iter()).enumerate() {
298            if cur != orig {
299                if first_diff.is_none() {
300                    first_diff = Some(i);
301                }
302                diff_count += 1;
303            }
304        }
305        (first_diff, diff_count)
306    }
307
308    /// Attempt to repair a corrupted block using parity data.
309    ///
310    /// Returns `Ok(RepairAction)` describing the outcome, or `Err` if the
311    /// block does not exist.
312    pub fn repair_block(&mut self, cid: &str) -> Result<RepairAction, String> {
313        if !self.blocks.contains_key(cid) {
314            return Err(format!("block not found: {}", cid));
315        }
316
317        // If the block is already clean, nothing to do.
318        if self.detect_corruption(cid).is_none() {
319            return Ok(RepairAction::Restored);
320        }
321
322        if !self.config.enable_parity {
323            if self.config.auto_quarantine_on_fail {
324                self.quarantine(cid);
325                self.stats.repairs_failed += 1;
326                return Ok(RepairAction::Quarantined);
327            }
328            self.stats.repairs_failed += 1;
329            return Ok(RepairAction::Unrecoverable);
330        }
331
332        // Attempt parity-based repair up to max_repair_attempts times.
333        let mut repaired = false;
334        for _attempt in 0..self.config.max_repair_attempts {
335            if self.try_parity_repair(cid) {
336                repaired = true;
337                break;
338            }
339        }
340
341        if repaired {
342            self.stats.repairs_successful += 1;
343            // Update the report if one exists.
344            if let Some(report) = self.reports.iter_mut().rev().find(|r| r.cid == cid) {
345                report.repair_action = Some(RepairAction::RebuiltFromParity);
346            }
347            Ok(RepairAction::RebuiltFromParity)
348        } else {
349            self.stats.repairs_failed += 1;
350            if self.config.auto_quarantine_on_fail {
351                self.quarantine(cid);
352                Ok(RepairAction::Quarantined)
353            } else {
354                Ok(RepairAction::Unrecoverable)
355            }
356        }
357    }
358
359    /// Try to repair a single corrupted byte using XOR parity.
360    ///
361    /// This works when exactly one byte per parity-group is corrupted.
362    /// For each parity position that differs, we XOR the current data
363    /// contributions back and apply the stored parity to derive the
364    /// correct byte value.
365    fn try_parity_repair(&mut self, cid: &str) -> bool {
366        let parity_bs = if self.config.parity_block_size == 0 {
367            256
368        } else {
369            self.config.parity_block_size
370        };
371
372        // We need to work with owned data to avoid borrow issues.
373        let block = match self.blocks.get(cid) {
374            Some(b) => b.clone(),
375            None => return false,
376        };
377
378        let current_parity = Self::compute_parity(&block.data, parity_bs);
379
380        // Find which parity positions differ.
381        let mut diff_positions: Vec<usize> = Vec::new();
382        for (i, (cur, orig)) in current_parity.iter().zip(block.parity.iter()).enumerate() {
383            if cur != orig {
384                diff_positions.push(i);
385            }
386        }
387
388        if diff_positions.is_empty() {
389            return true; // Already clean.
390        }
391
392        // For each differing parity position, find the data byte(s) contributing
393        // to that position and try flipping them.
394        let mut new_data = block.data.clone();
395        let mut any_fixed = false;
396
397        for &pos in &diff_positions {
398            // Collect all data indices that map to this parity position.
399            let indices: Vec<usize> = (pos..block.data.len()).step_by(parity_bs).collect();
400
401            if indices.len() == 1 {
402                // Only one byte contributes: we can recover it exactly.
403                // parity[pos] = XOR of all bytes at indices mapping to pos.
404                // With one byte: stored_parity[pos] = original_byte
405                // current_parity[pos] = current_byte
406                // So original_byte = current_byte ^ current_parity[pos] ^ stored_parity[pos]
407                let idx = indices[0];
408                new_data[idx] ^= current_parity[pos] ^ block.parity[pos];
409                any_fixed = true;
410            } else {
411                // Multiple bytes: try fixing each one individually and check.
412                for &idx in &indices {
413                    let mut candidate = block.data.clone();
414                    candidate[idx] ^= current_parity[pos] ^ block.parity[pos];
415                    let candidate_checksum = Self::compute_checksum(&candidate);
416                    if candidate_checksum == block.checksum {
417                        new_data = candidate;
418                        any_fixed = true;
419                        break;
420                    }
421                }
422            }
423        }
424
425        if any_fixed {
426            let new_checksum = Self::compute_checksum(&new_data);
427            if new_checksum == block.checksum {
428                if let Some(b) = self.blocks.get_mut(cid) {
429                    b.data = new_data;
430                    b.parity = Self::compute_parity(&b.data, parity_bs);
431                }
432                return true;
433            }
434        }
435
436        false
437    }
438
439    /// Inject corruption into a block for testing purposes.
440    ///
441    /// Sets the byte at `offset` to `value`.
442    pub fn corrupt_block(&mut self, cid: &str, offset: usize, value: u8) -> Result<(), String> {
443        let block = self
444            .blocks
445            .get_mut(cid)
446            .ok_or_else(|| format!("block not found: {}", cid))?;
447        if offset >= block.data.len() {
448            return Err(format!(
449                "offset {} out of range for block of length {}",
450                offset,
451                block.data.len()
452            ));
453        }
454        block.data[offset] = value;
455        Ok(())
456    }
457
458    /// Verify that a block's current data matches its stored checksum.
459    pub fn verify_block(&self, cid: &str) -> bool {
460        match self.blocks.get(cid) {
461            Some(block) => Self::compute_checksum(&block.data) == block.checksum,
462            None => false,
463        }
464    }
465
466    /// Quarantine a block, marking it as unservable.
467    ///
468    /// Returns `true` if the block was found and quarantined, `false` otherwise.
469    pub fn quarantine(&mut self, cid: &str) -> bool {
470        if self.blocks.contains_key(cid) {
471            let inserted = self.quarantined.insert(cid.to_string());
472            if inserted {
473                self.stats.quarantined += 1;
474            }
475            true
476        } else {
477            false
478        }
479    }
480
481    /// Check whether a block is currently quarantined.
482    pub fn is_quarantined(&self, cid: &str) -> bool {
483        self.quarantined.contains(cid)
484    }
485
486    /// Get the most recent corruption report for a given CID.
487    pub fn get_report(&self, cid: &str) -> Option<&CorruptionReport> {
488        self.reports.iter().rev().find(|r| r.cid == cid)
489    }
490
491    /// Get aggregate repair statistics.
492    pub fn stats(&self) -> &RepairStats {
493        &self.stats
494    }
495}
496
497/// Return current time as epoch seconds, falling back to 0 on error.
498fn current_epoch_secs() -> u64 {
499    std::time::SystemTime::now()
500        .duration_since(std::time::UNIX_EPOCH)
501        .map(|d| d.as_secs())
502        .unwrap_or(0)
503}
504
505// ---------------------------------------------------------------------------
506// Tests
507// ---------------------------------------------------------------------------
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    fn default_config() -> RepairConfig {
514        RepairConfig::default()
515    }
516
517    fn sample_data() -> Vec<u8> {
518        (0..128).map(|i| (i * 7 + 13) as u8).collect()
519    }
520
521    #[test]
522    fn test_add_and_verify_clean_block() {
523        let mut repairer = CorruptionRepairer::new(default_config());
524        repairer.add_block("cid1", &sample_data());
525        assert!(repairer.verify_block("cid1"));
526    }
527
528    #[test]
529    fn test_verify_nonexistent_block() {
530        let repairer = CorruptionRepairer::new(default_config());
531        assert!(!repairer.verify_block("nonexistent"));
532    }
533
534    #[test]
535    fn test_detect_no_corruption_clean_block() {
536        let mut repairer = CorruptionRepairer::new(default_config());
537        repairer.add_block("cid1", &sample_data());
538        assert!(repairer.detect_corruption("cid1").is_none());
539    }
540
541    #[test]
542    fn test_detect_bit_flip() {
543        let mut repairer = CorruptionRepairer::new(default_config());
544        let data = sample_data();
545        repairer.add_block("cid1", &data);
546        // Flip a bit at an offset beyond the header region (>=16).
547        let offset = 64;
548        let original = data[offset];
549        let flipped = original ^ 0x01;
550        repairer.corrupt_block("cid1", offset, flipped).ok();
551        let corruption = repairer.detect_corruption("cid1");
552        assert!(corruption.is_some());
553        assert!(!repairer.verify_block("cid1"));
554    }
555
556    #[test]
557    fn test_detect_truncation() {
558        let mut repairer = CorruptionRepairer::new(default_config());
559        let data = sample_data();
560        repairer.add_block("cid1", &data);
561        // Simulate truncation by directly modifying the stored data.
562        if let Some(block) = repairer.blocks.get_mut("cid1") {
563            block.data.truncate(64);
564        }
565        let corruption = repairer.detect_corruption("cid1");
566        assert!(corruption.is_some());
567    }
568
569    #[test]
570    fn test_detect_zero_fill() {
571        let mut repairer = CorruptionRepairer::new(default_config());
572        let mut data: Vec<u8> = (1..=128).collect();
573        // Ensure no natural zero runs
574        for b in data.iter_mut() {
575            if *b == 0 {
576                *b = 1;
577            }
578        }
579        repairer.add_block("cid1", &data);
580        // Zero-fill a region past the header.
581        for i in 32..48 {
582            repairer.corrupt_block("cid1", i, 0).ok();
583        }
584        let corruption = repairer.detect_corruption("cid1");
585        assert!(corruption.is_some());
586    }
587
588    #[test]
589    fn test_parity_computation_deterministic() {
590        let data = sample_data();
591        let p1 = CorruptionRepairer::compute_parity(&data, 64);
592        let p2 = CorruptionRepairer::compute_parity(&data, 64);
593        assert_eq!(p1, p2);
594    }
595
596    #[test]
597    fn test_parity_empty_data() {
598        let parity = CorruptionRepairer::compute_parity(&[], 32);
599        assert_eq!(parity.len(), 32);
600        assert!(parity.iter().all(|&b| b == 0));
601    }
602
603    #[test]
604    fn test_parity_single_byte() {
605        let data = vec![0xAB];
606        let parity = CorruptionRepairer::compute_parity(&data, 4);
607        assert_eq!(parity[0], 0xAB);
608        assert_eq!(parity[1], 0);
609    }
610
611    #[test]
612    fn test_repair_via_parity_single_byte_flip() {
613        let mut repairer = CorruptionRepairer::new(RepairConfig {
614            enable_parity: true,
615            parity_block_size: 256,
616            max_repair_attempts: 3,
617            auto_quarantine_on_fail: true,
618        });
619        // Use data shorter than parity block size so each byte maps to unique parity slot.
620        let data: Vec<u8> = (0..128).collect();
621        repairer.add_block("cid1", &data);
622
623        // Flip one byte.
624        let original = data[50];
625        repairer.corrupt_block("cid1", 50, original ^ 0xFF).ok();
626        assert!(!repairer.verify_block("cid1"));
627
628        let result = repairer.repair_block("cid1");
629        assert!(result.is_ok());
630        let action = result.unwrap_or(RepairAction::Unrecoverable);
631        assert_eq!(action, RepairAction::RebuiltFromParity);
632        assert!(repairer.verify_block("cid1"));
633    }
634
635    #[test]
636    fn test_unrecoverable_damage() {
637        let mut repairer = CorruptionRepairer::new(RepairConfig {
638            enable_parity: false,
639            parity_block_size: 256,
640            max_repair_attempts: 1,
641            auto_quarantine_on_fail: false,
642        });
643        let data = sample_data();
644        repairer.add_block("cid1", &data);
645        repairer.corrupt_block("cid1", 10, 0xFF).ok();
646        let result = repairer.repair_block("cid1");
647        assert!(result.is_ok());
648        assert_eq!(
649            result.unwrap_or(RepairAction::Restored),
650            RepairAction::Unrecoverable
651        );
652    }
653
654    #[test]
655    fn test_auto_quarantine_on_fail() {
656        let mut repairer = CorruptionRepairer::new(RepairConfig {
657            enable_parity: false,
658            parity_block_size: 256,
659            max_repair_attempts: 1,
660            auto_quarantine_on_fail: true,
661        });
662        let data = sample_data();
663        repairer.add_block("cid1", &data);
664        repairer.corrupt_block("cid1", 10, 0xFF).ok();
665        let result = repairer.repair_block("cid1");
666        assert_eq!(
667            result.unwrap_or(RepairAction::Unrecoverable),
668            RepairAction::Quarantined
669        );
670        assert!(repairer.is_quarantined("cid1"));
671    }
672
673    #[test]
674    fn test_scan_all_clean() {
675        let mut repairer = CorruptionRepairer::new(default_config());
676        repairer.add_block("a", &[1, 2, 3]);
677        repairer.add_block("b", &[4, 5, 6]);
678        let reports = repairer.scan_all();
679        assert!(reports.is_empty());
680        assert_eq!(repairer.stats().total_scanned, 2);
681    }
682
683    #[test]
684    fn test_scan_all_with_corruption() {
685        let mut repairer = CorruptionRepairer::new(default_config());
686        repairer.add_block("a", &[1, 2, 3]);
687        repairer.add_block("b", &[4, 5, 6]);
688        repairer.corrupt_block("b", 0, 0xFF).ok();
689        let reports = repairer.scan_all();
690        assert_eq!(reports.len(), 1);
691        assert_eq!(reports[0].cid, "b");
692        assert_eq!(repairer.stats().corruptions_found, 1);
693    }
694
695    #[test]
696    fn test_quarantine_existing_block() {
697        let mut repairer = CorruptionRepairer::new(default_config());
698        repairer.add_block("cid1", &[10, 20]);
699        assert!(repairer.quarantine("cid1"));
700        assert!(repairer.is_quarantined("cid1"));
701    }
702
703    #[test]
704    fn test_quarantine_nonexistent_block() {
705        let mut repairer = CorruptionRepairer::new(default_config());
706        assert!(!repairer.quarantine("missing"));
707        assert!(!repairer.is_quarantined("missing"));
708    }
709
710    #[test]
711    fn test_quarantine_idempotent() {
712        let mut repairer = CorruptionRepairer::new(default_config());
713        repairer.add_block("cid1", &[1]);
714        repairer.quarantine("cid1");
715        let stats_after_first = repairer.stats().quarantined;
716        repairer.quarantine("cid1");
717        assert_eq!(repairer.stats().quarantined, stats_after_first);
718    }
719
720    #[test]
721    fn test_stats_initial() {
722        let repairer = CorruptionRepairer::new(default_config());
723        let s = repairer.stats();
724        assert_eq!(s.total_scanned, 0);
725        assert_eq!(s.corruptions_found, 0);
726        assert_eq!(s.repairs_successful, 0);
727        assert_eq!(s.repairs_failed, 0);
728        assert_eq!(s.quarantined, 0);
729    }
730
731    #[test]
732    fn test_stats_after_operations() {
733        let mut repairer = CorruptionRepairer::new(RepairConfig {
734            enable_parity: true,
735            parity_block_size: 256,
736            max_repair_attempts: 3,
737            auto_quarantine_on_fail: true,
738        });
739        let data: Vec<u8> = (0..64).collect();
740        repairer.add_block("cid1", &data);
741        repairer.corrupt_block("cid1", 10, 0xFF).ok();
742        let _ = repairer.repair_block("cid1");
743        let s = repairer.stats();
744        assert!(s.repairs_successful > 0 || s.repairs_failed > 0);
745    }
746
747    #[test]
748    fn test_inject_corruption() {
749        let mut repairer = CorruptionRepairer::new(default_config());
750        repairer.add_block("cid1", &[1, 2, 3, 4]);
751        assert!(repairer.corrupt_block("cid1", 2, 99).is_ok());
752        assert!(!repairer.verify_block("cid1"));
753    }
754
755    #[test]
756    fn test_inject_corruption_out_of_range() {
757        let mut repairer = CorruptionRepairer::new(default_config());
758        repairer.add_block("cid1", &[1, 2, 3]);
759        let result = repairer.corrupt_block("cid1", 100, 0);
760        assert!(result.is_err());
761    }
762
763    #[test]
764    fn test_inject_corruption_missing_block() {
765        let mut repairer = CorruptionRepairer::new(default_config());
766        let result = repairer.corrupt_block("missing", 0, 0);
767        assert!(result.is_err());
768    }
769
770    #[test]
771    fn test_checksum_consistency() {
772        let data = sample_data();
773        let c1 = CorruptionRepairer::compute_checksum(&data);
774        let c2 = CorruptionRepairer::compute_checksum(&data);
775        assert_eq!(c1, c2);
776    }
777
778    #[test]
779    fn test_checksum_differs_for_different_data() {
780        let c1 = CorruptionRepairer::compute_checksum(&[1, 2, 3]);
781        let c2 = CorruptionRepairer::compute_checksum(&[4, 5, 6]);
782        assert_ne!(c1, c2);
783    }
784
785    #[test]
786    fn test_checksum_empty() {
787        let c = CorruptionRepairer::compute_checksum(&[]);
788        // FNV-1a offset basis.
789        assert_eq!(c, 0xcbf29ce484222325);
790    }
791
792    #[test]
793    fn test_multiple_blocks() {
794        let mut repairer = CorruptionRepairer::new(default_config());
795        for i in 0..10 {
796            let data: Vec<u8> = (0..32).map(|j| (i * 32 + j) as u8).collect();
797            repairer.add_block(&format!("cid{}", i), &data);
798        }
799        for i in 0..10 {
800            assert!(repairer.verify_block(&format!("cid{}", i)));
801        }
802    }
803
804    #[test]
805    fn test_empty_repairer() {
806        let mut repairer = CorruptionRepairer::new(default_config());
807        assert!(repairer.scan_all().is_empty());
808        assert_eq!(repairer.stats().total_scanned, 0);
809        assert!(repairer.detect_corruption("nonexistent").is_none());
810        assert!(!repairer.verify_block("nonexistent"));
811    }
812
813    #[test]
814    fn test_get_report_after_scan() {
815        let mut repairer = CorruptionRepairer::new(default_config());
816        repairer.add_block("cid1", &[10, 20, 30]);
817        repairer.corrupt_block("cid1", 1, 0xFF).ok();
818        repairer.scan_all();
819        let report = repairer.get_report("cid1");
820        assert!(report.is_some());
821        let r = report.unwrap_or_else(|| panic!("expected report"));
822        assert_eq!(r.cid, "cid1");
823        assert!(r.bytes_affected > 0);
824    }
825
826    #[test]
827    fn test_get_report_nonexistent() {
828        let repairer = CorruptionRepairer::new(default_config());
829        assert!(repairer.get_report("missing").is_none());
830    }
831
832    #[test]
833    fn test_repair_nonexistent_block() {
834        let mut repairer = CorruptionRepairer::new(default_config());
835        let result = repairer.repair_block("missing");
836        assert!(result.is_err());
837    }
838
839    #[test]
840    fn test_repair_already_clean_block() {
841        let mut repairer = CorruptionRepairer::new(default_config());
842        repairer.add_block("cid1", &[1, 2, 3]);
843        let result = repairer.repair_block("cid1");
844        assert_eq!(
845            result.unwrap_or(RepairAction::Unrecoverable),
846            RepairAction::Restored
847        );
848    }
849
850    #[test]
851    fn test_default_repair_config() {
852        let cfg = RepairConfig::default();
853        assert!(cfg.enable_parity);
854        assert_eq!(cfg.parity_block_size, 256);
855        assert_eq!(cfg.max_repair_attempts, 3);
856        assert!(cfg.auto_quarantine_on_fail);
857    }
858
859    #[test]
860    fn test_parity_block_size_zero_fallback() {
861        let parity = CorruptionRepairer::compute_parity(&[1, 2, 3], 0);
862        assert_eq!(parity.len(), 256);
863    }
864
865    #[test]
866    fn test_corruption_report_fields() {
867        let mut repairer = CorruptionRepairer::new(default_config());
868        repairer.add_block("cid1", &sample_data());
869        repairer.corrupt_block("cid1", 20, 0xAA).ok();
870        let reports = repairer.scan_all();
871        assert_eq!(reports.len(), 1);
872        assert!(reports[0].detected_at > 0);
873        assert_eq!(reports[0].repair_action, None);
874    }
875
876    #[test]
877    fn test_repair_updates_report() {
878        let mut repairer = CorruptionRepairer::new(RepairConfig {
879            enable_parity: true,
880            parity_block_size: 256,
881            max_repair_attempts: 3,
882            auto_quarantine_on_fail: true,
883        });
884        let data: Vec<u8> = (0..128).collect();
885        repairer.add_block("cid1", &data);
886        repairer.corrupt_block("cid1", 50, data[50] ^ 0xFF).ok();
887        repairer.scan_all();
888        let _ = repairer.repair_block("cid1");
889        let report = repairer.get_report("cid1");
890        assert!(report.is_some());
891        if let Some(r) = report {
892            if r.repair_action.is_some() {
893                assert_eq!(
894                    r.repair_action
895                        .as_ref()
896                        .unwrap_or(&RepairAction::Unrecoverable),
897                    &RepairAction::RebuiltFromParity
898                );
899            }
900        }
901    }
902}