1use 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
24pub 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#[cfg(not(target_arch = "wasm32"))]
46const CLOSURE_ENTRIES_PER_THREAD: usize = 1024;
47
48pub trait ObjectSource {
50 fn fetch(&mut self, id: &Hash) -> Result<Option<Cow<'_, [u8]>>, VerifyError>;
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ClosureReport {
64 pub root: Hash,
66 pub mode: ClosureMode,
68 pub verified: usize,
70 pub missing: Vec<Hash>,
72 pub corrupt: Vec<(Hash, String)>,
78 pub unreferenced: Vec<Hash>,
82 pub unreferenced_checked: bool,
86}
87
88impl ClosureReport {
89 #[must_use]
92 pub fn is_complete(&self) -> bool {
93 self.missing.is_empty() && self.corrupt.is_empty()
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ClosureExport {
100 pub manifest: Vec<u8>,
102 pub packs: Vec<Vec<u8>>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ClosureManifest {
114 pub root: Hash,
116 pub mode: ClosureMode,
118 pub packs: Vec<Hash>,
121}
122
123impl ClosureManifest {
124 #[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub(crate) enum RootRule {
218 Fail,
220 Record,
222}
223
224#[derive(Debug, Default)]
226pub(crate) struct Walk {
227 pub(crate) verified: usize,
229 pub(crate) skipped_known: usize,
231 pub(crate) missing: Vec<Hash>,
233 pub(crate) corrupt: Vec<(Hash, String)>,
235 pub(crate) bad_roots: Vec<(Hash, ObjectType)>,
237 pub(crate) visited: BTreeSet<Hash>,
239 #[cfg(test)]
242 pub(crate) peak_queue: usize,
243}
244
245pub(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 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
360fn 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 report.corrupt.extend(extra);
393 report.corrupt.sort_by_key(|(id, _)| *id);
394 report.corrupt.dedup_by_key(|(id, _)| *id);
395}
396
397type SuppliedIndex<'a> = (BTreeMap<Hash, &'a [u8]>, Vec<(Hash, String)>);
400
401type PackIndex = (BTreeMap<Hash, (usize, Range<usize>)>, Vec<(Hash, String)>);
405
406fn 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
418fn 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#[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
491fn 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
562pub 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
589pub 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
612pub fn verify_closure<'a>(
633 root: &Hash,
634 mode: ClosureMode,
635 objects: impl IntoIterator<Item = &'a [u8]>,
636) -> Result<ClosureReport, VerifyError> {
637 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
664pub fn verify_closure_packs(
676 root: &Hash,
677 mode: ClosureMode,
678 packs: &[&[u8]],
679) -> Result<ClosureReport, VerifyError> {
680 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 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
745pub 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
784pub 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 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 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 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 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}