Skip to main content

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}