Skip to main content

obj_core/pager/
page.rs

1//! Page primitives: the fixed page size, the strongly-typed page id,
2//! and the owned page buffer.
3//!
4//! All multi-byte integers in the on-disk page format are little-endian;
5//! this module is the single place that knows that fact for the M2
6//! pager. Encoders and decoders for individual page bodies live next
7//! to their owners (header in [`super::header`], freelist in
8//! [`super::freelist`]).
9
10#![forbid(unsafe_code)]
11
12use core::num::NonZeroU64;
13
14/// Page size in bytes. Fixed at 4 KiB for format version 0 (see
15/// `docs/format.md`). Parameterising this is deferred to a future
16/// milestone; the header reserves the byte for it.
17pub const PAGE_SIZE: usize = 4096;
18
19/// Number of bytes a CRC32C page trailer occupies. The trailer is
20/// written by [`super::Pager::write_page`] in milestone M3 issue #6;
21/// at issue #5 the trailing four bytes are reserved.
22pub const PAGE_TRAILER_SIZE: usize = 4;
23
24/// Phase 4 (issue #9): per-page on-disk overhead added by the
25/// encryption layer (24-byte XChaCha20-Poly1305 nonce + 16-byte
26/// Poly1305 tag = 40 bytes). Defined as a public constant in
27/// `pager::page` so callers that don't link the `encryption` Cargo
28/// feature can still reason about the encrypted physical stride
29/// (e.g. recovery tools that need to compute file offsets without
30/// invoking any crypto). Kept in lock-step with
31/// `crypto::ENCRYPTION_OVERHEAD` (24-byte nonce + 16-byte tag).
32pub const ENCRYPTION_OVERHEAD: usize = 24 + 16;
33
34/// Phase 4 (issue #9): physical-stride helper. Returns the on-disk
35/// size of a single non-header page given the file's
36/// `feature_flags` bit-set:
37///
38/// - When encryption bit 1 is set: `PAGE_SIZE + ENCRYPTION_OVERHEAD`
39///   = 4136 bytes (4096-byte ciphertext + 24-byte nonce + 16-byte
40///   tag).
41/// - Otherwise: `PAGE_SIZE` (4096 bytes).
42///
43/// Page 0 (the file header) is ALWAYS 4096 bytes regardless of
44/// `feature_flags` — the header carries the plaintext signal that
45/// tells a reader how to compute offsets for pages 1..N.
46#[must_use]
47pub const fn physical_page_stride(feature_flags: u32) -> usize {
48    if feature_flags & (1u32 << 1) != 0 {
49        PAGE_SIZE + ENCRYPTION_OVERHEAD
50    } else {
51        PAGE_SIZE
52    }
53}
54
55/// Phase 4 (issue #9): byte offset of page `raw_id`'s on-disk slot
56/// for a file with the given `feature_flags`.
57///
58/// Page 0 is always at offset 0 (4096 bytes, plaintext). Pages 1..N
59/// stride by [`physical_page_stride`] starting at byte 4096.
60///
61/// Total function: arithmetic uses `u64`, and a realistic file is
62/// orders of magnitude below `u64::MAX / stride`. Returns
63/// `u64::MAX` only on a contrived overflow input — the pager's
64/// `alloc_fresh` checks the resulting file length against a real
65/// `set_len` call which surfaces an `EINVAL`-style I/O error well
66/// below this saturation point.
67#[must_use]
68pub fn physical_offset_for(raw_id: u64, feature_flags: u32) -> u64 {
69    if raw_id == 0 {
70        return 0;
71    }
72    let stride = physical_page_stride(feature_flags) as u64;
73    // Page 0 occupies bytes [0, PAGE_SIZE). Page N (>= 1) starts
74    // at PAGE_SIZE + (N-1) * stride.
75    let after_header = PAGE_SIZE as u64;
76    let Some(rel) = raw_id.checked_sub(1).and_then(|n| n.checked_mul(stride)) else {
77        return u64::MAX;
78    };
79    after_header.saturating_add(rel)
80}
81
82/// Identifier of a page in a database file.
83///
84/// `PageId` is `NonZeroU64` so the on-disk value `0` can be used as a
85/// sentinel "no page" marker (e.g. the freelist-empty case). This
86/// follows power-of-ten Rule 5: encode the invariant in the type so
87/// neither the compiler nor a reviewer has to remember it.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
89pub struct PageId(NonZeroU64);
90
91impl PageId {
92    /// Construct a `PageId` from a raw `u64`. Returns `None` if `raw`
93    /// is `0`. Prefer this over the `unsafe` `NonZeroU64::new_unchecked`
94    /// (Rule 8: no `unsafe` outside the platform layer).
95    // #87: trivial constructor on the page-load hot path, called
96    // across the obj-db → obj-core boundary; inline to elide the call.
97    #[inline]
98    #[must_use]
99    pub const fn new(raw: u64) -> Option<Self> {
100        match NonZeroU64::new(raw) {
101            Some(nz) => Some(Self(nz)),
102            None => None,
103        }
104    }
105
106    /// Construct a `PageId` from a `NonZeroU64`. Total function.
107    #[must_use]
108    pub const fn from_nonzero(nz: NonZeroU64) -> Self {
109        Self(nz)
110    }
111
112    /// Get the underlying `u64`. Always non-zero.
113    // #87: trivial accessor on the page-load hot path, used across
114    // the obj-db → obj-core boundary; inline to elide the call.
115    #[inline]
116    #[must_use]
117    pub const fn get(self) -> u64 {
118        self.0.get()
119    }
120
121    /// Byte offset at which this page begins in the database file,
122    /// for a file with the given `feature_flags`.
123    ///
124    /// #28: the offset is stride-aware. Encrypted files (encryption
125    /// bit set in `feature_flags`) use a 4136-byte physical stride
126    /// for pages 1..N, so a fixed `id * PAGE_SIZE` computation is
127    /// wrong for them. This delegates to [`physical_offset_for`],
128    /// the same helper the pager uses for its real reads/writes
129    /// (`Pager::physical_offset`), so the two never diverge. Page 0
130    /// is always at offset 0. Pass `0` for plaintext (unencrypted)
131    /// files.
132    #[must_use]
133    pub fn byte_offset(self, feature_flags: u32) -> u64 {
134        physical_offset_for(self.0.get(), feature_flags)
135    }
136}
137
138/// An owned, page-sized buffer. Lives in the cache's `Vec<Frame>` and
139/// is reused across loads — the pager never allocates a new `Page` on
140/// the read hot path (Rule 3).
141#[derive(Debug, Clone)]
142pub struct Page {
143    bytes: Box<[u8; PAGE_SIZE]>,
144}
145
146impl Page {
147    /// Allocate a new zeroed page. Called only during cache
148    /// initialisation; never on the read hot path.
149    #[must_use]
150    pub fn zeroed() -> Self {
151        Self {
152            bytes: Box::new([0u8; PAGE_SIZE]),
153        }
154    }
155
156    /// Get the page as a byte slice.
157    #[must_use]
158    pub fn as_bytes(&self) -> &[u8; PAGE_SIZE] {
159        &self.bytes
160    }
161
162    /// Get the page as a mutable byte slice.
163    pub fn as_bytes_mut(&mut self) -> &mut [u8; PAGE_SIZE] {
164        &mut self.bytes
165    }
166
167    /// Zero the entire page in place. Used when the cache evicts a
168    /// frame and prepares it for reuse with new contents.
169    pub fn zero(&mut self) {
170        self.bytes.fill(0);
171    }
172}
173
174impl Default for Page {
175    fn default() -> Self {
176        Self::zeroed()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::{Page, PageId, ENCRYPTION_OVERHEAD, PAGE_SIZE};
183
184    #[test]
185    fn page_id_rejects_zero() {
186        assert!(PageId::new(0).is_none());
187        assert_eq!(PageId::new(1).map(PageId::get), Some(1));
188    }
189
190    #[test]
191    fn page_id_byte_offset() {
192        let id = PageId::new(3).expect("non-zero");
193        // Unencrypted (feature_flags = 0): legacy 4096-byte stride.
194        // Page 3 = PAGE_SIZE + (3 - 1) * PAGE_SIZE = 3 * PAGE_SIZE.
195        assert_eq!(id.byte_offset(0), 3 * PAGE_SIZE as u64);
196        // #28: encrypted (bit 1 set): 4136-byte stride for pages
197        // 1..N. Page 3 = PAGE_SIZE + 2 * 4136. Must agree with the
198        // pager's `physical_offset_for`.
199        let enc_flags = 1u32 << 1;
200        assert_eq!(
201            id.byte_offset(enc_flags),
202            super::physical_offset_for(3, enc_flags)
203        );
204        assert_eq!(
205            id.byte_offset(enc_flags),
206            PAGE_SIZE as u64 + 2 * (PAGE_SIZE + ENCRYPTION_OVERHEAD) as u64
207        );
208    }
209
210    #[test]
211    fn page_zeroed_and_zero() {
212        let mut p = Page::zeroed();
213        assert!(p.as_bytes().iter().all(|&b| b == 0));
214        p.as_bytes_mut()[0] = 0xAB;
215        p.zero();
216        assert!(p.as_bytes().iter().all(|&b| b == 0));
217    }
218
219    #[test]
220    fn physical_page_stride_picks_4096_or_4136() {
221        // No bits set → 4096.
222        assert_eq!(super::physical_page_stride(0), super::PAGE_SIZE);
223        // Compression bit only (bit 0) → still 4096 (compression
224        // does NOT change physical stride; it lives inside the
225        // 4096-byte page).
226        assert_eq!(super::physical_page_stride(0b01), super::PAGE_SIZE);
227        // Encryption bit (bit 1) → 4136 (PAGE_SIZE + 24-byte nonce +
228        // 16-byte tag).
229        assert_eq!(
230            super::physical_page_stride(0b10),
231            super::PAGE_SIZE + ENCRYPTION_OVERHEAD
232        );
233        // Both → 4136 (compression composes under encryption — the
234        // ciphertext payload is still 4096 bytes regardless).
235        assert_eq!(
236            super::physical_page_stride(0b11),
237            super::PAGE_SIZE + ENCRYPTION_OVERHEAD
238        );
239    }
240
241    #[test]
242    fn physical_offset_for_page_zero_is_zero() {
243        assert_eq!(super::physical_offset_for(0, 0), 0);
244        assert_eq!(super::physical_offset_for(0, 0b10), 0);
245    }
246
247    #[test]
248    fn physical_offset_for_unencrypted_matches_legacy() {
249        for raw in 1..32u64 {
250            assert_eq!(
251                super::physical_offset_for(raw, 0),
252                raw * super::PAGE_SIZE as u64
253            );
254        }
255    }
256
257    #[test]
258    fn physical_offset_for_encrypted_uses_4136_stride() {
259        // Page 1: starts at 4096.
260        assert_eq!(super::physical_offset_for(1, 0b10), super::PAGE_SIZE as u64);
261        // Page 2: starts at 4096 + 4136.
262        assert_eq!(
263            super::physical_offset_for(2, 0b10),
264            (super::PAGE_SIZE + super::PAGE_SIZE + super::ENCRYPTION_OVERHEAD) as u64
265        );
266        // Page 3: starts at 4096 + 2 * 4136.
267        assert_eq!(
268            super::physical_offset_for(3, 0b10),
269            (super::PAGE_SIZE + 2 * (super::PAGE_SIZE + super::ENCRYPTION_OVERHEAD)) as u64
270        );
271    }
272}