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}