1use std::fmt;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use fsqlite_error::{FrankenError, Result};
15#[cfg(feature = "native")]
16use fsqlite_vfs::host_fs;
17use tracing::{Level, debug, error, info, span, warn};
18
19const BEAD_ID: &str = "bd-1hi.18";
24
25pub const DB_FEC_MAGIC: [u8; 8] = *b"FSQLDFEC";
27
28pub const GROUP_META_MAGIC: [u8; 8] = *b"FSQLDGRP";
30
31pub const DB_FEC_VERSION: u32 = 1;
33
34pub const DEFAULT_GROUP_SIZE: u32 = 64;
36
37pub const DEFAULT_R_REPAIR: u32 = 4;
39
40pub const HEADER_PAGE_R_REPAIR: u32 = 4;
42
43pub const DB_GEN_DIGEST_DOMAIN: &str = "fsqlite:compat:dbgen:v1";
45
46pub const GROUP_OBJECT_ID_DOMAIN: &str = "fsqlite:compat:db-fec-group:v1";
48
49pub const DB_FEC_HEADER_SIZE: usize = 52;
53
54type RaptorqRepairEquation = (Vec<usize>, Vec<asupersync::raptorq::gf256::Gf256>);
55
56trait IntoDbFecRepairEquation {
57 fn into_db_fec_option(self) -> Option<RaptorqRepairEquation>;
58}
59
60impl IntoDbFecRepairEquation for RaptorqRepairEquation {
61 fn into_db_fec_option(self) -> Option<RaptorqRepairEquation> {
62 Some(self)
63 }
64}
65
66impl IntoDbFecRepairEquation for Option<RaptorqRepairEquation> {
67 fn into_db_fec_option(self) -> Option<RaptorqRepairEquation> {
68 self
69 }
70}
71
72pub static GLOBAL_SNAPSHOT_FEC_METRICS: SnapshotFecMetrics = SnapshotFecMetrics::new();
78
79pub struct SnapshotFecMetrics {
81 pub encoded_pages_total: AtomicU64,
83 pub sidecar_bytes_total: AtomicU64,
85 pub encode_ops: AtomicU64,
87}
88
89impl SnapshotFecMetrics {
90 #[must_use]
92 pub const fn new() -> Self {
93 Self {
94 encoded_pages_total: AtomicU64::new(0),
95 sidecar_bytes_total: AtomicU64::new(0),
96 encode_ops: AtomicU64::new(0),
97 }
98 }
99
100 pub fn record_encode(&self, pages_encoded: u64, sidecar_bytes: u64) {
102 self.encode_ops.fetch_add(1, Ordering::Relaxed);
103 self.encoded_pages_total
104 .fetch_add(pages_encoded, Ordering::Relaxed);
105 self.sidecar_bytes_total
106 .fetch_add(sidecar_bytes, Ordering::Relaxed);
107 }
108
109 #[must_use]
111 pub fn snapshot(&self) -> SnapshotFecMetricsSnapshot {
112 SnapshotFecMetricsSnapshot {
113 encoded_pages_total: self.encoded_pages_total.load(Ordering::Relaxed),
114 sidecar_bytes_total: self.sidecar_bytes_total.load(Ordering::Relaxed),
115 encode_ops: self.encode_ops.load(Ordering::Relaxed),
116 }
117 }
118
119 pub fn reset(&self) {
121 self.encoded_pages_total.store(0, Ordering::Relaxed);
122 self.sidecar_bytes_total.store(0, Ordering::Relaxed);
123 self.encode_ops.store(0, Ordering::Relaxed);
124 }
125}
126
127impl Default for SnapshotFecMetrics {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct SnapshotFecMetricsSnapshot {
136 pub encoded_pages_total: u64,
137 pub sidecar_bytes_total: u64,
138 pub encode_ops: u64,
139}
140
141impl fmt::Display for SnapshotFecMetricsSnapshot {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 write!(
144 f,
145 "snapshot_fec_pages_encoded={} sidecar_bytes={} encode_ops={}",
146 self.encoded_pages_total, self.sidecar_bytes_total, self.encode_ops,
147 )
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct PageGroup {
158 pub start_pgno: u32,
160 pub group_size: u32,
162 pub repair: u32,
164}
165
166#[must_use]
171pub fn partition_page_groups(db_size_pages: u32) -> Vec<PageGroup> {
172 if db_size_pages == 0 {
173 return Vec::new();
174 }
175
176 let mut groups = Vec::new();
177
178 groups.push(PageGroup {
180 start_pgno: 1,
181 group_size: 1,
182 repair: HEADER_PAGE_R_REPAIR,
183 });
184
185 let mut pgno: u32 = 2;
186 while pgno <= db_size_pages {
187 let remaining = db_size_pages - pgno + 1;
188 let group_size = remaining.min(DEFAULT_GROUP_SIZE);
189 groups.push(PageGroup {
190 start_pgno: pgno,
191 group_size,
192 repair: DEFAULT_R_REPAIR,
193 });
194 if let Some(next) = pgno.checked_add(group_size) {
195 pgno = next;
196 } else {
197 break;
198 }
199 }
200
201 debug!(
202 bead_id = BEAD_ID,
203 db_size_pages,
204 group_count = groups.len(),
205 "partitioned pages into .db-fec groups"
206 );
207
208 groups
209}
210
211#[must_use]
220pub fn compute_db_gen_digest(
221 change_counter: u32,
222 page_count: u32,
223 freelist_count: u32,
224 schema_cookie: u32,
225) -> [u8; 16] {
226 let mut hasher = blake3::Hasher::new();
227 hasher.update(DB_GEN_DIGEST_DOMAIN.as_bytes());
228 hasher.update(&change_counter.to_be_bytes());
229 hasher.update(&page_count.to_be_bytes());
230 hasher.update(&freelist_count.to_be_bytes());
231 hasher.update(&schema_cookie.to_be_bytes());
232 let hash = hasher.finalize();
233 let mut digest = [0u8; 16];
234 digest.copy_from_slice(&hash.as_bytes()[..16]);
235 digest
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct DbFecHeader {
245 pub magic: [u8; 8],
246 pub version: u32,
247 pub page_size: u32,
248 pub default_group_size: u32,
249 pub default_r_repair: u32,
250 pub header_page_r_repair: u32,
251 pub db_gen_digest: [u8; 16],
252 pub checksum: u64,
253}
254
255impl DbFecHeader {
256 #[must_use]
258 pub fn new(
259 page_size: u32,
260 change_counter: u32,
261 page_count: u32,
262 freelist_count: u32,
263 schema_cookie: u32,
264 ) -> Self {
265 let digest =
266 compute_db_gen_digest(change_counter, page_count, freelist_count, schema_cookie);
267 let mut hdr = Self {
268 magic: DB_FEC_MAGIC,
269 version: DB_FEC_VERSION,
270 page_size,
271 default_group_size: DEFAULT_GROUP_SIZE,
272 default_r_repair: DEFAULT_R_REPAIR,
273 header_page_r_repair: HEADER_PAGE_R_REPAIR,
274 db_gen_digest: digest,
275 checksum: 0,
276 };
277 hdr.checksum = hdr.compute_checksum();
278 hdr
279 }
280
281 #[must_use]
283 pub fn to_bytes(&self) -> [u8; DB_FEC_HEADER_SIZE] {
284 let mut buf = [0u8; DB_FEC_HEADER_SIZE];
285 buf[0..8].copy_from_slice(&self.magic);
286 buf[8..12].copy_from_slice(&self.version.to_le_bytes());
287 buf[12..16].copy_from_slice(&self.page_size.to_le_bytes());
288 buf[16..20].copy_from_slice(&self.default_group_size.to_le_bytes());
289 buf[20..24].copy_from_slice(&self.default_r_repair.to_le_bytes());
290 buf[24..28].copy_from_slice(&self.header_page_r_repair.to_le_bytes());
291 buf[28..44].copy_from_slice(&self.db_gen_digest);
292 buf[44..52].copy_from_slice(&self.checksum.to_le_bytes());
293 buf
294 }
295
296 pub fn from_bytes(buf: &[u8; DB_FEC_HEADER_SIZE]) -> Result<Self> {
298 let magic: [u8; 8] = buf[0..8].try_into().expect("slice len");
299 if magic != DB_FEC_MAGIC {
300 return Err(FrankenError::DatabaseCorrupt {
301 detail: format!("bad .db-fec magic: {magic:?}"),
302 });
303 }
304 let version = u32::from_le_bytes(buf[8..12].try_into().expect("slice len"));
305 if version != DB_FEC_VERSION {
306 return Err(FrankenError::DatabaseCorrupt {
307 detail: format!("unsupported .db-fec version: {version}"),
308 });
309 }
310 let page_size = u32::from_le_bytes(buf[12..16].try_into().expect("slice len"));
311 let default_group_size = u32::from_le_bytes(buf[16..20].try_into().expect("slice len"));
312 let default_r_repair = u32::from_le_bytes(buf[20..24].try_into().expect("slice len"));
313 let header_page_r_repair = u32::from_le_bytes(buf[24..28].try_into().expect("slice len"));
314 let mut db_gen_digest = [0u8; 16];
315 db_gen_digest.copy_from_slice(&buf[28..44]);
316 let checksum = u64::from_le_bytes(buf[44..52].try_into().expect("slice len"));
317
318 let hdr = Self {
319 magic,
320 version,
321 page_size,
322 default_group_size,
323 default_r_repair,
324 header_page_r_repair,
325 db_gen_digest,
326 checksum,
327 };
328
329 let expected = hdr.compute_checksum();
330 if hdr.checksum != expected {
331 return Err(FrankenError::DatabaseCorrupt {
332 detail: format!(
333 ".db-fec header checksum mismatch: stored={:#x}, computed={expected:#x}",
334 hdr.checksum
335 ),
336 });
337 }
338
339 info!(
340 bead_id = BEAD_ID,
341 page_size,
342 G_pages_per_group = default_group_size,
343 R_repair_pages = default_r_repair,
344 header_group_policy = header_page_r_repair,
345 format_version = version,
346 ".db-fec config on open"
347 );
348
349 Ok(hdr)
350 }
351
352 #[must_use]
354 fn compute_checksum(&self) -> u64 {
355 let buf = self.to_bytes();
356 xxhash_rust::xxh3::xxh3_64(&buf[..44])
358 }
359
360 #[must_use]
362 pub fn is_current(
363 &self,
364 change_counter: u32,
365 page_count: u32,
366 freelist_count: u32,
367 schema_cookie: u32,
368 ) -> bool {
369 let current =
370 compute_db_gen_digest(change_counter, page_count, freelist_count, schema_cookie);
371 self.db_gen_digest == current
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct DbFecGroupMeta {
382 pub magic: [u8; 8],
383 pub version: u32,
384 pub page_size: u32,
385 pub start_pgno: u32,
386 pub group_size: u32,
387 pub r_repair: u32,
388 pub object_id: [u8; 16],
390 pub source_page_xxh3_128: Vec<[u8; 16]>,
392 pub db_gen_digest: [u8; 16],
394 pub checksum: u64,
395}
396
397impl DbFecGroupMeta {
398 #[must_use]
400 pub fn new(
401 page_size: u32,
402 start_pgno: u32,
403 group_size: u32,
404 r_repair: u32,
405 source_page_xxh3_128: Vec<[u8; 16]>,
406 db_gen_digest: [u8; 16],
407 ) -> Self {
408 assert!(
409 source_page_xxh3_128.len() == group_size as usize,
410 "source_page_xxh3_128.len() must equal group_size"
411 );
412 let mut meta = Self {
413 magic: GROUP_META_MAGIC,
414 version: DB_FEC_VERSION,
415 page_size,
416 start_pgno,
417 group_size,
418 r_repair,
419 object_id: [0u8; 16],
420 source_page_xxh3_128,
421 db_gen_digest,
422 checksum: 0,
423 };
424 meta.object_id = meta.compute_object_id();
425 meta.checksum = meta.compute_checksum();
426 meta
427 }
428
429 const FIXED_SIZE: usize = 68;
433
434 #[must_use]
436 pub fn serialized_size(&self) -> usize {
437 Self::FIXED_SIZE + self.source_page_xxh3_128.len() * 16
438 }
439
440 #[must_use]
442 pub fn serialized_size_for(group_size: u32) -> usize {
443 (group_size as usize)
444 .saturating_mul(16)
445 .saturating_add(Self::FIXED_SIZE)
446 }
447
448 #[must_use]
450 pub fn to_bytes(&self) -> Vec<u8> {
451 let total = self.serialized_size();
452 let mut buf = vec![0u8; total];
453 buf[0..8].copy_from_slice(&self.magic);
454 buf[8..12].copy_from_slice(&self.version.to_le_bytes());
455 buf[12..16].copy_from_slice(&self.page_size.to_le_bytes());
456 buf[16..20].copy_from_slice(&self.start_pgno.to_le_bytes());
457 buf[20..24].copy_from_slice(&self.group_size.to_le_bytes());
458 buf[24..28].copy_from_slice(&self.r_repair.to_le_bytes());
459 buf[28..44].copy_from_slice(&self.object_id);
460 let hash_start = 44;
461 for (i, h) in self.source_page_xxh3_128.iter().enumerate() {
462 let off = hash_start + i * 16;
463 buf[off..off + 16].copy_from_slice(h);
464 }
465 let digest_off = hash_start + self.source_page_xxh3_128.len() * 16;
466 buf[digest_off..digest_off + 16].copy_from_slice(&self.db_gen_digest);
467 buf[digest_off + 16..digest_off + 24].copy_from_slice(&self.checksum.to_le_bytes());
468 buf
469 }
470
471 pub fn from_bytes(buf: &[u8]) -> Result<Self> {
473 if buf.len() < Self::FIXED_SIZE {
474 return Err(FrankenError::DatabaseCorrupt {
475 detail: format!("group meta too short: {} < {}", buf.len(), Self::FIXED_SIZE),
476 });
477 }
478 let magic: [u8; 8] = buf[0..8].try_into().expect("slice len");
479 if magic != GROUP_META_MAGIC {
480 return Err(FrankenError::DatabaseCorrupt {
481 detail: format!("bad group meta magic: {magic:?}"),
482 });
483 }
484 let version = u32::from_le_bytes(buf[8..12].try_into().expect("slice len"));
485 if version != DB_FEC_VERSION {
486 return Err(FrankenError::DatabaseCorrupt {
487 detail: format!("unsupported group meta version: {version}"),
488 });
489 }
490 let page_size = u32::from_le_bytes(buf[12..16].try_into().expect("slice len"));
491 let start_pgno = u32::from_le_bytes(buf[16..20].try_into().expect("slice len"));
492 let group_size = u32::from_le_bytes(buf[20..24].try_into().expect("slice len"));
493 let r_repair = u32::from_le_bytes(buf[24..28].try_into().expect("slice len"));
494 let mut object_id = [0u8; 16];
495 object_id.copy_from_slice(&buf[28..44]);
496
497 let expected_total = Self::serialized_size_for(group_size);
498 if buf.len() < expected_total {
499 return Err(FrankenError::DatabaseCorrupt {
500 detail: format!(
501 "group meta truncated: {} < {expected_total} for group_size={group_size}",
502 buf.len()
503 ),
504 });
505 }
506
507 let hash_start = 44;
508 let mut source_page_xxh3_128 = Vec::with_capacity(group_size as usize);
509 for i in 0..group_size as usize {
510 let off = hash_start + i * 16;
511 let mut h = [0u8; 16];
512 h.copy_from_slice(&buf[off..off + 16]);
513 source_page_xxh3_128.push(h);
514 }
515
516 let digest_off = hash_start + group_size as usize * 16;
517 let mut db_gen_digest = [0u8; 16];
518 db_gen_digest.copy_from_slice(&buf[digest_off..digest_off + 16]);
519 let checksum = u64::from_le_bytes(
520 buf[digest_off + 16..digest_off + 24]
521 .try_into()
522 .expect("slice len"),
523 );
524
525 let meta = Self {
526 magic,
527 version,
528 page_size,
529 start_pgno,
530 group_size,
531 r_repair,
532 object_id,
533 source_page_xxh3_128,
534 db_gen_digest,
535 checksum,
536 };
537
538 let expected_cksum = meta.compute_checksum();
539 if meta.checksum != expected_cksum {
540 return Err(FrankenError::DatabaseCorrupt {
541 detail: format!(
542 "group meta checksum mismatch: stored={:#x}, computed={expected_cksum:#x}",
543 meta.checksum
544 ),
545 });
546 }
547
548 let expected_oid = meta.compute_object_id();
549 if meta.object_id != expected_oid {
550 return Err(FrankenError::DatabaseCorrupt {
551 detail: "group meta object_id mismatch".into(),
552 });
553 }
554
555 debug!(
556 bead_id = BEAD_ID,
557 group_idx = meta.start_pgno,
558 pgno_start = meta.start_pgno,
559 K = meta.group_size,
560 R = meta.r_repair,
561 "group meta validated"
562 );
563
564 Ok(meta)
565 }
566
567 #[must_use]
569 fn compute_object_id(&self) -> [u8; 16] {
570 let mut hasher = blake3::Hasher::new();
571 hasher.update(GROUP_OBJECT_ID_DOMAIN.as_bytes());
572 hasher.update(&self.magic);
574 hasher.update(&self.version.to_le_bytes());
575 hasher.update(&self.page_size.to_le_bytes());
576 hasher.update(&self.start_pgno.to_le_bytes());
577 hasher.update(&self.group_size.to_le_bytes());
578 hasher.update(&self.r_repair.to_le_bytes());
579 for h in &self.source_page_xxh3_128 {
580 hasher.update(h);
581 }
582 hasher.update(&self.db_gen_digest);
583 let hash = hasher.finalize();
584 let mut oid = [0u8; 16];
585 oid.copy_from_slice(&hash.as_bytes()[..16]);
586 oid
587 }
588
589 #[must_use]
591 fn compute_checksum(&self) -> u64 {
592 let bytes = self.to_bytes();
593 xxhash_rust::xxh3::xxh3_64(&bytes[..bytes.len() - 8])
595 }
596}
597
598#[must_use]
611pub fn segment_offset(g: u32, segment_1_len: usize, full_segment_len: usize) -> usize {
612 DB_FEC_HEADER_SIZE + segment_1_len + g as usize * full_segment_len
613}
614
615#[must_use]
619pub fn group_segment_size(group_size: u32, r_repair: u32, page_size: u32) -> usize {
620 DbFecGroupMeta::serialized_size_for(group_size) + r_repair as usize * page_size as usize
621}
622
623#[must_use]
626pub fn find_full_group_index(pgno: u32) -> Option<u32> {
627 if pgno < 2 {
628 return None;
629 }
630 Some((pgno - 2) / DEFAULT_GROUP_SIZE)
631}
632
633#[derive(Debug, Clone, PartialEq, Eq)]
639pub enum RepairResult {
640 Intact,
642 Repaired { pgno: u32, symbols_used: u32 },
644 Unrecoverable {
646 pgno: u32,
647 missing_pages: u32,
648 r_budget: u32,
649 },
650}
651
652#[must_use]
656pub fn verify_page_xxh3_128(page_data: &[u8], expected_xxh3_128: &[u8; 16]) -> bool {
657 let hash = xxhash_rust::xxh3::xxh3_128(page_data);
658 hash.to_le_bytes() == *expected_xxh3_128
659}
660
661#[must_use]
663pub fn page_xxh3_128(page_data: &[u8]) -> [u8; 16] {
664 let hash = xxhash_rust::xxh3::xxh3_128(page_data);
665 hash.to_le_bytes()
666}
667
668#[allow(clippy::too_many_lines)]
681pub fn attempt_page_repair(
682 target_pgno: u32,
683 group_meta: &DbFecGroupMeta,
684 all_page_data: &dyn Fn(u32) -> Vec<u8>,
685 repair_symbols: &[(u32, Vec<u8>)],
686) -> Result<(Vec<u8>, RepairResult)> {
687 let local_idx = target_pgno
688 .checked_sub(group_meta.start_pgno)
689 .ok_or_else(|| {
690 FrankenError::internal(format!(
691 "target_pgno ({target_pgno}) < start_pgno ({})",
692 group_meta.start_pgno
693 ))
694 })?;
695 let k = group_meta.group_size;
696
697 debug!(
698 bead_id = BEAD_ID,
699 target_pgno,
700 group_start = group_meta.start_pgno,
701 K = k,
702 R = group_meta.r_repair,
703 "attempting on-the-fly page repair"
704 );
705
706 let mut available: Vec<(u32, Vec<u8>)> = Vec::new();
708 let mut corrupt_count: u32 = 0;
709
710 for i in 0..k {
711 let pgno = group_meta.start_pgno + i;
712 if pgno == target_pgno {
713 corrupt_count += 1;
714 continue;
715 }
716 let data = all_page_data(pgno);
717 if verify_page_xxh3_128(&data, &group_meta.source_page_xxh3_128[i as usize]) {
718 available.push((i, data));
719 } else {
720 corrupt_count += 1;
721 }
722 }
723
724 for (esi, sym_data) in repair_symbols {
726 available.push((*esi, sym_data.clone()));
727 }
728
729 debug!(
730 bead_id = BEAD_ID,
731 target_pgno,
732 available_symbols = available.len(),
733 corrupt_count,
734 K = k,
735 "collected symbols for repair"
736 );
737
738 #[allow(clippy::cast_possible_truncation)]
739 let available_count = available.len() as u32;
740 if available_count < k {
741 error!(
742 bead_id = BEAD_ID,
743 target_pgno,
744 missing_or_corrupt_pages = corrupt_count,
745 R_budget = group_meta.r_repair,
746 action = "fail",
747 "unrecoverable group loss"
748 );
749 return Err(FrankenError::DatabaseCorrupt {
750 detail: format!(
751 "page {target_pgno}: insufficient symbols for repair ({} available, {k} needed, {corrupt_count} corrupt)",
752 available.len()
753 ),
754 });
755 }
756
757 let page_size = group_meta.page_size as usize;
759 let k_usize = k as usize;
760 let seed = derive_db_fec_repair_seed(group_meta);
761 let decoder = asupersync::raptorq::decoder::InactivationDecoder::new(k_usize, page_size, seed);
762
763 let mut received = decoder.constraint_symbols();
764 let repair_padding_delta = {
765 let params = decoder.params();
766 params
767 .k_prime
768 .checked_sub(params.k)
769 .and_then(|delta| u32::try_from(delta).ok())
770 .ok_or_else(|| FrankenError::DatabaseCorrupt {
771 detail: format!(
772 "page {target_pgno}: invalid RaptorQ padding domain: K={} K'={}",
773 params.k, params.k_prime
774 ),
775 })?
776 };
777
778 for (esi, data) in &available {
779 if (*esi as usize) < k_usize {
780 received.push(asupersync::raptorq::decoder::ReceivedSymbol::source(
781 *esi,
782 data.clone(),
783 ));
784 } else {
785 esi.checked_add(repair_padding_delta)
786 .ok_or_else(|| FrankenError::DatabaseCorrupt {
787 detail: format!(
788 "page {target_pgno}: invalid RaptorQ repair ESI {esi}: overflow"
789 ),
790 })?;
791 let (cols, coefs) = decoder
792 .repair_equation_rfc6330(*esi)
793 .into_db_fec_option()
794 .ok_or_else(|| FrankenError::DatabaseCorrupt {
795 detail: format!(
796 "page {target_pgno}: invalid RaptorQ repair ESI {esi}: unsupported domain"
797 ),
798 })?;
799 received.push(asupersync::raptorq::decoder::ReceivedSymbol::repair(
800 *esi,
801 cols,
802 coefs,
803 data.clone(),
804 ));
805 }
806 }
807
808 let result = decoder
817 .decode(&received)
818 .map_err(|err| FrankenError::DatabaseCorrupt {
819 detail: format!("page {target_pgno}: RaptorQ decode failed: {err:?}"),
820 })?;
821
822 if result.source.len() != k_usize {
823 return Err(FrankenError::DatabaseCorrupt {
824 detail: format!(
825 "page {target_pgno}: RaptorQ decode returned {} source symbols, expected {k}",
826 result.source.len()
827 ),
828 });
829 }
830
831 let recovered = result.source[local_idx as usize].clone();
832
833 if verify_page_xxh3_128(
835 &recovered,
836 &group_meta.source_page_xxh3_128[local_idx as usize],
837 ) {
838 info!(
839 bead_id = BEAD_ID,
840 target_pgno,
841 group_start = group_meta.start_pgno,
842 pages_repaired = 1,
843 symbols_used = available.len(),
844 "successful on-the-fly page repair"
845 );
846 Ok((
847 recovered,
848 RepairResult::Repaired {
849 pgno: target_pgno,
850 symbols_used: available_count,
851 },
852 ))
853 } else {
854 warn!(
855 bead_id = BEAD_ID,
856 target_pgno,
857 missing_or_corrupt_pages = corrupt_count,
858 R_budget = group_meta.r_repair,
859 "near-capacity repair: recovered page xxh3 mismatch"
860 );
861 Err(FrankenError::DatabaseCorrupt {
862 detail: format!("page {target_pgno}: recovered page failed xxh3_128 validation"),
863 })
864 }
865}
866
867#[must_use]
873pub fn db_fec_path_for_db(db_path: &Path) -> PathBuf {
874 let mut p = db_path.as_os_str().to_owned();
875 p.push("-fec");
876 PathBuf::from(p)
877}
878
879const SQLITE_HEADER_MIN_BYTES: usize = 100;
881const PAGE_SIZE_OFFSET: usize = 16;
882const CHANGE_COUNTER_OFFSET: usize = 24;
883const PAGE_COUNT_OFFSET: usize = 28;
884const FREELIST_COUNT_OFFSET: usize = 36;
885const SCHEMA_COOKIE_OFFSET: usize = 40;
886
887#[derive(Debug, Clone, Copy)]
889pub struct DbHeaderFields {
890 pub page_size: u32,
891 pub change_counter: u32,
892 pub page_count: u32,
893 pub freelist_count: u32,
894 pub schema_cookie: u32,
895}
896
897#[cfg(feature = "native")]
899pub fn read_db_header_fields(db_path: &Path) -> Result<DbHeaderFields> {
900 let data = host_fs::read(db_path)?;
901 parse_db_header_fields(&data)
902}
903
904pub fn parse_db_header_fields(data: &[u8]) -> Result<DbHeaderFields> {
906 if data.len() < SQLITE_HEADER_MIN_BYTES {
907 return Err(FrankenError::DatabaseCorrupt {
908 detail: format!(
909 "database too short for header: {} < {SQLITE_HEADER_MIN_BYTES}",
910 data.len()
911 ),
912 });
913 }
914
915 let page_size_raw = u16::from_be_bytes(
916 data[PAGE_SIZE_OFFSET..PAGE_SIZE_OFFSET + 2]
917 .try_into()
918 .expect("fixed-length slice"),
919 );
920 let page_size = if page_size_raw == 1 {
922 65536
923 } else {
924 u32::from(page_size_raw)
925 };
926
927 let change_counter = u32::from_be_bytes(
928 data[CHANGE_COUNTER_OFFSET..CHANGE_COUNTER_OFFSET + 4]
929 .try_into()
930 .expect("fixed-length slice"),
931 );
932 let page_count = u32::from_be_bytes(
933 data[PAGE_COUNT_OFFSET..PAGE_COUNT_OFFSET + 4]
934 .try_into()
935 .expect("fixed-length slice"),
936 );
937 let freelist_count = u32::from_be_bytes(
938 data[FREELIST_COUNT_OFFSET..FREELIST_COUNT_OFFSET + 4]
939 .try_into()
940 .expect("fixed-length slice"),
941 );
942 let schema_cookie = u32::from_be_bytes(
943 data[SCHEMA_COOKIE_OFFSET..SCHEMA_COOKIE_OFFSET + 4]
944 .try_into()
945 .expect("fixed-length slice"),
946 );
947
948 Ok(DbHeaderFields {
949 page_size,
950 change_counter,
951 page_count,
952 freelist_count,
953 schema_cookie,
954 })
955}
956
957fn derive_db_fec_repair_seed(meta: &DbFecGroupMeta) -> u64 {
962 let mut seed_material = Vec::with_capacity(16 + 4 * 4 + 16);
963 seed_material.extend_from_slice(&meta.object_id);
964 seed_material.extend_from_slice(&meta.page_size.to_le_bytes());
965 seed_material.extend_from_slice(&meta.start_pgno.to_le_bytes());
966 seed_material.extend_from_slice(&meta.group_size.to_le_bytes());
967 seed_material.extend_from_slice(&meta.r_repair.to_le_bytes());
968 seed_material.extend_from_slice(&meta.db_gen_digest);
969 xxhash_rust::xxh3::xxh3_64(&seed_material)
970}
971
972pub fn compute_raptorq_repair_symbols(
977 meta: &DbFecGroupMeta,
978 source_pages: &[&[u8]],
979 page_size: usize,
980) -> Result<Vec<Vec<u8>>> {
981 if source_pages.len() != meta.group_size as usize {
982 return Err(FrankenError::DatabaseCorrupt {
983 detail: format!(
984 "source_pages.len()={} != meta.group_size={}; encoder/decoder seed mismatch would corrupt data",
985 source_pages.len(),
986 meta.group_size,
987 ),
988 });
989 }
990 let seed = derive_db_fec_repair_seed(meta);
991 let source_vecs: Vec<Vec<u8>> = source_pages.iter().map(|s| s.to_vec()).collect();
992 let encoder =
993 asupersync::raptorq::systematic::SystematicEncoder::new(&source_vecs, page_size, seed)
994 .ok_or_else(|| FrankenError::DatabaseCorrupt {
995 detail: "RaptorQ constraint matrix singular during encoding".to_owned(),
996 })?;
997
998 let k = u32::try_from(source_pages.len()).map_err(|_| FrankenError::DatabaseCorrupt {
999 detail: "source page count does not fit in u32".to_owned(),
1000 })?;
1001
1002 let mut symbols = Vec::with_capacity(meta.r_repair as usize);
1003 for r_idx in 0..meta.r_repair {
1004 let esi = k + r_idx;
1005 symbols.push(encoder.repair_symbol(esi));
1006 }
1007 Ok(symbols)
1008}
1009
1010fn read_page_from_bytes(db_data: &[u8], pgno: u32, page_size: usize) -> Vec<u8> {
1012 let offset_u64 = (u64::from(pgno) - 1) * (page_size as u64);
1013 let offset = usize::try_from(offset_u64).unwrap_or(usize::MAX);
1014 if offset.saturating_add(page_size) <= db_data.len() {
1015 db_data[offset..offset + page_size].to_vec()
1016 } else {
1017 let mut page = vec![0u8; page_size];
1018 if offset < db_data.len() {
1019 let available = db_data.len() - offset;
1020 page[..available].copy_from_slice(&db_data[offset..offset + available]);
1021 }
1022 page
1023 }
1024}
1025
1026#[allow(clippy::too_many_lines)]
1033pub fn generate_db_fec_from_bytes(db_data: &[u8]) -> Result<Vec<u8>> {
1034 let fields = parse_db_header_fields(db_data)?;
1035 let ps = fields.page_size as usize;
1036
1037 let header = DbFecHeader::new(
1038 fields.page_size,
1039 fields.change_counter,
1040 fields.page_count,
1041 fields.freelist_count,
1042 fields.schema_cookie,
1043 );
1044 let digest = header.db_gen_digest;
1045 let groups = partition_page_groups(fields.page_count);
1046
1047 let seg1_len = group_segment_size(1, HEADER_PAGE_R_REPAIR, fields.page_size);
1049 let full_seg_len = group_segment_size(DEFAULT_GROUP_SIZE, DEFAULT_R_REPAIR, fields.page_size);
1050
1051 let num_general_groups = groups.len().saturating_sub(1);
1053 let total_size = if groups.is_empty() {
1054 DB_FEC_HEADER_SIZE
1055 } else {
1056 DB_FEC_HEADER_SIZE + seg1_len + num_general_groups * full_seg_len
1057 };
1058 let mut sidecar = vec![0u8; total_size];
1059
1060 sidecar[..DB_FEC_HEADER_SIZE].copy_from_slice(&header.to_bytes());
1062
1063 let mut cursor = DB_FEC_HEADER_SIZE;
1064
1065 for (gi, group) in groups.iter().enumerate() {
1066 let source_refs: Vec<Vec<u8>> = (0..group.group_size)
1068 .map(|i| read_page_from_bytes(db_data, group.start_pgno + i, ps))
1069 .collect();
1070 let source_slices: Vec<&[u8]> = source_refs.iter().map(Vec::as_slice).collect();
1071
1072 let hashes: Vec<[u8; 16]> = source_slices.iter().map(|p| page_xxh3_128(p)).collect();
1074
1075 let meta = DbFecGroupMeta::new(
1077 fields.page_size,
1078 group.start_pgno,
1079 group.group_size,
1080 group.repair,
1081 hashes,
1082 digest,
1083 );
1084
1085 let repair_symbols = compute_raptorq_repair_symbols(&meta, &source_slices, ps)?;
1087
1088 let meta_bytes = meta.to_bytes();
1090 sidecar[cursor..cursor + meta_bytes.len()].copy_from_slice(&meta_bytes);
1091 cursor += meta_bytes.len();
1092
1093 for sym in &repair_symbols {
1095 sidecar[cursor..cursor + ps].copy_from_slice(sym);
1096 cursor += ps;
1097 }
1098
1099 if gi > 0 {
1101 let actual_seg_size = meta_bytes.len() + group.repair as usize * ps;
1102 let padding = full_seg_len - actual_seg_size;
1103 cursor += padding; }
1105 }
1106
1107 let sidecar_len = sidecar.len() as u64;
1108 let page_count_u64 = u64::from(fields.page_count);
1109
1110 let _span = span!(
1112 Level::INFO,
1113 "snapshot_raptorq",
1114 pages_encoded = page_count_u64,
1115 total_bytes = sidecar_len,
1116 groups = groups.len(),
1117 )
1118 .entered();
1119
1120 GLOBAL_SNAPSHOT_FEC_METRICS.record_encode(page_count_u64, sidecar_len);
1121
1122 info!(
1123 bead_id = "bd-2r4z",
1124 page_count = fields.page_count,
1125 page_size = fields.page_size,
1126 groups = groups.len(),
1127 sidecar_bytes = sidecar.len(),
1128 "generated .db-fec sidecar"
1129 );
1130
1131 Ok(sidecar)
1132}
1133
1134#[cfg(feature = "native")]
1136pub fn generate_db_fec_sidecar(db_path: &Path) -> Result<Vec<u8>> {
1137 let db_data = host_fs::read(db_path)?;
1138 generate_db_fec_from_bytes(&db_data)
1139}
1140
1141#[cfg(feature = "native")]
1143pub fn write_db_fec_sidecar(db_path: &Path) -> Result<PathBuf> {
1144 let sidecar_data = generate_db_fec_sidecar(db_path)?;
1145 let sidecar_path = db_fec_path_for_db(db_path);
1146 host_fs::write(&sidecar_path, &sidecar_data)?;
1147
1148 info!(
1149 bead_id = "bd-2r4z",
1150 db_path = %db_path.display(),
1151 sidecar_path = %sidecar_path.display(),
1152 sidecar_bytes = sidecar_data.len(),
1153 "wrote .db-fec sidecar"
1154 );
1155
1156 Ok(sidecar_path)
1157}
1158
1159#[cfg(feature = "native")]
1161pub fn read_db_fec_header(sidecar_path: &Path) -> Result<DbFecHeader> {
1162 let data = host_fs::read(sidecar_path)?;
1163 if data.len() < DB_FEC_HEADER_SIZE {
1164 return Err(FrankenError::DatabaseCorrupt {
1165 detail: format!(
1166 "sidecar too short for header: {} < {DB_FEC_HEADER_SIZE}",
1167 data.len()
1168 ),
1169 });
1170 }
1171 let buf: [u8; DB_FEC_HEADER_SIZE] = data[..DB_FEC_HEADER_SIZE]
1172 .try_into()
1173 .expect("fixed-length slice");
1174 DbFecHeader::from_bytes(&buf)
1175}
1176
1177#[allow(clippy::type_complexity)]
1182pub fn read_db_fec_group_for_page(
1183 sidecar_data: &[u8],
1184 header: &DbFecHeader,
1185 target_pgno: u32,
1186) -> Result<(DbFecGroupMeta, Vec<(u32, Vec<u8>)>)> {
1187 let ps = header.page_size as usize;
1188
1189 let (seg_offset, group_size_hint) = if target_pgno == 1 {
1191 (DB_FEC_HEADER_SIZE, 1_u32)
1192 } else {
1193 let gi =
1194 find_full_group_index(target_pgno).ok_or_else(|| FrankenError::DatabaseCorrupt {
1195 detail: format!("invalid target page number: {target_pgno}"),
1196 })?;
1197 let seg1_len = group_segment_size(1, HEADER_PAGE_R_REPAIR, header.page_size);
1198 let full_seg_len =
1199 group_segment_size(DEFAULT_GROUP_SIZE, DEFAULT_R_REPAIR, header.page_size);
1200 let offset = segment_offset(gi, seg1_len, full_seg_len);
1201 (offset, DEFAULT_GROUP_SIZE)
1202 };
1203
1204 if seg_offset >= sidecar_data.len() {
1205 return Err(FrankenError::DatabaseCorrupt {
1206 detail: format!(
1207 "sidecar too short for segment at offset {seg_offset}: len={}",
1208 sidecar_data.len()
1209 ),
1210 });
1211 }
1212
1213 let meta_size = DbFecGroupMeta::serialized_size_for(group_size_hint);
1215 let meta_end = seg_offset + meta_size;
1216 if meta_end > sidecar_data.len() {
1217 return Err(FrankenError::DatabaseCorrupt {
1218 detail: format!(
1219 "sidecar truncated reading group meta at {seg_offset}: need {meta_size}, have {}",
1220 sidecar_data.len() - seg_offset
1221 ),
1222 });
1223 }
1224 let meta = DbFecGroupMeta::from_bytes(&sidecar_data[seg_offset..meta_end])?;
1225
1226 let actual_r = meta.r_repair;
1227 let actual_meta_size = meta.serialized_size();
1228 let mut sym_cursor = seg_offset + actual_meta_size;
1229
1230 let needed_repair_bytes = (actual_r as usize).saturating_mul(ps);
1232 if sym_cursor.saturating_add(needed_repair_bytes) > sidecar_data.len() {
1233 return Err(FrankenError::DatabaseCorrupt {
1234 detail: format!("sidecar too short for {} repair symbols", actual_r),
1235 });
1236 }
1237
1238 let mut symbols = Vec::with_capacity(actual_r as usize);
1240 for r_idx in 0..actual_r {
1241 if sym_cursor + ps > sidecar_data.len() {
1242 return Err(FrankenError::DatabaseCorrupt {
1243 detail: format!("sidecar truncated reading repair symbol {r_idx} at {sym_cursor}"),
1244 });
1245 }
1246 let esi = meta.group_size + r_idx;
1247 symbols.push((esi, sidecar_data[sym_cursor..sym_cursor + ps].to_vec()));
1248 sym_cursor += ps;
1249 }
1250
1251 debug!(
1252 bead_id = "bd-2r4z",
1253 target_pgno,
1254 group_start = meta.start_pgno,
1255 K = meta.group_size,
1256 R = actual_r,
1257 "read .db-fec group for repair"
1258 );
1259
1260 Ok((meta, symbols))
1261}
1262
1263#[cfg(test)]
1268mod tests {
1269 use super::*;
1270
1271 #[test]
1274 fn test_db_fec_header_roundtrip() {
1275 let hdr = DbFecHeader::new(4096, 42, 100, 5, 99);
1276 let bytes = hdr.to_bytes();
1277 assert_eq!(bytes.len(), DB_FEC_HEADER_SIZE);
1278 let decoded = DbFecHeader::from_bytes(&bytes).expect("decode");
1279 assert_eq!(hdr, decoded);
1280 }
1281
1282 #[test]
1283 fn test_db_gen_digest_computation() {
1284 let d1 = compute_db_gen_digest(42, 100, 5, 99);
1286 let d2 = compute_db_gen_digest(42, 100, 5, 99);
1287 assert_eq!(d1, d2, "deterministic");
1288
1289 let d3 = compute_db_gen_digest(43, 100, 5, 99);
1291 assert_ne!(d1, d3);
1292 let d4 = compute_db_gen_digest(42, 101, 5, 99);
1293 assert_ne!(d1, d4);
1294 let d5 = compute_db_gen_digest(42, 100, 6, 99);
1295 assert_ne!(d1, d5);
1296 let d6 = compute_db_gen_digest(42, 100, 5, 100);
1297 assert_ne!(d1, d6);
1298 }
1299
1300 #[test]
1301 fn test_stale_sidecar_detection() {
1302 let hdr = DbFecHeader::new(4096, 42, 100, 5, 99);
1303 assert!(hdr.is_current(42, 100, 5, 99));
1304 assert!(!hdr.is_current(43, 100, 5, 99));
1306 assert!(!hdr.is_current(42, 101, 5, 99));
1307 }
1308
1309 #[test]
1310 fn test_db_fec_header_bad_checksum() {
1311 let hdr = DbFecHeader::new(4096, 42, 100, 5, 99);
1312 let mut bytes = hdr.to_bytes();
1313 bytes[44] ^= 0xFF;
1315 let result = DbFecHeader::from_bytes(&bytes);
1316 assert!(result.is_err());
1317 }
1318
1319 #[test]
1320 fn test_db_fec_header_bad_magic() {
1321 let hdr = DbFecHeader::new(4096, 42, 100, 5, 99);
1322 let mut bytes = hdr.to_bytes();
1323 bytes[0] = b'X';
1324 let result = DbFecHeader::from_bytes(&bytes);
1325 assert!(result.is_err());
1326 }
1327
1328 #[test]
1331 fn test_page_group_partitioning_single_page() {
1332 let groups = partition_page_groups(1);
1333 assert_eq!(groups.len(), 1);
1334 assert_eq!(
1335 groups[0],
1336 PageGroup {
1337 start_pgno: 1,
1338 group_size: 1,
1339 repair: HEADER_PAGE_R_REPAIR
1340 }
1341 );
1342 }
1343
1344 #[test]
1345 fn test_page_group_partitioning_64_pages() {
1346 let groups = partition_page_groups(64);
1347 assert_eq!(groups.len(), 2);
1348 assert_eq!(groups[0].start_pgno, 1);
1350 assert_eq!(groups[0].group_size, 1);
1351 assert_eq!(groups[0].repair, HEADER_PAGE_R_REPAIR);
1352 assert_eq!(groups[1].start_pgno, 2);
1354 assert_eq!(groups[1].group_size, 63);
1355 assert_eq!(groups[1].repair, DEFAULT_R_REPAIR);
1356 }
1357
1358 #[test]
1359 fn test_page_group_partitioning_65_pages() {
1360 let groups = partition_page_groups(65);
1361 assert_eq!(groups.len(), 2);
1362 assert_eq!(groups[1].start_pgno, 2);
1363 assert_eq!(groups[1].group_size, 64);
1364 assert_eq!(groups[1].repair, DEFAULT_R_REPAIR);
1365 }
1366
1367 #[test]
1368 fn test_page_group_partitioning_128_pages() {
1369 let groups = partition_page_groups(128);
1370 assert_eq!(groups.len(), 3);
1371 assert_eq!(groups[0].start_pgno, 1);
1372 assert_eq!(groups[0].group_size, 1);
1373 assert_eq!(groups[1].start_pgno, 2);
1374 assert_eq!(groups[1].group_size, 64);
1375 assert_eq!(groups[2].start_pgno, 66);
1376 assert_eq!(groups[2].group_size, 63);
1377 }
1378
1379 #[test]
1380 fn test_page_group_partitioning_1000_pages() {
1381 let groups = partition_page_groups(1000);
1382 assert_eq!(groups.len(), 17);
1384 assert_eq!(groups[0].group_size, 1);
1385 let total_pages: u32 = groups.iter().map(|g| g.group_size).sum();
1387 assert_eq!(total_pages, 1000);
1388 }
1389
1390 #[test]
1391 fn test_page_group_partitioning_zero() {
1392 let groups = partition_page_groups(0);
1393 assert!(groups.is_empty());
1394 }
1395
1396 #[test]
1397 fn test_header_page_400pct_redundancy() {
1398 let groups = partition_page_groups(100);
1399 assert_eq!(groups[0].group_size, 1);
1401 assert_eq!(groups[0].repair, 4);
1402 }
1403
1404 #[test]
1407 fn test_segment_offset_o1() {
1408 let page_size: u32 = 4096;
1409 let seg1_len = group_segment_size(1, HEADER_PAGE_R_REPAIR, page_size);
1410 let general_seg_len = group_segment_size(DEFAULT_GROUP_SIZE, DEFAULT_R_REPAIR, page_size);
1411
1412 for g in 0..10_u32 {
1414 let off = segment_offset(g, seg1_len, general_seg_len);
1415 let expected = DB_FEC_HEADER_SIZE + seg1_len + g as usize * general_seg_len;
1416 assert_eq!(off, expected, "segment offset mismatch for g={g}");
1417 }
1418 }
1419
1420 #[test]
1423 fn test_group_meta_roundtrip() {
1424 let hashes: Vec<[u8; 16]> = (0..4)
1425 .map(|i| {
1426 let mut h = [0u8; 16];
1427 h[0] = i;
1428 h
1429 })
1430 .collect();
1431 let digest = compute_db_gen_digest(1, 100, 0, 42);
1432 let meta = DbFecGroupMeta::new(4096, 2, 4, 4, hashes, digest);
1433 let bytes = meta.to_bytes();
1434 let decoded = DbFecGroupMeta::from_bytes(&bytes).expect("decode");
1435 assert_eq!(meta, decoded);
1436 }
1437
1438 #[test]
1439 fn test_group_meta_object_id() {
1440 let hashes: Vec<[u8; 16]> = (0..2)
1441 .map(|i| {
1442 let mut h = [0u8; 16];
1443 h[0] = i;
1444 h
1445 })
1446 .collect();
1447 let digest = compute_db_gen_digest(1, 100, 0, 42);
1448 let meta = DbFecGroupMeta::new(4096, 2, 2, 4, hashes, digest);
1449
1450 let oid = meta.object_id;
1452 assert_ne!(oid, [0u8; 16], "object_id should be non-zero");
1453
1454 let mut hashes2: Vec<[u8; 16]> = (0..2)
1456 .map(|i| {
1457 let mut h = [0u8; 16];
1458 h[0] = i;
1459 h
1460 })
1461 .collect();
1462 hashes2[0][1] = 0xFF;
1463 let meta2 = DbFecGroupMeta::new(4096, 2, 2, 4, hashes2, digest);
1464 assert_ne!(meta.object_id, meta2.object_id);
1465 }
1466
1467 #[test]
1468 fn test_group_meta_stale_guard() {
1469 let hashes = vec![[0u8; 16]; 1];
1470 let digest = compute_db_gen_digest(1, 100, 0, 42);
1471 let meta = DbFecGroupMeta::new(4096, 1, 1, 4, hashes, digest);
1472
1473 let stale_digest = compute_db_gen_digest(2, 100, 0, 42);
1474 assert_ne!(meta.db_gen_digest, stale_digest);
1476 }
1477
1478 #[test]
1479 fn test_group_meta_bad_checksum() {
1480 let hashes = vec![[1u8; 16]; 2];
1481 let digest = compute_db_gen_digest(1, 100, 0, 42);
1482 let meta = DbFecGroupMeta::new(4096, 2, 2, 4, hashes, digest);
1483 let mut bytes = meta.to_bytes();
1484 let last = bytes.len() - 1;
1486 bytes[last] ^= 0xFF;
1487 let result = DbFecGroupMeta::from_bytes(&bytes);
1488 assert!(result.is_err());
1489 }
1490
1491 #[test]
1494 fn test_read_path_intact() {
1495 let page_size = 64_u32;
1496 let page_data: Vec<Vec<u8>> = (0..4_u8).map(|i| vec![i; page_size as usize]).collect();
1497 let hashes: Vec<[u8; 16]> = page_data.iter().map(|d| page_xxh3_128(d)).collect();
1498 let digest = compute_db_gen_digest(1, 5, 0, 1);
1499 let meta = DbFecGroupMeta::new(page_size, 2, 4, 4, hashes, digest);
1500
1501 for (i, d) in page_data.iter().enumerate() {
1503 assert!(verify_page_xxh3_128(d, &meta.source_page_xxh3_128[i]));
1504 }
1505 }
1506
1507 #[test]
1508 fn test_read_path_single_corruption() {
1509 let page_size = 64_u32;
1510 let page_data: Vec<Vec<u8>> = (0..4_u8).map(|i| vec![i + 1; page_size as usize]).collect();
1511 let hashes: Vec<[u8; 16]> = page_data.iter().map(|d| page_xxh3_128(d)).collect();
1512 let digest = compute_db_gen_digest(1, 5, 0, 1);
1513 let meta = DbFecGroupMeta::new(page_size, 2, 4, 4, hashes, digest);
1514
1515 let source_slices: Vec<&[u8]> = page_data.iter().map(Vec::as_slice).collect();
1517 let repair_data = compute_raptorq_repair_symbols(&meta, &source_slices, page_size as usize)
1518 .expect("encode");
1519
1520 let target_pgno = 4;
1522 let corrupted = vec![0xFF_u8; page_size as usize];
1523
1524 let read_fn = |pgno: u32| -> Vec<u8> {
1525 if pgno == target_pgno {
1526 corrupted.clone()
1527 } else {
1528 page_data[(pgno - 2) as usize].clone()
1529 }
1530 };
1531
1532 let repair_symbols: Vec<(u32, Vec<u8>)> = repair_data
1534 .into_iter()
1535 .enumerate()
1536 .map(|(i, d)| (4 + u32::try_from(i).expect("i fits u32"), d))
1537 .collect();
1538 let result = attempt_page_repair(target_pgno, &meta, &read_fn, &repair_symbols);
1539 let (recovered, status) = result.expect("repair should succeed");
1540 assert_eq!(
1541 recovered, page_data[2],
1542 "recovered page must match original"
1543 );
1544 assert!(matches!(status, RepairResult::Repaired { pgno: 4, .. }));
1545 }
1546
1547 #[test]
1548 fn test_read_path_exceed_corruption() {
1549 let page_size = 64_u32;
1550 let page_data: Vec<Vec<u8>> = (0..4_u8).map(|i| vec![i + 1; page_size as usize]).collect();
1551 let hashes: Vec<[u8; 16]> = page_data.iter().map(|d| page_xxh3_128(d)).collect();
1552 let digest = compute_db_gen_digest(1, 5, 0, 1);
1553 let meta = DbFecGroupMeta::new(page_size, 2, 4, 4, hashes, digest);
1554
1555 let corrupted = vec![0xFF_u8; page_size as usize];
1557 let read_fn = |_pgno: u32| -> Vec<u8> { corrupted.clone() };
1558 let repair_symbols: Vec<(u32, Vec<u8>)> = Vec::new();
1559
1560 let result = attempt_page_repair(3, &meta, &read_fn, &repair_symbols);
1561 assert!(result.is_err());
1562 }
1563
1564 #[test]
1565 fn test_e2e_bitrot_recovery() {
1566 let page_size = 128_u32;
1568 let num_pages = 4_u32;
1569 let pages: Vec<Vec<u8>> = (0..num_pages)
1570 .map(|i| {
1571 let mut data = vec![0u8; page_size as usize];
1572 for (j, b) in data.iter_mut().enumerate() {
1574 #[allow(clippy::cast_possible_truncation)]
1575 {
1576 *b = ((i as usize * 37 + j * 13) & 0xFF) as u8;
1577 }
1578 }
1579 data
1580 })
1581 .collect();
1582
1583 let hashes: Vec<[u8; 16]> = pages.iter().map(|d| page_xxh3_128(d)).collect();
1584 let digest = compute_db_gen_digest(1, num_pages + 1, 0, 1);
1585 let meta = DbFecGroupMeta::new(page_size, 2, num_pages, 4, hashes, digest);
1586
1587 let source_slices: Vec<&[u8]> = pages.iter().map(Vec::as_slice).collect();
1589 let repair_data = compute_raptorq_repair_symbols(&meta, &source_slices, page_size as usize)
1590 .expect("encode");
1591
1592 let target = 2_u32;
1594 let corrupted = vec![0xAA_u8; page_size as usize];
1595
1596 let read_fn = |pgno: u32| -> Vec<u8> {
1597 if pgno == target {
1598 corrupted.clone()
1599 } else {
1600 pages[(pgno - 2) as usize].clone()
1601 }
1602 };
1603
1604 let repair_symbols: Vec<(u32, Vec<u8>)> = repair_data
1605 .into_iter()
1606 .enumerate()
1607 .map(|(i, d)| (num_pages + u32::try_from(i).expect("i fits u32"), d))
1608 .collect();
1609 let (recovered, _) =
1610 attempt_page_repair(target, &meta, &read_fn, &repair_symbols).expect("repair");
1611 assert_eq!(recovered, pages[0]);
1612 }
1613
1614 #[test]
1615 fn test_e2e_stale_sidecar_rejected() {
1616 let hdr1 = DbFecHeader::new(4096, 1, 100, 0, 1);
1617 let hdr2 = DbFecHeader::new(4096, 2, 100, 0, 1); assert_ne!(hdr1.db_gen_digest, hdr2.db_gen_digest);
1619 assert!(!hdr1.is_current(2, 100, 0, 1));
1620 }
1621
1622 #[test]
1623 fn test_overflow_threshold_g64_r4() {
1624 let overhead = f64::from(DEFAULT_R_REPAIR) / f64::from(DEFAULT_GROUP_SIZE);
1626 assert!((overhead - 0.0625).abs() < f64::EPSILON);
1627 }
1628
1629 #[test]
1630 fn test_last_group_partial() {
1631 let groups = partition_page_groups(100);
1633 assert_eq!(groups.len(), 3);
1634 assert_eq!(groups[2].start_pgno, 66);
1635 assert_eq!(groups[2].group_size, 35);
1636
1637 let page_size = 4096_u32;
1640 let seg1_len = group_segment_size(1, HEADER_PAGE_R_REPAIR, page_size);
1641 let general_seg_len = group_segment_size(DEFAULT_GROUP_SIZE, DEFAULT_R_REPAIR, page_size);
1642 let off = segment_offset(1, seg1_len, general_seg_len);
1643 assert_eq!(
1644 off,
1645 DB_FEC_HEADER_SIZE + seg1_len + general_seg_len,
1646 "second full-group offset"
1647 );
1648 }
1649
1650 #[test]
1651 fn test_find_full_group_index() {
1652 assert_eq!(find_full_group_index(1), None); assert_eq!(find_full_group_index(2), Some(0));
1654 assert_eq!(find_full_group_index(65), Some(0));
1655 assert_eq!(find_full_group_index(66), Some(1));
1656 assert_eq!(find_full_group_index(130), Some(2));
1657 }
1658
1659 #[test]
1662 fn test_bd_1hi_18_unit_compliance_gate() {
1663 assert_eq!(BEAD_ID, "bd-1hi.18");
1665 assert_eq!(DB_FEC_MAGIC, *b"FSQLDFEC");
1666 assert_eq!(GROUP_META_MAGIC, *b"FSQLDGRP");
1667 assert_eq!(DB_FEC_VERSION, 1);
1668 assert_eq!(DEFAULT_GROUP_SIZE, 64);
1669 assert_eq!(DEFAULT_R_REPAIR, 4);
1670 assert_eq!(HEADER_PAGE_R_REPAIR, 4);
1671 }
1672
1673 #[test]
1674 fn prop_bd_1hi_18_structure_compliance() {
1675 for n in [1_u32, 2, 63, 64, 65, 128, 129, 500, 1000] {
1677 let groups = partition_page_groups(n);
1678 let total: u32 = groups.iter().map(|g| g.group_size).sum();
1679 assert_eq!(total, n, "total pages mismatch for n={n}");
1680
1681 let mut covered = 0_u32;
1683 for g in &groups {
1684 assert!(g.start_pgno > covered, "overlap at pgno {}", g.start_pgno);
1685 covered = g.start_pgno + g.group_size - 1;
1686 }
1687 assert_eq!(covered, n);
1688 }
1689 }
1690
1691 #[test]
1692 fn test_e2e_bd_1hi_18_compliance() {
1693 let page_size = 4096_u32;
1695 let db_pages = 200_u32;
1696 let hdr = DbFecHeader::new(page_size, 10, db_pages, 3, 42);
1697
1698 let hdr2 = DbFecHeader::from_bytes(&hdr.to_bytes()).expect("roundtrip");
1700 assert_eq!(hdr, hdr2);
1701 assert!(hdr.is_current(10, db_pages, 3, 42));
1702
1703 let groups = partition_page_groups(db_pages);
1705 assert!(!groups.is_empty());
1706 let total: u32 = groups.iter().map(|g| g.group_size).sum();
1707 assert_eq!(total, db_pages);
1708
1709 assert_eq!(groups[0].group_size, 1);
1711 assert_eq!(groups[0].repair, HEADER_PAGE_R_REPAIR);
1712
1713 let seg1_len = group_segment_size(1, HEADER_PAGE_R_REPAIR, page_size);
1715 let general_seg_len = group_segment_size(DEFAULT_GROUP_SIZE, DEFAULT_R_REPAIR, page_size);
1716 let mut prev_off = 0;
1717 #[allow(clippy::cast_possible_truncation)]
1718 let group_count = groups.len().saturating_sub(1) as u32;
1719 for g in 0..group_count {
1720 let off = segment_offset(g, seg1_len, general_seg_len);
1721 assert!(
1722 off > prev_off || g == 0,
1723 "offsets must be monotonically increasing"
1724 );
1725 prev_off = off;
1726 }
1727 }
1728
1729 #[test]
1732 fn prop_db_gen_digest_deterministic() {
1733 for i in 0..50_u32 {
1734 let d1 = compute_db_gen_digest(i, i * 10, i * 2, i * 3);
1735 let d2 = compute_db_gen_digest(i, i * 10, i * 2, i * 3);
1736 assert_eq!(d1, d2, "digest must be deterministic for i={i}");
1737 }
1738 }
1739
1740 #[test]
1743 fn prop_group_segment_sizes_consistent() {
1744 for ps in [512_u32, 1024, 4096, 8192, 16384, 32768, 65536] {
1745 let seg1 = group_segment_size(1, HEADER_PAGE_R_REPAIR, ps);
1746 let general_seg = group_segment_size(DEFAULT_GROUP_SIZE, DEFAULT_R_REPAIR, ps);
1747
1748 assert!(seg1 < general_seg, "page-1 segment should be smaller");
1750
1751 let expected_seg1 = DbFecGroupMeta::serialized_size_for(1)
1753 + HEADER_PAGE_R_REPAIR as usize * ps as usize;
1754 assert_eq!(seg1, expected_seg1);
1755
1756 let expected_general_seg = DbFecGroupMeta::serialized_size_for(DEFAULT_GROUP_SIZE)
1757 + DEFAULT_R_REPAIR as usize * ps as usize;
1758 assert_eq!(general_seg, expected_general_seg);
1759 }
1760 }
1761
1762 fn make_synthetic_db(page_size: u32, page_count: u32) -> Vec<u8> {
1765 let ps = page_size as usize;
1766 let mut db = vec![0u8; ps * page_count as usize];
1767 db[..16].copy_from_slice(b"SQLite format 3\0");
1768 #[allow(clippy::cast_possible_truncation)]
1769 let ps_enc: u16 = if page_size == 65536 {
1770 1
1771 } else {
1772 page_size as u16
1773 };
1774 db[PAGE_SIZE_OFFSET..PAGE_SIZE_OFFSET + 2].copy_from_slice(&ps_enc.to_be_bytes());
1775 db[CHANGE_COUNTER_OFFSET..CHANGE_COUNTER_OFFSET + 4].copy_from_slice(&1_u32.to_be_bytes());
1776 db[PAGE_COUNT_OFFSET..PAGE_COUNT_OFFSET + 4].copy_from_slice(&page_count.to_be_bytes());
1777 db[FREELIST_COUNT_OFFSET..FREELIST_COUNT_OFFSET + 4].copy_from_slice(&0_u32.to_be_bytes());
1778 db[SCHEMA_COOKIE_OFFSET..SCHEMA_COOKIE_OFFSET + 4].copy_from_slice(&42_u32.to_be_bytes());
1779 for pgno in 1..=page_count {
1780 let offset = (pgno as usize - 1) * ps;
1781 let start = if pgno == 1 { 100 } else { 0 };
1782 for j in start..ps {
1783 #[allow(clippy::cast_possible_truncation)]
1784 {
1785 db[offset + j] = ((pgno as usize * 37 + j * 13) & 0xFF) as u8;
1786 }
1787 }
1788 }
1789 db
1790 }
1791
1792 #[test]
1793 fn test_parse_db_header_fields() {
1794 let db = make_synthetic_db(4096, 10);
1795 let fields = parse_db_header_fields(&db).expect("parse");
1796 assert_eq!(fields.page_size, 4096);
1797 assert_eq!(fields.change_counter, 1);
1798 assert_eq!(fields.page_count, 10);
1799 assert_eq!(fields.freelist_count, 0);
1800 assert_eq!(fields.schema_cookie, 42);
1801 }
1802
1803 #[test]
1804 fn test_parse_db_header_too_short() {
1805 assert!(parse_db_header_fields(&[0u8; 50]).is_err());
1806 }
1807
1808 #[test]
1809 fn test_db_fec_path_for_db() {
1810 let p = db_fec_path_for_db(Path::new("/tmp/test.db"));
1811 assert_eq!(p, PathBuf::from("/tmp/test.db-fec"));
1812 }
1813
1814 #[test]
1815 fn test_generate_db_fec_sidecar_header_valid() {
1816 let db = make_synthetic_db(512, 5);
1817 let sidecar = generate_db_fec_from_bytes(&db).expect("generate");
1818 assert!(sidecar.len() >= DB_FEC_HEADER_SIZE);
1819 let mut hdr_buf = [0u8; DB_FEC_HEADER_SIZE];
1820 hdr_buf.copy_from_slice(&sidecar[..DB_FEC_HEADER_SIZE]);
1821 let hdr = DbFecHeader::from_bytes(&hdr_buf).expect("header");
1822 assert_eq!(hdr.page_size, 512);
1823 assert!(hdr.is_current(1, 5, 0, 42));
1824 }
1825
1826 #[test]
1827 fn test_generate_and_read_group_roundtrip() {
1828 let db = make_synthetic_db(512, 5);
1829 let sidecar = generate_db_fec_from_bytes(&db).expect("generate");
1830 let mut hdr_buf = [0u8; DB_FEC_HEADER_SIZE];
1831 hdr_buf.copy_from_slice(&sidecar[..DB_FEC_HEADER_SIZE]);
1832 let hdr = DbFecHeader::from_bytes(&hdr_buf).expect("header");
1833 let (meta1, syms1) = read_db_fec_group_for_page(&sidecar, &hdr, 1).expect("page 1 group");
1834 assert_eq!(meta1.start_pgno, 1);
1835 assert_eq!(meta1.group_size, 1);
1836 assert_eq!(meta1.r_repair, HEADER_PAGE_R_REPAIR);
1837 assert_eq!(syms1.len(), HEADER_PAGE_R_REPAIR as usize);
1838 let (meta2, syms2) = read_db_fec_group_for_page(&sidecar, &hdr, 2).expect("page 2 group");
1839 assert_eq!(meta2.start_pgno, 2);
1840 assert_eq!(meta2.group_size, 4);
1841 assert_eq!(syms2.len(), DEFAULT_R_REPAIR as usize);
1842 for i in 0..meta2.group_size {
1843 let page = read_page_from_bytes(&db, meta2.start_pgno + i, 512);
1844 assert!(verify_page_xxh3_128(
1845 &page,
1846 &meta2.source_page_xxh3_128[i as usize]
1847 ));
1848 }
1849 }
1850
1851 #[test]
1852 fn test_sidecar_encode_corrupt_decode_cycle() {
1853 let ps = 512_usize;
1854 let mut db = make_synthetic_db(512, 5);
1855 let sidecar = generate_db_fec_from_bytes(&db).expect("generate");
1856 let mut hdr_buf = [0u8; DB_FEC_HEADER_SIZE];
1857 hdr_buf.copy_from_slice(&sidecar[..DB_FEC_HEADER_SIZE]);
1858 let hdr = DbFecHeader::from_bytes(&hdr_buf).expect("header");
1859 let target_pgno = 3_u32;
1860 let original_page = read_page_from_bytes(&db, target_pgno, ps);
1861 let corrupt_offset = (target_pgno as usize - 1) * ps;
1862 for b in &mut db[corrupt_offset..corrupt_offset + ps] {
1863 *b = 0xDE;
1864 }
1865 let (meta, repair_symbols) =
1866 read_db_fec_group_for_page(&sidecar, &hdr, target_pgno).expect("read group");
1867 let corrupted_data = read_page_from_bytes(&db, target_pgno, ps);
1868 let idx = (target_pgno - meta.start_pgno) as usize;
1869 assert!(!verify_page_xxh3_128(
1870 &corrupted_data,
1871 &meta.source_page_xxh3_128[idx]
1872 ));
1873 let read_fn = |pgno: u32| -> Vec<u8> { read_page_from_bytes(&db, pgno, ps) };
1874 let (recovered, result) =
1875 attempt_page_repair(target_pgno, &meta, &read_fn, &repair_symbols)
1876 .expect("repair should succeed");
1877 assert_eq!(recovered, original_page);
1878 assert!(matches!(result, RepairResult::Repaired { pgno: 3, .. }));
1879 }
1880
1881 #[test]
1882 fn test_sidecar_header_page_repair() {
1883 let ps = 256_usize;
1884 let mut db = make_synthetic_db(256, 3);
1885 let sidecar = generate_db_fec_from_bytes(&db).expect("generate");
1886 let mut hdr_buf = [0u8; DB_FEC_HEADER_SIZE];
1887 hdr_buf.copy_from_slice(&sidecar[..DB_FEC_HEADER_SIZE]);
1888 let hdr = DbFecHeader::from_bytes(&hdr_buf).expect("header");
1889 let original_page1 = read_page_from_bytes(&db, 1, ps);
1890 for b in &mut db[..ps] {
1891 *b = 0xCC;
1892 }
1893 let (meta, repair_symbols) =
1894 read_db_fec_group_for_page(&sidecar, &hdr, 1).expect("read group");
1895 assert_eq!(meta.group_size, 1);
1896 assert_eq!(meta.r_repair, 4);
1897 let read_fn = |_pgno: u32| -> Vec<u8> { read_page_from_bytes(&db, 1, ps) };
1898 let (recovered, _) =
1899 attempt_page_repair(1, &meta, &read_fn, &repair_symbols).expect("repair page 1");
1900 assert_eq!(recovered, original_page1);
1901 }
1902
1903 #[test]
1904 fn test_sidecar_stale_digest_detection() {
1905 let db = make_synthetic_db(512, 5);
1906 let sidecar = generate_db_fec_from_bytes(&db).expect("generate");
1907 let mut hdr_buf = [0u8; DB_FEC_HEADER_SIZE];
1908 hdr_buf.copy_from_slice(&sidecar[..DB_FEC_HEADER_SIZE]);
1909 let hdr = DbFecHeader::from_bytes(&hdr_buf).expect("header");
1910 assert!(hdr.is_current(1, 5, 0, 42));
1911 assert!(!hdr.is_current(2, 5, 0, 42));
1912 assert!(!hdr.is_current(1, 6, 0, 42));
1913 }
1914
1915 #[test]
1916 fn test_sidecar_xxh3_validates_corruption() {
1917 let db = make_synthetic_db(512, 5);
1918 let sidecar = generate_db_fec_from_bytes(&db).expect("generate");
1919 let mut hdr_buf = [0u8; DB_FEC_HEADER_SIZE];
1920 hdr_buf.copy_from_slice(&sidecar[..DB_FEC_HEADER_SIZE]);
1921 let hdr = DbFecHeader::from_bytes(&hdr_buf).expect("header");
1922 let (meta, _) = read_db_fec_group_for_page(&sidecar, &hdr, 3).expect("read");
1923 let page = read_page_from_bytes(&db, 3, 512);
1924 let idx = (3 - meta.start_pgno) as usize;
1925 assert!(verify_page_xxh3_128(&page, &meta.source_page_xxh3_128[idx]));
1926 let corrupt = vec![0xFF_u8; 512];
1927 assert!(!verify_page_xxh3_128(
1928 &corrupt,
1929 &meta.source_page_xxh3_128[idx]
1930 ));
1931 }
1932
1933 #[test]
1934 fn test_sidecar_large_db_128_pages() {
1935 let mut db = make_synthetic_db(512, 128);
1936 let sidecar = generate_db_fec_from_bytes(&db).expect("generate");
1937 let mut hdr_buf = [0u8; DB_FEC_HEADER_SIZE];
1938 hdr_buf.copy_from_slice(&sidecar[..DB_FEC_HEADER_SIZE]);
1939 let hdr = DbFecHeader::from_bytes(&hdr_buf).expect("header");
1940 let (m1, _) = read_db_fec_group_for_page(&sidecar, &hdr, 1).expect("page 1");
1941 assert_eq!(m1.group_size, 1);
1942 let (m2, _) = read_db_fec_group_for_page(&sidecar, &hdr, 30).expect("page 30");
1943 assert_eq!(m2.start_pgno, 2);
1944 assert_eq!(m2.group_size, 64);
1945 let (m3, _) = read_db_fec_group_for_page(&sidecar, &hdr, 100).expect("page 100");
1946 assert_eq!(m3.start_pgno, 66);
1947 assert_eq!(m3.group_size, 63);
1948 let original = read_page_from_bytes(&db, 100, 512);
1949 let off = (100 - 1) * 512;
1950 for b in &mut db[off..off + 512] {
1951 *b = 0xBB;
1952 }
1953 let (meta, syms) = read_db_fec_group_for_page(&sidecar, &hdr, 100).expect("read");
1954 let read_fn = |pgno: u32| -> Vec<u8> { read_page_from_bytes(&db, pgno, 512) };
1955 let (recovered, _) =
1956 attempt_page_repair(100, &meta, &read_fn, &syms).expect("repair page 100");
1957 assert_eq!(recovered, original);
1958 }
1959
1960 #[test]
1961 fn test_sidecar_file_write_read_roundtrip() {
1962 let dir = tempfile::tempdir().expect("tempdir");
1963 let db_path = dir.path().join("test.db");
1964 let db = make_synthetic_db(512, 5);
1965 std::fs::write(&db_path, &db).expect("write db");
1966 let sidecar_path = write_db_fec_sidecar(&db_path).expect("write sidecar");
1967 assert_eq!(sidecar_path, db_fec_path_for_db(&db_path));
1968 assert!(sidecar_path.exists());
1969 let hdr = read_db_fec_header(&sidecar_path).expect("read header");
1970 assert_eq!(hdr.page_size, 512);
1971 assert!(hdr.is_current(1, 5, 0, 42));
1972 }
1973
1974 #[test]
1977 fn test_raptorq_encode_deterministic() {
1978 let page_size = 128_u32;
1979 let pages: Vec<Vec<u8>> = (0..4_u8).map(|i| vec![i + 1; page_size as usize]).collect();
1980 let hashes: Vec<[u8; 16]> = pages.iter().map(|d| page_xxh3_128(d)).collect();
1981 let digest = compute_db_gen_digest(1, 5, 0, 1);
1982 let meta = DbFecGroupMeta::new(page_size, 2, 4, 4, hashes, digest);
1983 let slices: Vec<&[u8]> = pages.iter().map(Vec::as_slice).collect();
1984 let r1 = compute_raptorq_repair_symbols(&meta, &slices, page_size as usize).expect("e1");
1985 let r2 = compute_raptorq_repair_symbols(&meta, &slices, page_size as usize).expect("e2");
1986 assert_eq!(r1, r2, "RaptorQ encoding must be deterministic");
1987 }
1988
1989 #[test]
1990 fn test_raptorq_encode_produces_correct_count() {
1991 let page_size = 64_u32;
1992 let pages: Vec<Vec<u8>> = (0..8_u8).map(|i| vec![i; page_size as usize]).collect();
1993 let hashes: Vec<[u8; 16]> = pages.iter().map(|d| page_xxh3_128(d)).collect();
1994 let digest = compute_db_gen_digest(1, 9, 0, 1);
1995 let meta = DbFecGroupMeta::new(page_size, 2, 8, 4, hashes, digest);
1996 let slices: Vec<&[u8]> = pages.iter().map(Vec::as_slice).collect();
1997 let syms =
1998 compute_raptorq_repair_symbols(&meta, &slices, page_size as usize).expect("encode");
1999 assert_eq!(syms.len(), 4, "should produce R=4 repair symbols");
2000 for sym in &syms {
2001 assert_eq!(sym.len(), page_size as usize, "symbol size = page_size");
2002 }
2003 }
2004
2005 #[test]
2006 fn test_raptorq_multi_corruption_recovery() {
2007 let page_size = 128_u32;
2010 let k = 8_u32;
2011 let r = 4_u32;
2012 let pages: Vec<Vec<u8>> = (0..k)
2013 .map(|i| {
2014 let mut data = vec![0u8; page_size as usize];
2015 for (j, b) in data.iter_mut().enumerate() {
2016 #[allow(clippy::cast_possible_truncation)]
2017 {
2018 *b = ((i as usize * 41 + j * 7) & 0xFF) as u8;
2019 }
2020 }
2021 data
2022 })
2023 .collect();
2024
2025 let hashes: Vec<[u8; 16]> = pages.iter().map(|d| page_xxh3_128(d)).collect();
2026 let digest = compute_db_gen_digest(1, k + 1, 0, 1);
2027 let meta = DbFecGroupMeta::new(page_size, 2, k, r, hashes, digest);
2028
2029 let slices: Vec<&[u8]> = pages.iter().map(Vec::as_slice).collect();
2030 let repair_data =
2031 compute_raptorq_repair_symbols(&meta, &slices, page_size as usize).expect("encode");
2032 let repair_symbols: Vec<(u32, Vec<u8>)> = repair_data
2033 .into_iter()
2034 .enumerate()
2035 .map(|(i, d)| (k + u32::try_from(i).expect("i fits u32"), d))
2036 .collect();
2037
2038 let corrupt_pgnos = [2_u32, 3_u32];
2040 let corrupted = vec![0xDD_u8; page_size as usize];
2041
2042 let read_fn = |pgno: u32| -> Vec<u8> {
2043 if corrupt_pgnos.contains(&pgno) {
2044 corrupted.clone()
2045 } else {
2046 pages[(pgno - 2) as usize].clone()
2047 }
2048 };
2049
2050 let (recovered_p2, status) =
2052 attempt_page_repair(2, &meta, &read_fn, &repair_symbols).expect("repair page 2");
2053 assert_eq!(recovered_p2, pages[0]);
2054 assert!(matches!(status, RepairResult::Repaired { pgno: 2, .. }));
2055
2056 let (recovered_p3, status) =
2058 attempt_page_repair(3, &meta, &read_fn, &repair_symbols).expect("repair page 3");
2059 assert_eq!(recovered_p3, pages[1]);
2060 assert!(matches!(status, RepairResult::Repaired { pgno: 3, .. }));
2061 }
2062
2063 #[test]
2064 fn test_raptorq_seed_differs_per_group() {
2065 let digest = compute_db_gen_digest(1, 200, 0, 42);
2066 let meta_a = DbFecGroupMeta::new(4096, 1, 1, 4, vec![[0u8; 16]], digest);
2067 let meta_b = DbFecGroupMeta::new(4096, 2, 64, 4, vec![[0u8; 16]; 64], digest);
2068 let seed_a = derive_db_fec_repair_seed(&meta_a);
2069 let seed_b = derive_db_fec_repair_seed(&meta_b);
2070 assert_ne!(
2071 seed_a, seed_b,
2072 "different groups must produce different seeds"
2073 );
2074 }
2075
2076 #[test]
2081 fn test_snapshot_fec_metrics_record_and_snapshot() {
2082 let m = SnapshotFecMetrics::new();
2083 m.record_encode(100, 4096);
2084 m.record_encode(64, 2048);
2085 let s = m.snapshot();
2086 assert_eq!(s.encoded_pages_total, 164);
2087 assert_eq!(s.sidecar_bytes_total, 6144);
2088 assert_eq!(s.encode_ops, 2);
2089 }
2090
2091 #[test]
2092 fn test_snapshot_fec_metrics_reset() {
2093 let m = SnapshotFecMetrics::new();
2094 m.record_encode(10, 500);
2095 m.reset();
2096 let s = m.snapshot();
2097 assert_eq!(s.encoded_pages_total, 0);
2098 assert_eq!(s.sidecar_bytes_total, 0);
2099 assert_eq!(s.encode_ops, 0);
2100 }
2101
2102 #[test]
2103 fn test_snapshot_fec_metrics_display() {
2104 let m = SnapshotFecMetrics::new();
2105 m.record_encode(42, 1024);
2106 let s = m.snapshot();
2107 let text = format!("{s}");
2108 assert!(text.contains("snapshot_fec_pages_encoded=42"));
2109 assert!(text.contains("sidecar_bytes=1024"));
2110 assert!(text.contains("encode_ops=1"));
2111 }
2112
2113 #[test]
2114 fn test_snapshot_fec_metrics_global_delta() {
2115 let before = GLOBAL_SNAPSHOT_FEC_METRICS.snapshot();
2117 GLOBAL_SNAPSHOT_FEC_METRICS.record_encode(7, 256);
2118 let after = GLOBAL_SNAPSHOT_FEC_METRICS.snapshot();
2119 assert_eq!(after.encoded_pages_total - before.encoded_pages_total, 7);
2120 assert_eq!(after.sidecar_bytes_total - before.sidecar_bytes_total, 256);
2121 assert_eq!(after.encode_ops - before.encode_ops, 1);
2122 }
2123}