Skip to main content

fsqlite_core/
db_fec.rs

1//! `.db-fec` sidecar — erasure-coded page storage for on-the-fly repair (§3.4.6, bd-1hi.18).
2//!
3//! Provides `DbFecHeader`, `DbFecGroupMeta`, page group partitioning (G=64, R=4),
4//! O(1) segment offset computation, stale-sidecar guard via `db_gen_digest`, and
5//! the read-path repair algorithm.
6//!
7//! Note: the sidecar generation and group-read helpers are intentionally public so
8//! the `fsqlite-e2e` recovery demos can validate end-to-end repair flows.
9
10use 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
19// ---------------------------------------------------------------------------
20// Constants
21// ---------------------------------------------------------------------------
22
23const BEAD_ID: &str = "bd-1hi.18";
24
25/// Magic bytes for `.db-fec` header.
26pub const DB_FEC_MAGIC: [u8; 8] = *b"FSQLDFEC";
27
28/// Magic bytes for group metadata.
29pub const GROUP_META_MAGIC: [u8; 8] = *b"FSQLDGRP";
30
31/// Current format version.
32pub const DB_FEC_VERSION: u32 = 1;
33
34/// Default pages per group (256 KiB blast radius at 4 KiB pages).
35pub const DEFAULT_GROUP_SIZE: u32 = 64;
36
37/// Default repair symbols per group (tolerates 4 corrupted pages per group).
38pub const DEFAULT_R_REPAIR: u32 = 4;
39
40/// Header page (page 1) gets special 400% redundancy: G=1, R=4.
41pub const HEADER_PAGE_R_REPAIR: u32 = 4;
42
43/// Domain separation string for `db_gen_digest`.
44pub const DB_GEN_DIGEST_DOMAIN: &str = "fsqlite:compat:dbgen:v1";
45
46/// Domain separation string for group `object_id`.
47pub const GROUP_OBJECT_ID_DOMAIN: &str = "fsqlite:compat:db-fec-group:v1";
48
49/// `DbFecHeader` serialized size: 8 (magic) + 4 (version) + 4 (page_size)
50/// + 4 (default_group_size) + 4 (default_r_repair) + 4 (header_page_r_repair)
51/// + 16 (db_gen_digest) + 8 (checksum) = 52 bytes.
52pub 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
72// ---------------------------------------------------------------------------
73// Snapshot FEC metrics
74// ---------------------------------------------------------------------------
75
76/// Global snapshot FEC metrics singleton.
77pub static GLOBAL_SNAPSHOT_FEC_METRICS: SnapshotFecMetrics = SnapshotFecMetrics::new();
78
79/// Atomic counters for snapshot page FEC encoding.
80pub struct SnapshotFecMetrics {
81    /// Total pages encoded into FEC repair symbols.
82    pub encoded_pages_total: AtomicU64,
83    /// Total bytes of sidecar data generated.
84    pub sidecar_bytes_total: AtomicU64,
85    /// Total encoding operations.
86    pub encode_ops: AtomicU64,
87}
88
89impl SnapshotFecMetrics {
90    /// Create a zeroed metrics instance.
91    #[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    /// Record a snapshot FEC encoding operation.
101    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    /// Take a snapshot.
110    #[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    /// Reset all counters to zero.
120    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/// Point-in-time snapshot of snapshot FEC metrics.
134#[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// ---------------------------------------------------------------------------
152// PageGroup — partition of database pages into repair groups
153// ---------------------------------------------------------------------------
154
155/// A contiguous group of database pages sharing a single repair-symbol set.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct PageGroup {
158    /// 1-based first page number.
159    pub start_pgno: u32,
160    /// Number of source pages in this group (K).
161    pub group_size: u32,
162    /// Number of repair symbols (R).
163    pub repair: u32,
164}
165
166/// Partition pages into groups per spec pseudocode (§3.4.6).
167///
168/// Page 1 gets its own group with `HEADER_PAGE_R_REPAIR` repair symbols.
169/// Remaining pages are grouped in chunks of `DEFAULT_GROUP_SIZE`.
170#[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    // Special group for database header page.
179    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// ---------------------------------------------------------------------------
212// db_gen_digest — staleness guard
213// ---------------------------------------------------------------------------
214
215/// Compute `db_gen_digest` from `.db` header fields.
216///
217/// Uses offsets 24, 28, 36, 40 (big-endian u32):
218/// `Trunc128(BLAKE3(domain || change_counter || page_count || freelist_count || schema_cookie))`.
219#[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// ---------------------------------------------------------------------------
239// DbFecHeader
240// ---------------------------------------------------------------------------
241
242/// Header of the `.db-fec` sidecar file (byte offset 0).
243#[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    /// Create a new header for the given page size and db generation fields.
257    #[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    /// Serialize to bytes.
282    #[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    /// Deserialize from bytes.
297    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    /// Compute xxh3_64 checksum of all fields preceding the checksum field.
353    #[must_use]
354    fn compute_checksum(&self) -> u64 {
355        let buf = self.to_bytes();
356        // Checksum covers bytes 0..44 (everything except the checksum field itself).
357        xxhash_rust::xxh3::xxh3_64(&buf[..44])
358    }
359
360    /// Verify that this header's `db_gen_digest` matches the current `.db` generation.
361    #[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// ---------------------------------------------------------------------------
376// DbFecGroupMeta
377// ---------------------------------------------------------------------------
378
379/// Per-group metadata stored in the `.db-fec` sidecar.
380#[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    /// Content-addressed: `Trunc128(BLAKE3(domain || canonical))`.
389    pub object_id: [u8; 16],
390    /// Per-source-page xxh3_128 hashes; length == group_size.
391    pub source_page_xxh3_128: Vec<[u8; 16]>,
392    /// Must match `DbFecHeader.db_gen_digest`.
393    pub db_gen_digest: [u8; 16],
394    pub checksum: u64,
395}
396
397impl DbFecGroupMeta {
398    /// Create a new group meta. Computes `object_id` and `checksum` automatically.
399    #[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    /// Fixed-size portion of the serialized meta (excluding variable-length hash array).
430    /// 8 (magic) + 4 (version) + 4 (page_size) + 4 (start_pgno) + 4 (group_size)
431    /// + 4 (r_repair) + 16 (object_id) + 16 (db_gen_digest) + 8 (checksum) = 68.
432    const FIXED_SIZE: usize = 68;
433
434    /// Total serialized size.
435    #[must_use]
436    pub fn serialized_size(&self) -> usize {
437        Self::FIXED_SIZE + self.source_page_xxh3_128.len() * 16
438    }
439
440    /// Serialized size for a group with the given `group_size`.
441    #[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    /// Serialize to bytes.
449    #[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    /// Deserialize from bytes.
472    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    /// Compute the content-addressed `object_id`.
568    #[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        // Canonical representation: all fields except object_id and checksum.
573        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    /// Compute xxh3_64 checksum of all fields except the checksum field itself.
590    #[must_use]
591    fn compute_checksum(&self) -> u64 {
592        let bytes = self.to_bytes();
593        // Everything except the last 8 bytes (checksum field).
594        xxhash_rust::xxh3::xxh3_64(&bytes[..bytes.len() - 8])
595    }
596}
597
598// ---------------------------------------------------------------------------
599// Segment layout — O(1) random access
600// ---------------------------------------------------------------------------
601
602/// Compute the byte offset in the `.db-fec` file for the segment belonging to
603/// the full-group at 0-based index `g` (groups starting at page 2).
604///
605/// Layout:
606///   \[DbFecHeader\]\[Seg1 (page 1)\]\[SegG\_0\]\[SegG\_1\]...
607///
608/// `segment_1_len`: The total byte size of the page-1 segment (meta + R repair symbols).
609/// `full_segment_len`: The total byte size of a full-group segment (meta + R repair symbols).
610#[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/// Compute the total size of a group segment.
616///
617/// Each segment stores its `DbFecGroupMeta` plus R repair symbols of `page_size` bytes each.
618#[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/// Find which 0-based full-group index a page number belongs to.
624/// Returns `None` for page 1 (header group) or invalid pgno.
625#[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// ---------------------------------------------------------------------------
634// Read path — on-the-fly repair
635// ---------------------------------------------------------------------------
636
637/// Result of an on-the-fly repair attempt.
638#[derive(Debug, Clone, PartialEq, Eq)]
639pub enum RepairResult {
640    /// Page was intact, no repair needed.
641    Intact,
642    /// Page was repaired from group erasure coding.
643    Repaired { pgno: u32, symbols_used: u32 },
644    /// Repair failed — insufficient symbols.
645    Unrecoverable {
646        pgno: u32,
647        missing_pages: u32,
648        r_budget: u32,
649    },
650}
651
652/// Simulated page integrity check. In production this would check AEAD tag,
653/// page checksum, or structural integrity. Here we check xxh3_128 against
654/// the expected hash from group metadata.
655#[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/// Compute xxh3_128 of page data, returned as a 16-byte LE array.
662#[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/// Attempt on-the-fly repair of a corrupted page using `.db-fec` group data.
669///
670/// `target_pgno` — the 1-based page to repair.
671/// `group_meta` — metadata for the group containing the page.
672/// `all_page_data` — function to read raw page data by pgno.
673/// `repair_symbols` — the R repair symbol data blocks for this group.
674///
675/// Uses the RFC 6330 RaptorQ `InactivationDecoder` to reconstruct missing
676/// source pages from any combination of intact sources and repair symbols,
677/// provided at least K total symbols are available.
678///
679/// Returns the repaired page bytes or an error.
680#[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    // Collect available source symbols (intact pages in the group, excluding target).
707    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    // Add repair symbols.
725    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    // Decode via RFC 6330 RaptorQ InactivationDecoder.
758    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    // asupersync 0.3.x synthesizes the K..K' RFC 6330 zero-padding rows
809    // internally inside `decode` (see `implicit_padding_rows` in
810    // asupersync-0.3.1/src/raptorq/decoder.rs:1212 and the decode
811    // accumulation at line 1688). Supplying them here as caller-provided
812    // source symbols is now rejected because source ESIs are validated
813    // against the real K domain, so the previous manual K..K' injection
814    // block is removed. Closes #83.
815
816    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    // Validate recovered page.
834    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// ---------------------------------------------------------------------------
868// Sidecar generation utility (bd-2r4z)
869// ---------------------------------------------------------------------------
870
871/// Compute the `.db-fec` sidecar path from a database path.
872#[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
879/// SQLite header field offsets (big-endian u32/u16).
880const 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/// Fields extracted from a SQLite database header for FEC generation.
888#[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/// Read the header fields from a SQLite database file.
898#[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
904/// Parse header fields from raw database bytes.
905pub 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    // SQLite encoding: 1 means 65536.
921    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
957/// Derive a deterministic RaptorQ encoder seed from group metadata.
958///
959/// Uses xxh3_64 over the group's content-addressed fields to produce a
960/// seed that is unique per group and deterministic across encode/decode.
961fn 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
972/// Compute RFC 6330 RaptorQ repair symbols for a group of source pages.
973///
974/// Uses `asupersync::raptorq::systematic::SystematicEncoder` to produce
975/// `r_repair` repair symbols with ESIs `[K, K+R)`.
976pub 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
1010/// Read a single page from raw database bytes, zero-padding if file is short.
1011fn 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/// Generate a complete `.db-fec` sidecar from raw database bytes.
1027///
1028/// Returns the sidecar file content as a byte vector. The layout is:
1029/// `[DbFecHeader][Seg_page1][Seg_group0][Seg_group1]...`
1030///
1031/// Each general segment is padded to `full_segment_len` for O(1) random access.
1032#[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    // Pre-compute segment sizes for O(1) layout.
1048    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    // Total sidecar size: header + seg1 + (num_general_groups * full_seg_len).
1052    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    // Write header.
1061    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        // Read source pages.
1067        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        // Compute per-page hashes.
1073        let hashes: Vec<[u8; 16]> = source_slices.iter().map(|p| page_xxh3_128(p)).collect();
1074
1075        // Build group metadata.
1076        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        // Compute repair symbols.
1086        let repair_symbols = compute_raptorq_repair_symbols(&meta, &source_slices, ps)?;
1087
1088        // Write metadata.
1089        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        // Write repair symbols.
1094        for sym in &repair_symbols {
1095            sidecar[cursor..cursor + ps].copy_from_slice(sym);
1096            cursor += ps;
1097        }
1098
1099        // Pad general segments to full_seg_len for O(1) access.
1100        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; // Already zeroed by vec![0u8; total_size].
1104        }
1105    }
1106
1107    let sidecar_len = sidecar.len() as u64;
1108    let page_count_u64 = u64::from(fields.page_count);
1109
1110    // Structured tracing span for snapshot FEC encoding.
1111    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/// Generate a `.db-fec` sidecar for a database file path.
1135#[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/// Generate and write a `.db-fec` sidecar file, returning the sidecar path.
1142#[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/// Read the [`DbFecHeader`] from a `.db-fec` sidecar file.
1160#[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/// Read group metadata and repair symbols for a target page from sidecar bytes.
1178///
1179/// Returns `(group_meta, repair_symbols)` where repair symbols are `(esi, data)` pairs
1180/// compatible with [`attempt_page_repair`].
1181#[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    // Determine which segment to read.
1190    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    // Read group metadata (variable-length due to hash array).
1214    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    // Defend against OOM from malicious r_repair values.
1231    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    // Read repair symbols.
1239    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// ---------------------------------------------------------------------------
1264// Tests
1265// ---------------------------------------------------------------------------
1266
1267#[cfg(test)]
1268mod tests {
1269    use super::*;
1270
1271    // -- DbFecHeader tests --
1272
1273    #[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        // Known inputs.
1285        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        // Changing any field changes the digest.
1290        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        // Mismatched db_gen_digest -> sidecar ignored.
1305        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        // Corrupt checksum.
1314        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    // -- Page group partitioning tests --
1329
1330    #[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        // Page 1 special.
1349        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        // Pages 2-64.
1353        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        // Page 1 + ceil((1000-1)/64) = 1 + 16 = 17 groups.
1383        assert_eq!(groups.len(), 17);
1384        assert_eq!(groups[0].group_size, 1);
1385        // Verify all pages covered.
1386        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        // Page 1 group: G=1, R=4 -> 400% redundancy.
1400        assert_eq!(groups[0].group_size, 1);
1401        assert_eq!(groups[0].repair, 4);
1402    }
1403
1404    // -- Segment offset tests --
1405
1406    #[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        // Sequential layout check.
1413        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    // -- DbFecGroupMeta tests --
1421
1422    #[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        // object_id must be deterministic and content-addressed.
1451        let oid = meta.object_id;
1452        assert_ne!(oid, [0u8; 16], "object_id should be non-zero");
1453
1454        // Changing a hash changes the object_id.
1455        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        // Group meta with mismatched db_gen_digest should be ignored.
1475        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        // Corrupt last byte (checksum).
1485        let last = bytes.len() - 1;
1486        bytes[last] ^= 0xFF;
1487        let result = DbFecGroupMeta::from_bytes(&bytes);
1488        assert!(result.is_err());
1489    }
1490
1491    // -- Read path repair tests --
1492
1493    #[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        // All pages intact — verify_page_xxh3_128 succeeds.
1502        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        // Generate RaptorQ repair symbols.
1516        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        // Corrupt page 3 (pgno=4, index=2 in group).
1521        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        // Pair ESIs with repair data: ESI = K + r_idx.
1533        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        // All pages corrupted — no repair possible.
1556        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        // Insert data, corrupt one page, read back with repair.
1567        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                // Write unique pattern.
1573                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        // Generate RaptorQ repair symbols.
1588        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        // Corrupt page 2 (index 0 in group, pgno=2).
1593        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); // Different change_counter.
1618        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        // Overhead = R/G = 4/64 = 6.25%.
1625        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        // 100 pages: page 1 special, pages 2-65 (64 pages), pages 66-100 (35 pages).
1632        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        // Segment offset formula still applies (last group has smaller K but offset
1638        // is computed from the full-group formula for stable seekability).
1639        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); // Header page.
1653        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    // -- Compliance gates --
1660
1661    #[test]
1662    fn test_bd_1hi_18_unit_compliance_gate() {
1663        // Verify bead identifiers and mandatory test presence.
1664        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        // Property: partition_page_groups covers all pages exactly once.
1676        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            // No overlaps.
1682            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        // End-to-end: create header, create groups, verify sidecar coherence.
1694        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        // Verify round-trip.
1699        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        // Verify groups.
1704        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        // Page 1 special group.
1710        assert_eq!(groups[0].group_size, 1);
1711        assert_eq!(groups[0].repair, HEADER_PAGE_R_REPAIR);
1712
1713        // Verify segment offset monotonicity.
1714        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    // -- Property: db_gen_digest deterministic --
1730
1731    #[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    // -- Property: group_segment_size consistent --
1741
1742    #[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            // seg1 should be smaller (fewer source pages = fewer hashes).
1749            assert!(seg1 < general_seg, "page-1 segment should be smaller");
1750
1751            // Verify formula: meta_size + R * page_size.
1752            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    // -- Sidecar generation utility tests (bd-2r4z) --
1763
1764    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    // -- RaptorQ-specific tests (bd-n0g4q.2) --
1975
1976    #[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        // Verify that RaptorQ can recover from multiple corrupted pages
2008        // (up to R) — something the old XOR parity could not do.
2009        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        // Corrupt pages 2 and 3 (indices 0 and 1 in the group).
2039        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        // Repair page 2.
2051        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        // Repair page 3.
2057        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    // -------------------------------------------------------------------
2077    // Snapshot FEC metrics tests
2078    // -------------------------------------------------------------------
2079
2080    #[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        // Delta-based test safe for parallel execution.
2116        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}