Skip to main content

ipfrs_storage/
integrity_checker.rs

1//! Block Integrity Checker
2//!
3//! Verifies stored blocks have valid CIDs matching their content,
4//! detecting corruption and reporting detailed results.
5
6/// Errors that can occur during integrity checking.
7#[derive(Clone, Debug, PartialEq)]
8pub enum IntegrityError {
9    /// The stored CID does not match the computed CID for the block data.
10    CidMismatch {
11        stored_cid: String,
12        computed_cid: String,
13    },
14    /// The block has no data (empty).
15    EmptyBlock { cid: String },
16    /// The CID string is not in a valid format.
17    InvalidCidFormat { cid: String, reason: String },
18    /// The hash function encoded in the CID is not supported.
19    HashFunctionUnsupported { function_code: u64 },
20}
21
22/// The result of checking a single block.
23#[derive(Clone, Debug, PartialEq)]
24pub struct CheckedBlock {
25    /// The CID of the checked block.
26    pub cid: String,
27    /// Size of the block data in bytes.
28    pub size_bytes: usize,
29    /// Outcome of the integrity check.
30    pub status: CheckStatus,
31}
32
33/// The status of a block after integrity checking.
34#[derive(Clone, Debug, PartialEq)]
35pub enum CheckStatus {
36    /// The block's CID matches the computed CID from its content.
37    Valid,
38    /// The block failed integrity checking.
39    Invalid(IntegrityError),
40    /// The block was skipped (e.g. unsupported codec, empty block with skip_empty=true).
41    Skipped { reason: String },
42}
43
44/// Configuration for the `BlockIntegrityChecker`.
45#[derive(Clone, Debug)]
46pub struct CheckerConfig {
47    /// If `true`, empty blocks are skipped rather than treated as errors.
48    pub skip_empty: bool,
49    /// Stop after checking this many blocks (if `Some`).
50    pub max_blocks: Option<usize>,
51    /// The hash function to use when computing expected CIDs.
52    pub hash_fn: HashFunction,
53}
54
55impl Default for CheckerConfig {
56    fn default() -> Self {
57        Self {
58            skip_empty: false,
59            max_blocks: None,
60            hash_fn: HashFunction::Sha256,
61        }
62    }
63}
64
65/// Hash function used to verify block CIDs.
66#[derive(Clone, Debug, PartialEq)]
67pub enum HashFunction {
68    /// Standard CID hash (produces "bafy…" prefixed CIDs).
69    Sha256,
70    /// BLAKE3 hash (produces "bafk…" prefixed CIDs).
71    Blake3,
72    /// Identity: CID content equals the raw data.
73    /// Used for test blocks — CID is `identity:<hex of first 8 bytes>`.
74    Identity,
75}
76
77/// Aggregated report from checking a set of blocks.
78#[derive(Debug)]
79pub struct IntegrityReport {
80    /// Total number of blocks that were checked (including skipped).
81    pub total_checked: usize,
82    /// Number of blocks whose CID matched the computed CID.
83    pub valid: usize,
84    /// Number of blocks whose CID did not match or were otherwise invalid.
85    pub invalid: usize,
86    /// Number of blocks that were skipped.
87    pub skipped: usize,
88    /// Per-block results.
89    pub results: Vec<CheckedBlock>,
90}
91
92impl IntegrityReport {
93    /// Returns the ratio of invalid blocks to total checked blocks.
94    /// Returns `0.0` if no blocks were checked.
95    pub fn error_rate(&self) -> f64 {
96        if self.total_checked == 0 {
97            0.0
98        } else {
99            self.invalid as f64 / self.total_checked as f64
100        }
101    }
102
103    /// Returns the CIDs of all invalid blocks.
104    pub fn invalid_cids(&self) -> Vec<&str> {
105        self.results
106            .iter()
107            .filter(|b| matches!(b.status, CheckStatus::Invalid(_)))
108            .map(|b| b.cid.as_str())
109            .collect()
110    }
111}
112
113// ---------------------------------------------------------------------------
114// Internal helpers
115// ---------------------------------------------------------------------------
116
117/// FNV-1a 64-bit hash — used for CID simulation.
118fn fnv1a_64(data: &[u8]) -> u64 {
119    const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
120    const PRIME: u64 = 1_099_511_628_211;
121    let mut hash = OFFSET_BASIS;
122    for &byte in data {
123        hash ^= byte as u64;
124        hash = hash.wrapping_mul(PRIME);
125    }
126    hash
127}
128
129// ---------------------------------------------------------------------------
130// BlockIntegrityChecker
131// ---------------------------------------------------------------------------
132
133/// Checks stored blocks by verifying that their CIDs match the computed CID
134/// derived from the block content.
135pub struct BlockIntegrityChecker {
136    /// Configuration controlling check behaviour.
137    pub config: CheckerConfig,
138}
139
140impl BlockIntegrityChecker {
141    /// Creates a new checker with the given configuration.
142    pub fn new(config: CheckerConfig) -> Self {
143        Self { config }
144    }
145
146    /// Verifies that `cid` is a syntactically valid CID string.
147    ///
148    /// Currently this is a simplified check: an empty string is rejected,
149    /// any non-empty string is considered valid.
150    pub fn verify_cid_format(&self, cid: &str) -> Result<(), IntegrityError> {
151        if cid.is_empty() {
152            return Err(IntegrityError::InvalidCidFormat {
153                cid: cid.to_string(),
154                reason: "empty".to_string(),
155            });
156        }
157        Ok(())
158    }
159
160    /// Computes the expected CID for `data` using the configured hash function.
161    fn compute_cid(&self, data: &[u8]) -> String {
162        match self.config.hash_fn {
163            HashFunction::Identity => {
164                // CID = "identity:" + hex of first 8 bytes (zero-padded)
165                let len = data.len().min(8);
166                let hex_str: String = data[..len].iter().map(|b| format!("{:02x}", b)).collect();
167                format!("identity:{}", hex_str)
168            }
169            HashFunction::Sha256 => {
170                // Simplified simulation: "bafy" + FNV-1a hash of data as hex
171                let hash = fnv1a_64(data);
172                format!("bafy{:016x}", hash)
173            }
174            HashFunction::Blake3 => {
175                // Simplified simulation: "bafk" + FNV-1a of reversed data as hex
176                let mut reversed = data.to_vec();
177                reversed.reverse();
178                let hash = fnv1a_64(&reversed);
179                format!("bafk{:016x}", hash)
180            }
181        }
182    }
183
184    /// Checks the integrity of a single block.
185    ///
186    /// # Arguments
187    /// * `cid`  – The CID stored alongside the block.
188    /// * `data` – The raw block bytes.
189    pub fn check_block(&self, cid: &str, data: &[u8]) -> CheckedBlock {
190        // Handle empty data
191        if data.is_empty() {
192            if self.config.skip_empty {
193                return CheckedBlock {
194                    cid: cid.to_string(),
195                    size_bytes: 0,
196                    status: CheckStatus::Skipped {
197                        reason: "empty block".to_string(),
198                    },
199                };
200            } else {
201                return CheckedBlock {
202                    cid: cid.to_string(),
203                    size_bytes: 0,
204                    status: CheckStatus::Invalid(IntegrityError::EmptyBlock {
205                        cid: cid.to_string(),
206                    }),
207                };
208            }
209        }
210
211        // Validate CID format
212        if let Err(e) = self.verify_cid_format(cid) {
213            return CheckedBlock {
214                cid: cid.to_string(),
215                size_bytes: data.len(),
216                status: CheckStatus::Invalid(e),
217            };
218        }
219
220        // Compute expected CID and compare
221        let computed = self.compute_cid(data);
222        let status = if cid == computed {
223            CheckStatus::Valid
224        } else {
225            CheckStatus::Invalid(IntegrityError::CidMismatch {
226                stored_cid: cid.to_string(),
227                computed_cid: computed,
228            })
229        };
230
231        CheckedBlock {
232            cid: cid.to_string(),
233            size_bytes: data.len(),
234            status,
235        }
236    }
237
238    /// Checks a slice of `(cid, data)` pairs and returns an aggregated report.
239    ///
240    /// Respects `config.max_blocks`: stops after that many checks.
241    pub fn check_blocks(&self, blocks: &[(&str, &[u8])]) -> IntegrityReport {
242        let limit = self
243            .config
244            .max_blocks
245            .unwrap_or(usize::MAX)
246            .min(blocks.len());
247
248        let mut results = Vec::with_capacity(limit);
249        let mut valid = 0usize;
250        let mut invalid = 0usize;
251        let mut skipped = 0usize;
252
253        for (cid, data) in blocks.iter().take(limit) {
254            let checked = self.check_block(cid, data);
255            match &checked.status {
256                CheckStatus::Valid => valid += 1,
257                CheckStatus::Invalid(_) => invalid += 1,
258                CheckStatus::Skipped { .. } => skipped += 1,
259            }
260            results.push(checked);
261        }
262
263        IntegrityReport {
264            total_checked: results.len(),
265            valid,
266            invalid,
267            skipped,
268            results,
269        }
270    }
271}
272
273// ---------------------------------------------------------------------------
274// Tests
275// ---------------------------------------------------------------------------
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn default_checker() -> BlockIntegrityChecker {
282        BlockIntegrityChecker::new(CheckerConfig::default())
283    }
284
285    fn sha256_checker() -> BlockIntegrityChecker {
286        BlockIntegrityChecker::new(CheckerConfig {
287            hash_fn: HashFunction::Sha256,
288            ..CheckerConfig::default()
289        })
290    }
291
292    fn identity_checker() -> BlockIntegrityChecker {
293        BlockIntegrityChecker::new(CheckerConfig {
294            hash_fn: HashFunction::Identity,
295            ..CheckerConfig::default()
296        })
297    }
298
299    fn blake3_checker() -> BlockIntegrityChecker {
300        BlockIntegrityChecker::new(CheckerConfig {
301            hash_fn: HashFunction::Blake3,
302            ..CheckerConfig::default()
303        })
304    }
305
306    // 1. new() with default config
307    #[test]
308    fn test_new_default_config() {
309        let checker = default_checker();
310        assert!(!checker.config.skip_empty);
311        assert!(checker.config.max_blocks.is_none());
312        assert_eq!(checker.config.hash_fn, HashFunction::Sha256);
313    }
314
315    // 2. Empty data, skip_empty = false → Invalid(EmptyBlock)
316    #[test]
317    fn test_check_block_empty_skip_false() {
318        let checker = default_checker();
319        let result = checker.check_block("bafytest", &[]);
320        assert_eq!(
321            result.status,
322            CheckStatus::Invalid(IntegrityError::EmptyBlock {
323                cid: "bafytest".to_string()
324            })
325        );
326    }
327
328    // 3. Empty data, skip_empty = true → Skipped
329    #[test]
330    fn test_check_block_empty_skip_true() {
331        let checker = BlockIntegrityChecker::new(CheckerConfig {
332            skip_empty: true,
333            ..CheckerConfig::default()
334        });
335        let result = checker.check_block("bafytest", &[]);
336        assert!(matches!(result.status, CheckStatus::Skipped { .. }));
337    }
338
339    // 4. Identity hash matches computed CID
340    #[test]
341    fn test_check_block_identity_matches() {
342        let checker = identity_checker();
343        let data = b"hello world";
344        let cid = checker.compute_cid(data);
345        let result = checker.check_block(&cid, data);
346        assert_eq!(result.status, CheckStatus::Valid);
347    }
348
349    // 5. Identity hash with wrong CID → CidMismatch
350    #[test]
351    fn test_check_block_identity_mismatch() {
352        let checker = identity_checker();
353        let data = b"hello world";
354        let result = checker.check_block("identity:wrongvalue", data);
355        assert!(matches!(
356            result.status,
357            CheckStatus::Invalid(IntegrityError::CidMismatch { .. })
358        ));
359    }
360
361    // 6. Sha256 computed CID has "bafy" prefix
362    #[test]
363    fn test_check_block_sha256_prefix() {
364        let checker = sha256_checker();
365        let data = b"some block data";
366        let cid = checker.compute_cid(data);
367        assert!(
368            cid.starts_with("bafy"),
369            "CID should start with 'bafy', got: {}",
370            cid
371        );
372    }
373
374    // 7. Blake3 computed CID has "bafk" prefix
375    #[test]
376    fn test_check_block_blake3_prefix() {
377        let checker = blake3_checker();
378        let data = b"some block data";
379        let cid = checker.compute_cid(data);
380        assert!(
381            cid.starts_with("bafk"),
382            "CID should start with 'bafk', got: {}",
383            cid
384        );
385    }
386
387    // 8. check_blocks: multiple blocks, counts correct
388    #[test]
389    fn test_check_blocks_counts() {
390        let checker = sha256_checker();
391        let data1 = b"block one";
392        let data2 = b"block two";
393        let cid1 = checker.compute_cid(data1);
394        let cid2 = checker.compute_cid(data2);
395        let blocks: Vec<(&str, &[u8])> = vec![
396            (cid1.as_str(), data1.as_ref()),
397            (cid2.as_str(), data2.as_ref()),
398        ];
399        let report = checker.check_blocks(&blocks);
400        assert_eq!(report.total_checked, 2);
401        assert_eq!(report.valid, 2);
402        assert_eq!(report.invalid, 0);
403        assert_eq!(report.skipped, 0);
404    }
405
406    // 9. check_blocks: max_blocks limit respected
407    #[test]
408    fn test_check_blocks_max_blocks() {
409        let checker = BlockIntegrityChecker::new(CheckerConfig {
410            max_blocks: Some(2),
411            hash_fn: HashFunction::Sha256,
412            ..CheckerConfig::default()
413        });
414        let data: &[u8] = b"data";
415        let cid = checker.compute_cid(data);
416        let blocks: Vec<(&str, &[u8])> = vec![
417            (cid.as_str(), data),
418            (cid.as_str(), data),
419            (cid.as_str(), data),
420            (cid.as_str(), data),
421        ];
422        let report = checker.check_blocks(&blocks);
423        assert_eq!(report.total_checked, 2);
424    }
425
426    // 10. check_blocks: mix of valid and invalid
427    #[test]
428    fn test_check_blocks_mixed() {
429        let checker = sha256_checker();
430        let data = b"real data";
431        let good_cid = checker.compute_cid(data);
432        let blocks: Vec<(&str, &[u8])> = vec![
433            (good_cid.as_str(), data.as_ref()),
434            ("bafybadcid", data.as_ref()),
435        ];
436        let report = checker.check_blocks(&blocks);
437        assert_eq!(report.valid, 1);
438        assert_eq!(report.invalid, 1);
439    }
440
441    // 11. IntegrityReport error_rate calculation
442    #[test]
443    fn test_error_rate() {
444        let checker = sha256_checker();
445        let data = b"some data";
446        let good_cid = checker.compute_cid(data);
447        let blocks: Vec<(&str, &[u8])> = vec![
448            (good_cid.as_str(), data.as_ref()),
449            ("bafybad1", data.as_ref()),
450            ("bafybad2", data.as_ref()),
451            ("bafybad3", data.as_ref()),
452        ];
453        let report = checker.check_blocks(&blocks);
454        let rate = report.error_rate();
455        assert!(
456            (rate - 0.75).abs() < f64::EPSILON,
457            "Expected 0.75, got {}",
458            rate
459        );
460    }
461
462    // 12. IntegrityReport error_rate when no blocks checked
463    #[test]
464    fn test_error_rate_zero_blocks() {
465        let report = IntegrityReport {
466            total_checked: 0,
467            valid: 0,
468            invalid: 0,
469            skipped: 0,
470            results: vec![],
471        };
472        assert_eq!(report.error_rate(), 0.0);
473    }
474
475    // 13. IntegrityReport invalid_cids returns correct CIDs
476    #[test]
477    fn test_invalid_cids() {
478        let checker = sha256_checker();
479        let data = b"some data";
480        let good_cid = checker.compute_cid(data);
481        let blocks: Vec<(&str, &[u8])> = vec![
482            (good_cid.as_str(), data.as_ref()),
483            ("bafybad_a", data.as_ref()),
484            ("bafybad_b", data.as_ref()),
485        ];
486        let report = checker.check_blocks(&blocks);
487        let mut bad = report.invalid_cids();
488        bad.sort_unstable();
489        assert_eq!(bad, vec!["bafybad_a", "bafybad_b"]);
490    }
491
492    // 14. verify_cid_format: empty string → Err
493    #[test]
494    fn test_verify_cid_format_empty() {
495        let checker = default_checker();
496        let result = checker.verify_cid_format("");
497        assert!(result.is_err());
498        assert!(matches!(
499            result.unwrap_err(),
500            IntegrityError::InvalidCidFormat { .. }
501        ));
502    }
503
504    // 15. verify_cid_format: non-empty → Ok
505    #[test]
506    fn test_verify_cid_format_nonempty() {
507        let checker = default_checker();
508        assert!(checker
509            .verify_cid_format("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi")
510            .is_ok());
511        assert!(checker
512            .verify_cid_format("QmPK1s3pNYLi9ERiq3BDxKa4XosgWwFRQUydHUtz4YgpqB")
513            .is_ok());
514    }
515
516    // 16. Two identical data inputs produce the same CID
517    #[test]
518    fn test_identical_data_same_cid() {
519        let checker = sha256_checker();
520        let data = b"deterministic test data";
521        let cid1 = checker.compute_cid(data);
522        let cid2 = checker.compute_cid(data);
523        assert_eq!(cid1, cid2);
524    }
525
526    // 17. Different data produce different CIDs (Sha256)
527    #[test]
528    fn test_different_data_different_cids() {
529        let checker = sha256_checker();
530        let cid_a = checker.compute_cid(b"data alpha");
531        let cid_b = checker.compute_cid(b"data beta");
532        assert_ne!(cid_a, cid_b);
533    }
534
535    // 18. valid + invalid + skipped == total_checked
536    #[test]
537    fn test_sum_equals_total() {
538        let checker = BlockIntegrityChecker::new(CheckerConfig {
539            skip_empty: true,
540            hash_fn: HashFunction::Sha256,
541            ..CheckerConfig::default()
542        });
543        let data = b"real data";
544        let good_cid = checker.compute_cid(data);
545        let blocks: Vec<(&str, &[u8])> = vec![
546            (good_cid.as_str(), data.as_ref()), // valid
547            ("bafybad", data.as_ref()),         // invalid (CID mismatch)
548            ("bafyempty", b""),                 // skipped (empty + skip_empty=true)
549        ];
550        let report = checker.check_blocks(&blocks);
551        assert_eq!(
552            report.valid + report.invalid + report.skipped,
553            report.total_checked
554        );
555        assert_eq!(report.total_checked, 3);
556        assert_eq!(report.valid, 1);
557        assert_eq!(report.invalid, 1);
558        assert_eq!(report.skipped, 1);
559    }
560}