Skip to main content

ipfrs_storage/
index_recovery.rs

1//! Storage index recovery from corrupt or missing state by scanning raw block data.
2//!
3//! This module provides tools to reconstruct a storage index by scanning raw block
4//! data when the primary index is corrupt, missing, or out of sync. Uses FNV-1a
5//! checksums for fast integrity verification.
6
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// Public types
11// ---------------------------------------------------------------------------
12
13/// Current phase of the recovery process.
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
15pub enum RecoveryStatus {
16    #[default]
17    NotStarted,
18    Scanning,
19    Rebuilding,
20    Verifying,
21    Completed,
22    Failed(String),
23}
24
25/// A single recovered index entry describing one block.
26#[derive(Debug, Clone)]
27pub struct IndexEntry {
28    /// Content identifier of the block.
29    pub cid: String,
30    /// Size of the block data in bytes.
31    pub size: u64,
32    /// FNV-1a checksum of the block data.
33    pub checksum: u64,
34    /// Byte offset at which the block was found in the raw storage.
35    pub offset: u64,
36    /// Unix timestamp (seconds) at which this entry was recovered.
37    pub recovered_at: u64,
38}
39
40/// Configuration for the recovery process.
41#[derive(Debug, Clone)]
42pub struct RecoveryConfig {
43    /// Whether to verify checksums during scanning.
44    pub verify_checksums: bool,
45    /// Whether to skip corrupted blocks instead of halting.
46    pub skip_corrupted: bool,
47    /// Maximum recursion / nesting depth when scanning.
48    pub max_scan_depth: usize,
49    /// Number of blocks to process per batch.
50    pub batch_size: usize,
51}
52
53impl Default for RecoveryConfig {
54    fn default() -> Self {
55        RecoveryConfig {
56            verify_checksums: true,
57            skip_corrupted: true,
58            max_scan_depth: 64,
59            batch_size: 256,
60        }
61    }
62}
63
64/// A raw block as found in storage — CID string, raw bytes, and byte offset.
65#[derive(Debug, Clone)]
66pub struct RawBlock {
67    pub cid: String,
68    pub data: Vec<u8>,
69    pub offset: u64,
70}
71
72/// Aggregate statistics gathered during a recovery run.
73#[derive(Debug, Clone, Default)]
74pub struct RecoveryStats {
75    pub blocks_scanned: u64,
76    pub blocks_recovered: u64,
77    pub blocks_skipped: u64,
78    pub bytes_recovered: u64,
79    pub checksum_failures: u64,
80}
81
82// ---------------------------------------------------------------------------
83// IndexRecovery
84// ---------------------------------------------------------------------------
85
86/// Main recovery engine. Scans raw blocks and rebuilds the in-memory index.
87pub struct IndexRecovery {
88    config: RecoveryConfig,
89    status: RecoveryStatus,
90    recovered_index: HashMap<String, IndexEntry>,
91    errors: Vec<String>,
92    stats: RecoveryStats,
93}
94
95impl IndexRecovery {
96    // -----------------------------------------------------------------------
97    // Construction
98    // -----------------------------------------------------------------------
99
100    /// Create a new recovery engine with the given configuration.
101    pub fn new(config: RecoveryConfig) -> Self {
102        IndexRecovery {
103            config,
104            status: RecoveryStatus::NotStarted,
105            recovered_index: HashMap::new(),
106            errors: Vec::new(),
107            stats: RecoveryStats::default(),
108        }
109    }
110
111    // -----------------------------------------------------------------------
112    // Core FNV-1a checksum
113    // -----------------------------------------------------------------------
114
115    /// Compute the FNV-1a 64-bit checksum of `data`.
116    ///
117    /// Reference: <https://tools.ietf.org/html/draft-eastlake-fnv-17>
118    pub fn compute_checksum(data: &[u8]) -> u64 {
119        const FNV_OFFSET_BASIS: u64 = 14695981039346656037;
120        const FNV_PRIME: u64 = 1099511628211;
121
122        let mut hash = FNV_OFFSET_BASIS;
123        for byte in data {
124            hash ^= u64::from(*byte);
125            hash = hash.wrapping_mul(FNV_PRIME);
126        }
127        hash
128    }
129
130    // -----------------------------------------------------------------------
131    // Block scanning
132    // -----------------------------------------------------------------------
133
134    /// Scan a single raw block and produce an [`IndexEntry`], or return an
135    /// error string describing why the block could not be recovered.
136    ///
137    /// This method updates the internal status, index, and stats.
138    pub fn scan_block(&mut self, block: RawBlock) -> Result<IndexEntry, String> {
139        // Transition to Scanning on first use
140        if self.status == RecoveryStatus::NotStarted {
141            self.status = RecoveryStatus::Scanning;
142        }
143
144        self.stats.blocks_scanned += 1;
145
146        // Reject empty CIDs immediately
147        if block.cid.is_empty() {
148            let msg = format!("block at offset {} has empty CID", block.offset);
149            self.errors.push(msg.clone());
150            self.stats.blocks_skipped += 1;
151            if self.config.skip_corrupted {
152                return Err(msg);
153            }
154            self.status = RecoveryStatus::Failed(msg.clone());
155            return Err(msg);
156        }
157
158        let checksum = Self::compute_checksum(&block.data);
159
160        // Optionally verify by re-deriving the checksum embedded in the CID
161        // prefix.  The convention used here: if the CID starts with
162        // "bafy" (IPFS CIDv1 prefix) we trust it and only log the computed
163        // checksum; for synthetic / test CIDs that encode their checksum as
164        // a hex suffix we validate them.
165        if self.config.verify_checksums {
166            if let Some(fail_msg) = self.validate_cid_checksum(&block, checksum) {
167                self.stats.checksum_failures += 1;
168                self.errors.push(fail_msg.clone());
169                if self.config.skip_corrupted {
170                    self.stats.blocks_skipped += 1;
171                    return Err(fail_msg);
172                }
173                self.status = RecoveryStatus::Failed(fail_msg.clone());
174                return Err(fail_msg);
175            }
176        }
177
178        let now = current_timestamp_secs();
179        let size = block.data.len() as u64;
180
181        let entry = IndexEntry {
182            cid: block.cid.clone(),
183            size,
184            checksum,
185            offset: block.offset,
186            recovered_at: now,
187        };
188
189        // Handle duplicate CIDs: keep the entry with the lower offset (i.e.
190        // the first occurrence found in raw storage) so recovery is
191        // deterministic.
192        let inserted = match self.recovered_index.get(&block.cid) {
193            Some(existing) if existing.offset <= entry.offset => false,
194            _ => {
195                self.recovered_index
196                    .insert(block.cid.clone(), entry.clone());
197                true
198            }
199        };
200
201        if inserted {
202            self.stats.blocks_recovered += 1;
203            self.stats.bytes_recovered += size;
204        } else {
205            // Duplicate — count it but do not overwrite the primary entry.
206            self.stats.blocks_skipped += 1;
207        }
208
209        Ok(entry)
210    }
211
212    /// Validate a block's CID-encoded checksum, if present.
213    ///
214    /// Returns `Some(error_message)` if validation fails, `None` if the block
215    /// passes or has no embedded checksum to validate.
216    fn validate_cid_checksum(&self, block: &RawBlock, computed: u64) -> Option<String> {
217        // Synthetic CIDs used in tests may encode the expected FNV-1a checksum
218        // as a hex suffix separated by ':'.  Example: "testcid:1a2b3c4d5e6f7890".
219        // Real IPFS CIDs (starting with "bafy", "Qm", "b", etc.) are trusted.
220        if let Some(hex_part) = block.cid.split_once(':').map(|x| x.1) {
221            match u64::from_str_radix(hex_part, 16) {
222                Ok(expected) if expected != computed => {
223                    return Some(format!(
224                        "checksum mismatch for CID '{}': expected {:#018x}, got {:#018x}",
225                        block.cid, expected, computed
226                    ));
227                }
228                Ok(_) => {}
229                Err(_) => {} // Not a valid hex suffix — treat as trusted CID
230            }
231        }
232        None
233    }
234
235    // -----------------------------------------------------------------------
236    // Batch operations
237    // -----------------------------------------------------------------------
238
239    /// Scan a batch of raw blocks and return one result per block.
240    ///
241    /// The order of results matches the order of the input slice.
242    pub fn scan_batch(&mut self, blocks: Vec<RawBlock>) -> Vec<Result<IndexEntry, String>> {
243        blocks.into_iter().map(|b| self.scan_block(b)).collect()
244    }
245
246    /// Fully rebuild the index from `blocks`.
247    ///
248    /// This resets the engine state, transitions through `Scanning` →
249    /// `Rebuilding` → `Verifying` → `Completed` (or `Failed`) and returns
250    /// the final [`RecoveryStats`].
251    pub fn rebuild_index(&mut self, blocks: Vec<RawBlock>) -> RecoveryStats {
252        self.reset();
253        self.status = RecoveryStatus::Scanning;
254
255        // --- Scanning phase -------------------------------------------------
256        for chunk in blocks.chunks(self.config.batch_size) {
257            for block in chunk {
258                let _ = self.scan_block(block.clone());
259            }
260        }
261
262        // --- Rebuilding phase -----------------------------------------------
263        self.status = RecoveryStatus::Rebuilding;
264        // The index is already populated; this phase represents any secondary
265        // aggregation or cross-reference building that might be required.
266        // (Currently a no-op — the map is the rebuilt index.)
267
268        // --- Verifying phase ------------------------------------------------
269        self.status = RecoveryStatus::Verifying;
270
271        // If checksum verification is enabled, do a final pass over the
272        // recovered index to ensure all entries are self-consistent.
273        if self.config.verify_checksums {
274            let cids: Vec<String> = self.recovered_index.keys().cloned().collect();
275            let mut verification_failures: Vec<String> = Vec::new();
276            for cid in &cids {
277                if let Some(entry) = self.recovered_index.get(cid) {
278                    // Re-derive checksum from the stored entry (without access
279                    // to raw bytes at this stage we can only validate the
280                    // internal consistency of the entry itself).
281                    if entry.size == 0 && entry.checksum != Self::compute_checksum(&[]) {
282                        verification_failures
283                            .push(format!("entry '{}' has size 0 but non-empty checksum", cid));
284                    }
285                }
286            }
287            if !verification_failures.is_empty() {
288                for msg in &verification_failures {
289                    self.errors.push(msg.clone());
290                }
291                if !self.config.skip_corrupted {
292                    self.status = RecoveryStatus::Failed(verification_failures.join("; "));
293                    return self.stats.clone();
294                }
295            }
296        }
297
298        // Transition to Completed only if we are not already in Failed state.
299        if self.status != RecoveryStatus::Failed("".to_string()) {
300            // The pattern above doesn't equality-match Failed variants with
301            // arbitrary messages, so use a more robust check.
302            match &self.status {
303                RecoveryStatus::Failed(_) => {}
304                _ => {
305                    self.status = RecoveryStatus::Completed;
306                }
307            }
308        }
309
310        self.stats.clone()
311    }
312
313    // -----------------------------------------------------------------------
314    // Verification
315    // -----------------------------------------------------------------------
316
317    /// Verify that the stored `entry` is consistent with the provided `data`.
318    ///
319    /// Returns `true` if size and checksum match, `false` otherwise.
320    pub fn verify_entry(&self, entry: &IndexEntry, data: &[u8]) -> bool {
321        if entry.size != data.len() as u64 {
322            return false;
323        }
324        if self.config.verify_checksums {
325            let computed = Self::compute_checksum(data);
326            if computed != entry.checksum {
327                return false;
328            }
329        }
330        true
331    }
332
333    // -----------------------------------------------------------------------
334    // Accessors
335    // -----------------------------------------------------------------------
336
337    /// Look up a recovered entry by CID.
338    pub fn get_entry(&self, cid: &str) -> Option<&IndexEntry> {
339        self.recovered_index.get(cid)
340    }
341
342    /// Number of entries currently in the recovered index.
343    pub fn entry_count(&self) -> usize {
344        self.recovered_index.len()
345    }
346
347    /// Current recovery status.
348    pub fn status(&self) -> &RecoveryStatus {
349        &self.status
350    }
351
352    /// All error messages accumulated during recovery.
353    pub fn errors(&self) -> &[String] {
354        &self.errors
355    }
356
357    /// Aggregate statistics from the current (or last) recovery run.
358    pub fn stats(&self) -> &RecoveryStats {
359        &self.stats
360    }
361
362    // -----------------------------------------------------------------------
363    // Lifecycle
364    // -----------------------------------------------------------------------
365
366    /// Reset all state, ready for a fresh recovery run.
367    pub fn reset(&mut self) {
368        self.status = RecoveryStatus::NotStarted;
369        self.recovered_index.clear();
370        self.errors.clear();
371        self.stats = RecoveryStats::default();
372    }
373
374    // -----------------------------------------------------------------------
375    // Export
376    // -----------------------------------------------------------------------
377
378    /// Export all recovered entries, sorted ascending by CID string.
379    pub fn export_index(&self) -> Vec<IndexEntry> {
380        let mut entries: Vec<IndexEntry> = self.recovered_index.values().cloned().collect();
381        entries.sort_by(|a, b| a.cid.cmp(&b.cid));
382        entries
383    }
384}
385
386// ---------------------------------------------------------------------------
387// Helpers
388// ---------------------------------------------------------------------------
389
390/// Return a coarse Unix timestamp in seconds.
391///
392/// Falls back to 0 if the system clock cannot be read.
393fn current_timestamp_secs() -> u64 {
394    std::time::SystemTime::now()
395        .duration_since(std::time::UNIX_EPOCH)
396        .map(|d| d.as_secs())
397        .unwrap_or(0)
398}
399
400// ---------------------------------------------------------------------------
401// Tests
402// ---------------------------------------------------------------------------
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    // ------------------------------------------------------------------
409    // Helpers
410    // ------------------------------------------------------------------
411
412    fn default_config() -> RecoveryConfig {
413        RecoveryConfig::default()
414    }
415
416    fn strict_config() -> RecoveryConfig {
417        RecoveryConfig {
418            skip_corrupted: false,
419            ..RecoveryConfig::default()
420        }
421    }
422
423    fn make_block(cid: &str, data: &[u8], offset: u64) -> RawBlock {
424        RawBlock {
425            cid: cid.to_owned(),
426            data: data.to_vec(),
427            offset,
428        }
429    }
430
431    /// Build a block whose CID encodes the correct FNV-1a checksum as hex.
432    fn valid_block(label: &str, data: &[u8], offset: u64) -> RawBlock {
433        let checksum = IndexRecovery::compute_checksum(data);
434        let cid = format!("{}:{:016x}", label, checksum);
435        make_block(&cid, data, offset)
436    }
437
438    /// Build a block whose CID encodes a *wrong* checksum.
439    fn corrupted_block(label: &str, data: &[u8], offset: u64) -> RawBlock {
440        let cid = format!("{}:{:016x}", label, 0xdeadbeef_u64);
441        make_block(&cid, data, offset)
442    }
443
444    // ------------------------------------------------------------------
445    // 1. Initial status
446    // ------------------------------------------------------------------
447
448    #[test]
449    fn test_initial_status_is_not_started() {
450        let engine = IndexRecovery::new(default_config());
451        assert_eq!(*engine.status(), RecoveryStatus::NotStarted);
452    }
453
454    // ------------------------------------------------------------------
455    // 2. scan_block — valid block
456    // ------------------------------------------------------------------
457
458    #[test]
459    fn test_scan_valid_block() {
460        let mut engine = IndexRecovery::new(default_config());
461        let data = b"hello world";
462        let block = valid_block("cid1", data, 0);
463        let result = engine.scan_block(block);
464        assert!(result.is_ok());
465        let entry = result.unwrap_or_else(|_| panic!("expected Ok"));
466        assert_eq!(entry.size, data.len() as u64);
467        assert_eq!(entry.offset, 0);
468        assert_eq!(engine.stats().blocks_recovered, 1);
469        assert_eq!(engine.stats().blocks_scanned, 1);
470    }
471
472    // ------------------------------------------------------------------
473    // 3. scan_block — status transitions to Scanning
474    // ------------------------------------------------------------------
475
476    #[test]
477    fn test_status_transitions_to_scanning_on_first_scan() {
478        let mut engine = IndexRecovery::new(default_config());
479        let block = valid_block("cid_scan", b"data", 0);
480        let _ = engine.scan_block(block);
481        assert_eq!(*engine.status(), RecoveryStatus::Scanning);
482    }
483
484    // ------------------------------------------------------------------
485    // 4. scan_block — corrupted block with skip_corrupted = true (default)
486    // ------------------------------------------------------------------
487
488    #[test]
489    fn test_scan_corrupted_block_skip_enabled() {
490        let mut engine = IndexRecovery::new(default_config()); // skip_corrupted: true
491        let block = corrupted_block("cid_bad", b"data", 0);
492        let result = engine.scan_block(block);
493        assert!(result.is_err());
494        assert_eq!(engine.stats().checksum_failures, 1);
495        assert_eq!(engine.stats().blocks_skipped, 1);
496        assert_eq!(engine.stats().blocks_recovered, 0);
497        // Status must NOT be Failed when skip_corrupted is true
498        assert_ne!(*engine.status(), RecoveryStatus::Failed(String::new()));
499    }
500
501    // ------------------------------------------------------------------
502    // 5. scan_block — corrupted block with skip_corrupted = false
503    // ------------------------------------------------------------------
504
505    #[test]
506    fn test_scan_corrupted_block_no_skip() {
507        let mut engine = IndexRecovery::new(strict_config());
508        let block = corrupted_block("cid_bad", b"data", 0);
509        let result = engine.scan_block(block);
510        assert!(result.is_err());
511        match engine.status() {
512            RecoveryStatus::Failed(_) => {}
513            other => panic!("expected Failed, got {:?}", other),
514        }
515    }
516
517    // ------------------------------------------------------------------
518    // 6. scan_block — empty CID
519    // ------------------------------------------------------------------
520
521    #[test]
522    fn test_scan_block_empty_cid_skipped() {
523        let mut engine = IndexRecovery::new(default_config());
524        let block = make_block("", b"data", 0);
525        let result = engine.scan_block(block);
526        assert!(result.is_err());
527        assert_eq!(engine.stats().blocks_skipped, 1);
528        assert_eq!(engine.errors().len(), 1);
529    }
530
531    // ------------------------------------------------------------------
532    // 7. scan_batch — mixed valid and corrupted blocks
533    // ------------------------------------------------------------------
534
535    #[test]
536    fn test_scan_batch_mixed() {
537        let mut engine = IndexRecovery::new(default_config());
538        let blocks = vec![
539            valid_block("cid_a", b"aaa", 0),
540            corrupted_block("cid_b", b"bbb", 10),
541            valid_block("cid_c", b"ccc", 20),
542        ];
543        let results = engine.scan_batch(blocks);
544        assert_eq!(results.len(), 3);
545        assert!(results[0].is_ok());
546        assert!(results[1].is_err());
547        assert!(results[2].is_ok());
548        assert_eq!(engine.stats().blocks_recovered, 2);
549        assert_eq!(engine.stats().checksum_failures, 1);
550    }
551
552    // ------------------------------------------------------------------
553    // 8. scan_batch — all valid
554    // ------------------------------------------------------------------
555
556    #[test]
557    fn test_scan_batch_all_valid() {
558        let mut engine = IndexRecovery::new(default_config());
559        let blocks: Vec<RawBlock> = (0..10)
560            .map(|i| valid_block(&format!("cid_{}", i), &[i as u8; 32], i as u64 * 32))
561            .collect();
562        let results = engine.scan_batch(blocks);
563        assert!(results.iter().all(|r| r.is_ok()));
564        assert_eq!(engine.entry_count(), 10);
565    }
566
567    // ------------------------------------------------------------------
568    // 9. rebuild_index — status becomes Completed
569    // ------------------------------------------------------------------
570
571    #[test]
572    fn test_rebuild_index_status_completed() {
573        let mut engine = IndexRecovery::new(default_config());
574        let blocks = vec![
575            valid_block("cid1", b"hello", 0),
576            valid_block("cid2", b"world", 8),
577        ];
578        let stats = engine.rebuild_index(blocks);
579        assert_eq!(*engine.status(), RecoveryStatus::Completed);
580        assert_eq!(stats.blocks_recovered, 2);
581    }
582
583    // ------------------------------------------------------------------
584    // 10. rebuild_index — reset between runs
585    // ------------------------------------------------------------------
586
587    #[test]
588    fn test_rebuild_index_clears_previous_state() {
589        let mut engine = IndexRecovery::new(default_config());
590        engine.rebuild_index(vec![valid_block("old_cid", b"old", 0)]);
591        assert_eq!(engine.entry_count(), 1);
592
593        engine.rebuild_index(vec![
594            valid_block("new1", b"new1data", 0),
595            valid_block("new2", b"new2data", 8),
596        ]);
597        assert_eq!(engine.entry_count(), 2);
598        assert!(engine.get_entry("old_cid").is_none());
599    }
600
601    // ------------------------------------------------------------------
602    // 11. compute_checksum — FNV-1a known values
603    // ------------------------------------------------------------------
604
605    #[test]
606    fn test_compute_checksum_empty_slice() {
607        // FNV-1a 64-bit of empty input is the offset basis itself.
608        let expected: u64 = 14695981039346656037;
609        assert_eq!(IndexRecovery::compute_checksum(&[]), expected);
610    }
611
612    #[test]
613    fn test_compute_checksum_known_value() {
614        // FNV-1a 64-bit of b"a" = 0xaf63dc4c8601ec8c (well-known)
615        let expected: u64 = 0xaf63dc4c8601ec8c;
616        assert_eq!(IndexRecovery::compute_checksum(b"a"), expected);
617    }
618
619    #[test]
620    fn test_compute_checksum_deterministic() {
621        let data = b"deterministic test payload 1234";
622        let c1 = IndexRecovery::compute_checksum(data);
623        let c2 = IndexRecovery::compute_checksum(data);
624        assert_eq!(c1, c2);
625    }
626
627    #[test]
628    fn test_compute_checksum_differs_for_different_data() {
629        let c1 = IndexRecovery::compute_checksum(b"abc");
630        let c2 = IndexRecovery::compute_checksum(b"abd");
631        assert_ne!(c1, c2);
632    }
633
634    // ------------------------------------------------------------------
635    // 12. verify_entry — correct data
636    // ------------------------------------------------------------------
637
638    #[test]
639    fn test_verify_entry_correct_data() {
640        let engine = IndexRecovery::new(default_config());
641        let data = b"verify me";
642        let entry = IndexEntry {
643            cid: "test_cid".to_owned(),
644            size: data.len() as u64,
645            checksum: IndexRecovery::compute_checksum(data),
646            offset: 0,
647            recovered_at: 0,
648        };
649        assert!(engine.verify_entry(&entry, data));
650    }
651
652    // ------------------------------------------------------------------
653    // 13. verify_entry — wrong size
654    // ------------------------------------------------------------------
655
656    #[test]
657    fn test_verify_entry_wrong_size() {
658        let engine = IndexRecovery::new(default_config());
659        let data = b"verify me";
660        let entry = IndexEntry {
661            cid: "test_cid".to_owned(),
662            size: 999,
663            checksum: IndexRecovery::compute_checksum(data),
664            offset: 0,
665            recovered_at: 0,
666        };
667        assert!(!engine.verify_entry(&entry, data));
668    }
669
670    // ------------------------------------------------------------------
671    // 14. verify_entry — wrong checksum
672    // ------------------------------------------------------------------
673
674    #[test]
675    fn test_verify_entry_wrong_checksum() {
676        let engine = IndexRecovery::new(default_config());
677        let data = b"verify me";
678        let entry = IndexEntry {
679            cid: "test_cid".to_owned(),
680            size: data.len() as u64,
681            checksum: 0xdeadbeef,
682            offset: 0,
683            recovered_at: 0,
684        };
685        assert!(!engine.verify_entry(&entry, data));
686    }
687
688    // ------------------------------------------------------------------
689    // 15. get_entry — found and not found
690    // ------------------------------------------------------------------
691
692    #[test]
693    fn test_get_entry_found() {
694        let mut engine = IndexRecovery::new(default_config());
695        let data = b"content";
696        let checksum = IndexRecovery::compute_checksum(data);
697        let cid = format!("lookup_cid:{:016x}", checksum);
698        let block = make_block(&cid, data, 42);
699        engine.scan_block(block).ok();
700        let entry = engine.get_entry(&cid);
701        assert!(entry.is_some());
702        assert_eq!(entry.map(|e| e.offset), Some(42));
703    }
704
705    #[test]
706    fn test_get_entry_not_found() {
707        let engine = IndexRecovery::new(default_config());
708        assert!(engine.get_entry("nonexistent").is_none());
709    }
710
711    // ------------------------------------------------------------------
712    // 16. export_index — sorted by cid
713    // ------------------------------------------------------------------
714
715    #[test]
716    fn test_export_index_sorted_by_cid() {
717        let mut engine = IndexRecovery::new(default_config());
718        // Insert in non-alphabetical order
719        for label in &["zzz", "aaa", "mmm", "bbb"] {
720            let block = valid_block(label, b"data", 0);
721            engine.scan_block(block).ok();
722        }
723        let exported = engine.export_index();
724        let cids: Vec<&str> = exported.iter().map(|e| e.cid.as_str()).collect();
725        let mut sorted = cids.clone();
726        sorted.sort();
727        assert_eq!(cids, sorted);
728    }
729
730    // ------------------------------------------------------------------
731    // 17. reset — clears all state
732    // ------------------------------------------------------------------
733
734    #[test]
735    fn test_reset_clears_all_state() {
736        let mut engine = IndexRecovery::new(default_config());
737        engine.scan_block(valid_block("cid1", b"data", 0)).ok();
738        engine.reset();
739        assert_eq!(*engine.status(), RecoveryStatus::NotStarted);
740        assert_eq!(engine.entry_count(), 0);
741        assert_eq!(engine.errors().len(), 0);
742        assert_eq!(engine.stats().blocks_scanned, 0);
743        assert_eq!(engine.stats().blocks_recovered, 0);
744    }
745
746    // ------------------------------------------------------------------
747    // 18. errors tracking
748    // ------------------------------------------------------------------
749
750    #[test]
751    fn test_errors_accumulate() {
752        let mut engine = IndexRecovery::new(default_config());
753        engine.scan_block(corrupted_block("c1", b"d1", 0)).ok();
754        engine.scan_block(make_block("", b"d2", 10)).ok();
755        engine.scan_block(corrupted_block("c3", b"d3", 20)).ok();
756        assert_eq!(engine.errors().len(), 3);
757    }
758
759    // ------------------------------------------------------------------
760    // 19. duplicate CID handling — lower offset wins
761    // ------------------------------------------------------------------
762
763    #[test]
764    fn test_duplicate_cid_lower_offset_wins() {
765        let mut engine = IndexRecovery::new(RecoveryConfig {
766            verify_checksums: false, // plain CID without checksum suffix
767            ..RecoveryConfig::default()
768        });
769        let block_first = make_block("dup_cid", b"first", 0);
770        let block_second = make_block("dup_cid", b"first", 100);
771        engine.scan_block(block_first).ok();
772        engine.scan_block(block_second).ok();
773        // Only 1 recovered entry; the one at offset 0 is kept.
774        assert_eq!(engine.entry_count(), 1);
775        assert_eq!(engine.get_entry("dup_cid").map(|e| e.offset), Some(0));
776        // The second occurrence counted as skipped
777        assert_eq!(engine.stats().blocks_skipped, 1);
778    }
779
780    // ------------------------------------------------------------------
781    // 20. duplicate CID handling — higher offset superseded
782    // ------------------------------------------------------------------
783
784    #[test]
785    fn test_duplicate_cid_later_entry_not_inserted() {
786        let mut engine = IndexRecovery::new(RecoveryConfig {
787            verify_checksums: false,
788            ..RecoveryConfig::default()
789        });
790        engine.scan_block(make_block("dup", b"data", 50)).ok();
791        engine.scan_block(make_block("dup", b"data", 200)).ok();
792        assert_eq!(engine.stats().blocks_recovered, 1);
793        assert_eq!(engine.stats().blocks_skipped, 1);
794    }
795
796    // ------------------------------------------------------------------
797    // 21. large batch (100+ blocks) — stats accuracy
798    // ------------------------------------------------------------------
799
800    #[test]
801    fn test_large_batch_stats_accuracy() {
802        let mut engine = IndexRecovery::new(default_config());
803        let n = 150_usize;
804        let blocks: Vec<RawBlock> = (0..n)
805            .map(|i| valid_block(&format!("cid_{:04}", i), &[i as u8; 64], i as u64 * 64))
806            .collect();
807        let results = engine.scan_batch(blocks);
808        assert_eq!(results.len(), n);
809        assert_eq!(engine.stats().blocks_scanned, n as u64);
810        assert_eq!(engine.stats().blocks_recovered, n as u64);
811        assert_eq!(engine.stats().blocks_skipped, 0);
812        assert_eq!(engine.stats().checksum_failures, 0);
813        assert_eq!(engine.stats().bytes_recovered, (n as u64) * 64);
814        assert_eq!(engine.entry_count(), n);
815    }
816
817    // ------------------------------------------------------------------
818    // 22. entry_count reflects insertions
819    // ------------------------------------------------------------------
820
821    #[test]
822    fn test_entry_count() {
823        let mut engine = IndexRecovery::new(RecoveryConfig {
824            verify_checksums: false,
825            ..RecoveryConfig::default()
826        });
827        assert_eq!(engine.entry_count(), 0);
828        for i in 0..5 {
829            engine
830                .scan_block(make_block(&format!("c{}", i), b"d", i as u64))
831                .ok();
832        }
833        assert_eq!(engine.entry_count(), 5);
834    }
835
836    // ------------------------------------------------------------------
837    // 23. bytes_recovered tracks total bytes
838    // ------------------------------------------------------------------
839
840    #[test]
841    fn test_bytes_recovered_accumulates() {
842        let mut engine = IndexRecovery::new(RecoveryConfig {
843            verify_checksums: false,
844            ..RecoveryConfig::default()
845        });
846        engine.scan_block(make_block("a", &[0u8; 100], 0)).ok();
847        engine.scan_block(make_block("b", &[0u8; 200], 100)).ok();
848        assert_eq!(engine.stats().bytes_recovered, 300);
849    }
850
851    // ------------------------------------------------------------------
852    // 24. verify_entry with checksum disabled
853    // ------------------------------------------------------------------
854
855    #[test]
856    fn test_verify_entry_no_checksum_validation() {
857        let engine = IndexRecovery::new(RecoveryConfig {
858            verify_checksums: false,
859            ..RecoveryConfig::default()
860        });
861        let data = b"test data";
862        let entry = IndexEntry {
863            cid: "cid".to_owned(),
864            size: data.len() as u64,
865            checksum: 0, // deliberately wrong
866            offset: 0,
867            recovered_at: 0,
868        };
869        // Without checksum verification, size match is sufficient.
870        assert!(engine.verify_entry(&entry, data));
871    }
872
873    // ------------------------------------------------------------------
874    // 25. rebuild_index integrates errors into error list
875    // ------------------------------------------------------------------
876
877    #[test]
878    fn test_rebuild_index_accumulates_errors() {
879        let mut engine = IndexRecovery::new(default_config());
880        let blocks = vec![
881            valid_block("good1", b"ok", 0),
882            corrupted_block("bad1", b"corrupt", 10),
883            corrupted_block("bad2", b"corrupt2", 20),
884            valid_block("good2", b"ok2", 30),
885        ];
886        let stats = engine.rebuild_index(blocks);
887        assert_eq!(stats.blocks_recovered, 2);
888        assert_eq!(stats.checksum_failures, 2);
889        assert_eq!(engine.errors().len(), 2);
890        assert_eq!(*engine.status(), RecoveryStatus::Completed);
891    }
892
893    // ------------------------------------------------------------------
894    // 26. export_index returns empty vec when no entries
895    // ------------------------------------------------------------------
896
897    #[test]
898    fn test_export_index_empty() {
899        let engine = IndexRecovery::new(default_config());
900        assert!(engine.export_index().is_empty());
901    }
902
903    // ------------------------------------------------------------------
904    // 27. RecoveryStatus default is NotStarted
905    // ------------------------------------------------------------------
906
907    #[test]
908    fn test_recovery_status_default() {
909        assert_eq!(RecoveryStatus::default(), RecoveryStatus::NotStarted);
910    }
911
912    // ------------------------------------------------------------------
913    // 28. IndexEntry fields are accessible (clone + Debug)
914    // ------------------------------------------------------------------
915
916    #[test]
917    fn test_index_entry_clone_and_debug() {
918        let entry = IndexEntry {
919            cid: "test".to_owned(),
920            size: 42,
921            checksum: 0xabcd,
922            offset: 10,
923            recovered_at: 9999,
924        };
925        let cloned = entry.clone();
926        assert_eq!(cloned.cid, entry.cid);
927        let _ = format!("{:?}", entry);
928    }
929}