Skip to main content

sparrowdb_storage/
lib.rs

1pub mod metapage;
2
3/// Simple inverted full-text index for CALL db.index.fulltext.queryNodes.
4pub mod fulltext_index;
5
6/// At-rest page encryption using XChaCha20-Poly1305.
7pub mod encryption;
8
9/// CHECKPOINT and OPTIMIZE maintenance operations.
10pub mod maintenance;
11
12/// WAL subsystem — codec, writer, and replay.
13pub mod wal;
14
15/// CSR (Compressed Sparse Row) forward and backward edge files.
16pub mod csr;
17
18/// Node property column storage.
19pub mod node_store;
20
21/// In-memory B-tree property equality index (SPA-249).
22pub mod property_index;
23
24/// In-memory text search index for CONTAINS and STARTS WITH (SPA-251).
25pub mod text_index;
26
27/// Edge delta log and CSR rebuild on checkpoint.
28pub mod edge_store;
29
30use std::os::unix::fs::FileExt;
31use std::path::{Path, PathBuf};
32
33use sparrowdb_common::{Error, PageId, Result};
34
35use crate::encryption::EncryptionContext;
36
37/// Compute CRC32C (Castagnoli) of the entire buffer.
38pub fn crc32_of(buf: &[u8]) -> u32 {
39    crc32c::crc32c(buf)
40}
41
42/// Compute CRC32C (Castagnoli) of `buf` treating `zeroed_offset..zeroed_offset+zeroed_len`
43/// as all zeros.
44/// Used so CRC can be stored in the buffer itself (field is zeroed during calculation).
45///
46/// Returns `Err(Error::InvalidArgument)` if the zeroed range is out of bounds or overflows.
47pub fn crc32_zeroed_at(
48    buf: &[u8],
49    zeroed_offset: usize,
50    zeroed_len: usize,
51) -> sparrowdb_common::Result<u32> {
52    // Validate inputs before any arithmetic that could panic.
53    let end = zeroed_offset.checked_add(zeroed_len).ok_or_else(|| {
54        sparrowdb_common::Error::InvalidArgument("zeroed range overflows usize".into())
55    })?;
56    if end > buf.len() {
57        return Err(sparrowdb_common::Error::InvalidArgument(format!(
58            "zeroed range {}..{} out of bounds for buffer of length {}",
59            zeroed_offset,
60            end,
61            buf.len()
62        )));
63    }
64    // Feed the three segments separately; avoids any heap allocation.
65    let crc = crc32c::crc32c(&buf[..zeroed_offset]);
66    // Feed zeros in fixed-size stack chunks to support arbitrarily large zeroed_len.
67    const CHUNK: usize = 64;
68    let zeros = [0u8; CHUNK];
69    let mut crc = crc;
70    let mut remaining = zeroed_len;
71    while remaining > 0 {
72        let n = remaining.min(CHUNK);
73        crc = crc32c::crc32c_append(crc, &zeros[..n]);
74        remaining -= n;
75    }
76    Ok(crc32c::crc32c_append(crc, &buf[end..]))
77}
78
79/// Page store: maps logical page IDs to on-disk locations, with optional
80/// at-rest encryption via [`EncryptionContext`].
81///
82/// ## On-disk layout
83///
84/// When encryption is **disabled** (passthrough mode), each page occupies
85/// exactly `page_size` bytes at offset `page_id * page_size`.
86///
87/// When encryption is **enabled**, each page occupies `page_size + 40` bytes
88/// (the "encrypted stride"):
89///
90/// ```text
91/// ┌────────────────────────┬───────────────────────────────────┐
92/// │  nonce (24 bytes)      │  ciphertext + auth tag            │
93/// │                        │  (page_size + 16 bytes)           │
94/// └────────────────────────┴───────────────────────────────────┘
95/// total: page_size + 40 bytes per page slot
96/// ```
97///
98/// The encryption stride ensures that a file opened without a key cannot be
99/// accidentally decoded as raw pages (sizes won't align).
100pub struct PageStore {
101    /// The backing data file.
102    ///
103    /// `pread`/`pwrite` (`FileExt::read_exact_at` / `write_all_at`) are
104    /// used for all I/O so the file descriptor is never mutated (no seek
105    /// cursor movement).  This makes concurrent reads safe without a mutex.
106    file: std::fs::File,
107    /// Logical page size in bytes (plaintext).
108    page_size: usize,
109    /// The encryption context — passthrough if no key was provided.
110    enc: EncryptionContext,
111    /// Path for diagnostics.
112    _path: PathBuf,
113}
114
115impl PageStore {
116    /// Open (or create) a page store at `path` with the given `page_size` and
117    /// no encryption.
118    ///
119    /// Use [`PageStore::open_encrypted`] to enable at-rest encryption.
120    pub fn open(path: &Path) -> Result<Self> {
121        Self::open_inner(path, 4096, EncryptionContext::none())
122    }
123
124    /// Open (or create) a page store with the given page size and no encryption.
125    pub fn open_with_page_size(path: &Path, page_size: usize) -> Result<Self> {
126        Self::open_inner(path, page_size, EncryptionContext::none())
127    }
128
129    /// Open (or create) an **encrypted** page store.
130    ///
131    /// `key` — 32-byte master encryption key (XChaCha20-Poly1305).
132    ///
133    /// If the file already exists and was written with a different key, the
134    /// first `read_page` call will return
135    /// [`Error::EncryptionAuthFailed`].
136    pub fn open_encrypted(path: &Path, page_size: usize, key: [u8; 32]) -> Result<Self> {
137        Self::open_inner(path, page_size, EncryptionContext::with_key(key))
138    }
139
140    fn open_inner(path: &Path, page_size: usize, enc: EncryptionContext) -> Result<Self> {
141        let file = std::fs::OpenOptions::new()
142            .read(true)
143            .write(true)
144            .create(true)
145            .truncate(false)
146            .open(path)?;
147        Ok(PageStore {
148            file,
149            page_size,
150            enc,
151            _path: path.to_path_buf(),
152        })
153    }
154
155    /// Byte offset of page `id` in the backing file.
156    fn page_offset(&self, id: PageId) -> u64 {
157        let stride = if self.enc.is_encrypted() {
158            self.page_size + 40
159        } else {
160            self.page_size
161        };
162        id.0 * stride as u64
163    }
164
165    /// Read page `id` into `buf`.
166    ///
167    /// `buf` must be exactly `page_size` bytes.
168    ///
169    /// # Errors
170    /// - [`Error::InvalidArgument`] — `buf.len() != page_size`.
171    /// - [`Error::EncryptionAuthFailed`] — AEAD tag rejected (wrong key or
172    ///   corrupted page).
173    /// - [`Error::Io`] — underlying I/O failure.
174    pub fn read_page(&self, id: PageId, buf: &mut [u8]) -> Result<()> {
175        if buf.len() != self.page_size {
176            return Err(Error::InvalidArgument(format!(
177                "read_page: buf len {} != page_size {}",
178                buf.len(),
179                self.page_size
180            )));
181        }
182
183        let offset = self.page_offset(id);
184        let on_disk_size = if self.enc.is_encrypted() {
185            self.page_size + 40
186        } else {
187            self.page_size
188        };
189
190        let mut on_disk_buf = vec![0u8; on_disk_size];
191        self.file.read_exact_at(&mut on_disk_buf, offset)?;
192
193        if self.enc.is_encrypted() {
194            let plaintext = self.enc.decrypt_page(id.0, &on_disk_buf)?;
195            buf.copy_from_slice(&plaintext);
196        } else {
197            buf.copy_from_slice(&on_disk_buf);
198        }
199
200        Ok(())
201    }
202
203    /// Write `buf` as page `id`.
204    ///
205    /// `buf` must be exactly `page_size` bytes.
206    ///
207    /// # Errors
208    /// - [`Error::InvalidArgument`] — `buf.len() != page_size`.
209    /// - [`Error::Io`] — underlying I/O failure.
210    pub fn write_page(&self, id: PageId, buf: &[u8]) -> Result<()> {
211        if buf.len() != self.page_size {
212            return Err(Error::InvalidArgument(format!(
213                "write_page: buf len {} != page_size {}",
214                buf.len(),
215                self.page_size
216            )));
217        }
218
219        let on_disk_data = if self.enc.is_encrypted() {
220            self.enc.encrypt_page(id.0, buf)?
221        } else {
222            buf.to_vec()
223        };
224
225        let offset = self.page_offset(id);
226        self.file.write_all_at(&on_disk_data, offset)?;
227        Ok(())
228    }
229
230    /// Flush and fsync the backing file to durable storage.
231    pub fn fsync(&self) -> Result<()> {
232        self.file.sync_all()?;
233        Ok(())
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn page_store_type_exists() {
243        // Verify the open function compiles with the right signature.
244        let _: fn(&Path) -> Result<PageStore> = PageStore::open;
245    }
246
247    #[test]
248    fn page_store_plaintext_roundtrip() {
249        let dir = tempfile::tempdir().unwrap();
250        let path = dir.path().join("pages.bin");
251        let store = PageStore::open_with_page_size(&path, 512).unwrap();
252
253        let write_buf = vec![0x42u8; 512];
254        store.write_page(PageId(0), &write_buf).unwrap();
255
256        let mut read_buf = vec![0u8; 512];
257        store.read_page(PageId(0), &mut read_buf).unwrap();
258        assert_eq!(read_buf, write_buf);
259    }
260
261    #[test]
262    fn page_store_encrypted_roundtrip() {
263        let dir = tempfile::tempdir().unwrap();
264        let path = dir.path().join("enc_pages.bin");
265        let key = [0x11u8; 32];
266        let store = PageStore::open_encrypted(&path, 512, key).unwrap();
267
268        let write_buf = vec![0xAAu8; 512];
269        store.write_page(PageId(3), &write_buf).unwrap();
270
271        let mut read_buf = vec![0u8; 512];
272        store.read_page(PageId(3), &mut read_buf).unwrap();
273        assert_eq!(read_buf, write_buf);
274    }
275
276    #[test]
277    fn page_store_wrong_key_returns_auth_failed() {
278        let dir = tempfile::tempdir().unwrap();
279        let path = dir.path().join("enc_pages.bin");
280
281        // Write with key A.
282        {
283            let store = PageStore::open_encrypted(&path, 512, [0xAAu8; 32]).unwrap();
284            store.write_page(PageId(0), &vec![0x55u8; 512]).unwrap();
285        }
286
287        // Read with key B.
288        let store = PageStore::open_encrypted(&path, 512, [0xBBu8; 32]).unwrap();
289        let mut buf = vec![0u8; 512];
290        let result = store.read_page(PageId(0), &mut buf);
291        assert!(
292            matches!(result, Err(Error::EncryptionAuthFailed)),
293            "expected EncryptionAuthFailed, got {:?}",
294            result
295        );
296    }
297
298    #[test]
299    fn page_store_multiple_pages_encrypted() {
300        let dir = tempfile::tempdir().unwrap();
301        let path = dir.path().join("multi.bin");
302        let key = [0x33u8; 32];
303        let store = PageStore::open_encrypted(&path, 256, key).unwrap();
304
305        for i in 0u64..4 {
306            let buf = vec![i as u8; 256];
307            store.write_page(PageId(i), &buf).unwrap();
308        }
309
310        for i in 0u64..4 {
311            let mut buf = vec![0u8; 256];
312            store.read_page(PageId(i), &mut buf).unwrap();
313            assert!(buf.iter().all(|&b| b == i as u8));
314        }
315    }
316
317    #[test]
318    fn crc32_zeroed_at_matches_manual_zeroing() {
319        let mut buf = [0xABu8; 16];
320        // Place a known value at [4..8]
321        buf[4..8].copy_from_slice(&0xDEADBEEFu32.to_le_bytes());
322        // Manual: zero [4..8], compute crc32c
323        let mut manual = buf;
324        manual[4..8].copy_from_slice(&[0u8; 4]);
325        let expected = crc32c::crc32c(&manual);
326        let actual = crc32_zeroed_at(&buf, 4, 4).unwrap();
327        assert_eq!(actual, expected);
328    }
329
330    #[test]
331    fn crc32_zeroed_at_rejects_out_of_bounds() {
332        let buf = [0u8; 16];
333        assert!(crc32_zeroed_at(&buf, 14, 4).is_err()); // 14+4=18 > 16
334        assert!(crc32_zeroed_at(&buf, usize::MAX, 1).is_err()); // overflow
335    }
336}