Skip to main content

obj_core/pager/
header.rs

1//! Page-0 file header — encode / decode.
2//!
3//! The on-disk layout is described byte-by-byte in `docs/format.md`;
4//! this module is its reference implementation. Field offsets and
5//! sizes are encoded as `const` items rather than magic numbers in the
6//! function bodies so a reviewer can audit the layout against the spec
7//! at a glance.
8//!
9//! At milestone M2 the header's `header_crc32c` field is **reserved**
10//! (written as four zero bytes, not verified on read). Issue #6 wires
11//! the CRC32C algorithm; this module is structured so that change is a
12//! one-line edit to [`encode_header`] and [`decode_header`].
13
14#![forbid(unsafe_code)]
15
16use crate::error::{Error, Result};
17use crate::pager::checksum::crc32c;
18use crate::pager::page::{Page, PAGE_SIZE};
19
20/// File magic. ASCII `OBJF`.
21pub const MAGIC: [u8; 4] = *b"OBJF";
22
23/// Format major version implemented by this build.
24///
25/// Phase 8 (issue #17): bumped from `0` to `1` for the v1.0 freeze.
26/// Every new database the v1.0.0 writer creates stamps
27/// `format_major = 1` on page 0. Readers accept `format_major ∈
28/// {0, 1}` so pre-1.0 (0.x-era) databases continue to open
29/// without a migration tool; see `SUPPORTED_FORMAT_MAJORS`
30/// (crate-private).
31pub const FORMAT_MAJOR: u16 = 1;
32/// The set of `format_major` values this build's reader accepts.
33///
34/// - `0` — pre-1.0 (0.x) databases. Read-compatible only; v1.0
35///   writers never produce new `format_major = 0` files.
36/// - `1` — v1.0 frozen wire format (this build's writer).
37///
38/// A future v2.0 build will reject `format_major = 0` and `1` in
39/// favour of `2`; see `docs/format.md` § Reserved for Future
40/// Compatibility for the migration policy.
41///
42/// Private to the crate so the v1.0 public API surface stays
43/// pinned at the Phase 7B baseline; the WAL recovery path
44/// re-exports the helper [`is_supported_format_major`].
45pub(crate) const SUPPORTED_FORMAT_MAJORS: &[u16] = &[0, 1];
46
47/// Crate-private predicate over [`SUPPORTED_FORMAT_MAJORS`].
48///
49/// Exposed for the WAL header reader, which validates that a
50/// `-wal` sidecar's `format_major` field is in the same set the
51/// page-0 decoder accepts. Public on `pub(crate)` to avoid
52/// duplicating the slice in the WAL module while keeping the v1.0
53/// public-API surface unchanged.
54#[must_use]
55pub(crate) fn is_supported_format_major(format_major: u16) -> bool {
56    SUPPORTED_FORMAT_MAJORS.contains(&format_major)
57}
58/// Format minor version implemented by this build.
59///
60/// - `0` — pre-1.0 baseline (`format_major = 0` only). Full-32-bit
61///   CRC32C per-page trailer; no compression, no encryption.
62/// - `1` — pre-1.0 compression-capable layout (`format_major = 0`
63///   only). When `feature_flags` bit 0 is set, every non-header
64///   page on disk uses the v1 trailer interpretation (bit 31 =
65///   "page is LZ4-compressed", bits 0..30 = 31-bit CRC32C).
66/// - `2` — feature-complete encryption-capable layout. The ONLY
67///   valid `format_minor` for `format_major = 1` files. Page 0
68///   carries a 32-byte `kdf_salt` field at offset 72..104, and
69///   when `feature_flags` bit 1 is set every non-header page on
70///   disk is encrypted with XChaCha20-Poly1305 (4136-byte
71///   physical stride: 4096 ciphertext + 24-byte nonce + 16-byte
72///   tag). Compression (bit 0) and encryption (bit 1) compose:
73///   compress first, encrypt second.
74///
75/// Phase 8 (issue #17) freezes `FORMAT_MINOR` at `2` for the
76/// indefinite v1.x series — no further minor bumps without a
77/// `format_major` 2.0 release.
78pub const FORMAT_MINOR: u16 = 2;
79
80/// `feature_flags` bit indicating the file uses per-page LZ4
81/// compression (Phase 3 issue #8). Set on creation by a `Pager`
82/// opened with `Config::compression_mode = CompressionMode::Lz4`.
83pub const FEATURE_FLAG_COMPRESSION: u32 = 1 << 0;
84
85/// `feature_flags` bit indicating the file uses per-page
86/// `XChaCha20-Poly1305` encryption (Phase 4 issue #9). Set on
87/// creation by a `Pager` whose `Config::encryption_key` is
88/// `Some(_)`. When this bit is set, every non-header page on
89/// disk is `4096 + 24 + 16 = 4136` bytes physical (ciphertext
90/// plus nonce plus Poly1305 tag), and the page-0 `kdf_salt`
91/// field (offset 72..104) carries the 32-byte HKDF-SHA256 salt
92/// used to derive the per-file page encryption key from the
93/// caller's user key.
94pub const FEATURE_FLAG_ENCRYPTION: u32 = 1 << 1;
95
96/// Mask of all `feature_flags` bits this build understands. Any
97/// bit set in the on-disk header but NOT in this mask is rejected
98/// at open time with [`Error::InvalidFormat`]: an unknown flag
99/// might change how subsequent bytes are interpreted, and a reader
100/// that does not know what it means MUST refuse to guess.
101pub const FEATURE_FLAGS_KNOWN: u32 = FEATURE_FLAG_COMPRESSION | FEATURE_FLAG_ENCRYPTION;
102
103/// `PAGE_SIZE` (4096) expressed as a `u16` for the on-disk header
104/// field. A `const` assertion below pins the value so the cast is
105/// audit-grade rather than a magic literal.
106const PAGE_SIZE_U16: u16 = 4096;
107const _: () = assert!(PAGE_SIZE_U16 as usize == PAGE_SIZE);
108
109// --- Field offsets within page 0. See docs/format.md. -----------------
110const OFF_MAGIC: usize = 0;
111const OFF_FORMAT_MAJOR: usize = 4;
112const OFF_FORMAT_MINOR: usize = 6;
113const OFF_PAGE_SIZE: usize = 8;
114// Phase 3 (issue #8): the first 4 of the 6 reserved-a bytes at
115// offset 10..16 are now `feature_flags: u32 LE`. The remaining 2
116// bytes (offset 14..16) stay reserved and MUST be zero on writers.
117const OFF_FEATURE_FLAGS: usize = 10;
118const OFF_PAGE_COUNT: usize = 16;
119const OFF_ROOT_CATALOG: usize = 24;
120const OFF_FREELIST_HEAD: usize = 32;
121const OFF_WAL_SALT: usize = 40;
122const OFF_FILE_UUID: usize = 56;
123// Phase 4 (issue #9): `kdf_salt: [u8; 32]` at offset 72..104.
124// Placed immediately after `file_uuid` so the named-field region
125// of page 0 stays contiguous. Always zero on `format_minor < 2`
126// files (the old `_reserved` region was zero-init by convention).
127// `format_minor = 2` files MUST carry a CSPRNG-generated salt
128// here when `feature_flags` bit 1 is set; the HKDF-SHA256
129// per-file page key is derived from `(user_key, kdf_salt)`.
130const OFF_KDF_SALT: usize = 72;
131const OFF_HEADER_CRC: usize = PAGE_SIZE - 4;
132
133/// In-memory representation of the page-0 file header.
134///
135/// Constructed by [`decode_header`] or by the pager when initialising
136/// a new file. Field semantics are documented in `docs/format.md`.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct FileHeader {
139    /// Format major version. Must equal [`FORMAT_MAJOR`].
140    pub format_major: u16,
141    /// Format minor version. Must satisfy `<=` [`FORMAT_MINOR`] for
142    /// write access; readers tolerate higher minors.
143    pub format_minor: u16,
144    /// On-disk page size. Must equal [`PAGE_SIZE`] at format major 0.
145    pub page_size: u16,
146    /// Phase 3 (issue #8): per-file feature-bit mask. Bit 0 =
147    /// "uses LZ4 page compression"; other bits reserved (MUST be
148    /// zero — readers reject unknown bits as
149    /// [`Error::InvalidFormat`]).
150    pub feature_flags: u32,
151    /// Number of pages in the file, including page 0.
152    pub page_count: u64,
153    /// Root catalog page-id, or `0` if the catalog is empty.
154    pub root_catalog: u64,
155    /// First page on the freelist, or `0` if the freelist is empty.
156    pub freelist_head: u64,
157    /// Salt for WAL frame hashes. Written by M3; zero in M2.
158    pub wal_salt: [u8; 16],
159    /// Stable file UUID. Written by M3; zero in M2.
160    pub file_uuid: [u8; 16],
161    /// Phase 4 (issue #9): 32-byte salt for the HKDF-SHA256
162    /// per-file page-key derivation. Plaintext on disk (page 0
163    /// is never encrypted); the file's actual page-encryption
164    /// key is `HKDF-SHA256(ikm=user_key, salt=kdf_salt,
165    /// info=b"obj-page-encryption-v1")`. Always zero on
166    /// `format_minor < 2` files; CSPRNG-generated on creation
167    /// of `format_minor = 2` files with `feature_flags` bit 1
168    /// set.
169    ///
170    /// #60 (integrity posture): the `kdf_salt` lives in the
171    /// plaintext page-0 header and is protected ONLY by the
172    /// header's own CRC. It is NOT bound into any page's AEAD
173    /// associated data (page AD is just `page_id`; see
174    /// `crypto.rs`), so the AEAD tag does not authenticate it.
175    /// Its integrity therefore rests on two independent layers:
176    /// (1) the page-0 header CRC detects accidental corruption,
177    /// and (2) any tampering that survives the CRC changes the
178    /// derived page key, which surfaces as
179    /// `Error::EncryptionKeyInvalid` (wrong-key detection) on the
180    /// first page decrypt rather than as silent plaintext
181    /// disclosure. Binding the salt into page AD is deliberately
182    /// NOT done — it would be a format-affecting change.
183    pub kdf_salt: [u8; 32],
184}
185
186impl FileHeader {
187    /// Header for a freshly-initialised database: just page 0, no
188    /// catalog, empty freelist, zero WAL salt and UUID (M3 fills the
189    /// latter two).
190    ///
191    /// Phase 8 (issue #17): every v1.0 writer stamps
192    /// `format_major = 1, format_minor = 2` — the feature-complete
193    /// frozen baseline. `feature_flags = 0` because this constructor
194    /// produces a plain (no-compression, no-encryption) file; the
195    /// other `new_empty_*` constructors set the corresponding
196    /// `feature_flags` bits.
197    #[must_use]
198    pub const fn new_empty() -> Self {
199        Self {
200            format_major: FORMAT_MAJOR,
201            format_minor: FORMAT_MINOR,
202            page_size: PAGE_SIZE_U16,
203            feature_flags: 0,
204            page_count: 1,
205            root_catalog: 0,
206            freelist_head: 0,
207            wal_salt: [0; 16],
208            file_uuid: [0; 16],
209            kdf_salt: [0; 32],
210        }
211    }
212
213    /// Phase 3 (issue #8): header for a freshly-initialised
214    /// compression-capable database. `feature_flags` bit 0 set;
215    /// `format_minor` is the frozen v1.0 feature-complete value
216    /// ([`FORMAT_MINOR`] = 2). Everything else matches
217    /// [`FileHeader::new_empty`].
218    #[must_use]
219    pub const fn new_empty_with_compression() -> Self {
220        Self {
221            format_major: FORMAT_MAJOR,
222            format_minor: FORMAT_MINOR,
223            page_size: PAGE_SIZE_U16,
224            feature_flags: FEATURE_FLAG_COMPRESSION,
225            page_count: 1,
226            root_catalog: 0,
227            freelist_head: 0,
228            wal_salt: [0; 16],
229            file_uuid: [0; 16],
230            kdf_salt: [0; 32],
231        }
232    }
233
234    /// Phase 4 (issue #9): header for a freshly-initialised
235    /// encryption-capable database. `format_minor = 2`,
236    /// `feature_flags` bit 1 set, `kdf_salt` populated from the
237    /// caller-supplied CSPRNG bytes. Compression (bit 0) is
238    /// left OFF; the higher-level
239    /// [`FileHeader::new_empty_with_encryption_and_compression`]
240    /// constructor sets both bits.
241    #[must_use]
242    pub const fn new_empty_with_encryption(kdf_salt: [u8; 32]) -> Self {
243        Self {
244            format_major: FORMAT_MAJOR,
245            format_minor: FORMAT_MINOR,
246            page_size: PAGE_SIZE_U16,
247            feature_flags: FEATURE_FLAG_ENCRYPTION,
248            page_count: 1,
249            root_catalog: 0,
250            freelist_head: 0,
251            wal_salt: [0; 16],
252            file_uuid: [0; 16],
253            kdf_salt,
254        }
255    }
256
257    /// Phase 4 (issue #9): header for a freshly-initialised
258    /// database that uses BOTH compression AND encryption. The
259    /// layering order is compress-then-encrypt: the 4092-byte
260    /// raw body is compressed (Phase 3 path), the resulting
261    /// 4096-byte logical page is encrypted (Phase 4 path), and
262    /// the encrypted ciphertext (+ nonce + tag) lands on disk
263    /// as a 4136-byte physical page.
264    #[must_use]
265    pub const fn new_empty_with_encryption_and_compression(kdf_salt: [u8; 32]) -> Self {
266        Self {
267            format_major: FORMAT_MAJOR,
268            format_minor: FORMAT_MINOR,
269            page_size: PAGE_SIZE_U16,
270            feature_flags: FEATURE_FLAG_COMPRESSION | FEATURE_FLAG_ENCRYPTION,
271            page_count: 1,
272            root_catalog: 0,
273            freelist_head: 0,
274            wal_salt: [0; 16],
275            file_uuid: [0; 16],
276            kdf_salt,
277        }
278    }
279}
280
281/// Encode `header` into `page`. Power-of-ten Rule 5: invariants the
282/// caller is supposed to uphold are documented via `debug_assert!`.
283pub fn encode_header(header: &FileHeader, page: &mut Page) {
284    debug_assert_eq!(
285        header.page_size as usize, PAGE_SIZE,
286        "every supported format major fixes PAGE_SIZE at 4096",
287    );
288    // Phase 8 (issue #17): the encoder re-stamps the page-0 header
289    // any time a `Pager` mutation lands (page_count, root_catalog,
290    // freelist_head, ...). When a v1.0 build opens a pre-1.0
291    // (`format_major = 0`) database, those writes must preserve the
292    // file's original major so a subsequent open by another 0.x
293    // reader keeps working. The encoder therefore accepts any major
294    // in [`SUPPORTED_FORMAT_MAJORS`]; new files are created with
295    // [`FileHeader::new_empty`] et al. which already stamp
296    // [`FORMAT_MAJOR`].
297    debug_assert!(
298        SUPPORTED_FORMAT_MAJORS.contains(&header.format_major),
299        "encoder only writes a format major this build supports",
300    );
301
302    let buf = page.as_bytes_mut();
303    buf.fill(0);
304    buf[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&MAGIC);
305    buf[OFF_FORMAT_MAJOR..OFF_FORMAT_MAJOR + 2].copy_from_slice(&header.format_major.to_le_bytes());
306    buf[OFF_FORMAT_MINOR..OFF_FORMAT_MINOR + 2].copy_from_slice(&header.format_minor.to_le_bytes());
307    buf[OFF_PAGE_SIZE..OFF_PAGE_SIZE + 2].copy_from_slice(&header.page_size.to_le_bytes());
308    // Phase 3 (issue #8): feature_flags at offset 10..14. The 2
309    // reserved bytes at offset 14..16 stay zero from the `buf.fill(0)`
310    // above.
311    buf[OFF_FEATURE_FLAGS..OFF_FEATURE_FLAGS + 4]
312        .copy_from_slice(&header.feature_flags.to_le_bytes());
313    buf[OFF_PAGE_COUNT..OFF_PAGE_COUNT + 8].copy_from_slice(&header.page_count.to_le_bytes());
314    buf[OFF_ROOT_CATALOG..OFF_ROOT_CATALOG + 8].copy_from_slice(&header.root_catalog.to_le_bytes());
315    buf[OFF_FREELIST_HEAD..OFF_FREELIST_HEAD + 8]
316        .copy_from_slice(&header.freelist_head.to_le_bytes());
317    buf[OFF_WAL_SALT..OFF_WAL_SALT + 16].copy_from_slice(&header.wal_salt);
318    buf[OFF_FILE_UUID..OFF_FILE_UUID + 16].copy_from_slice(&header.file_uuid);
319    // Phase 4 (issue #9): kdf_salt at offset 72..104. Zero on
320    // pre-encryption files.
321    buf[OFF_KDF_SALT..OFF_KDF_SALT + 32].copy_from_slice(&header.kdf_salt);
322
323    // Header CRC32C covers bytes [0, OFF_HEADER_CRC). The slice ends
324    // before the four-byte checksum field itself, exactly as the
325    // format spec describes.
326    let crc = crc32c(&buf[..OFF_HEADER_CRC]);
327    buf[OFF_HEADER_CRC..OFF_HEADER_CRC + 4].copy_from_slice(&crc.to_le_bytes());
328}
329
330/// Decode the page-0 header from the given `page` buffer. Validates
331/// magic, page-size, major version, the per-major `format_minor`
332/// constraint, and the `header_crc32c` field.
333///
334/// Phase 8 (issue #17): readers accept any
335/// `SUPPORTED_FORMAT_MAJORS` value (`0` for pre-1.0 databases,
336/// `1` for v1.0+). The per-major `format_minor` constraint is:
337///
338/// - `format_major = 0` → `format_minor ∈ {0, 1, 2}` (the pre-1.0
339///   incremental rollout: baseline, compression-capable,
340///   encryption-capable).
341/// - `format_major = 1` → `format_minor = 2` (the v1.0 frozen
342///   feature-complete value; the only valid minor inside the v1.x
343///   series).
344///
345/// # Errors
346///
347/// - [`Error::InvalidFormat`] if the magic bytes do not match, if
348///   `format_major` is unsupported by this build, if
349///   `format_minor` is not valid for the file's `format_major`,
350///   or if `page_size` disagrees with [`PAGE_SIZE`].
351/// - [`Error::Corruption`] with `page_id = 0` if the stored
352///   `header_crc32c` does not match the CRC32C of the rest of the
353///   page.
354pub fn decode_header(page: &Page) -> Result<FileHeader> {
355    let buf = page.as_bytes();
356    if buf[OFF_MAGIC..OFF_MAGIC + 4] != MAGIC {
357        return Err(Error::InvalidFormat {
358            reason: "magic bytes are not OBJF",
359        });
360    }
361    let format_major = u16::from_le_bytes(read_array(buf, OFF_FORMAT_MAJOR));
362    if !SUPPORTED_FORMAT_MAJORS.contains(&format_major) {
363        return Err(Error::InvalidFormat {
364            reason: "format-major version not supported",
365        });
366    }
367    let format_minor = u16::from_le_bytes(read_array::<2>(buf, OFF_FORMAT_MINOR));
368    if !is_supported_minor(format_major, format_minor) {
369        return Err(Error::InvalidFormat {
370            reason: "format-minor not valid for the file's format-major",
371        });
372    }
373    let page_size = u16::from_le_bytes(read_array(buf, OFF_PAGE_SIZE));
374    if usize::from(page_size) != PAGE_SIZE {
375        return Err(Error::InvalidFormat {
376            reason: "page size does not match this build",
377        });
378    }
379    let stored_crc = u32::from_le_bytes(read_array::<4>(buf, OFF_HEADER_CRC));
380    let computed_crc = crc32c(&buf[..OFF_HEADER_CRC]);
381    if stored_crc != computed_crc {
382        return Err(Error::Corruption { page_id: 0 });
383    }
384    // Phase 3 (issue #8): feature_flags at offset 10..14. Bytes
385    // 14..16 are reserved and MUST be zero. Reject any unknown
386    // feature bit; an unknown flag might change how on-disk bytes
387    // are interpreted and the reader MUST NOT guess (Rule 5).
388    let feature_flags = u32::from_le_bytes(read_array::<4>(buf, OFF_FEATURE_FLAGS));
389    if feature_flags & !FEATURE_FLAGS_KNOWN != 0 {
390        return Err(Error::InvalidFormat {
391            reason: "unknown feature_flags bit set on page-0 header",
392        });
393    }
394    let reserved_after_flags =
395        u16::from_le_bytes([buf[OFF_FEATURE_FLAGS + 4], buf[OFF_FEATURE_FLAGS + 5]]);
396    if reserved_after_flags != 0 {
397        return Err(Error::InvalidFormat {
398            reason: "reserved bytes after feature_flags must be zero",
399        });
400    }
401    Ok(FileHeader {
402        format_major,
403        format_minor,
404        page_size,
405        feature_flags,
406        page_count: u64::from_le_bytes(read_array(buf, OFF_PAGE_COUNT)),
407        root_catalog: u64::from_le_bytes(read_array(buf, OFF_ROOT_CATALOG)),
408        freelist_head: u64::from_le_bytes(read_array(buf, OFF_FREELIST_HEAD)),
409        wal_salt: read_array(buf, OFF_WAL_SALT),
410        file_uuid: read_array(buf, OFF_FILE_UUID),
411        kdf_salt: read_array(buf, OFF_KDF_SALT),
412    })
413}
414
415/// Read a fixed-size array out of the page buffer. Used by
416/// [`decode_header`] to avoid `unwrap` on `try_into` (Rule 7).
417fn read_array<const N: usize>(buf: &[u8; PAGE_SIZE], off: usize) -> [u8; N] {
418    // `off + N <= PAGE_SIZE` is established by the caller (all
419    // call-sites use compile-time constants checked by debug_assert).
420    debug_assert!(off + N <= PAGE_SIZE, "header field out of bounds");
421    let mut out = [0u8; N];
422    out.copy_from_slice(&buf[off..off + N]);
423    out
424}
425
426/// Phase 8 (issue #17): per-major `format_minor` enforcement.
427///
428/// v1.0 freezes `format_minor = 2` as the only minor for
429/// `format_major = 1`; pre-1.0 (`format_major = 0`) files keep
430/// their historical `format_minor ∈ {0, 1, 2}` range. Any other
431/// pairing (including any major outside [`SUPPORTED_FORMAT_MAJORS`])
432/// returns `false`; the caller surfaces that as
433/// [`Error::InvalidFormat`].
434pub(crate) fn is_supported_minor(format_major: u16, format_minor: u16) -> bool {
435    match format_major {
436        0 => (0..=2).contains(&format_minor),
437        1 => format_minor == FORMAT_MINOR,
438        _ => false,
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::{decode_header, encode_header, FileHeader, FORMAT_MAJOR, FORMAT_MINOR};
445    use crate::pager::page::Page;
446
447    #[test]
448    fn round_trip_default_header() {
449        let h = FileHeader::new_empty();
450        let mut p = Page::zeroed();
451        encode_header(&h, &mut p);
452        let decoded = decode_header(&p).expect("encode/decode round-trip");
453        assert_eq!(decoded, h);
454    }
455
456    #[test]
457    fn round_trip_non_default_header() {
458        let h = FileHeader {
459            format_major: FORMAT_MAJOR,
460            format_minor: FORMAT_MINOR,
461            page_size: 4096,
462            feature_flags: 0,
463            page_count: 17,
464            root_catalog: 2,
465            freelist_head: 3,
466            wal_salt: [0xAA; 16],
467            file_uuid: [0xCC; 16],
468            kdf_salt: [0; 32],
469        };
470        let mut p = Page::zeroed();
471        encode_header(&h, &mut p);
472        assert_eq!(decode_header(&p).expect("round-trip"), h);
473    }
474
475    #[test]
476    fn round_trip_compression_header() {
477        let h = FileHeader {
478            format_major: FORMAT_MAJOR,
479            format_minor: FORMAT_MINOR,
480            page_size: 4096,
481            feature_flags: super::FEATURE_FLAG_COMPRESSION,
482            page_count: 5,
483            root_catalog: 0,
484            freelist_head: 0,
485            wal_salt: [0; 16],
486            file_uuid: [0; 16],
487            kdf_salt: [0; 32],
488        };
489        let mut p = Page::zeroed();
490        encode_header(&h, &mut p);
491        assert_eq!(decode_header(&p).expect("round-trip"), h);
492    }
493
494    #[test]
495    fn round_trip_encryption_header() {
496        // Phase 4 (issue #9): format_minor = FORMAT_MINOR (= 2) +
497        // feature_flags bit 1 round-trips a non-zero `kdf_salt`.
498        let mut salt = [0u8; 32];
499        for (i, b) in salt.iter_mut().enumerate() {
500            *b = u8::try_from(i & 0xFF).unwrap_or(0);
501        }
502        let h = FileHeader {
503            format_major: FORMAT_MAJOR,
504            format_minor: FORMAT_MINOR,
505            page_size: 4096,
506            feature_flags: super::FEATURE_FLAG_ENCRYPTION,
507            page_count: 5,
508            root_catalog: 0,
509            freelist_head: 0,
510            wal_salt: [0; 16],
511            file_uuid: [0; 16],
512            kdf_salt: salt,
513        };
514        let mut p = Page::zeroed();
515        encode_header(&h, &mut p);
516        assert_eq!(decode_header(&p).expect("round-trip"), h);
517    }
518
519    #[test]
520    fn round_trip_encryption_and_compression_header() {
521        let h = FileHeader {
522            format_major: FORMAT_MAJOR,
523            format_minor: FORMAT_MINOR,
524            page_size: 4096,
525            feature_flags: super::FEATURE_FLAG_COMPRESSION | super::FEATURE_FLAG_ENCRYPTION,
526            page_count: 5,
527            root_catalog: 0,
528            freelist_head: 0,
529            wal_salt: [0; 16],
530            file_uuid: [0; 16],
531            kdf_salt: [0x77; 32],
532        };
533        let mut p = Page::zeroed();
534        encode_header(&h, &mut p);
535        assert_eq!(decode_header(&p).expect("round-trip"), h);
536    }
537
538    #[test]
539    fn rejects_unknown_feature_flag() {
540        let mut h = FileHeader::new_empty();
541        // bit 2 is reserved (FEATURE_FLAGS_KNOWN only covers bits 0 + 1).
542        h.feature_flags = 0b100;
543        let mut p = Page::zeroed();
544        encode_header(&h, &mut p);
545        let err = decode_header(&p).expect_err("unknown flag must fail");
546        assert!(matches!(err, crate::error::Error::InvalidFormat { .. }));
547    }
548
549    /// Phase 8 (issue #17): backward-compat reader contract. A
550    /// pre-1.0 (`format_major = 0`) file with the baseline
551    /// `format_minor = 0` MUST open under the v1.0 reader. We
552    /// synthesize one by hand (no v1.0 writer can produce a
553    /// `format_major = 0` file) and confirm `decode_header`
554    /// accepts it.
555    #[test]
556    fn decodes_legacy_format_major_zero_minor_zero() {
557        let h = FileHeader {
558            format_major: 0,
559            format_minor: 0,
560            page_size: 4096,
561            feature_flags: 0,
562            page_count: 7,
563            root_catalog: 0,
564            freelist_head: 0,
565            wal_salt: [0x11; 16],
566            file_uuid: [0x22; 16],
567            kdf_salt: [0; 32],
568        };
569        let mut p = Page::zeroed();
570        encode_header(&h, &mut p);
571        let decoded = decode_header(&p).expect("legacy 0.x file must decode");
572        assert_eq!(decoded, h);
573        assert_eq!(decoded.format_major, 0);
574        assert_eq!(decoded.format_minor, 0);
575    }
576
577    /// Phase 8 (issue #17): a pre-1.0 compression-capable file
578    /// (`format_major = 0, format_minor = 1`) MUST open under
579    /// the v1.0 reader.
580    #[test]
581    fn decodes_legacy_format_major_zero_minor_one() {
582        let h = FileHeader {
583            format_major: 0,
584            format_minor: 1,
585            page_size: 4096,
586            feature_flags: super::FEATURE_FLAG_COMPRESSION,
587            page_count: 3,
588            root_catalog: 0,
589            freelist_head: 0,
590            wal_salt: [0; 16],
591            file_uuid: [0; 16],
592            kdf_salt: [0; 32],
593        };
594        let mut p = Page::zeroed();
595        encode_header(&h, &mut p);
596        let decoded = decode_header(&p).expect("0.x compression-capable must decode");
597        assert_eq!(decoded, h);
598    }
599
600    /// Phase 8 (issue #17): `format_major = 2` (a hypothetical
601    /// future-major file) MUST be rejected with `InvalidFormat`.
602    /// The reader never silently misinterprets bytes from an
603    /// unknown major. Synthesised by hand because the encoder
604    /// `debug_assert` forbids constructing a `FileHeader` with
605    /// `format_major = 2`.
606    #[test]
607    fn rejects_unsupported_format_major_two() {
608        // Build a syntactically valid page-0 by encoding a known
609        // header, then overwrite the format_major field to `2`
610        // and recompute the CRC so the major-check fires before
611        // the CRC check.
612        let h = FileHeader::new_empty();
613        let mut p = Page::zeroed();
614        encode_header(&h, &mut p);
615        p.as_bytes_mut()[super::OFF_FORMAT_MAJOR..super::OFF_FORMAT_MAJOR + 2]
616            .copy_from_slice(&2u16.to_le_bytes());
617        let crc = super::crc32c(&p.as_bytes()[..super::OFF_HEADER_CRC]);
618        p.as_bytes_mut()[super::OFF_HEADER_CRC..super::OFF_HEADER_CRC + 4]
619            .copy_from_slice(&crc.to_le_bytes());
620        let err = decode_header(&p).expect_err("format_major = 2 must be rejected");
621        assert!(
622            matches!(err, crate::error::Error::InvalidFormat { reason }
623                if reason.contains("format-major")),
624            "expected InvalidFormat reason mentioning format-major; got {err:?}",
625        );
626    }
627
628    /// Phase 8 (issue #17): a `format_major = 1` file with
629    /// `format_minor = 0` or `1` must be rejected. The v1.0
630    /// freeze locks the only valid minor for major 1 at 2.
631    #[test]
632    fn rejects_format_major_one_with_legacy_minor() {
633        for bad_minor in [0u16, 1u16] {
634            let h = FileHeader::new_empty();
635            let mut p = Page::zeroed();
636            encode_header(&h, &mut p);
637            p.as_bytes_mut()[super::OFF_FORMAT_MINOR..super::OFF_FORMAT_MINOR + 2]
638                .copy_from_slice(&bad_minor.to_le_bytes());
639            let crc = super::crc32c(&p.as_bytes()[..super::OFF_HEADER_CRC]);
640            p.as_bytes_mut()[super::OFF_HEADER_CRC..super::OFF_HEADER_CRC + 4]
641                .copy_from_slice(&crc.to_le_bytes());
642            let err = decode_header(&p).expect_err("format_major = 1 + legacy minor must fail");
643            assert!(
644                matches!(err, crate::error::Error::InvalidFormat { reason }
645                    if reason.contains("format-minor")),
646                "expected InvalidFormat reason mentioning format-minor; got {err:?}",
647            );
648        }
649    }
650
651    #[test]
652    fn rejects_nonzero_reserved_after_feature_flags() {
653        let h = FileHeader::new_empty();
654        let mut p = Page::zeroed();
655        encode_header(&h, &mut p);
656        // Corrupt bytes 14..16 (reserved after feature_flags) and
657        // recompute the CRC so the bad-reserved check fires before
658        // the CRC check.
659        p.as_bytes_mut()[14] = 0xFF;
660        let crc = super::crc32c(&p.as_bytes()[..super::OFF_HEADER_CRC]);
661        p.as_bytes_mut()[super::OFF_HEADER_CRC..super::OFF_HEADER_CRC + 4]
662            .copy_from_slice(&crc.to_le_bytes());
663        let err = decode_header(&p).expect_err("nonzero reserved must fail");
664        assert!(matches!(err, crate::error::Error::InvalidFormat { .. }));
665    }
666
667    #[test]
668    fn rejects_bad_magic() {
669        let h = FileHeader::new_empty();
670        let mut p = Page::zeroed();
671        encode_header(&h, &mut p);
672        p.as_bytes_mut()[0] = b'X';
673        let err = decode_header(&p).expect_err("bad magic must fail");
674        assert!(matches!(err, crate::error::Error::InvalidFormat { .. }));
675    }
676}