Skip to main content

nodedb_spatial/
persist.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! R-tree checkpoint/restore for durable persistence.
4//!
5//! ## On-disk framing
6//!
7//! **Plaintext** R-tree checkpoints start with the 6-byte magic `RKSPT\0`.
8//! The first 4 bytes are never `SEGV`, so detection is unambiguous.
9//!
10//! **Encrypted** checkpoints use the same SEGV framing as the vector engine:
11//!
12//! ```text
13//! [SEGV (4B)] [version_u16_le (2B)] [cipher_alg_u8 (1B)] [kid_u8 (1B)]
14//! [HKDF salt (16B)] [random nonce (12B)] [AES-256-GCM ciphertext + tag]
15//! ```
16//!
17//! The inner payload is either the raw rkyv bytes for R-tree or the msgpack
18//! bytes for geohash. The complete preamble is AAD, and each envelope derives
19//! a one-use data key from its KEK, salt, nonce, and domain metadata.
20//!
21//! Storage key scheme (in redb under Namespace::Spatial):
22//! - `{collection}\x00{field}\x00rtree` → serialized R-tree entries
23//! - `{collection}\x00{field}\x00meta`  → SpatialIndexMeta
24
25use nodedb_mem::ScopedMemory;
26use nodedb_types::BoundingBox;
27use serde::{Deserialize, Serialize};
28use zerompk::{FromMessagePack, ToMessagePack};
29
30use crate::rtree::{RTree, RTreeEntry};
31
32// ── SEGV framing constants ─────────────────────────────────────────────────
33//
34// The encrypted envelope itself lives in `nodedb_wal::crypto`; only the
35// magic constant is local to this module.
36
37/// Magic bytes identifying an encrypted spatial checkpoint. Shared with
38/// `nodedb-vector`'s collection checkpoint format.
39const SEGV_MAGIC: [u8; 4] = *b"SEGV";
40
41// ── Plaintext inner-format constants ───────────────────────────────────────
42
43/// Magic header for rkyv-serialized R-tree snapshots (6 bytes).
44const RTREE_RKYV_MAGIC: &[u8; 6] = b"RKSPT\0";
45
46/// Current format version for rkyv-serialized R-tree snapshots.
47pub const RTREE_FORMAT_VERSION: u8 = 1;
48
49// ── SEGV framing helpers ───────────────────────────────────────────────────
50
51fn encrypt_payload(
52    key: &nodedb_wal::crypto::WalEncryptionKey,
53    plaintext: &[u8],
54) -> Result<Vec<u8>, RTreeCheckpointError> {
55    nodedb_wal::crypto::encrypt_segment_envelope(key, &SEGV_MAGIC, plaintext)
56        .map_err(|e| RTreeCheckpointError::EncryptionFailed(e.to_string()))
57}
58
59fn decrypt_payload(
60    key: &nodedb_wal::crypto::WalEncryptionKey,
61    blob: &[u8],
62) -> Result<Vec<u8>, RTreeCheckpointError> {
63    nodedb_wal::crypto::decrypt_segment_envelope(key, &SEGV_MAGIC, blob)
64        .map_err(|e| RTreeCheckpointError::DecryptionFailed(e.to_string()))
65}
66
67/// Encrypt a geohash msgpack payload (called from `geohash_index.rs`).
68pub(crate) fn encrypt_geohash_payload(
69    key: &nodedb_wal::crypto::WalEncryptionKey,
70    plaintext: &[u8],
71) -> Result<Vec<u8>, RTreeCheckpointError> {
72    encrypt_payload(key, plaintext)
73}
74
75/// Decrypt a geohash msgpack payload (called from `geohash_index.rs`).
76pub(crate) fn decrypt_geohash_payload(
77    key: &nodedb_wal::crypto::WalEncryptionKey,
78    blob: &[u8],
79) -> Result<Vec<u8>, RTreeCheckpointError> {
80    decrypt_payload(key, blob)
81}
82
83// ── Metadata types ─────────────────────────────────────────────────────────
84
85/// Metadata for a persisted spatial index.
86#[derive(Debug, Clone, Serialize, Deserialize, ToMessagePack, FromMessagePack)]
87pub struct SpatialIndexMeta {
88    /// Collection this index belongs to.
89    pub collection: String,
90    /// Geometry field name being indexed.
91    pub field: String,
92    /// Index type.
93    pub index_type: SpatialIndexType,
94    /// Number of entries at last checkpoint.
95    pub entry_count: u64,
96    /// Bounding box of all indexed geometries (spatial extent).
97    pub extent: Option<BoundingBox>,
98}
99
100/// Type of spatial index.
101#[derive(
102    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToMessagePack, FromMessagePack,
103)]
104#[msgpack(c_enum)]
105#[repr(u8)]
106#[non_exhaustive]
107pub enum SpatialIndexType {
108    RTree = 0,
109    Geohash = 1,
110}
111
112impl SpatialIndexType {
113    pub fn as_str(&self) -> &'static str {
114        match self {
115            Self::RTree => "rtree",
116            Self::Geohash => "geohash",
117        }
118    }
119}
120
121impl std::fmt::Display for SpatialIndexType {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.write_str(self.as_str())
124    }
125}
126
127// ── rkyv snapshot type ─────────────────────────────────────────────────────
128
129/// rkyv-serialized R-tree snapshot.
130#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
131struct RTreeSnapshotRkyv {
132    entries: Vec<RTreeEntry>,
133}
134
135// ── RTree checkpoint impl ──────────────────────────────────────────────────
136
137impl RTree {
138    /// Serialize the R-tree to bytes for checkpointing.
139    ///
140    /// When `kek` is `Some`, the inner rkyv payload is wrapped in an AES-256-GCM
141    /// encrypted SEGV envelope. When `None`, the raw rkyv bytes (with `RKSPT\0`
142    /// inner magic) are returned (existing plaintext format).
143    pub fn checkpoint_to_bytes(
144        &self,
145        kek: Option<&nodedb_wal::crypto::WalEncryptionKey>,
146    ) -> Result<Vec<u8>, RTreeCheckpointError> {
147        let snapshot = RTreeSnapshotRkyv {
148            entries: self.entries().into_iter().cloned().collect(),
149        };
150        let rkyv_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&snapshot)
151            .map_err(|e| RTreeCheckpointError::RkyvSerialize(e.to_string()))?;
152
153        // Build inner plaintext: magic + version + rkyv payload.
154        let inner_len = RTREE_RKYV_MAGIC.len() + 1 + rkyv_bytes.len();
155        let _guard = self.memory().reserve(inner_len).ok();
156        let mut inner = Vec::with_capacity(inner_len);
157        inner.extend_from_slice(RTREE_RKYV_MAGIC);
158        inner.push(RTREE_FORMAT_VERSION);
159        inner.extend_from_slice(&rkyv_bytes);
160
161        if let Some(key) = kek {
162            return encrypt_payload(key, &inner);
163        }
164
165        Ok(inner)
166    }
167
168    /// Restore an R-tree from checkpoint bytes.
169    ///
170    /// `kek` controls the expected framing:
171    /// - `None` → file must be plaintext (starting with `RKSPT\0`). If it
172    ///   starts with `SEGV`, returns `Err(MissingKek)`.
173    /// - `Some(key)` → encryption is **required**. If the file starts with
174    ///   `SEGV`, it is decrypted. If plaintext, returns `Err(KekRequired)`.
175    pub fn from_checkpoint(
176        bytes: &[u8],
177        kek: Option<&nodedb_wal::crypto::WalEncryptionKey>,
178        memory: ScopedMemory,
179    ) -> Result<Self, RTreeCheckpointError> {
180        let is_encrypted = bytes.len() >= 4 && bytes[0..4] == SEGV_MAGIC;
181
182        let inner: Vec<u8>;
183        let inner_ref: &[u8];
184
185        if is_encrypted {
186            if let Some(key) = kek {
187                inner = decrypt_payload(key, bytes)?;
188                inner_ref = &inner;
189            } else {
190                return Err(RTreeCheckpointError::MissingKek);
191            }
192        } else if kek.is_some() {
193            return Err(RTreeCheckpointError::KekRequired);
194        } else {
195            inner_ref = bytes;
196        }
197
198        Self::decode_plaintext_inner(inner_ref, memory)
199    }
200
201    fn decode_plaintext_inner(
202        bytes: &[u8],
203        memory: ScopedMemory,
204    ) -> Result<Self, RTreeCheckpointError> {
205        let header_len = RTREE_RKYV_MAGIC.len() + 1; // magic + version byte
206        if bytes.len() <= header_len || &bytes[..RTREE_RKYV_MAGIC.len()] != RTREE_RKYV_MAGIC {
207            return Err(RTreeCheckpointError::UnrecognizedFormat);
208        }
209        let version = bytes[RTREE_RKYV_MAGIC.len()];
210        if version != RTREE_FORMAT_VERSION {
211            return Err(RTreeCheckpointError::UnsupportedVersion {
212                found: version,
213                expected: RTREE_FORMAT_VERSION,
214            });
215        }
216        let rkyv_bytes = &bytes[header_len..];
217        let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(rkyv_bytes.len());
218        aligned.extend_from_slice(rkyv_bytes);
219        let snapshot: RTreeSnapshotRkyv =
220            rkyv::from_bytes::<RTreeSnapshotRkyv, rkyv::rancor::Error>(&aligned)
221                .map_err(|e| RTreeCheckpointError::RkyvDeserialize(e.to_string()))?;
222        Ok(RTree::bulk_load(snapshot.entries, memory))
223    }
224}
225
226// ── Storage key helpers ────────────────────────────────────────────────────
227
228/// Build the storage key for an R-tree checkpoint.
229///
230/// Format: `{collection}\0{field}\0rtree`
231pub fn rtree_storage_key(collection: &str, field: &str) -> Vec<u8> {
232    let mut key = Vec::with_capacity(collection.len() + field.len() + 8);
233    key.extend_from_slice(collection.as_bytes());
234    key.push(0);
235    key.extend_from_slice(field.as_bytes());
236    key.push(0);
237    key.extend_from_slice(b"rtree");
238    key
239}
240
241/// Build the storage key for spatial index metadata.
242///
243/// Format: `{collection}\0{field}\0meta`
244pub fn meta_storage_key(collection: &str, field: &str) -> Vec<u8> {
245    let mut key = Vec::with_capacity(collection.len() + field.len() + 7);
246    key.extend_from_slice(collection.as_bytes());
247    key.push(0);
248    key.extend_from_slice(field.as_bytes());
249    key.push(0);
250    key.extend_from_slice(b"meta");
251    key
252}
253
254/// Serialize index metadata to bytes.
255pub fn serialize_meta(meta: &SpatialIndexMeta) -> Result<Vec<u8>, RTreeCheckpointError> {
256    zerompk::to_msgpack_vec(meta).map_err(RTreeCheckpointError::Serialize)
257}
258
259/// Deserialize index metadata from bytes.
260pub fn deserialize_meta(bytes: &[u8]) -> Result<SpatialIndexMeta, RTreeCheckpointError> {
261    zerompk::from_msgpack(bytes).map_err(RTreeCheckpointError::Deserialize)
262}
263
264// ── Error type ─────────────────────────────────────────────────────────────
265
266/// Errors during R-tree checkpoint operations.
267#[derive(Debug, thiserror::Error)]
268#[non_exhaustive]
269pub enum RTreeCheckpointError {
270    #[error("R-tree checkpoint serialization failed: {0}")]
271    Serialize(zerompk::Error),
272    #[error("R-tree checkpoint deserialization failed: {0}")]
273    Deserialize(zerompk::Error),
274    #[error("R-tree rkyv serialization failed: {0}")]
275    RkyvSerialize(String),
276    #[error("R-tree rkyv deserialization failed: {0}")]
277    RkyvDeserialize(String),
278    #[error("unsupported R-tree checkpoint version {found}; expected {expected}")]
279    UnsupportedVersion { found: u8, expected: u8 },
280    #[error("unrecognized R-tree checkpoint format (missing RKSPT\\0 magic)")]
281    UnrecognizedFormat,
282    /// Checkpoint is encrypted (starts with `SEGV`) but no KEK was supplied.
283    #[error(
284        "spatial checkpoint is encrypted but no encryption key was provided; \
285         cannot load an encrypted checkpoint without a key"
286    )]
287    MissingKek,
288    /// Checkpoint is plaintext but a KEK is configured (policy violation).
289    #[error(
290        "spatial checkpoint is plaintext but an encryption key is configured; \
291         refusing to load an unencrypted checkpoint when encryption is required"
292    )]
293    KekRequired,
294    /// AES-256-GCM encryption failed.
295    #[error("spatial checkpoint encryption failed: {0}")]
296    EncryptionFailed(String),
297    /// AES-256-GCM decryption failed.
298    #[error("spatial checkpoint decryption failed: {0}")]
299    DecryptionFailed(String),
300}
301
302// ── Tests ──────────────────────────────────────────────────────────────────
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::test_support::test_memory;
308
309    fn make_entry(id: u64, lng: f64, lat: f64) -> RTreeEntry {
310        RTreeEntry {
311            id,
312            bbox: BoundingBox::from_point(lng, lat),
313        }
314    }
315
316    #[test]
317    fn checkpoint_roundtrip_empty() {
318        let tree = RTree::new(test_memory());
319        let bytes = tree.checkpoint_to_bytes(None).unwrap();
320        let restored = RTree::from_checkpoint(&bytes, None, test_memory()).unwrap();
321        assert_eq!(restored.len(), 0);
322    }
323
324    #[test]
325    fn checkpoint_roundtrip_entries() {
326        let mut tree = RTree::new(test_memory());
327        for i in 0..100 {
328            tree.insert(make_entry(i, (i as f64) * 0.5, (i as f64) * 0.3));
329        }
330        assert_eq!(tree.len(), 100);
331
332        let bytes = tree.checkpoint_to_bytes(None).unwrap();
333        let restored = RTree::from_checkpoint(&bytes, None, test_memory()).unwrap();
334        assert_eq!(restored.len(), 100);
335
336        // All entries should be searchable.
337        let all = restored.search(&BoundingBox::new(-180.0, -90.0, 180.0, 90.0));
338        assert_eq!(all.len(), 100);
339    }
340
341    #[test]
342    fn checkpoint_preserves_ids() {
343        let mut tree = RTree::new(test_memory());
344        tree.insert(make_entry(42, 10.0, 20.0));
345        tree.insert(make_entry(99, 30.0, 40.0));
346
347        let bytes = tree.checkpoint_to_bytes(None).unwrap();
348        let restored = RTree::from_checkpoint(&bytes, None, test_memory()).unwrap();
349
350        let results = restored.search(&BoundingBox::new(5.0, 15.0, 15.0, 25.0));
351        assert_eq!(results.len(), 1);
352        assert_eq!(results[0].id, 42);
353    }
354
355    #[test]
356    fn corrupted_bytes_returns_error() {
357        assert!(matches!(
358            RTree::from_checkpoint(&[0xFF, 0xFF, 0xFF], None, test_memory()),
359            Err(RTreeCheckpointError::UnrecognizedFormat)
360        ));
361    }
362
363    #[test]
364    fn meta_roundtrip() {
365        let meta = SpatialIndexMeta {
366            collection: "buildings".to_string(),
367            field: "geom".to_string(),
368            index_type: SpatialIndexType::RTree,
369            entry_count: 1000,
370            extent: Some(BoundingBox::new(-180.0, -90.0, 180.0, 90.0)),
371        };
372        let bytes = serialize_meta(&meta).unwrap();
373        let restored = deserialize_meta(&bytes).unwrap();
374        assert_eq!(restored.collection, "buildings");
375        assert_eq!(restored.entry_count, 1000);
376        assert_eq!(restored.index_type, SpatialIndexType::RTree);
377    }
378
379    #[test]
380    fn storage_key_format() {
381        let key = rtree_storage_key("buildings", "geom");
382        assert_eq!(key, b"buildings\0geom\0rtree");
383
384        let meta_key = meta_storage_key("buildings", "geom");
385        assert_eq!(meta_key, b"buildings\0geom\0meta");
386    }
387
388    #[test]
389    fn checkpoint_size_reasonable() {
390        let mut tree = RTree::new(test_memory());
391        for i in 0..1000 {
392            tree.insert(make_entry(i, (i as f64) * 0.01, (i as f64) * 0.01));
393        }
394        let bytes = tree.checkpoint_to_bytes(None).unwrap();
395        // Each entry: id(8) + 4 f64(32) = ~40 bytes + rkyv overhead.
396        // 1000 entries ≈ 40-60KB is reasonable.
397        assert!(
398            bytes.len() < 100_000,
399            "checkpoint too large: {} bytes",
400            bytes.len()
401        );
402        assert!(
403            bytes.len() > 10_000,
404            "checkpoint too small: {} bytes",
405            bytes.len()
406        );
407    }
408
409    #[test]
410    fn golden_header_layout() {
411        let mut tree = RTree::new(test_memory());
412        tree.insert(make_entry(1, 10.0, 20.0));
413        let bytes = tree.checkpoint_to_bytes(None).unwrap();
414        // Magic at bytes[0..6].
415        assert_eq!(&bytes[0..6], b"RKSPT\0");
416        // Version byte at bytes[6].
417        assert_eq!(bytes[6], super::RTREE_FORMAT_VERSION);
418        // rkyv payload follows immediately.
419        assert!(bytes.len() > 7);
420    }
421
422    #[test]
423    fn version_mismatch_returns_error() {
424        let mut tree = RTree::new(test_memory());
425        tree.insert(make_entry(1, 10.0, 20.0));
426        let mut bytes = tree.checkpoint_to_bytes(None).unwrap();
427        // Corrupt the version byte to an unsupported value.
428        bytes[6] = 0;
429        match RTree::from_checkpoint(&bytes, None, test_memory()) {
430            Err(RTreeCheckpointError::UnsupportedVersion { found, expected }) => {
431                assert_eq!(found, 0);
432                assert_eq!(expected, super::RTREE_FORMAT_VERSION);
433            }
434            Err(other) => panic!("unexpected error: {other}"),
435            Ok(_) => panic!("expected UnsupportedVersion error, got Ok"),
436        }
437    }
438
439    fn make_test_kek() -> nodedb_wal::crypto::WalEncryptionKey {
440        nodedb_wal::crypto::WalEncryptionKey::from_bytes(&[0x42u8; 32]).unwrap()
441    }
442
443    #[test]
444    fn spatial_rtree_checkpoint_encrypted_at_rest() {
445        let kek = make_test_kek();
446        let mut tree = RTree::new(test_memory());
447        for i in 0..50 {
448            tree.insert(make_entry(i, i as f64, i as f64 * 0.5));
449        }
450
451        let enc_bytes = tree.checkpoint_to_bytes(Some(&kek)).unwrap();
452
453        // Encrypted blob must start with SEGV, not RKSPT.
454        assert_eq!(&enc_bytes[0..4], b"SEGV");
455
456        // Round-trip: decrypt and verify all entries survive.
457        let restored = RTree::from_checkpoint(&enc_bytes, Some(&kek), test_memory()).unwrap();
458        assert_eq!(restored.len(), 50);
459        let all = restored.search(&BoundingBox::new(-180.0, -90.0, 180.0, 90.0));
460        assert_eq!(all.len(), 50);
461    }
462
463    #[test]
464    fn spatial_rtree_refuses_plaintext_when_kek_required() {
465        let kek = make_test_kek();
466        let mut tree = RTree::new(test_memory());
467        tree.insert(make_entry(1, 10.0, 20.0));
468
469        // Write plaintext checkpoint.
470        let plain_bytes = tree.checkpoint_to_bytes(None).unwrap();
471
472        // Attempting to load with a KEK must be refused.
473        assert!(matches!(
474            RTree::from_checkpoint(&plain_bytes, Some(&kek), test_memory()),
475            Err(RTreeCheckpointError::KekRequired)
476        ));
477    }
478
479    #[test]
480    fn spatial_rtree_refuses_encrypted_without_kek() {
481        let kek = make_test_kek();
482        let mut tree = RTree::new(test_memory());
483        tree.insert(make_entry(1, 10.0, 20.0));
484
485        let enc_bytes = tree.checkpoint_to_bytes(Some(&kek)).unwrap();
486
487        // Loading without a key must be refused.
488        assert!(matches!(
489            RTree::from_checkpoint(&enc_bytes, None, test_memory()),
490            Err(RTreeCheckpointError::MissingKek)
491        ));
492    }
493
494    #[test]
495    fn spatial_rtree_tampered_ciphertext_rejected() {
496        let kek = make_test_kek();
497        let mut tree = RTree::new(test_memory());
498        tree.insert(make_entry(1, 10.0, 20.0));
499
500        let mut enc_bytes = tree.checkpoint_to_bytes(Some(&kek)).unwrap();
501        // Flip a byte in the ciphertext region after the current preamble.
502        enc_bytes[nodedb_wal::crypto::SEGMENT_ENVELOPE_PREAMBLE_SIZE + 2] ^= 0xFF;
503
504        assert!(matches!(
505            RTree::from_checkpoint(&enc_bytes, Some(&kek), test_memory()),
506            Err(RTreeCheckpointError::DecryptionFailed(_))
507        ));
508    }
509}