Skip to main content

chisel/superblock/
mod.rs

1// superblock.rs — Foundation layer (layer 1). Superblock layout, (de)serialization,
2// and the dual-superblock selection rule that makes commits atomic.
3//
4// Shadow-paging commit protocol (see transaction.rs for the orchestration):
5//   1. Write all new/COW'd data pages; fsync the data file.
6//   2. Write the new superblock to slot `new_txn_counter % superblock_count`.
7//      With the default N=2 this alternates slot 0/1; with N>2 (ISSUES.md
8//      R4) it rotates through all N slots. The "previous" slot is always
9//      left untouched, so a crash between (1) and (3) cannot destroy the
10//      last-known-good superblock.
11//   3. fsync again.
12// If we crash between (1) and (2), the old superblock is still current and
13// the new data pages are orphaned garbage. They are NOT actively cleaned up
14// on next mount — they remain in the file as dead weight. Subsequent
15// allocations overwrite them because `open_existing` reseeds next_page_id
16// from the authoritative superblock's total_pages (see ISSUES.md I4), so
17// the garbage tail gets reclaimed the next time the file grows through
18// that range. In the meantime they are harmless (unreferenced from any
19// live root). A clean rollback via `rollback()` DOES truncate the file
20// immediately (ISSUES.md I3); the "leave it for later" path only applies
21// to actual crashes.
22// If we crash mid-write of the new superblock, its checksum will fail and
23// `select()` will fall back to the previous one. Either way, exactly one
24// consistent snapshot is recoverable — no WAL replay needed.
25//
26// Invariant: the superblock slots occupy fixed page ids 0..N (where N is
27// the configurable `superblock_count` — see ISSUES.md R4). Which one is
28// "current" is determined solely by txn_counter + checksum validity,
29// never by position. N=2 is the default (matches the original v1 layout);
30// higher N trades disk space for resilience against consecutive torn
31// writes. N is stored inside each superblock so open-time recovery can
32// discover it from the first valid slot.
33
34use crate::crypto::{CryptoError, NONCE_LEN, TAG_LEN};
35use crate::page::{self, MAGIC, PAGE_SIZE};
36use std::fmt;
37
38mod crypto_header;
39// Re-export the crypto-header API for consumers (open/create code in later
40// phases, key-management tools, tests). Items not yet referenced in non-test
41// module code are still public API surface — the allow is intentional.
42#[allow(unused_imports)]
43pub use crypto_header::{
44    CryptoHeader, KeySlot, ALGO_XCHACHA20POLY1305, CRYPTO_HEADER_OFFSET, CRYPTO_HEADER_SIZE,
45    KEY_SLOT_COUNT, KEY_SLOT_SIZE,
46};
47
48// Superblock count bounds (ISSUES.md R4). Hardcoded limits keep the
49// probe-at-open-time cost bounded and prevent obviously-broken configs.
50// N=1 is disqualified because it provides no redundancy — a single torn
51// write would brick the database. N > 16 is refused because any realistic
52// workload's sweet spot is N=2-4; beyond that the disk-space cost grows
53// without providing additional resilience worth the complexity.
54pub const MIN_SUPERBLOCKS: u32 = 2;
55pub const MAX_SUPERBLOCKS: u32 = 16;
56pub const DEFAULT_SUPERBLOCK_COUNT: u32 = 2;
57
58// Byte offset of the superblock_count field within the serialized
59// superblock page. Placed AFTER the named-root table (which ends at
60// NAMED_ROOTS_END = 308). Deserialization rejects any value outside
61// MIN_SUPERBLOCKS..=MAX_SUPERBLOCKS — a slot with an out-of-range
62// count is treated like a failed checksum: discarded by `select()`,
63// letting the sibling slot take over. A bogus count can't be allowed
64// to reach commit because it's used as the slot-selection modulus
65// (txn_counter % superblock_count); an out-of-range value would
66// direct superblock writes into the data region.
67const SUPERBLOCK_COUNT_OFFSET: usize = 308;
68
69// Membership-index root (chunk-tags). 8 bytes at 312..320, the first
70// free reserved bytes after superblock_count. PAGE_ID_NONE when no
71// tagged chunk exists (field was added as part of the chunk-tags
72// feature; bytes were previously zeroed reserved space).
73const ROOT_MEMBERSHIP_INDEX_OFFSET: usize = 312;
74
75// Depth of the multi-page radix freemap tree. 4 bytes at 320..324.
76// 0 means the single-page freemap (pre-multi-page-freemap files, or
77// a new database that has not yet grown past one leaf's capacity).
78// Old files have these bytes zeroed; depth 0 is backward-compatible
79// because the existing single-page path is the depth-0 case.
80const FREEMAP_DEPTH_OFFSET: usize = 320;
81
82// Named-root table (ISSUES.md F2). A small fixed-width table lives inside
83// the superblock itself so that named roots get the same atomic-commit
84// semantics as the handle-table root for free. Client use case (from the
85// F2 design discussion): replace the "handle 0 is always the meta B-tree
86// root" convention with an explicit name → handle mapping. Typical
87// database needs one or two named roots; eight is deliberate overkill.
88//
89// Layout per entry: 24 bytes of UTF-8 name (null-padded, no terminator
90// required; a name whose first byte is 0 is an UNUSED slot) followed by a
91// u64 handle value = 32 bytes. 8 entries × 32 bytes = 256 bytes total.
92// Placed immediately after the existing fields (starting at byte 52) so
93// pre-v2 serialized layouts don't need to move.
94pub const NAMED_ROOT_COUNT: usize = 8;
95pub const NAMED_ROOT_NAME_LEN: usize = 24;
96const NAMED_ROOT_ENTRY_SIZE: usize = NAMED_ROOT_NAME_LEN + 8;
97const NAMED_ROOTS_OFFSET: usize = 52;
98// End offset of the named-root table. Documented as a const for the
99// reader (future fields must start at or after this byte); not referenced
100// directly from code since the serialize/deserialize loops compute their
101// own offsets from NAMED_ROOTS_OFFSET and the entry stride.
102#[allow(dead_code)]
103const NAMED_ROOTS_END: usize = NAMED_ROOTS_OFFSET + NAMED_ROOT_COUNT * NAMED_ROOT_ENTRY_SIZE;
104
105/// A single entry in the superblock's named-root table.
106///
107/// `name` is a fixed 24-byte buffer holding UTF-8 bytes padded with NULs.
108/// An entry with `name[0] == 0` is considered unused — this is the
109/// convention that makes `new_empty()` (which zero-initializes the array)
110/// produce a correctly-empty table without any extra bookkeeping.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct NamedRoot {
113    pub name: [u8; NAMED_ROOT_NAME_LEN],
114    pub handle: u64,
115}
116
117impl NamedRoot {
118    pub const EMPTY: NamedRoot = NamedRoot {
119        name: [0u8; NAMED_ROOT_NAME_LEN],
120        handle: 0,
121    };
122
123    /// Returns true if this slot is unused (name[0] is zero).
124    pub fn is_empty(&self) -> bool {
125        self.name[0] == 0
126    }
127}
128
129/// Why a superblock slot failed validation (I106). These are exactly the three
130/// torn-slot causes `deserialize` checks. `Copy` and small so the failure-path
131/// `Vec<SlotDefect>` is cheap.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133#[non_exhaustive]
134// Variant names intentionally share the `Bad` prefix — they describe the
135// three distinct bad-slot causes and the prefix reads naturally at call
136// sites (SuperblockDefect::BadChecksum, etc.). Suppressed until Task 2
137// wires the hot path through these types and callers appear in non-test code.
138#[allow(clippy::enum_variant_names)]
139pub enum SuperblockDefect {
140    BadChecksum,
141    BadMagic,
142    BadCount(u32), // the out-of-range superblock_count value
143}
144
145impl fmt::Display for SuperblockDefect {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        match self {
148            SuperblockDefect::BadChecksum => write!(f, "bad checksum"),
149            SuperblockDefect::BadMagic => write!(f, "bad magic"),
150            SuperblockDefect::BadCount(n) => write!(f, "bad superblock_count {n}"),
151        }
152    }
153}
154
155/// A defect tagged with the candidate-slot index it was found at (I106).
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct SlotDefect {
158    pub slot: u32,
159    pub defect: SuperblockDefect,
160}
161
162/// In-memory superblock. The on-disk encoding is a fixed little-endian layout:
163/// bytes 0..52 hold the scalar fields, bytes 52..308 hold the named-root
164/// table (8 × 32-byte entries), bytes 308..312 hold `superblock_count`,
165/// bytes 312..320 hold `root_membership_index_page`, and bytes
166/// 320..CHECKSUM_OFFSET are reserved (zero) for forward compatibility.
167/// The last 8 bytes are the checksum.
168///
169/// `format_version` is a packed u32 (upper 16 = MAJOR, lower 16 = MINOR);
170/// see `page.rs`. Within a major version, new fields may be added by
171/// consuming bytes from the reserved region and bumping MINOR. A major
172/// bump is reserved for structural or semantic changes that cannot be
173/// expressed additively (repositioned fields, altered page formats,
174/// new checksum scheme, and so on). Any change — additive or not —
175/// requires updating serialize/deserialize in lockstep.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct Superblock {
178    pub magic: u32,
179    pub format_version: u32,
180    // Monotonically increasing per commit. Also the tiebreaker that selects
181    // which of the two superblock slots is current.
182    pub txn_counter: u64,
183    // PAGE_ID_NONE until the first value is inserted (no handle table yet).
184    pub root_handle_table_page: u64,
185    // PAGE_ID_NONE if the freemap has not been materialized yet.
186    pub root_freemap_page: u64,
187    // Total allocated pages in the file, including both superblock slots.
188    // Used to detect truncation (see FileSizeMismatch).
189    pub total_pages: u64,
190    // Next u64 handle id to hand out. Handles are never reused. Handle 0 is
191    // reserved as the "no handle" sentinel and is never minted — a fresh store
192    // seeds this at 1 (see new_empty), so the first handle handed out is 1.
193    pub next_handle: u64,
194    // Validated against the compiled `PAGE_SIZE` at open time
195    // (`TransactionManager::open_existing`). A mismatch surfaces as
196    // `UnsupportedPageSize` before any data is read.
197    pub page_size: u32,
198    // Named-root table (F2). Fixed size; unused slots have name[0] == 0.
199    // Serialized at byte offset 52 for 256 bytes total. These are part of
200    // the transactional commit point — set_root_name writes to the
201    // in-memory Roots snapshot and the whole thing gets promoted on commit,
202    // so named roots survive rollback/savepoint correctly for free.
203    pub named_roots: [NamedRoot; NAMED_ROOT_COUNT],
204    // Number of superblock slots in the file at pages 0..N (ISSUES.md
205    // R4). Serialized at byte 308 (right after named_roots). Stored in
206    // each slot so open-time recovery can discover N from the first
207    // valid slot it finds. Valid range is MIN_SUPERBLOCKS..=MAX_SUPERBLOCKS;
208    // any value outside that range (including 0) is rejected by
209    // `deserialize` and the slot is treated as corrupt — a zero value
210    // would be catastrophic because the commit path uses
211    // `txn_counter % superblock_count` to pick the write slot.
212    pub superblock_count: u32,
213    // Root page of the membership index (chunk-tags feature). PAGE_ID_NONE
214    // until the first tagged chunk is written. Serialized at bytes 312..320,
215    // the first 8 bytes of the reserved region that follows superblock_count.
216    // Old files (pre-chunk-tags) have these bytes zeroed; callers normalize
217    // 0 → PAGE_ID_NONE on open so the rest of the engine has a single
218    // "empty" sentinel.
219    pub root_membership_index_page: u64,
220    // Depth of the multi-page radix freemap tree. Serialized at bytes 320..324.
221    // 0 = single-page freemap (the pre-multi-page-freemap path, compatible with
222    // old files whose reserved bytes are zeroed). Depth > 0 means the freemap
223    // is a COW radix tree of FreeMap bitmap leaves with FreeMapInterior inner
224    // nodes; root_freemap_page points to the root at that depth.
225    pub freemap_depth: u32,
226    /// Crypto-header for an encrypted database. `None` for plaintext DBs, in
227    /// which case serialize/deserialize use the existing all-plaintext layout.
228    /// `Some` means the sensitive fields are sealed in a DEK-encrypted body
229    /// sub-blob; those fields are zero until the caller supplies the DEK and
230    /// calls `decrypt_body`.
231    pub encryption: Option<CryptoHeader>,
232}
233
234/// The three torn-slot rules, shared by the hot path (`deserialize`) and the
235/// cold path (`diagnose`). Order is load-bearing: checksum first — a bad
236/// checksum means the rest of the buffer (including magic) is untrusted, so it
237/// is reported as `BadChecksum` even if the magic bytes also differ.
238fn validate(buf: &[u8; PAGE_SIZE]) -> Result<(), SuperblockDefect> {
239    if !page::verify_checksum(buf) {
240        return Err(SuperblockDefect::BadChecksum);
241    }
242    let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
243    if magic != MAGIC {
244        return Err(SuperblockDefect::BadMagic);
245    }
246    let count = u32::from_le_bytes(
247        buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4]
248            .try_into()
249            .unwrap(),
250    );
251    if !(MIN_SUPERBLOCKS..=MAX_SUPERBLOCKS).contains(&count) {
252        return Err(SuperblockDefect::BadCount(count));
253    }
254    Ok(())
255}
256
257// Offset where the DEK-sealed body sub-blob starts, immediately after the
258// crypto-header's key-slot table. Layout of the sealed region:
259//   SEALED_BODY_OFFSET .. +24    nonce (XChaCha20 192-bit)
260//   +24 .. +40                   Poly1305 authentication tag
261//   +40 .. +42                   ciphertext length (u16 LE)
262//   +42 .. +42+ct_len            ciphertext
263// The sealed region must fit before CHECKSUM_OFFSET (8184); at the maximum
264// body length (see BODY_LEN) the region ends well inside that bound.
265pub const SEALED_BODY_OFFSET: usize =
266    crypto_header::CRYPTO_HEADER_OFFSET + crypto_header::CRYPTO_HEADER_SIZE; // 1356
267
268// Plaintext body layout (the bytes fed to seal_body): the sensitive fields in
269// a fixed order.
270//   0..8   root_handle_table_page (u64 LE)
271//   8..16  root_freemap_page (u64 LE)
272//   16..24 root_membership_index_page (u64 LE)
273//   24..32 total_pages (u64 LE)
274//   32..40 next_handle (u64 LE)
275//   40..44 freemap_depth (u32 LE)
276//   44..44+NAMED_ROOT_COUNT*NAMED_ROOT_ENTRY_SIZE  named_roots
277const BODY_LEN: usize = 8 * 5 + 4 + (NAMED_ROOT_COUNT * NAMED_ROOT_ENTRY_SIZE);
278
279// Compile-time check: the sealed blob fits before the checksum.
280// SEALED_BODY_OFFSET(1356) + NONCE_LEN(24) + TAG_LEN(16) + 2(len) + BODY_LEN.
281const _: () =
282    assert!(SEALED_BODY_OFFSET + NONCE_LEN + TAG_LEN + 2 + BODY_LEN <= page::CHECKSUM_OFFSET);
283
284impl Superblock {
285    /// Serialize the superblock into a full page buffer with a trailing checksum.
286    /// The offsets below are part of the on-disk format contract — do not
287    /// reorder fields within a MAJOR; reordering or resizing existing fields
288    /// is a major-version bump.
289    pub fn serialize(&self) -> [u8; PAGE_SIZE] {
290        let mut buf = [0u8; PAGE_SIZE];
291        buf[0..4].copy_from_slice(&self.magic.to_le_bytes());
292        buf[4..8].copy_from_slice(&self.format_version.to_le_bytes());
293        buf[8..16].copy_from_slice(&self.txn_counter.to_le_bytes());
294        buf[16..24].copy_from_slice(&self.root_handle_table_page.to_le_bytes());
295        buf[24..32].copy_from_slice(&self.root_freemap_page.to_le_bytes());
296        buf[32..40].copy_from_slice(&self.total_pages.to_le_bytes());
297        buf[40..48].copy_from_slice(&self.next_handle.to_le_bytes());
298        buf[48..52].copy_from_slice(&self.page_size.to_le_bytes());
299        // Named-root table (F2). Entries are written in array order; empty
300        // slots serialize as all zeros, which round-trips correctly because
301        // NamedRoot::is_empty() tests name[0] == 0.
302        for (i, entry) in self.named_roots.iter().enumerate() {
303            let base = NAMED_ROOTS_OFFSET + i * NAMED_ROOT_ENTRY_SIZE;
304            buf[base..base + NAMED_ROOT_NAME_LEN].copy_from_slice(&entry.name);
305            buf[base + NAMED_ROOT_NAME_LEN..base + NAMED_ROOT_NAME_LEN + 8]
306                .copy_from_slice(&entry.handle.to_le_bytes());
307        }
308        // Superblock count (R4). Written at byte 308, within the v2
309        // reserved region that follows the named-root table.
310        buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4]
311            .copy_from_slice(&self.superblock_count.to_le_bytes());
312        // Membership-index root (chunk-tags). Written at bytes 312..320,
313        // immediately after superblock_count.
314        buf[ROOT_MEMBERSHIP_INDEX_OFFSET..ROOT_MEMBERSHIP_INDEX_OFFSET + 8]
315            .copy_from_slice(&self.root_membership_index_page.to_le_bytes());
316        // Freemap tree depth. Written at bytes 320..324. Old files leave these
317        // bytes zero (= single-page freemap, backward compatible).
318        buf[FREEMAP_DEPTH_OFFSET..FREEMAP_DEPTH_OFFSET + 4]
319            .copy_from_slice(&self.freemap_depth.to_le_bytes());
320        page::stamp_checksum(&mut buf);
321        buf
322    }
323
324    /// Build the AAD that binds the sealed body and each key-slot's DEK wrap to
325    /// this superblock's plaintext identity. The four bootstrap fields that stay
326    /// cleartext in both encrypted and plaintext DBs are included; this prevents
327    /// transplanting a sealed body from a different DB or a different txn_counter.
328    ///
329    /// These four MUST stay cleartext even in an encrypted DB precisely because
330    /// they are the AAD: slot selection (`max_by_key` on `txn_counter`) and this
331    /// AAD derivation both run BEFORE any DEK is available, so the fields cannot
332    /// live in the DEK-sealed body — the engine must read them to pick the live
333    /// slot and rebuild the AAD before it can open that body.
334    pub fn sb_identity_aad(&self) -> [u8; 24] {
335        let mut a = [0u8; 24];
336        a[0..4].copy_from_slice(&self.magic.to_le_bytes());
337        a[4..8].copy_from_slice(&self.format_version.to_le_bytes());
338        a[8..16].copy_from_slice(&self.txn_counter.to_le_bytes());
339        a[16..20].copy_from_slice(&self.superblock_count.to_le_bytes());
340        // bytes 20..24 are reserved (zero) for future AAD fields.
341        a
342    }
343
344    /// Assemble the plaintext body for sealing: all sensitive fields in the
345    /// canonical order defined by BODY_LEN. Called only for encrypted DBs.
346    fn body_plaintext(&self) -> Vec<u8> {
347        let mut b = Vec::with_capacity(BODY_LEN);
348        b.extend_from_slice(&self.root_handle_table_page.to_le_bytes());
349        b.extend_from_slice(&self.root_freemap_page.to_le_bytes());
350        b.extend_from_slice(&self.root_membership_index_page.to_le_bytes());
351        b.extend_from_slice(&self.total_pages.to_le_bytes());
352        b.extend_from_slice(&self.next_handle.to_le_bytes());
353        b.extend_from_slice(&self.freemap_depth.to_le_bytes());
354        for entry in &self.named_roots {
355            b.extend_from_slice(&entry.name);
356            b.extend_from_slice(&entry.handle.to_le_bytes());
357        }
358        b
359    }
360
361    /// Unpack a decrypted body blob into `self`'s sensitive fields. The body
362    /// layout must match `body_plaintext`'s encoding.
363    fn load_body(&mut self, body: &[u8]) {
364        // open_body returns exactly the plaintext that was sealed, which is
365        // always BODY_LEN bytes (see body_plaintext); document the invariant.
366        debug_assert_eq!(body.len(), BODY_LEN);
367        self.root_handle_table_page = u64::from_le_bytes(body[0..8].try_into().unwrap());
368        self.root_freemap_page = u64::from_le_bytes(body[8..16].try_into().unwrap());
369        self.root_membership_index_page = u64::from_le_bytes(body[16..24].try_into().unwrap());
370        self.total_pages = u64::from_le_bytes(body[24..32].try_into().unwrap());
371        self.next_handle = u64::from_le_bytes(body[32..40].try_into().unwrap());
372        self.freemap_depth = u32::from_le_bytes(body[40..44].try_into().unwrap());
373        let mut off = 44;
374        for entry in self.named_roots.iter_mut() {
375            entry
376                .name
377                .copy_from_slice(&body[off..off + NAMED_ROOT_NAME_LEN]);
378            entry.handle = u64::from_le_bytes(
379                body[off + NAMED_ROOT_NAME_LEN..off + NAMED_ROOT_NAME_LEN + 8]
380                    .try_into()
381                    .unwrap(),
382            );
383            off += NAMED_ROOT_ENTRY_SIZE;
384        }
385    }
386
387    /// Serialize an encrypted superblock: bootstrap fields + crypto-header in
388    /// cleartext; sensitive fields sealed under the DEK. The byte ranges that
389    /// would hold sensitive data in a plaintext page are left ZERO so nothing
390    /// leaks (named_roots at 52..308, root/page-id scalars at 16..48, etc.;
391    /// page_size at 48..52 stays cleartext).
392    ///
393    /// Panics if `self.encryption` is `None` — only call for encrypted DBs.
394    pub fn serialize_encrypted(&self, cipher: &crate::crypto::PageCipher) -> [u8; PAGE_SIZE] {
395        let header = self
396            .encryption
397            .as_ref()
398            .expect("serialize_encrypted requires Superblock.encryption = Some");
399        let mut buf = [0u8; PAGE_SIZE];
400        // Plaintext bootstrap fields only. Sensitive scalar fields (16..48)
401        // and named_roots (52..308) are intentionally left zero (page_size at
402        // 48..52 is a cleartext bootstrap field, written just below).
403        buf[0..4].copy_from_slice(&self.magic.to_le_bytes());
404        buf[4..8].copy_from_slice(&self.format_version.to_le_bytes());
405        buf[8..16].copy_from_slice(&self.txn_counter.to_le_bytes());
406        buf[48..52].copy_from_slice(&self.page_size.to_le_bytes());
407        buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4]
408            .copy_from_slice(&self.superblock_count.to_le_bytes());
409        // Crypto-header written into reserved region (plaintext).
410        header.serialize_into(&mut buf);
411        // Seal the sensitive body into the region immediately after the
412        // key-slot table: nonce || tag || ct_len(u16 LE) || ciphertext.
413        let aad = self.sb_identity_aad();
414        let (nonce, tag, ct) = cipher.seal_body(&aad, &self.body_plaintext());
415        let base = SEALED_BODY_OFFSET;
416        buf[base..base + NONCE_LEN].copy_from_slice(&nonce);
417        buf[base + NONCE_LEN..base + NONCE_LEN + TAG_LEN].copy_from_slice(&tag);
418        buf[base + NONCE_LEN + TAG_LEN..base + NONCE_LEN + TAG_LEN + 2]
419            .copy_from_slice(&(ct.len() as u16).to_le_bytes());
420        let coff = base + NONCE_LEN + TAG_LEN + 2;
421        buf[coff..coff + ct.len()].copy_from_slice(&ct);
422        page::stamp_checksum(&mut buf);
423        buf
424    }
425
426    /// Decrypt the sealed body into `self`'s sensitive fields. Caller must have
427    /// already called `deserialize` (which fills bootstrap fields and the
428    /// crypto-header from cleartext) and obtained the matching DEK. Returns
429    /// `CryptoError::Auth` if the DEK or AAD is wrong, or the blob is tampered.
430    pub fn decrypt_body(
431        &mut self,
432        cipher: &crate::crypto::PageCipher,
433        raw: &[u8; PAGE_SIZE],
434    ) -> Result<(), CryptoError> {
435        let base = SEALED_BODY_OFFSET;
436        let mut nonce = [0u8; NONCE_LEN];
437        nonce.copy_from_slice(&raw[base..base + NONCE_LEN]);
438        let mut tag = [0u8; TAG_LEN];
439        tag.copy_from_slice(&raw[base + NONCE_LEN..base + NONCE_LEN + TAG_LEN]);
440        let ct_len = u16::from_le_bytes(
441            raw[base + NONCE_LEN + TAG_LEN..base + NONCE_LEN + TAG_LEN + 2]
442                .try_into()
443                .unwrap(),
444        ) as usize;
445        let coff = base + NONCE_LEN + TAG_LEN + 2;
446        // Bounds-check ct_len before slicing. The page checksum is XXH3 (non-
447        // cryptographic): an attacker who edits the page can recompute it, so a
448        // forged ct_len must NOT reach the slice and panic. Treat an out-of-
449        // range length as an undecryptable body — same Err as a failed AEAD
450        // auth, since open_body would never accept it anyway.
451        if coff + ct_len > page::CHECKSUM_OFFSET {
452            return Err(CryptoError::Auth);
453        }
454        let ct = &raw[coff..coff + ct_len];
455        let aad = self.sb_identity_aad();
456        let body = cipher.open_body(&aad, &nonce, &tag, ct)?;
457        self.load_body(&body);
458        Ok(())
459    }
460
461    /// Deserialize from a page buffer. Returns None if the checksum is invalid
462    /// or the magic number doesn't match.
463    ///
464    /// Returning `Option` (instead of `Result`) is intentional: a failed
465    /// superblock is not a fatal error here — `select()` needs to be able to
466    /// discard a torn slot and fall back to its sibling. Promotion to a
467    /// fatal `CorruptSuperblock` happens only if *both* slots fail.
468    pub fn deserialize(buf: &[u8; PAGE_SIZE]) -> Option<Superblock> {
469        // Delegates to validate() for the three torn-slot checks (checksum,
470        // magic, superblock_count range). See validate()'s doc for why order
471        // matters. NOTE: format_version is read but not validated here. Callers
472        // that need version gating must check it on the returned struct (see
473        // TransactionManager::open_existing and ISSUES.md I15). page_size is
474        // likewise read-not-validated for the same reason: a mismatch against
475        // the compiled PAGE_SIZE is a fatal open-time error the caller raises,
476        // not a torn-slot signal that should make select() fall back.
477        validate(buf).ok()?;
478        // Check for an encryption header first. For encrypted DBs only the
479        // bootstrap fields are in cleartext; the sensitive fields stay zero
480        // until the caller supplies the DEK and calls `decrypt_body`.
481        let encryption = crypto_header::CryptoHeader::deserialize(buf);
482        if encryption.is_some() {
483            let superblock_count = u32::from_le_bytes(
484                buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4]
485                    .try_into()
486                    .unwrap(),
487            );
488            return Some(Superblock {
489                magic: u32::from_le_bytes(buf[0..4].try_into().unwrap()),
490                format_version: u32::from_le_bytes(buf[4..8].try_into().unwrap()),
491                txn_counter: u64::from_le_bytes(buf[8..16].try_into().unwrap()),
492                root_handle_table_page: 0,
493                root_freemap_page: 0,
494                total_pages: 0,
495                next_handle: 0,
496                page_size: u32::from_le_bytes(buf[48..52].try_into().unwrap()),
497                named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT],
498                superblock_count,
499                root_membership_index_page: 0,
500                freemap_depth: 0,
501                encryption,
502            });
503        }
504        // Plaintext path: all fields are directly readable.
505        let mut named_roots = [NamedRoot::EMPTY; NAMED_ROOT_COUNT];
506        for (i, entry) in named_roots.iter_mut().enumerate() {
507            let base = NAMED_ROOTS_OFFSET + i * NAMED_ROOT_ENTRY_SIZE;
508            entry
509                .name
510                .copy_from_slice(&buf[base..base + NAMED_ROOT_NAME_LEN]);
511            entry.handle = u64::from_le_bytes(
512                buf[base + NAMED_ROOT_NAME_LEN..base + NAMED_ROOT_NAME_LEN + 8]
513                    .try_into()
514                    .unwrap(),
515            );
516        }
517        let superblock_count = u32::from_le_bytes(
518            buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4]
519                .try_into()
520                .unwrap(),
521        );
522        Some(Superblock {
523            magic: u32::from_le_bytes(buf[0..4].try_into().unwrap()),
524            format_version: u32::from_le_bytes(buf[4..8].try_into().unwrap()),
525            txn_counter: u64::from_le_bytes(buf[8..16].try_into().unwrap()),
526            root_handle_table_page: u64::from_le_bytes(buf[16..24].try_into().unwrap()),
527            root_freemap_page: u64::from_le_bytes(buf[24..32].try_into().unwrap()),
528            total_pages: u64::from_le_bytes(buf[32..40].try_into().unwrap()),
529            next_handle: u64::from_le_bytes(buf[40..48].try_into().unwrap()),
530            page_size: u32::from_le_bytes(buf[48..52].try_into().unwrap()),
531            named_roots,
532            superblock_count,
533            root_membership_index_page: u64::from_le_bytes(
534                buf[ROOT_MEMBERSHIP_INDEX_OFFSET..ROOT_MEMBERSHIP_INDEX_OFFSET + 8]
535                    .try_into()
536                    .unwrap(),
537            ),
538            freemap_depth: u32::from_le_bytes(
539                buf[FREEMAP_DEPTH_OFFSET..FREEMAP_DEPTH_OFFSET + 4]
540                    .try_into()
541                    .unwrap(),
542            ),
543            encryption: None,
544        })
545    }
546
547    /// Select the active superblock from a list of candidate slot buffers.
548    ///
549    /// Deserialize every candidate, discard any whose
550    /// checksum/magic/count-range validation fails, and return the
551    /// survivor with the highest `txn_counter`.
552    ///
553    /// Used in tests only. Production code (`open_existing`) inlines the
554    /// same `filter_map` + `max_by_key` so it can keep the winning buffer
555    /// alongside the deserialized superblock for `decrypt_body`.
556    ///
557    /// Returns None only when *every* candidate is corrupt.
558    ///
559    /// Tie-break: `max_by_key` returns the LAST maximum in iteration order
560    /// (highest page index on a tie). Ties should not arise in normal
561    /// operation; this is a deterministic fallback for tests.
562    #[cfg(test)]
563    pub fn select(buffers: &[[u8; PAGE_SIZE]]) -> Option<Superblock> {
564        buffers
565            .iter()
566            .filter_map(Superblock::deserialize)
567            .max_by_key(|sb| sb.txn_counter)
568    }
569
570    /// Explain why every candidate slot failed. Called only on the cold path,
571    /// when `select` returned `None` — which means EVERY candidate failed
572    /// `validate` (none deserialized), so this returns one `SlotDefect` per
573    /// genuine superblock slot.
574    ///
575    /// The `buffers` slice may contain up to `MAX_SUPERBLOCKS` pages because
576    /// the open path reads blindly up to that limit without first knowing N.
577    /// Pages at indices >= the actual superblock_count are ordinary data
578    /// pages, not superblock slots — including them in the defect list would
579    /// falsely label intact data pages as corrupt superblocks.
580    ///
581    /// To recover the true count without a valid superblock, we scan each
582    /// buffer's raw superblock_count field (byte offset 308). The first value
583    /// in `MIN_SUPERBLOCKS..=MAX_SUPERBLOCKS` is used as the upper bound.
584    /// If no buffer yields a plausible count (every slot is so badly mangled
585    /// that even the count field is garbage), we fall back to `MIN_SUPERBLOCKS`:
586    /// any real database has at least that many slots, so slots 0..MIN are
587    /// always legitimate candidates to report and we never under-report.
588    pub(crate) fn diagnose(buffers: &[[u8; PAGE_SIZE]]) -> Vec<SlotDefect> {
589        let bound = buffers
590            .iter()
591            .map(|b| {
592                u32::from_le_bytes(
593                    b[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4]
594                        .try_into()
595                        .unwrap(),
596                )
597            })
598            .find(|&n| (MIN_SUPERBLOCKS..=MAX_SUPERBLOCKS).contains(&n))
599            .unwrap_or(MIN_SUPERBLOCKS) as usize;
600
601        buffers[..bound.min(buffers.len())]
602            .iter()
603            .enumerate()
604            .filter_map(|(i, b)| {
605                validate(b).err().map(|defect| SlotDefect {
606                    slot: i as u32,
607                    defect,
608                })
609            })
610            .collect()
611    }
612
613    /// Create the initial superblock for a new, empty database.
614    ///
615    /// `txn_counter` starts at `superblock_count - 1` so the first
616    /// user commit (which bumps to `superblock_count`) writes slot
617    /// `superblock_count % superblock_count == 0` — the highest-
618    /// counter slot, exactly as the I2 fix requires for N=2. The
619    /// caller (`TransactionManager::create_new`) pairs this with
620    /// additional lower-counter "fallback" slots in positions 1..N
621    /// (counters superblock_count-2, superblock_count-3, ..., 0) so
622    /// that every slot on disk holds a valid-empty-database
623    /// superblock from the moment the file exists; this means a
624    /// torn write on the FIRST commit can still fall back to one of
625    /// the pre-seeded siblings rather than to garbage.
626    ///
627    /// `total_pages = superblock_count` reserves the N slots and
628    /// nothing else. Both root pointers are PAGE_ID_NONE — the handle
629    /// table and freemap are created lazily on first write. The named-
630    /// root table starts empty.
631    pub fn new_empty(superblock_count: u32) -> Superblock {
632        Superblock {
633            magic: MAGIC,
634            format_version: page::FORMAT_VERSION,
635            txn_counter: (superblock_count - 1) as u64,
636            root_handle_table_page: page::PAGE_ID_NONE,
637            root_freemap_page: page::PAGE_ID_NONE,
638            total_pages: superblock_count as u64,
639            // Handle 0 is reserved as the "no handle" sentinel and is never
640            // minted, so a fresh store seeds the counter at 1. Must match the
641            // in-memory Roots in TransactionManager's create path.
642            next_handle: 1,
643            page_size: PAGE_SIZE as u32,
644            named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT],
645            superblock_count,
646            root_membership_index_page: page::PAGE_ID_NONE,
647            freemap_depth: 0,
648            encryption: None,
649        }
650    }
651
652    /// Like `new_empty`, but stamps MAJOR=2 and embeds the crypto-header so
653    /// `serialize_encrypted` can seal the body. Called exclusively from the
654    /// `create_new` encrypted path; the `CryptoHeader` carries the wrapped DEK
655    /// in slot 0 and is written in cleartext into the superblock's reserved region.
656    pub fn new_empty_encrypted(superblock_count: u32, header: CryptoHeader) -> Superblock {
657        Superblock {
658            magic: MAGIC,
659            format_version: page::format_version_encrypted(),
660            txn_counter: (superblock_count - 1) as u64,
661            root_handle_table_page: page::PAGE_ID_NONE,
662            root_freemap_page: page::PAGE_ID_NONE,
663            total_pages: superblock_count as u64,
664            next_handle: 1,
665            page_size: PAGE_SIZE as u32,
666            named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT],
667            superblock_count,
668            root_membership_index_page: page::PAGE_ID_NONE,
669            freemap_depth: 0,
670            encryption: Some(header),
671        }
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use proptest::prop_assert_eq;
679
680    #[test]
681    fn new_empty_reserves_handle_zero() {
682        // Handle 0 is the reserved "no handle" sentinel: a fresh store's
683        // superblock seeds next_handle at 1 so the allocator never mints 0.
684        let sb = Superblock::new_empty(2);
685        assert_eq!(sb.next_handle, 1);
686    }
687
688    /// A superblock whose count field is outside [MIN, MAX] must be
689    /// rejected by `deserialize`. Otherwise the recovered count would
690    /// feed the `txn_counter % superblock_count` slot calculation in
691    /// commit and could direct a superblock write into the data region.
692    #[test]
693    fn deserialize_rejects_out_of_range_superblock_count() {
694        let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT);
695        for bogus in [0u32, 1, MAX_SUPERBLOCKS + 1, 1_000_000, u32::MAX] {
696            sb.superblock_count = bogus;
697            // Build with the bad value, then re-stamp the checksum so
698            // the only reason to reject is the count field itself.
699            let mut buf = sb.serialize();
700            buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4]
701                .copy_from_slice(&bogus.to_le_bytes());
702            page::stamp_checksum(&mut buf);
703            assert!(
704                Superblock::deserialize(&buf).is_none(),
705                "deserialize accepted out-of-range superblock_count = {bogus}"
706            );
707        }
708    }
709
710    /// All in-range counts must round-trip cleanly.
711    #[test]
712    fn deserialize_accepts_all_valid_superblock_counts() {
713        for n in MIN_SUPERBLOCKS..=MAX_SUPERBLOCKS {
714            let sb = Superblock::new_empty(n);
715            let buf = sb.serialize();
716            let got = Superblock::deserialize(&buf).expect("valid count rejected");
717            assert_eq!(got.superblock_count, n);
718        }
719    }
720
721    /// If one slot has a bogus count but the sibling is valid,
722    /// `select()` must pick the valid sibling rather than returning None.
723    #[test]
724    fn select_falls_back_when_one_slot_has_bad_count() {
725        let good = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT);
726        let good_buf = good.serialize();
727
728        let mut bad_buf = good.serialize();
729        bad_buf[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4]
730            .copy_from_slice(&1_000_000u32.to_le_bytes());
731        page::stamp_checksum(&mut bad_buf);
732
733        let picked = Superblock::select(&[bad_buf, good_buf]).expect("no slot picked");
734        assert_eq!(picked.superblock_count, DEFAULT_SUPERBLOCK_COUNT);
735    }
736
737    #[test]
738    fn validate_classifies_each_defect() {
739        let good = Superblock::new_empty(2).serialize();
740        assert_eq!(validate(&good), Ok(()));
741
742        let mut bad_checksum = good;
743        bad_checksum[16] ^= 0xFF;
744        assert_eq!(validate(&bad_checksum), Err(SuperblockDefect::BadChecksum));
745
746        let mut bad_magic = good;
747        bad_magic[0] ^= 0xFF;
748        page::stamp_checksum(&mut bad_magic);
749        assert_eq!(validate(&bad_magic), Err(SuperblockDefect::BadMagic));
750
751        let mut bad_count = good;
752        bad_count[SUPERBLOCK_COUNT_OFFSET..SUPERBLOCK_COUNT_OFFSET + 4]
753            .copy_from_slice(&99u32.to_le_bytes());
754        page::stamp_checksum(&mut bad_count);
755        assert_eq!(validate(&bad_count), Err(SuperblockDefect::BadCount(99)));
756    }
757
758    #[test]
759    fn diagnose_reports_each_slots_defect() {
760        let good = Superblock::new_empty(2).serialize();
761        let mut slot0 = good;
762        slot0[16] ^= 0xFF;
763        let mut slot1 = good;
764        slot1[0] ^= 0xFF;
765        page::stamp_checksum(&mut slot1);
766        assert_eq!(
767            Superblock::diagnose(&[slot0, slot1]),
768            vec![
769                SlotDefect {
770                    slot: 0,
771                    defect: SuperblockDefect::BadChecksum
772                },
773                SlotDefect {
774                    slot: 1,
775                    defect: SuperblockDefect::BadMagic
776                },
777            ]
778        );
779    }
780
781    // ── Migrated 2026-05-22 from tests/basic_ops.rs (I35 reshape) ──
782    //
783    // The originals used the bare `MAGIC`/`FORMAT_VERSION`/`PAGE_ID_NONE`
784    // page-module constants via `use chisel::page::...`; from inside src/
785    // they are reached through `crate::page::*`, which `use super::*` and
786    // the existing `use crate::page::{self, MAGIC, PAGE_SIZE};` at file
787    // scope make available without further imports.
788
789    #[test]
790    fn test_superblock_roundtrip() {
791        let sb = Superblock {
792            magic: MAGIC,
793            format_version: crate::page::FORMAT_VERSION,
794            txn_counter: 42,
795            root_handle_table_page: 5,
796            root_freemap_page: 8,
797            total_pages: 100,
798            next_handle: 50,
799            page_size: PAGE_SIZE as u32,
800            named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT],
801            superblock_count: DEFAULT_SUPERBLOCK_COUNT,
802            root_membership_index_page: crate::page::PAGE_ID_NONE,
803            freemap_depth: 0,
804            encryption: None,
805        };
806        let buf = sb.serialize();
807        let sb2 = Superblock::deserialize(&buf).unwrap();
808        assert_eq!(sb, sb2);
809    }
810
811    #[test]
812    fn test_superblock_checksum_validation() {
813        let sb = Superblock {
814            magic: MAGIC,
815            format_version: crate::page::FORMAT_VERSION,
816            txn_counter: 1,
817            root_handle_table_page: crate::page::PAGE_ID_NONE,
818            root_freemap_page: crate::page::PAGE_ID_NONE,
819            total_pages: 2,
820            next_handle: 0,
821            page_size: PAGE_SIZE as u32,
822            named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT],
823            superblock_count: DEFAULT_SUPERBLOCK_COUNT,
824            root_membership_index_page: crate::page::PAGE_ID_NONE,
825            freemap_depth: 0,
826            encryption: None,
827        };
828        let mut buf = sb.serialize();
829        buf[10] ^= 0xFF;
830        assert!(Superblock::deserialize(&buf).is_none());
831    }
832
833    #[test]
834    fn test_superblock_selection() {
835        let sb1 = Superblock {
836            magic: MAGIC,
837            format_version: crate::page::FORMAT_VERSION,
838            txn_counter: 5,
839            root_handle_table_page: 2,
840            root_freemap_page: 3,
841            total_pages: 10,
842            next_handle: 3,
843            page_size: PAGE_SIZE as u32,
844            named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT],
845            superblock_count: DEFAULT_SUPERBLOCK_COUNT,
846            root_membership_index_page: crate::page::PAGE_ID_NONE,
847            freemap_depth: 0,
848            encryption: None,
849        };
850        let sb2 = Superblock {
851            magic: MAGIC,
852            format_version: crate::page::FORMAT_VERSION,
853            txn_counter: 7,
854            root_handle_table_page: 4,
855            root_freemap_page: 5,
856            total_pages: 12,
857            next_handle: 5,
858            page_size: PAGE_SIZE as u32,
859            named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT],
860            superblock_count: DEFAULT_SUPERBLOCK_COUNT,
861            root_membership_index_page: crate::page::PAGE_ID_NONE,
862            freemap_depth: 0,
863            encryption: None,
864        };
865        let buf1 = sb1.serialize();
866        let buf2 = sb2.serialize();
867        let selected = Superblock::select(&[buf1, buf2]).unwrap();
868        assert_eq!(selected.txn_counter, 7);
869    }
870
871    #[test]
872    fn test_superblock_selection_with_one_corrupt() {
873        let sb1 = Superblock {
874            magic: MAGIC,
875            format_version: crate::page::FORMAT_VERSION,
876            txn_counter: 5,
877            root_handle_table_page: 2,
878            root_freemap_page: 3,
879            total_pages: 10,
880            next_handle: 3,
881            page_size: PAGE_SIZE as u32,
882            named_roots: [NamedRoot::EMPTY; NAMED_ROOT_COUNT],
883            superblock_count: DEFAULT_SUPERBLOCK_COUNT,
884            root_membership_index_page: crate::page::PAGE_ID_NONE,
885            freemap_depth: 0,
886            encryption: None,
887        };
888        let sb2_buf = [0u8; PAGE_SIZE];
889        let buf1 = sb1.serialize();
890        let selected = Superblock::select(&[buf1, sb2_buf]).unwrap();
891        assert_eq!(selected.txn_counter, 5);
892    }
893
894    #[test]
895    fn test_superblock_selection_both_corrupt() {
896        let buf1 = [0u8; PAGE_SIZE];
897        let buf2 = [0u8; PAGE_SIZE];
898        assert!(Superblock::select(&[buf1, buf2]).is_none());
899    }
900
901    #[test]
902    fn freemap_depth_round_trips_and_defaults_zero() {
903        let mut sb = Superblock::new_empty(2);
904        sb.root_freemap_page = 9;
905        sb.freemap_depth = 3;
906        let buf = sb.serialize();
907        let back = Superblock::deserialize(&buf).unwrap();
908        assert_eq!(back.freemap_depth, 3);
909        assert_eq!(back.root_freemap_page, 9);
910
911        let mut legacy = sb.serialize();
912        legacy[320..324].fill(0);
913        page::stamp_checksum(&mut legacy);
914        let back0 = Superblock::deserialize(&legacy).unwrap();
915        assert_eq!(back0.freemap_depth, 0);
916    }
917
918    /// The new root_membership_index_page field must persist across serialize/
919    /// deserialize and default to PAGE_ID_NONE on new_empty(). Also verifies
920    /// that an old serialized form (bytes 312..320 zeroed) reads back as 0, not
921    /// as PAGE_ID_NONE — callers like open_existing normalize 0 → PAGE_ID_NONE.
922    #[test]
923    fn membership_root_round_trips_through_serialize() {
924        let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT);
925        assert_eq!(sb.root_membership_index_page, page::PAGE_ID_NONE);
926        sb.root_membership_index_page = 1234;
927        let buf = sb.serialize();
928        let back = Superblock::deserialize(&buf).unwrap();
929        assert_eq!(back.root_membership_index_page, 1234);
930        let mut old = sb.serialize();
931        old[312..320].fill(0);
932        page::stamp_checksum(&mut old);
933        assert_eq!(
934            Superblock::deserialize(&old)
935                .unwrap()
936                .root_membership_index_page,
937            0
938        );
939    }
940
941    // I71 (ISSUES.md, 2026-05-22): property test —
942    // `deserialize(serialize(sb)) == Some(sb)` for any well-formed
943    // Superblock. The proptest strategy builds a Superblock by
944    // sampling its individually-varying fields (txn_counter, roots,
945    // page-id fields, named-root names/handles, superblock_count)
946    // while pinning the format-invariant fields (magic, format_version,
947    // page_size). NamedRoot.name uses a byte-array strategy so the
948    // empty-slot convention (name[0] == 0) gets exercised alongside
949    // populated names.
950    // ── Encrypted-superblock tests (Task 2.2) ──
951
952    /// Full encrypt→serialize→deserialize→decrypt round-trip: sensitive fields
953    /// must survive the seal/open cycle and must not appear in the raw bytes.
954    #[test]
955    fn encrypted_superblock_hides_sensitive_fields_and_round_trips() {
956        use crate::crypto::{random_dek, PageCipher};
957
958        let cipher = PageCipher::new(random_dek());
959        let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT];
960        header_slots[0].state = 1; // mark one slot active (simulates a real key-slot)
961        let header = CryptoHeader {
962            algorithm: ALGO_XCHACHA20POLY1305,
963            stride: 8232,
964            slots: header_slots,
965        };
966
967        let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT);
968        sb.root_handle_table_page = 7;
969        sb.next_handle = 99;
970        sb.total_pages = 41;
971        sb.named_roots[0].name[..5].copy_from_slice(b"users");
972        sb.named_roots[0].handle = 12345;
973        sb.encryption = Some(header);
974
975        let buf = sb.serialize_encrypted(&cipher);
976
977        // Sensitive bytes must be absent from cleartext.
978        // named_roots occupy 52..308; all must be zero in the encrypted page.
979        assert_eq!(
980            &buf[52..308],
981            &[0u8; 256][..],
982            "named_roots leaked in cleartext"
983        );
984        // Scalar sensitive fields at 16..48 must be zero.
985        assert_eq!(&buf[16..48], &[0u8; 32][..], "sensitive scalars leaked");
986        // root_membership_index_page (312..320) and freemap_depth (320..324)
987        // are also sealed-only, so their plaintext slots must be zero. Bytes
988        // 308..312 (superblock_count) are legitimately cleartext and skipped.
989        assert_eq!(
990            &buf[312..324],
991            &[0u8; 12][..],
992            "membership/freemap_depth leaked"
993        );
994        // Bootstrap fields stay plaintext.
995        assert_eq!(u32::from_le_bytes(buf[0..4].try_into().unwrap()), MAGIC);
996        assert_eq!(
997            u64::from_le_bytes(buf[8..16].try_into().unwrap()),
998            sb.txn_counter
999        );
1000
1001        // Two-phase deserialize: sensitive fields are zero after deserialize.
1002        let mut back = Superblock::deserialize(&buf).expect("encrypted sb deserializes");
1003        assert!(
1004            back.encryption.is_some(),
1005            "encryption field must be populated"
1006        );
1007        assert_eq!(back.root_handle_table_page, 0, "not yet decrypted");
1008        assert_eq!(back.next_handle, 0, "not yet decrypted");
1009
1010        // After decrypt_body the sensitive fields are restored.
1011        back.decrypt_body(&cipher, &buf).expect("DEK opens body");
1012        assert_eq!(back.root_handle_table_page, 7);
1013        assert_eq!(back.next_handle, 99);
1014        assert_eq!(back.total_pages, 41);
1015        assert_eq!(&back.named_roots[0].name[..5], b"users");
1016        assert_eq!(back.named_roots[0].handle, 12345);
1017    }
1018
1019    /// Wrong DEK must produce a CryptoError (authentication failure), not
1020    /// silently corrupt the sensitive fields.
1021    #[test]
1022    fn wrong_dek_fails_body_authentication() {
1023        use crate::crypto::{random_dek, PageCipher};
1024
1025        let cipher = PageCipher::new(random_dek());
1026        let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT];
1027        header_slots[0].state = 1;
1028        let header = CryptoHeader {
1029            algorithm: ALGO_XCHACHA20POLY1305,
1030            stride: 8232,
1031            slots: header_slots,
1032        };
1033        let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT);
1034        sb.encryption = Some(header);
1035        let buf = sb.serialize_encrypted(&cipher);
1036
1037        let wrong = PageCipher::new(random_dek());
1038        let mut back = Superblock::deserialize(&buf).unwrap();
1039        assert!(back.decrypt_body(&wrong, &buf).is_err());
1040    }
1041
1042    /// A forged ct_len (the XXH3 page checksum is non-cryptographic, so it
1043    /// cannot protect it) must surface as a recoverable Err, never a panic on
1044    /// the slice. Regression guard for the out-of-bounds slice fixed in review.
1045    #[test]
1046    fn forged_ct_len_returns_err_not_panic() {
1047        use crate::crypto::{random_dek, PageCipher};
1048
1049        let cipher = PageCipher::new(random_dek());
1050        let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT];
1051        header_slots[0].state = 1;
1052        let header = CryptoHeader {
1053            algorithm: ALGO_XCHACHA20POLY1305,
1054            stride: 8232,
1055            slots: header_slots,
1056        };
1057        let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT);
1058        sb.encryption = Some(header);
1059        let mut buf = sb.serialize_encrypted(&cipher);
1060
1061        // Overwrite the 2-byte ct_len field with 0xFFFF (65535), which would
1062        // slice far past CHECKSUM_OFFSET, then re-stamp the checksum so the
1063        // page validates (simulating an attacker who recomputed XXH3).
1064        let len_off = SEALED_BODY_OFFSET + NONCE_LEN + TAG_LEN;
1065        buf[len_off..len_off + 2].copy_from_slice(&0xFFFFu16.to_le_bytes());
1066        page::stamp_checksum(&mut buf);
1067
1068        let mut back = Superblock::deserialize(&buf).unwrap();
1069        // Must return Err, not panic.
1070        assert!(back.decrypt_body(&cipher, &buf).is_err());
1071    }
1072
1073    /// Security property: a recognizable named-root name must NOT appear in
1074    /// cleartext anywhere in the serialized page for an encrypted superblock.
1075    ///
1076    /// This is the core anti-leak assertion for Task 2.3. The name bytes are
1077    /// stored only inside the DEK-sealed body (ciphertext), so they must be
1078    /// invisible in the raw page. The test also verifies the sentinel IS
1079    /// recovered after `decrypt_body`, proving it was sealed rather than dropped.
1080    #[test]
1081    fn encrypted_named_root_name_absent_from_cleartext() {
1082        use crate::crypto::{random_dek, PageCipher};
1083
1084        // Sentinel: exactly NAMED_ROOT_NAME_LEN (24) bytes, recognizable prefix.
1085        // "secret-LEAKCHECK" = 16 ASCII bytes, padded with zeros to fill the slot.
1086        let mut sentinel = [0u8; NAMED_ROOT_NAME_LEN];
1087        sentinel[..16].copy_from_slice(b"secret-LEAKCHECK");
1088
1089        let cipher = PageCipher::new(random_dek());
1090        let mut header_slots = [KeySlot::EMPTY; KEY_SLOT_COUNT];
1091        header_slots[0].state = 1;
1092        let header = CryptoHeader {
1093            algorithm: ALGO_XCHACHA20POLY1305,
1094            stride: 8232,
1095            slots: header_slots,
1096        };
1097
1098        let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT);
1099        sb.named_roots[0].name = sentinel;
1100        sb.named_roots[0].handle = 0xDEAD_BEEF_CAFE_0001;
1101        sb.encryption = Some(header);
1102
1103        let buf = sb.serialize_encrypted(&cipher);
1104
1105        // 1. The named_roots region (52..308) must be zero in cleartext.
1106        assert_eq!(
1107            &buf[52..308],
1108            &[0u8; 256][..],
1109            "named_roots region (52..308) is not zeroed in encrypted superblock"
1110        );
1111
1112        // 2. The sentinel bytes must NOT appear as a contiguous subsequence
1113        //    anywhere in the full page (including the sealed-body region).
1114        //    The 16-byte scan window is long enough to be collision-resistant
1115        //    against random ciphertext (prob ≈ 2^-128).
1116        let needle = &sentinel[..16];
1117        assert!(
1118            !buf.windows(needle.len()).any(|w| w == needle),
1119            "sentinel name appears in cleartext page — named_root name leaked"
1120        );
1121
1122        // 3. Round-trip: decrypt_body must recover the sentinel, proving it was
1123        //    sealed (not silently dropped).
1124        let mut back = Superblock::deserialize(&buf).expect("encrypted sb must deserialize");
1125        back.decrypt_body(&cipher, &buf)
1126            .expect("correct DEK must open body");
1127        assert_eq!(
1128            back.named_roots[0].name, sentinel,
1129            "named_root name not recovered after decrypt_body"
1130        );
1131        assert_eq!(back.named_roots[0].handle, 0xDEAD_BEEF_CAFE_0001);
1132    }
1133
1134    /// Plaintext DBs must serialize byte-identically to the pre-encryption
1135    /// implementation (regression guard: `encryption: None` path is unchanged).
1136    #[test]
1137    fn plaintext_superblock_round_trips_unchanged() {
1138        let mut sb = Superblock::new_empty(DEFAULT_SUPERBLOCK_COUNT);
1139        sb.root_handle_table_page = 5;
1140        sb.root_freemap_page = 6;
1141        sb.total_pages = 20;
1142        sb.next_handle = 3;
1143        sb.named_roots[0].name[..4].copy_from_slice(b"test");
1144        sb.named_roots[0].handle = 42;
1145
1146        let buf = sb.serialize();
1147        let back = Superblock::deserialize(&buf).expect("plaintext must deserialize");
1148        assert!(back.encryption.is_none());
1149        assert_eq!(back.root_handle_table_page, 5);
1150        assert_eq!(back.named_roots[0].handle, 42);
1151        assert_eq!(&back.named_roots[0].name[..4], b"test");
1152    }
1153
1154    proptest::proptest! {
1155        #[test]
1156        fn prop_serialize_deserialize_roundtrip(
1157            txn_counter in 0u64..u64::MAX,
1158            root_handle_table_page in 0u64..u64::MAX,
1159            root_freemap_page in 0u64..u64::MAX,
1160            total_pages in 0u64..u64::MAX,
1161            next_handle in 0u64..u64::MAX,
1162            // superblock_count must lie in the validated range
1163            // MIN_SUPERBLOCKS..=MAX_SUPERBLOCKS (2..=16); values
1164            // outside this range cause deserialize() to return None
1165            // by design (defends against torn-slot corruption that
1166            // would otherwise direct a superblock write into the
1167            // data region). Sampling only the valid range keeps the
1168            // round-trip property well-defined.
1169            superblock_count in MIN_SUPERBLOCKS..=MAX_SUPERBLOCKS,
1170            named_root_bytes in proptest::array::uniform8(
1171                proptest::array::uniform24(0u8..=255u8)
1172            ),
1173            named_root_handles in proptest::array::uniform8(0u64..u64::MAX),
1174            root_membership_index_page in 0u64..u64::MAX,
1175        ) {
1176            let mut named_roots = [NamedRoot::EMPTY; NAMED_ROOT_COUNT];
1177            for (i, slot) in named_roots.iter_mut().enumerate() {
1178                slot.name = named_root_bytes[i];
1179                slot.handle = named_root_handles[i];
1180            }
1181            let sb = Superblock {
1182                magic: MAGIC,
1183                format_version: crate::page::FORMAT_VERSION,
1184                txn_counter,
1185                root_handle_table_page,
1186                root_freemap_page,
1187                total_pages,
1188                next_handle,
1189                page_size: PAGE_SIZE as u32,
1190                named_roots,
1191                superblock_count,
1192                root_membership_index_page,
1193                freemap_depth: 0,
1194                encryption: None,
1195            };
1196            let buf = sb.serialize();
1197            let parsed = Superblock::deserialize(&buf)
1198                .expect("a freshly-serialized superblock must deserialize");
1199            // Field-by-field equality. PartialEq isn't derived on
1200            // Superblock, so compare structurally — easier to diagnose
1201            // if a single field round-trips wrong.
1202            prop_assert_eq!(parsed.magic, sb.magic);
1203            prop_assert_eq!(parsed.format_version, sb.format_version);
1204            prop_assert_eq!(parsed.txn_counter, sb.txn_counter);
1205            prop_assert_eq!(parsed.root_handle_table_page, sb.root_handle_table_page);
1206            prop_assert_eq!(parsed.root_freemap_page, sb.root_freemap_page);
1207            prop_assert_eq!(parsed.total_pages, sb.total_pages);
1208            prop_assert_eq!(parsed.next_handle, sb.next_handle);
1209            prop_assert_eq!(parsed.page_size, sb.page_size);
1210            prop_assert_eq!(parsed.superblock_count, sb.superblock_count);
1211            prop_assert_eq!(parsed.root_membership_index_page, sb.root_membership_index_page);
1212            prop_assert_eq!(parsed.freemap_depth, sb.freemap_depth);
1213            prop_assert_eq!(parsed.encryption, sb.encryption);
1214            for i in 0..NAMED_ROOT_COUNT {
1215                prop_assert_eq!(parsed.named_roots[i].name, sb.named_roots[i].name);
1216                prop_assert_eq!(parsed.named_roots[i].handle, sb.named_roots[i].handle);
1217            }
1218        }
1219    }
1220}