Skip to main content

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}