1use nodedb_mem::ScopedMemory;
26use nodedb_types::BoundingBox;
27use serde::{Deserialize, Serialize};
28use zerompk::{FromMessagePack, ToMessagePack};
29
30use crate::rtree::{RTree, RTreeEntry};
31
32const SEGV_MAGIC: [u8; 4] = *b"SEGV";
40
41const RTREE_RKYV_MAGIC: &[u8; 6] = b"RKSPT\0";
45
46pub const RTREE_FORMAT_VERSION: u8 = 1;
48
49fn 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
67pub(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
75pub(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#[derive(Debug, Clone, Serialize, Deserialize, ToMessagePack, FromMessagePack)]
87pub struct SpatialIndexMeta {
88 pub collection: String,
90 pub field: String,
92 pub index_type: SpatialIndexType,
94 pub entry_count: u64,
96 pub extent: Option<BoundingBox>,
98}
99
100#[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#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
131struct RTreeSnapshotRkyv {
132 entries: Vec<RTreeEntry>,
133}
134
135impl RTree {
138 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 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 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; 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
226pub 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
241pub 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
254pub fn serialize_meta(meta: &SpatialIndexMeta) -> Result<Vec<u8>, RTreeCheckpointError> {
256 zerompk::to_msgpack_vec(meta).map_err(RTreeCheckpointError::Serialize)
257}
258
259pub fn deserialize_meta(bytes: &[u8]) -> Result<SpatialIndexMeta, RTreeCheckpointError> {
261 zerompk::from_msgpack(bytes).map_err(RTreeCheckpointError::Deserialize)
262}
263
264#[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 #[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 #[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 #[error("spatial checkpoint encryption failed: {0}")]
296 EncryptionFailed(String),
297 #[error("spatial checkpoint decryption failed: {0}")]
299 DecryptionFailed(String),
300}
301
302#[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 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 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 assert_eq!(&bytes[0..6], b"RKSPT\0");
416 assert_eq!(bytes[6], super::RTREE_FORMAT_VERSION);
418 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 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 assert_eq!(&enc_bytes[0..4], b"SEGV");
455
456 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 let plain_bytes = tree.checkpoint_to_bytes(None).unwrap();
471
472 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 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 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}