Skip to main content

fsqlite_wal/
checksum.rs

1//! WAL checksum and integrity helpers.
2
3use fsqlite_error::{FrankenError, Result};
4use fsqlite_types::PageSize;
5use serde::Serialize;
6use xxhash_rust::xxh3::xxh3_128;
7
8/// SQLite database header size.
9pub const SQLITE_DB_HEADER_SIZE: usize = 100;
10const SQLITE_DB_HEADER_SIZE_U16: u16 = 100;
11/// Offset in the 100-byte SQLite database header where reserved-bytes lives.
12pub const SQLITE_DB_HEADER_RESERVED_OFFSET: usize = 20;
13/// Bytes reserved at end-of-page for optional XXH3 checksum trailer.
14pub const PAGE_CHECKSUM_RESERVED_BYTES: usize = 16;
15/// SQLite WAL header size.
16pub const WAL_HEADER_SIZE: usize = 32;
17/// SQLite WAL frame header size.
18pub const WAL_FRAME_HEADER_SIZE: usize = 24;
19
20const WAL_HEADER_SALT1_OFFSET: usize = 16;
21const WAL_HEADER_SALT2_OFFSET: usize = 20;
22const WAL_HEADER_CKSUM1_OFFSET: usize = 24;
23const WAL_HEADER_CKSUM2_OFFSET: usize = 28;
24
25const WAL_FRAME_DB_SIZE_OFFSET: usize = 4;
26const WAL_FRAME_SALT1_OFFSET: usize = 8;
27const WAL_FRAME_SALT2_OFFSET: usize = 12;
28const WAL_FRAME_CKSUM1_OFFSET: usize = 16;
29const WAL_FRAME_CKSUM2_OFFSET: usize = 20;
30const SQLITE_DB_HEADER_MAGIC: [u8; 16] = *b"SQLite format 3\0";
31
32/// Hash tiers from the three-tier integrity strategy.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HashTier {
35    Integrity,
36    ContentAddressing,
37    Protocol,
38}
39
40/// SQLite cumulative checksum pair.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub struct SqliteWalChecksum {
43    pub s1: u32,
44    pub s2: u32,
45}
46
47/// Affine transform for SQLite's rolling WAL checksum.
48///
49/// Processing aligned WAL bytes maps an incoming `(s1, s2)` seed to a new pair
50/// via an affine transform over wrapping `u32` arithmetic. That lets callers
51/// precompute frame-local checksum work before they know the authoritative
52/// publish-time seed.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub struct WalChecksumTransform {
55    pub a11: u32,
56    pub a12: u32,
57    pub a21: u32,
58    pub a22: u32,
59    pub c1: u32,
60    pub c2: u32,
61}
62
63impl WalChecksumTransform {
64    /// Identity transform.
65    #[must_use]
66    pub const fn identity() -> Self {
67        Self {
68            a11: 1,
69            a12: 0,
70            a21: 0,
71            a22: 1,
72            c1: 0,
73            c2: 0,
74        }
75    }
76
77    /// Apply this transform to a running checksum seed.
78    #[must_use]
79    pub fn apply(self, seed: SqliteWalChecksum) -> SqliteWalChecksum {
80        SqliteWalChecksum {
81            s1: self
82                .a11
83                .wrapping_mul(seed.s1)
84                .wrapping_add(self.a12.wrapping_mul(seed.s2))
85                .wrapping_add(self.c1),
86            s2: self
87                .a21
88                .wrapping_mul(seed.s1)
89                .wrapping_add(self.a22.wrapping_mul(seed.s2))
90                .wrapping_add(self.c2),
91        }
92    }
93
94    /// Compose `next` after `self`.
95    #[must_use]
96    pub fn then(self, next: Self) -> Self {
97        Self {
98            a11: next
99                .a11
100                .wrapping_mul(self.a11)
101                .wrapping_add(next.a12.wrapping_mul(self.a21)),
102            a12: next
103                .a11
104                .wrapping_mul(self.a12)
105                .wrapping_add(next.a12.wrapping_mul(self.a22)),
106            a21: next
107                .a21
108                .wrapping_mul(self.a11)
109                .wrapping_add(next.a22.wrapping_mul(self.a21)),
110            a22: next
111                .a21
112                .wrapping_mul(self.a12)
113                .wrapping_add(next.a22.wrapping_mul(self.a22)),
114            c1: next
115                .a11
116                .wrapping_mul(self.c1)
117                .wrapping_add(next.a12.wrapping_mul(self.c2))
118                .wrapping_add(next.c1),
119            c2: next
120                .a21
121                .wrapping_mul(self.c1)
122                .wrapping_add(next.a22.wrapping_mul(self.c2))
123                .wrapping_add(next.c2),
124        }
125    }
126
127    #[must_use]
128    fn linear_coefficients_for_chunk_count(chunk_count: usize) -> (u32, u32, u32, u32) {
129        fn multiply(
130            left: (u32, u32, u32, u32),
131            right: (u32, u32, u32, u32),
132        ) -> (u32, u32, u32, u32) {
133            let (l11, l12, l21, l22) = left;
134            let (r11, r12, r21, r22) = right;
135            (
136                l11.wrapping_mul(r11).wrapping_add(l12.wrapping_mul(r21)),
137                l11.wrapping_mul(r12).wrapping_add(l12.wrapping_mul(r22)),
138                l21.wrapping_mul(r11).wrapping_add(l22.wrapping_mul(r21)),
139                l21.wrapping_mul(r12).wrapping_add(l22.wrapping_mul(r22)),
140            )
141        }
142
143        let mut result = (1, 0, 0, 1);
144        let mut base = (1, 1, 1, 2);
145        let mut exp = chunk_count;
146        while exp != 0 {
147            if exp & 1 == 1 {
148                result = multiply(result, base);
149            }
150            exp >>= 1;
151            if exp != 0 {
152                base = multiply(base, base);
153            }
154        }
155        result
156    }
157
158    /// Build the transform for aligned WAL checksum bytes.
159    pub fn from_aligned_bytes(data: &[u8], big_endian_checksum_words: bool) -> Result<Self> {
160        if !data.len().is_multiple_of(8) {
161            return Err(FrankenError::WalCorrupt {
162                detail: format!(
163                    "WAL checksum transform input must be 8-byte aligned, got {} bytes",
164                    data.len()
165                ),
166            });
167        }
168
169        let (a11, a12, a21, a22) = Self::linear_coefficients_for_chunk_count(data.len() / 8);
170        let mut c1 = 0_u32;
171        let mut c2 = 0_u32;
172        let (chunks, _rest) = data.as_chunks::<8>();
173        for chunk in chunks {
174            let x0 = decode_u32_words(&chunk[..4], big_endian_checksum_words);
175            let x1 = decode_u32_words(&chunk[4..], big_endian_checksum_words);
176            c1 = c1.wrapping_add(x0).wrapping_add(c2);
177            c2 = c2.wrapping_add(x1).wrapping_add(c1);
178        }
179
180        Ok(Self {
181            a11,
182            a12,
183            a21,
184            a22,
185            c1,
186            c2,
187        })
188    }
189
190    /// Build the transform for one WAL frame.
191    pub fn for_wal_frame(
192        frame: &[u8],
193        page_size: usize,
194        big_endian_checksum_words: bool,
195    ) -> Result<Self> {
196        ensure_frame_len(frame, page_size)?;
197        let header = Self::from_aligned_bytes(&frame[..8], big_endian_checksum_words)?;
198        let payload = Self::from_aligned_bytes(
199            &frame[WAL_FRAME_HEADER_SIZE..WAL_FRAME_HEADER_SIZE + page_size],
200            big_endian_checksum_words,
201        )?;
202        Ok(header.then(payload))
203    }
204}
205
206/// WAL salts copied into frame headers.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
208pub struct WalSalts {
209    pub salt1: u32,
210    pub salt2: u32,
211}
212
213impl WalSalts {
214    /// Fresh random salts for a brand-new WAL generation.
215    ///
216    /// Matching C SQLite (`walIndexRecover`/`walRestartHdr`), a new WAL must
217    /// carry unpredictable salts: frames from a stale or copied WAL of a
218    /// previous generation must fail salt validation instead of replaying
219    /// into the wrong database state. Deterministic salts (the old
220    /// `WalSalts::default()` = (0, 0) behavior, GH #201) let byte-identical
221    /// stale WALs validate across generations.
222    #[cfg(not(target_arch = "wasm32"))]
223    #[must_use]
224    pub fn generate() -> Self {
225        Self {
226            salt1: rand::random::<u32>(),
227            salt2: rand::random::<u32>(),
228        }
229    }
230
231    /// Wasm fallback: no durable WAL files exist on this target, so a
232    /// process-unique counter suffices to keep generations distinct.
233    #[cfg(target_arch = "wasm32")]
234    #[must_use]
235    pub fn generate() -> Self {
236        use core::sync::atomic::{AtomicU32, Ordering};
237        static NEXT: AtomicU32 = AtomicU32::new(1);
238        let n = NEXT.fetch_add(1, Ordering::Relaxed);
239        Self {
240            salt1: n,
241            salt2: n.wrapping_mul(0x9E37_79B9),
242        }
243    }
244
245    /// Successor salts for a checkpoint RESTART/TRUNCATE of an existing WAL.
246    ///
247    /// C SQLite's `walRestartHdr` increments salt-1 (readers can detect the
248    /// generation change cheaply) and randomizes salt-2 (stale frames cannot
249    /// validate against the reset WAL).
250    #[must_use]
251    pub fn next_generation(self) -> Self {
252        Self {
253            salt1: self.salt1.wrapping_add(1),
254            salt2: Self::generate().salt2,
255        }
256    }
257}
258
259/// WAL magic number for little-endian checksum mode.
260pub const WAL_MAGIC_LE: u32 = 0x377F_0682;
261
262/// WAL magic number for big-endian checksum mode.
263pub const WAL_MAGIC_BE: u32 = 0x377F_0683;
264
265/// WAL format version constant (SQLite 3.7.0+).
266pub const WAL_FORMAT_VERSION: u32 = 3_007_000;
267
268/// Parsed 32-byte WAL header.
269///
270/// Layout:
271/// ```text
272/// Offset  Size  Description
273///   0       4   Magic: 0x377F0682 (LE checksum) or 0x377F0683 (BE checksum)
274///   4       4   Format version: 3007000
275///   8       4   Page size in bytes
276///  12       4   Checkpoint sequence number
277///  16       4   Salt-1
278///  20       4   Salt-2
279///  24       4   Checksum-1 (of bytes 0..24)
280///  28       4   Checksum-2 (of bytes 0..24)
281/// ```
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub struct WalHeader {
284    /// Magic number: `WAL_MAGIC_LE` or `WAL_MAGIC_BE`.
285    pub magic: u32,
286    /// Format version (must be `WAL_FORMAT_VERSION`).
287    pub format_version: u32,
288    /// Database page size in bytes.
289    pub page_size: u32,
290    /// Checkpoint sequence number.
291    pub checkpoint_seq: u32,
292    /// Salt pair for frame validation.
293    pub salts: WalSalts,
294    /// Header checksum (covers bytes 0..24).
295    pub checksum: SqliteWalChecksum,
296}
297
298impl WalHeader {
299    /// Whether the magic indicates big-endian checksum words.
300    #[must_use]
301    pub const fn big_endian_checksum(&self) -> bool {
302        self.magic == WAL_MAGIC_BE
303    }
304
305    /// Parse a 32-byte WAL header from raw bytes.
306    pub fn from_bytes(buf: &[u8]) -> Result<Self> {
307        if buf.len() < WAL_HEADER_SIZE {
308            return Err(FrankenError::WalCorrupt {
309                detail: format!(
310                    "WAL header too small: expected >= {WAL_HEADER_SIZE}, got {}",
311                    buf.len()
312                ),
313            });
314        }
315        let magic = read_be_u32_at(buf, 0);
316        if magic != WAL_MAGIC_LE && magic != WAL_MAGIC_BE {
317            return Err(FrankenError::WalCorrupt {
318                detail: format!("invalid WAL magic: {magic:#010x}"),
319            });
320        }
321        let format_version = read_be_u32_at(buf, 4);
322        if format_version != WAL_FORMAT_VERSION {
323            return Err(FrankenError::WalCorrupt {
324                detail: format!(
325                    "unsupported WAL format version: {format_version} (expected {WAL_FORMAT_VERSION})"
326                ),
327            });
328        }
329        let page_size = read_be_u32_at(buf, 8);
330        ensure_valid_wal_header_page_size(page_size)?;
331
332        Ok(Self {
333            magic,
334            format_version,
335            page_size,
336            checkpoint_seq: read_be_u32_at(buf, 12),
337            salts: WalSalts {
338                salt1: read_be_u32_at(buf, WAL_HEADER_SALT1_OFFSET),
339                salt2: read_be_u32_at(buf, WAL_HEADER_SALT2_OFFSET),
340            },
341            checksum: SqliteWalChecksum {
342                s1: read_be_u32_at(buf, WAL_HEADER_CKSUM1_OFFSET),
343                s2: read_be_u32_at(buf, WAL_HEADER_CKSUM2_OFFSET),
344            },
345        })
346    }
347
348    /// Serialize this header into a 32-byte buffer and compute the checksum.
349    pub fn to_bytes(&self) -> Result<[u8; WAL_HEADER_SIZE]> {
350        ensure_valid_wal_header_page_size(self.page_size)?;
351
352        let mut buf = [0u8; WAL_HEADER_SIZE];
353        write_be_u32_at(&mut buf, 0, self.magic);
354        write_be_u32_at(&mut buf, 4, self.format_version);
355        write_be_u32_at(&mut buf, 8, self.page_size);
356        write_be_u32_at(&mut buf, 12, self.checkpoint_seq);
357        write_be_u32_at(&mut buf, WAL_HEADER_SALT1_OFFSET, self.salts.salt1);
358        write_be_u32_at(&mut buf, WAL_HEADER_SALT2_OFFSET, self.salts.salt2);
359        // Compute and write checksum over bytes 0..24.
360        let checksum = sqlite_wal_checksum(
361            &buf[..WAL_HEADER_CKSUM1_OFFSET],
362            0,
363            0,
364            self.big_endian_checksum(),
365        )?;
366        write_be_u32_at(&mut buf, WAL_HEADER_CKSUM1_OFFSET, checksum.s1);
367        write_be_u32_at(&mut buf, WAL_HEADER_CKSUM2_OFFSET, checksum.s2);
368        Ok(buf)
369    }
370}
371
372/// Parsed 24-byte WAL frame header.
373///
374/// Layout:
375/// ```text
376/// Offset  Size  Description
377///   0       4   Page number
378///   4       4   For commit frames: db size in pages. Otherwise 0.
379///   8       4   Salt-1 (must match WAL header)
380///  12       4   Salt-2 (must match WAL header)
381///  16       4   Cumulative checksum-1
382///  20       4   Cumulative checksum-2
383/// ```
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub struct WalFrameHeader {
386    /// Page number this frame writes to.
387    pub page_number: u32,
388    /// For commit frames: database size in pages after this commit. Otherwise 0.
389    pub db_size: u32,
390    /// Salt pair (must match WAL header salts).
391    pub salts: WalSalts,
392    /// Cumulative checksum (covers this frame and all prior frames).
393    pub checksum: SqliteWalChecksum,
394}
395
396impl WalFrameHeader {
397    /// Whether this frame is a commit frame (non-zero `db_size`).
398    #[must_use]
399    pub const fn is_commit(&self) -> bool {
400        self.db_size > 0
401    }
402
403    /// Parse a 24-byte WAL frame header from raw bytes.
404    pub fn from_bytes(buf: &[u8]) -> Result<Self> {
405        if buf.len() < WAL_FRAME_HEADER_SIZE {
406            return Err(FrankenError::WalCorrupt {
407                detail: format!(
408                    "WAL frame header too small: expected >= {WAL_FRAME_HEADER_SIZE}, got {}",
409                    buf.len()
410                ),
411            });
412        }
413        Ok(Self {
414            page_number: read_be_u32_at(buf, 0),
415            db_size: read_be_u32_at(buf, WAL_FRAME_DB_SIZE_OFFSET),
416            salts: WalSalts {
417                salt1: read_be_u32_at(buf, WAL_FRAME_SALT1_OFFSET),
418                salt2: read_be_u32_at(buf, WAL_FRAME_SALT2_OFFSET),
419            },
420            checksum: SqliteWalChecksum {
421                s1: read_be_u32_at(buf, WAL_FRAME_CKSUM1_OFFSET),
422                s2: read_be_u32_at(buf, WAL_FRAME_CKSUM2_OFFSET),
423            },
424        })
425    }
426
427    /// Serialize this frame header into a 24-byte buffer.
428    ///
429    /// Note: The checksum field is written as-is. To compute the correct
430    /// checksum, use `compute_wal_frame_checksum` on the complete frame.
431    pub fn to_bytes(&self) -> [u8; WAL_FRAME_HEADER_SIZE] {
432        let mut buf = [0u8; WAL_FRAME_HEADER_SIZE];
433        write_be_u32_at(&mut buf, 0, self.page_number);
434        write_be_u32_at(&mut buf, WAL_FRAME_DB_SIZE_OFFSET, self.db_size);
435        write_be_u32_at(&mut buf, WAL_FRAME_SALT1_OFFSET, self.salts.salt1);
436        write_be_u32_at(&mut buf, WAL_FRAME_SALT2_OFFSET, self.salts.salt2);
437        write_be_u32_at(&mut buf, WAL_FRAME_CKSUM1_OFFSET, self.checksum.s1);
438        write_be_u32_at(&mut buf, WAL_FRAME_CKSUM2_OFFSET, self.checksum.s2);
439        buf
440    }
441}
442
443/// First failure reason encountered while validating a WAL chain.
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
445pub enum WalChainInvalidReason {
446    HeaderChecksumMismatch,
447    TruncatedFrame,
448    SaltMismatch,
449    FrameSaltMismatch,
450    FrameChecksumMismatch,
451}
452
453/// Summary of WAL chain validation and replay boundary analysis.
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455pub struct WalChainValidation {
456    pub valid: bool,
457    pub valid_frames: usize,
458    pub replayable_frames: usize,
459    pub first_invalid_frame: Option<usize>,
460    pub reason: Option<WalChainInvalidReason>,
461    pub last_commit_frame: Option<usize>,
462
463    // Compatibility aliases for alternate test/layout variants.
464    pub header_valid: bool,
465    pub valid_frame_count: usize,
466    pub replayable_frame_count: usize,
467    pub first_invalid_reason: Option<WalChainInvalidReason>,
468    pub replayable_prefix_len: usize,
469}
470
471impl WalChainValidation {
472    fn from_core(
473        valid: bool,
474        valid_frames: usize,
475        replayable_frames: usize,
476        first_invalid_frame: Option<usize>,
477        reason: Option<WalChainInvalidReason>,
478        last_commit_frame: Option<usize>,
479        frame_size: usize,
480    ) -> Self {
481        Self {
482            valid,
483            valid_frames,
484            replayable_frames,
485            first_invalid_frame,
486            reason,
487            last_commit_frame,
488            header_valid: valid || reason != Some(WalChainInvalidReason::HeaderChecksumMismatch),
489            valid_frame_count: valid_frames,
490            replayable_frame_count: replayable_frames,
491            first_invalid_reason: reason,
492            replayable_prefix_len: WAL_HEADER_SIZE + replayable_frames * frame_size,
493        }
494    }
495}
496
497/// Five integrity-check levels aligned with SQLite-style deep validation stages.
498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499pub enum IntegrityCheckLevel {
500    Page,
501    BtreeStructural,
502    RecordFormat,
503    CrossReference,
504    Schema,
505}
506
507/// One integrity-check finding.
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct IntegrityCheckIssue {
510    pub level: IntegrityCheckLevel,
511    pub page_number: Option<u32>,
512    pub detail: String,
513}
514
515/// Result bundle for integrity-check execution.
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct IntegrityCheckReport {
518    pub pages_checked: usize,
519    pub issues: Vec<IntegrityCheckIssue>,
520}
521
522impl IntegrityCheckReport {
523    /// Build an empty report with a known page-count.
524    #[must_use]
525    pub fn ok(pages_checked: usize) -> Self {
526        Self {
527            pages_checked,
528            issues: Vec::new(),
529        }
530    }
531
532    /// True when no integrity issues were found.
533    #[must_use]
534    pub fn is_ok(&self) -> bool {
535        self.issues.is_empty()
536    }
537
538    /// SQLite-compatible string payload: either `ok` or a list of error lines.
539    #[must_use]
540    pub fn sqlite_messages(&self) -> Vec<String> {
541        if self.is_ok() {
542            vec!["ok".to_owned()]
543        } else {
544            self.issues
545                .iter()
546                .map(|issue| issue.detail.clone())
547                .collect()
548        }
549    }
550
551    fn push(
552        &mut self,
553        level: IntegrityCheckLevel,
554        page_number: Option<u32>,
555        detail: impl Into<String>,
556    ) {
557        self.issues.push(IntegrityCheckIssue {
558            level,
559            page_number,
560            detail: detail.into(),
561        });
562    }
563}
564
565/// Known SQLite b-tree page type flags.
566pub const BTREE_PAGE_TYPE_FLAGS: [u8; 4] = [0x02, 0x05, 0x0A, 0x0D];
567
568/// Crash-model torn-write sector sizes required by the spec.
569pub const CRASH_MODEL_SECTOR_SIZES: [usize; 3] = [512, 1024, 4096];
570
571/// Checksum families used for recovery routing.
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
573pub enum ChecksumFailureKind {
574    WalFrameChecksumMismatch,
575    Xxh3PageChecksumMismatch,
576    Crc32cSymbolMismatch,
577    DbFileCorruption,
578}
579
580/// Recovery policy selected for a checksum failure.
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
582pub enum RecoveryAction {
583    AttemptWalFecRepair,
584    TruncateWalAtFirstInvalidFrame,
585    EvictCacheAndRetryFromWal,
586    ExcludeCorruptedSymbolAndContinue,
587    ReportPersistentCorruption,
588}
589
590/// Result of an attempted WAL-FEC repair.
591#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
592pub enum WalFecRepairOutcome {
593    Repaired,
594    InsufficientSymbols,
595    SourceHashMismatch,
596}
597
598/// Final recovery decision for a WAL frame checksum mismatch.
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub enum WalRecoveryDecision {
601    Repaired,
602    Truncated,
603}
604
605/// Crash-model assertions used by durability/recovery code paths.
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
607pub struct CrashModelContract {
608    flags: u8,
609}
610
611impl CrashModelContract {
612    pub const CRASH_AT_ANY_POINT: u8 = 1 << 0;
613    pub const FSYNC_IS_DURABILITY_BARRIER: u8 = 1 << 1;
614    pub const WRITES_REORDER_WITHOUT_FSYNC: u8 = 1 << 2;
615    pub const BITROT_EXISTS: u8 = 1 << 3;
616    pub const METADATA_MAY_REQUIRE_DIRECTORY_FSYNC: u8 = 1 << 4;
617
618    #[must_use]
619    pub fn crash_at_any_point(self) -> bool {
620        self.flags & Self::CRASH_AT_ANY_POINT != 0
621    }
622
623    #[must_use]
624    pub fn fsync_is_durability_barrier(self) -> bool {
625        self.flags & Self::FSYNC_IS_DURABILITY_BARRIER != 0
626    }
627
628    #[must_use]
629    pub fn writes_reorder_without_fsync(self) -> bool {
630        self.flags & Self::WRITES_REORDER_WITHOUT_FSYNC != 0
631    }
632
633    #[must_use]
634    pub fn bitrot_exists(self) -> bool {
635        self.flags & Self::BITROT_EXISTS != 0
636    }
637
638    #[must_use]
639    pub fn metadata_may_require_directory_fsync(self) -> bool {
640        self.flags & Self::METADATA_MAY_REQUIRE_DIRECTORY_FSYNC != 0
641    }
642}
643
644impl Default for CrashModelContract {
645    fn default() -> Self {
646        Self {
647            flags: Self::CRASH_AT_ANY_POINT
648                | Self::FSYNC_IS_DURABILITY_BARRIER
649                | Self::WRITES_REORDER_WITHOUT_FSYNC
650                | Self::BITROT_EXISTS
651                | Self::METADATA_MAY_REQUIRE_DIRECTORY_FSYNC,
652        }
653    }
654}
655
656/// Return the current crash-model contract.
657#[must_use]
658pub fn crash_model_contract() -> CrashModelContract {
659    CrashModelContract::default()
660}
661
662/// True when a sector size is explicitly covered by torn-write simulations.
663#[must_use]
664pub fn supports_torn_write_sector_size(bytes_per_sector: usize) -> bool {
665    CRASH_MODEL_SECTOR_SIZES.contains(&bytes_per_sector)
666}
667
668/// True when the byte is a valid SQLite b-tree page type.
669#[must_use]
670pub fn is_valid_btree_page_type(page_type: u8) -> bool {
671    BTREE_PAGE_TYPE_FLAGS.contains(&page_type)
672}
673
674/// Integrity-check level 1: page-level validation of type/header/checksum.
675pub fn integrity_check_level1_page(
676    page: &[u8],
677    page_number: u32,
678    is_btree_page: bool,
679    verify_xxh3_trailer: bool,
680) -> Result<IntegrityCheckReport> {
681    let mut report = IntegrityCheckReport::ok(1);
682
683    if page.is_empty() {
684        report.push(
685            IntegrityCheckLevel::Page,
686            Some(page_number),
687            format!("page {page_number}: empty page buffer"),
688        );
689        return Ok(report);
690    }
691
692    if is_btree_page {
693        let page_type = page[0];
694        if !is_valid_btree_page_type(page_type) {
695            report.push(
696                IntegrityCheckLevel::Page,
697                Some(page_number),
698                format!("page {page_number}: invalid b-tree page type 0x{page_type:02x}"),
699            );
700            return Ok(report);
701        }
702
703        let header_size = if page_type == 0x02 || page_type == 0x05 {
704            12
705        } else {
706            8
707        };
708
709        if page.len() < header_size {
710            report.push(
711                IntegrityCheckLevel::Page,
712                Some(page_number),
713                format!(
714                    "page {page_number}: b-tree header too small (need {header_size}, got {})",
715                    page.len()
716                ),
717            );
718            return Ok(report);
719        }
720
721        let first_freeblock = u16::from_be_bytes([page[1], page[2]]);
722        if first_freeblock != 0 && usize::from(first_freeblock) >= page.len() {
723            report.push(
724                IntegrityCheckLevel::Page,
725                Some(page_number),
726                format!(
727                    "page {page_number}: first freeblock offset out of range ({first_freeblock})"
728                ),
729            );
730        }
731
732        let cell_count = u16::from_be_bytes([page[3], page[4]]);
733        let raw_cell_content_offset = u16::from_be_bytes([page[5], page[6]]);
734        let cell_content_offset = if raw_cell_content_offset == 0
735            && (page.len() == 65_536 || (page_number == 1 && page.len() == 65_536 - 100))
736        {
737            page.len()
738        } else {
739            usize::from(raw_cell_content_offset)
740        };
741
742        if cell_content_offset == 0 || cell_content_offset > page.len() {
743            report.push(
744                IntegrityCheckLevel::Page,
745                Some(page_number),
746                format!(
747                    "page {page_number}: cell content offset out of range ({cell_content_offset})"
748                ),
749            );
750        }
751
752        let pointer_bytes = usize::from(cell_count) * 2;
753        if header_size + pointer_bytes > page.len() {
754            report.push(
755                IntegrityCheckLevel::Page,
756                Some(page_number),
757                format!(
758                    "page {page_number}: cell pointer array exceeds page bounds (cells={cell_count})"
759                ),
760            );
761        } else if header_size + pointer_bytes > cell_content_offset {
762            report.push(
763                IntegrityCheckLevel::Page,
764                Some(page_number),
765                format!(
766                    "page {page_number}: cell pointer array overlaps cell content area (cells={cell_count}, offset={cell_content_offset})"
767                ),
768            );
769        }
770
771        let fragmented = page[7];
772        if fragmented > 60 {
773            report.push(
774                IntegrityCheckLevel::Page,
775                Some(page_number),
776                format!("page {page_number}: fragmented free bytes out of range ({fragmented})"),
777            );
778        }
779    }
780
781    if verify_xxh3_trailer {
782        match verify_page_checksum(page) {
783            Ok(true) => {}
784            Ok(false) => {
785                report.push(
786                    IntegrityCheckLevel::Page,
787                    Some(page_number),
788                    format!("page {page_number}: xxh3 page checksum mismatch"),
789                );
790            }
791            Err(err) => {
792                report.push(
793                    IntegrityCheckLevel::Page,
794                    Some(page_number),
795                    format!("page {page_number}: xxh3 verification error: {err}"),
796                );
797            }
798        }
799    }
800
801    Ok(report)
802}
803
804/// Validate the 100-byte SQLite database header.
805#[must_use]
806pub fn integrity_check_database_header(db_bytes: &[u8]) -> IntegrityCheckReport {
807    let mut report = IntegrityCheckReport::ok(1);
808    if db_bytes.len() < SQLITE_DB_HEADER_SIZE {
809        report.push(
810            IntegrityCheckLevel::Page,
811            Some(1),
812            format!(
813                "database header too small: expected >= {SQLITE_DB_HEADER_SIZE}, got {}",
814                db_bytes.len()
815            ),
816        );
817        return report;
818    }
819
820    if db_bytes[..SQLITE_DB_HEADER_MAGIC.len()] != SQLITE_DB_HEADER_MAGIC {
821        report.push(
822            IntegrityCheckLevel::Page,
823            Some(1),
824            "database header magic mismatch".to_owned(),
825        );
826    }
827
828    let page_size_raw = u16::from_be_bytes([db_bytes[16], db_bytes[17]]);
829    let page_size = if page_size_raw == 1 {
830        65_536
831    } else {
832        usize::from(page_size_raw)
833    };
834    if !(512..=65_536).contains(&page_size) || !page_size.is_power_of_two() {
835        report.push(
836            IntegrityCheckLevel::Page,
837            Some(1),
838            format!("database header page size out of range ({page_size})"),
839        );
840    }
841
842    report
843}
844
845/// Level-1 integrity check entrypoint for raw SQLite database bytes.
846pub fn integrity_check_sqlite_file_level1(db_bytes: &[u8]) -> Result<IntegrityCheckReport> {
847    let header_report = integrity_check_database_header(db_bytes);
848
849    let page_report = if db_bytes.len() >= SQLITE_DB_HEADER_SIZE + 8 {
850        let page_size = match sqlite_page_size_from_header(db_bytes) {
851            Some(ps) => ps,
852            None => {
853                // Cannot determine page size from a corrupted header.
854                // Silently assuming 4096 would produce false-negative
855                // corruption detection for databases with other page sizes.
856                // Report the issue and skip page-level checks.
857                let mut report = IntegrityCheckReport::ok(1);
858                report.push(
859                    IntegrityCheckLevel::Page,
860                    None,
861                    "cannot determine page size from database header; \
862                     header may be corrupted — skipping page checks"
863                        .to_owned(),
864                );
865                for issue in header_report.issues {
866                    report.issues.push(issue);
867                }
868                return Ok(report);
869            }
870        };
871        let first_page_end = page_size.min(db_bytes.len());
872        if first_page_end > SQLITE_DB_HEADER_SIZE {
873            let mut first_page = db_bytes[SQLITE_DB_HEADER_SIZE..first_page_end].to_vec();
874            normalize_first_page_header_offsets(&mut first_page);
875            integrity_check_level1_page(&first_page, 1, true, false)?
876        } else {
877            let mut report = IntegrityCheckReport::ok(1);
878            report.push(
879                IntegrityCheckLevel::Page,
880                Some(1),
881                "database first page payload missing".to_owned(),
882            );
883            report
884        }
885    } else {
886        let mut report = IntegrityCheckReport::ok(1);
887        report.push(
888            IntegrityCheckLevel::Page,
889            Some(1),
890            "database missing first b-tree page header bytes".to_owned(),
891        );
892        report
893    };
894
895    Ok(merge_integrity_reports(&[header_report, page_report]))
896}
897
898/// Integrity-check level 2: b-tree structural validation for cell bounds/overlap/key order.
899#[must_use]
900pub fn integrity_check_level2_btree(
901    page_number: u32,
902    page_size: usize,
903    cell_spans: &[(u16, u32)],
904    keys: &[i64],
905) -> IntegrityCheckReport {
906    let mut report = IntegrityCheckReport::ok(1);
907
908    if page_size == 0 {
909        report.push(
910            IntegrityCheckLevel::BtreeStructural,
911            Some(page_number),
912            format!("page {page_number}: invalid page size 0 for structural check"),
913        );
914        return report;
915    }
916
917    let mut sorted_spans = cell_spans.to_vec();
918    sorted_spans.sort_unstable_by_key(|&(start, _)| start);
919
920    for (start, end) in &sorted_spans {
921        let start_usize = *start as usize;
922        let end_usize = *end as usize;
923        if start_usize >= end_usize || end_usize > page_size {
924            report.push(
925                IntegrityCheckLevel::BtreeStructural,
926                Some(page_number),
927                format!("page {page_number}: cell span out of bounds ({start}..{end})"),
928            );
929        }
930    }
931
932    for window in sorted_spans.windows(2) {
933        let (_, prev_end) = window[0];
934        let (next_start, _) = window[1];
935        if prev_end > u32::from(next_start) {
936            report.push(
937                IntegrityCheckLevel::BtreeStructural,
938                Some(page_number),
939                format!(
940                    "page {page_number}: overlapping cell spans ({}) and ({})",
941                    format_args!("{}..{}", window[0].0, window[0].1),
942                    format_args!("{}..{}", window[1].0, window[1].1)
943                ),
944            );
945            break;
946        }
947    }
948
949    if keys.windows(2).any(|window| window[0] > window[1]) {
950        report.push(
951            IntegrityCheckLevel::BtreeStructural,
952            Some(page_number),
953            format!("page {page_number}: keys out of order"),
954        );
955    }
956
957    report
958}
959
960/// Integrity-check level 3: overflow-chain shape and reference validity.
961#[must_use]
962pub fn integrity_check_level3_overflow_chain(
963    page_number: u32,
964    overflow_chain: &[u32],
965    max_page_number: u32,
966) -> IntegrityCheckReport {
967    let mut report = IntegrityCheckReport::ok(1);
968    let mut seen = std::collections::HashSet::new();
969
970    for overflow_page in overflow_chain {
971        if *overflow_page == 0 || *overflow_page > max_page_number {
972            report.push(
973                IntegrityCheckLevel::RecordFormat,
974                Some(page_number),
975                format!(
976                    "page {page_number}: broken overflow chain references page {overflow_page}"
977                ),
978            );
979            break;
980        }
981        if !seen.insert(*overflow_page) {
982            report.push(
983                IntegrityCheckLevel::RecordFormat,
984                Some(page_number),
985                format!("page {page_number}: broken overflow chain cycle at page {overflow_page}"),
986            );
987            break;
988        }
989    }
990
991    report
992}
993
994/// Integrity-check level 4: global page-accounting cross-reference checks.
995#[must_use]
996pub fn integrity_check_level4_cross_reference(
997    expected_total_pages: u32,
998    accounted_pages: &[u32],
999) -> IntegrityCheckReport {
1000    let pages_checked = usize::try_from(expected_total_pages).unwrap_or(usize::MAX);
1001    let mut report = IntegrityCheckReport::ok(pages_checked);
1002    let mut seen = std::collections::HashSet::new();
1003
1004    for page in accounted_pages {
1005        if *page == 0 || *page > expected_total_pages {
1006            report.push(
1007                IntegrityCheckLevel::CrossReference,
1008                Some(*page),
1009                format!("page {page}: cross-reference contains out-of-range page reference"),
1010            );
1011            continue;
1012        }
1013        if !seen.insert(*page) {
1014            report.push(
1015                IntegrityCheckLevel::CrossReference,
1016                Some(*page),
1017                format!("page {page}: appears in multiple b-tree ownership sets"),
1018            );
1019        }
1020    }
1021
1022    for expected_page in 1..=expected_total_pages {
1023        if !seen.contains(&expected_page) {
1024            report.push(
1025                IntegrityCheckLevel::CrossReference,
1026                Some(expected_page),
1027                format!(
1028                    "page {expected_page}: not accounted for by any b-tree/freelist/pointer-map"
1029                ),
1030            );
1031        }
1032    }
1033
1034    report
1035}
1036
1037/// Integrity-check level 5: sqlite_master/schema parseability checks.
1038#[must_use]
1039pub fn integrity_check_level5_schema(schema_entries: &[String]) -> IntegrityCheckReport {
1040    let mut report = IntegrityCheckReport::ok(schema_entries.len());
1041
1042    if schema_entries.is_empty() {
1043        report.push(
1044            IntegrityCheckLevel::Schema,
1045            None,
1046            "malformed sqlite_master: no entries".to_owned(),
1047        );
1048        return report;
1049    }
1050
1051    for (index, entry) in schema_entries.iter().enumerate() {
1052        if !is_valid_schema_sql(entry) {
1053            report.push(
1054                IntegrityCheckLevel::Schema,
1055                None,
1056                format!("sqlite_master row {index}: malformed SQL entry"),
1057            );
1058        }
1059    }
1060
1061    report
1062}
1063
1064/// Merge several level-specific integrity reports into one SQLite-style output bundle.
1065#[must_use]
1066pub fn merge_integrity_reports(reports: &[IntegrityCheckReport]) -> IntegrityCheckReport {
1067    let pages_checked = reports.iter().map(|report| report.pages_checked).sum();
1068    let mut merged = IntegrityCheckReport::ok(pages_checked);
1069    for report in reports {
1070        merged.issues.extend(report.issues.clone());
1071    }
1072    merged
1073}
1074
1075/// Recovery routing based on checksum family and available decode budget.
1076#[must_use]
1077pub fn recovery_action_for_checksum_failure(
1078    failure: ChecksumFailureKind,
1079    surviving_symbols: Option<usize>,
1080    required_symbols: Option<usize>,
1081) -> RecoveryAction {
1082    match failure {
1083        ChecksumFailureKind::WalFrameChecksumMismatch => {
1084            if let (Some(surviving), Some(required)) = (surviving_symbols, required_symbols) {
1085                if surviving >= required {
1086                    RecoveryAction::AttemptWalFecRepair
1087                } else {
1088                    RecoveryAction::TruncateWalAtFirstInvalidFrame
1089                }
1090            } else {
1091                RecoveryAction::TruncateWalAtFirstInvalidFrame
1092            }
1093        }
1094        ChecksumFailureKind::Xxh3PageChecksumMismatch => RecoveryAction::EvictCacheAndRetryFromWal,
1095        ChecksumFailureKind::Crc32cSymbolMismatch => {
1096            RecoveryAction::ExcludeCorruptedSymbolAndContinue
1097        }
1098        ChecksumFailureKind::DbFileCorruption => RecoveryAction::ReportPersistentCorruption,
1099    }
1100}
1101
1102/// Attempt WAL-FEC repair using an independently validated source hash.
1103#[must_use]
1104pub fn attempt_wal_fec_repair(
1105    reconstructed_payload: &[u8],
1106    expected_source_hash: Xxh3Checksum128,
1107    surviving_symbols: usize,
1108    required_symbols: usize,
1109) -> WalFecRepairOutcome {
1110    if surviving_symbols < required_symbols {
1111        return WalFecRepairOutcome::InsufficientSymbols;
1112    }
1113    if verify_wal_fec_source_hash(reconstructed_payload, expected_source_hash) {
1114        WalFecRepairOutcome::Repaired
1115    } else {
1116        WalFecRepairOutcome::SourceHashMismatch
1117    }
1118}
1119
1120/// Concrete recovery path for WAL frame checksum mismatches.
1121#[must_use]
1122pub fn recover_wal_frame_checksum_mismatch(
1123    reconstructed_payload: Option<&[u8]>,
1124    expected_source_hash: Option<Xxh3Checksum128>,
1125    surviving_symbols: usize,
1126    required_symbols: usize,
1127) -> WalRecoveryDecision {
1128    let action = recovery_action_for_checksum_failure(
1129        ChecksumFailureKind::WalFrameChecksumMismatch,
1130        Some(surviving_symbols),
1131        Some(required_symbols),
1132    );
1133
1134    if action != RecoveryAction::AttemptWalFecRepair {
1135        return WalRecoveryDecision::Truncated;
1136    }
1137
1138    let (Some(payload), Some(expected_hash)) = (reconstructed_payload, expected_source_hash) else {
1139        return WalRecoveryDecision::Truncated;
1140    };
1141
1142    match attempt_wal_fec_repair(payload, expected_hash, surviving_symbols, required_symbols) {
1143        WalFecRepairOutcome::Repaired => WalRecoveryDecision::Repaired,
1144        WalFecRepairOutcome::InsufficientSymbols | WalFecRepairOutcome::SourceHashMismatch => {
1145            WalRecoveryDecision::Truncated
1146        }
1147    }
1148}
1149
1150/// Check whether a WAL stream indicates a torn-write event.
1151pub fn detect_torn_write_in_wal(
1152    wal_bytes: &[u8],
1153    page_size: usize,
1154    big_endian_checksum_words: bool,
1155) -> Result<bool> {
1156    let validation = validate_wal_chain(wal_bytes, page_size, big_endian_checksum_words)?;
1157    Ok(matches!(
1158        validation.reason,
1159        Some(WalChainInvalidReason::TruncatedFrame | WalChainInvalidReason::FrameChecksumMismatch)
1160    ))
1161}
1162
1163/// XXH3-128 digest split into low/high u64 words.
1164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1165pub struct Xxh3Checksum128 {
1166    pub low: u64,
1167    pub high: u64,
1168}
1169
1170impl Xxh3Checksum128 {
1171    /// Compute XXH3-128.
1172    #[must_use]
1173    pub fn compute(data: &[u8]) -> Self {
1174        from_u128_le(xxh3_128(data))
1175    }
1176
1177    /// Verify digest against payload.
1178    #[must_use]
1179    pub fn verify(&self, data: &[u8]) -> bool {
1180        *self == Self::compute(data)
1181    }
1182
1183    /// Return little-endian bytes.
1184    #[must_use]
1185    pub fn to_le_bytes(self) -> [u8; 16] {
1186        let mut out = [0_u8; 16];
1187        out[..8].copy_from_slice(&self.low.to_le_bytes());
1188        out[8..].copy_from_slice(&self.high.to_le_bytes());
1189        out
1190    }
1191}
1192
1193/// Configure reserved bytes in a SQLite database header.
1194pub fn configure_page_checksum_reserved_bytes(db_header: &mut [u8], enabled: bool) -> Result<()> {
1195    ensure_min_len(
1196        db_header,
1197        SQLITE_DB_HEADER_RESERVED_OFFSET + 1,
1198        "database header",
1199    )?;
1200    db_header[SQLITE_DB_HEADER_RESERVED_OFFSET] = if enabled {
1201        u8::try_from(PAGE_CHECKSUM_RESERVED_BYTES).expect("reserved-byte constant fits in u8")
1202    } else {
1203        0
1204    };
1205    Ok(())
1206}
1207
1208/// Read reserved bytes from a SQLite database header.
1209pub fn page_checksum_reserved_bytes(db_header: &[u8]) -> Result<u8> {
1210    ensure_min_len(
1211        db_header,
1212        SQLITE_DB_HEADER_RESERVED_OFFSET + 1,
1213        "database header",
1214    )?;
1215    Ok(db_header[SQLITE_DB_HEADER_RESERVED_OFFSET])
1216}
1217
1218/// Zero the checksum trailer bytes in a page.
1219pub fn zero_page_checksum_trailer(page: &mut [u8]) -> Result<()> {
1220    if page.len() < PAGE_CHECKSUM_RESERVED_BYTES {
1221        return Err(FrankenError::WalCorrupt {
1222            detail: format!(
1223                "page too small for checksum trailer: expected >= {PAGE_CHECKSUM_RESERVED_BYTES}, got {}",
1224                page.len()
1225            ),
1226        });
1227    }
1228
1229    let start = page.len() - PAGE_CHECKSUM_RESERVED_BYTES;
1230    page[start..].fill(0);
1231    Ok(())
1232}
1233
1234/// Write XXH3 trailer checksum into reserved page bytes.
1235pub fn write_page_checksum(page: &mut [u8]) -> Result<Xxh3Checksum128> {
1236    if page.len() < PAGE_CHECKSUM_RESERVED_BYTES {
1237        return Err(FrankenError::WalCorrupt {
1238            detail: format!(
1239                "page too small for checksum trailer: expected >= {PAGE_CHECKSUM_RESERVED_BYTES}, got {}",
1240                page.len()
1241            ),
1242        });
1243    }
1244
1245    let payload_end = page.len() - PAGE_CHECKSUM_RESERVED_BYTES;
1246    let digest = Xxh3Checksum128::compute(&page[..payload_end]);
1247    page[payload_end..].copy_from_slice(&digest.to_le_bytes());
1248    Ok(digest)
1249}
1250
1251/// Read XXH3 trailer checksum from reserved page bytes.
1252pub fn read_page_checksum(page: &[u8]) -> Result<Xxh3Checksum128> {
1253    if page.len() < PAGE_CHECKSUM_RESERVED_BYTES {
1254        return Err(FrankenError::WalCorrupt {
1255            detail: format!(
1256                "page too small for checksum trailer: expected >= {PAGE_CHECKSUM_RESERVED_BYTES}, got {}",
1257                page.len()
1258            ),
1259        });
1260    }
1261
1262    let checksum_start = page.len() - PAGE_CHECKSUM_RESERVED_BYTES;
1263    Ok(read_xxh3_from_bytes(
1264        &page[checksum_start..checksum_start + PAGE_CHECKSUM_RESERVED_BYTES],
1265    ))
1266}
1267
1268/// Verify page trailer checksum.
1269pub fn verify_page_checksum(page: &[u8]) -> Result<bool> {
1270    if page.len() < PAGE_CHECKSUM_RESERVED_BYTES {
1271        return Err(FrankenError::WalCorrupt {
1272            detail: format!(
1273                "page too small for checksum trailer: expected >= {PAGE_CHECKSUM_RESERVED_BYTES}, got {}",
1274                page.len()
1275            ),
1276        });
1277    }
1278
1279    let payload_end = page.len() - PAGE_CHECKSUM_RESERVED_BYTES;
1280    let expected = Xxh3Checksum128::compute(&page[..payload_end]);
1281    let actual = read_page_checksum(page)?;
1282    Ok(actual == expected)
1283}
1284
1285/// Compute independent FEC source hash for a page payload.
1286#[must_use]
1287pub fn wal_fec_source_hash_xxh3_128(page_payload: &[u8]) -> Xxh3Checksum128 {
1288    Xxh3Checksum128::compute(page_payload)
1289}
1290
1291/// Verify independent FEC source hash.
1292#[must_use]
1293pub fn verify_wal_fec_source_hash(page_payload: &[u8], expected: Xxh3Checksum128) -> bool {
1294    wal_fec_source_hash_xxh3_128(page_payload) == expected
1295}
1296
1297/// Read WAL header salts.
1298pub fn read_wal_header_salts(wal_header: &[u8]) -> Result<WalSalts> {
1299    ensure_min_len(wal_header, WAL_HEADER_SIZE, "WAL header")?;
1300    Ok(WalSalts {
1301        salt1: read_be_u32_at(wal_header, WAL_HEADER_SALT1_OFFSET),
1302        salt2: read_be_u32_at(wal_header, WAL_HEADER_SALT2_OFFSET),
1303    })
1304}
1305
1306/// Write WAL header salts.
1307pub fn write_wal_header_salts(wal_header: &mut [u8], salts: WalSalts) -> Result<()> {
1308    ensure_min_len(wal_header, WAL_HEADER_SIZE, "WAL header")?;
1309    write_be_u32_at(wal_header, WAL_HEADER_SALT1_OFFSET, salts.salt1);
1310    write_be_u32_at(wal_header, WAL_HEADER_SALT2_OFFSET, salts.salt2);
1311    Ok(())
1312}
1313
1314/// Read WAL header checksum pair.
1315pub fn read_wal_header_checksum(wal_header: &[u8]) -> Result<SqliteWalChecksum> {
1316    ensure_min_len(wal_header, WAL_HEADER_SIZE, "WAL header")?;
1317    Ok(SqliteWalChecksum {
1318        s1: read_be_u32_at(wal_header, WAL_HEADER_CKSUM1_OFFSET),
1319        s2: read_be_u32_at(wal_header, WAL_HEADER_CKSUM2_OFFSET),
1320    })
1321}
1322
1323/// Compute and write WAL header checksum.
1324pub fn write_wal_header_checksum(
1325    wal_header: &mut [u8],
1326    big_endian_checksum_words: bool,
1327) -> Result<SqliteWalChecksum> {
1328    ensure_min_len(wal_header, WAL_HEADER_SIZE, "WAL header")?;
1329    let checksum = wal_header_checksum(wal_header, big_endian_checksum_words)?;
1330    write_be_u32_at(wal_header, WAL_HEADER_CKSUM1_OFFSET, checksum.s1);
1331    write_be_u32_at(wal_header, WAL_HEADER_CKSUM2_OFFSET, checksum.s2);
1332    Ok(checksum)
1333}
1334
1335/// Compute WAL header checksum from first 24 bytes.
1336pub fn wal_header_checksum(
1337    wal_header: &[u8],
1338    big_endian_checksum_words: bool,
1339) -> Result<SqliteWalChecksum> {
1340    ensure_min_len(wal_header, WAL_HEADER_SIZE, "WAL header")?;
1341    sqlite_wal_checksum(
1342        &wal_header[..WAL_HEADER_CKSUM1_OFFSET],
1343        0,
1344        0,
1345        big_endian_checksum_words,
1346    )
1347}
1348
1349/// Validate checksum stored in WAL header.
1350pub fn validate_wal_header_checksum(
1351    wal_header: &[u8],
1352    big_endian_checksum_words: bool,
1353) -> Result<bool> {
1354    let expected = wal_header_checksum(wal_header, big_endian_checksum_words)?;
1355    let actual = read_wal_header_checksum(wal_header)?;
1356    Ok(actual == expected)
1357}
1358
1359/// Read salts from WAL frame header.
1360pub fn read_wal_frame_salts(frame_header: &[u8]) -> Result<WalSalts> {
1361    ensure_min_len(frame_header, WAL_FRAME_HEADER_SIZE, "WAL frame header")?;
1362    Ok(WalSalts {
1363        salt1: read_be_u32_at(frame_header, WAL_FRAME_SALT1_OFFSET),
1364        salt2: read_be_u32_at(frame_header, WAL_FRAME_SALT2_OFFSET),
1365    })
1366}
1367
1368/// Write salts into WAL frame header.
1369pub fn write_wal_frame_salts(frame_header: &mut [u8], salts: WalSalts) -> Result<()> {
1370    ensure_min_len(frame_header, WAL_FRAME_HEADER_SIZE, "WAL frame header")?;
1371    write_be_u32_at(frame_header, WAL_FRAME_SALT1_OFFSET, salts.salt1);
1372    write_be_u32_at(frame_header, WAL_FRAME_SALT2_OFFSET, salts.salt2);
1373    Ok(())
1374}
1375
1376/// Read checksum from WAL frame header.
1377pub fn read_wal_frame_checksum(frame_header: &[u8]) -> Result<SqliteWalChecksum> {
1378    ensure_min_len(frame_header, WAL_FRAME_HEADER_SIZE, "WAL frame header")?;
1379    Ok(SqliteWalChecksum {
1380        s1: read_be_u32_at(frame_header, WAL_FRAME_CKSUM1_OFFSET),
1381        s2: read_be_u32_at(frame_header, WAL_FRAME_CKSUM2_OFFSET),
1382    })
1383}
1384
1385/// Compute checksum for one WAL frame given prior rolling checksum.
1386pub fn compute_wal_frame_checksum(
1387    frame: &[u8],
1388    page_size: usize,
1389    previous: SqliteWalChecksum,
1390    big_endian_checksum_words: bool,
1391) -> Result<SqliteWalChecksum> {
1392    ensure_frame_len(frame, page_size)?;
1393    let c1 = sqlite_wal_checksum(
1394        &frame[..8],
1395        previous.s1,
1396        previous.s2,
1397        big_endian_checksum_words,
1398    )?;
1399    sqlite_wal_checksum(
1400        &frame[WAL_FRAME_HEADER_SIZE..WAL_FRAME_HEADER_SIZE + page_size],
1401        c1.s1,
1402        c1.s2,
1403        big_endian_checksum_words,
1404    )
1405}
1406
1407/// Compute and write checksum for one WAL frame, returning the next running checksum.
1408pub fn write_wal_frame_checksum(
1409    frame: &mut [u8],
1410    page_size: usize,
1411    previous: SqliteWalChecksum,
1412    big_endian_checksum_words: bool,
1413) -> Result<SqliteWalChecksum> {
1414    let checksum =
1415        compute_wal_frame_checksum(frame, page_size, previous, big_endian_checksum_words)?;
1416    write_wal_frame_checksum_fields(frame, checksum)?;
1417    Ok(checksum)
1418}
1419
1420/// Write an already-computed WAL frame checksum into the frame header.
1421pub fn write_wal_frame_checksum_fields(
1422    frame: &mut [u8],
1423    checksum: SqliteWalChecksum,
1424) -> Result<()> {
1425    ensure_min_len(frame, WAL_FRAME_HEADER_SIZE, "WAL frame")?;
1426    write_be_u32_at(frame, WAL_FRAME_CKSUM1_OFFSET, checksum.s1);
1427    write_be_u32_at(frame, WAL_FRAME_CKSUM2_OFFSET, checksum.s2);
1428    Ok(())
1429}
1430
1431/// Read frame DB-size commit marker.
1432pub fn wal_frame_db_size(frame_header: &[u8]) -> Result<u32> {
1433    ensure_min_len(frame_header, WAL_FRAME_HEADER_SIZE, "WAL frame header")?;
1434    Ok(read_be_u32_at(frame_header, WAL_FRAME_DB_SIZE_OFFSET))
1435}
1436
1437/// Validate WAL bytes and derive replayable prefix information.
1438pub fn validate_wal_chain(
1439    wal_bytes: &[u8],
1440    page_size: usize,
1441    big_endian_checksum_words: bool,
1442) -> Result<WalChainValidation> {
1443    ensure_min_len(wal_bytes, WAL_HEADER_SIZE, "WAL bytes")?;
1444    ensure_valid_wal_page_size(page_size, "WAL page_size")?;
1445
1446    let frame_size = WAL_FRAME_HEADER_SIZE + page_size;
1447    let wal_header = &wal_bytes[..WAL_HEADER_SIZE];
1448    if !validate_wal_header_checksum(wal_header, big_endian_checksum_words)? {
1449        return Ok(WalChainValidation::from_core(
1450            false,
1451            0,
1452            0,
1453            Some(0),
1454            Some(WalChainInvalidReason::HeaderChecksumMismatch),
1455            None,
1456            frame_size,
1457        ));
1458    }
1459
1460    let header_salts = read_wal_header_salts(wal_header)?;
1461    let mut running_checksum = read_wal_header_checksum(wal_header)?;
1462
1463    let frames = &wal_bytes[WAL_HEADER_SIZE..];
1464    let full_frames = frames.len() / frame_size;
1465    let trailing_bytes = frames.len() % frame_size;
1466
1467    let mut valid_frames = 0_usize;
1468    let mut replayable_frames = 0_usize;
1469    let mut last_commit_frame = None;
1470
1471    for frame_index in 0..full_frames {
1472        let start = frame_index * frame_size;
1473        let frame = &frames[start..start + frame_size];
1474        let frame_header = &frame[..WAL_FRAME_HEADER_SIZE];
1475
1476        if read_wal_frame_salts(frame_header)? != header_salts {
1477            return Ok(WalChainValidation::from_core(
1478                false,
1479                valid_frames,
1480                replayable_frames,
1481                Some(frame_index),
1482                Some(WalChainInvalidReason::SaltMismatch),
1483                last_commit_frame,
1484                frame_size,
1485            ));
1486        }
1487
1488        let expected = compute_wal_frame_checksum(
1489            frame,
1490            page_size,
1491            running_checksum,
1492            big_endian_checksum_words,
1493        )?;
1494        let actual = read_wal_frame_checksum(frame_header)?;
1495        if actual != expected {
1496            return Ok(WalChainValidation::from_core(
1497                false,
1498                valid_frames,
1499                replayable_frames,
1500                Some(frame_index),
1501                Some(WalChainInvalidReason::FrameChecksumMismatch),
1502                last_commit_frame,
1503                frame_size,
1504            ));
1505        }
1506
1507        running_checksum = actual;
1508        valid_frames += 1;
1509
1510        if wal_frame_db_size(frame_header)? > 0 {
1511            last_commit_frame = Some(frame_index);
1512            replayable_frames = frame_index + 1;
1513        }
1514    }
1515
1516    if trailing_bytes != 0 {
1517        return Ok(WalChainValidation::from_core(
1518            false,
1519            valid_frames,
1520            replayable_frames,
1521            Some(valid_frames),
1522            Some(WalChainInvalidReason::TruncatedFrame),
1523            last_commit_frame,
1524            frame_size,
1525        ));
1526    }
1527
1528    Ok(WalChainValidation::from_core(
1529        true,
1530        valid_frames,
1531        replayable_frames,
1532        None,
1533        None,
1534        last_commit_frame,
1535        frame_size,
1536    ))
1537}
1538
1539/// Compute SQLite-compatible rolling checksum over 8-byte chunks.
1540pub fn sqlite_wal_checksum(
1541    data: &[u8],
1542    seed_s1: u32,
1543    seed_s2: u32,
1544    big_endian_checksum_words: bool,
1545) -> Result<SqliteWalChecksum> {
1546    if !data.len().is_multiple_of(8) {
1547        return Err(FrankenError::WalCorrupt {
1548            detail: format!(
1549                "WAL checksum input must be 8-byte aligned, got {} bytes",
1550                data.len()
1551            ),
1552        });
1553    }
1554
1555    let mut s1 = seed_s1;
1556    let mut s2 = seed_s2;
1557
1558    let (chunks, _rest) = data.as_chunks::<8>();
1559    for chunk in chunks {
1560        let x0 = decode_u32_words(&chunk[..4], big_endian_checksum_words);
1561        let x1 = decode_u32_words(&chunk[4..], big_endian_checksum_words);
1562
1563        s1 = s1.wrapping_add(x0).wrapping_add(s2);
1564        s2 = s2.wrapping_add(x1).wrapping_add(s1);
1565    }
1566
1567    Ok(SqliteWalChecksum { s1, s2 })
1568}
1569
1570/// Integrity-tier hash bytes.
1571#[must_use]
1572pub fn integrity_hash_xxh3_128(data: &[u8]) -> [u8; 16] {
1573    xxh3_128(data).to_le_bytes()
1574}
1575
1576/// Content-addressing hash bytes (BLAKE3-128 truncation).
1577#[must_use]
1578pub fn content_address_hash_128(data: &[u8]) -> [u8; 16] {
1579    let digest = blake3::hash(data);
1580    let mut out = [0_u8; 16];
1581    out.copy_from_slice(&digest.as_bytes()[..16]);
1582    out
1583}
1584
1585/// Protocol-tier CRC-32C.
1586#[must_use]
1587pub fn crc32c_checksum(data: &[u8]) -> u32 {
1588    crc32c::crc32c(data)
1589}
1590
1591/// Map algorithm name to tier.
1592#[must_use]
1593pub fn tier_for_algorithm(algorithm: &str) -> Option<HashTier> {
1594    let normalized = algorithm.trim().to_ascii_lowercase();
1595    match normalized.as_str() {
1596        "xxh3_128" | "xxh3" => Some(HashTier::Integrity),
1597        "blake3_128" | "blake3" => Some(HashTier::ContentAddressing),
1598        "crc32c" => Some(HashTier::Protocol),
1599        _ => None,
1600    }
1601}
1602
1603fn is_valid_schema_sql(sql: &str) -> bool {
1604    let normalized = sql.trim_start().to_ascii_uppercase();
1605    normalized.starts_with("CREATE TABLE ")
1606        || normalized.starts_with("CREATE INDEX ")
1607        || normalized.starts_with("CREATE VIEW ")
1608        || normalized.starts_with("CREATE TRIGGER ")
1609        || normalized.starts_with("CREATE VIRTUAL TABLE ")
1610}
1611
1612fn sqlite_page_size_from_header(db_bytes: &[u8]) -> Option<usize> {
1613    if db_bytes.len() < SQLITE_DB_HEADER_SIZE {
1614        return None;
1615    }
1616    let raw = u16::from_be_bytes([db_bytes[16], db_bytes[17]]);
1617    let page_size = if raw == 1 { 65_536 } else { usize::from(raw) };
1618    Some(page_size)
1619}
1620
1621fn normalize_first_page_header_offsets(page: &mut [u8]) {
1622    if page.len() < 7 {
1623        return;
1624    }
1625
1626    let first_freeblock = u16::from_be_bytes([page[1], page[2]]);
1627    if first_freeblock >= SQLITE_DB_HEADER_SIZE_U16 {
1628        let adjusted = first_freeblock.saturating_sub(SQLITE_DB_HEADER_SIZE_U16);
1629        page[1..3].copy_from_slice(&adjusted.to_be_bytes());
1630    } else if first_freeblock != 0 {
1631        // Pointer into the DB header is invalid. Force failure in bounds check.
1632        page[1..3].copy_from_slice(&u16::MAX.to_be_bytes());
1633    }
1634
1635    let cell_content_offset = u16::from_be_bytes([page[5], page[6]]);
1636    if cell_content_offset >= SQLITE_DB_HEADER_SIZE_U16 {
1637        let adjusted = cell_content_offset.saturating_sub(SQLITE_DB_HEADER_SIZE_U16);
1638        page[5..7].copy_from_slice(&adjusted.to_be_bytes());
1639    } else if cell_content_offset != 0 {
1640        // Pointer into the DB header is invalid. Force failure in bounds check.
1641        page[5..7].copy_from_slice(&u16::MAX.to_be_bytes());
1642    }
1643}
1644
1645fn ensure_min_len(bytes: &[u8], minimum: usize, label: &str) -> Result<()> {
1646    if bytes.len() < minimum {
1647        return Err(FrankenError::WalCorrupt {
1648            detail: format!(
1649                "{label} too small: expected >= {minimum}, got {}",
1650                bytes.len()
1651            ),
1652        });
1653    }
1654    Ok(())
1655}
1656
1657fn ensure_frame_len(frame: &[u8], page_size: usize) -> Result<()> {
1658    ensure_valid_wal_page_size(page_size, "frame page_size")?;
1659    let frame_size = WAL_FRAME_HEADER_SIZE + page_size;
1660    ensure_min_len(frame, frame_size, "WAL frame")
1661}
1662
1663/// GH #292: classify a 32-byte WAL header exactly the way stock SQLite's
1664/// `walIndexRecover` does.
1665///
1666/// Returns `true` when the header is invalid in a way stock SQLite treats as
1667/// "this WAL is empty" and silently proceeds without it: bad magic, invalid
1668/// page size, or header-checksum mismatch (a torn or garbage sidecar, e.g.
1669/// left behind by a killed process). Returns `false` both for a fully valid
1670/// header and for the single hard-error case stock SQLite keeps: a
1671/// checksum-valid header with an unsupported format version
1672/// (`SQLITE_CANTOPEN` there, [`FrankenError::WalCorrupt`] here).
1673///
1674/// Check order mirrors `walIndexRecover`: magic → page size → checksum →
1675/// (version left to the caller), so a torn header with a damaged version
1676/// field still classifies as empty via its failed checksum.
1677#[must_use]
1678pub fn wal_header_treated_as_empty(header_buf: &[u8; WAL_HEADER_SIZE]) -> bool {
1679    let magic = read_be_u32_at(header_buf, 0);
1680    if magic != WAL_MAGIC_LE && magic != WAL_MAGIC_BE {
1681        return true;
1682    }
1683    if ensure_valid_wal_header_page_size(read_be_u32_at(header_buf, 8)).is_err() {
1684        return true;
1685    }
1686    let big_endian = magic == WAL_MAGIC_BE;
1687    let (Ok(stored), Ok(expected)) = (
1688        read_wal_header_checksum(header_buf),
1689        wal_header_checksum(header_buf, big_endian),
1690    ) else {
1691        return true;
1692    };
1693    stored != expected
1694}
1695
1696fn ensure_valid_wal_header_page_size(page_size: u32) -> Result<()> {
1697    if PageSize::new(page_size).is_none() {
1698        return Err(FrankenError::WalCorrupt {
1699            detail: format!(
1700                "invalid WAL header page_size {page_size}; expected power-of-two in 512..=65536"
1701            ),
1702        });
1703    }
1704    Ok(())
1705}
1706
1707fn ensure_valid_wal_page_size(page_size: usize, label: &str) -> Result<()> {
1708    let Ok(page_size_u32) = u32::try_from(page_size) else {
1709        return Err(FrankenError::WalCorrupt {
1710            detail: format!("{label} {page_size} does not fit in u32"),
1711        });
1712    };
1713    if PageSize::new(page_size_u32).is_none() {
1714        return Err(FrankenError::WalCorrupt {
1715            detail: format!("invalid {label} {page_size}; expected power-of-two in 512..=65536"),
1716        });
1717    }
1718    Ok(())
1719}
1720
1721#[inline]
1722fn decode_u32_words(bytes: &[u8], big_endian_checksum_words: bool) -> u32 {
1723    let raw = bytes[..4].try_into().unwrap();
1724    if big_endian_checksum_words {
1725        u32::from_be_bytes(raw)
1726    } else {
1727        u32::from_le_bytes(raw)
1728    }
1729}
1730
1731#[inline]
1732fn read_be_u32_at(bytes: &[u8], offset: usize) -> u32 {
1733    u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap())
1734}
1735
1736fn write_be_u32_at(bytes: &mut [u8], offset: usize, value: u32) {
1737    bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes());
1738}
1739
1740fn from_u128_le(value: u128) -> Xxh3Checksum128 {
1741    let bytes = value.to_le_bytes();
1742    let mut low = [0_u8; 8];
1743    let mut high = [0_u8; 8];
1744    low.copy_from_slice(&bytes[..8]);
1745    high.copy_from_slice(&bytes[8..]);
1746    Xxh3Checksum128 {
1747        low: u64::from_le_bytes(low),
1748        high: u64::from_le_bytes(high),
1749    }
1750}
1751
1752fn read_xxh3_from_bytes(bytes: &[u8]) -> Xxh3Checksum128 {
1753    let mut low = [0_u8; 8];
1754    let mut high = [0_u8; 8];
1755    low.copy_from_slice(&bytes[..8]);
1756    high.copy_from_slice(&bytes[8..16]);
1757    Xxh3Checksum128 {
1758        low: u64::from_le_bytes(low),
1759        high: u64::from_le_bytes(high),
1760    }
1761}
1762
1763#[cfg(test)]
1764mod tests {
1765    use super::*;
1766
1767    const PAGE_SIZE: usize = 4096;
1768
1769    fn sample_page(seed: u8) -> [u8; PAGE_SIZE] {
1770        let mut page = [0_u8; PAGE_SIZE];
1771        for (index, byte) in page.iter_mut().enumerate() {
1772            let reduced_index = u8::try_from(index % 251).expect("modulo result must fit in u8");
1773            *byte = reduced_index ^ seed;
1774        }
1775        page
1776    }
1777
1778    fn sample_btree_leaf_page() -> [u8; PAGE_SIZE] {
1779        let mut page = [0_u8; PAGE_SIZE];
1780        page[0] = 0x0D; // leaf table page
1781        page[1..3].copy_from_slice(&0_u16.to_be_bytes()); // first freeblock
1782        page[3..5].copy_from_slice(&0_u16.to_be_bytes()); // cell count
1783        page[5..7].copy_from_slice(
1784            &u16::try_from(PAGE_SIZE)
1785                .expect("PAGE_SIZE should fit in u16 for test")
1786                .to_be_bytes(),
1787        );
1788        page[7] = 0; // fragmented bytes
1789        page
1790    }
1791
1792    #[test]
1793    fn test_wal_header_magic_le_roundtrip() {
1794        let header = WalHeader {
1795            magic: WAL_MAGIC_LE,
1796            format_version: WAL_FORMAT_VERSION,
1797            page_size: u32::try_from(PAGE_SIZE).expect("page size fits in u32"),
1798            checkpoint_seq: 7,
1799            salts: WalSalts {
1800                salt1: 0x1111_2222,
1801                salt2: 0x3333_4444,
1802            },
1803            checksum: SqliteWalChecksum::default(),
1804        };
1805        let bytes = header.to_bytes().expect("header should serialize");
1806        assert_eq!(read_be_u32_at(&bytes, 0), WAL_MAGIC_LE);
1807        assert!(
1808            validate_wal_header_checksum(&bytes, false).expect("header checksum should validate")
1809        );
1810
1811        let parsed = WalHeader::from_bytes(&bytes).expect("header should parse");
1812        assert_eq!(parsed.magic, WAL_MAGIC_LE);
1813        assert!(!parsed.big_endian_checksum());
1814    }
1815
1816    #[test]
1817    fn test_wal_header_magic_be_roundtrip() {
1818        let header = WalHeader {
1819            magic: WAL_MAGIC_BE,
1820            format_version: WAL_FORMAT_VERSION,
1821            page_size: u32::try_from(PAGE_SIZE).expect("page size fits in u32"),
1822            checkpoint_seq: 11,
1823            salts: WalSalts {
1824                salt1: 0xAAAA_BBBB,
1825                salt2: 0xCCCC_DDDD,
1826            },
1827            checksum: SqliteWalChecksum::default(),
1828        };
1829        let bytes = header.to_bytes().expect("header should serialize");
1830        assert_eq!(read_be_u32_at(&bytes, 0), WAL_MAGIC_BE);
1831        assert!(
1832            validate_wal_header_checksum(&bytes, true).expect("header checksum should validate")
1833        );
1834
1835        let parsed = WalHeader::from_bytes(&bytes).expect("header should parse");
1836        assert_eq!(parsed.magic, WAL_MAGIC_BE);
1837        assert!(parsed.big_endian_checksum());
1838    }
1839
1840    #[test]
1841    fn test_wal_header_format_version_constant_and_rejection() {
1842        assert_eq!(WAL_FORMAT_VERSION, 3_007_000);
1843
1844        let header = WalHeader {
1845            magic: WAL_MAGIC_LE,
1846            format_version: WAL_FORMAT_VERSION,
1847            page_size: u32::try_from(PAGE_SIZE).expect("page size fits in u32"),
1848            checkpoint_seq: 0,
1849            salts: WalSalts { salt1: 1, salt2: 2 },
1850            checksum: SqliteWalChecksum::default(),
1851        };
1852        let mut bytes = header.to_bytes().expect("header should serialize");
1853        write_be_u32_at(&mut bytes, 4, WAL_FORMAT_VERSION + 1);
1854        let err = WalHeader::from_bytes(&bytes).expect_err("invalid version must be rejected");
1855        assert!(matches!(err, FrankenError::WalCorrupt { .. }));
1856    }
1857
1858    #[test]
1859    fn test_wal_header_rejects_invalid_page_size_on_parse_and_serialize() {
1860        let header = WalHeader {
1861            magic: WAL_MAGIC_LE,
1862            format_version: WAL_FORMAT_VERSION,
1863            page_size: u32::try_from(PAGE_SIZE).expect("page size fits in u32"),
1864            checkpoint_seq: 0,
1865            salts: WalSalts { salt1: 1, salt2: 2 },
1866            checksum: SqliteWalChecksum::default(),
1867        };
1868
1869        let mut bytes = header.to_bytes().expect("valid header should serialize");
1870        write_be_u32_at(&mut bytes, 8, 3000);
1871        let parse_err =
1872            WalHeader::from_bytes(&bytes).expect_err("invalid page size must be rejected");
1873        assert!(matches!(parse_err, FrankenError::WalCorrupt { .. }));
1874
1875        let invalid_header = WalHeader {
1876            page_size: 3000,
1877            ..header
1878        };
1879        let serialize_err = invalid_header
1880            .to_bytes()
1881            .expect_err("invalid page size must not serialize");
1882        assert!(matches!(serialize_err, FrankenError::WalCorrupt { .. }));
1883    }
1884
1885    #[test]
1886    fn test_wal_frame_header_commit_and_non_commit() {
1887        let salts = WalSalts {
1888            salt1: 0x0102_0304,
1889            salt2: 0x0506_0708,
1890        };
1891        let checksum = SqliteWalChecksum {
1892            s1: 0x1111_1111,
1893            s2: 0x2222_2222,
1894        };
1895
1896        let non_commit = WalFrameHeader {
1897            page_number: 4,
1898            db_size: 0,
1899            salts,
1900            checksum,
1901        };
1902        assert!(!non_commit.is_commit());
1903        let parsed_non_commit =
1904            WalFrameHeader::from_bytes(&non_commit.to_bytes()).expect("frame should parse");
1905        assert_eq!(parsed_non_commit, non_commit);
1906
1907        let commit = WalFrameHeader {
1908            page_number: 5,
1909            db_size: 99,
1910            salts,
1911            checksum,
1912        };
1913        assert!(commit.is_commit());
1914        let parsed_commit =
1915            WalFrameHeader::from_bytes(&commit.to_bytes()).expect("frame should parse");
1916        assert_eq!(parsed_commit, commit);
1917    }
1918
1919    #[test]
1920    fn test_wal_frame_salt_match_validation() {
1921        let header = WalHeader {
1922            magic: WAL_MAGIC_LE,
1923            format_version: WAL_FORMAT_VERSION,
1924            page_size: u32::try_from(PAGE_SIZE).expect("page size fits in u32"),
1925            checkpoint_seq: 1,
1926            salts: WalSalts {
1927                salt1: 0xABCD_1234,
1928                salt2: 0x9876_5432,
1929            },
1930            checksum: SqliteWalChecksum::default(),
1931        };
1932        let header_bytes = header.to_bytes().expect("header should serialize");
1933        let seed = read_wal_header_checksum(&header_bytes).expect("header checksum should read");
1934
1935        let mut frame = vec![0_u8; WAL_FRAME_HEADER_SIZE + PAGE_SIZE];
1936        frame[..4].copy_from_slice(&1_u32.to_be_bytes());
1937        frame[4..8].copy_from_slice(&1_u32.to_be_bytes());
1938        write_wal_frame_salts(&mut frame[..WAL_FRAME_HEADER_SIZE], header.salts)
1939            .expect("frame salts should write");
1940        frame[WAL_FRAME_HEADER_SIZE..].copy_from_slice(&sample_page(0x3A));
1941        write_wal_frame_checksum(&mut frame, PAGE_SIZE, seed, false)
1942            .expect("frame checksum should write");
1943
1944        let mut wal_bytes = Vec::with_capacity(WAL_HEADER_SIZE + frame.len());
1945        wal_bytes.extend_from_slice(&header_bytes);
1946        wal_bytes.extend_from_slice(&frame);
1947        let valid = validate_wal_chain(&wal_bytes, PAGE_SIZE, false).expect("valid chain");
1948        assert!(valid.valid);
1949        assert_eq!(valid.valid_frames, 1);
1950
1951        write_wal_frame_salts(
1952            &mut wal_bytes[WAL_HEADER_SIZE..WAL_HEADER_SIZE + WAL_FRAME_HEADER_SIZE],
1953            WalSalts {
1954                salt1: 0xDEAD_BEEF,
1955                salt2: 0xFACE_FEED,
1956            },
1957        )
1958        .expect("salt rewrite should succeed");
1959        let invalid =
1960            validate_wal_chain(&wal_bytes, PAGE_SIZE, false).expect("invalid chain should parse");
1961        assert_eq!(invalid.reason, Some(WalChainInvalidReason::SaltMismatch));
1962        assert_eq!(invalid.first_invalid_frame, Some(0));
1963    }
1964
1965    #[test]
1966    fn test_wal_checksum_transform_matches_frame_checksum() {
1967        let header = WalHeader {
1968            magic: WAL_MAGIC_LE,
1969            format_version: WAL_FORMAT_VERSION,
1970            page_size: u32::try_from(PAGE_SIZE).expect("page size fits in u32"),
1971            checkpoint_seq: 5,
1972            salts: WalSalts {
1973                salt1: 0x1234_5678,
1974                salt2: 0x9ABC_DEF0,
1975            },
1976            checksum: SqliteWalChecksum::default(),
1977        };
1978        let header_bytes = header.to_bytes().expect("header should serialize");
1979        let seed = read_wal_header_checksum(&header_bytes).expect("header checksum should read");
1980
1981        let mut frame = vec![0_u8; WAL_FRAME_HEADER_SIZE + PAGE_SIZE];
1982        frame[..4].copy_from_slice(&7_u32.to_be_bytes());
1983        frame[4..8].copy_from_slice(&7_u32.to_be_bytes());
1984        write_wal_frame_salts(&mut frame[..WAL_FRAME_HEADER_SIZE], header.salts)
1985            .expect("frame salts should write");
1986        frame[WAL_FRAME_HEADER_SIZE..].copy_from_slice(&sample_page(0x55));
1987
1988        let transform =
1989            WalChecksumTransform::for_wal_frame(&frame, PAGE_SIZE, false).expect("transform");
1990        let transformed = transform.apply(seed);
1991        let computed =
1992            compute_wal_frame_checksum(&frame, PAGE_SIZE, seed, false).expect("checksum");
1993
1994        assert_eq!(
1995            transformed, computed,
1996            "precomputed frame transform must match direct checksum evaluation"
1997        );
1998    }
1999
2000    #[test]
2001    fn test_wal_checksum_transform_matches_direct_checksum_for_chunk_counts() {
2002        for big_endian in [false, true] {
2003            for chunk_count in [0_usize, 1, 2, 3, 8, 31, 512] {
2004                let mut data = vec![0_u8; chunk_count * 8];
2005                for (idx, byte) in data.iter_mut().enumerate() {
2006                    *byte = u8::try_from((idx * 37 + chunk_count * 11) & 0xFF)
2007                        .expect("masked byte fits");
2008                }
2009                let transform = WalChecksumTransform::from_aligned_bytes(&data, big_endian)
2010                    .expect("aligned transform should build");
2011                for seed in [
2012                    SqliteWalChecksum { s1: 0, s2: 0 },
2013                    SqliteWalChecksum {
2014                        s1: 0x1234_5678,
2015                        s2: 0x9ABC_DEF0,
2016                    },
2017                    SqliteWalChecksum {
2018                        s1: u32::MAX,
2019                        s2: 0x0102_0304,
2020                    },
2021                ] {
2022                    let direct = sqlite_wal_checksum(&data, seed.s1, seed.s2, big_endian)
2023                        .expect("aligned checksum should compute");
2024                    assert_eq!(
2025                        transform.apply(seed),
2026                        direct,
2027                        "transform must match direct checksum for big_endian={big_endian} chunk_count={chunk_count} seed={seed:?}",
2028                    );
2029                }
2030            }
2031        }
2032    }
2033
2034    #[test]
2035    fn test_wal_checksum_chain_integrity_two_frames() {
2036        let header = WalHeader {
2037            magic: WAL_MAGIC_LE,
2038            format_version: WAL_FORMAT_VERSION,
2039            page_size: u32::try_from(PAGE_SIZE).expect("page size fits in u32"),
2040            checkpoint_seq: 3,
2041            salts: WalSalts {
2042                salt1: 0xA1A2_A3A4,
2043                salt2: 0xB1B2_B3B4,
2044            },
2045            checksum: SqliteWalChecksum::default(),
2046        };
2047        let header_bytes = header.to_bytes().expect("header should serialize");
2048        let mut running_checksum =
2049            read_wal_header_checksum(&header_bytes).expect("header checksum should read");
2050
2051        let mut frame1 = vec![0_u8; WAL_FRAME_HEADER_SIZE + PAGE_SIZE];
2052        frame1[..4].copy_from_slice(&1_u32.to_be_bytes());
2053        frame1[4..8].copy_from_slice(&0_u32.to_be_bytes());
2054        write_wal_frame_salts(&mut frame1[..WAL_FRAME_HEADER_SIZE], header.salts)
2055            .expect("frame salts should write");
2056        frame1[WAL_FRAME_HEADER_SIZE..].copy_from_slice(&sample_page(0x10));
2057        running_checksum =
2058            write_wal_frame_checksum(&mut frame1, PAGE_SIZE, running_checksum, false)
2059                .expect("frame checksum should write");
2060
2061        let mut frame2 = vec![0_u8; WAL_FRAME_HEADER_SIZE + PAGE_SIZE];
2062        frame2[..4].copy_from_slice(&2_u32.to_be_bytes());
2063        frame2[4..8].copy_from_slice(&7_u32.to_be_bytes());
2064        write_wal_frame_salts(&mut frame2[..WAL_FRAME_HEADER_SIZE], header.salts)
2065            .expect("frame salts should write");
2066        frame2[WAL_FRAME_HEADER_SIZE..].copy_from_slice(&sample_page(0x20));
2067        let frame2_checksum =
2068            write_wal_frame_checksum(&mut frame2, PAGE_SIZE, running_checksum, false)
2069                .expect("frame checksum should write");
2070
2071        let mut wal_bytes = Vec::with_capacity(WAL_HEADER_SIZE + frame1.len() + frame2.len());
2072        wal_bytes.extend_from_slice(&header_bytes);
2073        wal_bytes.extend_from_slice(&frame1);
2074        wal_bytes.extend_from_slice(&frame2);
2075        let validation = validate_wal_chain(&wal_bytes, PAGE_SIZE, false).expect("valid chain");
2076
2077        assert!(validation.valid);
2078        assert_eq!(validation.valid_frames, 2);
2079        assert_eq!(validation.replayable_frames, 2);
2080        assert_eq!(validation.last_commit_frame, Some(1));
2081        let parsed_frame2 =
2082            WalFrameHeader::from_bytes(&frame2[..WAL_FRAME_HEADER_SIZE]).expect("frame parses");
2083        assert_eq!(parsed_frame2.checksum, frame2_checksum);
2084    }
2085
2086    #[test]
2087    fn test_wal_frame_checksum_ignores_salt_words() {
2088        let seed = SqliteWalChecksum {
2089            s1: 0x1234_5678,
2090            s2: 0x9ABC_DEF0,
2091        };
2092        let mut frame_a = vec![0_u8; WAL_FRAME_HEADER_SIZE + PAGE_SIZE];
2093        frame_a[..4].copy_from_slice(&2_u32.to_be_bytes());
2094        frame_a[4..8].copy_from_slice(&0_u32.to_be_bytes());
2095        write_wal_frame_salts(
2096            &mut frame_a[..WAL_FRAME_HEADER_SIZE],
2097            WalSalts { salt1: 1, salt2: 2 },
2098        )
2099        .expect("frame salts should write");
2100        frame_a[WAL_FRAME_HEADER_SIZE..].copy_from_slice(&sample_page(0x55));
2101
2102        let mut frame_b = frame_a.clone();
2103        write_wal_frame_salts(
2104            &mut frame_b[..WAL_FRAME_HEADER_SIZE],
2105            WalSalts {
2106                salt1: 0xAAAA_BBBB,
2107                salt2: 0xCCCC_DDDD,
2108            },
2109        )
2110        .expect("frame salts should write");
2111
2112        let checksum_a =
2113            compute_wal_frame_checksum(&frame_a, PAGE_SIZE, seed, false).expect("checksum");
2114        let checksum_b =
2115            compute_wal_frame_checksum(&frame_b, PAGE_SIZE, seed, false).expect("checksum");
2116        assert_eq!(checksum_a, checksum_b);
2117    }
2118
2119    #[test]
2120    fn test_sqlite_checksum_alignment_guard() {
2121        let err = sqlite_wal_checksum(&[1_u8, 2, 3], 0, 0, false).expect_err("must reject");
2122        assert!(matches!(err, FrankenError::WalCorrupt { .. }));
2123        let detail = match err {
2124            FrankenError::WalCorrupt { detail } => detail,
2125            _ => String::new(),
2126        };
2127        assert!(detail.contains("8-byte aligned"));
2128    }
2129
2130    #[test]
2131    fn test_page_checksum_roundtrip() {
2132        let mut page = sample_page(7);
2133        let expected = write_page_checksum(&mut page).expect("write should succeed");
2134        let actual = read_page_checksum(&page).expect("read should succeed");
2135        assert_eq!(expected, actual);
2136        assert!(verify_page_checksum(&page).expect("verify should succeed"));
2137    }
2138
2139    #[test]
2140    fn test_configure_reserved_bytes() {
2141        let mut header = [0_u8; 100];
2142        configure_page_checksum_reserved_bytes(&mut header, true).expect("config should work");
2143        assert_eq!(
2144            page_checksum_reserved_bytes(&header).expect("read should work"),
2145            u8::try_from(PAGE_CHECKSUM_RESERVED_BYTES).expect("fits")
2146        );
2147    }
2148
2149    #[test]
2150    fn test_integrity_check_database_header_magic() {
2151        let mut bytes = vec![0_u8; SQLITE_DB_HEADER_SIZE];
2152        bytes[..SQLITE_DB_HEADER_MAGIC.len()].copy_from_slice(&SQLITE_DB_HEADER_MAGIC);
2153        bytes[16..18].copy_from_slice(&4096_u16.to_be_bytes());
2154        let ok_report = integrity_check_database_header(&bytes);
2155        assert!(ok_report.is_ok());
2156
2157        bytes[0] ^= 0x7F;
2158        let bad_report = integrity_check_database_header(&bytes);
2159        assert!(
2160            bad_report
2161                .sqlite_messages()
2162                .iter()
2163                .any(|line| line.contains("header magic mismatch"))
2164        );
2165    }
2166
2167    #[test]
2168    fn test_integrity_check_valid_db() {
2169        let page = sample_btree_leaf_page();
2170        let report = integrity_check_level1_page(&page, 1, true, false)
2171            .expect("level1 integrity check should run");
2172        assert!(report.is_ok());
2173        assert_eq!(report.sqlite_messages(), vec!["ok".to_owned()]);
2174    }
2175
2176    #[test]
2177    fn test_integrity_check_bad_page_type() {
2178        let mut page = sample_btree_leaf_page();
2179        page[0] = 0xFF;
2180
2181        let report = integrity_check_level1_page(&page, 7, true, false)
2182            .expect("level1 integrity check should run");
2183        assert!(!report.is_ok());
2184        assert!(
2185            report
2186                .sqlite_messages()
2187                .iter()
2188                .any(|line| line.contains("invalid b-tree page type"))
2189        );
2190    }
2191
2192    #[test]
2193    fn test_integrity_check_overlapping_cells() {
2194        let report =
2195            integrity_check_level2_btree(11, PAGE_SIZE, &[(100, 220), (200, 280)], &[1, 2]);
2196        assert!(
2197            report
2198                .sqlite_messages()
2199                .iter()
2200                .any(|line| line.contains("overlapping cell spans"))
2201        );
2202    }
2203
2204    #[test]
2205    fn test_integrity_check_unsorted_keys() {
2206        let report =
2207            integrity_check_level2_btree(12, PAGE_SIZE, &[(100, 120), (140, 180)], &[1, 3, 2]);
2208        assert!(
2209            report
2210                .sqlite_messages()
2211                .iter()
2212                .any(|line| line.contains("keys out of order"))
2213        );
2214    }
2215
2216    #[test]
2217    fn test_integrity_check_bad_overflow() {
2218        let report = integrity_check_level3_overflow_chain(13, &[7, 8, 7], 64);
2219        assert!(
2220            report
2221                .sqlite_messages()
2222                .iter()
2223                .any(|line| line.contains("broken overflow chain"))
2224        );
2225    }
2226
2227    #[test]
2228    fn test_integrity_check_page_not_accounted() {
2229        let report = integrity_check_level4_cross_reference(4, &[1, 3, 4]);
2230        assert!(
2231            report
2232                .sqlite_messages()
2233                .iter()
2234                .any(|line| line.contains("page 2: not accounted"))
2235        );
2236    }
2237
2238    #[test]
2239    fn test_integrity_check_schema_corrupt() {
2240        let report = integrity_check_level5_schema(&["garbage schema line".to_owned()]);
2241        assert!(
2242            report
2243                .sqlite_messages()
2244                .iter()
2245                .any(|line| line.contains("malformed SQL entry"))
2246        );
2247    }
2248
2249    #[test]
2250    fn test_integrity_check_output_matches_c() {
2251        let level1 = integrity_check_level1_page(&sample_btree_leaf_page(), 1, true, false)
2252            .expect("level1 integrity check should run");
2253        let level2 =
2254            integrity_check_level2_btree(1, PAGE_SIZE, &[(120, 140), (220, 250)], &[1, 2, 3]);
2255        let level3 = integrity_check_level3_overflow_chain(1, &[7, 9, 11], 20);
2256        let level4 = integrity_check_level4_cross_reference(3, &[1, 2, 3]);
2257        let level5 = integrity_check_level5_schema(&["CREATE TABLE t(x INTEGER)".to_owned()]);
2258        let report = merge_integrity_reports(&[level1, level2, level3, level4, level5]);
2259        assert_eq!(report.sqlite_messages(), vec!["ok".to_owned()]);
2260    }
2261
2262    #[test]
2263    fn test_recovery_wal_fec_repair() {
2264        let action = recovery_action_for_checksum_failure(
2265            ChecksumFailureKind::WalFrameChecksumMismatch,
2266            Some(8),
2267            Some(6),
2268        );
2269        assert_eq!(action, RecoveryAction::AttemptWalFecRepair);
2270
2271        let payload = sample_page(11);
2272        let hash = wal_fec_source_hash_xxh3_128(&payload);
2273        let decision = recover_wal_frame_checksum_mismatch(Some(&payload), Some(hash), 8, 6);
2274        assert_eq!(decision, WalRecoveryDecision::Repaired);
2275    }
2276
2277    #[test]
2278    fn test_recovery_wal_fec_insufficient() {
2279        let action = recovery_action_for_checksum_failure(
2280            ChecksumFailureKind::WalFrameChecksumMismatch,
2281            Some(3),
2282            Some(4),
2283        );
2284        assert_eq!(action, RecoveryAction::TruncateWalAtFirstInvalidFrame);
2285
2286        let payload = sample_page(9);
2287        let hash = wal_fec_source_hash_xxh3_128(&payload);
2288        let decision = recover_wal_frame_checksum_mismatch(Some(&payload), Some(hash), 3, 4);
2289        assert_eq!(decision, WalRecoveryDecision::Truncated);
2290    }
2291
2292    #[test]
2293    fn test_recovery_crc32c_exclude() {
2294        let action = recovery_action_for_checksum_failure(
2295            ChecksumFailureKind::Crc32cSymbolMismatch,
2296            Some(0),
2297            Some(0),
2298        );
2299        assert_eq!(action, RecoveryAction::ExcludeCorruptedSymbolAndContinue);
2300    }
2301
2302    #[test]
2303    fn test_recovery_xxh3_evict_retry() {
2304        let action = recovery_action_for_checksum_failure(
2305            ChecksumFailureKind::Xxh3PageChecksumMismatch,
2306            None,
2307            None,
2308        );
2309        assert_eq!(action, RecoveryAction::EvictCacheAndRetryFromWal);
2310    }
2311
2312    #[test]
2313    fn test_recovery_wal_fec_hash_mismatch_truncates() {
2314        let payload = sample_page(3);
2315        let wrong_hash = wal_fec_source_hash_xxh3_128(&sample_page(4));
2316        let decision = recover_wal_frame_checksum_mismatch(Some(&payload), Some(wrong_hash), 8, 6);
2317        assert_eq!(decision, WalRecoveryDecision::Truncated);
2318    }
2319
2320    #[test]
2321    fn test_crash_at_any_point() {
2322        let contract = crash_model_contract();
2323        assert!(contract.crash_at_any_point());
2324        for crash_step in 0..16 {
2325            let before = u64::try_from(crash_step).expect("step should fit");
2326            let after = before.saturating_add(1);
2327            assert!(after >= before);
2328            assert!(contract.fsync_is_durability_barrier());
2329        }
2330    }
2331
2332    #[test]
2333    fn test_torn_write_detection() {
2334        let mut wal_header = [0_u8; WAL_HEADER_SIZE];
2335        wal_header[..4].copy_from_slice(&0x377F_0682_u32.to_be_bytes());
2336        wal_header[4..8].copy_from_slice(&3_007_000_u32.to_be_bytes());
2337        wal_header[8..12].copy_from_slice(
2338            &u32::try_from(PAGE_SIZE)
2339                .expect("PAGE_SIZE should fit in u32")
2340                .to_be_bytes(),
2341        );
2342        let salts = WalSalts {
2343            salt1: 0x1111_2222,
2344            salt2: 0x3333_4444,
2345        };
2346        write_wal_header_salts(&mut wal_header, salts).expect("header salts should write");
2347        write_wal_header_checksum(&mut wal_header, false).expect("header checksum should write");
2348
2349        let mut frame = vec![0_u8; WAL_FRAME_HEADER_SIZE + PAGE_SIZE];
2350        frame[..4].copy_from_slice(&1_u32.to_be_bytes());
2351        frame[4..8].copy_from_slice(&1_u32.to_be_bytes());
2352        write_wal_frame_salts(&mut frame[..WAL_FRAME_HEADER_SIZE], salts)
2353            .expect("frame salts should write");
2354
2355        for (idx, byte) in frame[WAL_FRAME_HEADER_SIZE..].iter_mut().enumerate() {
2356            let reduced = u8::try_from(idx % 251).expect("index modulo fits in u8");
2357            *byte = reduced ^ 0x5A;
2358        }
2359
2360        let seed = read_wal_header_checksum(&wal_header).expect("header checksum should read");
2361        write_wal_frame_checksum(&mut frame, PAGE_SIZE, seed, false)
2362            .expect("frame checksum should write");
2363
2364        let mut wal_bytes = Vec::with_capacity(WAL_HEADER_SIZE + frame.len());
2365        wal_bytes.extend_from_slice(&wal_header);
2366        wal_bytes.extend_from_slice(&frame);
2367        assert!(!detect_torn_write_in_wal(&wal_bytes, PAGE_SIZE, false).expect("validate WAL"));
2368
2369        wal_bytes.truncate(WAL_HEADER_SIZE + WAL_FRAME_HEADER_SIZE + PAGE_SIZE / 2);
2370        assert!(detect_torn_write_in_wal(&wal_bytes, PAGE_SIZE, false).expect("validate torn WAL"));
2371    }
2372
2373    #[test]
2374    fn test_fsync_durability() {
2375        let contract = crash_model_contract();
2376        assert!(contract.crash_at_any_point());
2377        assert!(contract.fsync_is_durability_barrier());
2378        assert!(contract.writes_reorder_without_fsync());
2379        assert!(contract.bitrot_exists());
2380        assert!(contract.metadata_may_require_directory_fsync());
2381        assert!(supports_torn_write_sector_size(512));
2382        assert!(supports_torn_write_sector_size(1024));
2383        assert!(supports_torn_write_sector_size(4096));
2384        assert!(!supports_torn_write_sector_size(2048));
2385    }
2386
2387    #[test]
2388    fn test_e2e_bd_36hc() {
2389        let level1 = integrity_check_level1_page(&sample_btree_leaf_page(), 1, true, false)
2390            .expect("level1 integrity check should run");
2391        let level2 = integrity_check_level2_btree(
2392            1,
2393            PAGE_SIZE,
2394            &[(120, 150), (180, 210), (240, 280)],
2395            &[1, 2, 3],
2396        );
2397        let level3 = integrity_check_level3_overflow_chain(1, &[5, 7, 9], 64);
2398        let level4 = integrity_check_level4_cross_reference(10, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
2399        let level5 = integrity_check_level5_schema(&[
2400            "CREATE TABLE t0(id INTEGER PRIMARY KEY, v TEXT)".to_owned(),
2401            "CREATE INDEX i0 ON t0(v)".to_owned(),
2402        ]);
2403        let merged = merge_integrity_reports(&[level1, level2, level3, level4, level5]);
2404
2405        assert!(merged.is_ok());
2406        assert_eq!(merged.sqlite_messages(), vec!["ok".to_owned()]);
2407
2408        let mut wal_header = [0_u8; WAL_HEADER_SIZE];
2409        wal_header[..4].copy_from_slice(&0x377F_0682_u32.to_be_bytes());
2410        wal_header[4..8].copy_from_slice(&3_007_000_u32.to_be_bytes());
2411        wal_header[8..12].copy_from_slice(
2412            &u32::try_from(PAGE_SIZE)
2413                .expect("PAGE_SIZE should fit in u32")
2414                .to_be_bytes(),
2415        );
2416        let salts = WalSalts {
2417            salt1: 0x0102_0304,
2418            salt2: 0xA0B0_C0D0,
2419        };
2420        write_wal_header_salts(&mut wal_header, salts).expect("header salts should write");
2421        write_wal_header_checksum(&mut wal_header, false).expect("header checksum should write");
2422        let mut running =
2423            read_wal_header_checksum(&wal_header).expect("header checksum should read");
2424
2425        let mut wal_bytes =
2426            Vec::with_capacity(WAL_HEADER_SIZE + 100 * (WAL_FRAME_HEADER_SIZE + PAGE_SIZE));
2427        wal_bytes.extend_from_slice(&wal_header);
2428        for frame_index in 0..100_u32 {
2429            let mut frame = vec![0_u8; WAL_FRAME_HEADER_SIZE + PAGE_SIZE];
2430            frame[..4].copy_from_slice(&(frame_index + 1).to_be_bytes());
2431            frame[4..8].copy_from_slice(&(frame_index + 1).to_be_bytes()); // commit
2432            write_wal_frame_salts(&mut frame[..WAL_FRAME_HEADER_SIZE], salts)
2433                .expect("frame salts should write");
2434            for (offset, byte) in frame[WAL_FRAME_HEADER_SIZE..].iter_mut().enumerate() {
2435                let reduced = u8::try_from(offset % 251).expect("offset modulo must fit");
2436                *byte = reduced ^ u8::try_from(frame_index % 251).expect("frame modulo must fit");
2437            }
2438            running = write_wal_frame_checksum(&mut frame, PAGE_SIZE, running, false)
2439                .expect("frame checksum should write");
2440            wal_bytes.extend_from_slice(&frame);
2441        }
2442
2443        for scenario in 0..100_usize {
2444            let crash_frame = (scenario * 37) % 100;
2445            let torn_cut = WAL_HEADER_SIZE
2446                + crash_frame * (WAL_FRAME_HEADER_SIZE + PAGE_SIZE)
2447                + WAL_FRAME_HEADER_SIZE
2448                + PAGE_SIZE / 3;
2449            let torn = &wal_bytes[..torn_cut];
2450            let validation =
2451                validate_wal_chain(torn, PAGE_SIZE, false).expect("torn chain should parse");
2452            assert_eq!(validation.valid_frames, crash_frame);
2453            assert_eq!(validation.replayable_frames, crash_frame);
2454        }
2455    }
2456
2457    // ── bd-lldk §11.8-11.9 WAL header / frame / checksum tests ─────────
2458
2459    #[test]
2460    fn test_wal_header_magic_le() {
2461        let header = WalHeader {
2462            magic: WAL_MAGIC_LE,
2463            format_version: WAL_FORMAT_VERSION,
2464            page_size: 4096,
2465            checkpoint_seq: 0,
2466            salts: WalSalts {
2467                salt1: 0xAAAA_BBBB,
2468                salt2: 0xCCCC_DDDD,
2469            },
2470            checksum: SqliteWalChecksum::default(),
2471        };
2472        assert!(!header.big_endian_checksum());
2473        let bytes = header.to_bytes().expect("LE header should serialize");
2474        assert_eq!(&bytes[..4], &WAL_MAGIC_LE.to_be_bytes());
2475    }
2476
2477    #[test]
2478    fn test_wal_header_magic_be() {
2479        let header = WalHeader {
2480            magic: WAL_MAGIC_BE,
2481            format_version: WAL_FORMAT_VERSION,
2482            page_size: 4096,
2483            checkpoint_seq: 0,
2484            salts: WalSalts {
2485                salt1: 0x1111_2222,
2486                salt2: 0x3333_4444,
2487            },
2488            checksum: SqliteWalChecksum::default(),
2489        };
2490        assert!(header.big_endian_checksum());
2491        let bytes = header.to_bytes().expect("BE header should serialize");
2492        assert_eq!(&bytes[..4], &WAL_MAGIC_BE.to_be_bytes());
2493    }
2494
2495    #[test]
2496    fn test_wal_header_format_version() {
2497        let header = WalHeader {
2498            magic: WAL_MAGIC_LE,
2499            format_version: WAL_FORMAT_VERSION,
2500            page_size: 4096,
2501            checkpoint_seq: 1,
2502            salts: WalSalts::default(),
2503            checksum: SqliteWalChecksum::default(),
2504        };
2505        let bytes = header.to_bytes().expect("header should serialize");
2506        let parsed = WalHeader::from_bytes(&bytes).expect("header should parse");
2507        assert_eq!(parsed.format_version, 3_007_000);
2508
2509        // Wrong format version must be rejected.
2510        let mut bad_bytes = bytes;
2511        bad_bytes[4..8].copy_from_slice(&999_u32.to_be_bytes());
2512        assert!(WalHeader::from_bytes(&bad_bytes).is_err());
2513    }
2514
2515    #[test]
2516    fn test_wal_header_round_trip() {
2517        let header = WalHeader {
2518            magic: WAL_MAGIC_LE,
2519            format_version: WAL_FORMAT_VERSION,
2520            page_size: 4096,
2521            checkpoint_seq: 42,
2522            salts: WalSalts {
2523                salt1: 0xDEAD_BEEF,
2524                salt2: 0xCAFE_BABE,
2525            },
2526            checksum: SqliteWalChecksum::default(),
2527        };
2528        let bytes = header.to_bytes().expect("header should serialize");
2529        assert_eq!(bytes.len(), WAL_HEADER_SIZE);
2530
2531        let parsed = WalHeader::from_bytes(&bytes).expect("header should parse");
2532        assert_eq!(parsed.magic, WAL_MAGIC_LE);
2533        assert_eq!(parsed.format_version, WAL_FORMAT_VERSION);
2534        assert_eq!(parsed.page_size, 4096);
2535        assert_eq!(parsed.checkpoint_seq, 42);
2536        assert_eq!(parsed.salts.salt1, 0xDEAD_BEEF);
2537        assert_eq!(parsed.salts.salt2, 0xCAFE_BABE);
2538        // Checksum is computed by to_bytes; parsed checksum should be non-zero.
2539        assert!(
2540            parsed.checksum.s1 != 0 || parsed.checksum.s2 != 0,
2541            "computed checksum should be non-trivial"
2542        );
2543    }
2544
2545    #[test]
2546    fn test_wal_frame_header_commit() {
2547        // Commit frame: db_size > 0.
2548        let commit_frame = WalFrameHeader {
2549            page_number: 1,
2550            db_size: 10,
2551            salts: WalSalts {
2552                salt1: 0x1111,
2553                salt2: 0x2222,
2554            },
2555            checksum: SqliteWalChecksum { s1: 100, s2: 200 },
2556        };
2557        assert!(commit_frame.is_commit());
2558
2559        let bytes = commit_frame.to_bytes();
2560        assert_eq!(bytes.len(), WAL_FRAME_HEADER_SIZE);
2561        let parsed = WalFrameHeader::from_bytes(&bytes).expect("frame should parse");
2562        assert!(parsed.is_commit());
2563        assert_eq!(parsed.db_size, 10);
2564
2565        // Non-commit frame: db_size == 0.
2566        let non_commit = WalFrameHeader {
2567            page_number: 2,
2568            db_size: 0,
2569            salts: WalSalts {
2570                salt1: 0x1111,
2571                salt2: 0x2222,
2572            },
2573            checksum: SqliteWalChecksum { s1: 300, s2: 400 },
2574        };
2575        assert!(!non_commit.is_commit());
2576
2577        let bytes2 = non_commit.to_bytes();
2578        let parsed2 = WalFrameHeader::from_bytes(&bytes2).expect("frame should parse");
2579        assert!(!parsed2.is_commit());
2580        assert_eq!(parsed2.db_size, 0);
2581    }
2582
2583    #[test]
2584    fn test_wal_frame_header_salt_match() {
2585        let wal_salts = WalSalts {
2586            salt1: 0xAAAA_BBBB,
2587            salt2: 0xCCCC_DDDD,
2588        };
2589
2590        // Frame with matching salt: accepted.
2591        let good_frame = WalFrameHeader {
2592            page_number: 1,
2593            db_size: 5,
2594            salts: wal_salts,
2595            checksum: SqliteWalChecksum::default(),
2596        };
2597        assert_eq!(good_frame.salts, wal_salts);
2598
2599        // Frame with mismatched salt: rejected.
2600        let bad_salts = WalSalts {
2601            salt1: 0x0000_0000,
2602            salt2: 0x0000_0000,
2603        };
2604        let bad_frame = WalFrameHeader {
2605            page_number: 1,
2606            db_size: 5,
2607            salts: bad_salts,
2608            checksum: SqliteWalChecksum::default(),
2609        };
2610        assert_ne!(
2611            bad_frame.salts, wal_salts,
2612            "mismatched salt must be detected"
2613        );
2614    }
2615
2616    #[test]
2617    fn test_wal_checksum_chain_integrity() {
2618        // Build a multi-frame WAL and verify the cumulative checksum chain.
2619        let salts = WalSalts {
2620            salt1: 0x1234_5678,
2621            salt2: 0x9ABC_DEF0,
2622        };
2623        let mut header_buf = [0_u8; WAL_HEADER_SIZE];
2624        header_buf[..4].copy_from_slice(&WAL_MAGIC_LE.to_be_bytes());
2625        header_buf[4..8].copy_from_slice(&WAL_FORMAT_VERSION.to_be_bytes());
2626        header_buf[8..12].copy_from_slice(
2627            &u32::try_from(PAGE_SIZE)
2628                .expect("page size fits")
2629                .to_be_bytes(),
2630        );
2631        write_wal_header_salts(&mut header_buf, salts).expect("write salts");
2632        write_wal_header_checksum(&mut header_buf, false).expect("write header checksum");
2633
2634        let mut running = read_wal_header_checksum(&header_buf).expect("read header checksum");
2635
2636        let mut wal_bytes = Vec::new();
2637        wal_bytes.extend_from_slice(&header_buf);
2638
2639        // Write 5 frames, each a commit frame.
2640        for frame_idx in 0..5_u32 {
2641            let mut frame = vec![0_u8; WAL_FRAME_HEADER_SIZE + PAGE_SIZE];
2642            frame[..4].copy_from_slice(&(frame_idx + 1).to_be_bytes());
2643            frame[4..8].copy_from_slice(&(frame_idx + 1).to_be_bytes()); // commit
2644            write_wal_frame_salts(&mut frame[..WAL_FRAME_HEADER_SIZE], salts)
2645                .expect("write frame salts");
2646
2647            for (offset, byte) in frame[WAL_FRAME_HEADER_SIZE..].iter_mut().enumerate() {
2648                let r = u8::try_from(offset % 251).unwrap();
2649                let s = u8::try_from(frame_idx % 251).unwrap();
2650                *byte = r ^ s;
2651            }
2652
2653            running = write_wal_frame_checksum(&mut frame, PAGE_SIZE, running, false)
2654                .expect("write frame checksum");
2655            wal_bytes.extend_from_slice(&frame);
2656        }
2657
2658        // Validate the entire chain.
2659        let validation =
2660            validate_wal_chain(&wal_bytes, PAGE_SIZE, false).expect("chain should validate");
2661        assert!(validation.valid, "chain must be fully valid");
2662        assert_eq!(validation.valid_frames, 5);
2663        assert_eq!(validation.replayable_frames, 5);
2664        assert!(validation.reason.is_none());
2665
2666        // Corrupt one byte in frame 3's page data; chain must break at frame 3.
2667        let frame3_page_offset =
2668            WAL_HEADER_SIZE + 2 * (WAL_FRAME_HEADER_SIZE + PAGE_SIZE) + WAL_FRAME_HEADER_SIZE + 10;
2669        wal_bytes[frame3_page_offset] ^= 0xFF;
2670        let bad_validation =
2671            validate_wal_chain(&wal_bytes, PAGE_SIZE, false).expect("corrupt chain should parse");
2672        assert!(!bad_validation.valid);
2673        assert_eq!(bad_validation.valid_frames, 2, "frames 1-2 should be valid");
2674        assert_eq!(
2675            bad_validation.reason,
2676            Some(WalChainInvalidReason::FrameChecksumMismatch)
2677        );
2678    }
2679
2680    // ── bd-xfn30.2: Corruption classification & repair-decision tests ──
2681
2682    /// Build a valid WAL byte stream with `n` frames (all commits).
2683    fn build_valid_wal(n: usize) -> Vec<u8> {
2684        let salts = WalSalts {
2685            salt1: 0xAAAA_BBBB,
2686            salt2: 0xCCCC_DDDD,
2687        };
2688        let mut header_buf = [0u8; WAL_HEADER_SIZE];
2689        header_buf[..4].copy_from_slice(&WAL_MAGIC_LE.to_be_bytes());
2690        header_buf[4..8].copy_from_slice(&WAL_FORMAT_VERSION.to_be_bytes());
2691        header_buf[8..12].copy_from_slice(
2692            &u32::try_from(PAGE_SIZE)
2693                .expect("page size fits")
2694                .to_be_bytes(),
2695        );
2696        write_wal_header_salts(&mut header_buf, salts).expect("write salts");
2697        write_wal_header_checksum(&mut header_buf, false).expect("write hdr cksum");
2698
2699        let frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
2700        let mut wal = Vec::with_capacity(WAL_HEADER_SIZE + n * frame_size);
2701        wal.extend_from_slice(&header_buf);
2702
2703        let mut running = read_wal_header_checksum(&header_buf).expect("read hdr cksum");
2704        for i in 0..n {
2705            let pg = u32::try_from(i + 1).unwrap();
2706            let mut frame = vec![0u8; frame_size];
2707            frame[..4].copy_from_slice(&pg.to_be_bytes());
2708            frame[4..8].copy_from_slice(&pg.to_be_bytes()); // commit
2709            write_wal_frame_salts(&mut frame[..WAL_FRAME_HEADER_SIZE], salts).expect("frame salts");
2710            for (off, byte) in frame[WAL_FRAME_HEADER_SIZE..].iter_mut().enumerate() {
2711                let r = u8::try_from(off % 251).unwrap();
2712                let s = u8::try_from(i % 251).unwrap();
2713                *byte = r ^ s;
2714            }
2715            running =
2716                write_wal_frame_checksum(&mut frame, PAGE_SIZE, running, false).expect("cksum");
2717            wal.extend_from_slice(&frame);
2718        }
2719        wal
2720    }
2721
2722    #[test]
2723    fn test_classify_clean_wal_valid() {
2724        let wal = build_valid_wal(10);
2725        let v = validate_wal_chain(&wal, PAGE_SIZE, false).expect("validate");
2726        assert!(v.valid);
2727        assert_eq!(v.valid_frames, 10);
2728        assert_eq!(v.replayable_frames, 10);
2729        assert!(v.reason.is_none());
2730        assert!(v.header_valid);
2731    }
2732
2733    #[test]
2734    fn test_classify_header_corruption() {
2735        let mut wal = build_valid_wal(5);
2736        // Corrupt header magic.
2737        wal[0] ^= 0xFF;
2738        let v = validate_wal_chain(&wal, PAGE_SIZE, false);
2739        // Header corruption should error or report HeaderChecksumMismatch.
2740        if let Ok(val) = v {
2741            assert!(!val.header_valid);
2742            assert_eq!(
2743                val.reason,
2744                Some(WalChainInvalidReason::HeaderChecksumMismatch)
2745            );
2746        } // Also acceptable: outright error
2747    }
2748
2749    #[test]
2750    fn test_classify_single_bit_flip_in_frame_data() {
2751        let mut wal = build_valid_wal(5);
2752        let frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
2753        // Flip one bit in frame 3's page data.
2754        let offset = WAL_HEADER_SIZE + 2 * frame_size + WAL_FRAME_HEADER_SIZE + 100;
2755        wal[offset] ^= 0x01;
2756        let v = validate_wal_chain(&wal, PAGE_SIZE, false).expect("validate");
2757        assert!(!v.valid);
2758        assert_eq!(v.valid_frames, 2, "first 2 frames should survive");
2759        assert_eq!(v.first_invalid_frame, Some(2));
2760        assert_eq!(v.reason, Some(WalChainInvalidReason::FrameChecksumMismatch));
2761    }
2762
2763    #[test]
2764    fn test_classify_torn_write_mid_frame() {
2765        let wal = build_valid_wal(5);
2766        let frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
2767        // Truncate in the middle of frame 4 (index 3).
2768        let cut = WAL_HEADER_SIZE + 3 * frame_size + frame_size / 2;
2769        let torn = &wal[..cut];
2770        let v = validate_wal_chain(torn, PAGE_SIZE, false).expect("validate");
2771        assert_eq!(
2772            v.valid_frames, 3,
2773            "only 3 complete frames before truncation"
2774        );
2775        assert_eq!(v.reason, Some(WalChainInvalidReason::TruncatedFrame));
2776    }
2777
2778    #[test]
2779    fn test_classify_torn_write_in_header() {
2780        let wal = build_valid_wal(3);
2781        // Truncate to partial header.
2782        let torn = &wal[..16];
2783        let v = validate_wal_chain(torn, PAGE_SIZE, false);
2784        // Should error or report header issue.
2785        assert!(v.is_err() || !v.unwrap().header_valid);
2786    }
2787
2788    #[test]
2789    fn test_classify_salt_mismatch_in_frame() {
2790        let mut wal = build_valid_wal(5);
2791        let frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
2792        // Corrupt salt1 in frame 2's header (bytes 8..12).
2793        let salt_offset = WAL_HEADER_SIZE + frame_size + 8;
2794        wal[salt_offset] ^= 0xFF;
2795        let v = validate_wal_chain(&wal, PAGE_SIZE, false).expect("validate");
2796        // Chain should break at frame 2 due to salt or checksum mismatch.
2797        assert!(v.valid_frames <= 1, "at most frame 0 should survive");
2798    }
2799
2800    #[test]
2801    fn test_classify_zero_fill_corruption() {
2802        let mut wal = build_valid_wal(5);
2803        // Zero-fill frame 1's data (simulating media erasure).
2804        let start = WAL_HEADER_SIZE + WAL_FRAME_HEADER_SIZE;
2805        for byte in &mut wal[start..start + PAGE_SIZE] {
2806            *byte = 0;
2807        }
2808        let v = validate_wal_chain(&wal, PAGE_SIZE, false).expect("validate");
2809        assert_eq!(v.valid_frames, 0, "frame 0 corrupted so 0 valid frames");
2810        assert_eq!(v.reason, Some(WalChainInvalidReason::FrameChecksumMismatch));
2811    }
2812
2813    #[test]
2814    fn test_classify_corruption_at_first_frame() {
2815        let mut wal = build_valid_wal(3);
2816        // Corrupt very first frame's page data byte 0.
2817        let offset = WAL_HEADER_SIZE + WAL_FRAME_HEADER_SIZE;
2818        wal[offset] ^= 0xAA;
2819        let v = validate_wal_chain(&wal, PAGE_SIZE, false).expect("validate");
2820        assert_eq!(v.valid_frames, 0);
2821        assert_eq!(v.first_invalid_frame, Some(0));
2822    }
2823
2824    #[test]
2825    fn test_classify_corruption_at_last_frame() {
2826        let mut wal = build_valid_wal(5);
2827        let frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
2828        // Corrupt the last frame.
2829        let offset = WAL_HEADER_SIZE + 4 * frame_size + WAL_FRAME_HEADER_SIZE + 50;
2830        wal[offset] ^= 0xBB;
2831        let v = validate_wal_chain(&wal, PAGE_SIZE, false).expect("validate");
2832        assert_eq!(v.valid_frames, 4, "first 4 should survive");
2833        assert_eq!(v.first_invalid_frame, Some(4));
2834    }
2835
2836    #[test]
2837    fn test_detect_torn_write_true_on_truncation() {
2838        let wal = build_valid_wal(5);
2839        let frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
2840        let cut = WAL_HEADER_SIZE + 2 * frame_size + 10;
2841        let torn = &wal[..cut];
2842        assert!(detect_torn_write_in_wal(torn, PAGE_SIZE, false).expect("detect"));
2843    }
2844
2845    #[test]
2846    fn test_detect_torn_write_false_on_clean() {
2847        let wal = build_valid_wal(5);
2848        assert!(!detect_torn_write_in_wal(&wal, PAGE_SIZE, false).expect("detect"));
2849    }
2850
2851    #[test]
2852    fn test_detect_torn_write_true_on_bit_flip() {
2853        let mut wal = build_valid_wal(3);
2854        let offset = WAL_HEADER_SIZE + WAL_FRAME_HEADER_SIZE + 200;
2855        wal[offset] ^= 0x01;
2856        assert!(detect_torn_write_in_wal(&wal, PAGE_SIZE, false).expect("detect"));
2857    }
2858
2859    // ── Repair-decision edge cases ──
2860
2861    #[test]
2862    fn test_repair_decision_exact_boundary_symbols() {
2863        // Exactly enough symbols: should attempt repair.
2864        let action = recovery_action_for_checksum_failure(
2865            ChecksumFailureKind::WalFrameChecksumMismatch,
2866            Some(6),
2867            Some(6),
2868        );
2869        assert_eq!(action, RecoveryAction::AttemptWalFecRepair);
2870    }
2871
2872    #[test]
2873    fn test_repair_decision_one_short() {
2874        // One symbol short: must truncate.
2875        let action = recovery_action_for_checksum_failure(
2876            ChecksumFailureKind::WalFrameChecksumMismatch,
2877            Some(5),
2878            Some(6),
2879        );
2880        assert_eq!(action, RecoveryAction::TruncateWalAtFirstInvalidFrame);
2881    }
2882
2883    #[test]
2884    fn test_repair_decision_no_symbol_info() {
2885        // No symbol info at all: must truncate.
2886        let action = recovery_action_for_checksum_failure(
2887            ChecksumFailureKind::WalFrameChecksumMismatch,
2888            None,
2889            None,
2890        );
2891        assert_eq!(action, RecoveryAction::TruncateWalAtFirstInvalidFrame);
2892    }
2893
2894    #[test]
2895    fn test_repair_decision_partial_symbol_info() {
2896        // Only one side of symbol info available.
2897        let action = recovery_action_for_checksum_failure(
2898            ChecksumFailureKind::WalFrameChecksumMismatch,
2899            Some(10),
2900            None,
2901        );
2902        assert_eq!(action, RecoveryAction::TruncateWalAtFirstInvalidFrame);
2903    }
2904
2905    #[test]
2906    fn test_repair_decision_db_corruption_always_report() {
2907        let action = recovery_action_for_checksum_failure(
2908            ChecksumFailureKind::DbFileCorruption,
2909            Some(100),
2910            Some(1),
2911        );
2912        assert_eq!(action, RecoveryAction::ReportPersistentCorruption);
2913    }
2914
2915    #[test]
2916    fn test_attempt_fec_repair_insufficient_symbols() {
2917        let payload = sample_page(1);
2918        let hash = wal_fec_source_hash_xxh3_128(&payload);
2919        let result = attempt_wal_fec_repair(&payload, hash, 3, 6);
2920        assert_eq!(result, WalFecRepairOutcome::InsufficientSymbols);
2921    }
2922
2923    #[test]
2924    fn test_attempt_fec_repair_correct_hash() {
2925        let payload = sample_page(42);
2926        let hash = wal_fec_source_hash_xxh3_128(&payload);
2927        let result = attempt_wal_fec_repair(&payload, hash, 8, 6);
2928        assert_eq!(result, WalFecRepairOutcome::Repaired);
2929    }
2930
2931    #[test]
2932    fn test_attempt_fec_repair_wrong_hash() {
2933        let payload = sample_page(42);
2934        let wrong_hash = wal_fec_source_hash_xxh3_128(&sample_page(99));
2935        let result = attempt_wal_fec_repair(&payload, wrong_hash, 8, 6);
2936        assert_eq!(result, WalFecRepairOutcome::SourceHashMismatch);
2937    }
2938
2939    #[test]
2940    fn test_recover_decision_no_payload_truncates() {
2941        let decision = recover_wal_frame_checksum_mismatch(None, None, 10, 6);
2942        assert_eq!(decision, WalRecoveryDecision::Truncated);
2943    }
2944
2945    #[test]
2946    fn test_recover_decision_payload_but_no_hash_truncates() {
2947        let payload = sample_page(1);
2948        let decision = recover_wal_frame_checksum_mismatch(Some(&payload), None, 10, 6);
2949        assert_eq!(decision, WalRecoveryDecision::Truncated);
2950    }
2951
2952    #[test]
2953    fn test_recover_decision_full_repair_path() {
2954        let payload = sample_page(7);
2955        let hash = wal_fec_source_hash_xxh3_128(&payload);
2956        let decision = recover_wal_frame_checksum_mismatch(Some(&payload), Some(hash), 8, 6);
2957        assert_eq!(decision, WalRecoveryDecision::Repaired);
2958    }
2959
2960    #[test]
2961    fn test_all_failure_kinds_have_deterministic_action() {
2962        // Exhaustive check: every ChecksumFailureKind produces a valid action.
2963        let kinds = [
2964            ChecksumFailureKind::WalFrameChecksumMismatch,
2965            ChecksumFailureKind::Xxh3PageChecksumMismatch,
2966            ChecksumFailureKind::Crc32cSymbolMismatch,
2967            ChecksumFailureKind::DbFileCorruption,
2968        ];
2969        for kind in kinds {
2970            let action = recovery_action_for_checksum_failure(kind, Some(10), Some(5));
2971            // Must be one of the known variants.
2972            assert!(matches!(
2973                action,
2974                RecoveryAction::AttemptWalFecRepair
2975                    | RecoveryAction::TruncateWalAtFirstInvalidFrame
2976                    | RecoveryAction::EvictCacheAndRetryFromWal
2977                    | RecoveryAction::ExcludeCorruptedSymbolAndContinue
2978                    | RecoveryAction::ReportPersistentCorruption
2979            ));
2980        }
2981    }
2982
2983    #[test]
2984    fn test_multi_corruption_sites_first_wins() {
2985        // When multiple frames are corrupt, only the first is detected.
2986        let mut wal = build_valid_wal(10);
2987        let frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
2988        // Corrupt frames 3 and 7.
2989        let off3 = WAL_HEADER_SIZE + 2 * frame_size + WAL_FRAME_HEADER_SIZE + 10;
2990        let off7 = WAL_HEADER_SIZE + 6 * frame_size + WAL_FRAME_HEADER_SIZE + 10;
2991        wal[off3] ^= 0xCC;
2992        wal[off7] ^= 0xDD;
2993        let v = validate_wal_chain(&wal, PAGE_SIZE, false).expect("validate");
2994        assert_eq!(v.valid_frames, 2, "stops at first corruption (frame 3)");
2995        assert_eq!(v.first_invalid_frame, Some(2));
2996    }
2997
2998    #[test]
2999    fn test_crash_model_contract_flags_exhaustive() {
3000        let contract = crash_model_contract();
3001        assert!(contract.crash_at_any_point());
3002        assert!(contract.fsync_is_durability_barrier());
3003        assert!(contract.writes_reorder_without_fsync());
3004        assert!(contract.bitrot_exists());
3005        assert!(contract.metadata_may_require_directory_fsync());
3006    }
3007
3008    #[test]
3009    fn test_replayable_frames_stop_at_last_commit() {
3010        // Build WAL where frames 1-3 are commits, frames 4-5 are non-commit.
3011        // Technically all 5 pass checksum chain but only 3 are "replayable"
3012        // (up to last commit in the valid prefix).
3013        let salts = WalSalts {
3014            salt1: 0x1111_2222,
3015            salt2: 0x3333_4444,
3016        };
3017        let mut hdr = [0u8; WAL_HEADER_SIZE];
3018        hdr[..4].copy_from_slice(&WAL_MAGIC_LE.to_be_bytes());
3019        hdr[4..8].copy_from_slice(&WAL_FORMAT_VERSION.to_be_bytes());
3020        hdr[8..12].copy_from_slice(&u32::try_from(PAGE_SIZE).unwrap().to_be_bytes());
3021        write_wal_header_salts(&mut hdr, salts).expect("salts");
3022        write_wal_header_checksum(&mut hdr, false).expect("hdr cksum");
3023
3024        let frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
3025        let mut wal = Vec::with_capacity(WAL_HEADER_SIZE + 5 * frame_size);
3026        wal.extend_from_slice(&hdr);
3027        let mut running = read_wal_header_checksum(&hdr).expect("seed");
3028
3029        for i in 0..5u32 {
3030            let mut frame = vec![0u8; frame_size];
3031            frame[..4].copy_from_slice(&(i + 1).to_be_bytes());
3032            // Commit on frames 1-3 (indices 0-2), non-commit on 4-5 (indices 3-4).
3033            let db_size = if i < 3 { i + 1 } else { 0 };
3034            frame[4..8].copy_from_slice(&db_size.to_be_bytes());
3035            write_wal_frame_salts(&mut frame[..WAL_FRAME_HEADER_SIZE], salts).expect("salts");
3036            for (off, byte) in frame[WAL_FRAME_HEADER_SIZE..].iter_mut().enumerate() {
3037                *byte = u8::try_from((off + usize::try_from(i).unwrap()) % 251).unwrap();
3038            }
3039            running =
3040                write_wal_frame_checksum(&mut frame, PAGE_SIZE, running, false).expect("cksum");
3041            wal.extend_from_slice(&frame);
3042        }
3043
3044        let v = validate_wal_chain(&wal, PAGE_SIZE, false).expect("validate");
3045        assert!(v.valid, "all 5 pass checksum");
3046        assert_eq!(v.valid_frames, 5);
3047        // Replayable should be 3 (last commit is at index 2).
3048        assert_eq!(v.replayable_frames, 3);
3049        assert_eq!(v.last_commit_frame, Some(2));
3050    }
3051
3052    #[test]
3053    fn test_tier_for_algorithm_known_and_unknown() {
3054        assert_eq!(tier_for_algorithm("xxh3_128"), Some(HashTier::Integrity));
3055        assert_eq!(tier_for_algorithm("xxh3"), Some(HashTier::Integrity));
3056        assert_eq!(
3057            tier_for_algorithm("blake3_128"),
3058            Some(HashTier::ContentAddressing)
3059        );
3060        assert_eq!(
3061            tier_for_algorithm("blake3"),
3062            Some(HashTier::ContentAddressing)
3063        );
3064        assert_eq!(tier_for_algorithm("crc32c"), Some(HashTier::Protocol));
3065        assert_eq!(tier_for_algorithm("  XXH3  "), Some(HashTier::Integrity));
3066        assert_eq!(
3067            tier_for_algorithm("BLAKE3_128"),
3068            Some(HashTier::ContentAddressing)
3069        );
3070        assert_eq!(tier_for_algorithm("sha256"), None);
3071        assert_eq!(tier_for_algorithm(""), None);
3072    }
3073
3074    #[test]
3075    fn test_checksum_transform_identity_is_noop() {
3076        let id = WalChecksumTransform::identity();
3077        for seed in [
3078            SqliteWalChecksum { s1: 0, s2: 0 },
3079            SqliteWalChecksum {
3080                s1: 0xDEAD_BEEF,
3081                s2: 0xCAFE_BABE,
3082            },
3083            SqliteWalChecksum {
3084                s1: u32::MAX,
3085                s2: u32::MAX,
3086            },
3087        ] {
3088            assert_eq!(id.apply(seed), seed, "identity must leave seed unchanged");
3089        }
3090    }
3091
3092    #[test]
3093    fn test_checksum_transform_then_identity_laws() {
3094        let data = vec![0x42_u8; 64];
3095        let t = WalChecksumTransform::from_aligned_bytes(&data, false).expect("transform");
3096        let id = WalChecksumTransform::identity();
3097        let seed = SqliteWalChecksum {
3098            s1: 0x1111_2222,
3099            s2: 0x3333_4444,
3100        };
3101        assert_eq!(id.then(t).apply(seed), t.apply(seed), "id.then(t) == t");
3102        assert_eq!(t.then(id).apply(seed), t.apply(seed), "t.then(id) == t");
3103    }
3104
3105    #[test]
3106    fn test_xxh3_checksum128_to_le_bytes_roundtrip() {
3107        let data = b"deterministic test payload";
3108        let digest = Xxh3Checksum128::compute(data);
3109        let le = digest.to_le_bytes();
3110        let reconstructed = read_xxh3_from_bytes(&le);
3111        assert_eq!(digest, reconstructed);
3112    }
3113
3114    #[test]
3115    fn test_crc32c_and_content_address_determinism() {
3116        let data = b"hello world";
3117        let c1 = crc32c_checksum(data);
3118        let c2 = crc32c_checksum(data);
3119        assert_eq!(c1, c2, "crc32c must be deterministic");
3120        assert_ne!(crc32c_checksum(data), crc32c_checksum(b"hello worlD"));
3121
3122        let h1 = content_address_hash_128(data);
3123        let h2 = content_address_hash_128(data);
3124        assert_eq!(h1, h2, "content_address_hash must be deterministic");
3125        assert_ne!(
3126            content_address_hash_128(data),
3127            content_address_hash_128(b"different")
3128        );
3129
3130        let i1 = integrity_hash_xxh3_128(data);
3131        let i2 = integrity_hash_xxh3_128(data);
3132        assert_eq!(i1, i2, "integrity_hash must be deterministic");
3133    }
3134
3135    #[test]
3136    fn integrity_check_report_ok_and_sqlite_messages() {
3137        let report = IntegrityCheckReport::ok(10);
3138        assert!(report.is_ok());
3139        assert_eq!(report.pages_checked, 10);
3140        assert_eq!(report.sqlite_messages(), vec!["ok"]);
3141    }
3142
3143    #[test]
3144    fn integrity_check_report_with_issues() {
3145        let mut report = IntegrityCheckReport::ok(5);
3146        report.push(IntegrityCheckLevel::Page, Some(3), "bad page");
3147        report.push(IntegrityCheckLevel::Schema, None, "schema err");
3148        assert!(!report.is_ok());
3149        assert_eq!(report.issues.len(), 2);
3150        let msgs = report.sqlite_messages();
3151        assert_eq!(msgs, vec!["bad page", "schema err"]);
3152        assert_eq!(report.issues[0].level, IntegrityCheckLevel::Page);
3153        assert_eq!(report.issues[1].page_number, None);
3154    }
3155
3156    #[test]
3157    fn checksum_failure_kind_all_variants_copy_eq() {
3158        let variants = [
3159            ChecksumFailureKind::WalFrameChecksumMismatch,
3160            ChecksumFailureKind::Xxh3PageChecksumMismatch,
3161            ChecksumFailureKind::Crc32cSymbolMismatch,
3162            ChecksumFailureKind::DbFileCorruption,
3163        ];
3164        for (i, v) in variants.iter().enumerate() {
3165            let copied = *v;
3166            assert_eq!(copied, *v);
3167            for (j, w) in variants.iter().enumerate() {
3168                assert_eq!(i == j, v == w);
3169            }
3170        }
3171        let dbg = format!("{:?}", ChecksumFailureKind::Crc32cSymbolMismatch);
3172        assert!(dbg.contains("Crc32cSymbolMismatch"));
3173    }
3174
3175    #[test]
3176    fn integrity_check_level_all_variants_copy_eq() {
3177        let variants = [
3178            IntegrityCheckLevel::Page,
3179            IntegrityCheckLevel::BtreeStructural,
3180            IntegrityCheckLevel::RecordFormat,
3181            IntegrityCheckLevel::CrossReference,
3182            IntegrityCheckLevel::Schema,
3183        ];
3184        for (i, v) in variants.iter().enumerate() {
3185            let copied = *v;
3186            assert_eq!(copied, *v);
3187            for (j, w) in variants.iter().enumerate() {
3188                assert_eq!(i == j, v == w);
3189            }
3190        }
3191        let dbg = format!("{:?}", IntegrityCheckLevel::BtreeStructural);
3192        assert!(dbg.contains("BtreeStructural"));
3193    }
3194
3195    #[test]
3196    fn recovery_action_all_variants_copy_eq_debug() {
3197        let variants = [
3198            RecoveryAction::AttemptWalFecRepair,
3199            RecoveryAction::TruncateWalAtFirstInvalidFrame,
3200            RecoveryAction::EvictCacheAndRetryFromWal,
3201            RecoveryAction::ExcludeCorruptedSymbolAndContinue,
3202            RecoveryAction::ReportPersistentCorruption,
3203        ];
3204        for (i, v) in variants.iter().enumerate() {
3205            let copied = *v;
3206            assert_eq!(copied, *v);
3207            for (j, w) in variants.iter().enumerate() {
3208                assert_eq!(i == j, v == w);
3209            }
3210        }
3211        let dbg = format!("{:?}", RecoveryAction::AttemptWalFecRepair);
3212        assert!(dbg.contains("AttemptWalFecRepair"));
3213    }
3214
3215    #[test]
3216    fn wal_recovery_decision_and_fec_repair_outcome_copy_eq() {
3217        let d1 = WalRecoveryDecision::Repaired;
3218        let d2 = WalRecoveryDecision::Truncated;
3219        assert_eq!(d1, d1);
3220        assert_ne!(d1, d2);
3221        let copied = d1;
3222        assert_eq!(copied, WalRecoveryDecision::Repaired);
3223        assert!(format!("{d2:?}").contains("Truncated"));
3224
3225        let outcomes = [
3226            WalFecRepairOutcome::Repaired,
3227            WalFecRepairOutcome::InsufficientSymbols,
3228            WalFecRepairOutcome::SourceHashMismatch,
3229        ];
3230        for (i, o) in outcomes.iter().enumerate() {
3231            let copied = *o;
3232            assert_eq!(copied, *o);
3233            for (j, p) in outcomes.iter().enumerate() {
3234                assert_eq!(i == j, o == p);
3235            }
3236        }
3237        assert!(
3238            format!("{:?}", WalFecRepairOutcome::SourceHashMismatch).contains("SourceHashMismatch")
3239        );
3240    }
3241
3242    #[test]
3243    fn crash_model_contract_default_all_flags_set_and_accessors() {
3244        let c = CrashModelContract::default();
3245        assert!(c.crash_at_any_point());
3246        assert!(c.fsync_is_durability_barrier());
3247        assert!(c.writes_reorder_without_fsync());
3248        assert!(c.bitrot_exists());
3249        assert!(c.metadata_may_require_directory_fsync());
3250        assert_eq!(c, crash_model_contract());
3251
3252        let empty = CrashModelContract { flags: 0 };
3253        assert!(!empty.crash_at_any_point());
3254        assert!(!empty.fsync_is_durability_barrier());
3255        assert!(!empty.writes_reorder_without_fsync());
3256        assert!(!empty.bitrot_exists());
3257        assert!(!empty.metadata_may_require_directory_fsync());
3258        assert_ne!(c, empty);
3259        assert!(format!("{c:?}").contains("CrashModelContract"));
3260    }
3261
3262    #[test]
3263    fn sector_sizes_and_btree_page_types_constants() {
3264        assert!(supports_torn_write_sector_size(512));
3265        assert!(supports_torn_write_sector_size(1024));
3266        assert!(supports_torn_write_sector_size(4096));
3267        assert!(!supports_torn_write_sector_size(2048));
3268        assert!(!supports_torn_write_sector_size(0));
3269
3270        assert!(is_valid_btree_page_type(0x02));
3271        assert!(is_valid_btree_page_type(0x05));
3272        assert!(is_valid_btree_page_type(0x0A));
3273        assert!(is_valid_btree_page_type(0x0D));
3274        assert!(!is_valid_btree_page_type(0x00));
3275        assert!(!is_valid_btree_page_type(0xFF));
3276    }
3277}