Skip to main content

mkit_core/verify/
closure.rs

1//! Full-disclosure (closure profile) verification: prove that a served
2//! object set is exactly the content a commit (or remix, or tag) id
3//! commits to.
4//!
5//! The root id is the only trust anchor. The [`ClosureManifest`] is a
6//! convenience index of pack hashes — a verifier that already has the
7//! packs can call [`verify_closure_packs`] directly. Wire format and
8//! verifier obligations are pinned in `docs/specs/SPEC-DISCLOSURE.md`.
9
10use std::borrow::Cow;
11use std::collections::{BTreeMap, BTreeSet, VecDeque};
12use std::ops::Range;
13
14use bytes::Buf;
15use commonware_codec::{EncodeSize, ReadExt, ReadRangeExt, Write};
16
17use crate::hash::{Hash, hash};
18use crate::object::{Object, ObjectType};
19use crate::ops::graph::{ClosureMode, children};
20use crate::pack::{self, PackEntries, PackEntry, PackWriter, pack_key};
21use crate::store::{ObjectStore, StoreError};
22use crate::verify::VerifyError;
23
24/// Hard cap on the number of packs a closure manifest may list.
25pub const MAX_CLOSURE_PACKS: usize = 65_536;
26
27const MANIFEST_MAGIC: &[u8; 4] = b"MKCL";
28const MANIFEST_VERSION: u8 = 1;
29const MODE_SNAPSHOT: u8 = 0;
30const MODE_HISTORY: u8 = 1;
31
32/// Fan-out threshold (per available thread) for [`index_supplied_objects`]/
33/// [`index_pack_entries`]. Deliberately much higher than
34/// `pack::stage_raw_entries`'s `ENTRIES_PER_THREAD` (8): that fan-out's
35/// per-entry work is a zstd decompression (tens of microseconds), so a
36/// `std::thread::scope` spawn pays for itself almost immediately. Here
37/// each entry is a small canonical-object deserialize plus one
38/// BLAKE3/BMT id derivation — often under a microsecond — so thread
39/// creation itself dominates below a few thousand entries. Measured via
40/// `closure_verify_fanout` (`rust/benches/benches/`) on a 4-core box:
41/// 64-1024 entries lost 2-4x to spawn overhead at `ENTRIES_PER_THREAD =
42/// 8`; only past ~4096 entries did fan-out clearly win (~10%). This
43/// value keeps every closure below that size on the plain sequential
44/// path and only pays thread cost for genuinely large closures.
45#[cfg(not(target_arch = "wasm32"))]
46const CLOSURE_ENTRIES_PER_THREAD: usize = 1024;
47
48/// Provides a pull-based source for canonical object bytes.
49pub trait ObjectSource {
50    /// Returns canonical bytes for `id`, or `Ok(None)` when it is absent.
51    /// Implementations MUST NOT use their own identity decision as the
52    /// verification result; the closure walker always deserializes the bytes
53    /// and re-derives the object id itself.
54    ///
55    /// # Errors
56    ///
57    /// Returns a source error when the requested object cannot be fetched.
58    fn fetch(&mut self, id: &Hash) -> Result<Option<Cow<'_, [u8]>>, VerifyError>;
59}
60
61/// Outcome of walking a supplied object set from `root` in `mode`.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ClosureReport {
64    /// The id the walk started from.
65    pub root: Hash,
66    /// Walk mode used.
67    pub mode: ClosureMode,
68    /// Objects whose id was recomputed and that were reachable from `root`.
69    pub verified: usize,
70    /// Referenced by a reachable object but not supplied, sorted.
71    pub missing: Vec<Hash>,
72    /// Bytes that failed to deserialize or whose derived id did not match
73    /// the requested id, sorted by reported id. The map path keys decode
74    /// failures by BLAKE3 of the supplied bytes and preserves the
75    /// `missing` + `unreferenced` result for parsable bit flips; a streaming
76    /// fetch-by-id path reports either failure under the requested id.
77    pub corrupt: Vec<(Hash, String)>,
78    /// Supplied (and successfully identified) but not reachable from
79    /// `root`, sorted. A DA provider may legitimately serve a superset;
80    /// this field is reported, not an error.
81    pub unreferenced: Vec<Hash>,
82    /// Whether the verifier could enumerate every supplied object to compute
83    /// [`Self::unreferenced`]. Streaming sources set this to `false` and
84    /// leave that list empty; map- and pack-backed paths set it to `true`.
85    pub unreferenced_checked: bool,
86}
87
88impl ClosureReport {
89    /// Complete iff nothing referenced is missing or corrupt.
90    /// Unreferenced extras do not fail completeness.
91    #[must_use]
92    pub fn is_complete(&self) -> bool {
93        self.missing.is_empty() && self.corrupt.is_empty()
94    }
95}
96
97/// Encoded closure plus the raw-only packs it indexes.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ClosureExport {
100    /// [`ClosureManifest`] bytes.
101    pub manifest: Vec<u8>,
102    /// Raw-only v1 packs, in the order listed by the manifest.
103    pub packs: Vec<Vec<u8>>,
104}
105
106/// Convenience index of a closure export: magic `"MKCL"`, version 1,
107/// root id, mode, and the [`pack_key`] of each pack in order.
108///
109/// The caller supplies the trusted root to [`verify_closure_manifest`];
110/// the manifest does not authenticate the objects and is not itself a
111/// trust anchor. It only tells a verifier which pack hashes to expect.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ClosureManifest {
114    /// Commit, remix, or tag id the closure is claimed to cover.
115    pub root: Hash,
116    /// Snapshot or history.
117    pub mode: ClosureMode,
118    /// `pack_key` of each pack, in export order. At most
119    /// [`MAX_CLOSURE_PACKS`] entries.
120    pub packs: Vec<Hash>,
121}
122
123impl ClosureManifest {
124    /// Encode to `"MKCL" ‖ version ‖ root ‖ mode ‖ packs`.
125    #[must_use]
126    pub fn encode(&self) -> Vec<u8> {
127        let mut out = Vec::with_capacity(5 + 32 + 1 + self.packs.encode_size());
128        out.extend_from_slice(MANIFEST_MAGIC);
129        out.push(MANIFEST_VERSION);
130        self.root.write(&mut out);
131        mode_byte(self.mode).write(&mut out);
132        self.packs.write(&mut out);
133        out
134    }
135
136    /// Decode, rejecting unknown version/mode and trailing bytes.
137    ///
138    /// # Errors
139    ///
140    /// [`VerifyError::ClosureManifestBadMagic`],
141    /// [`VerifyError::ClosureManifestUnsupportedVersion`], or
142    /// [`VerifyError::ClosureManifestMalformed`].
143    pub fn decode(bytes: &[u8]) -> Result<Self, VerifyError> {
144        if bytes.len() < 5 || bytes[..4] != *MANIFEST_MAGIC {
145            return Err(VerifyError::ClosureManifestBadMagic);
146        }
147        let version = bytes[4];
148        if version != MANIFEST_VERSION {
149            return Err(VerifyError::ClosureManifestUnsupportedVersion(version));
150        }
151        let mut r: &[u8] = &bytes[5..];
152        let root = Hash::read(&mut r).map_err(|_| VerifyError::ClosureManifestMalformed)?;
153        let mode_b = u8::read(&mut r).map_err(|_| VerifyError::ClosureManifestMalformed)?;
154        let mode = match mode_b {
155            MODE_SNAPSHOT => ClosureMode::Snapshot,
156            MODE_HISTORY => ClosureMode::History,
157            _ => return Err(VerifyError::ClosureManifestMalformed),
158        };
159        let packs = Vec::<Hash>::read_range(&mut r, ..=MAX_CLOSURE_PACKS)
160            .map_err(|_| VerifyError::ClosureManifestMalformed)?;
161        if r.has_remaining() {
162            return Err(VerifyError::ClosureManifestMalformed);
163        }
164        Ok(Self { root, mode, packs })
165    }
166}
167
168fn mode_byte(mode: ClosureMode) -> u8 {
169    match mode {
170        ClosureMode::Snapshot => MODE_SNAPSHOT,
171        ClosureMode::History => MODE_HISTORY,
172    }
173}
174
175struct MapObjectSource<'a> {
176    objects: BTreeMap<Hash, &'a [u8]>,
177}
178
179impl ObjectSource for MapObjectSource<'_> {
180    fn fetch(&mut self, id: &Hash) -> Result<Option<Cow<'_, [u8]>>, VerifyError> {
181        Ok(self.objects.get(id).map(|bytes| Cow::Borrowed(*bytes)))
182    }
183}
184
185struct PackObjectSource<'a> {
186    packs: Vec<&'a [u8]>,
187    index: BTreeMap<Hash, (usize, Range<usize>)>,
188    corrupt: Vec<(Hash, String)>,
189}
190
191impl ObjectSource for PackObjectSource<'_> {
192    fn fetch(&mut self, id: &Hash) -> Result<Option<Cow<'_, [u8]>>, VerifyError> {
193        let Some((pack_index, range)) = self.index.get(id) else {
194            return Ok(None);
195        };
196        Ok(Some(Cow::Borrowed(&self.packs[*pack_index][range.clone()])))
197    }
198}
199
200impl ObjectSource for &ObjectStore {
201    fn fetch(&mut self, id: &Hash) -> Result<Option<Cow<'_, [u8]>>, VerifyError> {
202        // `read_raw_for_verification` is deliberately used instead of
203        // `ObjectStore::read`: the latter discards mismatching bytes, while
204        // this walker must see them so it can report `corrupt` under the
205        // requested id. The walker is the only identity authority here.
206        match (*self).read_raw_for_verification(id) {
207            Ok(bytes) => Ok(Some(Cow::Owned(bytes))),
208            Err(StoreError::ObjectNotFound(_)) => Ok(None),
209            Err(error) => Err(VerifyError::Store(error)),
210        }
211    }
212}
213
214/// What the shared walker does when a root turns out not to be a
215/// commit, remix or tag.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub(crate) enum RootRule {
218    /// Abort with [`VerifyError::ClosureRootWrongType`] (closure callers).
219    Fail,
220    /// Record it in [`Walk::bad_roots`] and keep walking (push verification).
221    Record,
222}
223
224/// Outcome of the shared [`walk`].
225#[derive(Debug, Default)]
226pub(crate) struct Walk {
227    /// Fetched objects whose id was re-derived and matched.
228    pub(crate) verified: usize,
229    /// Frontier stops: ids for which `known` held; never fetched.
230    pub(crate) skipped_known: usize,
231    /// Referenced (or a root) but absent from the source, sorted.
232    pub(crate) missing: Vec<Hash>,
233    /// Undecodable or mis-identified bytes, sorted by requested id.
234    pub(crate) corrupt: Vec<(Hash, String)>,
235    /// Roots that are not a commit/remix/tag ([`RootRule::Record`]), sorted.
236    pub(crate) bad_roots: Vec<(Hash, ObjectType)>,
237    /// Every id the walk reached, including frontier stops.
238    pub(crate) visited: BTreeSet<Hash>,
239    /// Largest queue length seen, so tests can pin that an id is queued
240    /// at most once.
241    #[cfg(test)]
242    pub(crate) peak_queue: usize,
243}
244
245/// The one closure BFS. Every closure and push verifier in this crate
246/// walks through here, taking its edges from [`children`]`(obj, mode)`.
247///
248/// Starting from every id in `roots`, each id is queued and visited at
249/// most once (an id is marked reached when it is queued, so a tree whose
250/// entries repeat one id does not grow the queue):
251/// - `known(id)` true: a frontier stop, counted and neither fetched nor
252///   descended;
253/// - otherwise fetched once from `source`, deserialized and re-hashed
254///   ([`crate::object::id_from_object`]); absent → `missing`, bad bytes
255///   or a different derived id → `corrupt` (not descended);
256/// - a root must be a commit/remix/tag (`root_rule`);
257/// - `visit` sees each verified object, then its children are queued and
258///   its bytes dropped.
259///
260/// `known` is the caller's frontier contract: it may hold only for ids
261/// whose whole closure in `mode` was already verified *in the same
262/// repository*. See [`crate::verify::verify_push`].
263///
264/// # Errors
265///
266/// [`VerifyError::TooManyClosureObjects`] once more than
267/// [`pack::MAX_ENTRIES`] distinct ids are reached (checked as they are
268/// queued, which bounds the queue), [`VerifyError::ClosureRootWrongType`] under
269/// [`RootRule::Fail`], a source error, or the first error `visit` returns.
270pub(crate) fn walk(
271    roots: &[Hash],
272    mode: ClosureMode,
273    source: &mut impl ObjectSource,
274    mut known: impl FnMut(&Hash) -> bool,
275    root_rule: RootRule,
276    mut visit: impl FnMut(&Hash, &Object) -> Result<(), VerifyError>,
277) -> Result<Walk, VerifyError> {
278    let root_set: BTreeSet<Hash> = roots.iter().copied().collect();
279    let mut out = Walk::default();
280    let mut queue: VecDeque<Hash> = VecDeque::new();
281    // `out.visited` is "reached": an id joins it when first queued, and is
282    // never queued again. Dequeue order is therefore exactly the order of
283    // first occurrence a queue with duplicates would have produced, so the
284    // fetch sequence is unchanged; only repeats are no longer stored.
285    let reach = |id: Hash, out: &mut Walk, queue: &mut VecDeque<Hash>| {
286        if out.visited.insert(id) {
287            if out.visited.len() > pack::MAX_ENTRIES as usize {
288                return Err(VerifyError::TooManyClosureObjects);
289            }
290            queue.push_back(id);
291            #[cfg(test)]
292            {
293                out.peak_queue = out.peak_queue.max(queue.len());
294            }
295        }
296        Ok(())
297    };
298    for root in roots {
299        reach(*root, &mut out, &mut queue)?;
300    }
301
302    while let Some(id) = queue.pop_front() {
303        if known(&id) {
304            out.skipped_known += 1;
305            continue;
306        }
307
308        let Some(bytes) = source.fetch(&id)? else {
309            out.missing.push(id);
310            continue;
311        };
312        let object = match crate::serialize::deserialize(bytes.as_ref()) {
313            Ok(object) => object,
314            Err(error) => {
315                out.corrupt.push((id, error.to_string()));
316                continue;
317            }
318        };
319        let derived = crate::object::id_from_object(&object, bytes.as_ref());
320        if derived != id {
321            out.corrupt.push((
322                id,
323                format!(
324                    "object bytes hash to {}, expected {}",
325                    crate::hash::to_hex(&derived),
326                    crate::hash::to_hex(&id)
327                ),
328            ));
329            continue;
330        }
331        if root_set.contains(&id) {
332            match &object {
333                Object::Commit(_) | Object::Remix(_) | Object::Tag(_) => {}
334                other => match root_rule {
335                    RootRule::Fail => {
336                        return Err(VerifyError::ClosureRootWrongType(other.object_type()));
337                    }
338                    RootRule::Record => out.bad_roots.push((id, other.object_type())),
339                },
340            }
341        }
342        visit(&id, &object)?;
343
344        let child_ids = children(&object, mode);
345        out.verified += 1;
346        drop(object);
347        drop(bytes);
348        for child in child_ids {
349            reach(child, &mut out, &mut queue)?;
350        }
351    }
352
353    out.missing.sort_unstable();
354    out.missing.dedup();
355    out.corrupt.sort_by_key(|(id, _)| *id);
356    out.bad_roots.sort_by_key(|(id, _)| *id);
357    Ok(out)
358}
359
360/// Single-root closure walk: [`walk`] with no frontier, no visitor and a
361/// hard root-type rule.
362fn walk_closure(
363    root: &Hash,
364    mode: ClosureMode,
365    source: &mut impl ObjectSource,
366) -> Result<(ClosureReport, BTreeSet<Hash>), VerifyError> {
367    let walked = walk(
368        std::slice::from_ref(root),
369        mode,
370        source,
371        |_| false,
372        RootRule::Fail,
373        |_, _| Ok(()),
374    )?;
375    Ok((
376        ClosureReport {
377            root: *root,
378            mode,
379            verified: walked.verified,
380            missing: walked.missing,
381            corrupt: walked.corrupt,
382            unreferenced: Vec::new(),
383            unreferenced_checked: false,
384        },
385        walked.visited,
386    ))
387}
388
389fn merge_corrupt(report: &mut ClosureReport, extra: Vec<(Hash, String)>) {
390    // Walker corrupt is empty on these paths: the index is keyed by
391    // derived id, so fetch never returns bytes that fail the id check.
392    report.corrupt.extend(extra);
393    report.corrupt.sort_by_key(|(id, _)| *id);
394    report.corrupt.dedup_by_key(|(id, _)| *id);
395}
396
397/// Result of indexing [`verify_closure`]'s supplied object set:
398/// derived-id → bytes, plus anything that failed to classify.
399type SuppliedIndex<'a> = (BTreeMap<Hash, &'a [u8]>, Vec<(Hash, String)>);
400
401/// Result of indexing [`verify_closure_packs`]'s recorded entries:
402/// derived-id → `(pack_index, payload_range)`, plus anything that
403/// failed to classify.
404type PackIndex = (BTreeMap<Hash, (usize, Range<usize>)>, Vec<(Hash, String)>);
405
406/// Deserialize `bytes` as a canonical object and derive its id, or
407/// classify it as corrupt under the BLAKE3 of the raw bytes. Pure and
408/// independent per call — the per-item step shared by every branch
409/// below, and by [`walk_closure`]'s own (deliberately separate)
410/// re-derivation.
411fn classify_object(bytes: &[u8]) -> Result<Hash, (Hash, String)> {
412    match crate::serialize::deserialize(bytes) {
413        Err(e) => Err((hash(bytes), e.to_string())),
414        Ok(obj) => Ok(crate::object::id_from_object(&obj, bytes)),
415    }
416}
417
418/// Indexes `verify_closure`'s supplied object set by derived id: a
419/// pure map over independent byte slices, same shape as
420/// `pack::stage_raw_entries`'s raw-entry fan-out. Runs a plain
421/// sequential loop below a small-set threshold, and fans out across a
422/// scoped thread pool at or above it (native builds only — wasm32 has
423/// no threads).
424fn index_supplied_objects<'a>(items: &[&'a [u8]]) -> SuppliedIndex<'a> {
425    #[cfg(not(target_arch = "wasm32"))]
426    {
427        let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
428        if threads > 1 && items.len() >= CLOSURE_ENTRIES_PER_THREAD.saturating_mul(threads) {
429            return index_supplied_objects_parallel(items, threads);
430        }
431    }
432    index_supplied_objects_sequential(items)
433}
434
435fn index_supplied_objects_sequential<'a>(items: &[&'a [u8]]) -> SuppliedIndex<'a> {
436    let mut by_id = BTreeMap::new();
437    let mut corrupt = Vec::new();
438    for bytes in items {
439        match classify_object(bytes) {
440            Ok(id) => {
441                by_id.insert(id, *bytes);
442            }
443            Err(e) => corrupt.push(e),
444        }
445    }
446    (by_id, corrupt)
447}
448
449/// Parallel branch of [`index_supplied_objects`]: split `items` into
450/// `threads` contiguous chunks and process each chunk on its own
451/// scoped thread. `std::thread::scope` (not a persistent pool) is
452/// deliberate — `mkit-core` stays dependency-neutral and wasm-clean
453/// (see `pack::stage_raw_entries_parallel`'s doc for the same
454/// reasoning) — and this call is already gated by
455/// [`index_supplied_objects`]'s threshold so the per-call spawn cost is
456/// only paid when there is enough work to amortize it.
457#[cfg(not(target_arch = "wasm32"))]
458fn index_supplied_objects_parallel<'a>(items: &[&'a [u8]], threads: usize) -> SuppliedIndex<'a> {
459    let chunk_size = items.len().div_ceil(threads).max(1);
460    let mut by_id = BTreeMap::new();
461    let mut corrupt = Vec::new();
462    std::thread::scope(|scope| {
463        let handles: Vec<_> = items
464            .chunks(chunk_size)
465            .map(|chunk| {
466                scope.spawn(move || {
467                    chunk
468                        .iter()
469                        .map(|bytes| classify_object(bytes).map(|id| (id, *bytes)))
470                        .collect::<Vec<_>>()
471                })
472            })
473            .collect();
474        for handle in handles {
475            for result in handle
476                .join()
477                .expect("closure object indexing worker thread panicked")
478            {
479                match result {
480                    Ok((id, bytes)) => {
481                        by_id.insert(id, bytes);
482                    }
483                    Err(e) => corrupt.push(e),
484                }
485            }
486        }
487    });
488    (by_id, corrupt)
489}
490
491/// Indexes `verify_closure_packs`'s recorded `(pack_index,
492/// payload_range)` entries by derived id — the pack-backed
493/// counterpart of [`index_supplied_objects`], same threshold and
494/// `std::thread::scope` shape.
495fn index_pack_entries(packs: &[&[u8]], refs: &[(usize, Range<usize>)]) -> PackIndex {
496    #[cfg(not(target_arch = "wasm32"))]
497    {
498        let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
499        if threads > 1 && refs.len() >= CLOSURE_ENTRIES_PER_THREAD.saturating_mul(threads) {
500            return index_pack_entries_parallel(packs, refs, threads);
501        }
502    }
503    index_pack_entries_sequential(packs, refs)
504}
505
506fn index_pack_entries_sequential(packs: &[&[u8]], refs: &[(usize, Range<usize>)]) -> PackIndex {
507    let mut index = BTreeMap::new();
508    let mut corrupt = Vec::new();
509    for (pack_index, range) in refs {
510        let bytes = &packs[*pack_index][range.clone()];
511        match classify_object(bytes) {
512            Ok(id) => {
513                index.insert(id, (*pack_index, range.clone()));
514            }
515            Err(e) => corrupt.push(e),
516        }
517    }
518    (index, corrupt)
519}
520
521#[cfg(not(target_arch = "wasm32"))]
522fn index_pack_entries_parallel(
523    packs: &[&[u8]],
524    refs: &[(usize, Range<usize>)],
525    threads: usize,
526) -> PackIndex {
527    let chunk_size = refs.len().div_ceil(threads).max(1);
528    let mut index = BTreeMap::new();
529    let mut corrupt = Vec::new();
530    std::thread::scope(|scope| {
531        let handles: Vec<_> = refs
532            .chunks(chunk_size)
533            .map(|chunk| {
534                scope.spawn(move || {
535                    chunk
536                        .iter()
537                        .map(|(pack_index, range)| {
538                            let bytes = &packs[*pack_index][range.clone()];
539                            classify_object(bytes).map(|id| (id, *pack_index, range.clone()))
540                        })
541                        .collect::<Vec<_>>()
542                })
543            })
544            .collect();
545        for handle in handles {
546            for result in handle
547                .join()
548                .expect("closure pack indexing worker thread panicked")
549            {
550                match result {
551                    Ok((id, pack_index, range)) => {
552                        index.insert(id, (pack_index, range));
553                    }
554                    Err(e) => corrupt.push(e),
555                }
556            }
557        }
558    });
559    (index, corrupt)
560}
561
562/// Walks a closure by fetching each requested id only when the BFS reaches it.
563/// Bytes are deserialized, re-hashed, and discarded after their child ids are
564/// extracted, so the walk retains object ids rather than the complete object
565/// set. A fetch-by-id source that returns bytes whose derived id differs from
566/// the requested id reports `corrupt` under the requested id; this is
567/// intentionally different from the map path's legacy
568/// `missing` + `unreferenced` classification for a parsable bit flip.
569///
570/// The root itself missing yields `missing = [root]`, `verified = 0`. The
571/// root, when present and correctly identified, MUST deserialize as a
572/// `Commit`, `Remix`, or `Tag`. Since a streaming source cannot enumerate
573/// objects it never fetched, the returned report has an empty `unreferenced`
574/// list and `unreferenced_checked = false`.
575///
576/// # Errors
577///
578/// [`VerifyError::TooManyClosureObjects`] if the walk visits more than
579/// [`pack::MAX_ENTRIES`] ids; [`VerifyError::ClosureRootWrongType`] if the
580/// root is present but not a commit/remix/tag; or a source error.
581pub fn verify_closure_streaming(
582    root: &Hash,
583    mode: ClosureMode,
584    source: &mut impl ObjectSource,
585) -> Result<ClosureReport, VerifyError> {
586    walk_closure(root, mode, source).map(|(report, _visited)| report)
587}
588
589/// Verifies a closure by reading the local [`ObjectStore`] on demand.
590///
591/// The source reads only ids reached by the BFS. It uses the store's raw
592/// capped read so a file whose bytes no longer match its filename remains
593/// available to the walker; the walker then re-derives the id and reports a
594/// hash mismatch as `corrupt` under the requested id. This is the deliberate
595/// store-backed counterpart to the map path, whose parsable bit-flip behavior
596/// remains `missing` plus `unreferenced`.
597///
598/// # Errors
599///
600/// Returns [`VerifyError::TooManyClosureObjects`] if the walk visits more than
601/// [`pack::MAX_ENTRIES`] ids, [`VerifyError::ClosureRootWrongType`] if the root
602/// is not a commit/remix/tag, or [`VerifyError::Store`] for a store failure.
603pub fn verify_closure_store(
604    store: &ObjectStore,
605    root: &Hash,
606    mode: ClosureMode,
607) -> Result<ClosureReport, VerifyError> {
608    let mut source = store;
609    verify_closure_streaming(root, mode, &mut source)
610}
611
612/// Re-hashes every supplied object and walks from `root` with
613/// [`children`]`(obj, mode)`. Store-less: the caller provides the
614/// object bytes. This compatibility path is implemented by the same
615/// pull-based walker as [`verify_closure_streaming`], with an in-memory
616/// source keyed by each successfully derived id, followed by the legacy
617/// supplied-set `unreferenced` pass.
618///
619/// A deserialize failure is recorded as `corrupt` under the BLAKE3 of
620/// those bytes. A parsable bit flip remains `missing` (the requested id)
621/// plus `unreferenced` (the derived id), preserving the map path's existing
622/// semantics. The root itself missing yields `missing = [root]`,
623/// `verified = 0`. The root, when present, MUST deserialize as a
624/// `Commit`, `Remix`, or `Tag`.
625///
626/// # Errors
627///
628/// [`VerifyError::TooManyClosureObjects`] if more than
629/// [`pack::MAX_ENTRIES`] objects are supplied;
630/// [`VerifyError::ClosureRootWrongType`] if the root is present but
631/// not a commit/remix/tag.
632pub fn verify_closure<'a>(
633    root: &Hash,
634    mode: ClosureMode,
635    objects: impl IntoIterator<Item = &'a [u8]>,
636) -> Result<ClosureReport, VerifyError> {
637    // Bail as soon as the cap is exceeded rather than draining `objects`
638    // first: it's a caller-supplied `IntoIterator`, so for an adversarial
639    // or merely huge lazy source this keeps the old per-item fail-fast
640    // bound instead of paying to produce every item before rejecting.
641    let mut items: Vec<&'a [u8]> = Vec::new();
642    for (supplied, bytes) in objects.into_iter().enumerate() {
643        if supplied >= pack::MAX_ENTRIES as usize {
644            return Err(VerifyError::TooManyClosureObjects);
645        }
646        items.push(bytes);
647    }
648    let (by_id, mut corrupt) = index_supplied_objects(&items);
649    corrupt.sort_by_key(|a| a.0);
650
651    let mut source = MapObjectSource { objects: by_id };
652    let (mut report, visited) = walk_closure(root, mode, &mut source)?;
653    merge_corrupt(&mut report, corrupt);
654    report.unreferenced = source
655        .objects
656        .keys()
657        .copied()
658        .filter(|id| !visited.contains(id))
659        .collect();
660    report.unreferenced_checked = true;
661    Ok(report)
662}
663
664/// Iterates each pack with [`PackEntries`]. Any delta or compressed
665/// entry is a profile violation — the closure profile is raw-only so a
666/// wasm verifier (no `pack-zstd`) can consume it. Raw payloads are indexed
667/// by their derived ids and served as borrowed slices into the original
668/// pack buffers.
669///
670/// # Errors
671///
672/// [`VerifyError::ClosureProfileViolation`] on a non-`0x00` entry;
673/// [`VerifyError::Pack`] on framing errors; plus
674/// [`verify_closure`]'s errors.
675pub fn verify_closure_packs(
676    root: &Hash,
677    mode: ClosureMode,
678    packs: &[&[u8]],
679) -> Result<ClosureReport, VerifyError> {
680    // Phase 1 (sequential, cheap): walk each pack's frames to validate
681    // the raw-only profile and record every entry's `(pack_index,
682    // payload_range)` in encounter order. This never decompresses —
683    // `is_raw_only` (computed by `PackEntries::new`'s header scan) is
684    // required before the loop even starts, so every entry this loop
685    // reaches is already known to be an uncompressed `0x00` frame; the
686    // `PackEntry::Delta` arm below is unreachable in practice and kept
687    // only as defense in depth.
688    let mut refs: Vec<(usize, Range<usize>)> = Vec::new();
689    let mut supplied = 0usize;
690    for (pack_index, pack) in packs.iter().enumerate() {
691        let entries = PackEntries::new(pack)?;
692        if !entries.is_raw_only() {
693            return Err(VerifyError::ClosureProfileViolation {
694                pack_index,
695                entry_index: entries.first_non_raw_index().unwrap_or(0) as usize,
696            });
697        }
698        let mut entries = entries;
699        let mut entry_index = 0usize;
700        while let Some(entry) = entries.next() {
701            if supplied >= pack::MAX_ENTRIES as usize {
702                return Err(VerifyError::TooManyClosureObjects);
703            }
704            supplied += 1;
705            let payload_range = entries
706                .last_payload_range()
707                .ok_or(VerifyError::Pack(pack::PackError::UnexpectedEof))?;
708            match entry? {
709                PackEntry::Raw { .. } => refs.push((pack_index, payload_range)),
710                PackEntry::Delta { .. } => {
711                    return Err(VerifyError::ClosureProfileViolation {
712                        pack_index,
713                        entry_index,
714                    });
715                }
716            }
717            entry_index += 1;
718        }
719    }
720
721    // Phase 2 (fanned out above a size threshold): deserialize + derive
722    // the id of every recorded entry — the actual CPU-bound work
723    // (canonical decode + a BLAKE3/BMT id derivation per object),
724    // independent per entry since each only reads its own payload
725    // range.
726    let (index, mut corrupt) = index_pack_entries(packs, &refs);
727    corrupt.sort_by_key(|a| a.0);
728    let mut source = PackObjectSource {
729        packs: packs.to_vec(),
730        index,
731        corrupt,
732    };
733    let (mut report, visited) = walk_closure(root, mode, &mut source)?;
734    merge_corrupt(&mut report, source.corrupt);
735    report.unreferenced = source
736        .index
737        .keys()
738        .copied()
739        .filter(|id| !visited.contains(id))
740        .collect();
741    report.unreferenced_checked = true;
742    Ok(report)
743}
744
745/// Decode the manifest, reject it if its `root` is not `expected_root`,
746/// check pack count and `pack_key` equality in order, then
747/// [`verify_closure_packs`]. `mode` comes from the manifest (it only
748/// widens or narrows the walk; the report carries it). The caller
749/// supplies the trusted root; the manifest is a locator, never a
750/// trust anchor.
751///
752/// # Errors
753///
754/// Manifest decode errors, [`VerifyError::ClosureRootMismatch`],
755/// [`VerifyError::ClosurePackCountMismatch`],
756/// [`VerifyError::ClosurePackKeyMismatch`], plus
757/// [`verify_closure_packs`]'s errors.
758pub fn verify_closure_manifest(
759    expected_root: &Hash,
760    manifest: &[u8],
761    packs: &[&[u8]],
762) -> Result<ClosureReport, VerifyError> {
763    let decoded = ClosureManifest::decode(manifest)?;
764    if decoded.root != *expected_root {
765        return Err(VerifyError::ClosureRootMismatch {
766            expected: *expected_root,
767            got: decoded.root,
768        });
769    }
770    if decoded.packs.len() != packs.len() {
771        return Err(VerifyError::ClosurePackCountMismatch {
772            expected: decoded.packs.len(),
773            got: packs.len(),
774        });
775    }
776    for (index, (want, got)) in decoded.packs.iter().zip(packs.iter()).enumerate() {
777        if pack_key(got) != *want {
778            return Err(VerifyError::ClosurePackKeyMismatch { index });
779        }
780    }
781    verify_closure_packs(&decoded.root, decoded.mode, packs)
782}
783
784/// Walk `store` in `mode`, write raw-only packs of the reachable
785/// objects in sorted id order, and return the encoded manifest plus
786/// packs. Native-only (`ObjectStore`).
787///
788/// Packs split when the next object would exceed
789/// [`pack::MAX_TOTAL_PAYLOAD`] or [`pack::MAX_ENTRIES`].
790///
791/// # Errors
792///
793/// Store errors, [`crate::pack::PackError`] from the raw-only writer, or
794/// [`VerifyError::ClosureRootWrongType`] if `root` is not a
795/// commit/remix/tag.
796pub fn export_closure(
797    store: &ObjectStore,
798    root: &Hash,
799    mode: ClosureMode,
800) -> Result<ClosureExport, VerifyError> {
801    export_closure_with_limits(
802        store,
803        root,
804        mode,
805        pack::MAX_TOTAL_PAYLOAD,
806        pack::MAX_ENTRIES,
807    )
808}
809
810pub(crate) fn export_closure_with_limits(
811    store: &ObjectStore,
812    root: &Hash,
813    mode: ClosureMode,
814    max_pack_payload: u64,
815    max_entries: u32,
816) -> Result<ClosureExport, VerifyError> {
817    let obj = store.read_object(root)?;
818    match &obj {
819        Object::Commit(_) | Object::Remix(_) | Object::Tag(_) => {}
820        other => return Err(VerifyError::ClosureRootWrongType(other.object_type())),
821    }
822
823    let hashes = match mode {
824        ClosureMode::Snapshot => crate::ops::graph::reachable_snapshot(store, root)?,
825        ClosureMode::History => crate::ops::graph::reachable_objects(store, root)?,
826    };
827
828    let mut packs: Vec<Vec<u8>> = Vec::new();
829    let mut writer = PackWriter::new_raw_only();
830    for h in &hashes {
831        let bytes = store.read(h)?;
832        let next_payload = writer.total_payload().saturating_add(bytes.len() as u64);
833        if writer.entry_count() > 0
834            && (next_payload > max_pack_payload || writer.entry_count() >= max_entries as usize)
835        {
836            packs.push(writer.finish()?);
837            writer = PackWriter::new_raw_only();
838        }
839        writer.push_raw(*h, &bytes)?;
840    }
841    if writer.entry_count() > 0 {
842        packs.push(writer.finish()?);
843    }
844    if packs.len() > MAX_CLOSURE_PACKS {
845        return Err(VerifyError::TooManyClosureObjects);
846    }
847
848    let manifest = ClosureManifest {
849        root: *root,
850        mode,
851        packs: packs.iter().map(|p| pack_key(p)).collect(),
852    };
853    Ok(ClosureExport {
854        manifest: manifest.encode(),
855        packs,
856    })
857}
858
859#[cfg(test)]
860#[allow(clippy::unwrap_used)]
861mod tests {
862    use super::*;
863    use std::borrow::Cow;
864    use std::fs;
865
866    use crate::hash::ZERO;
867    use crate::layout::RepoLayout;
868    use crate::object::{Blob, Commit, EntryMode, Identity, Object, ObjectType, Tree, TreeEntry};
869    use crate::sign::{KeyPair, sign_commit};
870    use crate::worktree::store_file_object;
871    use tempfile::TempDir;
872
873    fn fixture() -> (TempDir, ObjectStore, Hash, Hash) {
874        let dir = TempDir::new().unwrap();
875        let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
876        let blob = store_file_object(&store, b"hello closure").unwrap();
877        let tree = Tree {
878            entries: vec![TreeEntry {
879                name: b"f.txt".to_vec(),
880                mode: EntryMode::Blob,
881                object_hash: blob,
882            }],
883        };
884        let tree_hash = store
885            .write(&crate::serialize::serialize(&Object::Tree(tree)).unwrap())
886            .unwrap();
887        let kp = KeyPair::from_seed([0x11; 32]);
888        let mut commit = Commit {
889            tree_hash,
890            parents: vec![],
891            author: Identity::ed25519(kp.public.0),
892            signer: kp.public.0,
893            message: b"closure unit".to_vec(),
894            timestamp: 1,
895            message_hash: ZERO,
896            content_digest: ZERO,
897            signature: [0u8; 64],
898        };
899        commit.signature = sign_commit(&commit, &kp).unwrap().0;
900        let commit_id = store
901            .write(&crate::serialize::serialize(&Object::Commit(commit)).unwrap())
902            .unwrap();
903        (dir, store, commit_id, blob)
904    }
905
906    fn two_commit_fixture() -> (TempDir, ObjectStore, Hash, Hash) {
907        let (dir, store, c1, _) = fixture();
908        let blob2 = store_file_object(&store, b"second commit only").unwrap();
909        let tree2 = Tree {
910            entries: vec![TreeEntry {
911                name: b"g.txt".to_vec(),
912                mode: EntryMode::Blob,
913                object_hash: blob2,
914            }],
915        };
916        let tree2_hash = store
917            .write(&crate::serialize::serialize(&Object::Tree(tree2)).unwrap())
918            .unwrap();
919        let kp = KeyPair::from_seed([0x11; 32]);
920        let mut commit = Commit {
921            tree_hash: tree2_hash,
922            parents: vec![c1],
923            author: Identity::ed25519(kp.public.0),
924            signer: kp.public.0,
925            message: b"child".to_vec(),
926            timestamp: 2,
927            message_hash: ZERO,
928            content_digest: ZERO,
929            signature: [0u8; 64],
930        };
931        commit.signature = sign_commit(&commit, &kp).unwrap().0;
932        let c2 = store
933            .write(&crate::serialize::serialize(&Object::Commit(commit)).unwrap())
934            .unwrap();
935        (dir, store, c1, c2)
936    }
937
938    struct CountingSource {
939        objects: BTreeMap<Hash, Vec<u8>>,
940        missing: BTreeSet<Hash>,
941        fetches: BTreeMap<Hash, usize>,
942    }
943
944    impl CountingSource {
945        fn from_store(store: &ObjectStore) -> Self {
946            let mut objects = BTreeMap::new();
947            for id in store.iter_object_hashes().unwrap() {
948                objects.insert(id, store.read(&id).unwrap());
949            }
950            Self {
951                objects,
952                missing: BTreeSet::new(),
953                fetches: BTreeMap::new(),
954            }
955        }
956
957        fn fetched_ids(&self) -> BTreeSet<Hash> {
958            self.fetches.keys().copied().collect()
959        }
960    }
961
962    impl ObjectSource for CountingSource {
963        fn fetch(&mut self, id: &Hash) -> Result<Option<Cow<'_, [u8]>>, VerifyError> {
964            *self.fetches.entry(*id).or_default() += 1;
965            if self.missing.contains(id) {
966                return Ok(None);
967            }
968            let bytes = self
969                .objects
970                .get(id)
971                .unwrap_or_else(|| panic!("unexpected object fetch: {}", crate::hash::to_hex(id)));
972            Ok(Some(Cow::Borrowed(bytes)))
973        }
974    }
975
976    #[test]
977    fn repeated_child_ids_are_queued_once() {
978        // A tree whose entries all name one blob (plus a commit naming the
979        // tree twice over its parents) must not grow the queue per
980        // reference: every id is queued once, fetched once, and the fetch
981        // order is the first-occurrence BFS order.
982        const ENTRIES: usize = 10_000;
983        let blob_bytes = crate::serialize::serialize(&Object::Blob(Blob {
984            data: b"shared".to_vec(),
985        }))
986        .unwrap();
987        let blob = hash(&blob_bytes);
988        let tree = Object::Tree(Tree {
989            entries: (0..ENTRIES)
990                .map(|i| TreeEntry {
991                    name: format!("f{i:05}").into_bytes(),
992                    mode: EntryMode::Blob,
993                    object_hash: blob,
994                })
995                .collect(),
996        });
997        let tree_bytes = crate::serialize::serialize(&tree).unwrap();
998        let tree_id = crate::object::id_from_object(&tree, &tree_bytes);
999        let kp = KeyPair::from_seed([0x11; 32]);
1000        let mut commit = Commit {
1001            tree_hash: tree_id,
1002            parents: vec![],
1003            author: Identity::ed25519(kp.public.0),
1004            signer: kp.public.0,
1005            message: b"fan-in".to_vec(),
1006            timestamp: 1,
1007            message_hash: ZERO,
1008            content_digest: ZERO,
1009            signature: [0u8; 64],
1010        };
1011        commit.signature = sign_commit(&commit, &kp).unwrap().0;
1012        let commit_bytes = crate::serialize::serialize(&Object::Commit(commit)).unwrap();
1013        let root = hash(&commit_bytes);
1014
1015        let mut source = CountingSource {
1016            objects: BTreeMap::from([
1017                (blob, blob_bytes),
1018                (tree_id, tree_bytes),
1019                (root, commit_bytes),
1020            ]),
1021            missing: BTreeSet::new(),
1022            fetches: BTreeMap::new(),
1023        };
1024        let walked = walk(
1025            &[root, root],
1026            ClosureMode::History,
1027            &mut source,
1028            |_| false,
1029            RootRule::Fail,
1030            |_, _| Ok(()),
1031        )
1032        .unwrap();
1033        assert_eq!(walked.verified, 3);
1034        assert!(walked.peak_queue <= 1, "peak queue {}", walked.peak_queue);
1035        assert!(source.fetches.values().all(|n| *n == 1));
1036        assert_eq!(
1037            walked.visited,
1038            BTreeSet::from([root, tree_id, blob]),
1039            "reached set must equal the visited set"
1040        );
1041    }
1042
1043    #[test]
1044    fn export_verify_round_trip_snapshot_and_history() {
1045        let (_d, store, _c1, c2) = two_commit_fixture();
1046        for mode in [ClosureMode::Snapshot, ClosureMode::History] {
1047            let export = export_closure(&store, &c2, mode).unwrap();
1048            let packs: Vec<&[u8]> = export.packs.iter().map(Vec::as_slice).collect();
1049            let report = verify_closure_manifest(&c2, &export.manifest, &packs).unwrap();
1050            assert!(report.is_complete(), "{mode:?}: {report:?}");
1051            assert!(report.unreferenced.is_empty());
1052            assert!(report.unreferenced_checked);
1053            assert_eq!(report.root, c2);
1054            assert_eq!(report.mode, mode);
1055            assert!(report.verified >= 3);
1056        }
1057    }
1058
1059    #[test]
1060    fn history_on_snapshot_reports_parent_missing() {
1061        let (_d, store, c1, c2) = two_commit_fixture();
1062        let export = export_closure(&store, &c2, ClosureMode::Snapshot).unwrap();
1063        let packs: Vec<&[u8]> = export.packs.iter().map(Vec::as_slice).collect();
1064        let report = verify_closure_packs(&c2, ClosureMode::History, &packs).unwrap();
1065        assert!(
1066            report.missing.contains(&c1),
1067            "parent must be missing: {report:?}"
1068        );
1069        assert!(!report.is_complete());
1070        assert!(report.unreferenced_checked);
1071    }
1072
1073    #[test]
1074    fn snapshot_on_history_reports_parent_unreferenced() {
1075        let (_d, store, c1, c2) = two_commit_fixture();
1076        let export = export_closure(&store, &c2, ClosureMode::History).unwrap();
1077        let packs: Vec<&[u8]> = export.packs.iter().map(Vec::as_slice).collect();
1078        let report = verify_closure_packs(&c2, ClosureMode::Snapshot, &packs).unwrap();
1079        assert!(
1080            report.unreferenced.contains(&c1),
1081            "parent commit is history-only: {report:?}"
1082        );
1083        assert!(report.is_complete());
1084        assert!(report.unreferenced_checked);
1085    }
1086
1087    #[test]
1088    fn streaming_fetches_each_reachable_object_once_and_only() {
1089        let (_d, store, c1, c2) = two_commit_fixture();
1090        let snapshot_expected = crate::ops::graph::reachable_snapshot(&store, &c2).unwrap();
1091        let mut snapshot_source = CountingSource::from_store(&store);
1092        let snapshot =
1093            verify_closure_streaming(&c2, ClosureMode::Snapshot, &mut snapshot_source).unwrap();
1094        assert!(snapshot.is_complete(), "{snapshot:?}");
1095        assert_eq!(snapshot_source.fetched_ids(), snapshot_expected);
1096        assert!(snapshot_source.fetches.values().all(|count| *count == 1));
1097
1098        let mut history_source = CountingSource::from_store(&store);
1099        let history =
1100            verify_closure_streaming(&c2, ClosureMode::History, &mut history_source).unwrap();
1101        let history_expected = crate::ops::graph::reachable_objects(&store, &c2).unwrap();
1102        assert!(history.is_complete(), "{history:?}");
1103        assert_eq!(history_source.fetched_ids(), history_expected);
1104        assert!(history_source.fetches.values().all(|count| *count == 1));
1105
1106        let parent_closure = crate::ops::graph::reachable_snapshot(&store, &c1).unwrap();
1107        let history_only: BTreeSet<Hash> = history_expected
1108            .difference(&snapshot_expected)
1109            .copied()
1110            .collect();
1111        assert_eq!(history_only, parent_closure);
1112    }
1113
1114    #[test]
1115    fn streaming_reports_missing_and_corrupt_under_requested_id() {
1116        let (_d, store, root, blob) = fixture();
1117
1118        let mut missing_source = CountingSource::from_store(&store);
1119        missing_source.missing.insert(blob);
1120        let missing =
1121            verify_closure_streaming(&root, ClosureMode::Snapshot, &mut missing_source).unwrap();
1122        assert!(missing.missing.contains(&blob), "{missing:?}");
1123        assert!(missing.corrupt.is_empty(), "{missing:?}");
1124        assert!(!missing.unreferenced_checked);
1125
1126        let mut corrupt_source = CountingSource::from_store(&store);
1127        let bytes = corrupt_source.objects.get_mut(&blob).unwrap();
1128        let last = bytes.len() - 1;
1129        bytes[last] ^= 0x01;
1130        let corrupt =
1131            verify_closure_streaming(&root, ClosureMode::Snapshot, &mut corrupt_source).unwrap();
1132        assert!(
1133            corrupt.corrupt.iter().any(|(id, _)| *id == blob),
1134            "{corrupt:?}"
1135        );
1136        assert!(!corrupt.missing.contains(&blob), "{corrupt:?}");
1137
1138        let mut malformed_source = CountingSource::from_store(&store);
1139        malformed_source
1140            .objects
1141            .insert(blob, b"not an object".to_vec());
1142        let malformed =
1143            verify_closure_streaming(&root, ClosureMode::Snapshot, &mut malformed_source).unwrap();
1144        assert!(
1145            malformed.corrupt.iter().any(|(id, _)| *id == blob),
1146            "{malformed:?}"
1147        );
1148        assert!(!malformed.missing.contains(&blob), "{malformed:?}");
1149    }
1150
1151    struct MismatchSource {
1152        objects: BTreeMap<Hash, Vec<u8>>,
1153        mismatch_id: Hash,
1154        mismatch_bytes: Vec<u8>,
1155    }
1156
1157    impl ObjectSource for MismatchSource {
1158        fn fetch(&mut self, id: &Hash) -> Result<Option<Cow<'_, [u8]>>, VerifyError> {
1159            if *id == self.mismatch_id {
1160                return Ok(Some(Cow::Borrowed(self.mismatch_bytes.as_slice())));
1161            }
1162            Ok(self.objects.get(id).map(|b| Cow::Borrowed(b.as_slice())))
1163        }
1164    }
1165
1166    #[test]
1167    fn merge_keeps_walker_corrupt_when_source_bytes_hash_elsewhere() {
1168        let (_d, store, root, blob) = fixture();
1169        let blob_bytes = store.read(&blob).unwrap();
1170        let derived = crate::object::id_from_object(
1171            &crate::serialize::deserialize(&blob_bytes).unwrap(),
1172            &blob_bytes,
1173        );
1174        assert_eq!(derived, blob);
1175
1176        let mut objects = BTreeMap::new();
1177        for id in store.iter_object_hashes().unwrap() {
1178            if id != blob {
1179                objects.insert(id, store.read(&id).unwrap());
1180            }
1181        }
1182        let mut source = MismatchSource {
1183            objects,
1184            mismatch_id: blob,
1185            mismatch_bytes: crate::serialize::serialize(&Object::Blob(Blob {
1186                data: b"different blob".to_vec(),
1187            }))
1188            .unwrap(),
1189        };
1190        let other_id = crate::object::id_from_object(
1191            &crate::serialize::deserialize(&source.mismatch_bytes).unwrap(),
1192            &source.mismatch_bytes,
1193        );
1194        assert_ne!(other_id, blob);
1195
1196        let (mut report, _) = walk_closure(&root, ClosureMode::Snapshot, &mut source).unwrap();
1197        assert!(
1198            report.corrupt.iter().any(|(id, _)| *id == blob),
1199            "walker must classify the mismatch under the requested id: {report:?}"
1200        );
1201
1202        let extra_id = hash(b"not an object");
1203        merge_corrupt(
1204            &mut report,
1205            vec![(extra_id, "deserialize failed".to_string())],
1206        );
1207        assert!(
1208            report.corrupt.iter().any(|(id, _)| *id == blob),
1209            "walker entry must survive the merge: {report:?}"
1210        );
1211        assert!(
1212            report.corrupt.iter().any(|(id, _)| *id == extra_id),
1213            "index-time entry must survive the merge: {report:?}"
1214        );
1215    }
1216
1217    #[test]
1218    fn map_path_keeps_parsable_bit_flip_as_missing_and_unreferenced() {
1219        let (_d, store, root, blob) = fixture();
1220        let mut objects = Vec::new();
1221        let mut flipped_bytes = None;
1222        for id in store.iter_object_hashes().unwrap() {
1223            let mut bytes = store.read(&id).unwrap();
1224            if id == blob {
1225                let last = bytes.len() - 1;
1226                bytes[last] ^= 0x01;
1227                flipped_bytes = Some(bytes.clone());
1228            }
1229            objects.push(bytes);
1230        }
1231        let flipped = flipped_bytes.expect("flipped blob");
1232        let flipped_object = crate::serialize::deserialize(&flipped).unwrap();
1233        let flipped_id = crate::object::id_from_object(&flipped_object, &flipped);
1234        assert_ne!(flipped_id, blob);
1235
1236        let report = verify_closure(
1237            &root,
1238            ClosureMode::Snapshot,
1239            objects.iter().map(Vec::as_slice),
1240        )
1241        .unwrap();
1242        assert!(report.missing.contains(&blob), "{report:?}");
1243        assert!(report.unreferenced.contains(&flipped_id), "{report:?}");
1244        assert!(report.corrupt.is_empty(), "{report:?}");
1245        assert!(report.unreferenced_checked);
1246    }
1247
1248    #[test]
1249    fn store_streaming_reports_missing_and_corrupt_files() {
1250        let (_d, store, root, blob) = fixture();
1251        fs::remove_file(store.path_for(&blob)).unwrap();
1252        let missing = verify_closure_store(&store, &root, ClosureMode::Snapshot).unwrap();
1253        assert!(missing.missing.contains(&blob), "{missing:?}");
1254        assert!(!missing.unreferenced_checked);
1255
1256        let (_d, store, root, blob) = fixture();
1257        let path = store.path_for(&blob);
1258        let mut bytes = fs::read(&path).unwrap();
1259        let last = bytes.len() - 1;
1260        bytes[last] ^= 0x01;
1261        fs::write(path, bytes).unwrap();
1262        let corrupt = verify_closure_store(&store, &root, ClosureMode::Snapshot).unwrap();
1263        assert!(
1264            corrupt.corrupt.iter().any(|(id, _)| *id == blob),
1265            "{corrupt:?}"
1266        );
1267        assert!(!corrupt.missing.contains(&blob), "{corrupt:?}");
1268        assert!(!corrupt.unreferenced_checked);
1269    }
1270
1271    #[test]
1272    fn missing_root_is_reported() {
1273        let root = [0xAB; 32];
1274        let report = verify_closure(&root, ClosureMode::Snapshot, std::iter::empty()).unwrap();
1275        assert_eq!(report.missing, vec![root]);
1276        assert_eq!(report.verified, 0);
1277        assert!(!report.is_complete());
1278        assert!(report.unreferenced_checked);
1279    }
1280
1281    #[test]
1282    fn corrupt_bytes_are_reported() {
1283        let (_d, store, commit_id, _) = fixture();
1284        let export = export_closure(&store, &commit_id, ClosureMode::Snapshot).unwrap();
1285        let mut objects: Vec<Vec<u8>> = Vec::new();
1286        for pack in &export.packs {
1287            for entry in PackEntries::new(pack).unwrap() {
1288                let PackEntry::Raw { bytes } = entry.unwrap() else {
1289                    panic!("raw-only");
1290                };
1291                objects.push(bytes.into_owned());
1292            }
1293        }
1294        objects.push(b"not an object".to_vec());
1295        let report = verify_closure(
1296            &commit_id,
1297            ClosureMode::Snapshot,
1298            objects.iter().map(Vec::as_slice),
1299        )
1300        .unwrap();
1301        assert!(!report.corrupt.is_empty());
1302        assert!(!report.is_complete());
1303    }
1304
1305    #[test]
1306    fn unreferenced_extra_is_still_complete() {
1307        let (_d, store, commit_id, _) = fixture();
1308        let extra = crate::serialize::serialize(&Object::Blob(Blob {
1309            data: b"unrelated".to_vec(),
1310        }))
1311        .unwrap();
1312        let export = export_closure(&store, &commit_id, ClosureMode::Snapshot).unwrap();
1313        let mut objects: Vec<Vec<u8>> = Vec::new();
1314        for pack in &export.packs {
1315            for entry in PackEntries::new(pack).unwrap() {
1316                let PackEntry::Raw { bytes } = entry.unwrap() else {
1317                    panic!("raw-only");
1318                };
1319                objects.push(bytes.into_owned());
1320            }
1321        }
1322        objects.push(extra);
1323        let report = verify_closure(
1324            &commit_id,
1325            ClosureMode::Snapshot,
1326            objects.iter().map(Vec::as_slice),
1327        )
1328        .unwrap();
1329        assert!(report.is_complete());
1330        assert_eq!(report.unreferenced.len(), 1);
1331        assert!(report.unreferenced_checked);
1332    }
1333
1334    #[test]
1335    fn wrong_root_type_is_typed_error() {
1336        let blob = crate::serialize::serialize(&Object::Blob(Blob {
1337            data: b"not a commit".to_vec(),
1338        }))
1339        .unwrap();
1340        let id =
1341            crate::object::id_from_object(&crate::serialize::deserialize(&blob).unwrap(), &blob);
1342        let err = verify_closure(&id, ClosureMode::Snapshot, std::iter::once(blob.as_slice()))
1343            .unwrap_err();
1344        assert!(matches!(
1345            err,
1346            VerifyError::ClosureRootWrongType(ObjectType::Blob)
1347        ));
1348    }
1349
1350    #[test]
1351    fn delta_pack_is_profile_violation() {
1352        let base = crate::serialize::serialize(&Object::Blob(Blob {
1353            data: b"delta-base-xxxx".to_vec(),
1354        }))
1355        .unwrap();
1356        let target = crate::serialize::serialize(&Object::Blob(Blob {
1357            data: b"delta-target-yy".to_vec(),
1358        }))
1359        .unwrap();
1360        let base_hash = hash(&base);
1361        let stream = crate::delta::encode(&base, &target).unwrap();
1362        let mut w = PackWriter::new();
1363        w.push_raw(base_hash, &base).unwrap();
1364        w.push_delta(&base_hash, &stream).unwrap();
1365        let pack = w.finish().unwrap();
1366        let err = verify_closure_packs(&[0u8; 32], ClosureMode::Snapshot, &[pack.as_slice()])
1367            .unwrap_err();
1368        assert!(matches!(
1369            err,
1370            VerifyError::ClosureProfileViolation {
1371                pack_index: 0,
1372                entry_index: 1
1373            }
1374        ));
1375    }
1376
1377    #[test]
1378    fn manifest_rejects_unknown_version_trailing_and_mode() {
1379        let m = ClosureManifest {
1380            root: [0u8; 32],
1381            mode: ClosureMode::Snapshot,
1382            packs: vec![],
1383        };
1384        let mut bytes = m.encode();
1385        bytes.push(0xFF);
1386        assert!(matches!(
1387            ClosureManifest::decode(&bytes),
1388            Err(VerifyError::ClosureManifestMalformed)
1389        ));
1390        let mut v2 = m.encode();
1391        v2[4] = 2;
1392        assert!(matches!(
1393            ClosureManifest::decode(&v2),
1394            Err(VerifyError::ClosureManifestUnsupportedVersion(2))
1395        ));
1396        assert!(matches!(
1397            ClosureManifest::decode(b"XXXX\x01"),
1398            Err(VerifyError::ClosureManifestBadMagic)
1399        ));
1400        let mut bad_mode = m.encode();
1401        // magic(4)+version(1)+root(32)+mode
1402        bad_mode[5 + 32] = 9;
1403        assert!(matches!(
1404            ClosureManifest::decode(&bad_mode),
1405            Err(VerifyError::ClosureManifestMalformed)
1406        ));
1407    }
1408
1409    #[test]
1410    fn manifest_pack_key_mismatch() {
1411        let (_d, store, commit_id, _) = fixture();
1412        let export = export_closure(&store, &commit_id, ClosureMode::Snapshot).unwrap();
1413        let mut decoded = ClosureManifest::decode(&export.manifest).unwrap();
1414        decoded.packs[0] = [0xFF; 32];
1415        let bad = decoded.encode();
1416        let packs: Vec<&[u8]> = export.packs.iter().map(Vec::as_slice).collect();
1417        let err = verify_closure_manifest(&commit_id, &bad, &packs).unwrap_err();
1418        assert!(matches!(
1419            err,
1420            VerifyError::ClosurePackKeyMismatch { index: 0 }
1421        ));
1422    }
1423
1424    #[test]
1425    fn manifest_root_mismatch() {
1426        let (_d, store, commit_id, _) = fixture();
1427        let export = export_closure(&store, &commit_id, ClosureMode::Snapshot).unwrap();
1428        let packs: Vec<&[u8]> = export.packs.iter().map(Vec::as_slice).collect();
1429        let other = [0x11u8; 32];
1430        let err = verify_closure_manifest(&other, &export.manifest, &packs).unwrap_err();
1431        assert!(matches!(
1432            err,
1433            VerifyError::ClosureRootMismatch { expected, got }
1434                if expected == other && got == commit_id
1435        ));
1436    }
1437
1438    #[test]
1439    fn raw_only_pack_round_trips_through_pack_reader() {
1440        let (_d, store, commit_id, _) = fixture();
1441        let export = export_closure(&store, &commit_id, ClosureMode::Snapshot).unwrap();
1442        assert!(!export.packs.is_empty());
1443        for pack in &export.packs {
1444            assert_eq!(&pack[..4], b"MKIT");
1445            assert_eq!(
1446                u32::from_le_bytes(pack[4..8].try_into().unwrap()),
1447                pack::VERSION
1448            );
1449            let entries = PackEntries::new(pack).unwrap();
1450            assert!(entries.is_raw_only());
1451            let (_dir, dest) = {
1452                let d = TempDir::new().unwrap();
1453                let s = ObjectStore::init(&RepoLayout::single(d.path())).unwrap();
1454                (d, s)
1455            };
1456            crate::pack::PackReader::read(pack, &dest).unwrap();
1457        }
1458    }
1459
1460    /// A closure with enough objects to be a meaningfully different
1461    /// shape from every other (tiny) fixture in this module. With
1462    /// `CLOSURE_ENTRIES_PER_THREAD` tuned for real fan-out wins (see
1463    /// its doc — only past ~4096 entries per thread), this fixture
1464    /// stays below the live dispatch threshold on any real machine, so
1465    /// these tests instead call `index_supplied_objects_sequential`/
1466    /// `_parallel` directly to exercise and cross-check the parallel
1467    /// branch's correctness regardless of threshold.
1468    const LARGE_FIXTURE_N: usize = 200;
1469
1470    fn large_fixture() -> (TempDir, ObjectStore, Hash, usize) {
1471        let dir = TempDir::new().unwrap();
1472        let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
1473        let mut entries = Vec::with_capacity(LARGE_FIXTURE_N);
1474        for i in 0..LARGE_FIXTURE_N {
1475            let blob =
1476                store_file_object(&store, format!("large closure blob #{i}").as_bytes()).unwrap();
1477            entries.push(TreeEntry {
1478                name: format!("f{i:04}.txt").into_bytes(),
1479                mode: EntryMode::Blob,
1480                object_hash: blob,
1481            });
1482        }
1483        let tree_hash = store
1484            .write(&crate::serialize::serialize(&Object::Tree(Tree { entries })).unwrap())
1485            .unwrap();
1486        let kp = KeyPair::from_seed([0x22; 32]);
1487        let mut commit = Commit {
1488            tree_hash,
1489            parents: vec![],
1490            author: Identity::ed25519(kp.public.0),
1491            signer: kp.public.0,
1492            message: b"large closure fan-out fixture".to_vec(),
1493            timestamp: 1,
1494            message_hash: ZERO,
1495            content_digest: ZERO,
1496            signature: [0u8; 64],
1497        };
1498        commit.signature = sign_commit(&commit, &kp).unwrap().0;
1499        let commit_id = store
1500            .write(&crate::serialize::serialize(&Object::Commit(commit)).unwrap())
1501            .unwrap();
1502        let total = store.iter_object_hashes().unwrap().len();
1503        (dir, store, commit_id, total)
1504    }
1505
1506    #[test]
1507    fn large_object_set_parallel_path_matches_sequential_map() {
1508        let (_d, store, commit_id, total) = large_fixture();
1509        let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
1510
1511        let objects: Vec<Vec<u8>> = store
1512            .iter_object_hashes()
1513            .unwrap()
1514            .into_iter()
1515            .map(|id| store.read(&id).unwrap())
1516            .collect();
1517
1518        let report = verify_closure(
1519            &commit_id,
1520            ClosureMode::Snapshot,
1521            objects.iter().map(Vec::as_slice),
1522        )
1523        .unwrap();
1524        assert!(report.is_complete(), "{report:?}");
1525        assert_eq!(report.verified, total);
1526        assert!(report.unreferenced.is_empty());
1527
1528        let refs: Vec<&[u8]> = objects.iter().map(Vec::as_slice).collect();
1529        let (seq_by_id, mut seq_corrupt) = index_supplied_objects_sequential(&refs);
1530        let (par_by_id, mut par_corrupt) = index_supplied_objects_parallel(&refs, threads.max(2));
1531        seq_corrupt.sort_by_key(|a| a.0);
1532        par_corrupt.sort_by_key(|a| a.0);
1533        assert_eq!(seq_by_id, par_by_id);
1534        assert_eq!(seq_corrupt, par_corrupt);
1535        assert!(seq_corrupt.is_empty());
1536    }
1537
1538    #[test]
1539    fn large_object_set_parallel_path_matches_sequential_packs() {
1540        let (_d, store, commit_id, total) = large_fixture();
1541        let export = export_closure(&store, &commit_id, ClosureMode::Snapshot).unwrap();
1542        let packs: Vec<&[u8]> = export.packs.iter().map(Vec::as_slice).collect();
1543
1544        let report = verify_closure_packs(&commit_id, ClosureMode::Snapshot, &packs).unwrap();
1545        assert!(report.is_complete(), "{report:?}");
1546        assert_eq!(report.verified, total);
1547        assert!(report.unreferenced.is_empty());
1548        assert!(report.unreferenced_checked);
1549    }
1550
1551    #[test]
1552    fn large_object_set_parallel_path_classifies_corrupt_entry_correctly() {
1553        // A bit-flipped (but still parsable) object doesn't exercise
1554        // `classify_object`'s error arm at all — it just derives a
1555        // different id — so it wouldn't touch `index_supplied_objects_*`'s
1556        // `corrupt` bookkeeping. Replace one entry with bytes that fail to
1557        // deserialize entirely instead, and call the parallel indexer
1558        // directly (`large_fixture`'s object count stays under the live
1559        // dispatch threshold — see its doc) so this actually runs the
1560        // parallel branch's per-chunk corrupt handling, whichever
1561        // thread-chunk the corrupt entry lands in.
1562        let (_d, store, _commit_id, total) = large_fixture();
1563        let mut objects: Vec<Vec<u8>> = store
1564            .iter_object_hashes()
1565            .unwrap()
1566            .into_iter()
1567            .map(|id| store.read(&id).unwrap())
1568            .collect();
1569        let corrupt_index = total / 2;
1570        objects[corrupt_index] = b"not an object".to_vec();
1571        let expected_corrupt_id = hash(&objects[corrupt_index]);
1572
1573        let refs: Vec<&[u8]> = objects.iter().map(Vec::as_slice).collect();
1574        let threads = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
1575        let (seq_by_id, mut seq_corrupt) = index_supplied_objects_sequential(&refs);
1576        let (par_by_id, mut par_corrupt) = index_supplied_objects_parallel(&refs, threads.max(2));
1577        seq_corrupt.sort_by_key(|a| a.0);
1578        par_corrupt.sort_by_key(|a| a.0);
1579
1580        assert_eq!(par_by_id, seq_by_id);
1581        assert_eq!(par_corrupt, seq_corrupt);
1582        assert_eq!(seq_corrupt.len(), 1, "{seq_corrupt:?}");
1583        assert_eq!(seq_corrupt[0].0, expected_corrupt_id);
1584        assert_eq!(
1585            seq_by_id.len(),
1586            total - 1,
1587            "the corrupt entry must not be indexed"
1588        );
1589    }
1590}