Skip to main content

grit_lib/
unpack_objects.rs

1//! `unpack-objects`: unpack a pack stream into individual objects.
2//!
3//! Reads a pack-format byte stream, validates the trailing checksum, and
4//! writes each object to the object database ([`unpack_objects`]) or to any
5//! [`WritableObjectStore`] ([`ingest_pack_into`]).  Delta objects (both
6//! `OFS_DELTA` and `REF_DELTA`) are resolved against already-unpacked objects
7//! or objects already present in the target.
8//!
9//! Large blobs stream to the ODB without retaining payload bytes; delta bases use a
10//! bounded byte LRU so multi-object unpack stays flat in memory (streaming read).
11
12use std::borrow::Cow;
13use std::collections::{HashMap, HashSet};
14use std::io::{self, Read};
15
16use crate::error::{Error, Result};
17use crate::gitmodules::{self, DotFsckTracker};
18use crate::hash::{ChecksumHasher, ObjectPayloadHasher};
19use crate::index::MODE_GITLINK;
20use crate::index_pack::ObjectKindCounts;
21use crate::objects::{parse_commit, parse_tag, parse_tree, HashAlgo, Object, ObjectId, ObjectKind};
22use crate::odb::store::{ObjectStore, WritableObjectStore};
23use crate::odb::{Odb, WriteOptions};
24use crate::progress::{poll_cancel, Cancel, NullProgress, ProgressPhase, ProgressSink};
25use flate2::read::ZlibDecoder;
26use flate2::{Decompress, FlushDecompress, Status};
27
28/// Options controlling `unpack-objects` behaviour.
29#[derive(Debug, Default)]
30pub struct UnpackOptions {
31    /// Validate and decompress objects but do not write them to the ODB.
32    pub dry_run: bool,
33    /// Suppress informational output.
34    pub quiet: bool,
35    /// Reject packs whose commits/trees/tags reference missing objects.
36    pub strict: bool,
37    /// Object IDs that strict connectivity may treat as promised by a configured promisor remote.
38    pub allowed_missing: HashSet<ObjectId>,
39    /// Whether strict connectivity may tolerate references to missing objects in a promisor repo.
40    pub allow_promisor_missing_references: bool,
41    /// Maximum number of raw pack bytes that may be consumed (including the 20-byte trailer).
42    ///
43    /// Matches Git's `unpack-objects --max-input-size` / `receive.maxInputSize`: counts every
44    /// byte read from the pack stream after crossing the limit. `None` means no limit.
45    pub max_input_bytes: Option<u64>,
46    /// Commit OIDs that are shallow boundaries (grafts): their parents are intentionally absent and
47    /// must not be required during the `--strict` connectivity walk.
48    ///
49    /// Mirrors `unpack-objects --shallow-file <file>` in upstream `receive-pack`, where the shallow
50    /// file lists the commits whose parent objects were deliberately not transferred.
51    pub shallow_boundaries: HashSet<ObjectId>,
52}
53
54/// A delta that could not yet be resolved because its base was not yet known.
55struct PendingDelta {
56    /// Byte offset of this object in the pack stream (used to anchor
57    /// `OFS_DELTA` back-references from later objects).
58    offset: usize,
59    /// For `REF_DELTA`: SHA-1 of the base object.
60    base_oid: Option<ObjectId>,
61    /// For `OFS_DELTA`: absolute byte offset of the base object.
62    base_offset: Option<usize>,
63    /// Decompressed delta data.
64    delta_data: Vec<u8>,
65}
66
67/// Default cap on unpacked delta-base bytes retained in RAM (`core.deltaBaseCacheLimit`).
68const UNPACK_DELTA_BASE_BYTE_LIMIT: usize = 96 * 1024 * 1024;
69
70/// Byte-bounded LRU of recently resolved object payloads keyed by pack offset (delta bases only).
71struct UnpackBaseByteCache {
72    byte_limit: usize,
73    bytes_used: usize,
74    entries: HashMap<usize, (ObjectKind, Vec<u8>)>,
75    head: Option<usize>,
76    tail: Option<usize>,
77    links: HashMap<usize, (Option<usize>, Option<usize>)>,
78}
79
80impl UnpackBaseByteCache {
81    fn new(byte_limit: usize) -> Self {
82        Self {
83            byte_limit,
84            bytes_used: 0,
85            entries: HashMap::new(),
86            head: None,
87            tail: None,
88            links: HashMap::new(),
89        }
90    }
91
92    fn unlink(&mut self, key: usize) {
93        let Some((prev, next)) = self.links.remove(&key) else {
94            return;
95        };
96        match prev {
97            Some(p) => {
98                if let Some(link) = self.links.get_mut(&p) {
99                    link.1 = next;
100                }
101            }
102            None => self.head = next,
103        }
104        match next {
105            Some(n) => {
106                if let Some(link) = self.links.get_mut(&n) {
107                    link.0 = prev;
108                }
109            }
110            None => self.tail = prev,
111        }
112    }
113
114    fn append_tail(&mut self, key: usize) {
115        let prev = self.tail;
116        self.links.insert(key, (prev, None));
117        if let Some(p) = prev {
118            if let Some(link) = self.links.get_mut(&p) {
119                link.1 = Some(key);
120            }
121        } else {
122            self.head = Some(key);
123        }
124        self.tail = Some(key);
125    }
126
127    fn touch(&mut self, key: usize) {
128        self.unlink(key);
129        self.append_tail(key);
130    }
131
132    fn evict_key(&mut self, key: usize) {
133        self.unlink(key);
134        if let Some((_, data)) = self.entries.remove(&key) {
135            self.bytes_used = self.bytes_used.saturating_sub(data.len());
136        }
137    }
138
139    fn get(&mut self, offset: usize) -> Option<(ObjectKind, Vec<u8>)> {
140        let (kind, data) = self.entries.get(&offset).map(|(k, d)| (*k, d.clone()))?;
141        self.touch(offset);
142        Some((kind, data))
143    }
144
145    fn put(&mut self, offset: usize, kind: ObjectKind, data: Vec<u8>) {
146        if self.byte_limit == 0 {
147            return;
148        }
149        let sz = data.len();
150        if sz > self.byte_limit {
151            return;
152        }
153        use std::collections::hash_map::Entry;
154        match self.entries.entry(offset) {
155            Entry::Occupied(mut slot) => {
156                let old_len = slot.get().1.len();
157                slot.insert((kind, data));
158                self.bytes_used = self.bytes_used.saturating_sub(old_len).saturating_add(sz);
159                self.touch(offset);
160            }
161            Entry::Vacant(slot) => {
162                slot.insert((kind, data));
163                self.bytes_used = self.bytes_used.saturating_add(sz);
164                self.append_tail(offset);
165            }
166        }
167        while self.bytes_used > self.byte_limit {
168            let Some(evict) = self.head else {
169                break;
170            };
171            self.evict_key(evict);
172        }
173        test_note_retained_bytes(self.bytes_used);
174    }
175
176    fn bytes_used(&self) -> usize {
177        self.bytes_used
178    }
179}
180
181#[cfg(test)]
182static TEST_UNPACK_CACHE_BYTE_LIMIT: std::sync::atomic::AtomicUsize =
183    std::sync::atomic::AtomicUsize::new(0);
184#[cfg(test)]
185static TEST_UNPACK_RETAINED_PEAK: std::sync::atomic::AtomicUsize =
186    std::sync::atomic::AtomicUsize::new(0);
187#[cfg(test)]
188static TEST_UNPACK_RETAIN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
189
190fn unpack_base_cache_byte_limit() -> usize {
191    #[cfg(test)]
192    {
193        let override_limit =
194            TEST_UNPACK_CACHE_BYTE_LIMIT.load(std::sync::atomic::Ordering::Relaxed);
195        if override_limit != 0 {
196            return override_limit;
197        }
198    }
199    UNPACK_DELTA_BASE_BYTE_LIMIT
200}
201
202#[cfg(test)]
203pub(crate) fn test_set_unpack_base_cache_byte_limit(bytes: usize) {
204    TEST_UNPACK_CACHE_BYTE_LIMIT.store(bytes, std::sync::atomic::Ordering::Relaxed);
205    TEST_UNPACK_RETAINED_PEAK.store(0, std::sync::atomic::Ordering::Relaxed);
206}
207
208#[cfg(test)]
209pub(crate) fn test_peak_unpack_retained_bytes() -> usize {
210    TEST_UNPACK_RETAINED_PEAK.load(std::sync::atomic::Ordering::Relaxed)
211}
212
213fn test_note_retained_bytes(bytes: usize) {
214    #[cfg(test)]
215    {
216        let mut cur = TEST_UNPACK_RETAINED_PEAK.load(std::sync::atomic::Ordering::Relaxed);
217        while cur < bytes {
218            match TEST_UNPACK_RETAINED_PEAK.compare_exchange_weak(
219                cur,
220                bytes,
221                std::sync::atomic::Ordering::Relaxed,
222                std::sync::atomic::Ordering::Relaxed,
223            ) {
224                Ok(_) => break,
225                Err(v) => cur = v,
226            }
227        }
228    }
229    #[cfg(not(test))]
230    let _ = bytes;
231}
232
233fn test_note_pending_delta_bytes(pending: &[PendingDelta], cache_bytes: usize) {
234    #[cfg(test)]
235    {
236        let pending_bytes: usize = pending.iter().map(|d| d.delta_data.len()).sum();
237        test_note_retained_bytes(cache_bytes.saturating_add(pending_bytes));
238    }
239    let _ = (pending, cache_bytes);
240}
241
242/// Objects a pack stream delivered into an object store, from [`ingest_pack_into`].
243#[derive(Debug, Clone, Default)]
244pub struct IngestedObjects {
245    /// Id of every object the pack held (delta objects by their resolved id).
246    pub object_ids: HashSet<ObjectId>,
247    /// How many objects of each type the pack held.
248    pub object_counts: ObjectKindCounts,
249    /// Bytes of pack stream consumed, trailer included.
250    pub pack_bytes: u64,
251}
252
253/// Where [`unpack_into`] stores resolved objects and finds objects the pack did not carry.
254trait UnpackSink {
255    fn hash_algo(&self) -> HashAlgo;
256
257    /// Store `data`, or only hash it when `dry_run` is set.
258    fn write(&self, kind: ObjectKind, data: &[u8], dry_run: bool) -> Result<ObjectId>;
259
260    /// When true, [`Self::write_inflated_from_pack`] streams large inflated payloads.
261    fn supports_streaming_inflate(&self) -> bool {
262        false
263    }
264
265    /// Inflate one pack object from `rd` and store it without holding the full payload in RAM.
266    fn write_inflated_from_pack(
267        &self,
268        kind: ObjectKind,
269        size: usize,
270        rd: &mut StreamingPackReader<'_>,
271        dry_run: bool,
272    ) -> Result<ObjectId> {
273        let _ = (kind, size, rd, dry_run);
274        Err(Error::UnsupportedObjectStore {
275            operation: "write_inflated_from_pack",
276        })
277    }
278
279    /// Read an object stored before or during this unpack (thin-pack bases, large blobs).
280    fn read_stored(&self, oid: &ObjectId) -> Result<Option<Object>>;
281
282    /// Whether `oid` is already stored (strict connectivity).
283    fn contains(&self, oid: &ObjectId) -> Result<bool>;
284}
285
286impl UnpackSink for Odb {
287    fn hash_algo(&self) -> HashAlgo {
288        Odb::hash_algo(self)
289    }
290
291    fn write(&self, kind: ObjectKind, data: &[u8], dry_run: bool) -> Result<ObjectId> {
292        if dry_run {
293            return self.hash(kind, data);
294        }
295        if !self.uses_files_primary() {
296            return Odb::write(self, kind, data);
297        }
298        // Always materialize into this ODB: objects reachable only via alternates must still be
299        // written locally (matches git unpack-objects; t5519-push-alternates).
300        self.write_local(kind, data)
301    }
302
303    fn read_stored(&self, oid: &ObjectId) -> Result<Option<Object>> {
304        match self.read(oid) {
305            Ok(obj) => Ok(Some(obj)),
306            Err(Error::ObjectNotFound(_)) => Ok(None),
307            Err(e) => Err(e),
308        }
309    }
310
311    fn contains(&self, oid: &ObjectId) -> Result<bool> {
312        Odb::contains(self, oid)
313    }
314
315    fn supports_streaming_inflate(&self) -> bool {
316        self.uses_files_primary()
317    }
318
319    fn write_inflated_from_pack(
320        &self,
321        kind: ObjectKind,
322        size: usize,
323        rd: &mut StreamingPackReader<'_>,
324        dry_run: bool,
325    ) -> Result<ObjectId> {
326        let size_u64 = u64::try_from(size).map_err(|_| {
327            Error::CorruptObject(format!("object size {size} exceeds address space"))
328        })?;
329        if dry_run {
330            let mut hasher = ObjectPayloadHasher::new(self.hash_algo(), kind, size_u64);
331            rd.decompress_into(size, |chunk| {
332                hasher.update(chunk);
333                Ok(())
334            })?;
335            return hasher.finish();
336        }
337        if !self.uses_files_primary() {
338            let data = rd.decompress(size)?;
339            return self.write_local(kind, &data);
340        }
341        self.write_local_stream_with(kind, size_u64, |chunk_sink| {
342            rd.decompress_into(size, chunk_sink)
343        })
344    }
345}
346
347/// A [`WritableObjectStore`] target with a separate view for objects it already holds.
348struct StoreSink<'a> {
349    store: &'a dyn WritableObjectStore,
350    bases: &'a dyn ObjectStore,
351}
352
353impl UnpackSink for StoreSink<'_> {
354    fn hash_algo(&self) -> HashAlgo {
355        self.store.hash_algo()
356    }
357
358    fn write(&self, kind: ObjectKind, data: &[u8], dry_run: bool) -> Result<ObjectId> {
359        if dry_run {
360            return crate::hash::hash_object(self.store.hash_algo(), kind, data);
361        }
362        self.store.write(kind, data, WriteOptions::default())
363    }
364
365    fn read_stored(&self, oid: &ObjectId) -> Result<Option<Object>> {
366        if let Some(obj) = self.store.read(oid)? {
367            return Ok(Some(obj));
368        }
369        self.bases.read(oid)
370    }
371
372    fn contains(&self, oid: &ObjectId) -> Result<bool> {
373        Ok(self.store.contains(oid)? || self.bases.contains(oid)?)
374    }
375}
376
377/// Unpack a pack stream from `reader` into `odb`.
378///
379/// Reads the complete pack from `reader`, validates the trailing SHA-1
380/// checksum, unpacks all objects (including full delta-chain resolution), and —
381/// unless [`UnpackOptions::dry_run`] is set — writes each object to `odb`.
382///
383/// With a files-backed primary every object is written as a loose object in this
384/// object directory, even when an alternate already has it. With a custom
385/// [`OdbBuilder::primary`](crate::odb::OdbBuilder::primary) objects go to that store
386/// through [`Odb::write`].
387///
388/// Returns the total number of objects processed.
389///
390/// # Errors
391///
392/// - [`Error::CorruptObject`] — invalid pack format or checksum mismatch.
393/// - [`Error::UnresolvedDeltas`] — deltas whose bases are in neither the pack nor `odb`.
394/// - [`Error::Io`] — I/O failure reading from `reader`.
395/// - [`Error::Zlib`] — decompression failure.
396pub fn unpack_objects(reader: &mut dyn Read, odb: &Odb, opts: &UnpackOptions) -> Result<usize> {
397    unpack_into(reader, odb, opts, None, None, None).map(|unpacked| unpacked.count)
398}
399
400/// Asks whether a reference the pack does not resolve is promised.
401///
402/// Called with the missing object and the object naming it; `Ok(true)` lets the
403/// strict check pass it.
404pub(crate) type PromisedReference<'a> = &'a dyn Fn(&ObjectId, &ObjectId) -> Result<bool>;
405
406/// [`unpack_objects`] for a repository with promisor remotes: under
407/// [`UnpackOptions::strict`], a reference that resolves neither in the pack nor in
408/// `odb` passes when `promised` says a promisor remote has it, as Git's
409/// connectivity check skips promisor objects.
410///
411/// # Errors
412///
413/// As [`unpack_objects`], plus whatever `promised` returns.
414pub(crate) fn unpack_objects_with_promises(
415    reader: &mut dyn Read,
416    odb: &Odb,
417    opts: &UnpackOptions,
418    promised: PromisedReference<'_>,
419) -> Result<usize> {
420    unpack_into(reader, odb, opts, None, None, Some(promised)).map(|unpacked| unpacked.count)
421}
422
423/// Apply a pack stream to any [`WritableObjectStore`], resolving every delta.
424///
425/// This is how a pack received by fetch, clone, push or a bundle reaches a custom
426/// backend (SQLite, key-value, remote blob storage): each object is inflated, its
427/// `OFS_DELTA`/`REF_DELTA` chain resolved, and the result stored with
428/// [`WritableObjectStore::write`]. No `objects/` directory is involved.
429///
430/// - `reader` yields the raw pack (`PACK` header through the trailing checksum). It is
431///   read in small pieces, so wrap files and sockets in a [`std::io::BufReader`].
432/// - `store` receives every object in the pack.
433/// - `bases` resolves `REF_DELTA` bases a *thin* pack leaves out, and answers
434///   [`UnpackOptions::strict`] connectivity checks. `store` is consulted first, so pass
435///   `store` again when it is the only source, or a repository-wide view (a repository's
436///   [`Odb::read_store`], or a [`CompositeStore`](crate::odb::store::CompositeStore)) when
437///   bases may live elsewhere.
438///
439/// The trailing checksum is verified before any delta is applied. Objects without a
440/// delta are written as they are read, so a failed ingest can leave some objects in
441/// `store`; Git stores tolerate unreferenced objects and later garbage collection
442/// removes them. Delta bases are bounded by a byte LRU (default 96 MiB, like
443/// `core.deltaBaseCacheLimit`); larger blobs and strict connectivity re-read from `store` on demand.
444///
445/// Only [`UnpackOptions::dry_run`], [`UnpackOptions::strict`] and its allowances, and
446/// [`UnpackOptions::max_input_bytes`] apply; [`UnpackOptions::quiet`] has no effect.
447///
448/// # Examples
449///
450/// ```
451/// use std::io::Cursor;
452/// use std::sync::Arc;
453///
454/// use grit_lib::objects::odb::store::{MemoryStore, ObjectStore};
455/// use grit_lib::objects::odb::OdbBuilder;
456/// use grit_lib::objects::pack_objects::{build_pack, PackBuildOptions};
457/// use grit_lib::objects::unpack_objects::{ingest_pack_into, UnpackOptions};
458/// use grit_lib::objects::{HashAlgo, ObjectKind};
459///
460/// # fn main() -> grit_lib::error::Result<()> {
461/// // Any object database can produce the pack; here another in-memory store.
462/// let source = OdbBuilder::files("/nonexistent/objects")
463///     .primary(Arc::new(MemoryStore::new(HashAlgo::Sha1)))
464///     .alternates(false)
465///     .build();
466/// let blob = source.write(ObjectKind::Blob, b"hello\n")?;
467/// let pack = build_pack(&source, &[blob], &[], &PackBuildOptions::default())?;
468///
469/// let target = MemoryStore::new(HashAlgo::Sha1);
470/// let ingested = ingest_pack_into(
471///     &mut Cursor::new(pack),
472///     &target,
473///     &target,
474///     &UnpackOptions::default(),
475/// )?;
476/// assert!(ingested.object_ids.contains(&blob));
477/// assert!(target.contains(&blob)?);
478/// # Ok(())
479/// # }
480/// ```
481///
482/// # Errors
483///
484/// - [`Error::CorruptObject`] — invalid pack format, checksum mismatch, or (with
485///   [`UnpackOptions::strict`]) a reference that resolves nowhere.
486/// - [`Error::UnresolvedDeltas`] — deltas whose bases are in neither the pack nor `bases`.
487/// - [`Error::Io`] — failure reading from `reader`.
488/// - [`Error::Zlib`] — decompression failure.
489/// - Any error `store` or `bases` return.
490pub fn ingest_pack_into(
491    reader: &mut dyn Read,
492    store: &dyn WritableObjectStore,
493    bases: &dyn ObjectStore,
494    opts: &UnpackOptions,
495) -> Result<IngestedObjects> {
496    ingest_pack_into_with_progress(reader, store, bases, opts, None, None)
497}
498
499/// Like [`ingest_pack_into`], reporting [`ProgressPhase::IndexingObjects`] and
500/// [`ProgressPhase::ResolvingDeltas`] on `progress` and stopping with
501/// [`Error::Cancelled`] once `cancel` fires.
502///
503/// # Errors
504///
505/// Same as [`ingest_pack_into`], plus [`Error::Cancelled`].
506pub fn ingest_pack_into_with_progress(
507    reader: &mut dyn Read,
508    store: &dyn WritableObjectStore,
509    bases: &dyn ObjectStore,
510    opts: &UnpackOptions,
511    progress: Option<&mut dyn ProgressSink>,
512    cancel: Option<&dyn Cancel>,
513) -> Result<IngestedObjects> {
514    unpack_into(
515        reader,
516        &StoreSink { store, bases },
517        opts,
518        progress,
519        cancel,
520        None,
521    )
522    .map(Unpacked::into_ingested)
523}
524
525/// Like [`ingest_pack_into_with_progress`], writing into `odb` the way [`unpack_objects`]
526/// does (loose objects for a files primary, the custom primary otherwise).
527pub(crate) fn ingest_pack_into_odb(
528    reader: &mut dyn Read,
529    odb: &Odb,
530    opts: &UnpackOptions,
531    progress: Option<&mut dyn ProgressSink>,
532    cancel: Option<&dyn Cancel>,
533) -> Result<IngestedObjects> {
534    unpack_into(reader, odb, opts, progress, cancel, None).map(Unpacked::into_ingested)
535}
536
537/// Everything [`unpack_into`] resolved.
538struct Unpacked {
539    count: usize,
540    by_oid: HashMap<ObjectId, PackedObjectEntry>,
541    object_counts: ObjectKindCounts,
542    pack_bytes: u64,
543}
544
545impl Unpacked {
546    fn into_ingested(self) -> IngestedObjects {
547        IngestedObjects {
548            object_ids: self.by_oid.into_keys().collect(),
549            object_counts: self.object_counts,
550            pack_bytes: self.pack_bytes,
551        }
552    }
553}
554
555fn poll_unpack_cancel(
556    progress: &mut Option<&mut dyn ProgressSink>,
557    cancel: Option<&dyn Cancel>,
558) -> Result<()> {
559    let Some(cancel) = cancel else {
560        return Ok(());
561    };
562    match progress {
563        Some(p) => poll_cancel(&mut **p, cancel),
564        None => poll_cancel(&mut NullProgress, cancel),
565    }
566}
567
568fn unpack_into<S: UnpackSink + ?Sized>(
569    reader: &mut dyn Read,
570    sink: &S,
571    opts: &UnpackOptions,
572    mut progress: Option<&mut dyn ProgressSink>,
573    cancel: Option<&dyn Cancel>,
574    promised: Option<PromisedReference<'_>>,
575) -> Result<Unpacked> {
576    /// Blobs larger than this may stream straight to disk without caching payload bytes.
577    const MAX_STREAM_BLOB_BYTES: usize = 1024 * 1024;
578
579    let algo = sink.hash_algo();
580    let mut rd = StreamingPackReader::new(reader, opts.max_input_bytes, algo);
581    let mut base_cache = UnpackBaseByteCache::new(unpack_base_cache_byte_limit());
582
583    // Validate magic and version.
584    let sig = rd.read_exact_n(4)?;
585    if sig != b"PACK" {
586        return Err(Error::CorruptObject(
587            "not a pack stream: invalid signature".to_owned(),
588        ));
589    }
590    let version = rd.read_u32_be()?;
591    if version != 2 && version != 3 {
592        return Err(Error::CorruptObject(format!(
593            "unsupported pack version {version}"
594        )));
595    }
596    let nr_objects = rd.read_u32_be()? as usize;
597
598    let mut by_offset: HashMap<usize, PackedObjectEntry> = HashMap::new();
599    let mut by_oid: HashMap<ObjectId, PackedObjectEntry> = HashMap::new();
600
601    let mut pending: Vec<PendingDelta> = Vec::new();
602    let mut count = 0usize;
603    let mut object_counts = ObjectKindCounts::default();
604
605    if let Some(p) = progress.as_mut() {
606        p.start(ProgressPhase::IndexingObjects, Some(nr_objects as u64));
607    }
608    for _ in 0..nr_objects {
609        poll_unpack_cancel(&mut progress, cancel)?;
610        let obj_offset = rd.stream_pos();
611        let (type_code, size) = rd.read_type_size()?;
612
613        match type_code {
614            1..=4 => {
615                let kind = type_code_to_kind(type_code)?;
616                if !opts.dry_run
617                    && size > MAX_STREAM_BLOB_BYTES
618                    && kind == ObjectKind::Blob
619                    && sink.supports_streaming_inflate()
620                {
621                    let oid = sink.write_inflated_from_pack(kind, size, &mut rd, false)?;
622                    let entry = PackedObjectEntry::Stored { kind, oid };
623                    by_offset.insert(obj_offset, entry.clone());
624                    by_oid.insert(oid, entry);
625                } else {
626                    let data = rd.decompress(size)?;
627                    let oid = sink.write(kind, &data, opts.dry_run)?;
628                    record_resolved_object(
629                        &mut by_offset,
630                        &mut by_oid,
631                        obj_offset,
632                        oid,
633                        kind,
634                        data,
635                        opts,
636                        MAX_STREAM_BLOB_BYTES,
637                        &mut base_cache,
638                    )?;
639                }
640                object_counts.add(kind);
641                count += 1;
642            }
643            6 => {
644                let neg = rd.read_ofs_neg_offset()?;
645                let base_offset = obj_offset.checked_sub(neg).ok_or_else(|| {
646                    Error::CorruptObject("ofs-delta base offset underflow".to_owned())
647                })?;
648                let delta_data = rd.decompress(size)?;
649                let delta = PendingDelta {
650                    offset: obj_offset,
651                    base_oid: None,
652                    base_offset: Some(base_offset),
653                    delta_data,
654                };
655                test_note_pending_delta_bytes(&pending, base_cache.bytes_used());
656                if let Some((oid, kind, data)) = resolve_pending_delta(
657                    &delta,
658                    &by_offset,
659                    &by_oid,
660                    sink,
661                    opts,
662                    MAX_STREAM_BLOB_BYTES,
663                    &mut base_cache,
664                )? {
665                    record_resolved_object(
666                        &mut by_offset,
667                        &mut by_oid,
668                        delta.offset,
669                        oid,
670                        kind,
671                        data,
672                        opts,
673                        MAX_STREAM_BLOB_BYTES,
674                        &mut base_cache,
675                    )?;
676                    object_counts.add(kind);
677                    count += 1;
678                } else {
679                    pending.push(delta);
680                }
681            }
682            7 => {
683                let base_bytes = rd.read_exact_n(algo.len())?;
684                let base_oid = ObjectId::from_bytes(&base_bytes)?;
685                let delta_data = rd.decompress(size)?;
686                let delta = PendingDelta {
687                    offset: obj_offset,
688                    base_oid: Some(base_oid),
689                    base_offset: None,
690                    delta_data,
691                };
692                test_note_pending_delta_bytes(&pending, base_cache.bytes_used());
693                if let Some((oid, kind, data)) = resolve_pending_delta(
694                    &delta,
695                    &by_offset,
696                    &by_oid,
697                    sink,
698                    opts,
699                    MAX_STREAM_BLOB_BYTES,
700                    &mut base_cache,
701                )? {
702                    record_resolved_object(
703                        &mut by_offset,
704                        &mut by_oid,
705                        delta.offset,
706                        oid,
707                        kind,
708                        data,
709                        opts,
710                        MAX_STREAM_BLOB_BYTES,
711                        &mut base_cache,
712                    )?;
713                    object_counts.add(kind);
714                    count += 1;
715                } else {
716                    pending.push(delta);
717                }
718            }
719            other => {
720                return Err(Error::CorruptObject(format!(
721                    "unknown packed-object type {other}"
722                )))
723            }
724        }
725        if let Some(p) = progress.as_mut() {
726            p.inc(1);
727        }
728    }
729    if let Some(p) = progress.as_mut() {
730        p.finish();
731    }
732
733    let digest = rd.finalize_hasher();
734    let trailing = rd.read_trailer()?;
735    if digest != trailing {
736        return Err(Error::CorruptObject(
737            "pack trailing checksum mismatch".to_owned(),
738        ));
739    }
740    let pack_bytes = rd.stream_pos() as u64;
741
742    let mut remaining = pending;
743    if let (Some(p), false) = (progress.as_mut(), remaining.is_empty()) {
744        p.start(ProgressPhase::ResolvingDeltas, Some(remaining.len() as u64));
745    }
746    let resolving = !remaining.is_empty();
747    loop {
748        if remaining.is_empty() {
749            break;
750        }
751        poll_unpack_cancel(&mut progress, cancel)?;
752        test_note_pending_delta_bytes(&remaining, base_cache.bytes_used());
753        let before = remaining.len();
754        let mut still_pending: Vec<PendingDelta> = Vec::new();
755
756        for delta in remaining {
757            if let Some((oid, kind, data)) = resolve_pending_delta(
758                &delta,
759                &by_offset,
760                &by_oid,
761                sink,
762                opts,
763                MAX_STREAM_BLOB_BYTES,
764                &mut base_cache,
765            )? {
766                record_resolved_object(
767                    &mut by_offset,
768                    &mut by_oid,
769                    delta.offset,
770                    oid,
771                    kind,
772                    data,
773                    opts,
774                    MAX_STREAM_BLOB_BYTES,
775                    &mut base_cache,
776                )?;
777                object_counts.add(kind);
778                count += 1;
779                if let Some(p) = progress.as_mut() {
780                    p.inc(1);
781                }
782            } else {
783                still_pending.push(delta);
784            }
785        }
786
787        remaining = still_pending;
788        if remaining.len() == before {
789            return Err(Error::UnresolvedDeltas {
790                count: u64::try_from(remaining.len()).unwrap_or(u64::MAX),
791            });
792        }
793    }
794    if let (Some(p), true) = (progress.as_mut(), resolving) {
795        p.finish();
796    }
797
798    if opts.strict {
799        verify_packed_dot_special_entries(&by_oid, sink)?;
800        strict_verify_packed_references_map(
801            sink,
802            &by_oid,
803            &opts.allowed_missing,
804            opts.allow_promisor_missing_references,
805            &opts.shallow_boundaries,
806            promised,
807        )?;
808    }
809
810    Ok(Unpacked {
811        count,
812        by_oid,
813        object_counts,
814        pack_bytes,
815    })
816}
817
818/// Slot in the in-pack index: dry-run payloads or a stored object id.
819#[derive(Debug, Clone)]
820enum PackedObjectEntry {
821    DryRun { kind: ObjectKind, data: Vec<u8> },
822    Stored { kind: ObjectKind, oid: ObjectId },
823}
824
825fn packed_entry_kind(entry: &PackedObjectEntry) -> ObjectKind {
826    match entry {
827        PackedObjectEntry::DryRun { kind, .. } | PackedObjectEntry::Stored { kind, .. } => *kind,
828    }
829}
830
831#[allow(clippy::too_many_arguments)]
832fn record_resolved_object(
833    by_offset: &mut HashMap<usize, PackedObjectEntry>,
834    by_oid: &mut HashMap<ObjectId, PackedObjectEntry>,
835    pack_offset: usize,
836    oid: ObjectId,
837    kind: ObjectKind,
838    data: Vec<u8>,
839    opts: &UnpackOptions,
840    max_stream_blob: usize,
841    cache: &mut UnpackBaseByteCache,
842) -> Result<()> {
843    let entry =
844        packed_entry_after_write(kind, data, oid, opts, max_stream_blob, pack_offset, cache);
845    by_offset.insert(pack_offset, entry.clone());
846    by_oid.insert(oid, entry);
847    Ok(())
848}
849
850fn resolve_pending_delta<S: UnpackSink + ?Sized>(
851    delta: &PendingDelta,
852    by_offset: &HashMap<usize, PackedObjectEntry>,
853    by_oid: &HashMap<ObjectId, PackedObjectEntry>,
854    sink: &S,
855    opts: &UnpackOptions,
856    max_stream_blob: usize,
857    cache: &mut UnpackBaseByteCache,
858) -> Result<Option<(ObjectId, ObjectKind, Vec<u8>)>> {
859    let Some((base_kind, base_data)) =
860        delta_base_bytes(delta, by_offset, by_oid, sink, opts, cache)?
861    else {
862        return Ok(None);
863    };
864    let result = apply_delta(base_data.as_ref(), &delta.delta_data)?;
865    let oid = sink.write(base_kind, &result, opts.dry_run)?;
866    if !opts.dry_run && (base_kind != ObjectKind::Blob || result.len() <= max_stream_blob) {
867        cache.put(delta.offset, base_kind, result.clone());
868    }
869    Ok(Some((oid, base_kind, result)))
870}
871
872fn delta_base_bytes<'a, S: UnpackSink + ?Sized>(
873    delta: &PendingDelta,
874    by_offset: &'a HashMap<usize, PackedObjectEntry>,
875    by_oid: &'a HashMap<ObjectId, PackedObjectEntry>,
876    sink: &S,
877    opts: &UnpackOptions,
878    cache: &mut UnpackBaseByteCache,
879) -> Result<Option<(ObjectKind, Cow<'a, [u8]>)>> {
880    if let Some(base_off) = delta.base_offset {
881        if let Some((kind, data)) = cache.get(base_off) {
882            return Ok(Some((kind, Cow::Owned(data))));
883        }
884        let Some(entry) = by_offset.get(&base_off) else {
885            return Ok(None);
886        };
887        return entry_object_bytes(entry, sink, Some(base_off), cache).map(Some);
888    }
889    if let Some(ref base_id) = delta.base_oid {
890        if let Some(entry) = by_oid.get(base_id) {
891            return entry_object_bytes(entry, sink, None, cache).map(Some);
892        }
893        if !opts.dry_run {
894            if let Some(obj) = sink.read_stored(base_id)? {
895                return Ok(Some((obj.kind, Cow::Owned(obj.data))));
896            }
897        }
898    }
899    Ok(None)
900}
901
902fn verify_packed_dot_special_entries<S: UnpackSink + ?Sized>(
903    by_oid: &HashMap<ObjectId, PackedObjectEntry>,
904    sink: &S,
905) -> Result<()> {
906    let mut tracker = DotFsckTracker::default();
907    let mut keys: Vec<ObjectId> = by_oid.keys().copied().collect();
908    keys.sort();
909    for oid in keys {
910        let entry = &by_oid[&oid];
911        if matches!(
912            entry,
913            PackedObjectEntry::Stored {
914                kind: ObjectKind::Blob,
915                ..
916            } | PackedObjectEntry::DryRun {
917                kind: ObjectKind::Blob,
918                ..
919            }
920        ) {
921            continue;
922        }
923        let (kind, data) = entry_payload_for_strict(entry, sink, &oid)?;
924        if kind == ObjectKind::Tree {
925            for di in tracker.on_tree(&oid, &data)? {
926                if di.is_error_severity() {
927                    return Err(Error::CorruptObject(di.format_line()));
928                }
929            }
930        }
931        for di in tracker.on_object(&oid, kind, &data) {
932            if di.is_error_severity() {
933                return Err(Error::CorruptObject(di.format_line()));
934            }
935        }
936    }
937    let pending_gm: Vec<ObjectId> = tracker
938        .gitmodules_found
939        .difference(&tracker.gitmodules_done)
940        .copied()
941        .collect();
942    let pending_ga: Vec<ObjectId> = tracker
943        .gitattributes_found
944        .difference(&tracker.gitattributes_done)
945        .copied()
946        .collect();
947    for oid in pending_gm {
948        tracker.gitmodules_done.insert(oid);
949        let Some(entry) = by_oid.get(&oid) else {
950            continue;
951        };
952        let (kind, data) = dot_special_blob_bytes(entry, sink, &oid)?;
953        for di in gitmodules::fsck_dot_special_object(
954            &oid,
955            kind,
956            &data,
957            &tracker.gitmodules_found,
958            &tracker.gitattributes_found,
959        ) {
960            if di.is_error_severity() {
961                return Err(Error::CorruptObject(di.format_line()));
962            }
963        }
964    }
965    for oid in pending_ga {
966        if tracker.gitattributes_done.contains(&oid) {
967            continue;
968        }
969        tracker.gitattributes_done.insert(oid);
970        let Some(entry) = by_oid.get(&oid) else {
971            continue;
972        };
973        let (kind, data) = dot_special_blob_bytes(entry, sink, &oid)?;
974        for di in gitmodules::fsck_dot_special_object(
975            &oid,
976            kind,
977            &data,
978            &tracker.gitmodules_found,
979            &tracker.gitattributes_found,
980        ) {
981            if di.is_error_severity() {
982                return Err(Error::CorruptObject(di.format_line()));
983            }
984        }
985    }
986    Ok(())
987}
988
989fn dot_special_blob_bytes<S: UnpackSink + ?Sized>(
990    entry: &PackedObjectEntry,
991    sink: &S,
992    id: &ObjectId,
993) -> Result<(ObjectKind, Vec<u8>)> {
994    entry_payload_for_strict(entry, sink, id)
995}
996
997fn entry_payload_for_strict<S: UnpackSink + ?Sized>(
998    entry: &PackedObjectEntry,
999    sink: &S,
1000    id: &ObjectId,
1001) -> Result<(ObjectKind, Vec<u8>)> {
1002    match entry {
1003        PackedObjectEntry::DryRun { kind, data } => Ok((*kind, data.clone())),
1004        PackedObjectEntry::Stored { kind, oid } => {
1005            debug_assert_eq!(oid, id);
1006            let Some(obj) = sink.read_stored(id)? else {
1007                return Err(Error::CorruptObject(format!("missing object {id}")));
1008            };
1009            Ok((*kind, obj.data))
1010        }
1011    }
1012}
1013
1014fn packed_entry_after_write(
1015    kind: ObjectKind,
1016    data: Vec<u8>,
1017    oid: ObjectId,
1018    opts: &UnpackOptions,
1019    max_stream_blob: usize,
1020    _pack_offset: usize,
1021    _cache: &mut UnpackBaseByteCache,
1022) -> PackedObjectEntry {
1023    let _ = max_stream_blob;
1024    if opts.dry_run {
1025        return PackedObjectEntry::DryRun { kind, data };
1026    }
1027    PackedObjectEntry::Stored { kind, oid }
1028}
1029
1030fn entry_object_bytes<'a, S: UnpackSink + ?Sized>(
1031    entry: &'a PackedObjectEntry,
1032    sink: &S,
1033    pack_offset: Option<usize>,
1034    cache: &mut UnpackBaseByteCache,
1035) -> Result<(ObjectKind, Cow<'a, [u8]>)> {
1036    match entry {
1037        PackedObjectEntry::DryRun { kind, data } => Ok((*kind, Cow::Borrowed(data.as_slice()))),
1038        PackedObjectEntry::Stored { kind, oid } => {
1039            if let Some(off) = pack_offset {
1040                if let Some((k, data)) = cache.get(off) {
1041                    return Ok((k, Cow::Owned(data)));
1042                }
1043            }
1044            let obj = sink
1045                .read_stored(oid)?
1046                .ok_or_else(|| Error::ObjectNotFound(oid.to_hex()))?;
1047            if let Some(off) = pack_offset {
1048                cache.put(off, *kind, obj.data.clone());
1049            }
1050            Ok((*kind, Cow::Owned(obj.data)))
1051        }
1052    }
1053}
1054
1055fn strict_verify_packed_references_map<S: UnpackSink + ?Sized>(
1056    sink: &S,
1057    pack: &HashMap<ObjectId, PackedObjectEntry>,
1058    allowed_missing: &HashSet<ObjectId>,
1059    allow_promisor_missing_references: bool,
1060    shallow_boundaries: &HashSet<ObjectId>,
1061    promised: Option<PromisedReference<'_>>,
1062) -> Result<()> {
1063    let resolves = |target: &ObjectId, referrer: &ObjectId| -> Result<bool> {
1064        if strict_ref_resolves_map(
1065            target,
1066            pack,
1067            sink,
1068            allowed_missing,
1069            allow_promisor_missing_references,
1070        )? {
1071            return Ok(true);
1072        }
1073        promised.map_or(Ok(false), |promised| promised(target, referrer))
1074    };
1075    for (oid, entry) in pack {
1076        let kind = packed_entry_kind(entry);
1077        if kind == ObjectKind::Blob {
1078            continue;
1079        }
1080        let (_, data) = entry_payload_for_strict(entry, sink, oid)?;
1081        match kind {
1082            ObjectKind::Tree => {
1083                for e in parse_tree(&data)? {
1084                    // Gitlink (submodule) entries point at commits that live
1085                    // in the submodule repository, not the superproject's
1086                    // pack/ODB. Skip them in the connectivity walk, matching
1087                    // upstream git (git/fsck.c:374 `if (S_ISGITLINK) continue;`).
1088                    if e.mode == MODE_GITLINK {
1089                        continue;
1090                    }
1091                    if !resolves(&e.oid, oid)? {
1092                        return Err(Error::CorruptObject(format!(
1093                            "strict: missing object {} referenced by tree",
1094                            e.oid.to_hex()
1095                        )));
1096                    }
1097                }
1098            }
1099            ObjectKind::Commit => {
1100                let c = parse_commit(&data)?;
1101                if !resolves(&c.tree, oid)? {
1102                    return Err(Error::CorruptObject(format!(
1103                        "strict: missing tree {} referenced by commit",
1104                        c.tree.to_hex()
1105                    )));
1106                }
1107                // A commit recorded as a shallow boundary (graft) has its parents intentionally
1108                // absent — skip parent connectivity for it, matching unpack-objects run with a
1109                // `--shallow-file` listing this commit.
1110                if shallow_boundaries.contains(oid) {
1111                    continue;
1112                }
1113                for p in &c.parents {
1114                    if !resolves(p, oid)? {
1115                        return Err(Error::CorruptObject(format!(
1116                            "strict: missing parent {} referenced by commit",
1117                            p.to_hex()
1118                        )));
1119                    }
1120                }
1121            }
1122            ObjectKind::Tag => {
1123                let t = parse_tag(&data)?;
1124                if !resolves(&t.object, oid)? {
1125                    return Err(Error::CorruptObject(format!(
1126                        "strict: missing object {} referenced by tag",
1127                        t.object.to_hex()
1128                    )));
1129                }
1130            }
1131            ObjectKind::Blob => {}
1132        }
1133    }
1134    Ok(())
1135}
1136
1137fn strict_ref_resolves_map<S: UnpackSink + ?Sized>(
1138    oid: &ObjectId,
1139    pack: &HashMap<ObjectId, PackedObjectEntry>,
1140    sink: &S,
1141    allowed_missing: &HashSet<ObjectId>,
1142    allow_promisor_missing_references: bool,
1143) -> Result<bool> {
1144    if pack.contains_key(oid) || allowed_missing.contains(oid) || allow_promisor_missing_references
1145    {
1146        return Ok(true);
1147    }
1148    sink.contains(oid)
1149}
1150
1151fn strict_ref_resolves(
1152    oid: &ObjectId,
1153    pack: &std::collections::HashMap<ObjectId, (ObjectKind, Vec<u8>)>,
1154    odb: Option<&Odb>,
1155) -> Result<bool> {
1156    if pack.contains_key(oid) {
1157        return Ok(true);
1158    }
1159    odb.map_or(Ok(false), |o| o.contains(oid))
1160}
1161
1162/// Verifies that references from commits, trees, and tags resolve to objects present in `pack`
1163/// or, when `odb` is [`Some`], to loose objects in that database.
1164///
1165/// Use [`None`] for `odb` when indexing or unpacking in a context with no repository (Git allows
1166/// `index-pack --strict` outside a work tree when the pack is self-contained).
1167///
1168/// # Errors
1169///
1170/// [`Error::CorruptObject`] for a reference that resolves nowhere; [`Odb::contains`]
1171/// failures propagate unchanged.
1172pub fn strict_verify_packed_references(
1173    odb: Option<&Odb>,
1174    pack: &HashMap<ObjectId, (ObjectKind, Vec<u8>)>,
1175) -> Result<()> {
1176    for (kind, data) in pack.values() {
1177        match kind {
1178            ObjectKind::Tree => {
1179                for e in parse_tree(data)? {
1180                    // Gitlink (submodule) entries point at commits that live in
1181                    // the submodule repository, not this pack/ODB. Skip them in
1182                    // the connectivity walk, matching upstream git
1183                    // (git/fsck.c:374 `if (S_ISGITLINK) continue;`).
1184                    if e.mode == MODE_GITLINK {
1185                        continue;
1186                    }
1187                    if !strict_ref_resolves(&e.oid, pack, odb)? {
1188                        return Err(Error::CorruptObject(format!(
1189                            "strict: missing object {} referenced by tree",
1190                            e.oid.to_hex()
1191                        )));
1192                    }
1193                }
1194            }
1195            ObjectKind::Commit => {
1196                let c = parse_commit(data)?;
1197                if !strict_ref_resolves(&c.tree, pack, odb)? {
1198                    return Err(Error::CorruptObject(format!(
1199                        "strict: missing tree {} referenced by commit",
1200                        c.tree.to_hex()
1201                    )));
1202                }
1203                for p in &c.parents {
1204                    if !strict_ref_resolves(p, pack, odb)? {
1205                        return Err(Error::CorruptObject(format!(
1206                            "strict: missing parent {} referenced by commit",
1207                            p.to_hex()
1208                        )));
1209                    }
1210                }
1211            }
1212            ObjectKind::Tag => {
1213                let t = parse_tag(data)?;
1214                if !strict_ref_resolves(&t.object, pack, odb)? {
1215                    return Err(Error::CorruptObject(format!(
1216                        "strict: missing object {} referenced by tag",
1217                        t.object.to_hex()
1218                    )));
1219                }
1220            }
1221            ObjectKind::Blob => {}
1222        }
1223    }
1224    Ok(())
1225}
1226
1227/// Whether `data` is a *thin* pack — i.e. it contains a `ref-delta` (type 7) whose base object is
1228/// not itself present in the pack. `git pack-objects --thin` produces such packs; a receiver that
1229/// rejects thin packs (`receive-pack --reject-thin-pack-for-testing`) uses this to refuse them.
1230///
1231/// Conservative: any parse error makes this return `false` (treat as non-thin) so a malformed pack
1232/// is handled by the normal ingestion path rather than mislabeled.
1233pub fn pack_is_thin(data: &[u8], algo: HashAlgo) -> bool {
1234    pack_is_thin_inner(data, algo).unwrap_or(false)
1235}
1236
1237fn pack_is_thin_inner(data: &[u8], algo: HashAlgo) -> Result<bool> {
1238    use crate::pack_zlib::skip_zlib_at;
1239
1240    let mut rd = PackReader::new(data);
1241    if rd.read_exact(4)? != b"PACK" {
1242        return Ok(false);
1243    }
1244    let _version = rd.read_u32_be()?;
1245    let nr_objects = rd.read_u32_be()? as usize;
1246
1247    let mut ref_delta_bases: Vec<ObjectId> = Vec::new();
1248    let mut saw_ref_delta = false;
1249
1250    for _ in 0..nr_objects {
1251        let obj_offset = rd.pos;
1252        let (type_code, size) = rd.read_type_size()?;
1253        match type_code {
1254            1..=4 => {
1255                let zlib_offset = rd.pos;
1256                let consumed = skip_zlib_at(data, zlib_offset, size)?;
1257                rd.pos = zlib_offset + consumed;
1258                let _ = obj_offset;
1259            }
1260            6 => {
1261                let neg = rd.read_ofs_neg_offset()?;
1262                let _base_offset = obj_offset.checked_sub(neg).ok_or_else(|| {
1263                    Error::CorruptObject("ofs-delta base offset underflow".to_owned())
1264                })?;
1265                let zlib_offset = rd.pos;
1266                let consumed = skip_zlib_at(data, zlib_offset, size)?;
1267                rd.pos = zlib_offset + consumed;
1268            }
1269            7 => {
1270                saw_ref_delta = true;
1271                let base_bytes = rd.read_exact(algo.len())?;
1272                let base_oid = ObjectId::from_bytes(base_bytes)?;
1273                ref_delta_bases.push(base_oid);
1274                let zlib_offset = rd.pos;
1275                let consumed = skip_zlib_at(data, zlib_offset, size)?;
1276                rd.pos = zlib_offset + consumed;
1277            }
1278            _ => return Ok(false),
1279        }
1280    }
1281
1282    if !saw_ref_delta {
1283        return Ok(false);
1284    }
1285
1286    pack_is_thin_with_ofs_deltas(data, algo, ref_delta_bases)
1287}
1288
1289/// Resolve delta chains only far enough to test ref-delta bases (no full pack materialization).
1290fn pack_is_thin_with_ofs_deltas(
1291    data: &[u8],
1292    algo: HashAlgo,
1293    ref_delta_bases: Vec<ObjectId>,
1294) -> Result<bool> {
1295    let tmp = tempfile::tempdir().map_err(Error::Io)?;
1296    let git_dir = tmp.path().join(".git");
1297    std::fs::create_dir_all(git_dir.join("objects")).map_err(Error::Io)?;
1298    if algo == HashAlgo::Sha256 {
1299        std::fs::write(
1300            git_dir.join("config"),
1301            "[extensions]\n\tobjectformat = sha256\n",
1302        )
1303        .map_err(Error::Io)?;
1304    }
1305    let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir);
1306    match crate::pack_index_build::build_pack_index_records(
1307        data,
1308        &odb,
1309        crate::hash::Parallelism::resolve(Some(1)),
1310        false,
1311    ) {
1312        Ok(records) => {
1313            let in_pack: HashSet<ObjectId> = records.into_iter().map(|r| r.oid).collect();
1314            Ok(ref_delta_bases.iter().any(|b| !in_pack.contains(b)))
1315        }
1316        Err(Error::UnresolvedDeltas { .. }) => Ok(true),
1317        Err(e) => Err(e),
1318    }
1319}
1320
1321/// Parse a pack byte stream and return every resolved object (after delta resolution) keyed by OID.
1322///
1323/// Does not write to any object database. Used for receive-pack connectivity checks before
1324/// applying a push to the permanent ODB.
1325///
1326/// Thin-pack bases may be resolved from `odb` when they are not present in the pack.
1327///
1328/// # Errors
1329///
1330/// Returns [`Error::UnresolvedDeltas`] when some delta bases are in neither the pack
1331/// nor `odb`, and [`Error::CorruptObject`] or [`Error::Zlib`] for a malformed pack.
1332pub fn pack_bytes_to_object_map(data: &[u8], odb: &Odb) -> Result<HashMap<ObjectId, Object>> {
1333    build_pack_object_map(PackReader::new(data), odb)
1334}
1335
1336/// Resolved `(oid, pack_offset)` pairs for every object slot in a pack byte stream.
1337///
1338/// Offsets point at packed object headers (including delta slots). Thin-pack bases
1339/// may be resolved from `odb` when they are not present in the pack.
1340/// One row for a v2 pack index: resolved OID, header offset, CRC32 of raw pack bytes.
1341#[derive(Debug, Clone)]
1342pub struct PackIndexRecord {
1343    /// Object id (after delta resolution).
1344    pub oid: ObjectId,
1345    /// Byte offset of the object header in the pack.
1346    pub offset: u64,
1347    /// CRC32 of the packed object slice (header + zlib payload).
1348    pub crc32: u32,
1349    /// Object type after delta resolution.
1350    pub kind: ObjectKind,
1351}
1352
1353/// Build index records using the repository's default parallelism (`pack.threads` when unset).
1354///
1355/// Does not copy the pack buffer or retain inflated objects beyond the delta chain each
1356/// worker is walking; see [`crate::objects::index_pack::build_pack_index_records`].
1357///
1358/// # Errors
1359///
1360/// Returns [`Error::CorruptObject`] for a malformed pack and [`Error::UnresolvedDeltas`]
1361/// for deltas whose bases are in neither the pack nor `odb`.
1362pub fn pack_index_records_from_bytes(data: &[u8], odb: &Odb) -> Result<Vec<PackIndexRecord>> {
1363    pack_index_records_with_threads(
1364        data,
1365        odb,
1366        crate::config::ConfigSet::pack_index_parallelism_for_git_dir(
1367            &crate::environment::Environment::empty(),
1368            odb.config_git_dir(),
1369        ),
1370    )
1371}
1372
1373/// Build index records with an explicit worker thread count (`None`/`0` → available CPUs).
1374pub fn pack_index_records_with_threads(
1375    data: &[u8],
1376    odb: &Odb,
1377    parallelism: crate::hash::Parallelism,
1378) -> Result<Vec<PackIndexRecord>> {
1379    crate::pack_index_build::build_pack_index_records(data, odb, parallelism, true)
1380}
1381
1382/// Like [`pack_index_records_with_threads`], reporting indexing and delta resolution
1383/// progress on `progress` when provided.
1384pub fn pack_index_records_with_threads_with_progress(
1385    data: &[u8],
1386    odb: &Odb,
1387    parallelism: crate::hash::Parallelism,
1388    progress: Option<&mut dyn crate::progress::ProgressSink>,
1389) -> Result<Vec<PackIndexRecord>> {
1390    crate::pack_index_build::build_pack_index_records_with_progress(
1391        data,
1392        odb,
1393        parallelism,
1394        true,
1395        progress,
1396        None,
1397    )
1398}
1399
1400fn build_pack_object_map(mut rd: PackReader<'_>, odb: &Odb) -> Result<HashMap<ObjectId, Object>> {
1401    let algo = odb.hash_algo();
1402    let sig = rd.read_exact(4)?;
1403    if sig != b"PACK" {
1404        return Err(Error::CorruptObject(
1405            "not a pack stream: invalid signature".to_owned(),
1406        ));
1407    }
1408    let version = rd.read_u32_be()?;
1409    if version != 2 && version != 3 {
1410        return Err(Error::CorruptObject(format!(
1411            "unsupported pack version {version}"
1412        )));
1413    }
1414    let nr_objects = rd.read_u32_be()? as usize;
1415
1416    let mut by_offset: HashMap<usize, (ObjectKind, Vec<u8>)> = HashMap::new();
1417    let mut by_oid: HashMap<ObjectId, (ObjectKind, Vec<u8>)> = HashMap::new();
1418    let mut pending: Vec<PendingDelta> = Vec::new();
1419
1420    fn base_from_pack_or_odb(
1421        by_oid: &HashMap<ObjectId, (ObjectKind, Vec<u8>)>,
1422        odb: &Odb,
1423        id: &ObjectId,
1424    ) -> Option<(ObjectKind, Vec<u8>)> {
1425        if let Some(e) = by_oid.get(id) {
1426            return Some(e.clone());
1427        }
1428        odb.read(id).ok().map(|o| (o.kind, o.data))
1429    }
1430
1431    for _ in 0..nr_objects {
1432        let obj_offset = rd.pos;
1433        let (type_code, size) = rd.read_type_size()?;
1434
1435        match type_code {
1436            1..=4 => {
1437                let kind = type_code_to_kind(type_code)?;
1438                let data = rd.decompress(size)?;
1439                let oid = odb.hash(kind, &data)?;
1440                by_offset.insert(obj_offset, (kind, data.clone()));
1441                by_oid.insert(oid, (kind, data));
1442            }
1443            6 => {
1444                let neg = rd.read_ofs_neg_offset()?;
1445                let base_offset = obj_offset.checked_sub(neg).ok_or_else(|| {
1446                    Error::CorruptObject("ofs-delta base offset underflow".to_owned())
1447                })?;
1448                let delta_data = rd.decompress(size)?;
1449                pending.push(PendingDelta {
1450                    offset: obj_offset,
1451                    base_oid: None,
1452                    base_offset: Some(base_offset),
1453                    delta_data,
1454                });
1455            }
1456            7 => {
1457                let base_bytes = rd.read_exact(algo.len())?;
1458                let base_oid = ObjectId::from_bytes(base_bytes)?;
1459                let delta_data = rd.decompress(size)?;
1460                pending.push(PendingDelta {
1461                    offset: obj_offset,
1462                    base_oid: Some(base_oid),
1463                    base_offset: None,
1464                    delta_data,
1465                });
1466            }
1467            other => {
1468                return Err(Error::CorruptObject(format!(
1469                    "unknown packed-object type {other}"
1470                )))
1471            }
1472        }
1473    }
1474
1475    let consumed = rd.pos;
1476    {
1477        let mut hasher = ChecksumHasher::new(algo);
1478        hasher.update(&rd.data[..consumed]);
1479        let digest = hasher.finalize();
1480        let trailing = rd.read_exact(algo.len())?;
1481        if digest.as_slice() != trailing {
1482            return Err(Error::CorruptObject(
1483                "pack trailing checksum mismatch".to_owned(),
1484            ));
1485        }
1486    }
1487
1488    let mut remaining = pending;
1489    loop {
1490        if remaining.is_empty() {
1491            break;
1492        }
1493        let before = remaining.len();
1494        let mut still_pending: Vec<PendingDelta> = Vec::new();
1495
1496        for delta in remaining {
1497            let base = if let Some(base_off) = delta.base_offset {
1498                by_offset.get(&base_off).cloned()
1499            } else if let Some(ref base_id) = delta.base_oid {
1500                base_from_pack_or_odb(&by_oid, odb, base_id)
1501            } else {
1502                None
1503            };
1504
1505            if let Some((base_kind, base_data)) = base {
1506                let result = apply_delta(&base_data, &delta.delta_data)?;
1507                let oid = odb.hash(base_kind, &result)?;
1508                by_offset.insert(delta.offset, (base_kind, result.clone()));
1509                by_oid.insert(oid, (base_kind, result));
1510            } else {
1511                still_pending.push(delta);
1512            }
1513        }
1514
1515        remaining = still_pending;
1516        if remaining.len() == before {
1517            return Err(Error::UnresolvedDeltas {
1518                count: u64::try_from(remaining.len()).unwrap_or(u64::MAX),
1519            });
1520        }
1521    }
1522
1523    Ok(by_oid
1524        .into_iter()
1525        .map(|(oid, (kind, data))| (oid, Object::new(kind, data)))
1526        .collect())
1527}
1528
1529/// Convert a pack object type code to an [`ObjectKind`].
1530pub(crate) fn type_code_to_kind(code: u8) -> Result<ObjectKind> {
1531    match code {
1532        1 => Ok(ObjectKind::Commit),
1533        2 => Ok(ObjectKind::Tree),
1534        3 => Ok(ObjectKind::Blob),
1535        4 => Ok(ObjectKind::Tag),
1536        _ => Err(Error::CorruptObject(format!(
1537            "type code {code} is not a regular object type"
1538        ))),
1539    }
1540}
1541
1542/// Low-level cursor over a buffered pack byte stream (in-memory pack parsing).
1543pub(crate) struct PackReader<'a> {
1544    data: &'a [u8],
1545    pub(crate) pos: usize,
1546}
1547
1548impl<'a> PackReader<'a> {
1549    pub(crate) fn new(data: &'a [u8]) -> Self {
1550        Self { data, pos: 0 }
1551    }
1552
1553    /// Read exactly `n` bytes and advance the cursor, returning a slice into
1554    /// the internal buffer.
1555    pub(crate) fn read_exact(&mut self, n: usize) -> Result<&[u8]> {
1556        if self.pos + n > self.data.len() {
1557            return Err(Error::CorruptObject(format!(
1558                "pack stream truncated: need {n} bytes at offset {}",
1559                self.pos
1560            )));
1561        }
1562        let slice = &self.data[self.pos..self.pos + n];
1563        self.pos += n;
1564        Ok(slice)
1565    }
1566
1567    /// Read a single byte and advance the cursor.
1568    pub(crate) fn read_byte(&mut self) -> Result<u8> {
1569        if self.pos >= self.data.len() {
1570            return Err(Error::CorruptObject(
1571                "unexpected end of pack stream".to_owned(),
1572            ));
1573        }
1574        let b = self.data[self.pos];
1575        self.pos += 1;
1576        Ok(b)
1577    }
1578
1579    /// Read a big-endian `u32`.
1580    pub(crate) fn read_u32_be(&mut self) -> Result<u32> {
1581        let bytes = self.read_exact(4)?;
1582        Ok(u32::from_be_bytes(bytes.try_into().map_err(|_| {
1583            Error::CorruptObject("u32 read failed".to_owned())
1584        })?))
1585    }
1586
1587    /// Read the packed-object type + size header (variable-length big-endian
1588    /// encoding with the type in bits 4-6 of the first byte).
1589    ///
1590    /// Returns `(type_code, uncompressed_size)`.
1591    pub(crate) fn read_type_size(&mut self) -> Result<(u8, usize)> {
1592        let c = self.read_byte()?;
1593        let type_code = (c >> 4) & 0x7;
1594        let mut size = (c & 0x0f) as usize;
1595        let mut shift = 4u32;
1596        let mut cur = c;
1597        while cur & 0x80 != 0 {
1598            cur = self.read_byte()?;
1599            size |= ((cur & 0x7f) as usize) << shift;
1600            shift += 7;
1601        }
1602        Ok((type_code, size))
1603    }
1604
1605    /// Read an `OFS_DELTA` negative-offset value.
1606    ///
1607    /// The encoding uses a big-endian variable-length integer with a +1 bias
1608    /// on each continuation byte, yielding values ≥ 1.
1609    pub(crate) fn read_ofs_neg_offset(&mut self) -> Result<usize> {
1610        let mut c = self.read_byte()?;
1611        let mut value = (c & 0x7f) as usize;
1612        while c & 0x80 != 0 {
1613            c = self.read_byte()?;
1614            value = (value + 1) << 7 | (c & 0x7f) as usize;
1615        }
1616        Ok(value)
1617    }
1618
1619    /// Decompress zlib-compressed data starting at the current cursor position.
1620    ///
1621    /// Advances the cursor by exactly the number of compressed bytes consumed.
1622    /// Returns an error if the decompressed length differs from `expected_size`.
1623    pub(crate) fn decompress(&mut self, expected_size: usize) -> Result<Vec<u8>> {
1624        let (out, consumed) = decompress_zlib_at(self.data, self.pos, expected_size)?;
1625        self.pos += consumed;
1626        Ok(out)
1627    }
1628}
1629
1630/// Decompress a zlib stream at `offset` in `pack` into `out` (cleared first; capacity reused).
1631pub(crate) fn decompress_zlib_at_into(
1632    pack: &[u8],
1633    offset: usize,
1634    expected_size: usize,
1635    out: &mut Vec<u8>,
1636) -> Result<usize> {
1637    out.clear();
1638    out.reserve(expected_size);
1639    let slice = pack.get(offset..).ok_or_else(|| {
1640        Error::CorruptObject(format!(
1641            "pack stream truncated: need zlib at offset {offset}"
1642        ))
1643    })?;
1644    let mut decoder = ZlibDecoder::new(slice);
1645    decoder
1646        .read_to_end(out)
1647        .map_err(|e| Error::Zlib(e.to_string()))?;
1648    if out.len() != expected_size {
1649        return Err(Error::CorruptObject(format!(
1650            "decompressed {} bytes but expected {}",
1651            out.len(),
1652            expected_size
1653        )));
1654    }
1655    Ok(decoder.total_in() as usize)
1656}
1657
1658/// Decompress a zlib stream at `offset` in `pack` without advancing a [`PackReader`].
1659pub(crate) fn decompress_zlib_at(
1660    pack: &[u8],
1661    offset: usize,
1662    expected_size: usize,
1663) -> Result<(Vec<u8>, usize)> {
1664    let mut out = Vec::new();
1665    let consumed = decompress_zlib_at_into(pack, offset, expected_size, &mut out)?;
1666    Ok((out, consumed))
1667}
1668
1669fn io_to_corrupt_eof(e: io::Error, stream_pos: usize, context: &str) -> Error {
1670    if e.kind() == io::ErrorKind::UnexpectedEof {
1671        Error::CorruptObject(format!(
1672            "pack stream truncated ({context}) at offset {stream_pos}"
1673        ))
1674    } else {
1675        Error::Io(e)
1676    }
1677}
1678
1679/// Streaming cursor over a pack file: hashes body bytes incrementally (no full-buffer read).
1680///
1681/// Raw pack bytes are either consumed as object headers (via [`Self::read_byte`]) or as zlib
1682/// payloads.  Zlib decoders may read ahead; overflow bytes stay in [`Self::pending`] so the next
1683/// object header or zlib stream starts at the correct offset.
1684struct StreamingPackReader<'a> {
1685    inner: &'a mut dyn Read,
1686    pack_hasher: ChecksumHasher,
1687    stream_pos: usize,
1688    max_input_bytes: Option<u64>,
1689    /// Compressed (or other) bytes already read from `inner` and hashed but not yet consumed by
1690    /// the current parsing step.
1691    pending: Vec<u8>,
1692}
1693
1694impl<'a> StreamingPackReader<'a> {
1695    fn new(inner: &'a mut dyn Read, max_input_bytes: Option<u64>, algo: HashAlgo) -> Self {
1696        Self {
1697            inner,
1698            pack_hasher: ChecksumHasher::new(algo),
1699            stream_pos: 0,
1700            max_input_bytes,
1701            pending: Vec::new(),
1702        }
1703    }
1704
1705    fn stream_pos(&self) -> usize {
1706        self.stream_pos
1707    }
1708
1709    fn enforce_max_input(&self) -> Result<()> {
1710        if let Some(limit) = self.max_input_bytes {
1711            let pos = u64::try_from(self.stream_pos)
1712                .map_err(|_| Error::CorruptObject("pack stream position overflow".to_owned()))?;
1713            if pos > limit {
1714                return Err(Error::CorruptObject(
1715                    "pack exceeds maximum allowed size".to_owned(),
1716                ));
1717            }
1718        }
1719        Ok(())
1720    }
1721
1722    /// Read pack-body bytes (hashed). Used for headers and non-zlib payload reads only.
1723    fn read_from_source(&mut self, buf: &mut [u8]) -> Result<usize> {
1724        let n = if !self.pending.is_empty() {
1725            let take = buf.len().min(self.pending.len());
1726            buf[..take].copy_from_slice(&self.pending[..take]);
1727            self.pending.drain(..take);
1728            take
1729        } else {
1730            self.inner.read(buf).map_err(Error::Io)?
1731        };
1732        if n > 0 {
1733            self.pack_hasher.update(&buf[..n]);
1734            self.stream_pos += n;
1735            self.enforce_max_input()?;
1736        }
1737        Ok(n)
1738    }
1739
1740    fn read_byte(&mut self) -> Result<u8> {
1741        let mut b = [0u8; 1];
1742        let n = self.read_from_source(&mut b)?;
1743        if n == 0 {
1744            return Err(Error::CorruptObject(format!(
1745                "pack stream truncated (read byte) at offset {}",
1746                self.stream_pos
1747            )));
1748        }
1749        Ok(b[0])
1750    }
1751
1752    fn read_exact_n(&mut self, n: usize) -> Result<Vec<u8>> {
1753        let mut v = vec![0u8; n];
1754        let mut got = 0usize;
1755        while got < n {
1756            let m = self.read_from_source(&mut v[got..n])?;
1757            if m == 0 {
1758                return Err(Error::CorruptObject(format!(
1759                    "pack stream truncated (read exact) at offset {}",
1760                    self.stream_pos
1761                )));
1762            }
1763            got += m;
1764        }
1765        Ok(v)
1766    }
1767
1768    fn read_u32_be(&mut self) -> Result<u32> {
1769        let mut b = [0u8; 4];
1770        let mut got = 0usize;
1771        while got < 4 {
1772            let m = self.read_from_source(&mut b[got..4])?;
1773            if m == 0 {
1774                return Err(Error::CorruptObject(format!(
1775                    "pack stream truncated (read u32) at offset {}",
1776                    self.stream_pos
1777                )));
1778            }
1779            got += m;
1780        }
1781        Ok(u32::from_be_bytes(b))
1782    }
1783
1784    fn read_type_size(&mut self) -> Result<(u8, usize)> {
1785        let c = self.read_byte()?;
1786        let type_code = (c >> 4) & 0x7;
1787        let mut size = (c & 0x0f) as usize;
1788        let mut shift = 4u32;
1789        let mut cur = c;
1790        while cur & 0x80 != 0 {
1791            cur = self.read_byte()?;
1792            size |= ((cur & 0x7f) as usize) << shift;
1793            shift += 7;
1794        }
1795        Ok((type_code, size))
1796    }
1797
1798    fn read_ofs_neg_offset(&mut self) -> Result<usize> {
1799        let mut c = self.read_byte()?;
1800        let mut value = (c & 0x7f) as usize;
1801        while c & 0x80 != 0 {
1802            c = self.read_byte()?;
1803            value = (value + 1) << 7 | (c & 0x7f) as usize;
1804        }
1805        Ok(value)
1806    }
1807
1808    /// Pull zlib-compressed bytes until one object inflates to `expected_size` bytes.
1809    ///
1810    /// Bytes read from `inner` into `pending` are not hashed until we know how many belong to the
1811    /// zlib stream (`total_in()`). Lookahead past the zlib end (including the 20-byte pack
1812    /// trailer) must never be fed to the pack checksum.
1813    ///
1814    /// When the pack arrives in small chunks (e.g. side-band-64k from `upload-pack`), `flate2` may
1815    /// return an error before the full deflate stream is in `pending`. Retry after reading more
1816    /// from `inner` (same idea as [`PackReader::decompress`], which sees the whole zlib at once).
1817    /// Inflate one pack object to `expected_size` bytes, invoking `chunk_sink` for each step.
1818    pub(crate) fn decompress_into(
1819        &mut self,
1820        expected_size: usize,
1821        mut chunk_sink: impl FnMut(&[u8]) -> Result<()>,
1822    ) -> Result<()> {
1823        if expected_size == 0 {
1824            const CHUNK: usize = 64 * 1024;
1825            let mut scratch = [0u8; CHUNK];
1826            loop {
1827                let mut cursor = std::io::Cursor::new(self.pending.as_slice());
1828                let mut z = ZlibDecoder::new(&mut cursor);
1829                let mut sink = [0u8; 1];
1830                match z.read(&mut sink) {
1831                    Ok(0) => {
1832                        let consumed = z.total_in() as usize;
1833                        if consumed > self.pending.len() {
1834                            return Err(Error::CorruptObject(
1835                                "zlib total_in exceeds pending buffer".to_owned(),
1836                            ));
1837                        }
1838                        if consumed == 0 {
1839                            let n = self.inner.read(&mut scratch).map_err(Error::Io)?;
1840                            if n == 0 {
1841                                return Err(Error::CorruptObject(format!(
1842                                    "pack stream truncated (zlib) at offset {}",
1843                                    self.stream_pos
1844                                )));
1845                            }
1846                            self.pending.extend_from_slice(&scratch[..n]);
1847                            continue;
1848                        }
1849                        self.pack_hasher.update(&self.pending[..consumed]);
1850                        self.stream_pos += consumed;
1851                        self.pending.drain(..consumed);
1852                        self.enforce_max_input()?;
1853                        return Ok(());
1854                    }
1855                    Ok(_) => {
1856                        return Err(Error::CorruptObject(
1857                            "0-byte packed object inflated to non-empty output".to_owned(),
1858                        ));
1859                    }
1860                    Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
1861                        let n = self.inner.read(&mut scratch).map_err(Error::Io)?;
1862                        if n == 0 {
1863                            return Err(Error::CorruptObject(format!(
1864                                "pack stream truncated (zlib) at offset {}",
1865                                self.stream_pos
1866                            )));
1867                        }
1868                        self.pending.extend_from_slice(&scratch[..n]);
1869                    }
1870                    Err(e) => return Err(Error::Zlib(e.to_string())),
1871                }
1872            }
1873        }
1874
1875        const CHUNK: usize = 64 * 1024;
1876        let mut scratch = [0u8; CHUNK];
1877        let mut scratch_out = [0u8; CHUNK];
1878
1879        let mut z = Decompress::new(true);
1880        let mut produced = 0usize;
1881        let mut eof = false;
1882        loop {
1883            if self.pending.is_empty() && !eof {
1884                let n = self.inner.read(&mut scratch).map_err(Error::Io)?;
1885                if n == 0 {
1886                    eof = true;
1887                } else {
1888                    self.pending.extend_from_slice(&scratch[..n]);
1889                }
1890            }
1891
1892            let flush = if eof && self.pending.is_empty() {
1893                FlushDecompress::Finish
1894            } else {
1895                FlushDecompress::None
1896            };
1897
1898            let before_in = z.total_in();
1899            let before_out = z.total_out();
1900            let status = z
1901                .decompress(self.pending.as_slice(), &mut scratch_out, flush)
1902                .map_err(|e| Error::Zlib(e.to_string()))?;
1903            let consumed = (z.total_in() - before_in) as usize;
1904            if consumed > self.pending.len() {
1905                return Err(Error::CorruptObject(
1906                    "zlib consumed more than pending buffer".to_owned(),
1907                ));
1908            }
1909            self.pack_hasher.update(&self.pending[..consumed]);
1910            self.stream_pos += consumed;
1911            self.pending.drain(..consumed);
1912            self.enforce_max_input()?;
1913            let chunk_len = (z.total_out() - before_out) as usize;
1914            if chunk_len > 0 {
1915                let chunk = &scratch_out[..chunk_len];
1916                let take = chunk.len().min(expected_size - produced);
1917                chunk_sink(&chunk[..take])?;
1918                produced += take;
1919                if take < chunk.len() {
1920                    return Err(Error::CorruptObject(format!(
1921                        "decompressed size mismatch: got {}, want {expected_size}",
1922                        produced + (chunk.len() - take)
1923                    )));
1924                }
1925            }
1926
1927            match status {
1928                Status::StreamEnd => {
1929                    if produced != expected_size {
1930                        return Err(Error::CorruptObject(format!(
1931                            "decompressed size mismatch: got {produced}, want {expected_size}"
1932                        )));
1933                    }
1934                    return Ok(());
1935                }
1936                Status::Ok | Status::BufError => {
1937                    if consumed == 0 && !eof {
1938                        let n = self.inner.read(&mut scratch).map_err(Error::Io)?;
1939                        if n == 0 {
1940                            eof = true;
1941                        } else {
1942                            self.pending.extend_from_slice(&scratch[..n]);
1943                        }
1944                    } else if eof && self.pending.is_empty() && consumed == 0 {
1945                        return Err(Error::CorruptObject(format!(
1946                            "pack stream truncated (zlib) at offset {}",
1947                            self.stream_pos
1948                        )));
1949                    }
1950                }
1951            }
1952        }
1953    }
1954
1955    fn decompress(&mut self, expected_size: usize) -> Result<Vec<u8>> {
1956        let mut out = vec![0u8; expected_size];
1957        let mut pos = 0usize;
1958        self.decompress_into(expected_size, |chunk| {
1959            out[pos..pos + chunk.len()].copy_from_slice(chunk);
1960            pos += chunk.len();
1961            Ok(())
1962        })?;
1963        Ok(out)
1964    }
1965
1966    /// Hash over all pack bytes read so far (objects only; trailer not yet read).
1967    fn finalize_hasher(&self) -> Vec<u8> {
1968        self.pack_hasher.clone().finalize()
1969    }
1970
1971    /// Trailing pack checksum (hash-width bytes); not included in [`Self::finalize_hasher`].
1972    fn read_trailer(&mut self) -> Result<Vec<u8>> {
1973        let hash_len = self.pack_hasher.algo().len();
1974        let mut b = vec![0u8; hash_len];
1975        if self.pending.len() >= hash_len {
1976            b.copy_from_slice(&self.pending[..hash_len]);
1977            self.pending.drain(..hash_len);
1978            self.stream_pos += hash_len;
1979            self.enforce_max_input()?;
1980            return Ok(b);
1981        }
1982        let tail = self.pending.len();
1983        if tail > 0 {
1984            b[..tail].copy_from_slice(&self.pending[..]);
1985            self.pending.clear();
1986        }
1987        self.inner
1988            .read_exact(&mut b[tail..])
1989            .map_err(|e| io_to_corrupt_eof(e, self.stream_pos, "trailer"))?;
1990        self.stream_pos += hash_len;
1991        self.enforce_max_input()?;
1992        Ok(b)
1993    }
1994}
1995
1996/// Apply a git "patch delta" to `base`, producing the patched result.
1997///
1998/// The delta binary format is:
1999/// 1. Source size: variable-length little-endian integer (must equal
2000///    `base.len()`).
2001/// 2. Destination size: variable-length little-endian integer.
2002/// 3. A sequence of COPY (MSB set) and INSERT (MSB clear) instructions.
2003///
2004/// # Errors
2005///
2006/// Returns [`Error::CorruptObject`] if the delta is malformed, the source-size
2007/// field does not match `base.len()`, or the result length does not match the
2008/// declared destination size.
2009pub fn apply_delta(base: &[u8], delta: &[u8]) -> Result<Vec<u8>> {
2010    let mut result = Vec::new();
2011    apply_delta_into(&mut result, base, delta)?;
2012    Ok(result)
2013}
2014
2015/// Apply `delta` onto `base`, reusing `out` storage (cleared first, capacity grown as needed).
2016///
2017/// Same semantics as [`apply_delta`]; prefer this when resolving multi-hop chains.
2018pub fn apply_delta_into(out: &mut Vec<u8>, base: &[u8], delta: &[u8]) -> Result<()> {
2019    let mut pos = 0usize;
2020
2021    let src_size = read_delta_varint(delta, &mut pos)?;
2022    if src_size != base.len() {
2023        return Err(Error::CorruptObject(format!(
2024            "delta source size {src_size} != base size {}",
2025            base.len()
2026        )));
2027    }
2028    let dest_size = read_delta_varint(delta, &mut pos)?;
2029    out.clear();
2030    if out.capacity() < dest_size {
2031        out.reserve(dest_size - out.capacity());
2032    }
2033
2034    while pos < delta.len() {
2035        let cmd = delta[pos];
2036        pos += 1;
2037        if cmd == 0 {
2038            return Err(Error::CorruptObject(
2039                "reserved opcode 0 in delta stream".to_owned(),
2040            ));
2041        }
2042        if cmd & 0x80 != 0 {
2043            // COPY instruction: up to 4 offset bytes (bits 0-3) and up to 3
2044            // size bytes (bits 4-6) are present, each controlled by a flag bit.
2045            let mut offset = 0usize;
2046            let mut size = 0usize;
2047
2048            macro_rules! maybe_read_byte {
2049                ($flag:expr, $shift:expr, $target:expr) => {
2050                    if cmd & $flag != 0 {
2051                        let b = *delta.get(pos).ok_or_else(|| {
2052                            Error::CorruptObject("truncated delta COPY operand".to_owned())
2053                        })?;
2054                        pos += 1;
2055                        $target |= (b as usize) << $shift;
2056                    }
2057                };
2058            }
2059
2060            maybe_read_byte!(0x01, 0, offset);
2061            maybe_read_byte!(0x02, 8, offset);
2062            maybe_read_byte!(0x04, 16, offset);
2063            maybe_read_byte!(0x08, 24, offset);
2064            maybe_read_byte!(0x10, 0, size);
2065            maybe_read_byte!(0x20, 8, size);
2066            maybe_read_byte!(0x40, 16, size);
2067
2068            if size == 0 {
2069                size = 0x10000;
2070            }
2071
2072            let end = offset.checked_add(size).ok_or_else(|| {
2073                Error::CorruptObject("delta COPY range overflows usize".to_owned())
2074            })?;
2075            let chunk = base.get(offset..end).ok_or_else(|| {
2076                Error::CorruptObject(format!(
2077                    "delta COPY [{offset},{end}) out of range (base is {} bytes)",
2078                    base.len()
2079                ))
2080            })?;
2081            out.extend_from_slice(chunk);
2082        } else {
2083            // INSERT instruction: copy the next `cmd` literal bytes verbatim.
2084            let n = cmd as usize;
2085            let chunk = delta
2086                .get(pos..pos + n)
2087                .ok_or_else(|| Error::CorruptObject("truncated delta INSERT data".to_owned()))?;
2088            out.extend_from_slice(chunk);
2089            pos += n;
2090        }
2091    }
2092
2093    if out.len() != dest_size {
2094        return Err(Error::CorruptObject(format!(
2095            "delta produced {} bytes but expected {dest_size}",
2096            out.len()
2097        )));
2098    }
2099
2100    Ok(())
2101}
2102
2103/// Uncompressed size of the object produced when `delta` is applied to its base.
2104///
2105/// Reads only the second varint in the Git binary delta stream (after `src_size`).
2106/// Returns [`None`] when `data` ends before both varints are complete (incremental zlib prefix
2107/// decoding); callers with a full delta buffer should treat [`None`] as a truncated stream.
2108pub(crate) fn delta_uncompressed_result_size_if_complete(data: &[u8]) -> Result<Option<usize>> {
2109    let mut pos = 0;
2110    let Some(_src_size) = read_delta_varint_if_complete(data, &mut pos)? else {
2111        return Ok(None);
2112    };
2113    read_delta_varint_if_complete(data, &mut pos)
2114}
2115
2116fn read_delta_varint_if_complete(data: &[u8], pos: &mut usize) -> Result<Option<usize>> {
2117    let start = *pos;
2118    let mut value = 0usize;
2119    let mut shift = 0u32;
2120    while *pos < data.len() {
2121        let b = data[*pos];
2122        *pos += 1;
2123        value |= ((b & 0x7f) as usize) << shift;
2124        shift += 7;
2125        if b & 0x80 == 0 {
2126            return Ok(Some(value));
2127        }
2128        if shift > 35 {
2129            return Err(Error::CorruptObject(
2130                "delta varint exceeds supported width".to_owned(),
2131            ));
2132        }
2133    }
2134    *pos = start;
2135    Ok(None)
2136}
2137
2138/// Read a variable-length little-endian integer from `data` starting at `*pos`.
2139///
2140/// Advances `*pos` past the consumed bytes.
2141fn read_delta_varint(data: &[u8], pos: &mut usize) -> Result<usize> {
2142    let mut value = 0usize;
2143    let mut shift = 0u32;
2144    loop {
2145        let b = *data
2146            .get(*pos)
2147            .ok_or_else(|| Error::CorruptObject("truncated delta varint".to_owned()))?;
2148        *pos += 1;
2149        value |= ((b & 0x7f) as usize) << shift;
2150        shift += 7;
2151        if b & 0x80 == 0 {
2152            break;
2153        }
2154    }
2155    Ok(value)
2156}
2157
2158#[cfg(test)]
2159mod tests {
2160    #![allow(clippy::vec_init_then_push)]
2161    use super::*;
2162
2163    // Helper: build a minimal pack from a list of (kind, data) pairs.
2164    // Returns the raw pack bytes.
2165    fn make_pack(objects: &[(ObjectKind, &[u8])]) -> Vec<u8> {
2166        use flate2::write::ZlibEncoder;
2167        use std::io::Write;
2168
2169        let mut entries: Vec<Vec<u8>> = Vec::new();
2170        for (kind, data) in objects {
2171            let type_code: u8 = match kind {
2172                ObjectKind::Commit => 1,
2173                ObjectKind::Tree => 2,
2174                ObjectKind::Blob => 3,
2175                ObjectKind::Tag => 4,
2176            };
2177            // Encode type+size header.
2178            let mut header = Vec::new();
2179            let mut size = data.len();
2180            let first = ((type_code & 0x7) << 4) | (size & 0x0f) as u8;
2181            size >>= 4;
2182            if size > 0 {
2183                header.push(first | 0x80);
2184                while size > 0 {
2185                    let b = (size & 0x7f) as u8;
2186                    size >>= 7;
2187                    header.push(if size > 0 { b | 0x80 } else { b });
2188                }
2189            } else {
2190                header.push(first);
2191            }
2192            // zlib-compress data.
2193            let mut enc = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
2194            enc.write_all(data).unwrap();
2195            let compressed = enc.finish().unwrap();
2196            let mut entry = header;
2197            entry.extend_from_slice(&compressed);
2198            entries.push(entry);
2199        }
2200
2201        // Assemble: PACK + version(2) + count + entries + SHA-1.
2202        let mut pack = Vec::new();
2203        pack.extend_from_slice(b"PACK");
2204        pack.extend_from_slice(&2u32.to_be_bytes());
2205        pack.extend_from_slice(&(objects.len() as u32).to_be_bytes());
2206        for entry in &entries {
2207            pack.extend_from_slice(entry);
2208        }
2209        pack.extend_from_slice(&HashAlgo::Sha1.checksum_digest(&pack));
2210        pack
2211    }
2212
2213    #[test]
2214    fn test_apply_delta_simple() {
2215        // Build a trivial delta: insert "hello world".
2216        let base = b"hello";
2217        let mut delta = Vec::new();
2218        // src_size = 5
2219        delta.push(5u8);
2220        // dest_size = 11
2221        delta.push(11u8);
2222        // COPY instruction: copy base[0..5]
2223        // cmd = 0x80 | 0x01 (offset present, byte 0) | 0x10 (size byte 0)
2224        delta.push(0x80 | 0x01 | 0x10); // 0x91
2225        delta.push(0u8); // offset = 0
2226        delta.push(5u8); // size = 5
2227                         // INSERT " world" (6 bytes)
2228        delta.push(6u8);
2229        delta.extend_from_slice(b" world");
2230
2231        let result = apply_delta(base, &delta).unwrap();
2232        assert_eq!(result, b"hello world");
2233    }
2234
2235    #[test]
2236    fn test_apply_delta_insert_only() {
2237        let base = b"";
2238        let mut delta = Vec::new();
2239        delta.push(0u8); // src_size = 0
2240        delta.push(5u8); // dest_size = 5
2241        delta.push(5u8); // INSERT 5 bytes
2242        delta.extend_from_slice(b"hello");
2243
2244        let result = apply_delta(base, &delta).unwrap();
2245        assert_eq!(result, b"hello");
2246    }
2247
2248    #[test]
2249    fn test_apply_delta_copy_only() {
2250        let base = b"abcdef";
2251        let mut delta = Vec::new();
2252        delta.push(6u8); // src_size = 6
2253        delta.push(3u8); // dest_size = 3
2254                         // COPY base[2..5]: offset=2, size=3
2255                         // cmd = 0x80 | 0x01 | 0x10
2256        delta.push(0x91u8);
2257        delta.push(2u8); // offset = 2
2258        delta.push(3u8); // size = 3
2259
2260        let result = apply_delta(base, &delta).unwrap();
2261        assert_eq!(result, b"cde");
2262    }
2263
2264    #[test]
2265    fn test_apply_delta_size_zero_means_65536() {
2266        // A COPY with size bytes all zero means 0x10000 = 65536.
2267        let base = vec![0xABu8; 65536];
2268        let mut delta = Vec::new();
2269        // src_size = 65536, encoded as 3 bytes little-endian varint
2270        delta.push(0x80 | (65536 & 0x7f) as u8); // 0
2271        delta.push(0x80 | ((65536 >> 7) & 0x7f) as u8); // 0x80
2272        delta.push(((65536 >> 14) & 0x7f) as u8); // 4
2273                                                  // dest_size = 65536, same
2274        delta.push(0x80 | (65536 & 0x7f) as u8);
2275        delta.push(0x80 | ((65536 >> 7) & 0x7f) as u8);
2276        delta.push(((65536 >> 14) & 0x7f) as u8);
2277        // COPY: offset=0 (no offset bytes), size=0 (no size bytes) → means 0x10000
2278        // cmd = 0x80 (no offset/size bytes present at all → offset=0, size=0→65536)
2279        delta.push(0x80u8);
2280
2281        let result = apply_delta(&base, &delta).unwrap();
2282        assert_eq!(result.len(), 65536);
2283        assert!(result.iter().all(|&b| b == 0xAB));
2284    }
2285
2286    #[cfg_attr(
2287        not(feature = "full-tests"),
2288        ignore = "starts system git or is heavy; run with --features full-tests"
2289    )]
2290    #[test]
2291    fn test_unpack_objects_blobs() {
2292        use tempfile::TempDir;
2293        let tmp = TempDir::new().unwrap();
2294        let objects_dir = tmp.path().join("objects");
2295        std::fs::create_dir_all(&objects_dir).unwrap();
2296        let odb = Odb::new(&objects_dir);
2297
2298        let pack = make_pack(&[
2299            (ObjectKind::Blob, b"hello\n"),
2300            (ObjectKind::Blob, b"world\n"),
2301        ]);
2302
2303        let opts = UnpackOptions::default();
2304        let count = unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap();
2305        assert_eq!(count, 2);
2306
2307        // Verify both blobs can be read back.
2308        let oid1 = HashAlgo::Sha1
2309            .hash_object(ObjectKind::Blob, b"hello\n")
2310            .expect("hash");
2311        let oid2 = HashAlgo::Sha1
2312            .hash_object(ObjectKind::Blob, b"world\n")
2313            .expect("hash");
2314        let obj1 = odb.read(&oid1).unwrap();
2315        let obj2 = odb.read(&oid2).unwrap();
2316        assert_eq!(obj1.data, b"hello\n");
2317        assert_eq!(obj2.data, b"world\n");
2318    }
2319
2320    #[cfg_attr(
2321        not(feature = "full-tests"),
2322        ignore = "starts system git or is heavy; run with --features full-tests"
2323    )]
2324    #[test]
2325    fn test_unpack_objects_empty_tree() {
2326        use tempfile::TempDir;
2327        let tmp = TempDir::new().unwrap();
2328        let objects_dir = tmp.path().join("objects");
2329        std::fs::create_dir_all(&objects_dir).unwrap();
2330        let odb = Odb::new(&objects_dir);
2331
2332        let pack = make_pack(&[(ObjectKind::Tree, b"")]);
2333        let opts = UnpackOptions::default();
2334        assert_eq!(
2335            unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap(),
2336            1
2337        );
2338        let oid = HashAlgo::Sha1
2339            .hash_object(ObjectKind::Tree, b"")
2340            .expect("hash");
2341        assert!(odb.exists(&oid));
2342        let loose = objects_dir
2343            .join(oid.loose_prefix())
2344            .join(oid.loose_suffix());
2345        assert!(
2346            loose.is_file(),
2347            "empty tree must be materialized as a loose object during unpack"
2348        );
2349    }
2350
2351    #[cfg_attr(
2352        not(feature = "full-tests"),
2353        ignore = "starts system git or is heavy; run with --features full-tests"
2354    )]
2355    #[test]
2356    fn test_strict_skips_gitlink_tree_entries() {
2357        use crate::index::{MODE_GITLINK, MODE_REGULAR};
2358        use crate::objects::{serialize_tree, TreeEntry};
2359
2360        // A submodule commit oid that is NOT in the pack/ODB (lives in the
2361        // submodule repository, like a 160000 gitlink target on push).
2362        let submodule_oid = ObjectId::from_hex(&"7f".repeat(20)).unwrap();
2363
2364        // Superproject tree referencing the submodule via a gitlink entry.
2365        let tree_data = serialize_tree(&[TreeEntry {
2366            mode: MODE_GITLINK,
2367            name: b"sub".to_vec(),
2368            oid: submodule_oid,
2369        }]);
2370        let tree_oid = HashAlgo::Sha1
2371            .hash_object(ObjectKind::Tree, &tree_data)
2372            .expect("hash");
2373
2374        // Strict connectivity must NOT flag the gitlink target as missing,
2375        // matching upstream git (git/fsck.c skips S_ISGITLINK entries).
2376        let mut pack = HashMap::new();
2377        pack.insert(tree_oid, (ObjectKind::Tree, tree_data.clone()));
2378        assert!(strict_verify_packed_references(None, &pack).is_ok());
2379
2380        // Regression guard: a non-gitlink (regular file) entry pointing at an
2381        // absent blob must still be reported as a strict connectivity error.
2382        let bad_tree = serialize_tree(&[TreeEntry {
2383            mode: MODE_REGULAR,
2384            name: b"file".to_vec(),
2385            oid: ObjectId::from_hex(&"ab".repeat(20)).unwrap(),
2386        }]);
2387        let bad_oid = HashAlgo::Sha1
2388            .hash_object(ObjectKind::Tree, &bad_tree)
2389            .expect("hash");
2390        let mut bad_pack = HashMap::new();
2391        bad_pack.insert(bad_oid, (ObjectKind::Tree, bad_tree));
2392        assert!(matches!(
2393            strict_verify_packed_references(None, &bad_pack),
2394            Err(Error::CorruptObject(_))
2395        ));
2396    }
2397
2398    /// `Read` that returns at most `max_len` bytes per call (simulates side-band chunking).
2399    struct ChunkedReader<'a> {
2400        data: &'a [u8],
2401        pos: usize,
2402        max_len: usize,
2403    }
2404
2405    impl io::Read for ChunkedReader<'_> {
2406        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2407            if self.pos >= self.data.len() {
2408                return Ok(0);
2409            }
2410            let take = (self.data.len() - self.pos)
2411                .min(self.max_len)
2412                .min(buf.len());
2413            buf[..take].copy_from_slice(&self.data[self.pos..self.pos + take]);
2414            self.pos += take;
2415            Ok(take)
2416        }
2417    }
2418
2419    #[cfg_attr(
2420        not(feature = "full-tests"),
2421        ignore = "starts system git or is heavy; run with --features full-tests"
2422    )]
2423    #[test]
2424    fn test_unpack_objects_chunked_read_matches_full_buffer() {
2425        use tempfile::TempDir;
2426        let pack = make_pack(&[(ObjectKind::Blob, b"chunked-stream")]);
2427        let opts = UnpackOptions::default();
2428        let oid = HashAlgo::Sha1
2429            .hash_object(ObjectKind::Blob, b"chunked-stream")
2430            .expect("hash");
2431
2432        let tmp = TempDir::new().unwrap();
2433        let objects_dir = tmp.path().join("objects");
2434        std::fs::create_dir_all(&objects_dir).unwrap();
2435        let odb = Odb::new(&objects_dir);
2436        assert_eq!(
2437            unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap(),
2438            1
2439        );
2440        assert!(odb.exists(&oid));
2441
2442        let tmp2 = TempDir::new().unwrap();
2443        let objects_dir2 = tmp2.path().join("objects");
2444        std::fs::create_dir_all(&objects_dir2).unwrap();
2445        let odb2 = Odb::new(&objects_dir2);
2446        let mut chunked = ChunkedReader {
2447            data: pack.as_slice(),
2448            pos: 0,
2449            max_len: 8,
2450        };
2451        assert_eq!(unpack_objects(&mut chunked, &odb2, &opts).unwrap(), 1);
2452        assert!(odb2.exists(&oid));
2453    }
2454
2455    #[cfg_attr(
2456        not(feature = "full-tests"),
2457        ignore = "starts system git or is heavy; run with --features full-tests"
2458    )]
2459    #[test]
2460    fn test_unpack_objects_dry_run_writes_nothing() {
2461        use tempfile::TempDir;
2462        let tmp = TempDir::new().unwrap();
2463        let objects_dir = tmp.path().join("objects");
2464        std::fs::create_dir_all(&objects_dir).unwrap();
2465        let odb = Odb::new(&objects_dir);
2466
2467        let pack = make_pack(&[(ObjectKind::Blob, b"test content")]);
2468
2469        let opts = UnpackOptions {
2470            dry_run: true,
2471            quiet: true,
2472            strict: false,
2473            allowed_missing: Default::default(),
2474            allow_promisor_missing_references: false,
2475            max_input_bytes: None,
2476            ..Default::default()
2477        };
2478        let count = unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap();
2479        assert_eq!(count, 1);
2480
2481        // Nothing should be written.
2482        let oid = HashAlgo::Sha1
2483            .hash_object(ObjectKind::Blob, b"test content")
2484            .expect("hash");
2485        assert!(!odb.exists(&oid));
2486    }
2487
2488    #[cfg_attr(
2489        not(feature = "full-tests"),
2490        ignore = "starts system git or is heavy; run with --features full-tests"
2491    )]
2492    #[test]
2493    fn test_unpack_objects_bad_signature() {
2494        use tempfile::TempDir;
2495        let tmp = TempDir::new().unwrap();
2496        let objects_dir = tmp.path().join("objects");
2497        std::fs::create_dir_all(&objects_dir).unwrap();
2498        let odb = Odb::new(&objects_dir);
2499
2500        let mut bad = b"NOPE\x00\x00\x00\x02\x00\x00\x00\x00".to_vec();
2501        bad.extend_from_slice(&[0u8; 20]);
2502        let opts = UnpackOptions::default();
2503        let err = unpack_objects(&mut bad.as_slice(), &odb, &opts).unwrap_err();
2504        assert!(err.to_string().contains("invalid signature"));
2505    }
2506
2507    #[cfg_attr(
2508        not(feature = "full-tests"),
2509        ignore = "starts system git or is heavy; run with --features full-tests"
2510    )]
2511    #[test]
2512    fn test_unpack_objects_checksum_mismatch() {
2513        use tempfile::TempDir;
2514        let tmp = TempDir::new().unwrap();
2515        let objects_dir = tmp.path().join("objects");
2516        std::fs::create_dir_all(&objects_dir).unwrap();
2517        let odb = Odb::new(&objects_dir);
2518
2519        let mut pack = make_pack(&[(ObjectKind::Blob, b"data")]);
2520        // Corrupt the trailing checksum.
2521        let n = pack.len();
2522        pack[n - 1] ^= 0xFF;
2523
2524        let opts = UnpackOptions::default();
2525        let err = unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap_err();
2526        assert!(err.to_string().contains("checksum"));
2527    }
2528
2529    #[test]
2530    fn test_apply_delta_source_size_mismatch() {
2531        let base = b"hi";
2532        let delta = [3u8, 2u8, 2u8, b'h', b'i']; // src_size=3 != base.len()=2
2533        let err = apply_delta(base, &delta).unwrap_err();
2534        assert!(err.to_string().contains("source size"));
2535    }
2536
2537    #[test]
2538    fn unpack_many_objects_does_not_retain_all_payloads() {
2539        let _lock = TEST_UNPACK_RETAIN_LOCK.lock().expect("retain lock");
2540        const N: usize = 2500;
2541        let blobs: Vec<(ObjectKind, Vec<u8>)> = (0..N)
2542            .map(|i| (ObjectKind::Blob, format!("blob-{i}\n").into_bytes()))
2543            .collect();
2544        let blob_refs: Vec<(ObjectKind, &[u8])> =
2545            blobs.iter().map(|(k, d)| (*k, d.as_slice())).collect();
2546        let pack = make_pack(&blob_refs);
2547        let tmp = tempfile::tempdir().expect("tempdir");
2548        let odb = Odb::new(tmp.path());
2549        let cache_budget = 256 * 1024;
2550        test_set_unpack_base_cache_byte_limit(cache_budget);
2551        let count =
2552            unpack_objects(&mut pack.as_slice(), &odb, &UnpackOptions::default()).expect("unpack");
2553        assert_eq!(count, N);
2554        assert!(
2555            test_peak_unpack_retained_bytes() <= cache_budget + 128 * 1024,
2556            "peak retained {} exceeds cache budget {}",
2557            test_peak_unpack_retained_bytes(),
2558            cache_budget
2559        );
2560        test_set_unpack_base_cache_byte_limit(0);
2561    }
2562
2563    #[test]
2564    fn unpack_many_deltified_blobs_respects_base_cache_budget() {
2565        let _lock = TEST_UNPACK_RETAIN_LOCK.lock().expect("retain lock");
2566        use crate::objects::{serialize_commit, CommitData};
2567        use grit_test_support::objects::{HashAlgo as FixtureAlgo, ObjectKind as PK, PackBuilder};
2568
2569        const N_DELTAS: usize = 350;
2570        const BLOB_SIZE: usize = 48 * 1024;
2571        let base: Vec<u8> = (0..BLOB_SIZE).map(|i| (i % 251) as u8).collect();
2572        let tree_oid = HashAlgo::Sha1
2573            .hash_object(ObjectKind::Tree, b"")
2574            .expect("hash");
2575        let commit = CommitData {
2576            tree: tree_oid,
2577            parents: vec![],
2578            author: "A <a@b.com>".into(),
2579            committer: "A <a@b.com>".into(),
2580            author_raw: Vec::new(),
2581            committer_raw: Vec::new(),
2582            encoding: None,
2583            message: "m".into(),
2584            raw_message: None,
2585            extra_headers: Vec::new(),
2586        };
2587        let commit_data = serialize_commit(&commit);
2588
2589        let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
2590        pb.add_full(PK::Tree, b"");
2591        pb.add_full(PK::Commit, &commit_data);
2592        let base_idx = pb.add_full(PK::Blob, &base);
2593        for i in 0..N_DELTAS {
2594            let mut variant = base.clone();
2595            variant[BLOB_SIZE - 1] = (i % 256) as u8;
2596            let delta = crate::delta_encode::encode_lcp_delta(&base, &variant).expect("delta");
2597            pb.add_ofs_delta(base_idx, &delta, delta.len());
2598        }
2599        let pack = pb.build().bytes;
2600
2601        let tmp = tempfile::tempdir().expect("tempdir");
2602        let odb = Odb::new(tmp.path());
2603        let cache_budget = 512 * 1024;
2604        test_set_unpack_base_cache_byte_limit(cache_budget);
2605        let opts = UnpackOptions {
2606            strict: true,
2607            ..Default::default()
2608        };
2609        let count = unpack_objects(&mut pack.as_slice(), &odb, &opts).expect("unpack");
2610        assert_eq!(count, N_DELTAS + 3);
2611        assert!(
2612            test_peak_unpack_retained_bytes() <= cache_budget + 256 * 1024,
2613            "peak retained {} exceeds cache budget {}",
2614            test_peak_unpack_retained_bytes(),
2615            cache_budget
2616        );
2617        test_set_unpack_base_cache_byte_limit(0);
2618    }
2619
2620    #[test]
2621    fn pack_index_many_blobs_without_retaining_all_payloads() {
2622        const N: usize = 1500;
2623        let blobs: Vec<(ObjectKind, Vec<u8>)> = (0..N)
2624            .map(|i| (ObjectKind::Blob, format!("blob-{i}\n").into_bytes()))
2625            .collect();
2626        let blob_refs: Vec<(ObjectKind, &[u8])> =
2627            blobs.iter().map(|(k, d)| (*k, d.as_slice())).collect();
2628        let pack = make_pack(&blob_refs);
2629        let tmp = tempfile::tempdir().expect("tempdir");
2630        let odb = Odb::new(tmp.path());
2631        let started = std::time::Instant::now();
2632        let records = pack_index_records_from_bytes(&pack, &odb).expect("index records");
2633        let elapsed = started.elapsed();
2634        assert_eq!(records.len(), N, "one index row per object");
2635        assert!(
2636            elapsed.as_secs() < 30,
2637            "indexing {N} blobs took {:?}; expected linear scan without full retention blow-up",
2638            elapsed
2639        );
2640    }
2641
2642    fn pack_with_ref_delta(base: &[u8], target: &[u8]) -> (Vec<u8>, ObjectId, ObjectId) {
2643        use grit_test_support::objects::{HashAlgo as FixtureAlgo, ObjectKind as PK, PackBuilder};
2644
2645        let delta = crate::delta_encode::encode_lcp_delta(base, target).expect("delta");
2646        let base_oid = HashAlgo::Sha1
2647            .hash_object(ObjectKind::Blob, base)
2648            .expect("hash");
2649        let target_oid = HashAlgo::Sha1
2650            .hash_object(ObjectKind::Blob, target)
2651            .expect("hash");
2652        let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
2653        pb.add_full(PK::Blob, base);
2654        pb.add_ref_delta(base_oid.as_bytes(), &delta, delta.len());
2655        (pb.build().bytes, base_oid, target_oid)
2656    }
2657
2658    #[cfg_attr(
2659        not(feature = "full-tests"),
2660        ignore = "starts system git or is heavy; run with --features full-tests"
2661    )]
2662    #[test]
2663    fn test_unpack_objects_resolves_ref_and_ofs_deltas() {
2664        use grit_test_support::objects::{HashAlgo as FixtureAlgo, ObjectKind as PK, PackBuilder};
2665
2666        let base = b"base payload";
2667        let target = b"base payload-extended";
2668        let delta = crate::delta_encode::encode_lcp_delta(base, target).unwrap();
2669        let base_oid = HashAlgo::Sha1
2670            .hash_object(ObjectKind::Blob, base)
2671            .expect("hash");
2672
2673        let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
2674        let base_idx = pb.add_full(PK::Blob, base);
2675        pb.add_ofs_delta(base_idx, &delta, delta.len());
2676        let ofs_pack = pb.build().bytes;
2677
2678        let tmp = tempfile::tempdir().unwrap();
2679        let odb = Odb::new(tmp.path());
2680        let opts = UnpackOptions::default();
2681        assert_eq!(
2682            unpack_objects(&mut ofs_pack.as_slice(), &odb, &opts).unwrap(),
2683            2
2684        );
2685        let target_oid = HashAlgo::Sha1
2686            .hash_object(ObjectKind::Blob, target)
2687            .expect("hash");
2688        assert_eq!(odb.read(&target_oid).unwrap().data, target);
2689
2690        let (ref_pack, _, ref_target_oid) = pack_with_ref_delta(base, target);
2691        let tmp2 = tempfile::tempdir().unwrap();
2692        let odb2 = Odb::new(tmp2.path());
2693        assert_eq!(
2694            unpack_objects(&mut ref_pack.as_slice(), &odb2, &opts).unwrap(),
2695            2
2696        );
2697        assert_eq!(odb2.read(&ref_target_oid).unwrap().data, target);
2698        assert!(odb2.exists(&base_oid));
2699    }
2700
2701    #[cfg_attr(
2702        not(feature = "full-tests"),
2703        ignore = "starts system git or is heavy; run with --features full-tests"
2704    )]
2705    #[test]
2706    fn test_unpack_objects_ref_delta_base_from_loose() {
2707        let base = b"loose-base";
2708        let target = b"loose-base-v2";
2709        let (_, base_oid, target_oid) = pack_with_ref_delta(base, target);
2710
2711        let tmp = tempfile::tempdir().unwrap();
2712        let odb = Odb::new(tmp.path());
2713        odb.write_local(ObjectKind::Blob, base).unwrap();
2714        // Pack only contains the delta slot (thin pack).
2715        let delta_only = {
2716            use grit_test_support::objects::{HashAlgo as FixtureAlgo, PackBuilder};
2717            let delta = crate::delta_encode::encode_lcp_delta(base, target).unwrap();
2718            let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
2719            pb.add_ref_delta(base_oid.as_bytes(), &delta, delta.len());
2720            pb.build().bytes
2721        };
2722        let pack = delta_only;
2723
2724        let opts = UnpackOptions::default();
2725        assert_eq!(
2726            unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap(),
2727            1
2728        );
2729        assert_eq!(odb.read(&target_oid).unwrap().data, target);
2730    }
2731
2732    #[cfg_attr(
2733        not(feature = "full-tests"),
2734        ignore = "starts system git or is heavy; run with --features full-tests"
2735    )]
2736    #[test]
2737    fn test_unpack_objects_strict_commit_tree_tag() {
2738        use crate::objects::{serialize_commit, serialize_tag, CommitData, TagData};
2739
2740        let tree_data = b"";
2741        let tree_oid = HashAlgo::Sha1
2742            .hash_object(ObjectKind::Tree, tree_data)
2743            .expect("hash");
2744        let commit = CommitData {
2745            tree: tree_oid,
2746            parents: vec![],
2747            author: "A <a@b.com>".into(),
2748            committer: "A <a@b.com>".into(),
2749            author_raw: Vec::new(),
2750            committer_raw: Vec::new(),
2751            encoding: None,
2752            message: "msg".into(),
2753            raw_message: None,
2754            extra_headers: Vec::new(),
2755        };
2756        let commit_data = serialize_commit(&commit);
2757        let commit_oid = HashAlgo::Sha1
2758            .hash_object(ObjectKind::Commit, &commit_data)
2759            .expect("hash");
2760        let tag = TagData {
2761            object: commit_oid,
2762            object_type: "commit".into(),
2763            tag: "v1".into(),
2764            tagger: Some("T <t@e.com>".into()),
2765            extra_headers: Vec::new(),
2766            message: "annotated".into(),
2767        };
2768        let tag_data = serialize_tag(&tag);
2769        let pack = make_pack(&[
2770            (ObjectKind::Tree, tree_data),
2771            (ObjectKind::Commit, &commit_data),
2772            (ObjectKind::Tag, &tag_data),
2773        ]);
2774
2775        let tmp = tempfile::tempdir().unwrap();
2776        let odb = Odb::new(tmp.path());
2777        let opts = UnpackOptions {
2778            strict: true,
2779            ..Default::default()
2780        };
2781        assert_eq!(
2782            unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap(),
2783            3
2784        );
2785        assert!(odb.exists(&commit_oid));
2786    }
2787
2788    #[cfg_attr(
2789        not(feature = "full-tests"),
2790        ignore = "starts system git or is heavy; run with --features full-tests"
2791    )]
2792    #[test]
2793    fn test_unpack_objects_strict_shallow_skips_missing_parent() {
2794        use crate::objects::{serialize_commit, CommitData};
2795
2796        let tree_oid = HashAlgo::Sha1
2797            .hash_object(ObjectKind::Tree, b"")
2798            .expect("hash");
2799        let missing_parent = ObjectId::from_hex(&"cc".repeat(20)).unwrap();
2800        let commit = CommitData {
2801            tree: tree_oid,
2802            parents: vec![missing_parent],
2803            author: "A <a@b.com>".into(),
2804            committer: "A <a@b.com>".into(),
2805            author_raw: Vec::new(),
2806            committer_raw: Vec::new(),
2807            encoding: None,
2808            message: "msg".into(),
2809            raw_message: None,
2810            extra_headers: Vec::new(),
2811        };
2812        let commit_data = serialize_commit(&commit);
2813        let commit_oid = HashAlgo::Sha1
2814            .hash_object(ObjectKind::Commit, &commit_data)
2815            .expect("hash");
2816        let pack = make_pack(&[(ObjectKind::Tree, b""), (ObjectKind::Commit, &commit_data)]);
2817
2818        let tmp = tempfile::tempdir().unwrap();
2819        let odb = Odb::new(tmp.path());
2820        let mut shallow = HashSet::new();
2821        shallow.insert(commit_oid);
2822        let opts = UnpackOptions {
2823            strict: true,
2824            shallow_boundaries: shallow,
2825            ..Default::default()
2826        };
2827        assert_eq!(
2828            unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap(),
2829            2
2830        );
2831    }
2832
2833    #[cfg_attr(
2834        not(feature = "full-tests"),
2835        ignore = "starts system git or is heavy; run with --features full-tests"
2836    )]
2837    #[test]
2838    fn test_unpack_objects_max_input_bytes() {
2839        let pack = make_pack(&[(ObjectKind::Blob, b"small")]);
2840        let tmp = tempfile::tempdir().unwrap();
2841        let odb = Odb::new(tmp.path());
2842        let opts = UnpackOptions {
2843            max_input_bytes: Some(4),
2844            ..Default::default()
2845        };
2846        let err = unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap_err();
2847        assert!(err.to_string().contains("maximum allowed size"));
2848    }
2849
2850    #[cfg_attr(
2851        not(feature = "full-tests"),
2852        ignore = "starts system git or is heavy; run with --features full-tests"
2853    )]
2854    #[test]
2855    fn test_unpack_objects_unresolved_ref_delta() {
2856        let missing = ObjectId::from_hex(&"dd".repeat(20)).unwrap();
2857        use grit_test_support::objects::{HashAlgo as FixtureAlgo, PackBuilder};
2858        let delta = [0u8, 1u8, 1u8, b'x'];
2859        let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
2860        pb.add_ref_delta(missing.as_bytes(), &delta, delta.len());
2861        let pack = pb.build().bytes;
2862
2863        let tmp = tempfile::tempdir().unwrap();
2864        let odb = Odb::new(tmp.path());
2865        let err =
2866            unpack_objects(&mut pack.as_slice(), &odb, &UnpackOptions::default()).unwrap_err();
2867        assert!(err.to_string().contains("could not be resolved"));
2868    }
2869
2870    #[cfg_attr(
2871        not(feature = "full-tests"),
2872        ignore = "starts system git or is heavy; run with --features full-tests"
2873    )]
2874    #[test]
2875    fn missing_ref_delta_base_is_typed_on_every_ingest_path() {
2876        use grit_test_support::objects::{HashAlgo as FixtureAlgo, PackBuilder};
2877        let delta = [0u8, 1u8, 1u8, b'x'];
2878        let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
2879        pb.add_ref_delta(&[0xdd; 20], &delta, delta.len());
2880        pb.add_ref_delta(&[0xee; 20], &delta, delta.len());
2881        let pack = pb.build().bytes;
2882        let tmp = tempfile::tempdir().unwrap();
2883        let odb = Odb::new(tmp.path());
2884
2885        let unpacked = unpack_objects(&mut pack.as_slice(), &odb, &UnpackOptions::default());
2886        assert!(
2887            matches!(unpacked, Err(Error::UnresolvedDeltas { count: 2 })),
2888            "{unpacked:?}"
2889        );
2890        let mapped = pack_bytes_to_object_map(&pack, &odb);
2891        assert!(
2892            matches!(mapped, Err(Error::UnresolvedDeltas { count: 2 })),
2893            "{mapped:?}"
2894        );
2895        let indexed = pack_index_records_from_bytes(&pack, &odb);
2896        assert!(
2897            matches!(indexed, Err(Error::UnresolvedDeltas { count: 2 })),
2898            "{indexed:?}"
2899        );
2900        assert!(pack_is_thin(&pack, HashAlgo::Sha1));
2901    }
2902
2903    #[test]
2904    fn test_apply_delta_into_matches_apply_delta() {
2905        let base = b"abcdef";
2906        let mut delta = Vec::new();
2907        delta.push(6u8);
2908        delta.push(3u8);
2909        delta.push(0x91u8);
2910        delta.push(2u8);
2911        delta.push(3u8);
2912
2913        let expected = apply_delta(base, &delta).unwrap();
2914        let mut out = Vec::new();
2915        apply_delta_into(&mut out, base, &delta).unwrap();
2916        assert_eq!(out, expected);
2917    }
2918
2919    #[test]
2920    fn test_pack_bytes_to_object_map_ref_delta() {
2921        let base = b"map-base";
2922        let target = b"map-base-2";
2923        let (pack, _, target_oid) = pack_with_ref_delta(base, target);
2924        let tmp = tempfile::tempdir().unwrap();
2925        let odb = Odb::new(tmp.path());
2926        let map = pack_bytes_to_object_map(&pack, &odb).unwrap();
2927        assert_eq!(map.get(&target_oid).unwrap().data, target);
2928    }
2929
2930    #[test]
2931    fn test_pack_is_thin_and_non_thin() {
2932        let base = b"thin";
2933        let target = b"thin-pack";
2934        let (full, base_oid, _) = pack_with_ref_delta(base, target);
2935        assert!(!pack_is_thin(&full, HashAlgo::Sha1));
2936
2937        let delta = crate::delta_encode::encode_lcp_delta(base, target).unwrap();
2938        use grit_test_support::objects::{HashAlgo as FixtureAlgo, PackBuilder};
2939        let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
2940        pb.add_ref_delta(base_oid.as_bytes(), &delta, delta.len());
2941        let thin = pb.build().bytes;
2942        assert!(pack_is_thin(&thin, HashAlgo::Sha1));
2943    }
2944
2945    #[cfg_attr(
2946        not(feature = "full-tests"),
2947        ignore = "starts system git or is heavy; run with --features full-tests"
2948    )]
2949    #[test]
2950    fn test_unpack_zero_byte_blob_via_pack_builder() {
2951        use grit_test_support::objects::{HashAlgo as FixtureAlgo, ObjectKind as PK, PackBuilder};
2952        let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
2953        pb.add_full(PK::Blob, b"");
2954        let pack = pb.build().bytes;
2955        let tmp = tempfile::tempdir().unwrap();
2956        let odb = Odb::new(tmp.path());
2957        assert_eq!(
2958            unpack_objects(&mut pack.as_slice(), &odb, &UnpackOptions::default()).unwrap(),
2959            1
2960        );
2961        let oid = HashAlgo::Sha1
2962            .hash_object(ObjectKind::Blob, b"")
2963            .expect("hash");
2964        assert!(odb.exists(&oid));
2965    }
2966
2967    #[test]
2968    fn test_strict_verify_rejects_missing_tree_reference() {
2969        let tree_oid = HashAlgo::Sha1
2970            .hash_object(ObjectKind::Tree, b"")
2971            .expect("hash");
2972        let missing = ObjectId::from_hex(&"ee".repeat(20)).unwrap();
2973        let bad_tree = crate::objects::serialize_tree(&[crate::objects::TreeEntry {
2974            mode: crate::index::MODE_REGULAR,
2975            name: b"f".to_vec(),
2976            oid: missing,
2977        }]);
2978        let bad_oid = HashAlgo::Sha1
2979            .hash_object(ObjectKind::Tree, &bad_tree)
2980            .expect("hash");
2981        let mut pack = HashMap::new();
2982        pack.insert(tree_oid, (ObjectKind::Tree, b"".to_vec()));
2983        pack.insert(bad_oid, (ObjectKind::Tree, bad_tree));
2984        assert!(strict_verify_packed_references(None, &pack).is_err());
2985    }
2986
2987    #[cfg_attr(
2988        not(feature = "full-tests"),
2989        ignore = "starts system git or is heavy; run with --features full-tests"
2990    )]
2991    #[test]
2992    fn test_unpack_large_blob_materializes_on_disk() {
2993        let big: Vec<u8> = vec![b'x'; 2 * 1024 * 1024 + 1];
2994        let pack = make_pack(&[(ObjectKind::Blob, &big)]);
2995        let tmp = tempfile::tempdir().unwrap();
2996        let odb = Odb::new(tmp.path());
2997        assert_eq!(
2998            unpack_objects(&mut pack.as_slice(), &odb, &UnpackOptions::default()).unwrap(),
2999            1
3000        );
3001        let oid = HashAlgo::Sha1
3002            .hash_object(ObjectKind::Blob, &big)
3003            .expect("hash");
3004        assert_eq!(odb.read(&oid).unwrap().data, big);
3005    }
3006
3007    #[cfg_attr(
3008        not(feature = "full-tests"),
3009        ignore = "starts system git or is heavy; run with --features full-tests"
3010    )]
3011    #[test]
3012    fn truncated_zlib_after_complete_payload_does_not_hang() {
3013        use std::time::{Duration, Instant};
3014
3015        let payload = vec![0u8; 4096];
3016        let mut pack = make_pack(&[(ObjectKind::Blob, &payload)]);
3017        pack.truncate(pack.len() - 21);
3018        let digest = HashAlgo::Sha1.checksum_digest(&pack);
3019        pack.extend_from_slice(&digest);
3020
3021        let tmp = tempfile::tempdir().unwrap();
3022        let odb = Odb::new(tmp.path());
3023        let started = Instant::now();
3024        let err =
3025            unpack_objects(&mut pack.as_slice(), &odb, &UnpackOptions::default()).unwrap_err();
3026        assert!(
3027            started.elapsed() < Duration::from_secs(2),
3028            "unpack hung on truncated zlib"
3029        );
3030        let msg = err.to_string();
3031        assert!(
3032            msg.contains("truncated") || msg.contains("zlib") || msg.contains("mismatch"),
3033            "unexpected error: {msg}"
3034        );
3035    }
3036
3037    #[test]
3038    fn strict_dot_special_does_not_reload_streamed_blobs() {
3039        use std::cell::Cell;
3040
3041        let tmp = tempfile::tempdir().unwrap();
3042        let odb = Odb::new(tmp.path());
3043        let big: Vec<u8> = vec![b'x'; 2 * 1024 * 1024 + 1];
3044        let big_oid = HashAlgo::Sha1
3045            .hash_object(ObjectKind::Blob, &big)
3046            .expect("hash");
3047        let tree_oid = HashAlgo::Sha1
3048            .hash_object(ObjectKind::Tree, b"")
3049            .expect("hash");
3050
3051        struct CountingSink<'a> {
3052            inner: &'a Odb,
3053            reads: Cell<usize>,
3054        }
3055
3056        impl UnpackSink for CountingSink<'_> {
3057            fn hash_algo(&self) -> HashAlgo {
3058                Odb::hash_algo(self.inner)
3059            }
3060
3061            fn write(&self, kind: ObjectKind, data: &[u8], dry_run: bool) -> Result<ObjectId> {
3062                if dry_run {
3063                    return self.inner.hash(kind, data);
3064                }
3065                self.inner.write(kind, data)
3066            }
3067
3068            fn read_stored(&self, oid: &ObjectId) -> Result<Option<Object>> {
3069                self.reads.set(self.reads.get() + 1);
3070                self.inner.read_stored(oid)
3071            }
3072
3073            fn contains(&self, oid: &ObjectId) -> Result<bool> {
3074                Odb::contains(self.inner, oid)
3075            }
3076        }
3077
3078        let mut by_oid = HashMap::new();
3079        by_oid.insert(
3080            big_oid,
3081            PackedObjectEntry::Stored {
3082                kind: ObjectKind::Blob,
3083                oid: big_oid,
3084            },
3085        );
3086        by_oid.insert(
3087            tree_oid,
3088            PackedObjectEntry::DryRun {
3089                kind: ObjectKind::Tree,
3090                data: Vec::new(),
3091            },
3092        );
3093
3094        let sink = CountingSink {
3095            inner: &odb,
3096            reads: Cell::new(0),
3097        };
3098        verify_packed_dot_special_entries(&by_oid, &sink).expect("dot-special walk");
3099        assert_eq!(
3100            sink.reads.get(),
3101            0,
3102            "must not read streamed blobs back for strict fsck"
3103        );
3104    }
3105
3106    #[test]
3107    fn strict_verify_does_not_reload_streamed_blobs() {
3108        use std::cell::Cell;
3109
3110        use crate::index::MODE_REGULAR;
3111        use crate::objects::{serialize_commit, serialize_tree, CommitData, TreeEntry};
3112
3113        let tmp = tempfile::tempdir().unwrap();
3114        let odb = Odb::new(tmp.path());
3115        let big: Vec<u8> = vec![b'x'; 2 * 1024 * 1024 + 1];
3116        let big_oid = odb.write(ObjectKind::Blob, &big).expect("write blob");
3117
3118        let tree_data = serialize_tree(&[TreeEntry {
3119            mode: MODE_REGULAR,
3120            name: b"big.bin".to_vec(),
3121            oid: big_oid,
3122        }]);
3123        let tree_oid = HashAlgo::Sha1
3124            .hash_object(ObjectKind::Tree, &tree_data)
3125            .expect("hash");
3126        let commit = CommitData {
3127            tree: tree_oid,
3128            parents: vec![],
3129            author: "A <a@b.com>".into(),
3130            committer: "A <a@b.com>".into(),
3131            author_raw: Vec::new(),
3132            committer_raw: Vec::new(),
3133            encoding: None,
3134            message: "m".into(),
3135            raw_message: None,
3136            extra_headers: Vec::new(),
3137        };
3138        let commit_data = serialize_commit(&commit);
3139        let commit_oid = HashAlgo::Sha1
3140            .hash_object(ObjectKind::Commit, &commit_data)
3141            .expect("hash");
3142
3143        struct CountingSink<'a> {
3144            inner: &'a Odb,
3145            reads: Cell<usize>,
3146        }
3147
3148        impl UnpackSink for CountingSink<'_> {
3149            fn hash_algo(&self) -> HashAlgo {
3150                Odb::hash_algo(self.inner)
3151            }
3152
3153            fn write(&self, kind: ObjectKind, data: &[u8], dry_run: bool) -> Result<ObjectId> {
3154                if dry_run {
3155                    return self.inner.hash(kind, data);
3156                }
3157                self.inner.write(kind, data)
3158            }
3159
3160            fn read_stored(&self, oid: &ObjectId) -> Result<Option<Object>> {
3161                self.reads.set(self.reads.get() + 1);
3162                self.inner.read_stored(oid)
3163            }
3164
3165            fn contains(&self, oid: &ObjectId) -> Result<bool> {
3166                Odb::contains(self.inner, oid)
3167            }
3168        }
3169
3170        let mut by_oid = HashMap::new();
3171        by_oid.insert(
3172            big_oid,
3173            PackedObjectEntry::Stored {
3174                kind: ObjectKind::Blob,
3175                oid: big_oid,
3176            },
3177        );
3178        by_oid.insert(
3179            tree_oid,
3180            PackedObjectEntry::DryRun {
3181                kind: ObjectKind::Tree,
3182                data: tree_data,
3183            },
3184        );
3185        by_oid.insert(
3186            commit_oid,
3187            PackedObjectEntry::DryRun {
3188                kind: ObjectKind::Commit,
3189                data: commit_data,
3190            },
3191        );
3192
3193        let sink = CountingSink {
3194            inner: &odb,
3195            reads: Cell::new(0),
3196        };
3197        strict_verify_packed_references_map(
3198            &sink,
3199            &by_oid,
3200            &HashSet::new(),
3201            false,
3202            &HashSet::new(),
3203            None,
3204        )
3205        .expect("strict connectivity");
3206        assert_eq!(
3207            sink.reads.get(),
3208            0,
3209            "strict verify must not read streamed blob payloads"
3210        );
3211
3212        let missing = ObjectId::from_hex(&"ab".repeat(20)).unwrap();
3213        let bad_tree = serialize_tree(&[TreeEntry {
3214            mode: MODE_REGULAR,
3215            name: b"missing".to_vec(),
3216            oid: missing,
3217        }]);
3218        let bad_tree_oid = HashAlgo::Sha1
3219            .hash_object(ObjectKind::Tree, &bad_tree)
3220            .expect("hash");
3221        let mut bad = HashMap::new();
3222        bad.insert(
3223            bad_tree_oid,
3224            PackedObjectEntry::DryRun {
3225                kind: ObjectKind::Tree,
3226                data: bad_tree,
3227            },
3228        );
3229        let sink2 = CountingSink {
3230            inner: &odb,
3231            reads: Cell::new(0),
3232        };
3233        assert!(matches!(
3234            strict_verify_packed_references_map(
3235                &sink2,
3236                &bad,
3237                &HashSet::new(),
3238                false,
3239                &HashSet::new(),
3240                None,
3241            ),
3242            Err(Error::CorruptObject(_))
3243        ));
3244        assert_eq!(sink2.reads.get(), 0);
3245    }
3246}