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