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