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