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