Skip to main content

vole_document/field/
index.rs

1//! Bounded hierarchical observation index (Phase 11.3).
2//!
3//! An index is an **advisory accelerator**, never authority (ADR-0024, DEC-4). It
4//! maps an observation *selector* — a page, object, stream, revision, or resource
5//! identified by `(kind, number, generation)` — to an exact source byte span and
6//! the seed node that serves it. A lying, corrupt, cyclic, out-of-depth, missing,
7//! or oversized node is rejected fail-closed; the exact materialization path never
8//! depends on the index.
9//!
10//! ## Why hierarchical
11//!
12//! A flat selector table is `Θ(document)`: one query reads the whole table. This
13//! module is keyed so a lookup descends `root -> internal(s) -> leaf`, reading
14//! only `O(depth)` small nodes and never a global table. `MAX_DEPTH = 3` bounds the
15//! descent to at most `MAX_DEPTH + 1` node reads.
16//!
17//! ## Wire shape (little-endian, packed, no padding, no serde)
18//!
19//! ```text
20//! header : magic:u8 | version:u8 | kind:u8 (0=leaf,1=internal) | depth:u8 | entry_count:u32
21//! leaf   : kind:u8 | generation:u16 | number:u32 | out_off:u64 | out_len:u64 | node_id:[u8;32]
22//! internal: min_kind:u8 | min_number:u32 | max_kind:u8 | max_number:u32 | child_id:[u8;32]
23//! ```
24//!
25//! Nodes are stored content-addressed (`NodeId = BLAKE3-256("VOLE:PSEED:v1" || node
26//! bytes)`), and every read verifies `NodeId::of_node(bytes) == id`.
27//!
28//! ## Bounds
29//!
30//! * `MAX_FANOUT = 256` entries per node (the absolute cap; the byte cap binds
31//!   first — a leaf holds at most 148 entries and an internal at most 194).
32//! * `MAX_DEPTH = 3` internal levels below which leaves sit at depth 0.
33//! * `MAX_INDEX_NODE_BYTES = 8 KiB` per node.
34
35use std::fs;
36use std::io::Write;
37use std::path::{Path, PathBuf};
38
39use crate::error::{Error, Result};
40use crate::store::{IoCounters, NodeId};
41
42/// Reserved HIER_INDEX record tag (`RecordTag::HierIndex`), reused as the node
43/// magic so an index node is self-identifying off the wire.
44pub const INDEX_MAGIC: u8 = 0x72;
45/// Canonical index-node format version.
46pub const INDEX_VERSION: u8 = 1;
47/// Absolute cap on entries in any node (the byte cap binds first for large entries).
48pub const MAX_FANOUT: usize = 256;
49/// Maximum internal depth; leaves always sit at depth 0.
50pub const MAX_DEPTH: u8 = 3;
51/// Maximum encoded size of a single node.
52pub const MAX_INDEX_NODE_BYTES: usize = 8 * 1024;
53
54/// Selector kind: a PDF page.
55pub const SEL_PAGE: u8 = 1;
56/// Selector kind: a PDF indirect object.
57pub const SEL_OBJECT: u8 = 2;
58/// Selector kind: an encoded stream.
59pub const SEL_STREAM: u8 = 3;
60/// Selector kind: a document revision.
61pub const SEL_REVISION: u8 = 4;
62/// Selector kind: a named resource.
63pub const SEL_RESOURCE: u8 = 5;
64/// Selector kind: a **decoded** stream, keyed by its owning object number.
65///
66/// A `PdfStreamDecoded` node is a deterministic function of its encoded node,
67/// the materializer, and the decoded length, so it gets its own index entry
68/// (Phase 11.9 review fix #3). This lets a `Stream(n) + DecodedBytes`/`Operators`
69/// observation resolve in `O(depth)` index reads instead of enumerating the
70/// whole seed store.
71pub const SEL_STREAM_DECODED: u8 = 6;
72
73/// Node kind: a run of leaf entries.
74const KIND_LEAF: u8 = 0;
75/// Node kind: a run of child pointers.
76const KIND_INTERNAL: u8 = 1;
77
78/// Encoded header length.
79const HEADER_LEN: usize = 8;
80/// One leaf entry: kind(1) + generation(2) + number(4) + out_off(8) + out_len(8)
81/// + node_id(32).
82const LEAF_ENTRY_LEN: usize = 1 + 2 + 4 + 8 + 8 + 32;
83/// One internal entry: min_kind(1) + min_number(4) + max_kind(1) + max_number(4)
84/// + child_id(32).
85const INTERNAL_ENTRY_LEN: usize = 1 + 4 + 1 + 4 + 32;
86
87/// Effective leaf capacity: the fanout cap and the node-size cap, whichever binds.
88const MAX_LEAF_ENTRIES: usize = {
89    let by_bytes = (MAX_INDEX_NODE_BYTES - HEADER_LEN) / LEAF_ENTRY_LEN;
90    if MAX_FANOUT < by_bytes {
91        MAX_FANOUT
92    } else {
93        by_bytes
94    }
95};
96
97/// Effective internal fanout: the fanout cap and the node-size cap, whichever binds.
98const MAX_INTERNAL_CHILDREN: usize = {
99    let by_bytes = (MAX_INDEX_NODE_BYTES - HEADER_LEN) / INTERNAL_ENTRY_LEN;
100    if MAX_FANOUT < by_bytes {
101        MAX_FANOUT
102    } else {
103        by_bytes
104    }
105};
106
107/// A selector key: an observation kind, its number, and its generation.
108///
109/// Ordering is lexicographic by `(kind, number, generation)`, which is the
110/// canonical index order.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
112pub struct SelectorKey {
113    /// [`SEL_PAGE`], [`SEL_OBJECT`], [`SEL_STREAM`], [`SEL_STREAM_DECODED`],
114    /// [`SEL_REVISION`], or [`SEL_RESOURCE`].
115    pub kind: u8,
116    /// The page/object/stream/revision/resource number.
117    pub number: u32,
118    /// The generation (`0` where the kind has none).
119    pub generation: u16,
120}
121
122impl SelectorKey {
123    /// A key with generation `0`.
124    pub const fn new(kind: u8, number: u32) -> Self {
125        SelectorKey {
126            kind,
127            number,
128            generation: 0,
129        }
130    }
131
132    /// A key with an explicit generation.
133    pub const fn with_generation(kind: u8, number: u32, generation: u16) -> Self {
134        SelectorKey {
135            kind,
136            number,
137            generation,
138        }
139    }
140}
141
142/// One resolved observation: where its bytes live and which seed node serves it.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct IndexEntry {
145    /// The selector this entry answers.
146    pub key: SelectorKey,
147    /// Start of the exact source byte span.
148    pub out_off: u64,
149    /// Length of the exact source byte span.
150    pub out_len: u64,
151    /// The seed node that materializes the span.
152    pub node_id: NodeId,
153}
154
155/// The reference index substrate: one file per node under `<root>/index`.
156///
157/// Layout: `<root>/index/<aa>/<bb>/<64-hex>` where `aa`/`bb` are the first two
158/// bytes of the id in hex. Writes are atomic (`tmp -> fsync -> rename`); reads are
159/// hash-verified by [`FsIndexStore::get`].
160pub struct FsIndexStore {
161    root: PathBuf,
162    io: IoCounters,
163}
164
165impl std::fmt::Debug for FsIndexStore {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("FsIndexStore")
168            .field("root", &self.root)
169            .finish_non_exhaustive()
170    }
171}
172
173impl FsIndexStore {
174    /// Open (creating if needed) an index store rooted at `root`, using
175    /// `root/index`, with a fresh, private I/O counter set.
176    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
177        Self::open_with_io(root, IoCounters::new())
178    }
179
180    /// Open an index store that accounts every node read against `io`.
181    pub fn open_with_io(root: impl AsRef<Path>, io: IoCounters) -> Result<Self> {
182        let root = root.as_ref().to_path_buf();
183        fs::create_dir_all(root.join("index"))?;
184        Ok(FsIndexStore { root, io })
185    }
186
187    /// Store one canonical node, returning its content id. Idempotent and atomic.
188    pub fn put(&mut self, canonical: &[u8]) -> Result<NodeId> {
189        let id = NodeId::of_node(canonical);
190        let path = self.node_path(&id);
191        if path.exists() {
192            return Ok(id);
193        }
194        let dir = path
195            .parent()
196            .ok_or_else(|| Error::internal_invariant("index node path has no parent"))?;
197        fs::create_dir_all(dir)?;
198        let tmp = dir.join(format!(".{}.tmp-{}", id.to_hex(), std::process::id()));
199        {
200            let mut f = fs::File::create(&tmp)?;
201            f.write_all(canonical)?;
202            f.sync_all()?;
203        }
204        fs::rename(&tmp, &path)?;
205        Ok(id)
206    }
207
208    /// Fetch a node, verifying `NodeId::of_node(bytes) == id`.
209    pub fn get(&self, id: &NodeId) -> Result<Vec<u8>> {
210        let path = self.node_path(id);
211        let bytes = fs::read(&path).map_err(|e| {
212            if e.kind() == std::io::ErrorKind::NotFound {
213                Error::missing_external_object(format!("index node {id} is not present"))
214            } else {
215                Error::io(format!("reading index node {id}: {e}"))
216            }
217        })?;
218        let actual = NodeId::of_node(&bytes);
219        if actual != *id {
220            return Err(Error::integrity_mismatch(format!(
221                "index node {id} content hashes to {actual}"
222            )));
223        }
224        self.io.add_index(bytes.len() as u64);
225        Ok(bytes)
226    }
227
228    /// Whether a node id is present.
229    pub fn contains(&self, id: &NodeId) -> Result<bool> {
230        Ok(self.node_path(id).exists())
231    }
232
233    /// Every node id physically present in the index namespace.
234    ///
235    /// Used to tell a genuinely new node from one a previous build already wrote
236    /// (the index is content-addressed, so an unchanged node has an unchanged
237    /// id and is never rewritten).
238    pub fn list_ids(&self) -> Result<Vec<NodeId>> {
239        let mut out: Vec<NodeId> = Vec::new();
240        let mut stack = vec![self.root.join("index")];
241        while let Some(dir) = stack.pop() {
242            let entries = match fs::read_dir(&dir) {
243                Ok(e) => e,
244                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
245                Err(e) => return Err(e.into()),
246            };
247            for entry in entries.flatten() {
248                let path = entry.path();
249                if path.is_dir() {
250                    stack.push(path);
251                    continue;
252                }
253                if let Some(name) = path.file_name().and_then(|n| n.to_str())
254                    && name.len() == 64
255                    && let Ok(id) = NodeId::from_hex(name)
256                {
257                    out.push(id);
258                }
259            }
260        }
261        out.sort_unstable();
262        out.dedup();
263        Ok(out)
264    }
265
266    /// Number of stored nodes.
267    pub fn count(&self) -> Result<u64> {
268        let mut n = 0u64;
269        let mut stack = vec![self.root.join("index")];
270        while let Some(dir) = stack.pop() {
271            let entries = match fs::read_dir(&dir) {
272                Ok(e) => e,
273                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
274                Err(e) => return Err(e.into()),
275            };
276            for entry in entries.flatten() {
277                let path = entry.path();
278                if path.is_dir() {
279                    stack.push(path);
280                    continue;
281                }
282                if let Some(name) = path.file_name().and_then(|n| n.to_str())
283                    && name.len() == 64
284                    && NodeId::from_hex(name).is_ok()
285                {
286                    n += 1;
287                }
288            }
289        }
290        Ok(n)
291    }
292
293    fn node_path(&self, id: &NodeId) -> PathBuf {
294        let hex = id.to_hex();
295        self.root
296            .join("index")
297            .join(&hex[0..2])
298            .join(&hex[2..4])
299            .join(&hex)
300    }
301}
302
303/// The minimal read surface a descent needs. Implemented by [`FsIndexStore`] and,
304/// in tests only, by a counting wrapper.
305trait NodeReader {
306    fn read_node(&self, id: &NodeId) -> Result<Vec<u8>>;
307}
308
309impl NodeReader for FsIndexStore {
310    fn read_node(&self, id: &NodeId) -> Result<Vec<u8>> {
311        self.get(id)
312    }
313}
314
315/// A half-open `(kind, number)` interval covered by one child pointer.
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317struct KeyRange {
318    min_kind: u8,
319    min_number: u32,
320    max_kind: u8,
321    max_number: u32,
322}
323
324impl KeyRange {
325    /// Whether this range can contain `key` (generation is not part of a range).
326    fn contains(&self, key: &SelectorKey) -> bool {
327        self.contains_pair(key.kind, key.number)
328    }
329
330    /// Whether `(kind, number)` lies within this range.
331    fn contains_pair(&self, kind: u8, number: u32) -> bool {
332        (self.min_kind, self.min_number) <= (kind, number)
333            && (kind, number) <= (self.max_kind, self.max_number)
334    }
335
336    /// Whether `other` is fully contained in this range.
337    fn contains_range(&self, other: &KeyRange) -> bool {
338        (self.min_kind, self.min_number) <= (other.min_kind, other.min_number)
339            && (other.max_kind, other.max_number) <= (self.max_kind, self.max_number)
340    }
341}
342
343/// A child pointer in an internal node.
344#[derive(Debug, Clone, Copy)]
345struct ChildRef {
346    range: KeyRange,
347    child_id: NodeId,
348}
349
350/// A decoded node. Exactly one of `leaf`/`internal` is populated, per `kind`.
351#[derive(Debug)]
352struct DecodedNode {
353    kind: u8,
354    depth: u8,
355    leaf: Vec<IndexEntry>,
356    internal: Vec<ChildRef>,
357}
358
359/// One pending descent step in validation.
360#[derive(Debug, Clone, Copy)]
361struct Descend {
362    id: NodeId,
363    expected_depth: Option<u8>,
364    expected_range: Option<KeyRange>,
365}
366
367/// Canonical total order for entries and query results.
368fn entry_order(a: &IndexEntry, b: &IndexEntry) -> std::cmp::Ordering {
369    a.key
370        .cmp(&b.key)
371        .then(a.out_off.cmp(&b.out_off))
372        .then(a.out_len.cmp(&b.out_len))
373        .then(a.node_id.cmp(&b.node_id))
374}
375
376/// Build a canonical index tree over `entries`; returns the root id.
377///
378/// Entries are de-duplicated and sorted by `(kind, number, generation, out_off)`
379/// (a total order over the remaining fields breaks ties), so a shuffled input
380/// yields exactly one canonical tree. Returns [`crate::ErrorClass::ResourceLimit`]
381/// if a node would exceed [`MAX_INDEX_NODE_BYTES`], if depth would exceed
382/// [`MAX_DEPTH`], or if fanout caps are violated.
383pub fn build(store: &mut FsIndexStore, entries: &[IndexEntry]) -> Result<NodeId> {
384    let mut sorted = entries.to_vec();
385    sorted.sort_by(entry_order);
386    sorted.dedup();
387
388    if sorted.is_empty() {
389        return store.put(&encode_leaf(&[], 0)?);
390    }
391
392    let mut level: Vec<ChildRef> = Vec::new();
393    for chunk in sorted.chunks(MAX_LEAF_ENTRIES) {
394        let bytes = encode_leaf(chunk, 0)?;
395        let id = store.put(&bytes)?;
396        level.push(child_from_leaf(chunk, id)?);
397    }
398
399    let mut depth = 0u8;
400    while level.len() > 1 {
401        if depth == MAX_DEPTH {
402            return Err(Error::resource_limit(format!(
403                "index would exceed MAX_DEPTH {MAX_DEPTH}"
404            )));
405        }
406        depth += 1;
407        let mut next: Vec<ChildRef> = Vec::new();
408        for chunk in level.chunks(MAX_INTERNAL_CHILDREN) {
409            let bytes = encode_internal(chunk, depth)?;
410            let id = store.put(&bytes)?;
411            next.push(child_from_internal(chunk, id)?);
412        }
413        level = next;
414    }
415
416    level
417        .pop()
418        .map(|root| root.child_id)
419        .ok_or_else(|| Error::internal_invariant("index build produced no node"))
420}
421
422/// Look up all entries whose key exactly matches `key`.
423///
424/// Every node on the descent is validated: framing, magic/version/kind/depth,
425/// content-id binding, and strictly decreasing depth. A corrupt, lying, missing,
426/// oversized, or out-of-depth node is a typed error, never a silent empty result.
427/// Only the nodes on the `root -> internal(s) -> leaf` path are read.
428pub fn lookup(store: &FsIndexStore, root: &NodeId, key: &SelectorKey) -> Result<Vec<IndexEntry>> {
429    lookup_impl(store, root, key)
430}
431
432/// Validate an entire tree. Returns the number of distinct nodes and the maximum
433/// depth observed. Any structural fault, identity mismatch, inconsistent
434/// duplicate, dangling child, or range violation is a typed error.
435pub fn validate(store: &FsIndexStore, root: &NodeId) -> Result<(u64, u8)> {
436    let (count, depth, _ids) = validate_impl_nodes(store, root)?;
437    Ok((count, depth))
438}
439
440/// Like [`validate`], but also returns every distinct tree-node id. A caller that
441/// must compare two trees can validate and enumerate in one pass instead of
442/// reading the same nodes twice.
443pub fn validate_nodes(store: &FsIndexStore, root: &NodeId) -> Result<(u64, u8, Vec<NodeId>)> {
444    validate_impl_nodes(store, root)
445}
446
447/// A single full traversal of an index tree: every leaf entry and every
448/// distinct tree-node id.
449///
450/// Both are collected in one pass so a caller that needs to carry untouched
451/// selector bindings forward (the immutable-edit witness) does not read the
452/// tree twice. Every node read is hash-checked by [`FsIndexStore::get`].
453pub struct TreeInspection {
454    /// Every leaf entry, sorted and deduplicated by [`entry_order`].
455    pub entries: Vec<IndexEntry>,
456    /// Every distinct node id reachable from the root (leaves and internals).
457    pub nodes: Vec<NodeId>,
458}
459
460/// Traverse the whole tree rooted at `root`, collecting every leaf entry and
461/// every distinct node id. This reads every node (not just a lookup path).
462pub fn inspect(store: &FsIndexStore, root: &NodeId) -> Result<TreeInspection> {
463    let mut entries: Vec<IndexEntry> = Vec::new();
464    let mut nodes: Vec<NodeId> = Vec::new();
465    let mut stack: Vec<NodeId> = vec![*root];
466    while let Some(id) = stack.pop() {
467        if nodes.contains(&id) {
468            continue;
469        }
470        let bytes = store.get(&id)?;
471        let node = parse_node(&bytes)?;
472        nodes.push(id);
473        match node.kind {
474            KIND_LEAF => entries.extend(node.leaf),
475            _ => {
476                for c in node.internal {
477                    stack.push(c.child_id);
478                }
479            }
480        }
481    }
482    entries.sort_by(entry_order);
483    entries.dedup();
484    Ok(TreeInspection { entries, nodes })
485}
486
487fn lookup_impl<R: NodeReader>(
488    store: &R,
489    root: &NodeId,
490    key: &SelectorKey,
491) -> Result<Vec<IndexEntry>> {
492    let mut out: Vec<IndexEntry> = Vec::new();
493    let mut stack: Vec<(NodeId, Option<u8>)> = vec![(*root, None)];
494    while let Some((id, expected)) = stack.pop() {
495        let bytes = store.read_node(&id)?;
496        let node = parse_node(&bytes)?;
497        if let Some(exp) = expected
498            && node.depth != exp
499        {
500            return Err(Error::integrity_mismatch(format!(
501                "index node {id} declares depth {} but was reached at depth {exp}",
502                node.depth
503            )));
504        }
505        match node.kind {
506            KIND_LEAF => {
507                for e in node.leaf {
508                    if e.key == *key {
509                        out.push(e);
510                    }
511                }
512            }
513            _ => {
514                let child_depth = node
515                    .depth
516                    .checked_sub(1)
517                    .ok_or_else(|| Error::integrity_mismatch("index internal node has depth 0"))?;
518                for child in node.internal {
519                    if child.range.contains(key) {
520                        stack.push((child.child_id, Some(child_depth)));
521                    }
522                }
523            }
524        }
525    }
526    out.sort_by(entry_order);
527    out.dedup();
528    Ok(out)
529}
530
531fn validate_impl_nodes<R: NodeReader>(store: &R, root: &NodeId) -> Result<(u64, u8, Vec<NodeId>)> {
532    let mut seen: Vec<(NodeId, Vec<u8>, u8)> = Vec::new();
533    let mut count = 0u64;
534    let mut max_depth = 0u8;
535    let mut stack: Vec<Descend> = vec![Descend {
536        id: *root,
537        expected_depth: None,
538        expected_range: None,
539    }];
540    while let Some(step) = stack.pop() {
541        let bytes = store.read_node(&step.id)?;
542        let node = parse_node(&bytes)?;
543        if let Some(exp) = step.expected_depth
544            && node.depth != exp
545        {
546            return Err(Error::integrity_mismatch(format!(
547                "index node {} declares depth {} but was reached at depth {exp}",
548                step.id, node.depth
549            )));
550        }
551        if let Some(range) = step.expected_range {
552            for e in &node.leaf {
553                if !range.contains_pair(e.key.kind, e.key.number) {
554                    return Err(Error::integrity_mismatch(format!(
555                        "index leaf entry {:?} lies outside its parent range",
556                        e.key
557                    )));
558                }
559            }
560            for c in &node.internal {
561                if !range.contains_range(&c.range) {
562                    return Err(Error::integrity_mismatch(format!(
563                        "index child range {:?} exceeds its parent range",
564                        c.range
565                    )));
566                }
567            }
568        }
569        if !note_node(&mut seen, step.id, &bytes, node.depth)? {
570            continue;
571        }
572        count += 1;
573        if node.depth > max_depth {
574            max_depth = node.depth;
575        }
576        if node.kind == KIND_INTERNAL {
577            validate_child_order(&node.internal)?;
578            let child_depth = node
579                .depth
580                .checked_sub(1)
581                .ok_or_else(|| Error::integrity_mismatch("index internal node has depth 0"))?;
582            for c in node.internal {
583                stack.push(Descend {
584                    id: c.child_id,
585                    expected_depth: Some(child_depth),
586                    expected_range: Some(c.range),
587                });
588            }
589        }
590    }
591    Ok((
592        count,
593        max_depth,
594        seen.into_iter().map(|(id, _, _)| id).collect(),
595    ))
596}
597
598/// Record a visited node. Returns `true` if newly seen and `false` if already
599/// visited. A repeated id with differing bytes, or at an inconsistent depth, is
600/// an [`crate::ErrorClass::IntegrityMismatch`].
601fn note_node(
602    seen: &mut Vec<(NodeId, Vec<u8>, u8)>,
603    id: NodeId,
604    bytes: &[u8],
605    depth: u8,
606) -> Result<bool> {
607    if let Some((_, prev, prev_depth)) = seen.iter().find(|(seen_id, _, _)| *seen_id == id) {
608        if prev.as_slice() != bytes {
609            return Err(Error::integrity_mismatch(format!(
610                "index node {id} decoded twice with differing bytes"
611            )));
612        }
613        if *prev_depth != depth {
614            return Err(Error::integrity_mismatch(format!(
615                "index node {id} appears at inconsistent depths"
616            )));
617        }
618        return Ok(false);
619    }
620    seen.push((id, bytes.to_vec(), depth));
621    Ok(true)
622}
623
624/// Check that internal children declare `min <= max` and ascend by `min`.
625fn validate_child_order(children: &[ChildRef]) -> Result<()> {
626    let mut prev_min: Option<(u8, u32)> = None;
627    for c in children {
628        let min = (c.range.min_kind, c.range.min_number);
629        let max = (c.range.max_kind, c.range.max_number);
630        if min > max {
631            return Err(Error::integrity_mismatch(
632                "index internal child has min greater than max",
633            ));
634        }
635        if let Some(prev) = prev_min
636            && min < prev
637        {
638            return Err(Error::integrity_mismatch(
639                "index internal children are not sorted",
640            ));
641        }
642        prev_min = Some(min);
643    }
644    Ok(())
645}
646
647fn child_from_leaf(chunk: &[IndexEntry], id: NodeId) -> Result<ChildRef> {
648    let first = chunk
649        .first()
650        .ok_or_else(|| Error::internal_invariant("empty leaf chunk"))?;
651    let last = chunk
652        .last()
653        .ok_or_else(|| Error::internal_invariant("empty leaf chunk"))?;
654    Ok(ChildRef {
655        range: KeyRange {
656            min_kind: first.key.kind,
657            min_number: first.key.number,
658            max_kind: last.key.kind,
659            max_number: last.key.number,
660        },
661        child_id: id,
662    })
663}
664
665fn child_from_internal(chunk: &[ChildRef], id: NodeId) -> Result<ChildRef> {
666    let first = chunk
667        .first()
668        .ok_or_else(|| Error::internal_invariant("empty internal chunk"))?;
669    let last = chunk
670        .last()
671        .ok_or_else(|| Error::internal_invariant("empty internal chunk"))?;
672    Ok(ChildRef {
673        range: KeyRange {
674            min_kind: first.range.min_kind,
675            min_number: first.range.min_number,
676            max_kind: last.range.max_kind,
677            max_number: last.range.max_number,
678        },
679        child_id: id,
680    })
681}
682
683fn push_header(out: &mut Vec<u8>, kind: u8, depth: u8, count: u32) {
684    out.push(INDEX_MAGIC);
685    out.push(INDEX_VERSION);
686    out.push(kind);
687    out.push(depth);
688    out.extend_from_slice(&count.to_le_bytes());
689}
690
691fn encode_leaf(entries: &[IndexEntry], depth: u8) -> Result<Vec<u8>> {
692    if entries.len() > MAX_FANOUT {
693        return Err(Error::resource_limit(format!(
694            "index leaf entry_count {} exceeds MAX_FANOUT {MAX_FANOUT}",
695            entries.len()
696        )));
697    }
698    let count = u32::try_from(entries.len())
699        .map_err(|_| Error::resource_limit("index leaf entry_count exceeds u32"))?;
700    let mut out = Vec::with_capacity(HEADER_LEN + entries.len() * LEAF_ENTRY_LEN);
701    push_header(&mut out, KIND_LEAF, depth, count);
702    for e in entries {
703        out.push(e.key.kind);
704        out.extend_from_slice(&e.key.generation.to_le_bytes());
705        out.extend_from_slice(&e.key.number.to_le_bytes());
706        out.extend_from_slice(&e.out_off.to_le_bytes());
707        out.extend_from_slice(&e.out_len.to_le_bytes());
708        out.extend_from_slice(e.node_id.as_bytes());
709    }
710    if out.len() > MAX_INDEX_NODE_BYTES {
711        return Err(Error::resource_limit(format!(
712            "index leaf node is {} bytes, exceeding MAX_INDEX_NODE_BYTES {MAX_INDEX_NODE_BYTES}",
713            out.len()
714        )));
715    }
716    Ok(out)
717}
718
719fn encode_internal(children: &[ChildRef], depth: u8) -> Result<Vec<u8>> {
720    if children.len() > MAX_FANOUT {
721        return Err(Error::resource_limit(format!(
722            "index internal entry_count {} exceeds MAX_FANOUT {MAX_FANOUT}",
723            children.len()
724        )));
725    }
726    let count = u32::try_from(children.len())
727        .map_err(|_| Error::resource_limit("index internal entry_count exceeds u32"))?;
728    let mut out = Vec::with_capacity(HEADER_LEN + children.len() * INTERNAL_ENTRY_LEN);
729    push_header(&mut out, KIND_INTERNAL, depth, count);
730    for c in children {
731        out.push(c.range.min_kind);
732        out.extend_from_slice(&c.range.min_number.to_le_bytes());
733        out.push(c.range.max_kind);
734        out.extend_from_slice(&c.range.max_number.to_le_bytes());
735        out.extend_from_slice(c.child_id.as_bytes());
736    }
737    if out.len() > MAX_INDEX_NODE_BYTES {
738        return Err(Error::resource_limit(format!(
739            "index internal node is {} bytes, exceeding MAX_INDEX_NODE_BYTES {MAX_INDEX_NODE_BYTES}",
740            out.len()
741        )));
742    }
743    Ok(out)
744}
745
746fn parse_node(bytes: &[u8]) -> Result<DecodedNode> {
747    if bytes.len() < HEADER_LEN {
748        return Err(Error::integrity_mismatch(
749            "index node is shorter than its header",
750        ));
751    }
752    let magic = bytes[0];
753    if magic != INDEX_MAGIC {
754        return Err(Error::integrity_mismatch(format!(
755            "index node has bad magic 0x{magic:02x}"
756        )));
757    }
758    let version = bytes[1];
759    if version != INDEX_VERSION {
760        return Err(Error::unsupported_version(format!(
761            "index node version {version} is not supported"
762        )));
763    }
764    let kind = bytes[2];
765    if kind != KIND_LEAF && kind != KIND_INTERNAL {
766        return Err(Error::integrity_mismatch(format!(
767            "index node has unknown kind {kind}"
768        )));
769    }
770    let depth = bytes[3];
771    if depth > MAX_DEPTH {
772        return Err(Error::resource_limit(format!(
773            "index node depth {depth} exceeds MAX_DEPTH {MAX_DEPTH}"
774        )));
775    }
776    if kind == KIND_LEAF && depth != 0 {
777        return Err(Error::integrity_mismatch(
778            "index leaf node has non-zero depth",
779        ));
780    }
781    if kind == KIND_INTERNAL && depth == 0 {
782        return Err(Error::integrity_mismatch("index internal node has depth 0"));
783    }
784    let count = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
785    if count as usize > MAX_FANOUT {
786        return Err(Error::resource_limit(format!(
787            "index node entry_count {count} exceeds MAX_FANOUT {MAX_FANOUT}"
788        )));
789    }
790    let entry_len = if kind == KIND_LEAF {
791        LEAF_ENTRY_LEN
792    } else {
793        INTERNAL_ENTRY_LEN
794    };
795    let count = count as usize;
796    let expected = HEADER_LEN
797        .checked_add(
798            count
799                .checked_mul(entry_len)
800                .ok_or_else(|| Error::resource_limit("index node size overflow"))?,
801        )
802        .ok_or_else(|| Error::resource_limit("index node size overflow"))?;
803    if bytes.len() < expected {
804        return Err(Error::integrity_mismatch(format!(
805            "index node is truncated: need {expected} bytes, have {}",
806            bytes.len()
807        )));
808    }
809    if bytes.len() > expected {
810        return Err(Error::integrity_mismatch(format!(
811            "index node has {} trailing bytes",
812            bytes.len() - expected
813        )));
814    }
815    if bytes.len() > MAX_INDEX_NODE_BYTES {
816        return Err(Error::resource_limit(format!(
817            "index node is {} bytes, exceeding MAX_INDEX_NODE_BYTES {MAX_INDEX_NODE_BYTES}",
818            bytes.len()
819        )));
820    }
821
822    let mut p = HEADER_LEN;
823    let mut leaf = Vec::new();
824    let mut internal = Vec::new();
825    if kind == KIND_LEAF {
826        leaf.reserve(count);
827        for _ in 0..count {
828            let kind = read_u8(bytes, &mut p)?;
829            let generation = read_u16(bytes, &mut p)?;
830            let number = read_u32(bytes, &mut p)?;
831            let out_off = read_u64(bytes, &mut p)?;
832            let out_len = read_u64(bytes, &mut p)?;
833            let node_id = read_node_id(bytes, &mut p)?;
834            leaf.push(IndexEntry {
835                key: SelectorKey {
836                    kind,
837                    number,
838                    generation,
839                },
840                out_off,
841                out_len,
842                node_id,
843            });
844        }
845    } else {
846        internal.reserve(count);
847        for _ in 0..count {
848            let min_kind = read_u8(bytes, &mut p)?;
849            let min_number = read_u32(bytes, &mut p)?;
850            let max_kind = read_u8(bytes, &mut p)?;
851            let max_number = read_u32(bytes, &mut p)?;
852            let child_id = read_node_id(bytes, &mut p)?;
853            internal.push(ChildRef {
854                range: KeyRange {
855                    min_kind,
856                    min_number,
857                    max_kind,
858                    max_number,
859                },
860                child_id,
861            });
862        }
863    }
864    Ok(DecodedNode {
865        kind,
866        depth,
867        leaf,
868        internal,
869    })
870}
871
872fn read_u8(bytes: &[u8], p: &mut usize) -> Result<u8> {
873    let v = *bytes
874        .get(*p)
875        .ok_or_else(|| Error::integrity_mismatch("truncated index node"))?;
876    *p += 1;
877    Ok(v)
878}
879
880fn read_u16(bytes: &[u8], p: &mut usize) -> Result<u16> {
881    let end = p
882        .checked_add(2)
883        .ok_or_else(|| Error::integrity_mismatch("index node cursor overflow"))?;
884    let s = bytes
885        .get(*p..end)
886        .ok_or_else(|| Error::integrity_mismatch("truncated index node"))?;
887    *p = end;
888    Ok(u16::from_le_bytes([s[0], s[1]]))
889}
890
891fn read_u32(bytes: &[u8], p: &mut usize) -> Result<u32> {
892    let end = p
893        .checked_add(4)
894        .ok_or_else(|| Error::integrity_mismatch("index node cursor overflow"))?;
895    let s = bytes
896        .get(*p..end)
897        .ok_or_else(|| Error::integrity_mismatch("truncated index node"))?;
898    *p = end;
899    Ok(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
900}
901
902fn read_u64(bytes: &[u8], p: &mut usize) -> Result<u64> {
903    let end = p
904        .checked_add(8)
905        .ok_or_else(|| Error::integrity_mismatch("index node cursor overflow"))?;
906    let s = bytes
907        .get(*p..end)
908        .ok_or_else(|| Error::integrity_mismatch("truncated index node"))?;
909    *p = end;
910    Ok(u64::from_le_bytes([
911        s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7],
912    ]))
913}
914
915fn read_node_id(bytes: &[u8], p: &mut usize) -> Result<NodeId> {
916    let end = p
917        .checked_add(32)
918        .ok_or_else(|| Error::integrity_mismatch("index node cursor overflow"))?;
919    let s = bytes
920        .get(*p..end)
921        .ok_or_else(|| Error::integrity_mismatch("truncated index node"))?;
922    let mut raw = [0u8; 32];
923    raw.copy_from_slice(s);
924    *p = end;
925    Ok(NodeId::from_bytes(raw))
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931    use std::cell::Cell;
932
933    /// A read-counting wrapper used only to prove lookup's working set is bounded.
934    struct CountingStore<'a> {
935        inner: &'a FsIndexStore,
936        gets: Cell<u64>,
937    }
938
939    impl<'a> CountingStore<'a> {
940        fn new(inner: &'a FsIndexStore) -> Self {
941            CountingStore {
942                inner,
943                gets: Cell::new(0),
944            }
945        }
946    }
947
948    impl NodeReader for CountingStore<'_> {
949        fn read_node(&self, id: &NodeId) -> Result<Vec<u8>> {
950            self.gets.set(self.gets.get() + 1);
951            self.inner.get(id)
952        }
953    }
954
955    fn temp_root(label: &str) -> PathBuf {
956        let mut p = std::env::temp_dir();
957        p.push(format!(
958            "vole-index-{label}-{}-{}",
959            std::process::id(),
960            std::time::SystemTime::now()
961                .duration_since(std::time::UNIX_EPOCH)
962                .unwrap()
963                .as_nanos()
964        ));
965        fs::create_dir_all(&p).unwrap();
966        p
967    }
968
969    fn entry(kind: u8, number: u32, tag: &[u8]) -> IndexEntry {
970        IndexEntry {
971            key: SelectorKey::new(kind, number),
972            out_off: u64::from(number) * 10,
973            out_len: 5,
974            node_id: NodeId::of_node(tag),
975        }
976    }
977
978    fn sample_entries(n: u32) -> Vec<IndexEntry> {
979        let kinds = [SEL_PAGE, SEL_OBJECT, SEL_STREAM, SEL_REVISION, SEL_RESOURCE];
980        (0..n)
981            .map(|i| {
982                let kind = kinds[(i as usize) % kinds.len()];
983                entry(kind, i, &i.to_le_bytes())
984            })
985            .collect()
986    }
987
988    fn node_files(index_root: &Path) -> Vec<PathBuf> {
989        let mut out = Vec::new();
990        let mut stack = vec![index_root.to_path_buf()];
991        while let Some(dir) = stack.pop() {
992            let Ok(entries) = fs::read_dir(&dir) else {
993                continue;
994            };
995            for e in entries.flatten() {
996                let path = e.path();
997                if path.is_dir() {
998                    stack.push(path);
999                } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
1000                    && name.len() == 64
1001                    && NodeId::from_hex(name).is_ok()
1002                {
1003                    out.push(path);
1004                }
1005            }
1006        }
1007        out.sort();
1008        out
1009    }
1010
1011    #[test]
1012    fn one_page_build_and_lookup() {
1013        let root = temp_root("one-page");
1014        let mut store = FsIndexStore::open(&root).unwrap();
1015        let e = entry(SEL_PAGE, 1, b"page-1");
1016        let root_id = build(&mut store, std::slice::from_ref(&e)).unwrap();
1017        assert_eq!(lookup(&store, &root_id, &e.key).unwrap(), vec![e.clone()]);
1018        assert!(
1019            lookup(&store, &root_id, &SelectorKey::new(SEL_OBJECT, 7))
1020                .unwrap()
1021                .is_empty()
1022        );
1023        assert_eq!(validate(&store, &root_id).unwrap(), (1, 0));
1024        assert_eq!(store.count().unwrap(), 1);
1025        fs::remove_dir_all(&root).ok();
1026    }
1027
1028    #[test]
1029    fn multi_level_build_finds_every_entry() {
1030        let root = temp_root("multi");
1031        let mut store = FsIndexStore::open(&root).unwrap();
1032        let entries = sample_entries(600);
1033        let root_id = build(&mut store, &entries).unwrap();
1034        let (nodes, depth) = validate(&store, &root_id).unwrap();
1035        assert!(nodes > 1, "expected internal nodes, got {nodes}");
1036        assert!(depth >= 1, "expected depth >= 1, got {depth}");
1037        for e in &entries {
1038            let got = lookup(&store, &root_id, &e.key).unwrap();
1039            assert_eq!(got, vec![e.clone()], "key {:?} not found", e.key);
1040        }
1041        fs::remove_dir_all(&root).ok();
1042    }
1043
1044    #[test]
1045    fn deep_tree_reaches_depth_two() {
1046        let root = temp_root("deep");
1047        let mut store = FsIndexStore::open(&root).unwrap();
1048        // 30_000 entries -> 203 leaves -> 2 internal nodes -> root depth 2.
1049        let entries = sample_entries(30_000);
1050        let root_id = build(&mut store, &entries).unwrap();
1051        let (nodes, depth) = validate(&store, &root_id).unwrap();
1052        assert!(nodes >= 203, "expected a deep tree, got {nodes} nodes");
1053        assert_eq!(depth, 2);
1054        for e in [&entries[0], &entries[12_345], &entries[29_999]] {
1055            assert_eq!(lookup(&store, &root_id, &e.key).unwrap(), vec![e.clone()]);
1056        }
1057        fs::remove_dir_all(&root).ok();
1058    }
1059
1060    #[test]
1061    fn missing_key_is_empty_not_error() {
1062        let root = temp_root("missing");
1063        let mut store = FsIndexStore::open(&root).unwrap();
1064        let root_id = build(&mut store, &sample_entries(50)).unwrap();
1065        let miss = SelectorKey::with_generation(SEL_PAGE, 999_999, 3);
1066        assert!(lookup(&store, &root_id, &miss).unwrap().is_empty());
1067        fs::remove_dir_all(&root).ok();
1068    }
1069
1070    #[test]
1071    fn empty_index_roundtrips() {
1072        let root = temp_root("empty");
1073        let mut store = FsIndexStore::open(&root).unwrap();
1074        let root_id = build(&mut store, &[]).unwrap();
1075        assert!(
1076            lookup(&store, &root_id, &SelectorKey::new(SEL_PAGE, 1))
1077                .unwrap()
1078                .is_empty()
1079        );
1080        assert_eq!(validate(&store, &root_id).unwrap(), (1, 0));
1081        fs::remove_dir_all(&root).ok();
1082    }
1083
1084    #[test]
1085    fn corrupt_node_is_rejected() {
1086        let root = temp_root("corrupt");
1087        let mut store = FsIndexStore::open(&root).unwrap();
1088        let root_id = build(&mut store, &sample_entries(600)).unwrap();
1089        let root_hex = root_id.to_hex();
1090        let target = node_files(&root.join("index"))
1091            .into_iter()
1092            .find(|p| p.file_name().and_then(|n| n.to_str()) != Some(root_hex.as_str()))
1093            .expect("expected a non-root node");
1094        fs::write(&target, b"corrupted bytes").unwrap();
1095        let err = validate(&store, &root_id).unwrap_err();
1096        assert_eq!(err.class(), crate::ErrorClass::IntegrityMismatch);
1097        fs::remove_dir_all(&root).ok();
1098    }
1099
1100    #[test]
1101    fn missing_child_node_is_missing_external_object() {
1102        let root = temp_root("dangling");
1103        let mut store = FsIndexStore::open(&root).unwrap();
1104        let root_id = build(&mut store, &sample_entries(600)).unwrap();
1105        let root_hex = root_id.to_hex();
1106        let target = node_files(&root.join("index"))
1107            .into_iter()
1108            .find(|p| p.file_name().and_then(|n| n.to_str()) != Some(root_hex.as_str()))
1109            .expect("expected a non-root node");
1110        fs::remove_file(&target).unwrap();
1111        let err = validate(&store, &root_id).unwrap_err();
1112        assert_eq!(err.class(), crate::ErrorClass::MissingExternalObject);
1113        fs::remove_dir_all(&root).ok();
1114    }
1115
1116    #[test]
1117    fn duplicate_id_with_differing_bytes_is_rejected() {
1118        let mut seen: Vec<(NodeId, Vec<u8>, u8)> = Vec::new();
1119        let id = NodeId::of_node(b"a");
1120        assert!(note_node(&mut seen, id, b"a", 0).unwrap());
1121        // A consistent repeat is fine.
1122        assert!(!note_node(&mut seen, id, b"a", 0).unwrap());
1123        // The same id with differing bytes is a live inconsistency.
1124        let err = note_node(&mut seen, id, b"b", 0).unwrap_err();
1125        assert_eq!(err.class(), crate::ErrorClass::IntegrityMismatch);
1126    }
1127
1128    #[test]
1129    fn caps_are_enforced() {
1130        // Node-size cap: a leaf one entry over capacity cannot be encoded.
1131        let too_many = sample_entries(MAX_LEAF_ENTRIES as u32 + 1);
1132        let err = encode_leaf(&too_many, 0).unwrap_err();
1133        assert_eq!(err.class(), crate::ErrorClass::ResourceLimit);
1134
1135        // Fanout cap at parse (checked before allocating the entry array).
1136        let mut node = vec![INDEX_MAGIC, INDEX_VERSION, KIND_LEAF, 0];
1137        node.extend_from_slice(&u32::try_from(MAX_FANOUT + 1).unwrap().to_le_bytes());
1138        node.resize(HEADER_LEN + (MAX_FANOUT + 1) * LEAF_ENTRY_LEN, 0);
1139        let err = parse_node(&node).unwrap_err();
1140        assert_eq!(err.class(), crate::ErrorClass::ResourceLimit);
1141
1142        // Node-size cap at parse: a valid count whose payload exceeds 8 KiB.
1143        let oversize = MAX_LEAF_ENTRIES + 1;
1144        let mut node = vec![INDEX_MAGIC, INDEX_VERSION, KIND_LEAF, 0];
1145        node.extend_from_slice(&u32::try_from(oversize).unwrap().to_le_bytes());
1146        node.resize(HEADER_LEN + oversize * LEAF_ENTRY_LEN, 0);
1147        let err = parse_node(&node).unwrap_err();
1148        assert_eq!(err.class(), crate::ErrorClass::ResourceLimit);
1149
1150        // Depth cap at parse.
1151        let node = vec![
1152            INDEX_MAGIC,
1153            INDEX_VERSION,
1154            KIND_INTERNAL,
1155            MAX_DEPTH + 1,
1156            0,
1157            0,
1158            0,
1159            0,
1160        ];
1161        let err = parse_node(&node).unwrap_err();
1162        assert_eq!(err.class(), crate::ErrorClass::ResourceLimit);
1163    }
1164
1165    #[test]
1166    fn framing_faults_are_rejected() {
1167        // Bad magic.
1168        let mut node = vec![0x00, INDEX_VERSION, KIND_LEAF, 0, 0, 0, 0, 0];
1169        assert_eq!(
1170            parse_node(&node).unwrap_err().class(),
1171            crate::ErrorClass::IntegrityMismatch
1172        );
1173        // Unknown version.
1174        node[0] = INDEX_MAGIC;
1175        node[1] = INDEX_VERSION + 1;
1176        assert_eq!(
1177            parse_node(&node).unwrap_err().class(),
1178            crate::ErrorClass::UnsupportedVersion
1179        );
1180        // Unknown kind.
1181        node[1] = INDEX_VERSION;
1182        node[2] = 9;
1183        assert_eq!(
1184            parse_node(&node).unwrap_err().class(),
1185            crate::ErrorClass::IntegrityMismatch
1186        );
1187        // Truncated header.
1188        assert_eq!(
1189            parse_node(&[INDEX_MAGIC, INDEX_VERSION])
1190                .unwrap_err()
1191                .class(),
1192            crate::ErrorClass::IntegrityMismatch
1193        );
1194        // Trailing bytes on a zero-entry leaf.
1195        let node = vec![INDEX_MAGIC, INDEX_VERSION, KIND_LEAF, 0, 0, 0, 0, 0, 0xff];
1196        assert_eq!(
1197            parse_node(&node).unwrap_err().class(),
1198            crate::ErrorClass::IntegrityMismatch
1199        );
1200    }
1201
1202    #[test]
1203    fn lookup_reads_bounded_nodes_regardless_of_entry_count() {
1204        let root = temp_root("bounded");
1205        let mut store = FsIndexStore::open(&root).unwrap();
1206        let entries = sample_entries(5_000);
1207        let root_id = build(&mut store, &entries).unwrap();
1208        let probe = entries[2_500].key;
1209        let counter = CountingStore::new(&store);
1210        let found = lookup_impl(&counter, &root_id, &probe).unwrap();
1211        assert_eq!(found.len(), 1);
1212        let reads = counter.gets.get();
1213        assert!(
1214            reads <= u64::from(MAX_DEPTH) + 1,
1215            "lookup read {reads} nodes, exceeding MAX_DEPTH+1"
1216        );
1217        fs::remove_dir_all(&root).ok();
1218    }
1219
1220    #[test]
1221    fn build_is_deterministic_under_shuffle() {
1222        let root = temp_root("determinism");
1223        let mut store = FsIndexStore::open(&root).unwrap();
1224        let entries = sample_entries(1_000);
1225        let id_a = build(&mut store, &entries).unwrap();
1226        let mut shuffled = entries.clone();
1227        shuffled.rotate_left(137);
1228        shuffled.reverse();
1229        let id_b = build(&mut store, &shuffled).unwrap();
1230        assert_eq!(id_a, id_b);
1231        fs::remove_dir_all(&root).ok();
1232    }
1233
1234    #[test]
1235    fn duplicate_entries_are_deduplicated() {
1236        let root = temp_root("dedup");
1237        let mut store = FsIndexStore::open(&root).unwrap();
1238        let entries = sample_entries(10);
1239        let id_a = build(&mut store, &entries).unwrap();
1240        let mut with_dupes = entries.clone();
1241        with_dupes.extend(entries.iter().cloned());
1242        let id_b = build(&mut store, &with_dupes).unwrap();
1243        assert_eq!(id_a, id_b);
1244        fs::remove_dir_all(&root).ok();
1245    }
1246}