1use std::collections::HashMap;
8
9#[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#[derive(Debug, Clone)]
27pub struct IndexEntry {
28 pub cid: String,
30 pub size: u64,
32 pub checksum: u64,
34 pub offset: u64,
36 pub recovered_at: u64,
38}
39
40#[derive(Debug, Clone)]
42pub struct RecoveryConfig {
43 pub verify_checksums: bool,
45 pub skip_corrupted: bool,
47 pub max_scan_depth: usize,
49 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#[derive(Debug, Clone)]
66pub struct RawBlock {
67 pub cid: String,
68 pub data: Vec<u8>,
69 pub offset: u64,
70}
71
72#[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
82pub 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 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 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 pub fn scan_block(&mut self, block: RawBlock) -> Result<IndexEntry, String> {
139 if self.status == RecoveryStatus::NotStarted {
141 self.status = RecoveryStatus::Scanning;
142 }
143
144 self.stats.blocks_scanned += 1;
145
146 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 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 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 self.stats.blocks_skipped += 1;
207 }
208
209 Ok(entry)
210 }
211
212 fn validate_cid_checksum(&self, block: &RawBlock, computed: u64) -> Option<String> {
217 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(_) => {} }
231 }
232 None
233 }
234
235 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 pub fn rebuild_index(&mut self, blocks: Vec<RawBlock>) -> RecoveryStats {
252 self.reset();
253 self.status = RecoveryStatus::Scanning;
254
255 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 self.status = RecoveryStatus::Rebuilding;
264 self.status = RecoveryStatus::Verifying;
270
271 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 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 if self.status != RecoveryStatus::Failed("".to_string()) {
300 match &self.status {
303 RecoveryStatus::Failed(_) => {}
304 _ => {
305 self.status = RecoveryStatus::Completed;
306 }
307 }
308 }
309
310 self.stats.clone()
311 }
312
313 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 pub fn get_entry(&self, cid: &str) -> Option<&IndexEntry> {
339 self.recovered_index.get(cid)
340 }
341
342 pub fn entry_count(&self) -> usize {
344 self.recovered_index.len()
345 }
346
347 pub fn status(&self) -> &RecoveryStatus {
349 &self.status
350 }
351
352 pub fn errors(&self) -> &[String] {
354 &self.errors
355 }
356
357 pub fn stats(&self) -> &RecoveryStats {
359 &self.stats
360 }
361
362 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 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
386fn 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#[cfg(test)]
405mod tests {
406 use super::*;
407
408 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 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 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 #[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 #[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 #[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 #[test]
489 fn test_scan_corrupted_block_skip_enabled() {
490 let mut engine = IndexRecovery::new(default_config()); 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 assert_ne!(*engine.status(), RecoveryStatus::Failed(String::new()));
499 }
500
501 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
606 fn test_compute_checksum_empty_slice() {
607 let expected: u64 = 14695981039346656037;
609 assert_eq!(IndexRecovery::compute_checksum(&[]), expected);
610 }
611
612 #[test]
613 fn test_compute_checksum_known_value() {
614 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 #[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 #[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 #[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 #[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 #[test]
716 fn test_export_index_sorted_by_cid() {
717 let mut engine = IndexRecovery::new(default_config());
718 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 #[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 #[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 #[test]
764 fn test_duplicate_cid_lower_offset_wins() {
765 let mut engine = IndexRecovery::new(RecoveryConfig {
766 verify_checksums: false, ..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 assert_eq!(engine.entry_count(), 1);
775 assert_eq!(engine.get_entry("dup_cid").map(|e| e.offset), Some(0));
776 assert_eq!(engine.stats().blocks_skipped, 1);
778 }
779
780 #[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 #[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 #[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 #[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 #[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, offset: 0,
867 recovered_at: 0,
868 };
869 assert!(engine.verify_entry(&entry, data));
871 }
872
873 #[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 #[test]
898 fn test_export_index_empty() {
899 let engine = IndexRecovery::new(default_config());
900 assert!(engine.export_index().is_empty());
901 }
902
903 #[test]
908 fn test_recovery_status_default() {
909 assert_eq!(RecoveryStatus::default(), RecoveryStatus::NotStarted);
910 }
911
912 #[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}