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