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}