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}