chisel/page.rs
1// page.rs — Foundation layer (layer 1). Defines the on-disk page format:
2// page size, type tags, header sizes, magic/format-version constants, and
3// the checksum primitives every other page module relies on.
4//
5// Invariant: every page on disk is exactly PAGE_SIZE bytes and ends with an
6// 8-byte little-endian XXH3 checksum computed over bytes 0..CHECKSUM_OFFSET.
7// PageCache validates this checksum on every disk LOAD (cache miss); cache
8// hits skip revalidation because the in-memory bytes are trusted between
9// writes. A mismatch at load time is fatal (ChecksumMismatch).
10// On-disk format is little-endian by convention (we assume LE hosts and
11// explicitly use to_le_bytes/from_le_bytes for portability if that changes).
12//
13// Versioning is two-tiered. At the FILE level, the superblock carries a
14// packed MAJOR/MINOR `format_version` (I29); the open-time gate rejects
15// a file whose MAJOR doesn't match the binary's. At the PAGE level, each
16// non-superblock page carries a one-byte per-page format version (I31)
17// that lets individual page layouts evolve within a MAJOR without a
18// file-wide bump — the foundation for lazy per-page upgrade. See the
19// PAGE_FORMAT_VERSION_CURRENT block below for the storage convention.
20
21use xxhash_rust::xxh3::xxh3_64;
22
23// 8 KiB pages: small enough to keep per-page I/O cheap and the cache working
24// set fine-grained, large enough to amortize header overhead. Changing this
25// is a format break — FORMAT_VERSION must bump.
26pub const PAGE_SIZE: usize = 8192;
27pub const CHECKSUM_SIZE: usize = 8;
28// Checksum lives at the very end of the page so that the entire header+body
29// region (bytes 0..CHECKSUM_OFFSET) is a single contiguous hashable slice.
30pub const CHECKSUM_OFFSET: usize = PAGE_SIZE - CHECKSUM_SIZE; // 8184
31
32// Common page header (first 16 bytes) is shared by non-superblock pages: bytes
33// 0..8 are page-type-specific (type tag at byte 0, per-page version at byte 1 —
34// or byte 2 for HandleTable — plus any type header fields) and bytes 8..16 are
35// the I31 reserved common-header region (COMMON_RESERVED_OFFSET..+LEN).
36// PageCache identifies a page's type from byte 0 without knowing the concrete
37// module. The superblock uses its own layout and does NOT carry this header (it
38// stores its txn_counter in bytes 8..16).
39//
40// `#[allow(dead_code)]`: documents the layout commitment that every page-type
41// module's `init_page` agrees with — the single source of truth even though no
42// current call site reads it. It MUST equal DATA_PAGE_HEADER_SIZE and
43// COMMON_RESERVED_OFFSET + COMMON_RESERVED_LEN; the const-asserts below lock
44// that. I133 (2026-06-21): this was 12 — a stale value predating the I31 8..16
45// reservation that contradicted its own sibling constants and every comment;
46// corrected to 16. A workflow confirmed no page type stores data in 8..16, so
47// the reserved region is genuine and the common header genuinely runs to 16.
48#[allow(dead_code)]
49pub const COMMON_HEADER_SIZE: usize = 16;
50// Data pages carry an extended 16-byte header (common header + slot-array
51// metadata). PAGE_BODY_SIZE is the space available to slot payloads after
52// subtracting that header and the trailing checksum.
53pub const DATA_PAGE_HEADER_SIZE: usize = 16;
54pub const PAGE_BODY_SIZE: usize = PAGE_SIZE - DATA_PAGE_HEADER_SIZE - CHECKSUM_SIZE; // 8168
55
56// Per-page format version (ISSUES.md I31). Each non-superblock page
57// carries a one-byte version that lets future binaries read older
58// page layouts without a MAJOR-format bump — the basis for lazy
59// per-page upgrade. Version 0 is reserved for "the layout as of the
60// I31 commit" (byte values pre-this-change happen to be zero already,
61// which is why introducing this byte does not require breaking
62// existing files).
63//
64// Storage offset is per-type:
65// - Data, Overflow, FreeMap: byte 1 (was unused / padding)
66// - HandleTable: byte 2 (byte 1 holds FLAG_LEAF/INTERIOR)
67//
68// Bytes 8..16 of every non-superblock page are RESERVED for future
69// common-header fields (64 bits of headroom). Today they are
70// universally zero. Adding a field there in the future will bump
71// the relevant page type's per-page version, not the superblock's
72// MAJOR.
73pub const PAGE_FORMAT_VERSION_CURRENT: u8 = 0;
74// I31 (ISSUES.md): reserved-region constants describe the 8-byte
75// common-header headroom every non-superblock page leaves for future
76// fields. No live code reads them today; they exist so that whenever a
77// new common field IS added (bumping the relevant per-page version),
78// the offset comes from one authoritative spot, not a fresh literal.
79#[allow(dead_code)]
80pub const COMMON_RESERVED_OFFSET: usize = 8;
81#[allow(dead_code)]
82pub const COMMON_RESERVED_LEN: usize = 8;
83
84// I133 (ISSUES.md, 2026-06-21): lock the header-size constants so the 12-vs-16
85// drift cannot recur. The common header spans bytes 0..16 (0..8 type-specific,
86// 8..16 reserved), so it must equal both the reserved region's end and the
87// data-page header — whose slot directory begins at byte 16, leaving no fields
88// in the reserved tail. The per-page version byte (1, or 2 for HandleTable)
89// sits below the reserved window. These are compile-time checks: a future edit
90// that desyncs the constants fails to build.
91const _: () = assert!(COMMON_HEADER_SIZE == COMMON_RESERVED_OFFSET + COMMON_RESERVED_LEN);
92const _: () = assert!(COMMON_HEADER_SIZE == DATA_PAGE_HEADER_SIZE);
93const _: () = assert!(2 < COMMON_RESERVED_OFFSET);
94
95// "CHSL" in ASCII, stored little-endian so it appears as C-H-S-L when you
96// hexdump the first 4 bytes of the file. Used to reject non-Chisel files
97// before we even look at the checksum.
98pub const MAGIC: u32 = 0x4348534C; // "CHSL"
99
100// On-disk format version: byte-packed u32 with upper 16 bits = MAJOR,
101// lower 16 bits = MINOR. Gates at open time on MAJOR only; same-major
102// files are read-compatible regardless of minor (additive-only layout
103// changes within a major are the invariant that makes this safe).
104// Write safety across minors is a separate concern — a binary at minor
105// M opening a file at minor M' > M can read but not safely write
106// without clobbering fields it doesn't know about; this check is
107// deferred until the first 1.1 release (at which point the gate grows
108// a "newer minor ⇒ refuse writes" arm). See ISSUES.md I29.
109//
110// Pre-1.0 files (format_version = 1 or 2 in the old flat scheme) have
111// major byte = 0 and are rejected with UnsupportedFormatVersion — a
112// clean break, since there are no production DBs yet.
113pub const FORMAT_MAJOR_VERSION: u16 = 1;
114pub const FORMAT_MINOR_VERSION: u16 = 1;
115
116/// MAJOR version stamped into an ENCRYPTED database's superblock. The bump from
117/// 1 → 2 hard-rejects old binaries (which gate on FORMAT_MAJOR_VERSION == 1).
118pub const FORMAT_MAJOR_VERSION_ENCRYPTED: u16 = 2;
119
120/// MINOR version for the encrypted-DB format series. A new MAJOR series starts
121/// its MINOR count at 0, so encrypted DBs stamp (2, 0) — NOT (2, FORMAT_MINOR_VERSION).
122/// The encrypted format carries its own minor series, independent of plaintext.
123pub(crate) const FORMAT_MINOR_VERSION_ENCRYPTED: u16 = 0;
124
125/// The SINGLE canonical packed format_version for an ENCRYPTED database's
126/// superblock: `pack(2, 0)`. `format_version_encrypted()` returns this constant;
127/// create, open, and the superblock-body AAD (`sb_identity_aad`) all route
128/// through that function, so there is exactly ONE on-disk value — no two
129/// constants that can drift. An encryption-unaware binary (FORMAT_MAJOR_VERSION
130/// == 1) rejects it as `UnsupportedFormatVersion`, the intended hard-reject.
131pub(crate) const ENCRYPTED_FORMAT_VERSION: u32 = pack_format_version(
132 FORMAT_MAJOR_VERSION_ENCRYPTED,
133 FORMAT_MINOR_VERSION_ENCRYPTED,
134);
135
136/// The encrypted-DB packed format version (MAJOR=2, MINOR=0). Single source of
137/// truth: returns `ENCRYPTED_FORMAT_VERSION`. Called by the create path
138/// (keys.rs, superblock::new_empty_encrypted), the commit stamp, and the
139/// open-time gate — they all agree because they all call this one function.
140pub fn format_version_encrypted() -> u32 {
141 ENCRYPTED_FORMAT_VERSION
142}
143
144/// Pack a (major, minor) pair into the on-disk u32 format version.
145pub const fn pack_format_version(major: u16, minor: u16) -> u32 {
146 ((major as u32) << 16) | (minor as u32)
147}
148
149/// Extract the major-version byte pair from a packed format_version.
150pub const fn format_major(version: u32) -> u16 {
151 (version >> 16) as u16
152}
153
154/// Extract the minor-version pair from a packed `format_version`. Companion to
155/// `format_major`; used by the open-time I29 write-gate — a binary whose
156/// MINOR is below the file's must not write the file (it would drop fields it
157/// doesn't know). See ISSUES.md I29.
158pub const fn format_minor(version: u32) -> u16 {
159 (version & 0xFFFF) as u16
160}
161
162pub const FORMAT_VERSION: u32 = pack_format_version(FORMAT_MAJOR_VERSION, FORMAT_MINOR_VERSION);
163
164/// Sentinel value meaning "not yet allocated" for root page pointers
165/// (e.g. an empty database has no handle-table or freemap root yet).
166/// u64::MAX is used because 0 is a legitimate page id.
167pub const PAGE_ID_NONE: u64 = u64::MAX;
168
169/// On-disk page type tag, stored as a single byte in the common header.
170/// Discriminants are explicit and stable — changing them is a format break.
171/// 0x00 is intentionally reserved so a zeroed/uninitialized page cannot be
172/// mistaken for a valid type.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174#[repr(u8)]
175pub enum PageType {
176 HandleTable = 0x01,
177 Data = 0x02,
178 Overflow = 0x03,
179 FreeMap = 0x04,
180 MembershipInterior = 0x05,
181 MembershipLeaf = 0x06,
182 // Interior node of the multi-page radix freemap tree (COW radix of bitmap
183 // leaves). Introduced for the multi-page freemap feature; 0x07 is the next
184 // slot after MembershipLeaf. Leaf pages reuse PageType::FreeMap (0x04).
185 // No non-test constructor yet — the freemap tree implementation unit is
186 // the production caller.
187 #[allow(dead_code)]
188 FreeMapInterior = 0x07,
189}
190
191/// The per-page format version a freshly-initialized page of `page_type`
192/// stamps — the single source of truth for the write side (every `init_page`
193/// site calls this). Returns 0 for every type today. When a page type's layout
194/// gains a version-requiring field, ONLY that type's arm changes; others stay
195/// put (per-type format evolution — see docs/specs/2026-06-21-per-page-format-versioning-design.md). The match is exhaustive on purpose — adding
196/// a `PageType` variant forces a decision here rather than defaulting silently.
197/// See ISSUES.md I31.
198pub const fn current_version(page_type: PageType) -> u8 {
199 match page_type {
200 PageType::HandleTable
201 | PageType::Data
202 | PageType::Overflow
203 | PageType::FreeMap
204 | PageType::MembershipInterior
205 | PageType::MembershipLeaf
206 | PageType::FreeMapInterior => PAGE_FORMAT_VERSION_CURRENT,
207 }
208}
209
210/// Read the per-page format version stamped in `buf`. Dispatches the byte
211/// offset on the page-type tag at byte 0: HandleTable keeps byte 1 for its
212/// leaf/interior flag and stores the version at byte 2; every other
213/// non-superblock page stores it at byte 1. A reader that must distinguish
214/// layouts (an additive field where zero is a legitimate value) branches on
215/// this: `if page_format_version(buf) >= K { read field } else { default }`.
216/// See ISSUES.md I31 and docs/specs/2026-06-21-per-page-format-versioning-design.md.
217// `#[allow(dead_code)]`: forward-looking API seam — read-side version gates
218// (I31 follow-on) are not yet wired in, so no call site exists today.
219#[allow(dead_code)]
220pub fn page_format_version(buf: &[u8; PAGE_SIZE]) -> u8 {
221 if buf[0] == PageType::HandleTable as u8 {
222 buf[2]
223 } else {
224 buf[1]
225 }
226}
227
228// XXH3 was chosen over CRC32C for throughput on modern CPUs. It is a
229// non-cryptographic hash — sufficient for detecting disk corruption, NOT
230// a defense against adversarial tampering.
231
232/// Compute the XXH3 checksum for a page buffer (over bytes 0..CHECKSUM_OFFSET).
233/// The checksum region itself is excluded so that stamp/verify are symmetric.
234pub fn compute_checksum(buf: &[u8; PAGE_SIZE]) -> u64 {
235 xxh3_64(&buf[..CHECKSUM_OFFSET])
236}
237
238/// Write the checksum into the last 8 bytes of the page buffer.
239/// Must be called after every mutation and before the page is handed to
240/// page_io for writing — otherwise the next read will see a stale checksum
241/// and treat the page as corrupt.
242pub fn stamp_checksum(buf: &mut [u8; PAGE_SIZE]) {
243 let cksum = compute_checksum(buf);
244 buf[CHECKSUM_OFFSET..].copy_from_slice(&cksum.to_le_bytes());
245}
246
247/// Verify the checksum in the last 8 bytes matches the computed checksum.
248/// PageCache calls this on every read; callers should not need to invoke it
249/// directly. A `false` result must be reported as ChecksumMismatch (fatal).
250pub fn verify_checksum(buf: &[u8; PAGE_SIZE]) -> bool {
251 // try_into().unwrap() is infallible: the slice length is a compile-time
252 // constant (CHECKSUM_SIZE = 8) matching u64's byte width.
253 let stored = u64::from_le_bytes(buf[CHECKSUM_OFFSET..].try_into().unwrap());
254 let computed = compute_checksum(buf);
255 stored == computed
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 // A freshly-constructed page of any type — that is, one that has
263 // passed through its module's init_page / create_root — must stamp
264 // `PAGE_FORMAT_VERSION_CURRENT` into its version byte. Data and
265 // FreeMap pages both keep that byte at position 1 (HandleTable's is
266 // at byte 2, but it inits through a cache-aware path, below). Today
267 // CURRENT is 0, so a pre-I31 page (all header bytes zero by accident)
268 // also reports 0; the test still pins the invariant for future bumps.
269 //
270 // Lives here rather than in each page-type test module because the
271 // intent is the cross-cutting convention, not any one type's init.
272 #[test]
273 fn fresh_pages_report_current_version() {
274 // Data — version byte at offset 1.
275 let mut buf = [0u8; PAGE_SIZE];
276 crate::data_page::DataPage::init_page(&mut buf);
277 assert_eq!(buf[1], PAGE_FORMAT_VERSION_CURRENT);
278
279 // FreeMap — version byte at offset 1.
280 let mut buf = [0u8; PAGE_SIZE];
281 crate::freemap::FreeMap::init_page(&mut buf);
282 assert_eq!(buf[1], PAGE_FORMAT_VERSION_CURRENT);
283
284 // Overflow and HandleTable init through their cache-aware paths,
285 // not a free-standing helper, so exercising them here would pull
286 // PageCache into a pure-byte test. They are covered end-to-end
287 // by the existing integration tests instead.
288 }
289
290 // ── Migrated 2026-05-22 from tests/basic_ops.rs (I35 reshape) ──
291
292 #[test]
293 fn test_checksum_roundtrip() {
294 let mut buf = [0u8; PAGE_SIZE];
295 buf[0] = 0x42;
296 buf[100] = 0xFF;
297 stamp_checksum(&mut buf);
298 assert!(verify_checksum(&buf));
299 }
300
301 #[test]
302 fn test_checksum_detects_corruption() {
303 let mut buf = [0u8; PAGE_SIZE];
304 buf[0] = 0x42;
305 stamp_checksum(&mut buf);
306 buf[50] = 0xAA;
307 assert!(!verify_checksum(&buf));
308 }
309
310 #[test]
311 fn test_checksum_detects_torn_write() {
312 let mut buf = [0u8; PAGE_SIZE];
313 buf[0] = 0x42;
314 stamp_checksum(&mut buf);
315 buf[PAGE_SIZE - 2] = 0;
316 buf[PAGE_SIZE - 1] = 0;
317 assert!(!verify_checksum(&buf));
318 }
319
320 // ── Migrated 2026-05-22 from tests/error_and_format.rs (I35 reshape) ──
321
322 #[test]
323 fn test_checksum_stamp_is_deterministic() {
324 let mut a = [0u8; PAGE_SIZE];
325 let mut b = [0u8; PAGE_SIZE];
326 a[42] = 0xAB;
327 b[42] = 0xAB;
328 stamp_checksum(&mut a);
329 stamp_checksum(&mut b);
330 assert_eq!(a[CHECKSUM_OFFSET..], b[CHECKSUM_OFFSET..]);
331 }
332
333 #[test]
334 fn test_checksum_detects_every_single_byte_flip_at_sampled_offsets() {
335 // Full 8192-byte sweep is overkill in unit tests; sample a handful
336 // of offsets including the very first and last non-checksum bytes.
337 let mut base = [0u8; PAGE_SIZE];
338 for (i, b) in base.iter_mut().enumerate().take(CHECKSUM_OFFSET) {
339 *b = (i & 0xFF) as u8;
340 }
341 stamp_checksum(&mut base);
342 assert!(verify_checksum(&base));
343
344 let offsets = [0usize, 1, 127, 4096, CHECKSUM_OFFSET - 1];
345 for &off in &offsets {
346 let mut buf = base;
347 buf[off] ^= 0x01;
348 assert!(
349 !verify_checksum(&buf),
350 "bit flip at offset {off} undetected",
351 );
352 }
353 }
354
355 #[test]
356 fn test_checksum_detects_flip_in_checksum_region() {
357 // Flipping a bit in the stored checksum itself must also fail
358 // verification — the check is "computed == stored", not just
359 // "hash(body) stable".
360 let mut buf = [0u8; PAGE_SIZE];
361 buf[0] = 0xFF;
362 stamp_checksum(&mut buf);
363 buf[CHECKSUM_OFFSET] ^= 0x80;
364 assert!(!verify_checksum(&buf));
365 }
366
367 #[test]
368 fn test_compute_checksum_ignores_trailing_bytes() {
369 // compute_checksum hashes 0..CHECKSUM_OFFSET only; the last 8 bytes
370 // must not affect the result.
371 let mut a = [0u8; PAGE_SIZE];
372 let mut b = [0u8; PAGE_SIZE];
373 a[5] = 0x11;
374 b[5] = 0x11;
375 b[PAGE_SIZE - 1] = 0xFF;
376 assert_eq!(compute_checksum(&a), compute_checksum(&b));
377 }
378
379 #[test]
380 fn test_page_layout_constants_are_self_consistent() {
381 // Pin the relationships between constants so a refactor of one
382 // doesn't silently desync the others.
383 assert_eq!(CHECKSUM_OFFSET + 8, PAGE_SIZE);
384 assert_eq!(PAGE_BODY_SIZE + 16 + 8, PAGE_SIZE); // header + body + checksum
385 assert_eq!(MAGIC, 0x4348534C);
386 }
387
388 #[test]
389 fn page_format_version_dispatches_offset_on_page_type() {
390 // HandleTable: version at byte 2 (byte 1 holds the leaf/interior flag).
391 let mut buf = [0u8; PAGE_SIZE];
392 buf[0] = PageType::HandleTable as u8;
393 buf[1] = 0x01; // FLAG_LEAF — must be ignored by the version reader
394 buf[2] = 7;
395 assert_eq!(page_format_version(&buf), 7);
396
397 // Every other non-superblock type: version at byte 1.
398 for pt in [
399 PageType::Data,
400 PageType::Overflow,
401 PageType::FreeMap,
402 PageType::FreeMapInterior,
403 PageType::MembershipInterior,
404 PageType::MembershipLeaf,
405 ] {
406 let mut buf = [0u8; PAGE_SIZE];
407 buf[0] = pt as u8;
408 buf[1] = 5;
409 buf[2] = 99; // type-specific byte must not leak into the version
410 assert_eq!(page_format_version(&buf), 5, "type {pt:?}");
411 }
412 }
413
414 #[test]
415 fn current_version_is_zero_for_all_types_today() {
416 for pt in [
417 PageType::HandleTable,
418 PageType::Data,
419 PageType::Overflow,
420 PageType::FreeMap,
421 PageType::FreeMapInterior,
422 PageType::MembershipInterior,
423 PageType::MembershipLeaf,
424 ] {
425 assert_eq!(current_version(pt), 0, "type {pt:?}");
426 }
427 }
428
429 #[test]
430 fn freemap_interior_type_tag_and_version() {
431 assert_eq!(PageType::FreeMapInterior as u8, 0x07);
432 assert_eq!(
433 current_version(PageType::FreeMapInterior),
434 PAGE_FORMAT_VERSION_CURRENT
435 );
436 }
437
438 #[test]
439 fn format_minor_extracts_low_16_bits() {
440 let v = pack_format_version(3, 42);
441 assert_eq!(format_major(v), 3);
442 assert_eq!(format_minor(v), 42);
443 assert_eq!(format_minor(FORMAT_VERSION), FORMAT_MINOR_VERSION);
444 }
445}