ipfrs_storage/integrity_scanner.rs
1//! Block Integrity Scanner
2//!
3//! Scans stored blocks for integrity issues including CID mismatches,
4//! size violations, corruption markers (magic byte mismatches), and
5//! missing blocks.
6
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// FNV-1a hash (64-bit, no external dependency)
11// ---------------------------------------------------------------------------
12
13const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
14const FNV_PRIME: u64 = 1_099_511_628_211;
15
16fn fnv1a(data: &[u8]) -> u64 {
17 let mut hash = FNV_OFFSET_BASIS;
18 for &byte in data {
19 hash ^= u64::from(byte);
20 hash = hash.wrapping_mul(FNV_PRIME);
21 }
22 hash
23}
24
25// ---------------------------------------------------------------------------
26// IntegrityIssue
27// ---------------------------------------------------------------------------
28
29/// Describes a single integrity problem found during a block scan.
30#[derive(Clone, Debug, PartialEq)]
31pub enum IntegrityIssue {
32 /// The CID stored in the registry does not match the CID computed from
33 /// the data presented at scan time.
34 CidMismatch {
35 /// CID that was registered (the "expected" CID).
36 cid: String,
37 /// CID computed from the actual data presented at scan time.
38 /// In production callers replace this with the real recomputed CID;
39 /// the scanner itself uses the registered CID as a placeholder so
40 /// that the type signature is stable.
41 computed_cid: String,
42 },
43 /// The size of the actual data does not match the expected size stored
44 /// in the registry, or exceeds the configured `max_size_bytes`.
45 SizeViolation {
46 /// CID of the block that triggered the violation.
47 cid: String,
48 /// Expected size in bytes (from registry or `max_size_bytes`).
49 expected: u64,
50 /// Actual size in bytes.
51 actual: u64,
52 },
53 /// The actual data does not begin with the configured magic bytes,
54 /// indicating likely corruption.
55 CorruptionMarker {
56 /// CID of the affected block.
57 cid: String,
58 /// Byte offset where the mismatch was detected (always 0 here,
59 /// since the prefix is checked from the start of the data).
60 offset: usize,
61 },
62 /// No registry entry was found for the requested CID.
63 MissingBlock {
64 /// The CID that was not found in the registry.
65 cid: String,
66 },
67}
68
69// ---------------------------------------------------------------------------
70// ScanRecord
71// ---------------------------------------------------------------------------
72
73/// Metadata stored in the scanner's registry for a single known block.
74#[derive(Clone, Debug, PartialEq)]
75pub struct ScanRecord {
76 /// Content identifier for this block.
77 pub cid: String,
78 /// Expected size in bytes as reported when the block was registered.
79 pub expected_size: u64,
80 /// FNV-1a hash of the raw data bytes at registration time.
81 pub data_hash: u64,
82 /// Unix timestamp (seconds) at which the block was registered.
83 pub registered_at_secs: u64,
84}
85
86// ---------------------------------------------------------------------------
87// ScanResult
88// ---------------------------------------------------------------------------
89
90/// The outcome of scanning a single block.
91#[derive(Clone, Debug, PartialEq)]
92pub struct ScanResult {
93 /// CID of the scanned block.
94 pub cid: String,
95 /// All integrity issues detected during the scan. Empty means healthy.
96 pub issues: Vec<IntegrityIssue>,
97 /// Wall-clock time spent on this scan, in microseconds.
98 pub scan_duration_us: u64,
99}
100
101impl ScanResult {
102 /// Returns `true` when no integrity issues were found.
103 pub fn is_healthy(&self) -> bool {
104 self.issues.is_empty()
105 }
106}
107
108// ---------------------------------------------------------------------------
109// ScannerConfig
110// ---------------------------------------------------------------------------
111
112/// Tunable parameters for [`BlockIntegrityScanner`].
113#[derive(Clone, Debug)]
114pub struct ScannerConfig {
115 /// Blocks larger than this value (in bytes) are flagged with a
116 /// [`IntegrityIssue::SizeViolation`].
117 /// Default: `256 * 1024 * 1024` (256 MiB).
118 pub max_size_bytes: u64,
119 /// Expected prefix bytes for every block. When non-empty, blocks whose
120 /// data does not start with these bytes are flagged with a
121 /// [`IntegrityIssue::CorruptionMarker`].
122 /// Default: empty (check skipped).
123 pub magic_bytes: Vec<u8>,
124 /// When `true`, the FNV-1a hash of the presented data is compared with
125 /// the hash stored at registration time; a difference causes a
126 /// [`IntegrityIssue::CidMismatch`].
127 /// Default: `true`.
128 pub verify_cid_hash: bool,
129}
130
131impl Default for ScannerConfig {
132 fn default() -> Self {
133 Self {
134 max_size_bytes: 256 * 1024 * 1024,
135 magic_bytes: Vec::new(),
136 verify_cid_hash: true,
137 }
138 }
139}
140
141// ---------------------------------------------------------------------------
142// ScannerStats
143// ---------------------------------------------------------------------------
144
145/// Aggregate statistics accumulated across all scan operations.
146#[derive(Clone, Debug, Default, PartialEq)]
147pub struct ScannerStats {
148 /// Total number of blocks scanned (including missing-block probes).
149 pub total_scanned: u64,
150 /// Number of blocks that were healthy (no issues).
151 pub healthy: u64,
152 /// Number of blocks that had at least one issue.
153 pub with_issues: u64,
154}
155
156impl ScannerStats {
157 /// Fraction of scanned blocks that had at least one issue.
158 ///
159 /// Returns `0.0` when no blocks have been scanned yet.
160 pub fn issue_rate(&self) -> f64 {
161 if self.total_scanned == 0 {
162 0.0
163 } else {
164 self.with_issues as f64 / self.total_scanned as f64
165 }
166 }
167}
168
169// ---------------------------------------------------------------------------
170// BlockIntegrityScanner
171// ---------------------------------------------------------------------------
172
173/// Scans stored blocks for integrity issues.
174///
175/// Callers first [`register_block`](BlockIntegrityScanner::register_block)
176/// each known block, then call
177/// [`scan_block`](BlockIntegrityScanner::scan_block) (or
178/// [`scan_all`](BlockIntegrityScanner::scan_all)) whenever they want to
179/// verify the live data against the registry.
180pub struct BlockIntegrityScanner {
181 /// Registry of all known blocks, keyed by CID string.
182 pub registry: HashMap<String, ScanRecord>,
183 /// Configuration controlling which checks are performed.
184 pub config: ScannerConfig,
185 /// Running statistics.
186 pub stats: ScannerStats,
187}
188
189impl BlockIntegrityScanner {
190 /// Creates a new scanner with the supplied configuration and an empty
191 /// registry.
192 pub fn new(config: ScannerConfig) -> Self {
193 Self {
194 registry: HashMap::new(),
195 config,
196 stats: ScannerStats::default(),
197 }
198 }
199
200 /// Registers a block in the scanner's registry.
201 ///
202 /// The FNV-1a hash of `data` is computed and stored alongside the
203 /// supplied metadata so that future scans can detect hash mismatches.
204 ///
205 /// # Parameters
206 /// - `cid` – Content identifier for the block.
207 /// - `expected_size` – Expected size of the block in bytes.
208 /// - `data` – Raw block data used to compute the stored hash.
209 /// - `now_secs` – Current Unix timestamp in seconds (caller-supplied
210 /// so the scanner remains deterministic in tests).
211 pub fn register_block(&mut self, cid: String, expected_size: u64, data: &[u8], now_secs: u64) {
212 let data_hash = fnv1a(data);
213 self.registry.insert(
214 cid.clone(),
215 ScanRecord {
216 cid,
217 expected_size,
218 data_hash,
219 registered_at_secs: now_secs,
220 },
221 );
222 }
223
224 /// Scans a single block and returns a [`ScanResult`] describing any
225 /// integrity issues found.
226 ///
227 /// The following checks are performed in order:
228 ///
229 /// 1. **Missing block** – If `cid` is not in the registry the result
230 /// contains a single [`IntegrityIssue::MissingBlock`] and all other
231 /// checks are skipped.
232 /// 2. **Size mismatch** – If `actual_data.len() as u64 != expected_size`
233 /// a [`IntegrityIssue::SizeViolation`] is added.
234 /// 3. **Hash verification** – When `verify_cid_hash` is `true`, the
235 /// FNV-1a hash of `actual_data` is compared with the stored hash; a
236 /// difference adds a [`IntegrityIssue::CidMismatch`].
237 /// 4. **Magic bytes** – When `magic_bytes` is non-empty and
238 /// `actual_data` does not start with those bytes, a
239 /// [`IntegrityIssue::CorruptionMarker`] is added at `offset = 0`.
240 /// 5. **Max size** – If `actual_data.len() as u64 > max_size_bytes` a
241 /// [`IntegrityIssue::SizeViolation`] is added (using `max_size_bytes`
242 /// as `expected`).
243 ///
244 /// Stats are updated before returning.
245 pub fn scan_block(&mut self, cid: &str, actual_data: &[u8], scan_time_us: u64) -> ScanResult {
246 let record = match self.registry.get(cid) {
247 Some(r) => r.clone(),
248 None => {
249 self.stats.total_scanned += 1;
250 self.stats.with_issues += 1;
251 return ScanResult {
252 cid: cid.to_string(),
253 issues: vec![IntegrityIssue::MissingBlock {
254 cid: cid.to_string(),
255 }],
256 scan_duration_us: scan_time_us,
257 };
258 }
259 };
260
261 let mut issues = Vec::new();
262 let actual_len = actual_data.len() as u64;
263
264 // Check 2: size mismatch against registered expected_size
265 if actual_len != record.expected_size {
266 issues.push(IntegrityIssue::SizeViolation {
267 cid: cid.to_string(),
268 expected: record.expected_size,
269 actual: actual_len,
270 });
271 }
272
273 // Check 3: hash verification (CidMismatch)
274 if self.config.verify_cid_hash {
275 let actual_hash = fnv1a(actual_data);
276 if actual_hash != record.data_hash {
277 // In production callers replace `computed_cid` with the real
278 // recomputed CID; the scanner uses `cid` as a placeholder.
279 issues.push(IntegrityIssue::CidMismatch {
280 cid: cid.to_string(),
281 computed_cid: cid.to_string(),
282 });
283 }
284 }
285
286 // Check 4: magic bytes prefix
287 if !self.config.magic_bytes.is_empty() && !actual_data.starts_with(&self.config.magic_bytes)
288 {
289 issues.push(IntegrityIssue::CorruptionMarker {
290 cid: cid.to_string(),
291 offset: 0,
292 });
293 }
294
295 // Check 5: max size
296 if actual_len > self.config.max_size_bytes {
297 issues.push(IntegrityIssue::SizeViolation {
298 cid: cid.to_string(),
299 expected: self.config.max_size_bytes,
300 actual: actual_len,
301 });
302 }
303
304 // Update stats
305 self.stats.total_scanned += 1;
306 if issues.is_empty() {
307 self.stats.healthy += 1;
308 } else {
309 self.stats.with_issues += 1;
310 }
311
312 ScanResult {
313 cid: cid.to_string(),
314 issues,
315 scan_duration_us: scan_time_us,
316 }
317 }
318
319 /// Scans all supplied blocks and returns one [`ScanResult`] per block.
320 ///
321 /// Each element in `blocks` is a `(cid, data)` pair. The same
322 /// `scan_time_us` value is recorded for every block in the batch.
323 pub fn scan_all(&mut self, blocks: &[(String, Vec<u8>)], scan_time_us: u64) -> Vec<ScanResult> {
324 blocks
325 .iter()
326 .map(|(cid, data)| self.scan_block(cid, data, scan_time_us))
327 .collect()
328 }
329
330 /// Returns references to all [`ScanRecord`]s in the registry.
331 ///
332 /// This does **not** filter by scan history; it simply exposes the full
333 /// contents of the registry at the time of the call.
334 pub fn healthy_blocks(&self) -> Vec<&ScanRecord> {
335 self.registry.values().collect()
336 }
337
338 /// Returns a reference to the scanner's accumulated statistics.
339 pub fn stats(&self) -> &ScannerStats {
340 &self.stats
341 }
342}
343
344// ---------------------------------------------------------------------------
345// Tests
346// ---------------------------------------------------------------------------
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 fn default_scanner() -> BlockIntegrityScanner {
353 BlockIntegrityScanner::new(ScannerConfig::default())
354 }
355
356 /// Helper: build scanner, register a block, return (scanner, data).
357 fn scanner_with_block(cid: &str, data: &[u8]) -> (BlockIntegrityScanner, Vec<u8>) {
358 let mut s = default_scanner();
359 s.register_block(cid.to_string(), data.len() as u64, data, 1_000);
360 (s, data.to_vec())
361 }
362
363 // -----------------------------------------------------------------------
364 // 1. Register + scan healthy block
365 // -----------------------------------------------------------------------
366 #[test]
367 fn test_register_and_scan_healthy() {
368 let data = b"hello ipfrs";
369 let (mut s, d) = scanner_with_block("cid1", data);
370 let result = s.scan_block("cid1", &d, 42);
371 assert!(
372 result.is_healthy(),
373 "expected no issues, got {:?}",
374 result.issues
375 );
376 assert_eq!(result.cid, "cid1");
377 assert_eq!(result.scan_duration_us, 42);
378 }
379
380 // -----------------------------------------------------------------------
381 // 2. is_healthy returns true for empty issues
382 // -----------------------------------------------------------------------
383 #[test]
384 fn test_is_healthy_true() {
385 let result = ScanResult {
386 cid: "x".to_string(),
387 issues: vec![],
388 scan_duration_us: 0,
389 };
390 assert!(result.is_healthy());
391 }
392
393 // -----------------------------------------------------------------------
394 // 3. is_healthy returns false when issues present
395 // -----------------------------------------------------------------------
396 #[test]
397 fn test_is_healthy_false() {
398 let result = ScanResult {
399 cid: "x".to_string(),
400 issues: vec![IntegrityIssue::MissingBlock {
401 cid: "x".to_string(),
402 }],
403 scan_duration_us: 0,
404 };
405 assert!(!result.is_healthy());
406 }
407
408 // -----------------------------------------------------------------------
409 // 4. Missing block
410 // -----------------------------------------------------------------------
411 #[test]
412 fn test_missing_block() {
413 let mut s = default_scanner();
414 let result = s.scan_block("not-registered", b"anything", 10);
415 assert!(!result.is_healthy());
416 assert_eq!(result.issues.len(), 1);
417 assert_eq!(
418 result.issues[0],
419 IntegrityIssue::MissingBlock {
420 cid: "not-registered".to_string()
421 }
422 );
423 }
424
425 // -----------------------------------------------------------------------
426 // 5. Size violation — actual != expected
427 // -----------------------------------------------------------------------
428 #[test]
429 fn test_size_violation_wrong_length() {
430 let data = b"original data";
431 let (mut s, _) = scanner_with_block("cid-sz", data);
432 // Present shorter data
433 let result = s.scan_block("cid-sz", b"short", 0);
434 let has_size_violation = result.issues.iter().any(|i| {
435 matches!(i, IntegrityIssue::SizeViolation { expected, actual, .. }
436 if *expected == data.len() as u64 && *actual == 5)
437 });
438 assert!(
439 has_size_violation,
440 "expected SizeViolation, got {:?}",
441 result.issues
442 );
443 }
444
445 // -----------------------------------------------------------------------
446 // 6. Hash mismatch triggers CidMismatch
447 // -----------------------------------------------------------------------
448 #[test]
449 fn test_hash_mismatch_triggers_cid_mismatch() {
450 let original = b"block content v1";
451 let (mut s, _) = scanner_with_block("cid-hash", original);
452 // Same length but different content
453 let tampered = b"block content v2";
454 assert_eq!(original.len(), tampered.len());
455 let result = s.scan_block("cid-hash", tampered, 0);
456 let has_cid_mismatch = result
457 .issues
458 .iter()
459 .any(|i| matches!(i, IntegrityIssue::CidMismatch { .. }));
460 assert!(
461 has_cid_mismatch,
462 "expected CidMismatch, got {:?}",
463 result.issues
464 );
465 }
466
467 // -----------------------------------------------------------------------
468 // 7. No CidMismatch when verify_cid_hash is false
469 // -----------------------------------------------------------------------
470 #[test]
471 fn test_no_cid_mismatch_when_verify_disabled() {
472 let config = ScannerConfig {
473 verify_cid_hash: false,
474 ..ScannerConfig::default()
475 };
476 let mut s = BlockIntegrityScanner::new(config);
477 let original = b"block content v1";
478 s.register_block("cid-nv".to_string(), original.len() as u64, original, 0);
479 let tampered = b"block content v2";
480 let result = s.scan_block("cid-nv", tampered, 0);
481 let has_cid_mismatch = result
482 .issues
483 .iter()
484 .any(|i| matches!(i, IntegrityIssue::CidMismatch { .. }));
485 assert!(
486 !has_cid_mismatch,
487 "did not expect CidMismatch when verify disabled"
488 );
489 }
490
491 // -----------------------------------------------------------------------
492 // 8. Magic bytes check — mismatch triggers CorruptionMarker
493 // -----------------------------------------------------------------------
494 #[test]
495 fn test_magic_bytes_mismatch_triggers_corruption_marker() {
496 let config = ScannerConfig {
497 magic_bytes: vec![0xDE, 0xAD, 0xBE, 0xEF],
498 ..ScannerConfig::default()
499 };
500 let mut s = BlockIntegrityScanner::new(config);
501 let data = b"\x00\x00\x00\x00extra";
502 s.register_block("cid-magic".to_string(), data.len() as u64, data, 0);
503 let result = s.scan_block("cid-magic", data, 0);
504 let has_corruption = result
505 .issues
506 .iter()
507 .any(|i| matches!(i, IntegrityIssue::CorruptionMarker { offset: 0, .. }));
508 assert!(
509 has_corruption,
510 "expected CorruptionMarker, got {:?}",
511 result.issues
512 );
513 }
514
515 // -----------------------------------------------------------------------
516 // 9. Magic bytes check — matching prefix is fine
517 // -----------------------------------------------------------------------
518 #[test]
519 fn test_magic_bytes_match_no_corruption() {
520 let config = ScannerConfig {
521 magic_bytes: vec![0xDE, 0xAD, 0xBE, 0xEF],
522 ..ScannerConfig::default()
523 };
524 let mut s = BlockIntegrityScanner::new(config);
525 let data = b"\xDE\xAD\xBE\xEFrest";
526 s.register_block("cid-ok-magic".to_string(), data.len() as u64, data, 0);
527 let result = s.scan_block("cid-ok-magic", data, 0);
528 let has_corruption = result
529 .issues
530 .iter()
531 .any(|i| matches!(i, IntegrityIssue::CorruptionMarker { .. }));
532 assert!(
533 !has_corruption,
534 "did not expect CorruptionMarker, got {:?}",
535 result.issues
536 );
537 }
538
539 // -----------------------------------------------------------------------
540 // 10. Max size violation
541 // -----------------------------------------------------------------------
542 #[test]
543 fn test_max_size_violation() {
544 let config = ScannerConfig {
545 max_size_bytes: 8,
546 ..ScannerConfig::default()
547 }; // very small for testing
548 let mut s = BlockIntegrityScanner::new(config);
549 // Data is 10 bytes — exceeds max_size_bytes=8
550 let data = b"0123456789";
551 s.register_block("cid-big".to_string(), data.len() as u64, data, 0);
552 let result = s.scan_block("cid-big", data, 0);
553 let has_max_violation = result.issues.iter().any(|i| {
554 matches!(
555 i,
556 IntegrityIssue::SizeViolation {
557 expected: 8,
558 actual: 10,
559 ..
560 }
561 )
562 });
563 assert!(
564 has_max_violation,
565 "expected max-size SizeViolation, got {:?}",
566 result.issues
567 );
568 }
569
570 // -----------------------------------------------------------------------
571 // 11. scan_all returns one result per block
572 // -----------------------------------------------------------------------
573 #[test]
574 fn test_scan_all_returns_one_result_per_block() {
575 let mut s = default_scanner();
576 let data_a = b"block-a";
577 let data_b = b"block-b";
578 s.register_block("a".to_string(), data_a.len() as u64, data_a, 0);
579 s.register_block("b".to_string(), data_b.len() as u64, data_b, 0);
580 let blocks = vec![
581 ("a".to_string(), data_a.to_vec()),
582 ("b".to_string(), data_b.to_vec()),
583 ];
584 let results = s.scan_all(&blocks, 5);
585 assert_eq!(results.len(), 2);
586 assert!(results[0].is_healthy(), "block a should be healthy");
587 assert!(results[1].is_healthy(), "block b should be healthy");
588 }
589
590 // -----------------------------------------------------------------------
591 // 12. scan_all with a missing block
592 // -----------------------------------------------------------------------
593 #[test]
594 fn test_scan_all_includes_missing_block() {
595 let mut s = default_scanner();
596 let blocks = vec![("ghost".to_string(), b"data".to_vec())];
597 let results = s.scan_all(&blocks, 0);
598 assert_eq!(results.len(), 1);
599 assert!(!results[0].is_healthy());
600 assert!(results[0]
601 .issues
602 .iter()
603 .any(|i| matches!(i, IntegrityIssue::MissingBlock { .. })));
604 }
605
606 // -----------------------------------------------------------------------
607 // 13. issue_rate when no scans
608 // -----------------------------------------------------------------------
609 #[test]
610 fn test_issue_rate_no_scans() {
611 let s = default_scanner();
612 assert_eq!(s.stats().issue_rate(), 0.0);
613 }
614
615 // -----------------------------------------------------------------------
616 // 14. issue_rate calculation
617 // -----------------------------------------------------------------------
618 #[test]
619 fn test_issue_rate_calculation() {
620 let mut s = default_scanner();
621 let data = b"some data";
622 s.register_block("ok".to_string(), data.len() as u64, data, 0);
623 // healthy scan
624 s.scan_block("ok", data, 0);
625 // missing-block scan
626 s.scan_block("bad", b"x", 0);
627
628 let stats = s.stats();
629 assert_eq!(stats.total_scanned, 2);
630 assert_eq!(stats.healthy, 1);
631 assert_eq!(stats.with_issues, 1);
632 let rate = stats.issue_rate();
633 assert!(
634 (rate - 0.5).abs() < f64::EPSILON,
635 "expected 0.5, got {rate}"
636 );
637 }
638
639 // -----------------------------------------------------------------------
640 // 15. healthy_blocks count
641 // -----------------------------------------------------------------------
642 #[test]
643 fn test_healthy_blocks_count() {
644 let mut s = default_scanner();
645 s.register_block("c1".to_string(), 4, b"aaaa", 0);
646 s.register_block("c2".to_string(), 4, b"bbbb", 0);
647 s.register_block("c3".to_string(), 4, b"cccc", 0);
648 // healthy_blocks returns all registry entries regardless of scans
649 assert_eq!(s.healthy_blocks().len(), 3);
650 }
651
652 // -----------------------------------------------------------------------
653 // 16. Stats update correctly across multiple scans
654 // -----------------------------------------------------------------------
655 #[test]
656 fn test_stats_accumulate_correctly() {
657 let mut s = default_scanner();
658 let data = b"payload";
659 for i in 0..5_u32 {
660 s.register_block(format!("cid-{i}"), data.len() as u64, data, 0);
661 }
662 // Scan all five healthy
663 for i in 0..5_u32 {
664 s.scan_block(&format!("cid-{i}"), data, 0);
665 }
666 // Scan one missing
667 s.scan_block("missing", b"x", 0);
668
669 let st = s.stats();
670 assert_eq!(st.total_scanned, 6);
671 assert_eq!(st.healthy, 5);
672 assert_eq!(st.with_issues, 1);
673 }
674
675 // -----------------------------------------------------------------------
676 // 17. Multiple issues in a single scan
677 // -----------------------------------------------------------------------
678 #[test]
679 fn test_multiple_issues_in_single_scan() {
680 let config = ScannerConfig {
681 magic_bytes: vec![0xFF],
682 max_size_bytes: 3,
683 ..ScannerConfig::default()
684 };
685 let mut s = BlockIntegrityScanner::new(config);
686
687 // Register a 5-byte block that starts with 0xFF
688 let original = b"\xFF\x00\x01\x02\x03";
689 s.register_block("multi".to_string(), original.len() as u64, original, 0);
690
691 // Present 5-byte block that does NOT start with 0xFF and has different hash
692 let tampered = b"\x00\x00\x01\x02\x03";
693 let result = s.scan_block("multi", tampered, 100);
694
695 // Should see: CidMismatch, CorruptionMarker, SizeViolation(max)
696 let has_cid = result
697 .issues
698 .iter()
699 .any(|i| matches!(i, IntegrityIssue::CidMismatch { .. }));
700 let has_cor = result
701 .issues
702 .iter()
703 .any(|i| matches!(i, IntegrityIssue::CorruptionMarker { .. }));
704 let has_sz = result
705 .issues
706 .iter()
707 .any(|i| matches!(i, IntegrityIssue::SizeViolation { .. }));
708 assert!(has_cid, "expected CidMismatch");
709 assert!(has_cor, "expected CorruptionMarker");
710 assert!(has_sz, "expected SizeViolation");
711 assert_eq!(result.scan_duration_us, 100);
712 }
713
714 // -----------------------------------------------------------------------
715 // 18. FNV-1a hash is deterministic
716 // -----------------------------------------------------------------------
717 #[test]
718 fn test_fnv1a_deterministic() {
719 let data = b"deterministic";
720 assert_eq!(fnv1a(data), fnv1a(data));
721 }
722
723 // -----------------------------------------------------------------------
724 // 19. Registering same CID twice overwrites the record
725 // -----------------------------------------------------------------------
726 #[test]
727 fn test_register_overwrites_existing() {
728 let mut s = default_scanner();
729 s.register_block("dup".to_string(), 4, b"aaaa", 100);
730 s.register_block("dup".to_string(), 8, b"bbbbbbbb", 200);
731 let record = s.registry.get("dup").expect("record should exist");
732 assert_eq!(record.expected_size, 8);
733 assert_eq!(record.registered_at_secs, 200);
734 }
735
736 // -----------------------------------------------------------------------
737 // 20. Empty magic_bytes skips corruption check
738 // -----------------------------------------------------------------------
739 #[test]
740 fn test_empty_magic_bytes_skips_corruption_check() {
741 let config = ScannerConfig {
742 magic_bytes: vec![],
743 ..ScannerConfig::default()
744 };
745 let mut s = BlockIntegrityScanner::new(config);
746 let data = b"anything goes here";
747 s.register_block("cid-em".to_string(), data.len() as u64, data, 0);
748 let result = s.scan_block("cid-em", data, 0);
749 let has_corruption = result
750 .issues
751 .iter()
752 .any(|i| matches!(i, IntegrityIssue::CorruptionMarker { .. }));
753 assert!(!has_corruption);
754 assert!(result.is_healthy());
755 }
756}