1use std::collections::{BTreeMap, HashMap, VecDeque};
38use std::sync::{Arc, Mutex};
39
40use bytes::Bytes;
41use gix_pack::data::entry::Header as EntryHeader;
42use tracing::{debug, warn};
43
44use crate::git::RefName;
45use crate::object_store::{ObjectStore, ObjectStoreError};
46use crate::remote::Remote;
47use crate::url::StorageEngine;
48
49use super::PackchainError;
50use super::keys::{pack_idx_key, pack_key};
51use super::manifest::{load_chain, load_path_index};
52use super::retry::{
53 PACK_MISSING_MAX_RETRIES, PACK_MISSING_RETRY_BACKOFFS, chain_references_pack_key,
54};
55use super::schema::{ChainManifest, ChainSegment, PathNode, Sha40};
56
57pub const MAX_DELTA_DEPTH: u32 = 50;
63
64pub const DEFAULT_CACHE_CAPACITY_BYTES: u64 = 64 * 1024 * 1024;
68
69const MAX_RANGE_BYTES: u64 = 1024 * 1024 * 1024;
77
78const MAX_DECOMPRESSED_BYTES: u64 = 1024 * 1024 * 1024;
87
88const MAX_RANGE_EXPANSIONS: u32 = 6;
92
93pub struct PackIndexCache {
136 inner: Mutex<CacheInner>,
137 capacity_bytes: u64,
138}
139
140struct CacheInner {
141 map: HashMap<CacheKey, Arc<CachedIndex>>,
147 order: VecDeque<CacheKey>,
149 total_bytes: u64,
150}
151
152type CacheKey = (String, Sha40);
153
154struct CachedIndex {
155 file: gix_pack::index::File<Vec<u8>>,
158 sorted_offsets: Vec<u64>,
162 bytes: u64,
165}
166
167impl PackIndexCache {
168 #[must_use]
173 pub fn new(capacity_bytes: u64) -> Self {
174 Self {
175 inner: Mutex::new(CacheInner {
176 map: HashMap::new(),
177 order: VecDeque::new(),
178 total_bytes: 0,
179 }),
180 capacity_bytes,
181 }
182 }
183
184 #[must_use]
192 pub fn resident_bytes(&self) -> u64 {
193 self.lock().total_bytes
194 }
195
196 #[must_use]
202 pub fn len(&self) -> usize {
203 self.lock().map.len()
204 }
205
206 #[must_use]
208 pub fn is_empty(&self) -> bool {
209 self.len() == 0
210 }
211
212 fn lock(&self) -> std::sync::MutexGuard<'_, CacheInner> {
213 self.inner.lock().expect("cache mutex poisoned")
214 }
215
216 fn get(&self, key: &CacheKey) -> Option<Arc<CachedIndex>> {
217 let mut inner = self.lock();
218 let entry = inner.map.get(key).cloned()?;
219 remove_from_order(&mut inner.order, key);
221 inner.order.push_back(key.clone());
222 Some(entry)
223 }
224
225 fn insert(&self, key: CacheKey, value: Arc<CachedIndex>) {
226 let mut inner = self.lock();
227 let bytes = value.bytes;
228 if let Some(prev) = inner.map.remove(&key) {
230 inner.total_bytes = inner.total_bytes.saturating_sub(prev.bytes);
231 remove_from_order(&mut inner.order, &key);
232 }
233 if bytes > self.capacity_bytes {
236 return;
237 }
238 while inner.total_bytes + bytes > self.capacity_bytes {
240 let Some(oldest) = inner.order.pop_front() else {
241 break;
242 };
243 if let Some(removed) = inner.map.remove(&oldest) {
244 inner.total_bytes = inner.total_bytes.saturating_sub(removed.bytes);
245 }
246 }
247 inner.total_bytes += bytes;
248 inner.order.push_back(key.clone());
249 inner.map.insert(key, value);
250 }
251}
252
253fn remove_from_order(order: &mut VecDeque<CacheKey>, key: &CacheKey) {
254 if let Some(pos) = order.iter().position(|k| k == key) {
255 order.remove(pos);
256 }
257}
258
259impl Default for PackIndexCache {
260 fn default() -> Self {
261 Self::new(DEFAULT_CACHE_CAPACITY_BYTES)
262 }
263}
264
265impl std::fmt::Debug for PackIndexCache {
266 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267 f.debug_struct("PackIndexCache")
273 .field("capacity_bytes", &self.capacity_bytes)
274 .field("resident_bytes", &self.resident_bytes())
275 .field("entries", &self.len())
276 .finish_non_exhaustive()
277 }
278}
279
280pub async fn read_blob(
328 remote: &Remote,
329 ref_name: &str,
330 path: &str,
331 cache: &PackIndexCache,
332) -> Result<Bytes, PackchainError> {
333 if remote.engine() != StorageEngine::Packchain {
334 return Err(PackchainError::WrongEngine {
335 found: remote.engine(),
336 });
337 }
338
339 let segments = parse_path(path)?;
340 let remote_ref = RefName::new(ref_name).map_err(|_| PackchainError::InvalidRefName {
341 name: ref_name.to_owned(),
342 })?;
343 let prefix_opt = (!remote.prefix().is_empty()).then(|| remote.prefix());
349
350 let chain = load_chain(remote.store(), prefix_opt, &remote_ref)
351 .await?
352 .ok_or_else(|| PackchainError::ChainAbsent {
353 ref_name: ref_name.to_owned(),
354 })?;
355
356 let path_index = load_path_index(remote.store(), prefix_opt, &remote_ref)
357 .await?
358 .ok_or_else(|| PackchainError::PathIndexAbsent {
359 ref_name: ref_name.to_owned(),
360 })?;
361
362 if path_index.tip != chain.tip {
373 return Err(PackchainError::TransientChainPathIndexMismatch {
374 ref_name: ref_name.to_owned(),
375 chain_tip: chain.tip.as_str().to_owned(),
376 path_index_tip: path_index.tip.as_str().to_owned(),
377 });
378 }
379
380 let blob_sha = walk_path(&path_index.tree, &segments, ref_name, path)?;
381
382 debug!(
383 ref_name = %ref_name,
384 path = %path,
385 blob = %blob_sha.as_str(),
386 segments = chain.segments.len(),
387 "read_blob: resolved path to blob, scanning chain"
388 );
389
390 let blob_oid = sha40_to_object_id(&blob_sha);
391 let result = read_with_pack_missing_retries(
392 remote.store(),
393 prefix_opt,
394 &remote_ref,
395 ref_name,
396 chain,
397 &blob_oid,
398 cache,
399 )
400 .await;
401 let blob_not_in_chain = || PackchainError::BlobNotInChain {
402 sha: blob_sha.as_str().to_owned(),
403 path: path.to_owned(),
404 };
405 match result {
406 Ok(ResolvedObject {
407 payload,
408 kind: ObjectKind::Blob,
409 }) => Ok(Bytes::from(payload)),
410 Ok(_) => Err(blob_not_in_chain()),
412 Err(PackchainError::BlobNotInChain { sha, .. }) if sha == blob_sha.as_str() => {
418 Err(blob_not_in_chain())
419 }
420 Err(e) => Err(e),
421 }
422}
423
424async fn read_with_pack_missing_retries(
447 store: &dyn ObjectStore,
448 prefix: Option<&str>,
449 remote_ref: &RefName,
450 ref_name: &str,
451 initial_chain: ChainManifest,
452 blob_oid: &gix_hash::ObjectId,
453 cache: &PackIndexCache,
454) -> Result<ResolvedObject, PackchainError> {
455 let mut current_chain = initial_chain;
456 let mut attempt: u32 = 0;
457 loop {
458 let mut depth = 0u32;
459 let result = read_object_from_chain(
460 store,
461 prefix,
462 ¤t_chain.segments,
463 blob_oid,
464 cache,
465 &mut depth,
466 )
467 .await;
468 let missing_key = match result {
469 Ok(resolved) => return Ok(resolved),
470 Err(PackchainError::PackMissing { key }) => key,
471 Err(e) => return Err(e),
472 };
473 let reloaded = load_chain(store, prefix, remote_ref)
476 .await?
477 .ok_or_else(|| PackchainError::ChainAbsent {
478 ref_name: ref_name.to_owned(),
479 })?;
480 if chain_references_pack_key(&reloaded, prefix, &missing_key)? {
481 return Err(PackchainError::PackMissing { key: missing_key });
485 }
486 if attempt >= PACK_MISSING_MAX_RETRIES {
487 warn!(
488 ref_name = %ref_name,
489 last_missing_key = %missing_key,
490 attempts = attempt,
491 "read_blob: exhausted pack-missing retries against concurrent GC"
492 );
493 return Err(PackchainError::ConcurrentGcRetriesExhausted {
494 last_missing_key: missing_key,
495 attempts: attempt,
496 });
497 }
498 debug!(
499 ref_name = %ref_name,
500 missing_key = %missing_key,
501 attempt = attempt,
502 "read_blob: PackMissing on chain no longer references the pack — retrying after GC race"
503 );
504 tokio::time::sleep(PACK_MISSING_RETRY_BACKOFFS[attempt as usize]).await;
505 attempt += 1;
506 current_chain = reloaded;
507 }
508}
509
510#[derive(Debug)]
513struct ResolvedObject {
514 payload: Vec<u8>,
515 kind: ObjectKind,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519enum ObjectKind {
520 Blob,
521 Commit,
522 Tree,
523 Tag,
524}
525
526impl ObjectKind {
527 fn to_gix_kind(self) -> gix::objs::Kind {
532 match self {
533 Self::Blob => gix::objs::Kind::Blob,
534 Self::Commit => gix::objs::Kind::Commit,
535 Self::Tree => gix::objs::Kind::Tree,
536 Self::Tag => gix::objs::Kind::Tag,
537 }
538 }
539}
540
541fn parse_path(path: &str) -> Result<Vec<&str>, PackchainError> {
547 if path.is_empty() {
548 return Err(PackchainError::MalformedPath {
549 path: path.to_owned(),
550 reason: "empty path",
551 });
552 }
553 if path.starts_with('/') {
554 return Err(PackchainError::MalformedPath {
555 path: path.to_owned(),
556 reason: "absolute paths are not allowed",
557 });
558 }
559 let segments: Vec<&str> = path.split('/').collect();
560 for seg in &segments {
561 if seg.is_empty() {
562 return Err(PackchainError::MalformedPath {
563 path: path.to_owned(),
564 reason: "empty segment (consecutive or trailing slash)",
565 });
566 }
567 if *seg == ".." {
568 return Err(PackchainError::MalformedPath {
569 path: path.to_owned(),
570 reason: "`..` segments are not allowed",
571 });
572 }
573 if *seg == "." {
574 return Err(PackchainError::MalformedPath {
575 path: path.to_owned(),
576 reason: "`.` segments are not allowed",
577 });
578 }
579 }
580 Ok(segments)
581}
582
583fn walk_path(
586 root: &BTreeMap<String, PathNode>,
587 segments: &[&str],
588 ref_name: &str,
589 path: &str,
590) -> Result<Sha40, PackchainError> {
591 let path_not_found = || PackchainError::PathNotFound {
592 ref_name: ref_name.to_owned(),
593 path: path.to_owned(),
594 };
595 let (last_seg, prefix_segs) = segments
599 .split_last()
600 .expect("parse_path guarantees at least one segment");
601 let mut current = root;
602 for seg in prefix_segs {
603 let Some(PathNode::Tree(children)) = current.get(*seg) else {
606 return Err(path_not_found());
607 };
608 current = children;
609 }
610 match current.get(*last_seg) {
611 Some(PathNode::Blob(sha)) => Ok(sha.clone()),
612 Some(PathNode::Tree(_)) => Err(PackchainError::PathNotABlob {
613 path: path.to_owned(),
614 }),
615 None => Err(path_not_found()),
616 }
617}
618
619fn sha40_to_object_id(sha: &Sha40) -> gix_hash::ObjectId {
620 gix_hash::ObjectId::from_hex(sha.as_str().as_bytes())
625 .expect("Sha40 is always 40 lowercase hex by construction")
626}
627
628async fn read_object_from_chain(
631 store: &dyn ObjectStore,
632 prefix: Option<&str>,
633 segments: &[ChainSegment],
634 target_oid: &gix_hash::ObjectId,
635 cache: &PackIndexCache,
636 depth: &mut u32,
637) -> Result<ResolvedObject, PackchainError> {
638 for segment in segments {
644 let content_sha = super::keys::segment_pack_sha(segment)?;
645 let idx = load_index(store, prefix, &content_sha, cache).await?;
646 let Some(entry_index) = idx.file.lookup(target_oid) else {
647 continue;
648 };
649 let pack_offset = idx.file.pack_offset_at_index(entry_index);
650 let bytes = fetch_entry_bytes(store, prefix, &content_sha, pack_offset, &idx).await?;
651 let resolved = Box::pin(decode_entry(
652 store,
653 prefix,
654 segments,
655 &content_sha,
656 pack_offset,
657 &bytes,
658 cache,
659 depth,
660 ))
661 .await?;
662 verify_content_hash(target_oid, &resolved)?;
673 return Ok(resolved);
674 }
675 Err(PackchainError::BlobNotInChain {
676 sha: target_oid.to_string(),
679 path: String::new(),
680 })
681}
682
683fn verify_content_hash(
693 target_oid: &gix_hash::ObjectId,
694 resolved: &ResolvedObject,
695) -> Result<(), PackchainError> {
696 let actual = gix::objs::compute_hash(
697 gix_hash::Kind::Sha1,
698 resolved.kind.to_gix_kind(),
699 &resolved.payload,
700 )
701 .map_err(|e| PackchainError::MalformedPackEntry {
702 offset: 0,
703 reason: format!("content-hash computation failed: {e}"),
704 })?;
705 if &actual != target_oid {
706 return Err(PackchainError::ContentHashMismatch {
707 expected: target_oid.to_string(),
708 actual: actual.to_string(),
709 });
710 }
711 Ok(())
712}
713
714async fn load_index(
715 store: &dyn ObjectStore,
716 prefix: Option<&str>,
717 content_sha: &Sha40,
718 cache: &PackIndexCache,
719) -> Result<Arc<CachedIndex>, PackchainError> {
720 let key = (prefix.unwrap_or("").to_owned(), content_sha.clone());
721 if let Some(hit) = cache.get(&key) {
722 return Ok(hit);
723 }
724
725 let idx_key = pack_idx_key(prefix, content_sha);
726 let idx_bytes = match store.get_bytes(&idx_key).await {
727 Ok(b) => b,
728 Err(ObjectStoreError::NotFound(_)) => {
729 return Err(PackchainError::PackMissing { key: idx_key });
730 }
731 Err(e) => return Err(PackchainError::Store(e)),
732 };
733
734 let owned: Vec<u8> = idx_bytes.to_vec();
735 let owned_len = owned.len() as u64;
736 let path = std::path::PathBuf::from(idx_key);
737 let file =
738 gix_pack::index::File::from_data(owned, path, gix_hash::Kind::Sha1).map_err(|e| {
739 PackchainError::MalformedPackEntry {
740 offset: 0,
741 reason: format!("idx parse: {e}"),
742 }
743 })?;
744 let sorted_offsets = file.sorted_offsets();
745 let offsets_bytes = (sorted_offsets.len() as u64).saturating_mul(8);
746 let cached = Arc::new(CachedIndex {
747 file,
748 sorted_offsets,
749 bytes: owned_len.saturating_add(offsets_bytes),
750 });
751 cache.insert(key, Arc::clone(&cached));
752 Ok(cached)
753}
754
755async fn fetch_entry_bytes(
772 store: &dyn ObjectStore,
773 prefix: Option<&str>,
774 content_sha: &Sha40,
775 pack_offset: u64,
776 idx: &CachedIndex,
777) -> Result<Bytes, PackchainError> {
778 let pack = pack_key(prefix, content_sha);
779 let next_offset = idx
780 .sorted_offsets
781 .iter()
782 .copied()
783 .find(|&o| o > pack_offset);
784 let end = if let Some(end) = next_offset {
785 end
786 } else {
787 let meta = match store.head(&pack).await {
790 Ok(m) => m,
791 Err(ObjectStoreError::NotFound(_)) => {
792 return Err(PackchainError::PackMissing { key: pack });
793 }
794 Err(e) => return Err(PackchainError::Store(e)),
795 };
796 if pack_offset >= meta.size {
797 return Err(PackchainError::MalformedPackEntry {
798 offset: pack_offset,
799 reason: "entry offset beyond pack EOF".to_owned(),
800 });
801 }
802 meta.size
803 };
804 let span = end.saturating_sub(pack_offset);
805 if span > MAX_RANGE_BYTES {
806 return Err(PackchainError::MalformedPackEntry {
807 offset: pack_offset,
808 reason: format!("entry range {span} bytes exceeds {MAX_RANGE_BYTES}-byte cap"),
809 });
810 }
811 match store.get_bytes_range(&pack, pack_offset..end).await {
812 Ok(b) => Ok(b),
813 Err(ObjectStoreError::NotFound(_)) => Err(PackchainError::PackMissing { key: pack }),
814 Err(e) => Err(PackchainError::Store(e)),
815 }
816}
817
818#[allow(clippy::too_many_arguments)]
819async fn decode_entry(
820 store: &dyn ObjectStore,
821 prefix: Option<&str>,
822 chain: &[ChainSegment],
823 content_sha: &Sha40,
824 pack_offset: u64,
825 raw: &[u8],
826 cache: &PackIndexCache,
827 depth: &mut u32,
828) -> Result<ResolvedObject, PackchainError> {
829 if *depth > MAX_DELTA_DEPTH {
836 return Err(PackchainError::DeltaTooDeep {
837 max: MAX_DELTA_DEPTH,
838 });
839 }
840 *depth += 1;
841
842 let entry =
843 gix_pack::data::Entry::from_bytes(raw, pack_offset, gix_hash::Kind::Sha1.len_in_bytes())
844 .map_err(|e| PackchainError::MalformedPackEntry {
845 offset: pack_offset,
846 reason: e.to_string(),
847 })?;
848
849 let header_size: usize = usize::try_from(entry.data_offset - pack_offset).map_err(|_| {
855 PackchainError::MalformedPackEntry {
856 offset: pack_offset,
857 reason: "entry header size exceeds usize".to_owned(),
858 }
859 })?;
860 if entry.decompressed_size > MAX_DECOMPRESSED_BYTES {
865 return Err(PackchainError::MalformedPackEntry {
866 offset: pack_offset,
867 reason: format!(
868 "decompressed object size {} exceeds {}-byte cap",
869 entry.decompressed_size, MAX_DECOMPRESSED_BYTES
870 ),
871 });
872 }
873 let decompressed_size: usize = usize::try_from(entry.decompressed_size).map_err(|_| {
874 PackchainError::MalformedPackEntry {
875 offset: pack_offset,
876 reason: "decompressed object size exceeds usize".to_owned(),
877 }
878 })?;
879
880 let inflated = inflate_with_retry(
881 store,
882 prefix,
883 content_sha,
884 pack_offset,
885 raw,
886 header_size,
887 decompressed_size,
888 )
889 .await?;
890
891 match entry.header {
892 EntryHeader::Blob => Ok(ResolvedObject {
893 payload: inflated,
894 kind: ObjectKind::Blob,
895 }),
896 EntryHeader::Commit => Ok(ResolvedObject {
897 payload: inflated,
898 kind: ObjectKind::Commit,
899 }),
900 EntryHeader::Tree => Ok(ResolvedObject {
901 payload: inflated,
902 kind: ObjectKind::Tree,
903 }),
904 EntryHeader::Tag => Ok(ResolvedObject {
905 payload: inflated,
906 kind: ObjectKind::Tag,
907 }),
908 EntryHeader::OfsDelta { base_distance } => {
909 let base_offset = pack_offset.checked_sub(base_distance).ok_or(
910 PackchainError::MalformedPackEntry {
911 offset: pack_offset,
912 reason: "ofs-delta base distance underflows pack offset".to_owned(),
913 },
914 )?;
915 let idx = load_index(store, prefix, content_sha, cache).await?;
916 let base_bytes =
917 fetch_entry_bytes(store, prefix, content_sha, base_offset, &idx).await?;
918 let base = Box::pin(decode_entry(
919 store,
920 prefix,
921 chain,
922 content_sha,
923 base_offset,
924 &base_bytes,
925 cache,
926 depth,
927 ))
928 .await?;
929 apply_delta(&base, &inflated)
930 }
931 EntryHeader::RefDelta { base_id } => {
932 let base = Box::pin(read_object_from_chain(
933 store, prefix, chain, &base_id, cache, depth,
934 ))
935 .await?;
936 apply_delta(&base, &inflated)
937 }
938 }
939}
940
941async fn inflate_with_retry(
946 store: &dyn ObjectStore,
947 prefix: Option<&str>,
948 content_sha: &Sha40,
949 pack_offset: u64,
950 raw: &[u8],
951 header_size: usize,
952 decompressed_size: usize,
953) -> Result<Vec<u8>, PackchainError> {
954 let mut current_buffer: Option<Bytes> = None;
961 let mut current_end = pack_offset.saturating_add(raw.len() as u64);
962 let mut expansions = 0u32;
963 loop {
964 let compressed: &[u8] = match ¤t_buffer {
965 Some(buf) => &buf[header_size..],
966 None => &raw[header_size..],
967 };
968 match inflate_to(compressed, decompressed_size) {
969 Ok(v) => return Ok(v),
970 Err(InflateOutcome::NeedMoreInput) => {
971 if expansions >= MAX_RANGE_EXPANSIONS {
972 return Err(PackchainError::MalformedPackEntry {
973 offset: pack_offset,
974 reason: "ran out of compressed bytes after maximum range expansion"
975 .to_owned(),
976 });
977 }
978 let next_size = ((current_end - pack_offset) * 2).min(MAX_RANGE_BYTES);
979 if next_size <= current_end - pack_offset {
980 return Err(PackchainError::MalformedPackEntry {
981 offset: pack_offset,
982 reason: "range expansion hit safety cap".to_owned(),
983 });
984 }
985 let new_end = pack_offset + next_size;
986 let pack = pack_key(prefix, content_sha);
987 let bytes = match store.get_bytes_range(&pack, pack_offset..new_end).await {
988 Ok(b) => b,
989 Err(ObjectStoreError::NotFound(_)) => {
990 return Err(PackchainError::PackMissing { key: pack });
991 }
992 Err(ObjectStoreError::RangeNotSatisfiable { .. }) => {
993 return Err(PackchainError::MalformedPackEntry {
994 offset: pack_offset,
995 reason: "zlib stream truncated at pack EOF".to_owned(),
996 });
997 }
998 Err(e) => return Err(PackchainError::Store(e)),
999 };
1000 current_buffer = Some(bytes);
1001 current_end = new_end;
1002 expansions += 1;
1003 }
1004 Err(InflateOutcome::Failed) => {
1005 return Err(PackchainError::Decompress {
1006 offset: pack_offset,
1007 });
1008 }
1009 }
1010 }
1011}
1012
1013fn inflate_to(input: &[u8], announced_size: usize) -> Result<Vec<u8>, InflateOutcome> {
1018 use gix::features::zlib::{FlushDecompress, Status};
1019
1020 let mut state = gix::features::zlib::Decompress::new();
1021 let mut out = vec![0u8; announced_size];
1022 match state.decompress(input, &mut out, FlushDecompress::Finish) {
1023 Ok(Status::StreamEnd) => {
1024 let produced =
1025 usize::try_from(state.total_out()).map_err(|_| InflateOutcome::Failed)?;
1026 if produced != announced_size {
1027 return Err(InflateOutcome::Failed);
1028 }
1029 Ok(out)
1030 }
1031 Ok(Status::Ok | Status::BufError) => Err(InflateOutcome::NeedMoreInput),
1032 Err(_) => Err(InflateOutcome::Failed),
1033 }
1034}
1035
1036enum InflateOutcome {
1037 NeedMoreInput,
1038 Failed,
1039}
1040
1041fn apply_delta(base: &ResolvedObject, delta: &[u8]) -> Result<ResolvedObject, PackchainError> {
1044 let mut cursor = 0usize;
1045 let (src_size, n) = read_size_varint(delta, cursor).ok_or(PackchainError::MalformedDelta {
1046 reason: "truncated source size header",
1047 })?;
1048 cursor += n;
1049 let (dst_size, n) = read_size_varint(delta, cursor).ok_or(PackchainError::MalformedDelta {
1050 reason: "truncated destination size header",
1051 })?;
1052 cursor += n;
1053 if src_size != base.payload.len() as u64 {
1054 return Err(PackchainError::MalformedDelta {
1055 reason: "delta source size does not match base object size",
1056 });
1057 }
1058 if dst_size > MAX_DECOMPRESSED_BYTES {
1063 return Err(PackchainError::MalformedDelta {
1064 reason: "delta destination size exceeds 1 GiB cap",
1065 });
1066 }
1067 let dst_size_usize = usize::try_from(dst_size).map_err(|_| PackchainError::MalformedDelta {
1068 reason: "delta destination size exceeds usize",
1069 })?;
1070 let mut out = Vec::with_capacity(dst_size_usize);
1071 while cursor < delta.len() {
1072 let op = delta[cursor];
1073 cursor += 1;
1074 if op & 0x80 != 0 {
1075 apply_delta_copy_op(op, delta, &mut cursor, &base.payload, &mut out)?;
1076 } else if op == 0 {
1077 return Err(PackchainError::MalformedDelta {
1078 reason: "reserved zero opcode",
1079 });
1080 } else {
1081 apply_delta_insert_op(op, delta, &mut cursor, &mut out)?;
1082 }
1083 if out.len() > dst_size_usize {
1089 return Err(PackchainError::MalformedDelta {
1090 reason: "produced object exceeds announced destination size",
1091 });
1092 }
1093 }
1094 if out.len() as u64 != dst_size {
1095 return Err(PackchainError::MalformedDelta {
1096 reason: "produced object does not match announced destination size",
1097 });
1098 }
1099 Ok(ResolvedObject {
1100 payload: out,
1101 kind: base.kind,
1102 })
1103}
1104
1105fn read_packed_operand(
1108 delta: &[u8],
1109 cursor: &mut usize,
1110 bitmask: u8,
1111 bits: u8,
1112 truncated_reason: &'static str,
1113) -> Result<u32, PackchainError> {
1114 let mut value = 0u32;
1115 for shift in 0..bits {
1116 if bitmask & (1 << shift) != 0 {
1117 let byte = *delta.get(*cursor).ok_or(PackchainError::MalformedDelta {
1118 reason: truncated_reason,
1119 })?;
1120 value |= u32::from(byte) << (u32::from(shift) * 8);
1121 *cursor += 1;
1122 }
1123 }
1124 Ok(value)
1125}
1126
1127const GIT_DELTA_DEFAULT_COPY_SIZE: u32 = 0x1_0000;
1131
1132fn apply_delta_copy_op(
1137 op: u8,
1138 delta: &[u8],
1139 cursor: &mut usize,
1140 base: &[u8],
1141 out: &mut Vec<u8>,
1142) -> Result<(), PackchainError> {
1143 let copy_offset = read_packed_operand(delta, cursor, op, 4, "truncated delta copy offset")?;
1144 let mut copy_size =
1145 read_packed_operand(delta, cursor, op >> 4, 3, "truncated delta copy size")?;
1146 if copy_size == 0 {
1147 copy_size = GIT_DELTA_DEFAULT_COPY_SIZE;
1148 }
1149 let start = copy_offset as usize;
1150 let end = start
1151 .checked_add(copy_size as usize)
1152 .ok_or(PackchainError::MalformedDelta {
1153 reason: "copy span overflow",
1154 })?;
1155 if end > base.len() {
1156 return Err(PackchainError::MalformedDelta {
1157 reason: "copy span exceeds base object",
1158 });
1159 }
1160 out.extend_from_slice(&base[start..end]);
1161 Ok(())
1162}
1163
1164fn apply_delta_insert_op(
1167 op: u8,
1168 delta: &[u8],
1169 cursor: &mut usize,
1170 out: &mut Vec<u8>,
1171) -> Result<(), PackchainError> {
1172 let len = op as usize;
1173 let end = cursor
1174 .checked_add(len)
1175 .ok_or(PackchainError::MalformedDelta {
1176 reason: "insert span overflow",
1177 })?;
1178 if end > delta.len() {
1179 return Err(PackchainError::MalformedDelta {
1180 reason: "insert span exceeds delta payload",
1181 });
1182 }
1183 out.extend_from_slice(&delta[*cursor..end]);
1184 *cursor = end;
1185 Ok(())
1186}
1187
1188fn read_size_varint(data: &[u8], mut cursor: usize) -> Option<(u64, usize)> {
1191 let start = cursor;
1192 let mut value: u64 = 0;
1193 let mut shift = 0u32;
1194 loop {
1195 let byte = *data.get(cursor)?;
1196 cursor += 1;
1197 value |= u64::from(byte & 0x7f).checked_shl(shift)?;
1198 if byte & 0x80 == 0 {
1199 return Some((value, cursor - start));
1200 }
1201 shift += 7;
1202 if shift >= 64 {
1203 return None;
1204 }
1205 }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210 use super::*;
1211
1212 fn sha40(s: &str) -> Sha40 {
1213 Sha40::try_new(s).expect("test fixture sha is valid")
1214 }
1215
1216 #[test]
1217 fn parse_path_rejects_empty() {
1218 let err = parse_path("").unwrap_err();
1219 assert!(matches!(err, PackchainError::MalformedPath { .. }));
1220 }
1221
1222 #[test]
1223 fn parse_path_rejects_absolute() {
1224 let err = parse_path("/etc/passwd").unwrap_err();
1225 let PackchainError::MalformedPath { reason, .. } = err else {
1226 panic!("expected MalformedPath");
1227 };
1228 assert!(reason.contains("absolute"));
1229 }
1230
1231 #[test]
1232 fn parse_path_rejects_dotdot() {
1233 let err = parse_path("src/../etc").unwrap_err();
1234 assert!(matches!(err, PackchainError::MalformedPath { .. }));
1235 }
1236
1237 #[test]
1238 fn parse_path_rejects_dot() {
1239 let err = parse_path("./src").unwrap_err();
1240 assert!(matches!(err, PackchainError::MalformedPath { .. }));
1241 }
1242
1243 #[test]
1244 fn parse_path_rejects_double_slash() {
1245 let err = parse_path("src//main.rs").unwrap_err();
1246 assert!(matches!(err, PackchainError::MalformedPath { .. }));
1247 }
1248
1249 #[test]
1250 fn parse_path_rejects_trailing_slash() {
1251 let err = parse_path("src/main.rs/").unwrap_err();
1252 assert!(matches!(err, PackchainError::MalformedPath { .. }));
1253 }
1254
1255 #[test]
1256 fn parse_path_accepts_nested() {
1257 let segs = parse_path("src/lib/mod.rs").unwrap();
1258 assert_eq!(segs, vec!["src", "lib", "mod.rs"]);
1259 }
1260
1261 #[test]
1262 fn parse_path_accepts_single_segment() {
1263 let segs = parse_path("Cargo.toml").unwrap();
1264 assert_eq!(segs, vec!["Cargo.toml"]);
1265 }
1266
1267 const SHA_A: &str = "0123456789abcdef0123456789abcdef01234567";
1268 const SHA_B: &str = "fedcba9876543210fedcba9876543210fedcba98";
1269 const SHA_C: &str = "1111111111111111111111111111111111111111";
1270
1271 #[test]
1272 fn walk_path_finds_top_level_blob() {
1273 let mut tree = BTreeMap::new();
1274 tree.insert("Cargo.toml".to_owned(), PathNode::Blob(sha40(SHA_A)));
1275 let segs = parse_path("Cargo.toml").unwrap();
1276 let result = walk_path(&tree, &segs, "refs/heads/main", "Cargo.toml").unwrap();
1277 assert_eq!(result.as_str(), SHA_A);
1278 }
1279
1280 #[test]
1281 fn walk_path_descends_subtree() {
1282 let mut subtree = BTreeMap::new();
1283 subtree.insert("main.rs".to_owned(), PathNode::Blob(sha40(SHA_A)));
1284 let mut tree = BTreeMap::new();
1285 tree.insert("src".to_owned(), PathNode::Tree(subtree));
1286 let segs = parse_path("src/main.rs").unwrap();
1287 let result = walk_path(&tree, &segs, "refs/heads/main", "src/main.rs").unwrap();
1288 assert_eq!(result.as_str(), SHA_A);
1289 }
1290
1291 #[test]
1292 fn walk_path_missing_returns_path_not_found() {
1293 let mut tree = BTreeMap::new();
1294 tree.insert("Cargo.toml".to_owned(), PathNode::Blob(sha40(SHA_A)));
1295 let segs = parse_path("missing.txt").unwrap();
1296 let err = walk_path(&tree, &segs, "refs/heads/main", "missing.txt").unwrap_err();
1297 assert!(matches!(err, PackchainError::PathNotFound { .. }));
1298 }
1299
1300 #[test]
1301 fn walk_path_directory_returns_path_not_a_blob() {
1302 let mut subtree = BTreeMap::new();
1303 subtree.insert("main.rs".to_owned(), PathNode::Blob(sha40(SHA_A)));
1304 let mut tree = BTreeMap::new();
1305 tree.insert("src".to_owned(), PathNode::Tree(subtree));
1306 let segs = parse_path("src").unwrap();
1307 let err = walk_path(&tree, &segs, "refs/heads/main", "src").unwrap_err();
1308 assert!(matches!(err, PackchainError::PathNotABlob { .. }));
1309 }
1310
1311 #[test]
1312 fn walk_path_through_blob_returns_not_found() {
1313 let mut tree = BTreeMap::new();
1314 tree.insert("Cargo.toml".to_owned(), PathNode::Blob(sha40(SHA_A)));
1315 let segs = parse_path("Cargo.toml/extra").unwrap();
1316 let err = walk_path(&tree, &segs, "refs/heads/main", "Cargo.toml/extra").unwrap_err();
1317 assert!(matches!(err, PackchainError::PathNotFound { .. }));
1318 }
1319
1320 #[test]
1321 fn read_size_varint_single_byte() {
1322 let (v, n) = read_size_varint(&[0x05], 0).unwrap();
1323 assert_eq!(v, 5);
1324 assert_eq!(n, 1);
1325 }
1326
1327 #[test]
1328 fn read_size_varint_multi_byte() {
1329 let (v, n) = read_size_varint(&[0x83, 0x02], 0).unwrap();
1333 assert_eq!(v, 259);
1334 assert_eq!(n, 2);
1335 }
1336
1337 #[test]
1338 fn read_size_varint_truncated() {
1339 assert!(read_size_varint(&[0x80], 0).is_none());
1341 }
1342
1343 #[test]
1344 fn cache_default_starts_empty() {
1345 let cache = PackIndexCache::default();
1351 assert_eq!(cache.len(), 0);
1352 assert!(cache.is_empty());
1353 assert_eq!(cache.resident_bytes(), 0);
1354 }
1355
1356 #[test]
1363 fn cache_default_enforces_64mib_capacity() {
1364 let cache = PackIndexCache::default();
1365 cache.insert(
1367 ("p".into(), sha40(SHA_A)),
1368 Arc::new(make_dummy_index(DEFAULT_CACHE_CAPACITY_BYTES + 1)),
1369 );
1370 assert_eq!(cache.len(), 0, "entry over 64 MiB must be rejected");
1371 cache.insert(
1373 ("p".into(), sha40(SHA_B)),
1374 Arc::new(make_dummy_index(DEFAULT_CACHE_CAPACITY_BYTES)),
1375 );
1376 assert_eq!(cache.len(), 1, "entry at 64 MiB must be accepted");
1377 }
1378
1379 #[test]
1380 fn cache_explicit_capacity_zero_disables_caching() {
1381 let cache = PackIndexCache::new(0);
1382 let dummy = make_dummy_index(1_024);
1385 cache.insert(("p".into(), sha40(SHA_A)), Arc::new(dummy));
1386 assert_eq!(cache.len(), 0);
1387 }
1388
1389 #[test]
1390 fn cache_evicts_lru_when_over_capacity() {
1391 let cache = PackIndexCache::new(3_000);
1392 cache.insert(
1393 ("p".into(), sha40(SHA_A)),
1394 Arc::new(make_dummy_index(1_000)),
1395 );
1396 cache.insert(
1397 ("p".into(), sha40(SHA_B)),
1398 Arc::new(make_dummy_index(1_000)),
1399 );
1400 cache.insert(
1401 ("p".into(), sha40(SHA_C)),
1402 Arc::new(make_dummy_index(1_000)),
1403 );
1404 assert_eq!(cache.len(), 3);
1405 assert_eq!(cache.resident_bytes(), 3_000);
1406
1407 let _ = cache.get(&("p".into(), sha40(SHA_A)));
1410 cache.insert(
1411 (
1412 "p".into(),
1413 sha40("dddddddddddddddddddddddddddddddddddddddd"),
1414 ),
1415 Arc::new(make_dummy_index(1_000)),
1416 );
1417 assert_eq!(cache.len(), 3);
1418 assert!(cache.get(&("p".into(), sha40(SHA_A))).is_some());
1419 assert!(cache.get(&("p".into(), sha40(SHA_B))).is_none());
1420 }
1421
1422 #[test]
1423 fn cache_repeated_inserts_replace_accounting() {
1424 let cache = PackIndexCache::new(10_000);
1425 let key: CacheKey = ("p".into(), sha40(SHA_A));
1426 cache.insert(key.clone(), Arc::new(make_dummy_index(1_000)));
1427 cache.insert(key.clone(), Arc::new(make_dummy_index(2_500)));
1428 assert_eq!(cache.len(), 1);
1429 assert_eq!(cache.resident_bytes(), 2_500);
1430 }
1431
1432 fn make_dummy_index(bytes: u64) -> CachedIndex {
1437 let mut data = Vec::with_capacity(8 + 256 * 4 + 40);
1441 data.extend_from_slice(b"\xfftOc"); data.extend_from_slice(&2u32.to_be_bytes()); for _ in 0..256 {
1444 data.extend_from_slice(&0u32.to_be_bytes()); }
1446 data.extend_from_slice(&[0u8; 20]); data.extend_from_slice(&[0u8; 20]); let file = gix_pack::index::File::from_data(
1449 data,
1450 std::path::PathBuf::from("dummy.idx"),
1451 gix_hash::Kind::Sha1,
1452 )
1453 .expect("hand-crafted minimal v2 idx parses");
1454 CachedIndex {
1455 file,
1456 sorted_offsets: Vec::new(),
1457 bytes,
1458 }
1459 }
1460
1461 #[test]
1462 fn sha40_to_object_id_roundtrips() {
1463 let sha = sha40(SHA_A);
1464 let oid = sha40_to_object_id(&sha);
1465 assert_eq!(oid.to_string(), SHA_A);
1466 }
1467
1468 fn base_blob(payload: &[u8]) -> ResolvedObject {
1477 ResolvedObject {
1478 payload: payload.to_vec(),
1479 kind: ObjectKind::Blob,
1480 }
1481 }
1482
1483 fn varint(mut value: u64) -> Vec<u8> {
1486 let mut out = Vec::new();
1487 loop {
1488 let byte = (value & 0x7f) as u8;
1489 value >>= 7;
1490 if value == 0 {
1491 out.push(byte);
1492 return out;
1493 }
1494 out.push(byte | 0x80);
1495 }
1496 }
1497
1498 #[test]
1499 fn apply_delta_insert_only_round_trips() {
1500 let base = base_blob(b"");
1504 let literal = b"Hello, packchain!";
1505 let mut delta = Vec::new();
1506 delta.extend_from_slice(&varint(0)); delta.extend_from_slice(&varint(literal.len() as u64)); delta.push(u8::try_from(literal.len()).expect("test literal fits in 7 bits"));
1511 delta.extend_from_slice(literal);
1512 let out = apply_delta(&base, &delta).expect("insert-only delta applies");
1513 assert_eq!(out.payload, literal);
1514 assert_eq!(out.kind, ObjectKind::Blob);
1515 }
1516
1517 #[test]
1518 fn apply_delta_copy_only_round_trips() {
1519 let base = base_blob(b"abcdefghij");
1521 let mut delta = Vec::new();
1522 delta.extend_from_slice(&varint(10)); delta.extend_from_slice(&varint(5)); delta.push(0b1001_0001);
1527 delta.push(0); delta.push(5); let out = apply_delta(&base, &delta).expect("copy-only delta applies");
1530 assert_eq!(out.payload, b"abcde");
1531 }
1532
1533 #[test]
1534 fn apply_delta_mixed_copy_and_insert_round_trips() {
1535 let base = base_blob(b"HELLO!?");
1538 let mut delta = Vec::new();
1539 delta.extend_from_slice(&varint(7)); delta.extend_from_slice(&varint(11)); delta.push(0b1001_0001);
1543 delta.push(0);
1544 delta.push(5);
1545 let literal = b" world";
1547 delta.push(u8::try_from(literal.len()).expect("test literal fits in 7 bits"));
1548 delta.extend_from_slice(literal);
1549 let out = apply_delta(&base, &delta).expect("mixed delta applies");
1550 assert_eq!(out.payload, b"HELLO world");
1551 }
1552
1553 #[test]
1554 fn apply_delta_preserves_base_kind() {
1555 let base = ResolvedObject {
1559 payload: b"x".to_vec(),
1560 kind: ObjectKind::Tree,
1561 };
1562 let mut delta = Vec::new();
1563 delta.extend_from_slice(&varint(1));
1564 delta.extend_from_slice(&varint(1));
1565 delta.push(0b1001_0001);
1566 delta.push(0);
1567 delta.push(1);
1568 let out = apply_delta(&base, &delta).expect("kind-preserving delta applies");
1569 assert_eq!(out.kind, ObjectKind::Tree);
1570 }
1571
1572 #[test]
1573 fn apply_delta_rejects_source_size_mismatch() {
1574 let base = base_blob(b"x");
1577 let mut delta = Vec::new();
1578 delta.extend_from_slice(&varint(99));
1579 delta.extend_from_slice(&varint(1));
1580 delta.push(1);
1581 delta.push(b'y');
1582 let err = apply_delta(&base, &delta).expect_err("size mismatch must fail");
1583 assert!(
1584 matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("source size")),
1585 "expected MalformedDelta source-size mismatch, got {err:?}",
1586 );
1587 }
1588
1589 #[test]
1590 fn apply_delta_rejects_copy_past_base_end() {
1591 let base = base_blob(b"abcd");
1594 let mut delta = Vec::new();
1595 delta.extend_from_slice(&varint(4));
1596 delta.extend_from_slice(&varint(5));
1597 delta.push(0b1001_0001);
1598 delta.push(3); delta.push(5); let err = apply_delta(&base, &delta).expect_err("out-of-range copy must fail");
1601 assert!(
1602 matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("copy span")),
1603 "expected MalformedDelta copy-span error, got {err:?}",
1604 );
1605 }
1606
1607 #[test]
1608 fn apply_delta_rejects_dst_size_over_cap() {
1609 let base = base_blob(b"");
1612 let mut delta = Vec::new();
1613 delta.extend_from_slice(&varint(0));
1614 delta.extend_from_slice(&varint(MAX_DECOMPRESSED_BYTES + 1));
1615 let err = apply_delta(&base, &delta).expect_err("oversize dst must fail");
1616 assert!(
1617 matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("1 GiB cap")),
1618 "expected MalformedDelta cap error, got {err:?}",
1619 );
1620 }
1621
1622 #[test]
1623 fn apply_delta_rejects_reserved_zero_opcode() {
1624 let base = base_blob(b"");
1626 let mut delta = Vec::new();
1627 delta.extend_from_slice(&varint(0));
1628 delta.extend_from_slice(&varint(0));
1629 delta.push(0); let err = apply_delta(&base, &delta).expect_err("reserved opcode must fail");
1631 assert!(
1632 matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("zero opcode")),
1633 "expected MalformedDelta reserved-opcode error, got {err:?}",
1634 );
1635 }
1636
1637 #[test]
1638 fn apply_delta_copy_size_zero_substitutes_default() {
1639 let base = base_blob(b"x");
1650 let mut delta = Vec::new();
1651 delta.extend_from_slice(&varint(1));
1652 delta.extend_from_slice(&varint(2)); delta.push(0b1000_0001);
1656 delta.push(0); let err = apply_delta(&base, &delta)
1658 .expect_err("default-size substitution must fail bounds check");
1659 assert!(
1660 matches!(&err, PackchainError::MalformedDelta { reason } if reason.contains("copy span exceeds base")),
1661 "expected copy-span-exceeds-base (proves default size was substituted), got {err:?}",
1662 );
1663 }
1664
1665 #[test]
1666 fn apply_delta_rejects_dst_size_undershoot() {
1667 let base = base_blob(b"abcdef");
1671 let mut delta = Vec::new();
1672 delta.extend_from_slice(&varint(6));
1673 delta.extend_from_slice(&varint(10)); delta.push(0b1001_0001);
1676 delta.push(0);
1677 delta.push(3);
1678 let err = apply_delta(&base, &delta).expect_err("undershoot must fail");
1679 assert!(
1680 matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("destination size")),
1681 "expected MalformedDelta undershoot error, got {err:?}",
1682 );
1683 }
1684
1685 #[test]
1686 fn apply_delta_rejects_overshoot() {
1687 let base = base_blob(b"abcdefgh");
1695 let mut delta = Vec::new();
1696 delta.extend_from_slice(&varint(8)); delta.extend_from_slice(&varint(4)); delta.push(0b1001_0001);
1700 delta.push(0);
1701 delta.push(8);
1702 let err = apply_delta(&base, &delta).expect_err("overshoot must fail");
1703 assert!(
1704 matches!(
1705 err,
1706 PackchainError::MalformedDelta {
1707 reason: "produced object exceeds announced destination size"
1708 }
1709 ),
1710 "expected MalformedDelta overshoot error, got {err:?}",
1711 );
1712 }
1713
1714 #[test]
1715 fn apply_delta_overshoot_check_fires_after_single_default_size_copy() {
1716 let base_payload = vec![b'x'; 0x1_0000];
1727 let base = base_blob(&base_payload);
1728 let mut delta = Vec::new();
1729 delta.extend_from_slice(&varint(0x1_0000)); delta.extend_from_slice(&varint(4)); delta.push(0b1000_0001);
1734 delta.push(0); let err = apply_delta(&base, &delta).expect_err("default-size overshoot must fail");
1736 assert!(
1737 matches!(
1738 err,
1739 PackchainError::MalformedDelta {
1740 reason: "produced object exceeds announced destination size"
1741 }
1742 ),
1743 "expected MalformedDelta overshoot error after first op, got {err:?}",
1744 );
1745 }
1746
1747 #[test]
1748 fn apply_delta_exact_match_does_not_trip_overshoot_check() {
1749 let base = base_blob(b"abcd");
1753 let mut delta = Vec::new();
1754 delta.extend_from_slice(&varint(4));
1755 delta.extend_from_slice(&varint(4));
1756 delta.push(0b1001_0001);
1757 delta.push(0);
1758 delta.push(4);
1759 let out = apply_delta(&base, &delta).expect("exact-match delta applies");
1760 assert_eq!(out.payload, b"abcd");
1761 }
1762
1763 use crate::object_store::mock::MockStore;
1776 use flate2::Compression;
1777 use flate2::write::ZlibEncoder;
1778 use std::io::Write;
1779
1780 #[allow(clippy::cast_possible_truncation)]
1785 fn encode_pack_entry_header(type_id: u8, mut size: u64) -> Vec<u8> {
1786 let mut out = Vec::new();
1787 let low4 = (size & 0x0f) as u8;
1788 size >>= 4;
1789 let mut byte = (type_id << 4) | low4;
1790 if size != 0 {
1791 byte |= 0x80;
1792 }
1793 out.push(byte);
1794 while size != 0 {
1795 let mut next = (size & 0x7f) as u8;
1796 size >>= 7;
1797 if size != 0 {
1798 next |= 0x80;
1799 }
1800 out.push(next);
1801 }
1802 out
1803 }
1804
1805 #[allow(clippy::cast_possible_truncation)]
1809 fn encode_ofs_delta_distance(distance: u64) -> Vec<u8> {
1810 let mut bytes = Vec::new();
1816 let mut v = distance;
1817 bytes.push((v & 0x7f) as u8);
1818 v >>= 7;
1819 while v != 0 {
1820 v -= 1;
1821 bytes.push(((v & 0x7f) as u8) | 0x80);
1822 v >>= 7;
1823 }
1824 bytes.reverse();
1825 bytes
1826 }
1827
1828 fn zlib_compress(data: &[u8]) -> Vec<u8> {
1829 let mut e = ZlibEncoder::new(Vec::new(), Compression::default());
1830 e.write_all(data).expect("zlib encode");
1831 e.finish().expect("zlib finish")
1832 }
1833
1834 #[allow(clippy::cast_possible_truncation)]
1840 fn make_insert_delta(base_size: u64, payload: &[u8]) -> Vec<u8> {
1841 let mut d = Vec::new();
1842 let put_varint = |mut v: u64, buf: &mut Vec<u8>| loop {
1845 let byte = (v & 0x7f) as u8;
1846 v >>= 7;
1847 if v == 0 {
1848 buf.push(byte);
1849 return;
1850 }
1851 buf.push(byte | 0x80);
1852 };
1853 put_varint(base_size, &mut d);
1854 put_varint(payload.len() as u64, &mut d);
1855 assert!(payload.len() < 0x80, "test literal too long for one insert");
1857 d.push(payload.len() as u8);
1858 d.extend_from_slice(payload);
1859 d
1860 }
1861
1862 #[allow(clippy::cast_possible_truncation)]
1865 fn push_pack_entry(
1866 pack: &mut Vec<u8>,
1867 offsets: &mut Vec<u64>,
1868 type_id: u8,
1869 ofs_delta_distance: Option<u64>,
1870 decompressed_payload: &[u8],
1871 ) {
1872 let start = pack.len() as u64;
1873 offsets.push(start);
1874 pack.extend(encode_pack_entry_header(
1875 type_id,
1876 decompressed_payload.len() as u64,
1877 ));
1878 if let Some(d) = ofs_delta_distance {
1879 pack.extend(encode_ofs_delta_distance(d));
1880 }
1881 pack.extend(zlib_compress(decompressed_payload));
1882 }
1883
1884 fn install_cached_index(
1889 cache: &PackIndexCache,
1890 prefix: &str,
1891 content_sha: &Sha40,
1892 offsets: Vec<u64>,
1893 ) {
1894 let cached = CachedIndex {
1895 file: minimal_v2_idx(),
1896 sorted_offsets: offsets,
1897 bytes: 1_024,
1898 };
1899 cache.insert((prefix.to_owned(), content_sha.clone()), Arc::new(cached));
1900 }
1901
1902 fn minimal_v2_idx() -> gix_pack::index::File<Vec<u8>> {
1903 let mut data = Vec::with_capacity(8 + 256 * 4 + 40);
1904 data.extend_from_slice(b"\xfftOc");
1905 data.extend_from_slice(&2u32.to_be_bytes());
1906 for _ in 0..256 {
1907 data.extend_from_slice(&0u32.to_be_bytes());
1908 }
1909 data.extend_from_slice(&[0u8; 20]);
1910 data.extend_from_slice(&[0u8; 20]);
1911 gix_pack::index::File::from_data(
1912 data,
1913 std::path::PathBuf::from("dummy.idx"),
1914 gix_hash::Kind::Sha1,
1915 )
1916 .expect("hand-crafted minimal v2 idx parses")
1917 }
1918
1919 #[tokio::test]
1925 async fn decode_entry_rejects_when_depth_already_over_cap() {
1926 let store = MockStore::new();
1927 let cache = PackIndexCache::default();
1928 let chain: Vec<ChainSegment> = Vec::new();
1929 let content_sha = sha40(SHA_A);
1930 let mut pack = Vec::new();
1933 let mut offsets = Vec::new();
1934 push_pack_entry(&mut pack, &mut offsets, 3 , None, b"x");
1935
1936 let mut depth = MAX_DELTA_DEPTH + 1;
1937 let err = decode_entry(
1938 &store,
1939 None,
1940 &chain,
1941 &content_sha,
1942 offsets[0],
1943 &pack[usize::try_from(offsets[0]).unwrap()..],
1944 &cache,
1945 &mut depth,
1946 )
1947 .await
1948 .expect_err("over-cap depth must fail");
1949 assert!(
1950 matches!(err, PackchainError::DeltaTooDeep { max } if max == MAX_DELTA_DEPTH),
1951 "expected DeltaTooDeep, got {err:?}",
1952 );
1953 }
1954
1955 #[tokio::test]
1960 async fn decode_entry_at_cap_with_non_delta_base_succeeds() {
1961 let store = MockStore::new();
1962 let cache = PackIndexCache::default();
1963 let chain: Vec<ChainSegment> = Vec::new();
1964 let content_sha = sha40(SHA_A);
1965 let mut pack = Vec::new();
1966 let mut offsets = Vec::new();
1967 push_pack_entry(
1968 &mut pack,
1969 &mut offsets,
1970 3, None,
1972 b"deepest-base",
1973 );
1974
1975 let mut depth = MAX_DELTA_DEPTH;
1976 let resolved = decode_entry(
1977 &store,
1978 None,
1979 &chain,
1980 &content_sha,
1981 offsets[0],
1982 &pack[usize::try_from(offsets[0]).unwrap()..],
1983 &cache,
1984 &mut depth,
1985 )
1986 .await
1987 .expect("blob at MAX boundary must decode");
1988 assert_eq!(resolved.payload, b"deepest-base");
1989 assert_eq!(resolved.kind, ObjectKind::Blob);
1990 }
1991
1992 #[tokio::test]
2005 async fn ofs_delta_recursion_consumes_depth_budget() {
2006 let store = MockStore::new();
2007 let cache = PackIndexCache::default();
2008 let chain: Vec<ChainSegment> = Vec::new();
2009 let content_sha = sha40(SHA_A);
2010
2011 let base_payload = b"base-blob";
2012 let mut pack = Vec::new();
2013 let mut offsets = Vec::new();
2014 push_pack_entry(&mut pack, &mut offsets, 3, None, base_payload);
2016 let delta = make_insert_delta(base_payload.len() as u64, b"reconstructed");
2021 let entry1_start = pack.len() as u64;
2022 let distance = entry1_start - offsets[0];
2023 push_pack_entry(&mut pack, &mut offsets, 6, Some(distance), &delta);
2024
2025 store.insert(pack_key(None, &content_sha), Bytes::from(pack.clone()));
2030 install_cached_index(&cache, "", &content_sha, offsets.clone());
2031
2032 let mut depth = MAX_DELTA_DEPTH;
2036 let err = decode_entry(
2037 &store,
2038 None,
2039 &chain,
2040 &content_sha,
2041 offsets[1],
2042 &pack[usize::try_from(offsets[1]).unwrap()..],
2043 &cache,
2044 &mut depth,
2045 )
2046 .await
2047 .expect_err("OFS_DELTA recursion must trip the depth guard");
2048 assert!(
2049 matches!(err, PackchainError::DeltaTooDeep { max } if max == MAX_DELTA_DEPTH),
2050 "expected DeltaTooDeep from OFS_DELTA recursion, got {err:?}",
2051 );
2052 }
2053
2054 struct FakeSizeStore {
2066 inner: MockStore,
2067 fake_size: u64,
2068 }
2069
2070 #[async_trait::async_trait]
2071 impl ObjectStore for FakeSizeStore {
2072 async fn list(
2073 &self,
2074 prefix: &str,
2075 ) -> Result<Vec<crate::object_store::ObjectMeta>, ObjectStoreError> {
2076 self.inner.list(prefix).await
2077 }
2078 async fn get_to_file(
2079 &self,
2080 key: &str,
2081 dest: &std::path::Path,
2082 opts: crate::object_store::GetOpts,
2083 ) -> Result<(), ObjectStoreError> {
2084 self.inner.get_to_file(key, dest, opts).await
2085 }
2086 async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
2087 self.inner.get_bytes(key).await
2088 }
2089 async fn get_bytes_range(
2090 &self,
2091 key: &str,
2092 range: std::ops::Range<u64>,
2093 ) -> Result<Bytes, ObjectStoreError> {
2094 self.inner.get_bytes_range(key, range).await
2095 }
2096 async fn put_bytes(
2097 &self,
2098 key: &str,
2099 body: Bytes,
2100 opts: crate::object_store::PutOpts,
2101 ) -> Result<(), ObjectStoreError> {
2102 self.inner.put_bytes(key, body, opts).await
2103 }
2104 async fn put_if_absent(&self, key: &str, body: Bytes) -> Result<bool, ObjectStoreError> {
2105 self.inner.put_if_absent(key, body).await
2106 }
2107 async fn head(
2108 &self,
2109 key: &str,
2110 ) -> Result<crate::object_store::ObjectMeta, ObjectStoreError> {
2111 let meta = self.inner.head(key).await?;
2114 Ok(crate::object_store::ObjectMeta {
2115 size: self.fake_size,
2116 ..meta
2117 })
2118 }
2119 async fn copy(&self, src: &str, dst: &str) -> Result<(), ObjectStoreError> {
2120 self.inner.copy(src, dst).await
2121 }
2122 async fn delete(&self, key: &str) -> Result<(), ObjectStoreError> {
2123 self.inner.delete(key).await
2124 }
2125 }
2126
2127 #[tokio::test]
2131 async fn fetch_entry_bytes_terminal_entry_under_cap_succeeds() {
2132 let store = MockStore::new();
2133 let cache = PackIndexCache::default();
2134 let content_sha = sha40(SHA_A);
2135
2136 let body: &[u8] = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09";
2139 store.insert(pack_key(None, &content_sha), Bytes::from(body.to_vec()));
2140 install_cached_index(&cache, "", &content_sha, vec![2]);
2144 let idx = cache
2145 .get(&(String::new(), content_sha.clone()))
2146 .expect("cache hit");
2147
2148 let got = fetch_entry_bytes(&store, None, &content_sha, 2, &idx)
2149 .await
2150 .expect("terminal entry under cap must succeed");
2151 assert_eq!(got.as_ref(), &body[2..]);
2152 }
2153
2154 #[tokio::test]
2158 async fn fetch_entry_bytes_terminal_entry_over_cap_rejected() {
2159 let inner = MockStore::new();
2160 let cache = PackIndexCache::default();
2161 let content_sha = sha40(SHA_A);
2162
2163 inner.insert(pack_key(None, &content_sha), Bytes::from_static(b"stub"));
2168 install_cached_index(&cache, "", &content_sha, vec![0]);
2169 let idx = cache
2170 .get(&(String::new(), content_sha.clone()))
2171 .expect("cache hit");
2172
2173 let store = FakeSizeStore {
2174 inner,
2175 fake_size: MAX_RANGE_BYTES + 1,
2176 };
2177
2178 let err = fetch_entry_bytes(&store, None, &content_sha, 0, &idx)
2179 .await
2180 .expect_err("terminal entry above cap must be rejected");
2181 assert!(
2182 matches!(
2183 err,
2184 PackchainError::MalformedPackEntry { offset: 0, ref reason }
2185 if reason.contains("exceeds") && reason.contains("cap")
2186 ),
2187 "expected MalformedPackEntry size-cap error, got {err:?}",
2188 );
2189 }
2190
2191 #[tokio::test]
2195 async fn fetch_entry_bytes_terminal_entry_offset_past_eof_rejected() {
2196 let store = MockStore::new();
2197 let cache = PackIndexCache::default();
2198 let content_sha = sha40(SHA_A);
2199
2200 store.insert(pack_key(None, &content_sha), Bytes::from_static(b"abc"));
2201 install_cached_index(&cache, "", &content_sha, vec![100]);
2204 let idx = cache
2205 .get(&(String::new(), content_sha.clone()))
2206 .expect("cache hit");
2207
2208 let err = fetch_entry_bytes(&store, None, &content_sha, 100, &idx)
2209 .await
2210 .expect_err("offset beyond EOF must be rejected");
2211 assert!(
2212 matches!(
2213 err,
2214 PackchainError::MalformedPackEntry { offset: 100, ref reason }
2215 if reason.contains("beyond pack EOF")
2216 ),
2217 "expected MalformedPackEntry EOF error, got {err:?}",
2218 );
2219 }
2220
2221 #[tokio::test]
2226 async fn ofs_delta_below_cap_decodes() {
2227 let store = MockStore::new();
2228 let cache = PackIndexCache::default();
2229 let chain: Vec<ChainSegment> = Vec::new();
2230 let content_sha = sha40(SHA_A);
2231
2232 let base_payload = b"base";
2233 let mut pack = Vec::new();
2234 let mut offsets = Vec::new();
2235 push_pack_entry(&mut pack, &mut offsets, 3, None, base_payload);
2236 let delta = make_insert_delta(base_payload.len() as u64, b"hi");
2237 let entry1_start = pack.len() as u64;
2238 let distance = entry1_start - offsets[0];
2239 push_pack_entry(&mut pack, &mut offsets, 6, Some(distance), &delta);
2240
2241 store.insert(pack_key(None, &content_sha), Bytes::from(pack.clone()));
2242 install_cached_index(&cache, "", &content_sha, offsets.clone());
2243
2244 let mut depth = 0u32;
2245 let resolved = decode_entry(
2246 &store,
2247 None,
2248 &chain,
2249 &content_sha,
2250 offsets[1],
2251 &pack[usize::try_from(offsets[1]).unwrap()..],
2252 &cache,
2253 &mut depth,
2254 )
2255 .await
2256 .expect("OFS_DELTA decodes below cap");
2257 assert_eq!(resolved.payload, b"hi");
2258 assert_eq!(resolved.kind, ObjectKind::Blob);
2259 }
2260
2261 use crate::packchain::keys::chain_key;
2270 use crate::packchain::schema::ChainManifest;
2271 use std::sync::atomic::{AtomicUsize, Ordering};
2272
2273 fn make_chain_with(tip_hex: &str, pack_sha_hex: &str) -> ChainManifest {
2274 ChainManifest {
2275 v: ChainManifest::SCHEMA_VERSION,
2276 tip: sha40(tip_hex),
2277 full_at: sha40(tip_hex),
2278 segments: vec![ChainSegment {
2279 sha: sha40(tip_hex),
2280 parent_sha: None,
2281 pack: format!("packs/{pack_sha_hex}.pack"),
2282 bytes: 1_024,
2283 }],
2284 }
2285 }
2286
2287 #[test]
2288 fn chain_references_pack_key_matches_pack_and_idx_keys() {
2289 let chain = make_chain_with(SHA_A, SHA_B);
2290 assert!(chain_references_pack_key(&chain, None, &format!("packs/{SHA_B}.pack")).unwrap());
2293 assert!(chain_references_pack_key(&chain, None, &format!("packs/{SHA_B}.idx")).unwrap());
2294 }
2295
2296 #[test]
2297 fn chain_references_pack_key_returns_false_for_unreferenced_pack() {
2298 let chain = make_chain_with(SHA_A, SHA_B);
2299 assert!(!chain_references_pack_key(&chain, None, &format!("packs/{SHA_C}.pack")).unwrap());
2300 assert!(!chain_references_pack_key(&chain, None, &format!("packs/{SHA_C}.idx")).unwrap());
2301 }
2302
2303 #[test]
2304 fn chain_references_pack_key_respects_prefix() {
2305 let chain = make_chain_with(SHA_A, SHA_B);
2306 assert!(
2310 chain_references_pack_key(&chain, Some("repo"), &format!("repo/packs/{SHA_B}.pack"))
2311 .unwrap()
2312 );
2313 assert!(
2314 !chain_references_pack_key(&chain, Some("repo"), &format!("packs/{SHA_B}.pack"))
2315 .unwrap()
2316 );
2317 }
2318
2319 #[test]
2320 fn chain_references_pack_key_returns_false_for_malformed_missing_key() {
2321 let chain = make_chain_with(SHA_A, SHA_B);
2328 assert!(!chain_references_pack_key(&chain, None, "weird/key").unwrap());
2330 assert!(!chain_references_pack_key(&chain, None, "packs/not-a-sha.pack").unwrap());
2332 assert!(!chain_references_pack_key(&chain, None, &format!("packs/{SHA_B}.bin")).unwrap());
2334 assert!(!chain_references_pack_key(&chain, None, "").unwrap());
2336 }
2337
2338 struct EvolvingChainStore {
2348 inner: MockStore,
2349 chain_key: String,
2350 bodies: Vec<Bytes>,
2351 calls: AtomicUsize,
2352 path_index_calls: AtomicUsize,
2358 }
2359
2360 impl EvolvingChainStore {
2361 fn new(inner: MockStore, chain_key: String, bodies: Vec<Bytes>) -> Self {
2362 assert!(!bodies.is_empty(), "must supply at least one chain body");
2363 Self {
2364 inner,
2365 chain_key,
2366 bodies,
2367 calls: AtomicUsize::new(0),
2368 path_index_calls: AtomicUsize::new(0),
2369 }
2370 }
2371
2372 fn chain_calls(&self) -> usize {
2373 self.calls.load(Ordering::SeqCst)
2374 }
2375
2376 fn path_index_calls(&self) -> usize {
2377 self.path_index_calls.load(Ordering::SeqCst)
2378 }
2379 }
2380
2381 crate::delegate_to_inner_impl! {
2387 impl ObjectStore for EvolvingChainStore {
2388 forward: list, get_to_file, get_bytes_range,
2389 put_bytes, put_if_absent,
2390 head, copy, delete;
2391
2392 async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
2393 if key == self.chain_key {
2394 let idx = self.calls.fetch_add(1, Ordering::SeqCst);
2395 let pick = idx.min(self.bodies.len() - 1);
2396 return Ok(self.bodies[pick].clone());
2397 }
2398 if key.ends_with("/path-index.json") {
2399 self.path_index_calls.fetch_add(1, Ordering::SeqCst);
2400 }
2401 self.inner.get_bytes(key).await
2402 }
2403 }
2404 }
2405
2406 fn build_one_object_v2_idx(target_sha: &Sha40, pack_offset: u32) -> Vec<u8> {
2410 let oid = sha40_to_object_id(target_sha);
2414 let sha_bytes = oid.as_bytes();
2415 let first_byte = sha_bytes[0];
2416
2417 let mut data = Vec::with_capacity(8 + 256 * 4 + 20 + 4 + 4 + 20 + 20);
2418 data.extend_from_slice(b"\xfftOc");
2420 data.extend_from_slice(&2u32.to_be_bytes());
2421 for i in 0u16..256 {
2426 let count = u32::from(u8::try_from(i).expect("0..256 fits in u8") >= first_byte);
2427 data.extend_from_slice(&count.to_be_bytes());
2428 }
2429 data.extend_from_slice(sha_bytes);
2431 data.extend_from_slice(&0u32.to_be_bytes());
2433 data.extend_from_slice(&pack_offset.to_be_bytes());
2435 data.extend_from_slice(&[0u8; 20]);
2439 data.extend_from_slice(&[0u8; 20]);
2440 data
2441 }
2442
2443 fn blob_oid_for(payload: &[u8]) -> Sha40 {
2449 let oid = gix::objs::compute_hash(gix_hash::Kind::Sha1, gix::objs::Kind::Blob, payload)
2450 .expect("blob hash");
2451 Sha40::from_oid(&oid).expect("oid is 40-hex by construction")
2452 }
2453
2454 fn chain_json_bytes(tip_hex: &str, pack_sha_hex: &str) -> Bytes {
2457 let json = make_chain_with(tip_hex, pack_sha_hex)
2458 .to_json_pretty()
2459 .expect("chain serialise");
2460 Bytes::from(json)
2461 }
2462
2463 #[tokio::test]
2470 async fn read_with_pack_missing_retries_succeeds_after_chain_reload() {
2471 let inner = MockStore::new();
2472 let cache = PackIndexCache::default();
2473
2474 let p1_sha = sha40(SHA_A);
2475 let p2_sha = sha40(SHA_B);
2476 let blob_payload = b"recovered blob";
2477 let blob_oid_sha = blob_oid_for(blob_payload);
2483 let blob_oid = sha40_to_object_id(&blob_oid_sha);
2484
2485 let mut pack = Vec::new();
2487 let mut offsets = Vec::new();
2488 push_pack_entry(
2489 &mut pack,
2490 &mut offsets,
2491 3, None,
2493 blob_payload,
2494 );
2495 inner.insert(pack_key(None, &p2_sha), Bytes::from(pack.clone()));
2496
2497 let idx_bytes = build_one_object_v2_idx(&blob_oid_sha, 0);
2501 inner.insert(pack_idx_key(None, &p2_sha), Bytes::from(idx_bytes));
2502
2503 let chain_key = chain_key(None, "refs/heads/main");
2509 let v1 = chain_json_bytes(SHA_A, p1_sha.as_str());
2510 let v2 = chain_json_bytes(SHA_A, p2_sha.as_str());
2511 let store = EvolvingChainStore::new(inner, chain_key, vec![v2]);
2512
2513 let initial = ChainManifest::from_json_bytes(&v1).expect("chain v1 parses");
2514 let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2515
2516 let resolved = read_with_pack_missing_retries(
2517 &store,
2518 None,
2519 &remote_ref,
2520 "refs/heads/main",
2521 initial,
2522 &blob_oid,
2523 &cache,
2524 )
2525 .await
2526 .expect("retry must succeed after chain reload");
2527 assert_eq!(resolved.payload, blob_payload);
2528 assert_eq!(resolved.kind, ObjectKind::Blob);
2529 assert_eq!(
2533 store.chain_calls(),
2534 1,
2535 "exactly one chain reload should have fired"
2536 );
2537 assert_eq!(
2547 store.path_index_calls(),
2548 0,
2549 "retry path must not reload path-index.json",
2550 );
2551 }
2552
2553 #[tokio::test]
2563 async fn read_with_pack_missing_retries_does_not_reload_path_index() {
2564 let inner = MockStore::new();
2565 let cache = PackIndexCache::default();
2566
2567 let p1_sha = sha40(SHA_A);
2568 let p2_sha = sha40(SHA_B);
2569 let blob_payload = b"recovered blob";
2570 let blob_oid_sha = blob_oid_for(blob_payload);
2573 let blob_oid = sha40_to_object_id(&blob_oid_sha);
2574
2575 let mut pack = Vec::new();
2577 let mut offsets = Vec::new();
2578 push_pack_entry(
2579 &mut pack,
2580 &mut offsets,
2581 3, None,
2583 blob_payload,
2584 );
2585 inner.insert(pack_key(None, &p2_sha), Bytes::from(pack));
2586 let idx_bytes = build_one_object_v2_idx(&blob_oid_sha, 0);
2587 inner.insert(pack_idx_key(None, &p2_sha), Bytes::from(idx_bytes));
2588
2589 inner.insert("refs/heads/main/path-index.json", Bytes::from_static(b"{}"));
2593
2594 let chain_key = chain_key(None, "refs/heads/main");
2596 let v1 = chain_json_bytes(SHA_A, p1_sha.as_str());
2597 let v2 = chain_json_bytes(SHA_A, p2_sha.as_str());
2598 let store = EvolvingChainStore::new(inner, chain_key, vec![v2]);
2599
2600 let initial = ChainManifest::from_json_bytes(&v1).expect("chain v1 parses");
2601 let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2602
2603 let resolved = read_with_pack_missing_retries(
2604 &store,
2605 None,
2606 &remote_ref,
2607 "refs/heads/main",
2608 initial,
2609 &blob_oid,
2610 &cache,
2611 )
2612 .await
2613 .expect("retry must succeed");
2614 assert_eq!(resolved.payload, blob_payload);
2615 assert_eq!(store.chain_calls(), 1);
2617 assert_eq!(
2622 store.path_index_calls(),
2623 0,
2624 "retry path read path-index.json {} times; must be zero",
2625 store.path_index_calls(),
2626 );
2627 }
2628
2629 #[tokio::test]
2635 async fn read_with_pack_missing_retries_fails_fast_when_chain_still_references_missing_pack() {
2636 let inner = MockStore::new();
2637 let cache = PackIndexCache::default();
2638
2639 let p1_sha = sha40(SHA_A);
2640 let blob_oid = sha40_to_object_id(&sha40(SHA_C));
2641
2642 let chain_key = chain_key(None, "refs/heads/main");
2645 let body = chain_json_bytes(SHA_A, p1_sha.as_str());
2646 let store = EvolvingChainStore::new(inner, chain_key, vec![body.clone()]);
2647 let initial = ChainManifest::from_json_bytes(&body).expect("chain parses");
2648 let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2649
2650 let err = read_with_pack_missing_retries(
2651 &store,
2652 None,
2653 &remote_ref,
2654 "refs/heads/main",
2655 initial,
2656 &blob_oid,
2657 &cache,
2658 )
2659 .await
2660 .expect_err("missing pack still in chain must fail fast");
2661 match err {
2662 PackchainError::PackMissing { key } => {
2663 assert!(
2664 key.contains(&format!("packs/{SHA_A}")),
2665 "PackMissing key should name the missing pack, got {key}",
2666 );
2667 }
2668 other => panic!("expected fail-fast PackMissing, got {other:?}"),
2669 }
2670 assert_eq!(store.chain_calls(), 1);
2673 }
2674
2675 #[tokio::test(start_paused = true)]
2680 async fn read_with_pack_missing_retries_surfaces_exhausted_after_max_retries() {
2681 let inner = MockStore::new();
2685 let cache = PackIndexCache::default();
2686
2687 let blob_oid = sha40_to_object_id(&sha40(SHA_C));
2688
2689 let pack_shas = [
2699 "0000000000000000000000000000000000000000",
2700 "1111111111111111111111111111111111111111",
2701 "2222222222222222222222222222222222222222",
2702 "3333333333333333333333333333333333333333",
2703 "4444444444444444444444444444444444444444",
2704 ];
2705 let chain_key = chain_key(None, "refs/heads/main");
2706 let v1 = chain_json_bytes(SHA_A, pack_shas[0]);
2707 let reload_bodies: Vec<Bytes> = pack_shas[1..]
2708 .iter()
2709 .map(|sha| chain_json_bytes(SHA_A, sha))
2710 .collect();
2711 let initial = ChainManifest::from_json_bytes(&v1).expect("chain v1 parses");
2712 let store = EvolvingChainStore::new(inner, chain_key, reload_bodies);
2713 let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2714
2715 let err = read_with_pack_missing_retries(
2716 &store,
2717 None,
2718 &remote_ref,
2719 "refs/heads/main",
2720 initial,
2721 &blob_oid,
2722 &cache,
2723 )
2724 .await
2725 .expect_err("exhausted retries must error");
2726 match err {
2727 PackchainError::ConcurrentGcRetriesExhausted {
2728 last_missing_key,
2729 attempts,
2730 } => {
2731 assert_eq!(attempts, PACK_MISSING_MAX_RETRIES);
2735 assert!(
2736 last_missing_key.contains(pack_shas[3]),
2737 "last missing key should name pack[3], got {last_missing_key}"
2738 );
2739 }
2740 other => panic!("expected ConcurrentGcRetriesExhausted, got {other:?}"),
2741 }
2742 assert_eq!(
2747 store.chain_calls(),
2748 usize::try_from(PACK_MISSING_MAX_RETRIES + 1).unwrap()
2749 );
2750 }
2751
2752 #[tokio::test]
2756 async fn read_with_pack_missing_retries_does_not_retry_on_non_pack_missing_errors() {
2757 let inner = MockStore::new();
2758 let cache = PackIndexCache::default();
2759
2760 let p1_sha = sha40(SHA_A);
2764 inner.insert(
2765 pack_idx_key(None, &p1_sha),
2766 Bytes::from_static(b"not a real idx"),
2767 );
2768
2769 let chain_key = chain_key(None, "refs/heads/main");
2770 let body = chain_json_bytes(SHA_A, p1_sha.as_str());
2771 let store = EvolvingChainStore::new(inner, chain_key, vec![body.clone()]);
2772 let initial = ChainManifest::from_json_bytes(&body).expect("chain parses");
2773 let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2774 let blob_oid = sha40_to_object_id(&sha40(SHA_C));
2775
2776 let err = read_with_pack_missing_retries(
2777 &store,
2778 None,
2779 &remote_ref,
2780 "refs/heads/main",
2781 initial,
2782 &blob_oid,
2783 &cache,
2784 )
2785 .await
2786 .expect_err("malformed idx must surface immediately");
2787 assert!(
2788 matches!(err, PackchainError::MalformedPackEntry { .. }),
2789 "expected MalformedPackEntry passthrough, got {err:?}"
2790 );
2791 assert_eq!(store.chain_calls(), 0);
2794 }
2795
2796 #[tokio::test]
2808 async fn read_with_pack_missing_retries_surfaces_chain_reload_error() {
2809 use crate::object_store::mock::Fault;
2810
2811 let store = MockStore::new();
2812 let cache = PackIndexCache::default();
2813
2814 let p1_sha = sha40(SHA_A);
2815 let blob_oid = sha40_to_object_id(&sha40(SHA_C));
2816
2817 let chain_key_str = chain_key(None, "refs/heads/main");
2821 let body = chain_json_bytes(SHA_A, p1_sha.as_str());
2822 let initial = ChainManifest::from_json_bytes(&body).expect("chain v1 parses");
2823 let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2824
2825 store.arm(Fault::NetworkOnGetBytes { key: chain_key_str });
2829
2830 let err = read_with_pack_missing_retries(
2831 &store,
2832 None,
2833 &remote_ref,
2834 "refs/heads/main",
2835 initial,
2836 &blob_oid,
2837 &cache,
2838 )
2839 .await
2840 .expect_err("chain reload failure must surface as an error");
2841
2842 assert!(
2843 matches!(err, PackchainError::Store(_)),
2844 "expected PackchainError::Store wrapping the chain-reload transport error; \
2845 a regression that swallowed the reload error would yield \
2846 ConcurrentGcRetriesExhausted or the original PackMissing instead. got {err:?}"
2847 );
2848 assert_eq!(
2850 store.pending_faults(),
2851 0,
2852 "armed chain-reload fault must have fired exactly once"
2853 );
2854 }
2855
2856 #[test]
2869 fn verify_content_hash_accepts_matching_blob() {
2870 let payload = b"the quick brown fox";
2871 let oid = sha40_to_object_id(&blob_oid_for(payload));
2872 let resolved = ResolvedObject {
2873 payload: payload.to_vec(),
2874 kind: ObjectKind::Blob,
2875 };
2876 verify_content_hash(&oid, &resolved).expect("matching content must verify");
2877 }
2878
2879 #[test]
2884 fn verify_content_hash_accepts_empty_blob() {
2885 let payload = b"";
2886 let oid = sha40_to_object_id(&blob_oid_for(payload));
2887 assert_eq!(
2888 oid.to_string(),
2889 "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
2890 "empty blob must hash to git's canonical empty-blob OID",
2891 );
2892 let resolved = ResolvedObject {
2893 payload: payload.to_vec(),
2894 kind: ObjectKind::Blob,
2895 };
2896 verify_content_hash(&oid, &resolved).expect("empty blob must verify");
2897 }
2898
2899 #[test]
2903 fn verify_content_hash_rejects_mismatched_content() {
2904 let expected_oid = sha40_to_object_id(&blob_oid_for(b"intended content"));
2906 let resolved = ResolvedObject {
2907 payload: b"tampered content".to_vec(),
2908 kind: ObjectKind::Blob,
2909 };
2910 let err = verify_content_hash(&expected_oid, &resolved)
2911 .expect_err("mismatched content must be rejected");
2912 let PackchainError::ContentHashMismatch { expected, actual } = err else {
2913 panic!("expected ContentHashMismatch, got {err:?}");
2914 };
2915 assert_eq!(expected, expected_oid.to_string());
2916 let actual_oid = sha40_to_object_id(&blob_oid_for(b"tampered content"));
2917 assert_eq!(actual, actual_oid.to_string());
2918 assert_ne!(expected, actual);
2919 }
2920
2921 #[tokio::test]
2927 async fn read_object_from_chain_rejects_idx_mapped_wrong_content() {
2928 let inner = MockStore::new();
2929 let cache = PackIndexCache::default();
2930 let pack_sha = sha40(SHA_A);
2931
2932 let actual_payload = b"actual stored bytes";
2934 let mut pack = Vec::new();
2935 let mut offsets = Vec::new();
2936 push_pack_entry(
2937 &mut pack,
2938 &mut offsets,
2939 3, None,
2941 actual_payload,
2942 );
2943 inner.insert(pack_key(None, &pack_sha), Bytes::from(pack));
2944
2945 let lie_oid_sha = blob_oid_for(b"what the caller wanted");
2950 let idx_bytes = build_one_object_v2_idx(&lie_oid_sha, 0);
2951 inner.insert(pack_idx_key(None, &pack_sha), Bytes::from(idx_bytes));
2952
2953 let chain = make_chain_with(SHA_A, pack_sha.as_str());
2954 let target_oid = sha40_to_object_id(&lie_oid_sha);
2955 let mut depth = 0u32;
2956 let err = read_object_from_chain(
2957 &inner,
2958 None,
2959 &chain.segments,
2960 &target_oid,
2961 &cache,
2962 &mut depth,
2963 )
2964 .await
2965 .expect_err("idx-mapped wrong content must be rejected");
2966 let PackchainError::ContentHashMismatch { expected, actual } = err else {
2967 panic!("expected ContentHashMismatch, got {err:?}");
2968 };
2969 assert_eq!(
2970 expected,
2971 target_oid.to_string(),
2972 "expected OID must be the caller's requested OID",
2973 );
2974 let actual_oid = sha40_to_object_id(&blob_oid_for(actual_payload));
2975 assert_eq!(
2976 actual,
2977 actual_oid.to_string(),
2978 "actual OID must be the hash of the bytes really stored",
2979 );
2980 }
2981
2982 #[tokio::test]
2987 async fn read_object_from_chain_returns_matching_content() {
2988 let inner = MockStore::new();
2989 let cache = PackIndexCache::default();
2990 let pack_sha = sha40(SHA_A);
2991
2992 let payload = b"honest stored bytes";
2993 let mut pack = Vec::new();
2994 let mut offsets = Vec::new();
2995 push_pack_entry(&mut pack, &mut offsets, 3 , None, payload);
2996 inner.insert(pack_key(None, &pack_sha), Bytes::from(pack));
2997
2998 let oid_sha = blob_oid_for(payload);
2999 let idx_bytes = build_one_object_v2_idx(&oid_sha, 0);
3000 inner.insert(pack_idx_key(None, &pack_sha), Bytes::from(idx_bytes));
3001
3002 let chain = make_chain_with(SHA_A, pack_sha.as_str());
3003 let target_oid = sha40_to_object_id(&oid_sha);
3004 let mut depth = 0u32;
3005 let resolved = read_object_from_chain(
3006 &inner,
3007 None,
3008 &chain.segments,
3009 &target_oid,
3010 &cache,
3011 &mut depth,
3012 )
3013 .await
3014 .expect("matching content must resolve");
3015 assert_eq!(resolved.payload, payload);
3016 assert_eq!(resolved.kind, ObjectKind::Blob);
3017 }
3018}