core_storage/v8/mod.rs
1//! V8 snapshot format: mmap-able zero-copy snapshot.
2//!
3//! Wire layout (all integers LE):
4//! ```text
5//! [0..4] MAGIC "GDB1"
6//! [4..6] VERSION = 8 or 9 (u16 LE) — same container, V9 adds section 12
7//! [6..8] section_count (u16 LE) — currently 13
8//! [8..8+16*N] SectionEntry * N -- {id:u8, _pad:[u8;3], offset:u32, len:u32, crc32:u32}
9//! [8+16*N..8+16*N+4] whole-header CRC32
10//! [..4096] zero-pad
11//! sections start at 8-byte aligned offsets (from file start, after the header page)
12//! ```
13//!
14//! Section ids (T5 layout):
15//! 0 = CSR topology (rkyv CsrData)
16//! 1 = columns (rkyv ColumnsData)
17//! 2 = id map (rkyv IdMapData)
18//! 3 = interner (rkyv InternerData)
19//! 4 = META (bincode V8Meta — labels + wal_truncated only; large fields moved to own sections)
20//! 5 = EDGE_PROPS (rkyv EdgePropsData, sorted by (etype,src,dst))
21//! 6 = HNSW (rkyv HnswSectionData, opaque blobs)
22//! 7 = PROVENANCE (rkyv ProvenanceSectionData; retained as undecoded bytes at open)
23//! 8 = RULES_META (rkyv RulesMetaData)
24//! 9 = VIEWS (rkyv ViewsSectionData)
25//! 10 = IVF_STATE (bincode BTreeMap<String,PerRuleIvfState>; retained as undecoded bytes at open)
26//! 11 = LAST_CHANGE (bincode HashMap<u32,u64>)
27//! 12 = STRINGS (rkyv StringTableData — the one table every Str column indexes; V9 only)
28
29pub mod encode;
30pub mod layout;
31pub mod seam;
32
33use crate::types::{GraphError, Result};
34use crate::v8::layout::{
35 ArchivedColumns, ArchivedCsr, ArchivedEdgeProps, ArchivedHnsw, ArchivedIdMap, ArchivedInterner,
36 ArchivedRulesMeta, ArchivedStringTable, ArchivedViews,
37};
38use memmap2::MmapOptions;
39use std::path::Path;
40use std::sync::atomic::{AtomicU8, Ordering};
41
42/// Storage backing for a `MappedBase`.
43///
44/// `Mapped` uses a read-only `mmap` backed by the file on disk.
45/// `Owned` holds the raw bytes in a `Vec` (used when the Fs abstraction
46/// returns bytes rather than a file path, e.g. in-memory Fs in tests).
47enum Backing {
48 Mapped(memmap2::Mmap),
49 Owned(Vec<u8>),
50}
51
52impl std::ops::Deref for Backing {
53 type Target = [u8];
54 fn deref(&self) -> &[u8] {
55 match self {
56 Backing::Mapped(m) => m.as_ref(),
57 Backing::Owned(v) => v.as_slice(),
58 }
59 }
60}
61
62/// Size of the header page in bytes.
63pub const HEADER_SIZE: usize = 4096;
64
65/// Section id constants (sections 0-4 from Task 1, sections 5-9 from Task 2).
66pub const SECTION_TOPOLOGY: u8 = 0;
67pub const SECTION_COLUMNS: u8 = 1;
68pub const SECTION_IDS: u8 = 2;
69pub const SECTION_SYMS: u8 = 3;
70pub const SECTION_META: u8 = 4;
71/// Edge properties: sorted `EdgePropsData` (etype, src, dst → props blob).
72pub const SECTION_EDGE_PROPS: u8 = 5;
73/// Per-rule HNSW graph blobs: `HnswSectionData` sorted by rule name.
74pub const SECTION_HNSW: u8 = 6;
75/// Per-rule provenance sorted triples: `ProvenanceSectionData`.
76pub const SECTION_PROVENANCE: u8 = 7;
77/// Rule definitions, trip flags, fire counters: `RulesMetaData`.
78pub const SECTION_RULES_META: u8 = 8;
79/// Materialized view definitions: `ViewsSectionData`.
80pub const SECTION_VIEWS: u8 = 9;
81/// Per-approximate-rule IVF cluster state: bincode `BTreeMap<String, PerRuleIvfState>`.
82/// Retained as undecoded bytes at open; consumed lazily on first mutation or WAL replay.
83pub const SECTION_IVF_STATE: u8 = 10;
84/// Per-node last-change commit sequence: bincode `HashMap<u32, u64>` (node_id → commit_seq).
85/// Small section (8-16 bytes/node); loaded eagerly at open. Missing in pre-Task-3 snapshots
86/// (treated as absent; the live map is rebuilt from WAL replay only).
87pub const SECTION_LAST_CHANGE: u8 = 11;
88/// Shared string table for every `ColumnData::Str` in the columns section:
89/// rkyv `StringTableData`. Written from V9 on; absent in V5–V8 snapshots,
90/// where each string column carries its own copy and that copy is authoritative.
91pub const SECTION_STRINGS: u8 = 12;
92
93/// Total number of canonical section slots (used for atomic check_state array).
94/// Extended from 11 (Task 5: +ivf_state) to 12 (Task 3: +last_change) to 13
95/// (v0.6.5: +strings).
96pub const V8_MAGIC_SECTION_COUNT: usize = 13;
97
98/// Returns `true` for sections whose content is large enough that a
99/// full-section CRC at first touch would cost tens or hundreds of
100/// milliseconds. Integrity for these sections is deferred to the explicit
101/// `mushroomdb verify` command. Bounds are still validated at open time via
102/// `validate_section_bounds`.
103///
104/// Small sections (IDS, SYMS, META, RULES_META, VIEWS) retain eager per-touch
105/// CRC because their size is below 3 MiB and the cost is negligible.
106fn is_large_section(id: u8) -> bool {
107 matches!(
108 id,
109 SECTION_TOPOLOGY
110 | SECTION_COLUMNS
111 | SECTION_EDGE_PROPS
112 | SECTION_HNSW
113 | SECTION_PROVENANCE
114 | SECTION_IVF_STATE
115 | SECTION_STRINGS
116 )
117}
118
119/// Atomic check state values.
120const STATE_UNCHECKED: u8 = 0;
121const STATE_OK: u8 = 1;
122const STATE_BAD: u8 = 2;
123
124#[derive(Clone, Copy)]
125struct SectionEntry {
126 id: u8,
127 offset: u32,
128 len: u32,
129 crc32: u32,
130}
131
132/// A read-only V8 snapshot backed by an mmap or owned bytes.
133///
134/// Small sections (IDS, SYMS, META, RULES_META, VIEWS) are CRC-checked on
135/// first access. Large sections (TOPOLOGY, COLUMNS, EDGE_PROPS, HNSW,
136/// PROVENANCE, IVF_STATE) skip automatic CRC; their rkyv accessors use
137/// `rkyv::access_unchecked` (O(1) root-pointer lookup, no full-section walk).
138/// Full integrity audit is available via `mushroomdb verify`.
139pub struct MappedBase {
140 backing: Backing,
141 dir: Vec<SectionEntry>,
142 /// Per-section lazy check state: 0=unchecked, 1=ok, 2=bad.
143 check_state: [AtomicU8; V8_MAGIC_SECTION_COUNT],
144 /// One decode per `Mixed` column, for the life of this mapping.
145 mixed: crate::v8::seam::MixedCache,
146}
147
148impl MappedBase {
149 /// Open and mmap a V8 snapshot file at `path`.
150 ///
151 /// Validates the 4KB header (magic, version, section directory,
152 /// whole-header CRC32). Per-section CRC validation is deferred until
153 /// first access.
154 pub fn map(path: &Path) -> Result<Self> {
155 let file = std::fs::File::open(path).map_err(GraphError::Io)?;
156 // SAFETY: `memmap2::Mmap` is created read-only (MAP_SHARED | PROT_READ).
157 // On Linux and macOS the kernel ref-counts the underlying vnode; the fd
158 // can be closed after mmap returns and the mapping remains valid for its
159 // lifetime. No mutable aliasing is possible because we never take a
160 // `&mut` reference to the mapped bytes through this type.
161 let mmap = unsafe { MmapOptions::new().map(&file) }.map_err(GraphError::Io)?;
162 let dir = parse_header(&mmap)?;
163 Ok(Self {
164 backing: Backing::Mapped(mmap),
165 dir,
166 check_state: std::array::from_fn(|_| AtomicU8::new(STATE_UNCHECKED)),
167 mixed: Default::default(),
168 })
169 }
170
171 /// Construct a `MappedBase` from an owned byte buffer (no file required).
172 ///
173 /// Used when the `Fs` implementation returns bytes directly (e.g. the
174 /// in-memory `MemFs` used in unit tests or the generic `open_with(fs)`
175 /// path that only exposes `Fs::read`).
176 pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
177 let dir = parse_header(&bytes)?;
178 Ok(Self {
179 backing: Backing::Owned(bytes),
180 dir,
181 check_state: std::array::from_fn(|_| AtomicU8::new(STATE_UNCHECKED)),
182 mixed: Default::default(),
183 })
184 }
185
186 /// Check that every section listed in the directory fits within the backing
187 /// buffer, and that the four large rkyv sections carry enough bytes for
188 /// their rkyv root struct. Pure pointer arithmetic — no bytes are read,
189 /// no CRCs are computed, and no page faults are triggered.
190 ///
191 /// Used by `restore_v8_base` to detect truncated or corrupt snapshots
192 /// eagerly at open time before the expensive section content reads are
193 /// deferred.
194 ///
195 /// The minimum-size check closes the gap between `validate_section_bounds`
196 /// (which only verifies `(offset, len)` fit in the file) and the
197 /// individual accessor checks in `topology()` / `columns()` /
198 /// `edge_props_section()` / `hnsw_section()`. Without this check a
199 /// crafted snapshot with `len = 1` for the TOPOLOGY section would pass
200 /// bounds validation and then panic inside `topology().expect(...)` on the
201 /// first query. After this check all "bounds validated at open" expects
202 /// become true post-validation invariants.
203 pub fn validate_section_bounds(&self) -> Result<()> {
204 for entry in &self.dir {
205 let start = entry.offset as usize;
206 let end = start
207 .checked_add(entry.len as usize)
208 .ok_or_else(|| GraphError::Corrupt {
209 detail: format!(
210 "v8: section {} length overflow (offset={}, len={})",
211 entry.id, entry.offset, entry.len
212 ),
213 })?;
214 self.backing
215 .get(start..end)
216 .ok_or_else(|| GraphError::Corrupt {
217 detail: format!(
218 "v8: section {} extends beyond file (end={}, file_len={})",
219 entry.id,
220 end,
221 self.backing.len()
222 ),
223 })?;
224 // Minimum rkyv root size check for the four large sections.
225 // The individual accessors (topology(), columns(), etc.) already
226 // guard on this, but only AFTER validate_section_bounds has
227 // returned Ok. Checking here prevents the expect()-on-Err panic
228 // that would otherwise fire on the first query after open.
229 if let Some(min) = min_rkyv_root_size(entry.id) {
230 if (entry.len as usize) < min {
231 return Err(GraphError::Corrupt {
232 detail: format!(
233 "v8: section {} payload too small for rkyv root \
234 (len={}, minimum={})",
235 entry.id, entry.len, min
236 ),
237 });
238 }
239 }
240 }
241 Ok(())
242 }
243
244 /// Validate the CRC32 of every section and check rkyv-accessible sections
245 /// for structural integrity.
246 ///
247 /// This is the on-demand integrity check exposed by `mushroomdb verify`.
248 /// Large sections skip automatic CRC during normal operation; this method
249 /// runs it explicitly.
250 ///
251 /// Returns one entry per directory section:
252 /// `(section_id, section_name, bytes_checked, Ok(()) | Err(msg))`.
253 pub fn verify_integrity(
254 &self,
255 ) -> Vec<(u8, &'static str, usize, std::result::Result<(), String>)> {
256 let name = |id| match id {
257 SECTION_TOPOLOGY => "topology",
258 SECTION_COLUMNS => "columns",
259 SECTION_IDS => "ids",
260 SECTION_SYMS => "syms",
261 SECTION_META => "meta",
262 SECTION_EDGE_PROPS => "edge_props",
263 SECTION_HNSW => "hnsw",
264 SECTION_PROVENANCE => "provenance",
265 SECTION_RULES_META => "rules_meta",
266 SECTION_VIEWS => "views",
267 SECTION_IVF_STATE => "ivf_state",
268 SECTION_LAST_CHANGE => "last_change",
269 SECTION_STRINGS => "strings",
270 _ => "unknown",
271 };
272 self.dir
273 .iter()
274 .map(|entry| {
275 let id = entry.id;
276 let start = entry.offset as usize;
277 let end = match start.checked_add(entry.len as usize) {
278 Some(e) => e,
279 None => {
280 return (
281 id,
282 name(id),
283 0,
284 Err(format!("section {id}: length overflow")),
285 )
286 }
287 };
288 let bytes = match self.backing.get(start..end) {
289 Some(b) => b,
290 None => {
291 return (
292 id,
293 name(id),
294 0,
295 Err(format!("section {id}: extends beyond file")),
296 )
297 }
298 };
299 let computed = crc32fast::hash(bytes);
300 if computed != entry.crc32 {
301 (
302 id,
303 name(id),
304 bytes.len(),
305 Err(format!(
306 "CRC mismatch (expected {:08x}, computed {:08x})",
307 entry.crc32, computed
308 )),
309 )
310 } else {
311 (id, name(id), bytes.len(), Ok(()))
312 }
313 })
314 .collect()
315 }
316
317 /// Return the raw bytes for `section_id`, validating its CRC32 lazily.
318 /// Return the raw bytes for a section by ID.
319 ///
320 /// Exposed as `pub(crate)` so that `snapshot::decode_v8_from_mapped` can
321 /// call `rkyv::access` (validated) for hostile-byte safety while the
322 /// production seam path uses the `access_unchecked` accessors above.
323 /// `true` when the directory carries an entry for `section_id`.
324 ///
325 /// The optional sections (IVF_STATE, LAST_CHANGE, STRINGS) are absent from
326 /// snapshots written before they existed; this is how a caller tells
327 /// "absent" from "present but unreadable" without swallowing the second.
328 pub(crate) fn has_section(&self, section_id: u8) -> bool {
329 self.dir.iter().any(|e| e.id == section_id)
330 }
331
332 pub(crate) fn section_bytes(&self, section_id: u8) -> Result<&[u8]> {
333 let entry = self
334 .dir
335 .iter()
336 .find(|e| e.id == section_id)
337 .ok_or_else(|| GraphError::Corrupt {
338 detail: format!("v8: section {section_id} not found in directory"),
339 })?;
340 let start = entry.offset as usize;
341 let end = start
342 .checked_add(entry.len as usize)
343 .ok_or_else(|| GraphError::Corrupt {
344 detail: format!("v8: section {section_id} length overflow"),
345 })?;
346 let bytes = self
347 .backing
348 .get(start..end)
349 .ok_or_else(|| GraphError::Corrupt {
350 detail: format!("v8: section {section_id} extends beyond file"),
351 })?;
352 // Per-section timing when MUSHROOMDB_TRACE_OPEN is set.
353 let _trace_t = if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
354 Some((section_id, std::time::Instant::now()))
355 } else {
356 None
357 };
358
359 // Lazy CRC validation — small sections only.
360 //
361 // Large sections (TOPOLOGY, COLUMNS, EDGE_PROPS, HNSW, PROVENANCE,
362 // IVF_STATE) skip the per-touch CRC because a full-section hash at
363 // hundreds of MiB costs 50–200 ms and is not necessary for memory
364 // safety (bounds are validated at open by `validate_section_bounds`;
365 // rkyv access is bounds-checked against the returned slice).
366 // Use `mushroomdb verify` for explicit integrity audits.
367 if !is_large_section(section_id) {
368 let idx = section_id as usize;
369 debug_assert!(
370 idx < V8_MAGIC_SECTION_COUNT,
371 "section_id {section_id} >= V8_MAGIC_SECTION_COUNT ({V8_MAGIC_SECTION_COUNT}); \
372 resize check_state before adding new section ids"
373 );
374 if idx < V8_MAGIC_SECTION_COUNT {
375 match self.check_state[idx].load(Ordering::Acquire) {
376 STATE_OK => {} // already verified
377 STATE_BAD => {
378 return Err(GraphError::Corrupt {
379 detail: format!("v8: section {section_id} CRC mismatch (cached)"),
380 });
381 }
382 _ => {
383 let computed = crc32fast::hash(bytes);
384 if computed != entry.crc32 {
385 self.check_state[idx].store(STATE_BAD, Ordering::Release);
386 return Err(GraphError::Corrupt {
387 detail: format!(
388 "v8: section {section_id} CRC mismatch \
389 (expected {:08x}, computed {:08x})",
390 entry.crc32, computed
391 ),
392 });
393 }
394 self.check_state[idx].store(STATE_OK, Ordering::Release);
395 }
396 }
397 }
398 }
399 if let Some((id, t)) = _trace_t {
400 eprintln!(
401 "[MUSHROOMDB_TRACE_OPEN] section_bytes({id}): {:>9.3?}",
402 t.elapsed()
403 );
404 }
405 Ok(bytes)
406 }
407
408 /// Zero-copy access to the archived topology (CSR).
409 ///
410 /// Uses `rkyv::access_unchecked` to avoid the O(section-size) pointer
411 /// validation walk that `rkyv::access` performs. Section bounds are
412 /// verified at open by `validate_section_bounds`; all CSR field accesses
413 /// in `seam.rs` go through Rust bounds-checked slice indexing. File
414 /// corruption is caught by `mushroomdb verify` (explicit full CRC32).
415 pub fn topology(&self) -> Result<&ArchivedCsr> {
416 let bytes = self.section_bytes(SECTION_TOPOLOGY)?;
417 if bytes.len() < std::mem::size_of::<crate::v8::layout::ArchivedCsrData>() {
418 return Err(GraphError::Corrupt {
419 detail: "v8: topology section too short for rkyv root".to_string(),
420 });
421 }
422 // SAFETY: (1) Checked bytes.len() >= size_of::<ArchivedCsrData>() above, so
423 // root_position cannot underflow. (2) The backing mmap maps the full file with
424 // PROT_READ; section bytes are a validated subslice. The encoder writes
425 // self-contained sections: all rkyv relative pointers from `encode_v8` are
426 // within-section. (3) This is sound for encoder-produced uncorrupted data.
427 // However, a bit-flip on a relative-pointer field causes `ArchivedVec::as_slice`
428 // to resolve an out-of-bounds address before any length check — genuine UB,
429 // not a panic. Mitigated by `mushroomdb verify` (full-section CRC32 on demand)
430 // and planned Miri/ASAN CI coverage.
431 Ok(unsafe { rkyv::access_unchecked::<crate::v8::layout::ArchivedCsrData>(bytes) })
432 }
433
434 /// The memo for this base's `Mixed` columns.
435 ///
436 /// Pair it with [`columns`](Self::columns) via
437 /// `ColumnsView::with_base_cached`; the two always describe the same
438 /// immutable mapping, so a memo can never outlive or mismatch its blobs.
439 pub fn mixed_cache(&self) -> &crate::v8::seam::MixedCache {
440 &self.mixed
441 }
442
443 /// Zero-copy access to the archived column store.
444 ///
445 /// Uses `rkyv::access_unchecked`; see `topology()` for the full safety
446 /// rationale. Per-field accesses in `ColumnsView` go through
447 /// bounds-checked slice indexing and explicit length guards.
448 pub fn columns(&self) -> Result<&ArchivedColumns> {
449 let bytes = self.section_bytes(SECTION_COLUMNS)?;
450 if bytes.len() < std::mem::size_of::<crate::v8::layout::ArchivedColumnsData>() {
451 return Err(GraphError::Corrupt {
452 detail: "v8: columns section too short for rkyv root".to_string(),
453 });
454 }
455 // SAFETY: Minimum length checked above; encoder writes self-contained sections
456 // with all relative pointers within-section. Sound for encoder-produced
457 // uncorrupted data. A bit-flip on a relative-pointer field causes
458 // `ArchivedVec::as_slice` to resolve an out-of-bounds address before any
459 // length check — genuine UB, not a panic. Mitigated by `mushroomdb verify`
460 // (full-section CRC32 on demand) and planned Miri/ASAN CI coverage.
461 Ok(unsafe { rkyv::access_unchecked::<crate::v8::layout::ArchivedColumnsData>(bytes) })
462 }
463
464 /// Zero-copy access to the shared string table (section 12).
465 ///
466 /// `None` when the snapshot predates the shared section (pre-V9): every
467 /// `ColumnData::Str` carries its own copy and that copy is authoritative.
468 /// `Some(Err(..))` only when the section is present but unreadable, which
469 /// must not be silently treated as "absent" — that would hand the caller
470 /// the empty per-column tables a V9 snapshot writes and lose every string.
471 ///
472 /// Uses `rkyv::access_unchecked`; see `topology()` for the full safety
473 /// rationale. Reads in `ColumnsView`/`archived_to_columnstore` are
474 /// bounds-checked against the returned slice.
475 pub fn string_table(&self) -> Option<Result<&ArchivedStringTable>> {
476 if !self.has_section(SECTION_STRINGS) {
477 return None;
478 }
479 Some((|| {
480 let bytes = self.section_bytes(SECTION_STRINGS)?;
481 if bytes.len() < std::mem::size_of::<crate::v8::layout::ArchivedStringTableData>() {
482 return Err(GraphError::Corrupt {
483 detail: "v8: strings section too short for rkyv root".to_string(),
484 });
485 }
486 // SAFETY: Minimum length checked above; encoder writes self-contained
487 // sections with all relative pointers within-section. Same rationale
488 // and same mitigation (`mushroomdb verify`) as `columns()`.
489 Ok(unsafe {
490 rkyv::access_unchecked::<crate::v8::layout::ArchivedStringTableData>(bytes)
491 })
492 })())
493 }
494
495 /// Zero-copy access to the archived id map.
496 pub fn ids(&self) -> Result<&ArchivedIdMap> {
497 let bytes = self.section_bytes(SECTION_IDS)?;
498 rkyv::access::<crate::v8::layout::ArchivedIdMapData, rkyv::rancor::Error>(bytes).map_err(
499 |e| GraphError::Corrupt {
500 detail: format!("v8: ids rkyv access: {e}"),
501 },
502 )
503 }
504
505 /// Zero-copy access to the archived symbol interner.
506 pub fn syms(&self) -> Result<&ArchivedInterner> {
507 let bytes = self.section_bytes(SECTION_SYMS)?;
508 rkyv::access::<crate::v8::layout::ArchivedInternerData, rkyv::rancor::Error>(bytes).map_err(
509 |e| GraphError::Corrupt {
510 detail: format!("v8: syms rkyv access: {e}"),
511 },
512 )
513 }
514
515 /// Raw bytes for the bincode meta section.
516 pub fn meta_bytes(&self) -> Result<&[u8]> {
517 self.section_bytes(SECTION_META)
518 }
519
520 /// Zero-copy access to the archived edge properties (section 5).
521 ///
522 /// Uses `rkyv::access_unchecked`; see `topology()` for the full safety
523 /// rationale. Per-edge property reads in `EdgePropsView` go through
524 /// bounds-checked slice indexing.
525 pub fn edge_props_section(&self) -> Result<&ArchivedEdgeProps> {
526 let bytes = self.section_bytes(SECTION_EDGE_PROPS)?;
527 if bytes.len() < std::mem::size_of::<crate::v8::layout::ArchivedEdgePropsData>() {
528 return Err(GraphError::Corrupt {
529 detail: "v8: edge_props section too short for rkyv root".to_string(),
530 });
531 }
532 // SAFETY: Minimum length checked above; encoder writes self-contained sections
533 // with all relative pointers within-section. Sound for encoder-produced
534 // uncorrupted data. A bit-flip on a relative-pointer field causes
535 // `ArchivedVec::as_slice` to resolve an out-of-bounds address before any
536 // length check — genuine UB, not a panic. Mitigated by `mushroomdb verify`
537 // (full-section CRC32 on demand) and planned Miri/ASAN CI coverage.
538 Ok(unsafe { rkyv::access_unchecked::<crate::v8::layout::ArchivedEdgePropsData>(bytes) })
539 }
540
541 /// Zero-copy access to the archived HNSW section (section 6).
542 ///
543 /// Uses `rkyv::access_unchecked`; see `topology()` for the full safety
544 /// rationale. Called once at first-use to load HNSW state into the engine.
545 pub fn hnsw_section(&self) -> Result<&ArchivedHnsw> {
546 let bytes = self.section_bytes(SECTION_HNSW)?;
547 if bytes.len() < std::mem::size_of::<crate::v8::layout::ArchivedHnswSectionData>() {
548 return Err(GraphError::Corrupt {
549 detail: "v8: hnsw section too short for rkyv root".to_string(),
550 });
551 }
552 // SAFETY: Minimum length checked above; encoder writes self-contained sections
553 // with all relative pointers within-section. Sound for encoder-produced
554 // uncorrupted data. A bit-flip on a relative-pointer field causes
555 // `ArchivedVec::as_slice` to resolve an out-of-bounds address before any
556 // length check — genuine UB, not a panic. Mitigated by `mushroomdb verify`
557 // (full-section CRC32 on demand) and planned Miri/ASAN CI coverage.
558 // The returned reference is immediately converted to owned data by
559 // `archived_hnsw_to_owned`, so no aliasing persists after the call.
560 Ok(unsafe { rkyv::access_unchecked::<crate::v8::layout::ArchivedHnswSectionData>(bytes) })
561 }
562
563 /// Structurally validate the sections that the hot path reads via
564 /// `access_unchecked` (topology, columns, edge_props, hnsw, and — from V9
565 /// on — the shared string table), using rkyv's
566 /// checked access (`bytecheck`). This walks every relative pointer and
567 /// rejects out-of-bounds / malformed archives — the defense the hot path
568 /// deliberately skips for speed.
569 ///
570 /// Unlike CRC32 (which an attacker who controls the bytes can recompute),
571 /// this catches a *maliciously* crafted snapshot whose pointers would
572 /// otherwise trigger UB in `access_unchecked`. It is O(section size) and
573 /// touches every page, so it is intended for a pre-flight check
574 /// (`mushroomdb verify`), not the per-query read path. Returns the first
575 /// section that fails to validate.
576 pub fn validate_hot_sections(&self) -> Result<()> {
577 use crate::v8::layout::{
578 ArchivedColumnsData, ArchivedCsrData, ArchivedEdgePropsData, ArchivedHnswSectionData,
579 ArchivedStringTableData,
580 };
581 let check = |bytes: &[u8], name: &str| -> Result<()> {
582 match name {
583 "topology" => {
584 rkyv::access::<ArchivedCsrData, rkyv::rancor::Error>(bytes).map(|_| ())
585 }
586 "columns" => {
587 rkyv::access::<ArchivedColumnsData, rkyv::rancor::Error>(bytes).map(|_| ())
588 }
589 "edge_props" => {
590 rkyv::access::<ArchivedEdgePropsData, rkyv::rancor::Error>(bytes).map(|_| ())
591 }
592 "hnsw" => {
593 rkyv::access::<ArchivedHnswSectionData, rkyv::rancor::Error>(bytes).map(|_| ())
594 }
595 "strings" => {
596 rkyv::access::<ArchivedStringTableData, rkyv::rancor::Error>(bytes).map(|_| ())
597 }
598 _ => Ok(()),
599 }
600 .map_err(|e| GraphError::Corrupt {
601 detail: format!("v8: {name} section failed structural validation: {e}"),
602 })
603 };
604 check(self.section_bytes(SECTION_TOPOLOGY)?, "topology")?;
605 check(self.section_bytes(SECTION_COLUMNS)?, "columns")?;
606 check(self.section_bytes(SECTION_EDGE_PROPS)?, "edge_props")?;
607 check(self.section_bytes(SECTION_HNSW)?, "hnsw")?;
608 // Absent in a pre-V9 snapshot; when present it is read through
609 // `access_unchecked` like the other large sections, so this is the pass
610 // that catches a crafted relative pointer in it.
611 if self.has_section(SECTION_STRINGS) {
612 check(self.section_bytes(SECTION_STRINGS)?, "strings")?;
613 }
614 Ok(())
615 }
616
617 /// Zero-copy access to the archived rules meta (section 8).
618 pub fn rules_meta_section(&self) -> Result<&ArchivedRulesMeta> {
619 let bytes = self.section_bytes(SECTION_RULES_META)?;
620 rkyv::access::<crate::v8::layout::ArchivedRulesMetaData, rkyv::rancor::Error>(bytes)
621 .map_err(|e| GraphError::Corrupt {
622 detail: format!("v8: rules_meta rkyv access: {e}"),
623 })
624 }
625
626 /// Zero-copy access to the archived views (section 9).
627 pub fn views_section(&self) -> Result<&ArchivedViews> {
628 let bytes = self.section_bytes(SECTION_VIEWS)?;
629 rkyv::access::<crate::v8::layout::ArchivedViewsSectionData, rkyv::rancor::Error>(bytes)
630 .map_err(|e| GraphError::Corrupt {
631 detail: format!("v8: views rkyv access: {e}"),
632 })
633 }
634
635 /// Raw bytes for the IVF-state section (section 10).
636 ///
637 /// The caller retains these bytes without decoding until first use.
638 /// Returns `Ok(&[])` when the section is absent from the directory
639 /// (pre-T5 stores migrated from V5–V7 have no IVF section; treat as empty).
640 /// Any other error (truncation, CRC mismatch) is propagated so that torn
641 /// writes are detected rather than silently returning an empty map.
642 pub fn ivf_bytes(&self) -> Result<&[u8]> {
643 if self.dir.iter().all(|e| e.id != SECTION_IVF_STATE) {
644 return Ok(&[]);
645 }
646 self.section_bytes(SECTION_IVF_STATE)
647 }
648
649 /// Raw bytes for the last-change section (section 11).
650 ///
651 /// Returns `Ok(&[])` when the section is absent from the directory
652 /// (pre-Task-3 snapshots have no LAST_CHANGE section; treat as empty map).
653 /// Any other error (truncation, CRC mismatch) is propagated.
654 pub fn last_change_bytes(&self) -> Result<&[u8]> {
655 if self.dir.iter().all(|e| e.id != SECTION_LAST_CHANGE) {
656 return Ok(&[]);
657 }
658 self.section_bytes(SECTION_LAST_CHANGE)
659 }
660
661 /// Raw bytes for the edge-props section (section 5).
662 /// Used for byte-identical passthrough when the overlay has no changes.
663 pub fn edge_props_raw_bytes(&self) -> Result<&[u8]> {
664 self.section_bytes(SECTION_EDGE_PROPS)
665 }
666
667 /// Raw bytes for the provenance section (section 7).
668 /// Retained without decoding until first provenance access.
669 pub fn provenance_raw_bytes(&self) -> Result<&[u8]> {
670 self.section_bytes(SECTION_PROVENANCE)
671 }
672}
673
674/// Minimum payload size for the four large rkyv-archived sections.
675///
676/// The rkyv root of an archived type must fit within the section payload;
677/// `rkyv::access_unchecked` reads the root pointer at `bytes.len() -
678/// size_of::<T::Archived>()`. A section shorter than the root struct would
679/// cause the accessor to attempt an out-of-bounds read. We catch this at
680/// open time in `validate_section_bounds` so the hot-path `expect()` calls
681/// never fire on corrupt data.
682fn min_rkyv_root_size(section_id: u8) -> Option<usize> {
683 use crate::v8::layout::{
684 ArchivedColumnsData, ArchivedCsrData, ArchivedEdgePropsData, ArchivedHnswSectionData,
685 ArchivedStringTableData,
686 };
687 match section_id {
688 SECTION_TOPOLOGY => Some(std::mem::size_of::<ArchivedCsrData>()),
689 SECTION_COLUMNS => Some(std::mem::size_of::<ArchivedColumnsData>()),
690 SECTION_EDGE_PROPS => Some(std::mem::size_of::<ArchivedEdgePropsData>()),
691 SECTION_HNSW => Some(std::mem::size_of::<ArchivedHnswSectionData>()),
692 SECTION_STRINGS => Some(std::mem::size_of::<ArchivedStringTableData>()),
693 _ => None,
694 }
695}
696
697/// Parse and validate the V8 header page.
698///
699/// Validates magic, version, directory bounds, and the whole-header CRC32.
700/// Returns the section directory on success.
701fn parse_header(mmap: &[u8]) -> Result<Vec<SectionEntry>> {
702 if mmap.len() < HEADER_SIZE {
703 return Err(GraphError::Corrupt {
704 detail: format!(
705 "v8: file is {} bytes; minimum for header is {HEADER_SIZE}",
706 mmap.len()
707 ),
708 });
709 }
710 if &mmap[0..4] != b"GDB1" {
711 return Err(GraphError::Corrupt {
712 detail: "v8: bad magic (expected GDB1)".into(),
713 });
714 }
715 // Infallible: `mmap.len() >= HEADER_SIZE` checked above; slices are exactly 2 bytes each.
716 // V8 and V9 share this container byte-for-byte: same magic, same 4 KB
717 // header page, same 16-byte directory entries, same per-section CRC. V9
718 // only adds section 12 and empties the per-column string tables, so one
719 // parser serves both and `string_table()` is what tells them apart.
720 let version = u16::from_le_bytes(mmap[4..6].try_into().unwrap());
721 if version != crate::snapshot::VERSION_8 && version != crate::snapshot::VERSION_9 {
722 return Err(GraphError::Corrupt {
723 detail: format!("v8: expected version 8 or 9, got {version}"),
724 });
725 }
726 let section_count = u16::from_le_bytes(mmap[6..8].try_into().unwrap()) as usize;
727 let dir_end = 8usize
728 .checked_add(section_count.saturating_mul(16))
729 .ok_or_else(|| GraphError::Corrupt {
730 detail: "v8: directory length overflow".into(),
731 })?;
732 if dir_end + 4 > HEADER_SIZE {
733 return Err(GraphError::Corrupt {
734 detail: format!(
735 "v8: {section_count} sections require dir_end={dir_end} which overflows the header"
736 ),
737 });
738 }
739 // Whole-header CRC32 covers bytes [0..dir_end].
740 // Infallible: `dir_end + 4 <= HEADER_SIZE` verified above; slice is exactly 4 bytes.
741 let stored_crc = u32::from_le_bytes(mmap[dir_end..dir_end + 4].try_into().unwrap());
742 let computed_crc = crc32fast::hash(&mmap[0..dir_end]);
743 if stored_crc != computed_crc {
744 return Err(GraphError::Corrupt {
745 detail: format!(
746 "v8: header CRC mismatch (expected {:08x}, computed {:08x})",
747 stored_crc, computed_crc
748 ),
749 });
750 }
751 // Parse directory entries: {id:u8, _pad:[u8;3], offset:u32, len:u32, crc32:u32}.
752 // Infallible: each `base + 16 <= dir_end <= HEADER_SIZE <= mmap.len()`, so every
753 // 4-byte subslice is within bounds; `try_into` on an exact-size slice cannot fail.
754 let mut dir = Vec::with_capacity(section_count);
755 for i in 0..section_count {
756 let base = 8 + i * 16;
757 let id = mmap[base];
758 let offset = u32::from_le_bytes(mmap[base + 4..base + 8].try_into().unwrap());
759 let len = u32::from_le_bytes(mmap[base + 8..base + 12].try_into().unwrap());
760 let crc32 = u32::from_le_bytes(mmap[base + 12..base + 16].try_into().unwrap());
761 dir.push(SectionEntry {
762 id,
763 offset,
764 len,
765 crc32,
766 });
767 }
768 Ok(dir)
769}
770
771// ---------------------------------------------------------------------------
772// Unit tests
773// ---------------------------------------------------------------------------
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778 use crate::columns::ColumnStore;
779 use crate::idmap::IdMap;
780 use crate::interner::Interner;
781 use crate::topology::Topology;
782 use crate::types::Value;
783 use crate::v8::encode::{encode_v8, V8Meta};
784 use std::collections::{BTreeMap, HashMap};
785
786 fn tiny_v8_meta() -> V8Meta {
787 V8Meta {
788 labels: vec![0, 0],
789 edge_props: crate::edge_props::EdgeProps::new(),
790 rule_defs: vec![],
791 provenance: BTreeMap::new(),
792 rule_tripped: BTreeMap::new(),
793 rule_fires: BTreeMap::new(),
794 ivf_bytes: Vec::new(),
795 view_defs: vec![],
796 wal_truncated: false,
797 hnsw: BTreeMap::new(),
798 last_change: HashMap::new(),
799 }
800 }
801
802 fn encode_tiny() -> Vec<u8> {
803 let mut ids = IdMap::new();
804 ids.get_or_insert("a");
805 ids.get_or_insert("b");
806 let mut syms = Interner::new();
807 let e = syms.intern("E");
808 let mut topo = Topology::new();
809 topo.add_edge(e, 0, 1);
810 let mut props = ColumnStore::new();
811 props.set(0, "v", Value::Int(42));
812 let meta = tiny_v8_meta();
813 let mut out = Vec::new();
814 encode_v8(
815 None, None, None, None, None, &topo, &props, &ids, &syms, &meta, &mut out,
816 )
817 .expect("encode_v8");
818 out
819 }
820
821 fn tmp_path(suffix: &str) -> std::path::PathBuf {
822 std::path::PathBuf::from(format!(
823 "/tmp/mushroom_v8_{}_{}.bin",
824 std::process::id(),
825 suffix
826 ))
827 }
828
829 #[test]
830 fn v8_encode_and_map_sections_valid() {
831 let bytes = encode_tiny();
832 let path = tmp_path("valid");
833 std::fs::write(&path, &bytes).unwrap();
834 let _cleanup = defer_remove(&path);
835 let base = MappedBase::map(&path).expect("map");
836 // Topology section: 1 edge
837 let topo = base.topology().expect("topology()");
838 assert_eq!(u64::from(topo.edge_count), 1);
839 // IDs section: 2 keys
840 let ids = base.ids().expect("ids()");
841 assert_eq!(ids.to_key.len(), 2);
842 // Syms section: 1 symbol
843 let syms = base.syms().expect("syms()");
844 assert_eq!(syms.to_str.len(), 1);
845 assert_eq!(syms.to_str[0].as_str(), "E");
846 }
847
848 #[test]
849 fn v8_corrupt_section_crc_returns_corrupt_error() {
850 // Corrupt a SMALL section (SECTION_IDS=2) whose CRC is still checked
851 // eagerly on access. Large sections (e.g. TOPOLOGY=0) skip per-touch
852 // CRC since v0.2.0; use verify_integrity() to audit them instead.
853 let mut bytes = encode_tiny();
854 // Directory starts at file offset 8.
855 // Each entry is 16 bytes: {id:u8, _pad:[u8;3], offset:u32, len:u32, crc32:u32}
856 let section_count = u16::from_le_bytes(bytes[6..8].try_into().unwrap()) as usize;
857 let mut target_entry_base = None;
858 for i in 0..section_count {
859 let base = 8 + i * 16;
860 if bytes[base] == SECTION_IDS {
861 target_entry_base = Some(base);
862 break;
863 }
864 }
865 let entry_base = target_entry_base.expect("SECTION_IDS not found in encode_tiny output");
866 // offset field is at entry_base + 4 .. entry_base + 8.
867 let section_offset =
868 u32::from_le_bytes(bytes[entry_base + 4..entry_base + 8].try_into().unwrap()) as usize;
869 // Flip a byte inside the section payload.
870 if section_offset < bytes.len() {
871 bytes[section_offset] ^= 0xff;
872 }
873 let path = tmp_path("corrupt");
874 std::fs::write(&path, &bytes).unwrap();
875 let _cleanup = defer_remove(&path);
876 match MappedBase::map(&path) {
877 Ok(base) => {
878 // Map succeeded (header ok). ids() must fail CRC.
879 let result = base.ids();
880 match result {
881 Err(GraphError::Corrupt { .. }) => {}
882 Err(e) => panic!("expected Corrupt, got {e:?}"),
883 Ok(_) => panic!("expected Corrupt error but ids() succeeded"),
884 }
885 }
886 Err(GraphError::Corrupt { .. }) => {
887 // Corruption may have hit the header — also acceptable.
888 }
889 Err(e) => panic!("unexpected error: {e:?}"),
890 }
891 }
892
893 #[test]
894 fn v8_verify_integrity_detects_large_section_corruption() {
895 // verify_integrity() must catch corruption in large sections (e.g.
896 // TOPOLOGY=0) even though section_bytes() skips their CRC.
897 let mut bytes = encode_tiny();
898 // Find SECTION_TOPOLOGY entry in directory.
899 let section_count = u16::from_le_bytes(bytes[6..8].try_into().unwrap()) as usize;
900 let mut target_entry_base = None;
901 for i in 0..section_count {
902 let base = 8 + i * 16;
903 if bytes[base] == SECTION_TOPOLOGY {
904 target_entry_base = Some(base);
905 break;
906 }
907 }
908 let entry_base = target_entry_base.expect("SECTION_TOPOLOGY not found");
909 let section_offset =
910 u32::from_le_bytes(bytes[entry_base + 4..entry_base + 8].try_into().unwrap()) as usize;
911 if section_offset < bytes.len() {
912 bytes[section_offset] ^= 0xff;
913 }
914 let path = tmp_path("corrupt_large");
915 std::fs::write(&path, &bytes).unwrap();
916 let _cleanup = defer_remove(&path);
917 let base = MappedBase::map(&path).expect("map");
918 // topology() should succeed (CRC skipped for large sections).
919 let _ = base
920 .topology()
921 .expect("topology() must not CRC-fail large section");
922 // verify_integrity() must catch it.
923 let results = base.verify_integrity();
924 let topo = results
925 .iter()
926 .find(|(id, _, _, _)| *id == SECTION_TOPOLOGY)
927 .expect("topology entry in verify results");
928 assert!(
929 topo.3.is_err(),
930 "verify_integrity must detect TOPOLOGY corruption; got Ok"
931 );
932 }
933
934 /// A corrupt snapshot where the TOPOLOGY directory entry's `len` is set to 1
935 /// (below the rkyv root minimum) must be rejected by `validate_section_bounds`.
936 ///
937 /// This is the targeted repro for the minimum-size validation gap: before the
938 /// fix, `validate_section_bounds` only checked `(offset, len)` fits in the
939 /// file, so a `len=1` for the TOPOLOGY section passed — then the first call to
940 /// `topology().expect("bounds validated at open")` panicked because
941 /// `topology()` returned `Err(Corrupt{too short for rkyv root})`.
942 #[test]
943 fn validate_section_bounds_rejects_tiny_section_len() {
944 let mut bytes = encode_tiny();
945 // Locate the SECTION_TOPOLOGY directory entry.
946 let section_count = u16::from_le_bytes(bytes[6..8].try_into().unwrap()) as usize;
947 let mut topo_entry_base = None;
948 for i in 0..section_count {
949 let base = 8 + i * 16;
950 if bytes[base] == SECTION_TOPOLOGY {
951 topo_entry_base = Some(base);
952 break;
953 }
954 }
955 let entry_base = topo_entry_base.expect("SECTION_TOPOLOGY in directory");
956 // Overwrite len (entry_base+8..entry_base+12) with 1.
957 let tiny_len: u32 = 1;
958 bytes[entry_base + 8..entry_base + 12].copy_from_slice(&tiny_len.to_le_bytes());
959 // Recompute the whole-header CRC so parse_header accepts it.
960 let dir_end = 8 + section_count * 16;
961 let new_crc = crc32fast::hash(&bytes[0..dir_end]);
962 bytes[dir_end..dir_end + 4].copy_from_slice(&new_crc.to_le_bytes());
963 // from_bytes validates only the header; validate_section_bounds (called
964 // by restore_v8_base) is the step that catches the tiny payload.
965 let base = MappedBase::from_bytes(bytes).expect("header CRC is correct after recompute");
966 let result = base.validate_section_bounds();
967 match result {
968 Err(GraphError::Corrupt { detail }) => {
969 assert!(
970 detail.contains("too small for rkyv root") || detail.contains("section"),
971 "error should mention tiny section; got: {detail}"
972 );
973 }
974 Err(other) => panic!("expected Corrupt, got {other:?}"),
975 Ok(_) => panic!("expected Err(Corrupt) for tiny section len, got Ok"),
976 }
977 }
978
979 /// `verify` must reject a structurally corrupt shared string table even
980 /// when every CRC has been repaired.
981 ///
982 /// Section 12 is a large section, so its CRC is skipped on the query path
983 /// and `verify_integrity` alone cannot be the defence: an attacker who
984 /// controls the bytes recomputes the checksum. `validate_hot_sections` is
985 /// the pass that walks the relative pointers, and this asserts it catches a
986 /// smashed root with both the section CRC and the header CRC rebuilt.
987 #[test]
988 fn verify_rejects_a_structurally_corrupt_string_table() {
989 let healthy = encode_with_strings();
990 // Locate section 12.
991 let section_count = u16::from_le_bytes(healthy[6..8].try_into().unwrap()) as usize;
992 let dir_end = 8 + section_count * 16;
993 let mut found = None;
994 for i in 0..section_count {
995 let base = 8 + i * 16;
996 if healthy[base] == SECTION_STRINGS {
997 let off =
998 u32::from_le_bytes(healthy[base + 4..base + 8].try_into().unwrap()) as usize;
999 let len =
1000 u32::from_le_bytes(healthy[base + 8..base + 12].try_into().unwrap()) as usize;
1001 found = Some((base, off, len));
1002 break;
1003 }
1004 }
1005 let (entry_base, off, len) = found.expect("a V9 snapshot must carry section 12");
1006 assert!(len > 16, "section 12 must hold a real table, got {len} B");
1007
1008 let mut detected_any = false;
1009 for byte in (len - 8)..len {
1010 let mut bytes = healthy.clone();
1011 bytes[off + byte] ^= 0xff;
1012 // Repair the section CRC, then the whole-header CRC.
1013 let crc = crc32fast::hash(&bytes[off..off + len]);
1014 bytes[entry_base + 12..entry_base + 16].copy_from_slice(&crc.to_le_bytes());
1015 let header_crc = crc32fast::hash(&bytes[0..dir_end]);
1016 bytes[dir_end..dir_end + 4].copy_from_slice(&header_crc.to_le_bytes());
1017
1018 let base = MappedBase::from_bytes(bytes).expect("header CRC is correct after repair");
1019 base.validate_section_bounds()
1020 .expect("bounds are untouched");
1021 // Every CRC was rebuilt, so the checksum audit must report clean —
1022 // which is exactly why it cannot be the defence here.
1023 assert!(
1024 base.verify_integrity().iter().all(|(_, _, _, r)| r.is_ok()),
1025 "CRCs were recomputed; a mismatch means this test is wrong"
1026 );
1027 match base.validate_hot_sections() {
1028 Err(GraphError::Corrupt { detail }) => {
1029 assert!(
1030 detail.contains("strings"),
1031 "the strings structural check must be what rejects it; got: {detail}"
1032 );
1033 detected_any = true;
1034 }
1035 Err(other) => panic!("expected Corrupt, got {other:?}"),
1036 // A flip inside the length field can still describe a
1037 // structurally valid (if wrong) archive; not a safety failure.
1038 Ok(()) => {}
1039 }
1040 }
1041 assert!(
1042 detected_any,
1043 "validate_hot_sections must reject a smashed string-table root even \
1044 with every CRC repaired"
1045 );
1046 }
1047
1048 /// A snapshot whose columns carry string properties, so section 12 holds a
1049 /// real table rather than an empty one.
1050 fn encode_with_strings() -> Vec<u8> {
1051 let mut ids = IdMap::new();
1052 let mut props = ColumnStore::new();
1053 for n in 0..32u32 {
1054 ids.get_or_insert(&format!("n{n}"));
1055 props.set(n, "tag", Value::Str(format!("tag-value-{n}")));
1056 }
1057 let mut meta = tiny_v8_meta();
1058 meta.labels = vec![0; 32];
1059 let mut out = Vec::new();
1060 encode_v8(
1061 None,
1062 None,
1063 None,
1064 None,
1065 None,
1066 &Topology::new(),
1067 &props,
1068 &ids,
1069 &Interner::new(),
1070 &meta,
1071 &mut out,
1072 )
1073 .expect("encode_v8");
1074 out
1075 }
1076
1077 /// RAII guard that removes the file on drop.
1078 struct DeferRemove(std::path::PathBuf);
1079 impl Drop for DeferRemove {
1080 fn drop(&mut self) {
1081 let _ = std::fs::remove_file(&self.0);
1082 }
1083 }
1084 fn defer_remove(p: &std::path::Path) -> DeferRemove {
1085 DeferRemove(p.to_path_buf())
1086 }
1087}