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