1use tracing::{debug, error, info, warn};
14
15use crate::db_fec::{self, DbFecGroupMeta, RepairResult};
16
17const BEAD_ID: &str = "bd-n0g4q.3";
18
19#[must_use]
25pub fn blake3_page_checksum(page_data: &[u8]) -> [u8; 32] {
26 *blake3::hash(page_data).as_bytes()
27}
28
29#[must_use]
31pub fn verify_page_blake3(page_data: &[u8], expected: &[u8; 32]) -> bool {
32 blake3_page_checksum(page_data) == *expected
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45pub struct RepairWitness {
46 pub pgno: u32,
48 pub corrupted_hash: [u8; 32],
50 pub repaired_hash: [u8; 32],
52 pub expected_hash: [u8; 32],
54 pub verified: bool,
56 pub symbols_used: u32,
58 pub corrupt_pages_in_group: u32,
60}
61
62impl RepairWitness {
63 #[must_use]
65 pub fn is_success(&self) -> bool {
66 self.verified
67 }
68}
69
70#[derive(Debug, Clone)]
76pub enum RepairOutcome {
77 Intact { pgno: u32, blake3_hash: [u8; 32] },
79 Repaired {
81 pgno: u32,
82 repaired_data: Vec<u8>,
83 witness: RepairWitness,
84 },
85 Unrecoverable {
88 pgno: u32,
89 witness: Option<RepairWitness>,
90 detail: String,
91 },
92}
93
94#[allow(clippy::too_many_lines)]
116pub fn detect_and_repair_page(
117 target_pgno: u32,
118 page_data: &[u8],
119 expected_blake3: &[u8; 32],
120 group_meta: &DbFecGroupMeta,
121 all_page_data: &dyn Fn(u32) -> Vec<u8>,
122 repair_symbols: &[(u32, Vec<u8>)],
123) -> RepairOutcome {
124 let group_end = group_meta.start_pgno + group_meta.group_size;
126 if target_pgno < group_meta.start_pgno || target_pgno >= group_end {
127 return RepairOutcome::Unrecoverable {
128 pgno: target_pgno,
129 witness: None,
130 detail: format!(
131 "page {target_pgno} is outside group range [{}, {})",
132 group_meta.start_pgno, group_end,
133 ),
134 };
135 }
136
137 let actual_hash = blake3_page_checksum(page_data);
138
139 if actual_hash == *expected_blake3 {
141 debug!(
142 bead_id = BEAD_ID,
143 pgno = target_pgno,
144 "page intact — BLAKE3 checksum verified"
145 );
146 return RepairOutcome::Intact {
147 pgno: target_pgno,
148 blake3_hash: actual_hash,
149 };
150 }
151
152 info!(
153 bead_id = BEAD_ID,
154 pgno = target_pgno,
155 group_start = group_meta.start_pgno,
156 K = group_meta.group_size,
157 R = group_meta.r_repair,
158 "BLAKE3 mismatch detected — initiating repair"
159 );
160
161 let mut corrupt_count: u32 = 0;
163 for i in 0..group_meta.group_size {
164 let pgno = group_meta.start_pgno + i;
165 let data = if pgno == target_pgno {
166 page_data.to_vec()
167 } else {
168 all_page_data(pgno)
169 };
170 if !db_fec::verify_page_xxh3_128(&data, &group_meta.source_page_xxh3_128[i as usize]) {
171 corrupt_count += 1;
172 }
173 }
174
175 match db_fec::attempt_page_repair(target_pgno, group_meta, all_page_data, repair_symbols) {
177 Ok((repaired_data, repair_result)) => {
178 let repaired_hash = blake3_page_checksum(&repaired_data);
179 let verified = repaired_hash == *expected_blake3;
180
181 let RepairResult::Repaired { symbols_used, .. } = &repair_result else {
182 return RepairOutcome::Unrecoverable {
185 pgno: target_pgno,
186 witness: None,
187 detail: format!(
188 "page {target_pgno}: unexpected repair result variant: {repair_result:?}"
189 ),
190 };
191 };
192 let symbols_used = *symbols_used;
193
194 let witness = RepairWitness {
195 pgno: target_pgno,
196 corrupted_hash: actual_hash,
197 repaired_hash,
198 expected_hash: *expected_blake3,
199 verified,
200 symbols_used,
201 corrupt_pages_in_group: corrupt_count,
202 };
203
204 if verified {
205 info!(
206 bead_id = BEAD_ID,
207 pgno = target_pgno,
208 symbols_used,
209 corrupt_in_group = corrupt_count,
210 "page repair VERIFIED — BLAKE3 witness confirmed"
211 );
212 RepairOutcome::Repaired {
213 pgno: target_pgno,
214 repaired_data,
215 witness,
216 }
217 } else {
218 warn!(
219 bead_id = BEAD_ID,
220 pgno = target_pgno,
221 "page repair produced data but BLAKE3 verification FAILED"
222 );
223 RepairOutcome::Unrecoverable {
224 pgno: target_pgno,
225 witness: Some(witness),
226 detail: format!("page {target_pgno}: repaired data failed BLAKE3 verification"),
227 }
228 }
229 }
230 Err(err) => {
231 error!(
232 bead_id = BEAD_ID,
233 pgno = target_pgno,
234 corrupt_in_group = corrupt_count,
235 error = %err,
236 "page repair FAILED — insufficient symbols"
237 );
238 RepairOutcome::Unrecoverable {
239 pgno: target_pgno,
240 witness: Some(RepairWitness {
241 pgno: target_pgno,
242 corrupted_hash: actual_hash,
243 repaired_hash: [0u8; 32],
244 expected_hash: *expected_blake3,
245 verified: false,
246 symbols_used: 0,
247 corrupt_pages_in_group: corrupt_count,
248 }),
249 detail: format!("{err}"),
250 }
251 }
252 }
253}
254
255pub fn detect_and_repair_pages(
261 target_pgnos: &[u32],
262 group_meta: &DbFecGroupMeta,
263 all_page_data: &dyn Fn(u32) -> Vec<u8>,
264 expected_blake3s: &dyn Fn(u32) -> [u8; 32],
265 repair_symbols: &[(u32, Vec<u8>)],
266) -> Vec<RepairOutcome> {
267 target_pgnos
268 .iter()
269 .map(|&pgno| {
270 let data = all_page_data(pgno);
271 let expected = expected_blake3s(pgno);
272 detect_and_repair_page(
273 pgno,
274 &data,
275 &expected,
276 group_meta,
277 all_page_data,
278 repair_symbols,
279 )
280 })
281 .collect()
282}
283
284#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::db_fec::{
292 DbFecGroupMeta, compute_db_gen_digest, compute_raptorq_repair_symbols, page_xxh3_128,
293 };
294
295 #[allow(clippy::cast_possible_truncation)]
297 fn make_test_pages(k: u32, page_size: usize) -> Vec<Vec<u8>> {
298 (0..k)
299 .map(|i| {
300 let mut data = vec![0u8; page_size];
301 for (j, b) in data.iter_mut().enumerate() {
302 *b = ((i as usize * 41 + j * 7) & 0xFF) as u8;
303 }
304 data
305 })
306 .collect()
307 }
308
309 fn make_test_group(
311 pages: &[Vec<u8>],
312 page_size: u32,
313 r_repair: u32,
314 start_pgno: u32,
315 ) -> (DbFecGroupMeta, Vec<(u32, Vec<u8>)>) {
316 let k = u32::try_from(pages.len()).expect("k fits u32");
317 let hashes: Vec<[u8; 16]> = pages.iter().map(|d| page_xxh3_128(d)).collect();
318 let digest = compute_db_gen_digest(1, k + 1, 0, 1);
319 let meta = DbFecGroupMeta::new(page_size, start_pgno, k, r_repair, hashes, digest);
320
321 let slices: Vec<&[u8]> = pages.iter().map(Vec::as_slice).collect();
322 let repair_data =
323 compute_raptorq_repair_symbols(&meta, &slices, page_size as usize).expect("encode");
324 let repair_symbols: Vec<(u32, Vec<u8>)> = repair_data
325 .into_iter()
326 .enumerate()
327 .map(|(i, d)| (k + u32::try_from(i).expect("i fits u32"), d))
328 .collect();
329
330 (meta, repair_symbols)
331 }
332
333 #[test]
336 fn test_blake3_checksum_deterministic() {
337 let data = vec![0xAB_u8; 512];
338 let h1 = blake3_page_checksum(&data);
339 let h2 = blake3_page_checksum(&data);
340 assert_eq!(h1, h2);
341 }
342
343 #[test]
344 fn test_blake3_checksum_sensitive_to_content() {
345 let data_a = vec![0x01_u8; 512];
346 let data_b = vec![0x02_u8; 512];
347 assert_ne!(blake3_page_checksum(&data_a), blake3_page_checksum(&data_b));
348 }
349
350 #[test]
351 fn test_blake3_verify_page() {
352 let data = vec![0x42_u8; 1024];
353 let hash = blake3_page_checksum(&data);
354 assert!(verify_page_blake3(&data, &hash));
355 let mut corrupted = data;
356 corrupted[0] ^= 0xFF;
357 assert!(!verify_page_blake3(&corrupted, &hash));
358 }
359
360 #[test]
363 fn test_detect_intact_page() {
364 let pages = make_test_pages(4, 128);
365 let (meta, repair_symbols) = make_test_group(&pages, 128, 4, 2);
366 let expected_hash = blake3_page_checksum(&pages[0]);
367
368 let read_fn = |pgno: u32| -> Vec<u8> { pages[(pgno - 2) as usize].clone() };
369
370 let outcome = detect_and_repair_page(
371 2,
372 &pages[0],
373 &expected_hash,
374 &meta,
375 &read_fn,
376 &repair_symbols,
377 );
378
379 assert!(matches!(outcome, RepairOutcome::Intact { pgno: 2, .. }));
380 }
381
382 #[test]
385 fn test_detect_and_repair_single_corruption() {
386 let pages = make_test_pages(4, 128);
387 let (meta, repair_symbols) = make_test_group(&pages, 128, 4, 2);
388 let expected_hash = blake3_page_checksum(&pages[1]);
389
390 let corrupted = vec![0xFF_u8; 128];
391 let read_fn = |pgno: u32| -> Vec<u8> {
392 if pgno == 3 {
393 corrupted.clone()
394 } else {
395 pages[(pgno - 2) as usize].clone()
396 }
397 };
398
399 let outcome = detect_and_repair_page(
400 3,
401 &corrupted,
402 &expected_hash,
403 &meta,
404 &read_fn,
405 &repair_symbols,
406 );
407
408 match outcome {
409 RepairOutcome::Repaired {
410 pgno,
411 repaired_data,
412 witness,
413 } => {
414 assert_eq!(pgno, 3);
415 assert_eq!(repaired_data, pages[1]);
416 assert!(witness.verified);
417 assert_eq!(witness.corrupted_hash, blake3_page_checksum(&corrupted));
418 assert_eq!(witness.repaired_hash, expected_hash);
419 assert_eq!(witness.expected_hash, expected_hash);
420 assert!(witness.corrupt_pages_in_group >= 1);
421 }
422 other => panic!("expected Repaired, got {other:?}"),
423 }
424 }
425
426 #[test]
429 fn test_detect_and_repair_multi_corruption() {
430 let pages = make_test_pages(8, 128);
431 let (meta, repair_symbols) = make_test_group(&pages, 128, 4, 2);
432
433 let corrupted = vec![0xCC_u8; 128];
434 let corrupt_pgnos = [2_u32, 3, 4]; let read_fn = |pgno: u32| -> Vec<u8> {
437 if corrupt_pgnos.contains(&pgno) {
438 corrupted.clone()
439 } else {
440 pages[(pgno - 2) as usize].clone()
441 }
442 };
443
444 for &target in &corrupt_pgnos {
445 let idx = (target - 2) as usize;
446 let expected_hash = blake3_page_checksum(&pages[idx]);
447 let outcome = detect_and_repair_page(
448 target,
449 &corrupted,
450 &expected_hash,
451 &meta,
452 &read_fn,
453 &repair_symbols,
454 );
455
456 match outcome {
457 RepairOutcome::Repaired {
458 repaired_data,
459 witness,
460 ..
461 } => {
462 assert_eq!(repaired_data, pages[idx]);
463 assert!(witness.verified);
464 assert!(witness.corrupt_pages_in_group >= 3);
465 }
466 other => panic!("expected Repaired for page {target}, got {other:?}"),
467 }
468 }
469 }
470
471 #[test]
474 fn test_detect_and_repair_contiguous_range() {
475 let pages = make_test_pages(8, 64);
476 let (meta, repair_symbols) = make_test_group(&pages, 64, 8, 2);
480
481 let corrupted = vec![0xBB_u8; 64];
482 let corrupt_pgnos = [5_u32, 6, 7, 8];
484
485 let read_fn = |pgno: u32| -> Vec<u8> {
486 if corrupt_pgnos.contains(&pgno) {
487 corrupted.clone()
488 } else {
489 pages[(pgno - 2) as usize].clone()
490 }
491 };
492
493 for &target in &corrupt_pgnos {
494 let idx = (target - 2) as usize;
495 let expected_hash = blake3_page_checksum(&pages[idx]);
496 let outcome = detect_and_repair_page(
497 target,
498 &corrupted,
499 &expected_hash,
500 &meta,
501 &read_fn,
502 &repair_symbols,
503 );
504
505 match outcome {
506 RepairOutcome::Repaired { witness, .. } => {
507 assert!(witness.verified, "page {target} should be repaired");
508 }
509 other => panic!("expected Repaired for page {target}, got {other:?}"),
510 }
511 }
512 }
513
514 #[test]
517 fn test_graceful_degradation_beyond_repair_capacity() {
518 let pages = make_test_pages(8, 64);
519 let (meta, repair_symbols) = make_test_group(&pages, 64, 4, 2);
520
521 let corrupted = vec![0xEE_u8; 64];
522 let corrupt_pgnos = [2_u32, 3, 4, 5, 6];
524
525 let read_fn = |pgno: u32| -> Vec<u8> {
526 if corrupt_pgnos.contains(&pgno) {
527 corrupted.clone()
528 } else {
529 pages[(pgno - 2) as usize].clone()
530 }
531 };
532
533 let expected_hash = blake3_page_checksum(&pages[0]);
534 let outcome = detect_and_repair_page(
535 2,
536 &corrupted,
537 &expected_hash,
538 &meta,
539 &read_fn,
540 &repair_symbols,
541 );
542
543 match outcome {
544 RepairOutcome::Unrecoverable {
545 pgno,
546 witness,
547 detail,
548 } => {
549 assert_eq!(pgno, 2);
550 assert!(witness.is_some());
551 assert!(!detail.is_empty());
552 let w = witness.unwrap();
553 assert!(!w.verified);
554 assert!(w.corrupt_pages_in_group >= 5);
555 }
556 other => panic!("expected Unrecoverable, got {other:?}"),
557 }
558 }
559
560 #[test]
563 fn test_witness_proof_completeness() {
564 let pages = make_test_pages(4, 128);
565 let (meta, repair_symbols) = make_test_group(&pages, 128, 4, 2);
566
567 let corrupted = vec![0xAA_u8; 128];
568 let expected_hash = blake3_page_checksum(&pages[2]);
569
570 let read_fn = |pgno: u32| -> Vec<u8> {
571 if pgno == 4 {
572 corrupted.clone()
573 } else {
574 pages[(pgno - 2) as usize].clone()
575 }
576 };
577
578 let outcome = detect_and_repair_page(
579 4,
580 &corrupted,
581 &expected_hash,
582 &meta,
583 &read_fn,
584 &repair_symbols,
585 );
586
587 match outcome {
588 RepairOutcome::Repaired { witness, .. } => {
589 assert_ne!(witness.corrupted_hash, [0u8; 32]);
591 assert_ne!(witness.repaired_hash, [0u8; 32]);
592 assert_ne!(witness.expected_hash, [0u8; 32]);
593 assert_ne!(witness.corrupted_hash, witness.repaired_hash);
594 assert_eq!(witness.repaired_hash, witness.expected_hash);
595 assert!(witness.symbols_used > 0);
596 assert!(witness.is_success());
597 }
598 other => panic!("expected Repaired, got {other:?}"),
599 }
600 }
601
602 #[test]
606 fn test_corruption_boundary_1_percent() {
607 corruption_boundary_test(64, 4, 1); }
609
610 #[test]
611 fn test_corruption_boundary_5_percent() {
612 corruption_boundary_test(64, 8, 5);
614 }
615
616 #[test]
617 fn test_corruption_boundary_10_percent() {
618 corruption_boundary_test(64, 16, 10);
620 }
621
622 #[test]
623 fn test_corruption_boundary_20_percent() {
624 corruption_boundary_test(64, 32, 20);
626 }
627
628 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
629 fn corruption_boundary_test(k: u32, r: u32, corruption_pct: u32) {
630 let page_size = 64_usize;
631 let pages = make_test_pages(k, page_size);
632 let (meta, repair_symbols) = make_test_group(&pages, page_size as u32, r, 2);
633
634 let num_corrupt = (f64::from(k) * f64::from(corruption_pct) / 100.0).ceil() as u32;
635 let num_corrupt = num_corrupt.max(1).min(k);
636
637 let corrupted = vec![0xDD_u8; page_size];
638 let corrupt_pgnos: Vec<u32> = (2..2 + num_corrupt).collect();
639
640 let read_fn = |pgno: u32| -> Vec<u8> {
641 if corrupt_pgnos.contains(&pgno) {
642 corrupted.clone()
643 } else {
644 pages[(pgno - 2) as usize].clone()
645 }
646 };
647
648 let target = corrupt_pgnos[0];
649 let idx = (target - 2) as usize;
650 let expected_hash = blake3_page_checksum(&pages[idx]);
651
652 let outcome = detect_and_repair_page(
653 target,
654 &corrupted,
655 &expected_hash,
656 &meta,
657 &read_fn,
658 &repair_symbols,
659 );
660
661 if num_corrupt <= r {
662 match outcome {
664 RepairOutcome::Repaired {
665 repaired_data,
666 witness,
667 ..
668 } => {
669 assert_eq!(repaired_data, pages[idx]);
670 assert!(
671 witness.verified,
672 "repair should succeed: {num_corrupt} corrupt <= R={r}"
673 );
674 }
675 other => {
676 panic!("expected Repaired for {num_corrupt} corrupt (R={r}), got {other:?}")
677 }
678 }
679 } else {
680 assert!(
682 matches!(outcome, RepairOutcome::Unrecoverable { .. }),
683 "expected Unrecoverable for {num_corrupt} corrupt > R={r}, got {outcome:?}"
684 );
685 }
686 }
687
688 #[test]
691 fn test_batch_detect_and_repair() {
692 let pages = make_test_pages(4, 128);
693 let (meta, repair_symbols) = make_test_group(&pages, 128, 4, 2);
694
695 let corrupted = vec![0xFF_u8; 128];
696 let read_fn = |pgno: u32| -> Vec<u8> {
697 if pgno == 3 {
698 corrupted.clone()
699 } else {
700 pages[(pgno - 2) as usize].clone()
701 }
702 };
703
704 let blake3_fn = |pgno: u32| -> [u8; 32] {
705 let idx = (pgno - 2) as usize;
706 blake3_page_checksum(&pages[idx])
707 };
708
709 let outcomes =
710 detect_and_repair_pages(&[2, 3, 4, 5], &meta, &read_fn, &blake3_fn, &repair_symbols);
711
712 assert_eq!(outcomes.len(), 4);
713 assert!(matches!(outcomes[0], RepairOutcome::Intact { .. }));
714 assert!(matches!(outcomes[1], RepairOutcome::Repaired { .. }));
715 assert!(matches!(outcomes[2], RepairOutcome::Intact { .. }));
716 assert!(matches!(outcomes[3], RepairOutcome::Intact { .. }));
717 }
718
719 #[test]
722 fn test_bd_n0g4q_3_compliance_gate() {
723 assert_eq!(BEAD_ID, "bd-n0g4q.3");
724 let witness = RepairWitness {
726 pgno: 1,
727 corrupted_hash: [0u8; 32],
728 repaired_hash: [1u8; 32],
729 expected_hash: [1u8; 32],
730 verified: true,
731 symbols_used: 5,
732 corrupt_pages_in_group: 1,
733 };
734 assert!(witness.is_success());
735 }
736}