Skip to main content

quiver_core/
page.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Fixed-size pages: the unit of checksum, encryption, and buffered I/O.
3//!
4//! Every paged file (the manifest, payload heaps, secondary indexes, index
5//! artifacts) is a sequence of 16 KiB pages. A page carries a fixed 32-byte
6//! header and a CRC32C over its plaintext, so corruption is *detected* on read
7//! and never served (ADR-0004). When encryption-at-rest is enabled the whole
8//! plaintext page is additionally sealed with an AEAD by a [`PageCodec`]; the
9//! inner CRC still guards the plaintext path and the unencrypted mode.
10//!
11//! Plaintext layout (little-endian, header 8-byte aligned):
12//!
13//! ```text
14//! 0   magic:u32        4  format_ver:u16   6  page_type:u8   7  _pad:u8
15//! 8   page_id:u64
16//! 16  lsn:u64
17//! 24  payload_len:u32                       28 crc32c:u32
18//! 32  ...body: payload_len bytes, then zero padding to 16 KiB...
19//! ```
20//!
21//! The CRC covers header bytes `0..28` (everything but the CRC field itself)
22//! followed by the live body bytes `0..payload_len`; trailing padding and the
23//! CRC field are excluded so the checksum is stable regardless of padding.
24
25use crate::error::{CoreError, Result};
26
27/// Size of a page in bytes — the fixed I/O, checksum, and encryption unit.
28pub const PAGE_SIZE: usize = 16 * 1024;
29/// Size of the fixed page header in bytes.
30pub const PAGE_HEADER_SIZE: usize = 32;
31/// Maximum payload bytes that fit in a single page body.
32pub const PAGE_BODY_CAP: usize = PAGE_SIZE - PAGE_HEADER_SIZE;
33/// Magic identifying a Quiver page (`b"QVPG"`, little-endian).
34pub const PAGE_MAGIC: u32 = u32::from_le_bytes(*b"QVPG");
35/// Current page format version. Unknown versions are refused on read.
36pub const PAGE_FORMAT_VERSION: u16 = 1;
37
38// Field offsets within the 32-byte header.
39const OFF_MAGIC: usize = 0;
40const OFF_FORMAT_VER: usize = 4;
41const OFF_PAGE_TYPE: usize = 6;
42const OFF_PAGE_ID: usize = 8;
43const OFF_LSN: usize = 16;
44const OFF_PAYLOAD_LEN: usize = 24;
45const OFF_CRC: usize = 28;
46// The CRC covers the header up to (but not including) the CRC field, then body.
47const CRC_HEADER_BYTES: usize = OFF_CRC; // 28
48
49/// The kind of data a page holds. It is stored in the header and validated on
50/// read, so a page can never be silently misread as the wrong type.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52#[repr(u8)]
53#[non_exhaustive]
54pub enum PageType {
55    /// A manifest (catalog) page.
56    Manifest = 1,
57    /// A sealed-segment page.
58    Segment = 2,
59    /// An index-artifact page (e.g. a disk-resident graph block, ADR-0019).
60    IndexBlock = 3,
61}
62
63impl PageType {
64    fn from_u8(v: u8) -> Result<Self> {
65        match v {
66            1 => Ok(Self::Manifest),
67            2 => Ok(Self::Segment),
68            3 => Ok(Self::IndexBlock),
69            other => Err(CoreError::MalformedPage(format!(
70                "unknown page type {other}"
71            ))),
72        }
73    }
74}
75
76#[inline]
77fn rd_u16(p: &[u8; PAGE_SIZE], off: usize) -> u16 {
78    u16::from_le_bytes([p[off], p[off + 1]])
79}
80
81#[inline]
82fn rd_u32(p: &[u8; PAGE_SIZE], off: usize) -> u32 {
83    u32::from_le_bytes([p[off], p[off + 1], p[off + 2], p[off + 3]])
84}
85
86#[inline]
87fn rd_u64(p: &[u8; PAGE_SIZE], off: usize) -> u64 {
88    u64::from_le_bytes([
89        p[off],
90        p[off + 1],
91        p[off + 2],
92        p[off + 3],
93        p[off + 4],
94        p[off + 5],
95        p[off + 6],
96        p[off + 7],
97    ])
98}
99
100// CRC32C over the header (minus the CRC field) and the live body bytes.
101fn page_crc(page: &[u8; PAGE_SIZE], payload_len: usize) -> u32 {
102    let crc = crc32c::crc32c(&page[..CRC_HEADER_BYTES]);
103    crc32c::crc32c_append(crc, &page[PAGE_HEADER_SIZE..PAGE_HEADER_SIZE + payload_len])
104}
105
106/// Serialize a page into a fresh 16 KiB plaintext buffer with a valid header and
107/// CRC. `body` must fit within [`PAGE_BODY_CAP`].
108pub fn build_page(
109    page_type: PageType,
110    page_id: u64,
111    lsn: u64,
112    body: &[u8],
113) -> Result<[u8; PAGE_SIZE]> {
114    if body.len() > PAGE_BODY_CAP {
115        return Err(CoreError::TooLarge(format!(
116            "page body {} exceeds capacity {PAGE_BODY_CAP}",
117            body.len()
118        )));
119    }
120    let mut page = [0u8; PAGE_SIZE];
121    page[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&PAGE_MAGIC.to_le_bytes());
122    page[OFF_FORMAT_VER..OFF_FORMAT_VER + 2].copy_from_slice(&PAGE_FORMAT_VERSION.to_le_bytes());
123    page[OFF_PAGE_TYPE] = page_type as u8;
124    // Byte at OFF_PAGE_TYPE + 1 is reserved padding; left zero.
125    page[OFF_PAGE_ID..OFF_PAGE_ID + 8].copy_from_slice(&page_id.to_le_bytes());
126    page[OFF_LSN..OFF_LSN + 8].copy_from_slice(&lsn.to_le_bytes());
127    let payload_len = body.len() as u32;
128    page[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4].copy_from_slice(&payload_len.to_le_bytes());
129    page[PAGE_HEADER_SIZE..PAGE_HEADER_SIZE + body.len()].copy_from_slice(body);
130    let crc = page_crc(&page, body.len());
131    page[OFF_CRC..OFF_CRC + 4].copy_from_slice(&crc.to_le_bytes());
132    Ok(page)
133}
134
135/// A parsed, validated page header.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct PageHeader {
138    /// The page kind.
139    pub page_type: PageType,
140    /// Monotonic page identifier within its file.
141    pub page_id: u64,
142    /// Last LSN that modified this page.
143    pub lsn: u64,
144    /// Number of live payload bytes in the body.
145    pub payload_len: u32,
146}
147
148/// Validate a 16 KiB plaintext page and return its header plus a borrow of the
149/// live body. Fails on bad magic, unknown version, wrong type, an impossible
150/// length, or a CRC mismatch — corruption is reported, never served.
151pub fn parse_page(page: &[u8; PAGE_SIZE], expected: PageType) -> Result<(PageHeader, &[u8])> {
152    let magic = rd_u32(page, OFF_MAGIC);
153    if magic != PAGE_MAGIC {
154        return Err(CoreError::BadMagic {
155            expected: PAGE_MAGIC,
156            found: magic,
157        });
158    }
159    let format_ver = rd_u16(page, OFF_FORMAT_VER);
160    if format_ver != PAGE_FORMAT_VERSION {
161        return Err(CoreError::UnsupportedVersion {
162            found: format_ver,
163            supported: PAGE_FORMAT_VERSION,
164        });
165    }
166    let page_type = PageType::from_u8(page[OFF_PAGE_TYPE])?;
167    if page_type != expected {
168        return Err(CoreError::MalformedPage(format!(
169            "page type {page_type:?} does not match expected {expected:?}"
170        )));
171    }
172    let page_id = rd_u64(page, OFF_PAGE_ID);
173    let lsn = rd_u64(page, OFF_LSN);
174    let payload_len = rd_u32(page, OFF_PAYLOAD_LEN);
175    // Bound the length before it is used to slice, so a corrupt header cannot
176    // index out of bounds; an in-range but wrong length is caught by the CRC.
177    if payload_len as usize > PAGE_BODY_CAP {
178        return Err(CoreError::MalformedPage(format!(
179            "payload_len {payload_len} exceeds body capacity {PAGE_BODY_CAP}"
180        )));
181    }
182    let stored_crc = rd_u32(page, OFF_CRC);
183    let computed = page_crc(page, payload_len as usize);
184    if stored_crc != computed {
185        return Err(CoreError::PageCorrupt {
186            page_id,
187            expected: stored_crc,
188            computed,
189        });
190    }
191    let body = &page[PAGE_HEADER_SIZE..PAGE_HEADER_SIZE + payload_len as usize];
192    Ok((
193        PageHeader {
194            page_type,
195            page_id,
196            lsn,
197            payload_len,
198        },
199        body,
200    ))
201}
202
203/// Transforms Quiver's durable bytes — fixed-size pages and variable-length
204/// records — to and from their on-disk representation.
205///
206/// The plaintext codec ([`PlainCodec`]) is the identity transform; integrity
207/// then comes from the page's inner CRC (and, for records, the WAL frame CRC).
208/// The encryption-at-rest codec (added with `quiver-crypto`) seals each page with
209/// an AEAD into a `[nonce][ciphertext][tag]` block of [`PageCodec::block_size`]
210/// bytes, deriving a unique nonce per page so reuse is impossible by
211/// construction; the inner CRC still protects the plaintext.
212///
213/// The WAL is record-framed rather than paged, so the AEAD codec must also seal
214/// each WAL record via [`PageCodec::seal_record`]; otherwise an
215/// encrypted-at-rest store would still leak its log in plaintext. The default
216/// record methods are the identity transform, so [`PlainCodec`] needs no change
217/// and a non-encrypting codec writes records verbatim.
218pub trait PageCodec: Send + Sync {
219    /// On-disk size, in bytes, of one sealed page.
220    fn block_size(&self) -> usize;
221
222    /// Seal a plaintext page into its on-disk block. `out` must be exactly
223    /// [`PageCodec::block_size`] bytes. `page_id` lets an AEAD codec bind the
224    /// page to its position (nonce derivation).
225    fn seal(&self, page_id: u64, plaintext: &[u8; PAGE_SIZE], out: &mut [u8]) -> Result<()>;
226
227    /// Open an on-disk block back into a plaintext page. `block` must be exactly
228    /// [`PageCodec::block_size`] bytes.
229    fn open(&self, page_id: u64, block: &[u8], out: &mut [u8; PAGE_SIZE]) -> Result<()>;
230
231    /// Clone this codec into a new boxed instance. A codec holds only key
232    /// material (or nothing), so a clone shares the same keys — this lets a
233    /// component that needs its own handle, such as a disk-resident index sealing
234    /// its own files, reuse the store's codec (ADR-0019).
235    fn clone_box(&self) -> Box<dyn PageCodec>;
236
237    /// Seal a variable-length record — a WAL frame payload — into a
238    /// self-describing on-disk blob. The default is the identity transform used
239    /// by [`PlainCodec`]; an AEAD codec overrides it to return
240    /// `[nonce][ciphertext+tag]`, so no plaintext record ever reaches the disk.
241    fn seal_record(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
242        Ok(plaintext.to_vec())
243    }
244
245    /// Open a record produced by [`PageCodec::seal_record`]. The default is the
246    /// identity transform; an AEAD codec authenticates and decrypts, returning an
247    /// error on a wrong key or any tampering.
248    fn open_record(&self, sealed: &[u8]) -> Result<Vec<u8>> {
249        Ok(sealed.to_vec())
250    }
251}
252
253/// The identity codec used when encryption-at-rest is disabled. On-disk bytes
254/// equal the plaintext page; integrity is provided by the page's inner CRC.
255#[derive(Debug, Default, Clone, Copy)]
256pub struct PlainCodec;
257
258impl PageCodec for PlainCodec {
259    fn block_size(&self) -> usize {
260        PAGE_SIZE
261    }
262
263    fn seal(&self, _page_id: u64, plaintext: &[u8; PAGE_SIZE], out: &mut [u8]) -> Result<()> {
264        if out.len() != PAGE_SIZE {
265            return Err(CoreError::MalformedPage(format!(
266                "seal output buffer is {} bytes, expected {PAGE_SIZE}",
267                out.len()
268            )));
269        }
270        out.copy_from_slice(plaintext);
271        Ok(())
272    }
273
274    fn open(&self, _page_id: u64, block: &[u8], out: &mut [u8; PAGE_SIZE]) -> Result<()> {
275        if block.len() != PAGE_SIZE {
276            return Err(CoreError::MalformedPage(format!(
277                "page block is {} bytes, expected {PAGE_SIZE}",
278                block.len()
279            )));
280        }
281        out.copy_from_slice(block);
282        Ok(())
283    }
284
285    fn clone_box(&self) -> Box<dyn PageCodec> {
286        Box::new(*self)
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use proptest::prelude::*;
294
295    #[test]
296    fn header_offsets_are_consistent() {
297        assert_eq!(PAGE_HEADER_SIZE, 32);
298        assert_eq!(PAGE_BODY_CAP, PAGE_SIZE - PAGE_HEADER_SIZE);
299        assert_eq!(OFF_CRC + 4, PAGE_HEADER_SIZE);
300    }
301
302    #[test]
303    fn build_then_parse_roundtrips() {
304        for len in [0usize, 1, 32, 1000, PAGE_BODY_CAP] {
305            let body: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
306            let page = build_page(PageType::Manifest, 7, 99, &body).unwrap();
307            let (hdr, got) = parse_page(&page, PageType::Manifest).unwrap();
308            assert_eq!(hdr.page_type, PageType::Manifest);
309            assert_eq!(hdr.page_id, 7);
310            assert_eq!(hdr.lsn, 99);
311            assert_eq!(hdr.payload_len as usize, len);
312            assert_eq!(got, &body[..]);
313        }
314    }
315
316    #[test]
317    fn oversized_body_is_rejected() {
318        let body = vec![0u8; PAGE_BODY_CAP + 1];
319        assert!(matches!(
320            build_page(PageType::Manifest, 0, 0, &body),
321            Err(CoreError::TooLarge(_))
322        ));
323    }
324
325    #[test]
326    fn corrupt_body_byte_is_detected() {
327        let body = vec![0xABu8; 512];
328        let mut page = build_page(PageType::Manifest, 1, 1, &body).unwrap();
329        page[PAGE_HEADER_SIZE + 10] ^= 0xFF;
330        assert!(matches!(
331            parse_page(&page, PageType::Manifest),
332            Err(CoreError::PageCorrupt { .. })
333        ));
334    }
335
336    #[test]
337    fn corrupt_header_field_is_detected() {
338        let body = vec![1u8; 64];
339        let mut page = build_page(PageType::Manifest, 1, 1, &body).unwrap();
340        // Flip a bit in the LSN field: covered by the CRC, so it must be caught.
341        page[OFF_LSN] ^= 0x01;
342        assert!(matches!(
343            parse_page(&page, PageType::Manifest),
344            Err(CoreError::PageCorrupt { .. })
345        ));
346    }
347
348    #[test]
349    fn bad_magic_is_detected() {
350        let mut page = build_page(PageType::Manifest, 1, 1, b"hi").unwrap();
351        page[OFF_MAGIC] ^= 0xFF;
352        assert!(matches!(
353            parse_page(&page, PageType::Manifest),
354            Err(CoreError::BadMagic { .. })
355        ));
356    }
357
358    #[test]
359    fn unknown_version_is_refused() {
360        let mut page = build_page(PageType::Manifest, 1, 1, b"hi").unwrap();
361        page[OFF_FORMAT_VER..OFF_FORMAT_VER + 2].copy_from_slice(&9u16.to_le_bytes());
362        // Recompute CRC so we exercise the version check, not the CRC check.
363        let crc = page_crc(&page, 2);
364        page[OFF_CRC..OFF_CRC + 4].copy_from_slice(&crc.to_le_bytes());
365        assert!(matches!(
366            parse_page(&page, PageType::Manifest),
367            Err(CoreError::UnsupportedVersion { found: 9, .. })
368        ));
369    }
370
371    #[test]
372    fn impossible_length_is_rejected_without_panicking() {
373        let mut page = build_page(PageType::Manifest, 1, 1, b"hi").unwrap();
374        page[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4]
375            .copy_from_slice(&(PAGE_BODY_CAP as u32 + 1).to_le_bytes());
376        assert!(matches!(
377            parse_page(&page, PageType::Manifest),
378            Err(CoreError::MalformedPage(_))
379        ));
380    }
381
382    #[test]
383    fn plain_codec_roundtrips() {
384        let codec = PlainCodec;
385        assert_eq!(codec.block_size(), PAGE_SIZE);
386        let page = build_page(PageType::Manifest, 3, 3, b"payload").unwrap();
387        let mut block = vec![0u8; codec.block_size()];
388        codec.seal(3, &page, &mut block).unwrap();
389        let mut back = [0u8; PAGE_SIZE];
390        codec.open(3, &block, &mut back).unwrap();
391        assert_eq!(page, back);
392    }
393
394    #[test]
395    fn plain_codec_rejects_wrong_buffer_sizes() {
396        let codec = PlainCodec;
397        let page = [0u8; PAGE_SIZE];
398        let mut small = vec![0u8; PAGE_SIZE - 1];
399        assert!(codec.seal(0, &page, &mut small).is_err());
400        let mut back = [0u8; PAGE_SIZE];
401        assert!(codec.open(0, &small, &mut back).is_err());
402    }
403
404    proptest! {
405        #[test]
406        fn any_body_roundtrips(body in proptest::collection::vec(any::<u8>(), 0..PAGE_BODY_CAP)) {
407            let page = build_page(PageType::Manifest, 42, 7, &body).unwrap();
408            let (hdr, got) = parse_page(&page, PageType::Manifest).unwrap();
409            prop_assert_eq!(hdr.payload_len as usize, body.len());
410            prop_assert_eq!(got, &body[..]);
411        }
412
413        #[test]
414        fn any_single_byte_flip_in_live_region_is_detected(
415            body in proptest::collection::vec(any::<u8>(), 1..2048usize),
416            flip in 0usize..(PAGE_HEADER_SIZE + 2048),
417        ) {
418            let page = build_page(PageType::Manifest, 1, 1, &body).unwrap();
419            let live = PAGE_HEADER_SIZE + body.len();
420            // Only positions within the header or live body are CRC-protected
421            // (the CRC field at 28..32 excepted). Flipping padding is invisible.
422            prop_assume!(flip < live);
423            prop_assume!(!(OFF_CRC..PAGE_HEADER_SIZE).contains(&flip));
424            let mut corrupt = page;
425            corrupt[flip] ^= 0x80;
426            // A flip in the CRC-protected region must surface as *some* error.
427            prop_assert!(parse_page(&corrupt, PageType::Manifest).is_err());
428        }
429    }
430}