Skip to main content

grit_lib/
unpack_objects.rs

1//! `unpack-objects`: unpack a pack stream into loose objects.
2//!
3//! Reads a pack-format byte stream, validates the trailing checksum, and
4//! writes each object as a loose file in the object database.  Delta objects
5//! (both `OFS_DELTA` and `REF_DELTA`) are resolved against already-unpacked
6//! objects or objects already present in the ODB.
7//!
8//! Large blobs are written to the ODB and dropped from the in-memory maps so
9//! cloning multi-gigabyte repositories does not require holding the full pack
10//! in RAM (streaming read + bounded retention).
11
12use std::borrow::Cow;
13use std::collections::{HashMap, HashSet};
14use std::io::{self, Read};
15
16use flate2::read::ZlibDecoder;
17use flate2::{Decompress, FlushDecompress, Status};
18use sha1::{Digest, Sha1};
19use sha2::{Digest as Sha256Digest, Sha256};
20
21use crate::error::{Error, Result};
22use crate::gitmodules;
23use crate::index::MODE_GITLINK;
24use crate::objects::{parse_commit, parse_tag, parse_tree, HashAlgo, Object, ObjectId, ObjectKind};
25use crate::odb::Odb;
26
27/// Incremental pack checksum hasher matching the repository hash algorithm.
28#[derive(Clone)]
29enum PackHasher {
30    Sha1(Sha1),
31    Sha256(Sha256),
32}
33
34impl PackHasher {
35    fn new(algo: HashAlgo) -> Self {
36        match algo {
37            HashAlgo::Sha1 => Self::Sha1(Sha1::new()),
38            HashAlgo::Sha256 => Self::Sha256(Sha256::new()),
39        }
40    }
41
42    fn update(&mut self, data: &[u8]) {
43        match self {
44            Self::Sha1(h) => Digest::update(h, data),
45            Self::Sha256(h) => Sha256Digest::update(h, data),
46        }
47    }
48
49    fn finalize(self) -> Vec<u8> {
50        match self {
51            Self::Sha1(h) => h.finalize().to_vec(),
52            Self::Sha256(h) => h.finalize().to_vec(),
53        }
54    }
55
56    fn len(&self) -> usize {
57        match self {
58            Self::Sha1(_) => 20,
59            Self::Sha256(_) => 32,
60        }
61    }
62}
63
64/// Compute an object id for `data` of the given `kind` using `algo`, without an
65/// `Odb` in scope (Git store form: `"<kind> <len>\0<data>"`).
66fn hash_object_with(algo: HashAlgo, kind: ObjectKind, data: &[u8]) -> ObjectId {
67    let header = format!("{kind} {}\0", data.len());
68    let mut h = PackHasher::new(algo);
69    h.update(header.as_bytes());
70    h.update(data);
71    // `finalize()` returns exactly `algo`'s digest width, which is always a
72    // valid OID length, so `from_bytes` cannot fail here.
73    #[allow(clippy::expect_used)]
74    ObjectId::from_bytes(&h.finalize()).expect("digest is a valid OID width")
75}
76
77/// Options controlling `unpack-objects` behaviour.
78#[derive(Debug, Default)]
79pub struct UnpackOptions {
80    /// Validate and decompress objects but do not write them to the ODB.
81    pub dry_run: bool,
82    /// Suppress informational output.
83    pub quiet: bool,
84    /// Reject packs whose commits/trees/tags reference missing objects.
85    pub strict: bool,
86    /// Object IDs that strict connectivity may treat as promised by a configured promisor remote.
87    pub allowed_missing: HashSet<ObjectId>,
88    /// Whether strict connectivity may tolerate references to missing objects in a promisor repo.
89    pub allow_promisor_missing_references: bool,
90    /// Maximum number of raw pack bytes that may be consumed (including the 20-byte trailer).
91    ///
92    /// Matches Git's `unpack-objects --max-input-size` / `receive.maxInputSize`: counts every
93    /// byte read from the pack stream after crossing the limit. `None` means no limit.
94    pub max_input_bytes: Option<u64>,
95    /// Commit OIDs that are shallow boundaries (grafts): their parents are intentionally absent and
96    /// must not be required during the `--strict` connectivity walk.
97    ///
98    /// Mirrors `unpack-objects --shallow-file <file>` in upstream `receive-pack`, where the shallow
99    /// file lists the commits whose parent objects were deliberately not transferred.
100    pub shallow_boundaries: HashSet<ObjectId>,
101}
102
103/// A delta that could not yet be resolved because its base was not yet known.
104struct PendingDelta {
105    /// Byte offset of this object in the pack stream (used to anchor
106    /// `OFS_DELTA` back-references from later objects).
107    offset: usize,
108    /// For `REF_DELTA`: SHA-1 of the base object.
109    base_oid: Option<ObjectId>,
110    /// For `OFS_DELTA`: absolute byte offset of the base object.
111    base_offset: Option<usize>,
112    /// Decompressed delta data.
113    delta_data: Vec<u8>,
114}
115
116/// Unpack a pack stream from `reader` into `odb`.
117///
118/// Reads the complete pack from `reader`, validates the trailing SHA-1
119/// checksum, unpacks all objects (including full delta-chain resolution), and —
120/// unless [`UnpackOptions::dry_run`] is set — writes each object to `odb`.
121///
122/// Returns the total number of objects processed.
123///
124/// # Errors
125///
126/// - [`Error::CorruptObject`] — invalid pack format, checksum mismatch, or
127///   unresolvable delta chains.
128/// - [`Error::Io`] — I/O failure reading from `reader`.
129/// - [`Error::Zlib`] — decompression failure.
130pub fn unpack_objects(reader: &mut dyn Read, odb: &Odb, opts: &UnpackOptions) -> Result<usize> {
131    /// Blobs larger than this stay on disk only (after write) so huge packs do
132    /// not retain every blob in RAM. Smaller objects are kept for delta bases
133    /// and `--strict` graph walks without extra ODB reads.
134    const MAX_RETAIN_BYTES: usize = 1024 * 1024;
135
136    let algo = odb.hash_algo();
137    let mut rd = StreamingPackReader::new(reader, opts.max_input_bytes, algo);
138
139    // Validate magic and version.
140    let sig = rd.read_exact_n(4)?;
141    if sig != b"PACK" {
142        return Err(Error::CorruptObject(
143            "not a pack stream: invalid signature".to_owned(),
144        ));
145    }
146    let version = rd.read_u32_be()?;
147    if version != 2 && version != 3 {
148        return Err(Error::CorruptObject(format!(
149            "unsupported pack version {version}"
150        )));
151    }
152    let nr_objects = rd.read_u32_be()? as usize;
153
154    // pack-stream offset → resolved object (see [`PackedObjectEntry`]).
155    let mut by_offset: HashMap<usize, PackedObjectEntry> = HashMap::new();
156    // ObjectId → in-pack object for REF_DELTA resolution and strict checks.
157    let mut by_oid: HashMap<ObjectId, PackedObjectEntry> = HashMap::new();
158
159    let mut pending: Vec<PendingDelta> = Vec::new();
160    let mut count = 0usize;
161
162    for _ in 0..nr_objects {
163        let obj_offset = rd.stream_pos();
164        let (type_code, size) = rd.read_type_size()?;
165
166        match type_code {
167            1..=4 => {
168                let kind = type_code_to_kind(type_code)?;
169                let data = rd.decompress(size)?;
170                let oid = write_or_hash(kind, &data, odb, opts.dry_run)?;
171                let entry = packed_entry_after_write(kind, data, oid, odb, opts, MAX_RETAIN_BYTES);
172                by_offset.insert(obj_offset, entry.clone());
173                by_oid.insert(oid, entry);
174                count += 1;
175            }
176            6 => {
177                // OFS_DELTA: base at a negative encoded offset from this object.
178                let neg = rd.read_ofs_neg_offset()?;
179                let base_offset = obj_offset.checked_sub(neg).ok_or_else(|| {
180                    Error::CorruptObject("ofs-delta base offset underflow".to_owned())
181                })?;
182                let delta_data = rd.decompress(size)?;
183                pending.push(PendingDelta {
184                    offset: obj_offset,
185                    base_oid: None,
186                    base_offset: Some(base_offset),
187                    delta_data,
188                });
189            }
190            7 => {
191                // REF_DELTA: base identified by its object id (hash-width bytes).
192                let base_bytes = rd.read_exact_n(algo.len())?;
193                let base_oid = ObjectId::from_bytes(&base_bytes)?;
194                let delta_data = rd.decompress(size)?;
195                pending.push(PendingDelta {
196                    offset: obj_offset,
197                    base_oid: Some(base_oid),
198                    base_offset: None,
199                    delta_data,
200                });
201            }
202            other => {
203                return Err(Error::CorruptObject(format!(
204                    "unknown packed-object type {other}"
205                )))
206            }
207        }
208    }
209
210    // Trailing pack checksum (hash of all preceding bytes); not included in the hash.
211    let digest = rd.finalize_hasher();
212    let trailing = rd.read_trailer()?;
213    if digest != trailing {
214        return Err(Error::CorruptObject(
215            "pack trailing checksum mismatch".to_owned(),
216        ));
217    }
218
219    // Resolve pending deltas iteratively.  Each pass resolves all deltas whose
220    // base is now known; repeat until none remain or we stall (corrupt pack).
221    let mut remaining = pending;
222    loop {
223        if remaining.is_empty() {
224            break;
225        }
226        let before = remaining.len();
227        let mut still_pending: Vec<PendingDelta> = Vec::new();
228
229        for delta in remaining {
230            let base_res: Option<Result<(ObjectKind, Cow<'_, [u8]>)>> =
231                if let Some(base_off) = delta.base_offset {
232                    by_offset
233                        .get(&base_off)
234                        .map(|e| entry_object_bytes(e, odb).map(|d| (e.kind(), d)))
235                } else if let Some(ref base_id) = delta.base_oid {
236                    if let Some(e) = by_oid.get(base_id) {
237                        Some(entry_object_bytes(e, odb).map(|d| (e.kind(), d)))
238                    } else if !opts.dry_run {
239                        odb.read(base_id)
240                            .ok()
241                            .map(|obj| Ok((obj.kind, Cow::Owned(obj.data))))
242                    } else {
243                        None
244                    }
245                } else {
246                    None
247                };
248
249            match base_res {
250                Some(Ok((base_kind, base_data))) => {
251                    let result = apply_delta(base_data.as_ref(), &delta.delta_data)?;
252                    let oid = write_or_hash(base_kind, &result, odb, opts.dry_run)?;
253                    let new_entry = packed_entry_after_write(
254                        base_kind,
255                        result,
256                        oid,
257                        odb,
258                        opts,
259                        MAX_RETAIN_BYTES,
260                    );
261                    by_offset.insert(delta.offset, new_entry.clone());
262                    by_oid.insert(oid, new_entry);
263                    count += 1;
264                }
265                Some(Err(e)) => return Err(e),
266                None => still_pending.push(delta),
267            }
268        }
269
270        remaining = still_pending;
271        if remaining.len() == before {
272            return Err(Error::CorruptObject(format!(
273                "{} delta(s) could not be resolved",
274                remaining.len()
275            )));
276        }
277    }
278
279    if opts.strict {
280        let mut dot_fsck_map: HashMap<ObjectId, (ObjectKind, Vec<u8>)> =
281            HashMap::with_capacity(by_oid.len());
282        for (oid, entry) in &by_oid {
283            let kind = entry.kind();
284            let data = match entry {
285                PackedObjectEntry::InMemory { data, .. } => data.clone(),
286                PackedObjectEntry::BlobOnDisk { oid: blob_oid } => odb.read(blob_oid)?.data,
287            };
288            dot_fsck_map.insert(*oid, (kind, data));
289        }
290        gitmodules::verify_packed_dot_special(&dot_fsck_map)?;
291        strict_verify_packed_references_map(
292            Some(odb),
293            &by_oid,
294            &opts.allowed_missing,
295            opts.allow_promisor_missing_references,
296            &opts.shallow_boundaries,
297        )?;
298    }
299
300    Ok(count)
301}
302
303/// Resolved non-delta object: either full bytes in memory or a large blob on disk.
304#[derive(Debug, Clone)]
305enum PackedObjectEntry {
306    InMemory { kind: ObjectKind, data: Vec<u8> },
307    BlobOnDisk { oid: ObjectId },
308}
309
310impl PackedObjectEntry {
311    fn kind(&self) -> ObjectKind {
312        match self {
313            PackedObjectEntry::InMemory { kind, .. } => *kind,
314            PackedObjectEntry::BlobOnDisk { .. } => ObjectKind::Blob,
315        }
316    }
317}
318
319fn packed_entry_after_write(
320    kind: ObjectKind,
321    data: Vec<u8>,
322    oid: ObjectId,
323    _odb: &Odb,
324    opts: &UnpackOptions,
325    max_retain: usize,
326) -> PackedObjectEntry {
327    if !opts.dry_run && kind == ObjectKind::Blob && data.len() > max_retain {
328        PackedObjectEntry::BlobOnDisk { oid }
329    } else {
330        PackedObjectEntry::InMemory { kind, data }
331    }
332}
333
334fn entry_object_bytes<'a>(entry: &'a PackedObjectEntry, odb: &Odb) -> Result<Cow<'a, [u8]>> {
335    match entry {
336        PackedObjectEntry::InMemory { data, .. } => Ok(Cow::Borrowed(data.as_slice())),
337        PackedObjectEntry::BlobOnDisk { oid } => Ok(Cow::Owned(odb.read(oid)?.data)),
338    }
339}
340
341fn strict_verify_packed_references_map(
342    odb: Option<&Odb>,
343    pack: &HashMap<ObjectId, PackedObjectEntry>,
344    allowed_missing: &HashSet<ObjectId>,
345    allow_promisor_missing_references: bool,
346    shallow_boundaries: &HashSet<ObjectId>,
347) -> Result<()> {
348    for (oid, entry) in pack {
349        match entry {
350            PackedObjectEntry::BlobOnDisk { .. } => {}
351            PackedObjectEntry::InMemory { kind, data } => match kind {
352                ObjectKind::Tree => {
353                    for e in parse_tree(data)? {
354                        // Gitlink (submodule) entries point at commits that live
355                        // in the submodule repository, not the superproject's
356                        // pack/ODB. Skip them in the connectivity walk, matching
357                        // upstream git (git/fsck.c:374 `if (S_ISGITLINK) continue;`).
358                        if e.mode == MODE_GITLINK {
359                            continue;
360                        }
361                        if !strict_ref_resolves_map(
362                            &e.oid,
363                            pack,
364                            odb,
365                            allowed_missing,
366                            allow_promisor_missing_references,
367                        ) {
368                            return Err(Error::CorruptObject(format!(
369                                "strict: missing object {} referenced by tree",
370                                e.oid.to_hex()
371                            )));
372                        }
373                    }
374                }
375                ObjectKind::Commit => {
376                    let c = parse_commit(data)?;
377                    if !strict_ref_resolves_map(
378                        &c.tree,
379                        pack,
380                        odb,
381                        allowed_missing,
382                        allow_promisor_missing_references,
383                    ) {
384                        return Err(Error::CorruptObject(format!(
385                            "strict: missing tree {} referenced by commit",
386                            c.tree.to_hex()
387                        )));
388                    }
389                    // A commit recorded as a shallow boundary (graft) has its parents intentionally
390                    // absent — skip parent connectivity for it, matching unpack-objects run with a
391                    // `--shallow-file` listing this commit.
392                    if shallow_boundaries.contains(oid) {
393                        continue;
394                    }
395                    for p in &c.parents {
396                        if !strict_ref_resolves_map(
397                            p,
398                            pack,
399                            odb,
400                            allowed_missing,
401                            allow_promisor_missing_references,
402                        ) {
403                            return Err(Error::CorruptObject(format!(
404                                "strict: missing parent {} referenced by commit",
405                                p.to_hex()
406                            )));
407                        }
408                    }
409                }
410                ObjectKind::Tag => {
411                    let t = parse_tag(data)?;
412                    if !strict_ref_resolves_map(
413                        &t.object,
414                        pack,
415                        odb,
416                        allowed_missing,
417                        allow_promisor_missing_references,
418                    ) {
419                        return Err(Error::CorruptObject(format!(
420                            "strict: missing object {} referenced by tag",
421                            t.object.to_hex()
422                        )));
423                    }
424                }
425                ObjectKind::Blob => {}
426            },
427        }
428    }
429    Ok(())
430}
431
432fn strict_ref_resolves_map(
433    oid: &ObjectId,
434    pack: &HashMap<ObjectId, PackedObjectEntry>,
435    odb: Option<&Odb>,
436    allowed_missing: &HashSet<ObjectId>,
437    allow_promisor_missing_references: bool,
438) -> bool {
439    pack.contains_key(oid)
440        || allowed_missing.contains(oid)
441        || odb.is_some_and(|o| o.exists(oid))
442        || allow_promisor_missing_references
443}
444
445fn strict_ref_resolves(
446    oid: &ObjectId,
447    pack: &std::collections::HashMap<ObjectId, (ObjectKind, Vec<u8>)>,
448    odb: Option<&Odb>,
449) -> bool {
450    pack.contains_key(oid) || odb.is_some_and(|o| o.exists(oid))
451}
452
453/// Verifies that references from commits, trees, and tags resolve to objects present in `pack`
454/// or, when `odb` is [`Some`], to loose objects in that database.
455///
456/// Use [`None`] for `odb` when indexing or unpacking in a context with no repository (Git allows
457/// `index-pack --strict` outside a work tree when the pack is self-contained).
458pub fn strict_verify_packed_references(
459    odb: Option<&Odb>,
460    pack: &HashMap<ObjectId, (ObjectKind, Vec<u8>)>,
461) -> Result<()> {
462    for (kind, data) in pack.values() {
463        match kind {
464            ObjectKind::Tree => {
465                for e in parse_tree(data)? {
466                    // Gitlink (submodule) entries point at commits that live in
467                    // the submodule repository, not this pack/ODB. Skip them in
468                    // the connectivity walk, matching upstream git
469                    // (git/fsck.c:374 `if (S_ISGITLINK) continue;`).
470                    if e.mode == MODE_GITLINK {
471                        continue;
472                    }
473                    if !strict_ref_resolves(&e.oid, pack, odb) {
474                        return Err(Error::CorruptObject(format!(
475                            "strict: missing object {} referenced by tree",
476                            e.oid.to_hex()
477                        )));
478                    }
479                }
480            }
481            ObjectKind::Commit => {
482                let c = parse_commit(data)?;
483                if !strict_ref_resolves(&c.tree, pack, odb) {
484                    return Err(Error::CorruptObject(format!(
485                        "strict: missing tree {} referenced by commit",
486                        c.tree.to_hex()
487                    )));
488                }
489                for p in &c.parents {
490                    if !strict_ref_resolves(p, pack, odb) {
491                        return Err(Error::CorruptObject(format!(
492                            "strict: missing parent {} referenced by commit",
493                            p.to_hex()
494                        )));
495                    }
496                }
497            }
498            ObjectKind::Tag => {
499                let t = parse_tag(data)?;
500                if !strict_ref_resolves(&t.object, pack, odb) {
501                    return Err(Error::CorruptObject(format!(
502                        "strict: missing object {} referenced by tag",
503                        t.object.to_hex()
504                    )));
505                }
506            }
507            ObjectKind::Blob => {}
508        }
509    }
510    Ok(())
511}
512
513/// Whether `data` is a *thin* pack — i.e. it contains a `ref-delta` (type 7) whose base object is
514/// not itself present in the pack. `git pack-objects --thin` produces such packs; a receiver that
515/// rejects thin packs (`receive-pack --reject-thin-pack-for-testing`) uses this to refuse them.
516///
517/// Conservative: any parse error makes this return `false` (treat as non-thin) so a malformed pack
518/// is handled by the normal ingestion path rather than mislabeled.
519pub fn pack_is_thin(data: &[u8], algo: HashAlgo) -> bool {
520    pack_is_thin_inner(data, algo).unwrap_or(false)
521}
522
523fn pack_is_thin_inner(data: &[u8], algo: HashAlgo) -> Result<bool> {
524    let mut rd = PackReader::new(data.to_vec());
525    if rd.read_exact(4)? != b"PACK" {
526        return Ok(false);
527    }
528    let _version = rd.read_u32_be()?;
529    let nr_objects = rd.read_u32_be()? as usize;
530
531    let mut in_pack: HashSet<ObjectId> = HashSet::new();
532    let mut ref_delta_bases: Vec<ObjectId> = Vec::new();
533    for _ in 0..nr_objects {
534        let obj_offset = rd.pos;
535        let (type_code, size) = rd.read_type_size()?;
536        match type_code {
537            1..=4 => {
538                let kind = type_code_to_kind(type_code)?;
539                let obj_data = rd.decompress(size)?;
540                in_pack.insert(hash_object_with(algo, kind, &obj_data));
541            }
542            6 => {
543                // ofs-delta: base is always in-pack (referenced by relative offset).
544                let _neg = rd.read_ofs_neg_offset()?;
545                let _ = obj_offset;
546                let _ = rd.decompress(size)?;
547            }
548            7 => {
549                let base_bytes = rd.read_exact(algo.len())?;
550                ref_delta_bases.push(ObjectId::from_bytes(base_bytes)?);
551                let _ = rd.decompress(size)?;
552            }
553            _ => return Ok(false),
554        }
555    }
556    // Thin iff any ref-delta points at a base that is not packed alongside it.
557    Ok(ref_delta_bases.iter().any(|b| !in_pack.contains(b)))
558}
559
560/// Parse a pack byte stream and return every resolved object (after delta resolution) keyed by OID.
561///
562/// Does not write to any object database. Used for receive-pack connectivity checks before
563/// applying a push to the permanent ODB.
564///
565/// Thin-pack bases may be resolved from `odb` when they are not present in the pack.
566pub fn pack_bytes_to_object_map(data: &[u8], odb: &Odb) -> Result<HashMap<ObjectId, Object>> {
567    let rd = PackReader::new(data.to_vec());
568    build_pack_object_map(rd, odb)
569}
570
571fn build_pack_object_map(mut rd: PackReader, odb: &Odb) -> Result<HashMap<ObjectId, Object>> {
572    let algo = odb.hash_algo();
573    let sig = rd.read_exact(4)?;
574    if sig != b"PACK" {
575        return Err(Error::CorruptObject(
576            "not a pack stream: invalid signature".to_owned(),
577        ));
578    }
579    let version = rd.read_u32_be()?;
580    if version != 2 && version != 3 {
581        return Err(Error::CorruptObject(format!(
582            "unsupported pack version {version}"
583        )));
584    }
585    let nr_objects = rd.read_u32_be()? as usize;
586
587    let mut by_offset: HashMap<usize, (ObjectKind, Vec<u8>)> = HashMap::new();
588    let mut by_oid: HashMap<ObjectId, (ObjectKind, Vec<u8>)> = HashMap::new();
589    let mut pending: Vec<PendingDelta> = Vec::new();
590
591    fn base_from_pack_or_odb(
592        by_oid: &HashMap<ObjectId, (ObjectKind, Vec<u8>)>,
593        odb: &Odb,
594        id: &ObjectId,
595    ) -> Option<(ObjectKind, Vec<u8>)> {
596        if let Some(e) = by_oid.get(id) {
597            return Some(e.clone());
598        }
599        odb.read(id).ok().map(|o| (o.kind, o.data))
600    }
601
602    for _ in 0..nr_objects {
603        let obj_offset = rd.pos;
604        let (type_code, size) = rd.read_type_size()?;
605
606        match type_code {
607            1..=4 => {
608                let kind = type_code_to_kind(type_code)?;
609                let data = rd.decompress(size)?;
610                let oid = odb.hash(kind, &data);
611                by_offset.insert(obj_offset, (kind, data.clone()));
612                by_oid.insert(oid, (kind, data));
613            }
614            6 => {
615                let neg = rd.read_ofs_neg_offset()?;
616                let base_offset = obj_offset.checked_sub(neg).ok_or_else(|| {
617                    Error::CorruptObject("ofs-delta base offset underflow".to_owned())
618                })?;
619                let delta_data = rd.decompress(size)?;
620                pending.push(PendingDelta {
621                    offset: obj_offset,
622                    base_oid: None,
623                    base_offset: Some(base_offset),
624                    delta_data,
625                });
626            }
627            7 => {
628                let base_bytes = rd.read_exact(algo.len())?;
629                let base_oid = ObjectId::from_bytes(base_bytes)?;
630                let delta_data = rd.decompress(size)?;
631                pending.push(PendingDelta {
632                    offset: obj_offset,
633                    base_oid: Some(base_oid),
634                    base_offset: None,
635                    delta_data,
636                });
637            }
638            other => {
639                return Err(Error::CorruptObject(format!(
640                    "unknown packed-object type {other}"
641                )))
642            }
643        }
644    }
645
646    let consumed = rd.pos;
647    {
648        let mut hasher = PackHasher::new(algo);
649        hasher.update(&rd.data[..consumed]);
650        let digest = hasher.finalize();
651        let trailing = rd.read_exact(algo.len())?;
652        if digest.as_slice() != trailing {
653            return Err(Error::CorruptObject(
654                "pack trailing checksum mismatch".to_owned(),
655            ));
656        }
657    }
658
659    let mut remaining = pending;
660    loop {
661        if remaining.is_empty() {
662            break;
663        }
664        let before = remaining.len();
665        let mut still_pending: Vec<PendingDelta> = Vec::new();
666
667        for delta in remaining {
668            let base = if let Some(base_off) = delta.base_offset {
669                by_offset.get(&base_off).cloned()
670            } else if let Some(ref base_id) = delta.base_oid {
671                base_from_pack_or_odb(&by_oid, odb, base_id)
672            } else {
673                None
674            };
675
676            if let Some((base_kind, base_data)) = base {
677                let result = apply_delta(&base_data, &delta.delta_data)?;
678                let oid = odb.hash(base_kind, &result);
679                by_offset.insert(delta.offset, (base_kind, result.clone()));
680                by_oid.insert(oid, (base_kind, result));
681            } else {
682                still_pending.push(delta);
683            }
684        }
685
686        remaining = still_pending;
687        if remaining.len() == before {
688            return Err(Error::CorruptObject(format!(
689                "{} delta(s) could not be resolved",
690                remaining.len()
691            )));
692        }
693    }
694
695    Ok(by_oid
696        .into_iter()
697        .map(|(oid, (kind, data))| (oid, Object::new(kind, data)))
698        .collect())
699}
700
701/// Either write `data` as a loose object (if `!dry_run`) or just compute its
702/// [`ObjectId`] without touching the filesystem.
703fn write_or_hash(kind: ObjectKind, data: &[u8], odb: &Odb, dry_run: bool) -> Result<ObjectId> {
704    if dry_run {
705        Ok(odb.hash(kind, data))
706    } else {
707        // Always materialize into this ODB: objects reachable only via alternates must still be
708        // written locally (matches git unpack-objects; t5519-push-alternates).
709        odb.write_local(kind, data)
710    }
711}
712
713/// Convert a pack object type code to an [`ObjectKind`].
714fn type_code_to_kind(code: u8) -> Result<ObjectKind> {
715    match code {
716        1 => Ok(ObjectKind::Commit),
717        2 => Ok(ObjectKind::Tree),
718        3 => Ok(ObjectKind::Blob),
719        4 => Ok(ObjectKind::Tag),
720        _ => Err(Error::CorruptObject(format!(
721            "type code {code} is not a regular object type"
722        ))),
723    }
724}
725
726/// Low-level cursor over a buffered pack byte stream (in-memory pack parsing).
727struct PackReader {
728    data: Vec<u8>,
729    pos: usize,
730}
731
732impl PackReader {
733    fn new(data: Vec<u8>) -> Self {
734        Self { data, pos: 0 }
735    }
736
737    /// Read exactly `n` bytes and advance the cursor, returning a slice into
738    /// the internal buffer.
739    fn read_exact(&mut self, n: usize) -> Result<&[u8]> {
740        if self.pos + n > self.data.len() {
741            return Err(Error::CorruptObject(format!(
742                "pack stream truncated: need {n} bytes at offset {}",
743                self.pos
744            )));
745        }
746        let slice = &self.data[self.pos..self.pos + n];
747        self.pos += n;
748        Ok(slice)
749    }
750
751    /// Read a single byte and advance the cursor.
752    fn read_byte(&mut self) -> Result<u8> {
753        if self.pos >= self.data.len() {
754            return Err(Error::CorruptObject(
755                "unexpected end of pack stream".to_owned(),
756            ));
757        }
758        let b = self.data[self.pos];
759        self.pos += 1;
760        Ok(b)
761    }
762
763    /// Read a big-endian `u32`.
764    fn read_u32_be(&mut self) -> Result<u32> {
765        let bytes = self.read_exact(4)?;
766        Ok(u32::from_be_bytes(bytes.try_into().map_err(|_| {
767            Error::CorruptObject("u32 read failed".to_owned())
768        })?))
769    }
770
771    /// Read the packed-object type + size header (variable-length big-endian
772    /// encoding with the type in bits 4-6 of the first byte).
773    ///
774    /// Returns `(type_code, uncompressed_size)`.
775    fn read_type_size(&mut self) -> Result<(u8, usize)> {
776        let c = self.read_byte()?;
777        let type_code = (c >> 4) & 0x7;
778        let mut size = (c & 0x0f) as usize;
779        let mut shift = 4u32;
780        let mut cur = c;
781        while cur & 0x80 != 0 {
782            cur = self.read_byte()?;
783            size |= ((cur & 0x7f) as usize) << shift;
784            shift += 7;
785        }
786        Ok((type_code, size))
787    }
788
789    /// Read an `OFS_DELTA` negative-offset value.
790    ///
791    /// The encoding uses a big-endian variable-length integer with a +1 bias
792    /// on each continuation byte, yielding values ≥ 1.
793    fn read_ofs_neg_offset(&mut self) -> Result<usize> {
794        let mut c = self.read_byte()?;
795        let mut value = (c & 0x7f) as usize;
796        while c & 0x80 != 0 {
797            c = self.read_byte()?;
798            value = (value + 1) << 7 | (c & 0x7f) as usize;
799        }
800        Ok(value)
801    }
802
803    /// Decompress zlib-compressed data starting at the current cursor position.
804    ///
805    /// Advances the cursor by exactly the number of compressed bytes consumed.
806    /// Returns an error if the decompressed length differs from `expected_size`.
807    fn decompress(&mut self, expected_size: usize) -> Result<Vec<u8>> {
808        let slice = &self.data[self.pos..];
809        let mut decoder = ZlibDecoder::new(slice);
810        let mut out = Vec::with_capacity(expected_size);
811        decoder
812            .read_to_end(&mut out)
813            .map_err(|e| Error::Zlib(e.to_string()))?;
814        if out.len() != expected_size {
815            return Err(Error::CorruptObject(format!(
816                "decompressed {} bytes but expected {}",
817                out.len(),
818                expected_size
819            )));
820        }
821        self.pos += decoder.total_in() as usize;
822        Ok(out)
823    }
824}
825
826fn io_to_corrupt_eof(e: io::Error, stream_pos: usize, context: &str) -> Error {
827    if e.kind() == io::ErrorKind::UnexpectedEof {
828        Error::CorruptObject(format!(
829            "pack stream truncated ({context}) at offset {stream_pos}"
830        ))
831    } else {
832        Error::Io(e)
833    }
834}
835
836/// Streaming cursor over a pack file: hashes body bytes incrementally (no full-buffer read).
837///
838/// Raw pack bytes are either consumed as object headers (via [`Self::read_byte`]) or as zlib
839/// payloads.  Zlib decoders may read ahead; overflow bytes stay in [`Self::pending`] so the next
840/// object header or zlib stream starts at the correct offset.
841struct StreamingPackReader<'a> {
842    inner: &'a mut dyn Read,
843    pack_hasher: PackHasher,
844    stream_pos: usize,
845    max_input_bytes: Option<u64>,
846    /// Compressed (or other) bytes already read from `inner` and hashed but not yet consumed by
847    /// the current parsing step.
848    pending: Vec<u8>,
849}
850
851impl<'a> StreamingPackReader<'a> {
852    fn new(inner: &'a mut dyn Read, max_input_bytes: Option<u64>, algo: HashAlgo) -> Self {
853        Self {
854            inner,
855            pack_hasher: PackHasher::new(algo),
856            stream_pos: 0,
857            max_input_bytes,
858            pending: Vec::new(),
859        }
860    }
861
862    fn stream_pos(&self) -> usize {
863        self.stream_pos
864    }
865
866    fn enforce_max_input(&self) -> Result<()> {
867        if let Some(limit) = self.max_input_bytes {
868            let pos = u64::try_from(self.stream_pos)
869                .map_err(|_| Error::CorruptObject("pack stream position overflow".to_owned()))?;
870            if pos > limit {
871                return Err(Error::CorruptObject(
872                    "pack exceeds maximum allowed size".to_owned(),
873                ));
874            }
875        }
876        Ok(())
877    }
878
879    /// Read pack-body bytes (hashed). Used for headers and non-zlib payload reads only.
880    fn read_from_source(&mut self, buf: &mut [u8]) -> Result<usize> {
881        let n = if !self.pending.is_empty() {
882            let take = buf.len().min(self.pending.len());
883            buf[..take].copy_from_slice(&self.pending[..take]);
884            self.pending.drain(..take);
885            take
886        } else {
887            self.inner.read(buf).map_err(Error::Io)?
888        };
889        if n > 0 {
890            self.pack_hasher.update(&buf[..n]);
891            self.stream_pos += n;
892            self.enforce_max_input()?;
893        }
894        Ok(n)
895    }
896
897    fn read_byte(&mut self) -> Result<u8> {
898        let mut b = [0u8; 1];
899        let n = self.read_from_source(&mut b)?;
900        if n == 0 {
901            return Err(Error::CorruptObject(format!(
902                "pack stream truncated (read byte) at offset {}",
903                self.stream_pos
904            )));
905        }
906        Ok(b[0])
907    }
908
909    fn read_exact_n(&mut self, n: usize) -> Result<Vec<u8>> {
910        let mut v = vec![0u8; n];
911        let mut got = 0usize;
912        while got < n {
913            let m = self.read_from_source(&mut v[got..n])?;
914            if m == 0 {
915                return Err(Error::CorruptObject(format!(
916                    "pack stream truncated (read exact) at offset {}",
917                    self.stream_pos
918                )));
919            }
920            got += m;
921        }
922        Ok(v)
923    }
924
925    fn read_u32_be(&mut self) -> Result<u32> {
926        let mut b = [0u8; 4];
927        let mut got = 0usize;
928        while got < 4 {
929            let m = self.read_from_source(&mut b[got..4])?;
930            if m == 0 {
931                return Err(Error::CorruptObject(format!(
932                    "pack stream truncated (read u32) at offset {}",
933                    self.stream_pos
934                )));
935            }
936            got += m;
937        }
938        Ok(u32::from_be_bytes(b))
939    }
940
941    fn read_type_size(&mut self) -> Result<(u8, usize)> {
942        let c = self.read_byte()?;
943        let type_code = (c >> 4) & 0x7;
944        let mut size = (c & 0x0f) as usize;
945        let mut shift = 4u32;
946        let mut cur = c;
947        while cur & 0x80 != 0 {
948            cur = self.read_byte()?;
949            size |= ((cur & 0x7f) as usize) << shift;
950            shift += 7;
951        }
952        Ok((type_code, size))
953    }
954
955    fn read_ofs_neg_offset(&mut self) -> Result<usize> {
956        let mut c = self.read_byte()?;
957        let mut value = (c & 0x7f) as usize;
958        while c & 0x80 != 0 {
959            c = self.read_byte()?;
960            value = (value + 1) << 7 | (c & 0x7f) as usize;
961        }
962        Ok(value)
963    }
964
965    /// Pull zlib-compressed bytes until one object inflates to `expected_size` bytes.
966    ///
967    /// Bytes read from `inner` into `pending` are not hashed until we know how many belong to the
968    /// zlib stream (`total_in()`). Lookahead past the zlib end (including the 20-byte pack
969    /// trailer) must never be fed to the pack checksum.
970    ///
971    /// When the pack arrives in small chunks (e.g. side-band-64k from `upload-pack`), `flate2` may
972    /// return an error before the full deflate stream is in `pending`. Retry after reading more
973    /// from `inner` (same idea as [`PackReader::decompress`], which sees the whole zlib at once).
974    fn decompress(&mut self, expected_size: usize) -> Result<Vec<u8>> {
975        // `Read::read_exact` into an empty buffer returns `Ok` immediately without touching the
976        // decoder, so a 0-byte packed object would leave the zlib header in `pending` and desync
977        // the pack stream (bundle / clone unpack). Always run the zlib decoder once.
978        if expected_size == 0 {
979            const CHUNK: usize = 64 * 1024;
980            let mut scratch = [0u8; CHUNK];
981            loop {
982                let mut cursor = std::io::Cursor::new(self.pending.as_slice());
983                let mut z = ZlibDecoder::new(&mut cursor);
984                let mut sink = [0u8; 1];
985                match z.read(&mut sink) {
986                    Ok(0) => {
987                        let consumed = z.total_in() as usize;
988                        if consumed > self.pending.len() {
989                            return Err(Error::CorruptObject(
990                                "zlib total_in exceeds pending buffer".to_owned(),
991                            ));
992                        }
993                        if consumed == 0 {
994                            let n = self.inner.read(&mut scratch).map_err(Error::Io)?;
995                            if n == 0 {
996                                return Err(Error::CorruptObject(format!(
997                                    "pack stream truncated (zlib) at offset {}",
998                                    self.stream_pos
999                                )));
1000                            }
1001                            self.pending.extend_from_slice(&scratch[..n]);
1002                            continue;
1003                        }
1004                        self.pack_hasher.update(&self.pending[..consumed]);
1005                        self.stream_pos += consumed;
1006                        self.pending.drain(..consumed);
1007                        self.enforce_max_input()?;
1008                        return Ok(Vec::new());
1009                    }
1010                    Ok(_) => {
1011                        return Err(Error::CorruptObject(
1012                            "0-byte packed object inflated to non-empty output".to_owned(),
1013                        ));
1014                    }
1015                    Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
1016                        let n = self.inner.read(&mut scratch).map_err(Error::Io)?;
1017                        if n == 0 {
1018                            return Err(Error::CorruptObject(format!(
1019                                "pack stream truncated (zlib) at offset {}",
1020                                self.stream_pos
1021                            )));
1022                        }
1023                        self.pending.extend_from_slice(&scratch[..n]);
1024                    }
1025                    Err(e) => return Err(Error::Zlib(e.to_string())),
1026                }
1027            }
1028        }
1029
1030        const CHUNK: usize = 64 * 1024;
1031        let mut scratch = [0u8; CHUNK];
1032
1033        let mut out = vec![0u8; expected_size];
1034        let mut z = Decompress::new(true);
1035        let mut out_pos = 0usize;
1036        let mut eof = false;
1037        loop {
1038            if self.pending.is_empty() && !eof {
1039                let n = self.inner.read(&mut scratch).map_err(Error::Io)?;
1040                if n == 0 {
1041                    eof = true;
1042                } else {
1043                    self.pending.extend_from_slice(&scratch[..n]);
1044                }
1045            }
1046
1047            let flush = if eof && self.pending.is_empty() {
1048                FlushDecompress::Finish
1049            } else {
1050                FlushDecompress::None
1051            };
1052
1053            let before_in = z.total_in();
1054            let before_out = z.total_out();
1055            let status = z
1056                .decompress(self.pending.as_slice(), &mut out[out_pos..], flush)
1057                .map_err(|e| Error::Zlib(e.to_string()))?;
1058            let consumed = (z.total_in() - before_in) as usize;
1059            if consumed > self.pending.len() {
1060                return Err(Error::CorruptObject(
1061                    "zlib consumed more than pending buffer".to_owned(),
1062                ));
1063            }
1064            self.pack_hasher.update(&self.pending[..consumed]);
1065            self.stream_pos += consumed;
1066            self.pending.drain(..consumed);
1067            self.enforce_max_input()?;
1068            out_pos += (z.total_out() - before_out) as usize;
1069
1070            match status {
1071                Status::StreamEnd => {
1072                    if out_pos != expected_size {
1073                        return Err(Error::CorruptObject(format!(
1074                            "decompressed size mismatch: got {out_pos}, want {expected_size}"
1075                        )));
1076                    }
1077                    return Ok(out);
1078                }
1079                Status::Ok | Status::BufError => {
1080                    if consumed == 0 && !eof {
1081                        let n = self.inner.read(&mut scratch).map_err(Error::Io)?;
1082                        if n == 0 {
1083                            eof = true;
1084                        } else {
1085                            self.pending.extend_from_slice(&scratch[..n]);
1086                        }
1087                    } else if eof && self.pending.is_empty() && out_pos != expected_size {
1088                        return Err(Error::CorruptObject(format!(
1089                            "pack stream truncated (zlib) at offset {}",
1090                            self.stream_pos
1091                        )));
1092                    }
1093                }
1094            }
1095        }
1096    }
1097
1098    /// Hash over all pack bytes read so far (objects only; trailer not yet read).
1099    fn finalize_hasher(&self) -> Vec<u8> {
1100        self.pack_hasher.clone().finalize()
1101    }
1102
1103    /// Trailing pack checksum (hash-width bytes); not included in [`Self::finalize_hasher`].
1104    fn read_trailer(&mut self) -> Result<Vec<u8>> {
1105        let hash_len = self.pack_hasher.len();
1106        let mut b = vec![0u8; hash_len];
1107        if self.pending.len() >= hash_len {
1108            b.copy_from_slice(&self.pending[..hash_len]);
1109            self.pending.drain(..hash_len);
1110            self.stream_pos += hash_len;
1111            self.enforce_max_input()?;
1112            return Ok(b);
1113        }
1114        let tail = self.pending.len();
1115        if tail > 0 {
1116            b[..tail].copy_from_slice(&self.pending[..]);
1117            self.pending.clear();
1118        }
1119        self.inner
1120            .read_exact(&mut b[tail..])
1121            .map_err(|e| io_to_corrupt_eof(e, self.stream_pos, "trailer"))?;
1122        self.stream_pos += hash_len;
1123        self.enforce_max_input()?;
1124        Ok(b)
1125    }
1126}
1127
1128/// Apply a git "patch delta" to `base`, producing the patched result.
1129///
1130/// The delta binary format is:
1131/// 1. Source size: variable-length little-endian integer (must equal
1132///    `base.len()`).
1133/// 2. Destination size: variable-length little-endian integer.
1134/// 3. A sequence of COPY (MSB set) and INSERT (MSB clear) instructions.
1135///
1136/// # Errors
1137///
1138/// Returns [`Error::CorruptObject`] if the delta is malformed, the source-size
1139/// field does not match `base.len()`, or the result length does not match the
1140/// declared destination size.
1141pub fn apply_delta(base: &[u8], delta: &[u8]) -> Result<Vec<u8>> {
1142    let mut pos = 0usize;
1143
1144    let src_size = read_delta_varint(delta, &mut pos)?;
1145    if src_size != base.len() {
1146        return Err(Error::CorruptObject(format!(
1147            "delta source size {src_size} != base size {}",
1148            base.len()
1149        )));
1150    }
1151    let dest_size = read_delta_varint(delta, &mut pos)?;
1152    let mut result = Vec::with_capacity(dest_size);
1153
1154    while pos < delta.len() {
1155        let cmd = delta[pos];
1156        pos += 1;
1157        if cmd == 0 {
1158            return Err(Error::CorruptObject(
1159                "reserved opcode 0 in delta stream".to_owned(),
1160            ));
1161        }
1162        if cmd & 0x80 != 0 {
1163            // COPY instruction: up to 4 offset bytes (bits 0-3) and up to 3
1164            // size bytes (bits 4-6) are present, each controlled by a flag bit.
1165            let mut offset = 0usize;
1166            let mut size = 0usize;
1167
1168            macro_rules! maybe_read_byte {
1169                ($flag:expr, $shift:expr, $target:expr) => {
1170                    if cmd & $flag != 0 {
1171                        let b = *delta.get(pos).ok_or_else(|| {
1172                            Error::CorruptObject("truncated delta COPY operand".to_owned())
1173                        })?;
1174                        pos += 1;
1175                        $target |= (b as usize) << $shift;
1176                    }
1177                };
1178            }
1179
1180            maybe_read_byte!(0x01, 0, offset);
1181            maybe_read_byte!(0x02, 8, offset);
1182            maybe_read_byte!(0x04, 16, offset);
1183            maybe_read_byte!(0x08, 24, offset);
1184            maybe_read_byte!(0x10, 0, size);
1185            maybe_read_byte!(0x20, 8, size);
1186            maybe_read_byte!(0x40, 16, size);
1187
1188            if size == 0 {
1189                size = 0x10000;
1190            }
1191
1192            let end = offset.checked_add(size).ok_or_else(|| {
1193                Error::CorruptObject("delta COPY range overflows usize".to_owned())
1194            })?;
1195            let chunk = base.get(offset..end).ok_or_else(|| {
1196                Error::CorruptObject(format!(
1197                    "delta COPY [{offset},{end}) out of range (base is {} bytes)",
1198                    base.len()
1199                ))
1200            })?;
1201            result.extend_from_slice(chunk);
1202        } else {
1203            // INSERT instruction: copy the next `cmd` literal bytes verbatim.
1204            let n = cmd as usize;
1205            let chunk = delta
1206                .get(pos..pos + n)
1207                .ok_or_else(|| Error::CorruptObject("truncated delta INSERT data".to_owned()))?;
1208            result.extend_from_slice(chunk);
1209            pos += n;
1210        }
1211    }
1212
1213    if result.len() != dest_size {
1214        return Err(Error::CorruptObject(format!(
1215            "delta produced {} bytes but expected {dest_size}",
1216            result.len()
1217        )));
1218    }
1219
1220    Ok(result)
1221}
1222
1223/// Read a variable-length little-endian integer from `data` starting at `*pos`.
1224///
1225/// Advances `*pos` past the consumed bytes.
1226fn read_delta_varint(data: &[u8], pos: &mut usize) -> Result<usize> {
1227    let mut value = 0usize;
1228    let mut shift = 0u32;
1229    loop {
1230        let b = *data
1231            .get(*pos)
1232            .ok_or_else(|| Error::CorruptObject("truncated delta varint".to_owned()))?;
1233        *pos += 1;
1234        value |= ((b & 0x7f) as usize) << shift;
1235        shift += 7;
1236        if b & 0x80 == 0 {
1237            break;
1238        }
1239    }
1240    Ok(value)
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245    use super::*;
1246
1247    // Helper: build a minimal pack from a list of (kind, data) pairs.
1248    // Returns the raw pack bytes.
1249    fn make_pack(objects: &[(ObjectKind, &[u8])]) -> Vec<u8> {
1250        use flate2::write::ZlibEncoder;
1251        use std::io::Write;
1252
1253        let mut entries: Vec<Vec<u8>> = Vec::new();
1254        for (kind, data) in objects {
1255            let type_code: u8 = match kind {
1256                ObjectKind::Commit => 1,
1257                ObjectKind::Tree => 2,
1258                ObjectKind::Blob => 3,
1259                ObjectKind::Tag => 4,
1260            };
1261            // Encode type+size header.
1262            let mut header = Vec::new();
1263            let mut size = data.len();
1264            let first = ((type_code & 0x7) << 4) | (size & 0x0f) as u8;
1265            size >>= 4;
1266            if size > 0 {
1267                header.push(first | 0x80);
1268                while size > 0 {
1269                    let b = (size & 0x7f) as u8;
1270                    size >>= 7;
1271                    header.push(if size > 0 { b | 0x80 } else { b });
1272                }
1273            } else {
1274                header.push(first);
1275            }
1276            // zlib-compress data.
1277            let mut enc = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
1278            enc.write_all(data).unwrap();
1279            let compressed = enc.finish().unwrap();
1280            let mut entry = header;
1281            entry.extend_from_slice(&compressed);
1282            entries.push(entry);
1283        }
1284
1285        // Assemble: PACK + version(2) + count + entries + SHA-1.
1286        let mut pack = Vec::new();
1287        pack.extend_from_slice(b"PACK");
1288        pack.extend_from_slice(&2u32.to_be_bytes());
1289        pack.extend_from_slice(&(objects.len() as u32).to_be_bytes());
1290        for entry in &entries {
1291            pack.extend_from_slice(entry);
1292        }
1293        let mut hasher = Sha1::new();
1294        hasher.update(&pack);
1295        let digest = hasher.finalize();
1296        pack.extend_from_slice(digest.as_slice());
1297        pack
1298    }
1299
1300    #[test]
1301    fn test_apply_delta_simple() {
1302        // Build a trivial delta: insert "hello world".
1303        let base = b"hello";
1304        let mut delta = Vec::new();
1305        // src_size = 5
1306        delta.push(5u8);
1307        // dest_size = 11
1308        delta.push(11u8);
1309        // COPY instruction: copy base[0..5]
1310        // cmd = 0x80 | 0x01 (offset present, byte 0) | 0x10 (size byte 0)
1311        delta.push(0x80 | 0x01 | 0x10); // 0x91
1312        delta.push(0u8); // offset = 0
1313        delta.push(5u8); // size = 5
1314                         // INSERT " world" (6 bytes)
1315        delta.push(6u8);
1316        delta.extend_from_slice(b" world");
1317
1318        let result = apply_delta(base, &delta).unwrap();
1319        assert_eq!(result, b"hello world");
1320    }
1321
1322    #[test]
1323    fn test_apply_delta_insert_only() {
1324        let base = b"";
1325        let mut delta = Vec::new();
1326        delta.push(0u8); // src_size = 0
1327        delta.push(5u8); // dest_size = 5
1328        delta.push(5u8); // INSERT 5 bytes
1329        delta.extend_from_slice(b"hello");
1330
1331        let result = apply_delta(base, &delta).unwrap();
1332        assert_eq!(result, b"hello");
1333    }
1334
1335    #[test]
1336    fn test_apply_delta_copy_only() {
1337        let base = b"abcdef";
1338        let mut delta = Vec::new();
1339        delta.push(6u8); // src_size = 6
1340        delta.push(3u8); // dest_size = 3
1341                         // COPY base[2..5]: offset=2, size=3
1342                         // cmd = 0x80 | 0x01 | 0x10
1343        delta.push(0x91u8);
1344        delta.push(2u8); // offset = 2
1345        delta.push(3u8); // size = 3
1346
1347        let result = apply_delta(base, &delta).unwrap();
1348        assert_eq!(result, b"cde");
1349    }
1350
1351    #[test]
1352    fn test_apply_delta_size_zero_means_65536() {
1353        // A COPY with size bytes all zero means 0x10000 = 65536.
1354        let base = vec![0xABu8; 65536];
1355        let mut delta = Vec::new();
1356        // src_size = 65536, encoded as 3 bytes little-endian varint
1357        delta.push(0x80 | (65536 & 0x7f) as u8); // 0
1358        delta.push(0x80 | ((65536 >> 7) & 0x7f) as u8); // 0x80
1359        delta.push(((65536 >> 14) & 0x7f) as u8); // 4
1360                                                  // dest_size = 65536, same
1361        delta.push(0x80 | (65536 & 0x7f) as u8);
1362        delta.push(0x80 | ((65536 >> 7) & 0x7f) as u8);
1363        delta.push(((65536 >> 14) & 0x7f) as u8);
1364        // COPY: offset=0 (no offset bytes), size=0 (no size bytes) → means 0x10000
1365        // cmd = 0x80 (no offset/size bytes present at all → offset=0, size=0→65536)
1366        delta.push(0x80u8);
1367
1368        let result = apply_delta(&base, &delta).unwrap();
1369        assert_eq!(result.len(), 65536);
1370        assert!(result.iter().all(|&b| b == 0xAB));
1371    }
1372
1373    #[test]
1374    fn test_unpack_objects_blobs() {
1375        use tempfile::TempDir;
1376        let tmp = TempDir::new().unwrap();
1377        let objects_dir = tmp.path().join("objects");
1378        std::fs::create_dir_all(&objects_dir).unwrap();
1379        let odb = Odb::new(&objects_dir);
1380
1381        let pack = make_pack(&[
1382            (ObjectKind::Blob, b"hello\n"),
1383            (ObjectKind::Blob, b"world\n"),
1384        ]);
1385
1386        let opts = UnpackOptions::default();
1387        let count = unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap();
1388        assert_eq!(count, 2);
1389
1390        // Verify both blobs can be read back.
1391        let oid1 = Odb::hash_object_data(ObjectKind::Blob, b"hello\n");
1392        let oid2 = Odb::hash_object_data(ObjectKind::Blob, b"world\n");
1393        let obj1 = odb.read(&oid1).unwrap();
1394        let obj2 = odb.read(&oid2).unwrap();
1395        assert_eq!(obj1.data, b"hello\n");
1396        assert_eq!(obj2.data, b"world\n");
1397    }
1398
1399    #[test]
1400    fn test_unpack_objects_empty_tree() {
1401        use tempfile::TempDir;
1402        let tmp = TempDir::new().unwrap();
1403        let objects_dir = tmp.path().join("objects");
1404        std::fs::create_dir_all(&objects_dir).unwrap();
1405        let odb = Odb::new(&objects_dir);
1406
1407        let pack = make_pack(&[(ObjectKind::Tree, b"")]);
1408        let opts = UnpackOptions::default();
1409        assert_eq!(
1410            unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap(),
1411            1
1412        );
1413        let oid = Odb::hash_object_data(ObjectKind::Tree, b"");
1414        assert!(odb.exists(&oid));
1415        let loose = objects_dir
1416            .join(oid.loose_prefix())
1417            .join(oid.loose_suffix());
1418        assert!(
1419            loose.is_file(),
1420            "empty tree must be materialized as a loose object during unpack"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_strict_skips_gitlink_tree_entries() {
1426        use crate::index::{MODE_GITLINK, MODE_REGULAR};
1427        use crate::objects::{serialize_tree, TreeEntry};
1428
1429        // A submodule commit oid that is NOT in the pack/ODB (lives in the
1430        // submodule repository, like a 160000 gitlink target on push).
1431        let submodule_oid = ObjectId::from_hex(&"7f".repeat(20)).unwrap();
1432
1433        // Superproject tree referencing the submodule via a gitlink entry.
1434        let tree_data = serialize_tree(&[TreeEntry {
1435            mode: MODE_GITLINK,
1436            name: b"sub".to_vec(),
1437            oid: submodule_oid,
1438        }]);
1439        let tree_oid = Odb::hash_object_data(ObjectKind::Tree, &tree_data);
1440
1441        // Strict connectivity must NOT flag the gitlink target as missing,
1442        // matching upstream git (git/fsck.c skips S_ISGITLINK entries).
1443        let mut pack = HashMap::new();
1444        pack.insert(tree_oid, (ObjectKind::Tree, tree_data.clone()));
1445        assert!(strict_verify_packed_references(None, &pack).is_ok());
1446
1447        // Regression guard: a non-gitlink (regular file) entry pointing at an
1448        // absent blob must still be reported as a strict connectivity error.
1449        let bad_tree = serialize_tree(&[TreeEntry {
1450            mode: MODE_REGULAR,
1451            name: b"file".to_vec(),
1452            oid: ObjectId::from_hex(&"ab".repeat(20)).unwrap(),
1453        }]);
1454        let bad_oid = Odb::hash_object_data(ObjectKind::Tree, &bad_tree);
1455        let mut bad_pack = HashMap::new();
1456        bad_pack.insert(bad_oid, (ObjectKind::Tree, bad_tree));
1457        assert!(matches!(
1458            strict_verify_packed_references(None, &bad_pack),
1459            Err(Error::CorruptObject(_))
1460        ));
1461    }
1462
1463    /// `Read` that returns at most `max_len` bytes per call (simulates side-band chunking).
1464    struct ChunkedReader<'a> {
1465        data: &'a [u8],
1466        pos: usize,
1467        max_len: usize,
1468    }
1469
1470    impl io::Read for ChunkedReader<'_> {
1471        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1472            if self.pos >= self.data.len() {
1473                return Ok(0);
1474            }
1475            let take = (self.data.len() - self.pos)
1476                .min(self.max_len)
1477                .min(buf.len());
1478            buf[..take].copy_from_slice(&self.data[self.pos..self.pos + take]);
1479            self.pos += take;
1480            Ok(take)
1481        }
1482    }
1483
1484    #[test]
1485    fn test_unpack_objects_chunked_read_matches_full_buffer() {
1486        use tempfile::TempDir;
1487        let pack = make_pack(&[(ObjectKind::Blob, b"chunked-stream")]);
1488        let opts = UnpackOptions::default();
1489        let oid = Odb::hash_object_data(ObjectKind::Blob, b"chunked-stream");
1490
1491        let tmp = TempDir::new().unwrap();
1492        let objects_dir = tmp.path().join("objects");
1493        std::fs::create_dir_all(&objects_dir).unwrap();
1494        let odb = Odb::new(&objects_dir);
1495        assert_eq!(
1496            unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap(),
1497            1
1498        );
1499        assert!(odb.exists(&oid));
1500
1501        let tmp2 = TempDir::new().unwrap();
1502        let objects_dir2 = tmp2.path().join("objects");
1503        std::fs::create_dir_all(&objects_dir2).unwrap();
1504        let odb2 = Odb::new(&objects_dir2);
1505        let mut chunked = ChunkedReader {
1506            data: pack.as_slice(),
1507            pos: 0,
1508            max_len: 8,
1509        };
1510        assert_eq!(unpack_objects(&mut chunked, &odb2, &opts).unwrap(), 1);
1511        assert!(odb2.exists(&oid));
1512    }
1513
1514    #[test]
1515    fn test_unpack_objects_dry_run_writes_nothing() {
1516        use tempfile::TempDir;
1517        let tmp = TempDir::new().unwrap();
1518        let objects_dir = tmp.path().join("objects");
1519        std::fs::create_dir_all(&objects_dir).unwrap();
1520        let odb = Odb::new(&objects_dir);
1521
1522        let pack = make_pack(&[(ObjectKind::Blob, b"test content")]);
1523
1524        let opts = UnpackOptions {
1525            dry_run: true,
1526            quiet: true,
1527            strict: false,
1528            allowed_missing: Default::default(),
1529            allow_promisor_missing_references: false,
1530            max_input_bytes: None,
1531            ..Default::default()
1532        };
1533        let count = unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap();
1534        assert_eq!(count, 1);
1535
1536        // Nothing should be written.
1537        let oid = Odb::hash_object_data(ObjectKind::Blob, b"test content");
1538        assert!(!odb.exists(&oid));
1539    }
1540
1541    #[test]
1542    fn test_unpack_objects_bad_signature() {
1543        use tempfile::TempDir;
1544        let tmp = TempDir::new().unwrap();
1545        let objects_dir = tmp.path().join("objects");
1546        std::fs::create_dir_all(&objects_dir).unwrap();
1547        let odb = Odb::new(&objects_dir);
1548
1549        let mut bad = b"NOPE\x00\x00\x00\x02\x00\x00\x00\x00".to_vec();
1550        bad.extend_from_slice(&[0u8; 20]);
1551        let opts = UnpackOptions::default();
1552        let err = unpack_objects(&mut bad.as_slice(), &odb, &opts).unwrap_err();
1553        assert!(err.to_string().contains("invalid signature"));
1554    }
1555
1556    #[test]
1557    fn test_unpack_objects_checksum_mismatch() {
1558        use tempfile::TempDir;
1559        let tmp = TempDir::new().unwrap();
1560        let objects_dir = tmp.path().join("objects");
1561        std::fs::create_dir_all(&objects_dir).unwrap();
1562        let odb = Odb::new(&objects_dir);
1563
1564        let mut pack = make_pack(&[(ObjectKind::Blob, b"data")]);
1565        // Corrupt the trailing checksum.
1566        let n = pack.len();
1567        pack[n - 1] ^= 0xFF;
1568
1569        let opts = UnpackOptions::default();
1570        let err = unpack_objects(&mut pack.as_slice(), &odb, &opts).unwrap_err();
1571        assert!(err.to_string().contains("checksum"));
1572    }
1573
1574    #[test]
1575    fn test_apply_delta_source_size_mismatch() {
1576        let base = b"hi";
1577        let delta = [3u8, 2u8, 2u8, b'h', b'i']; // src_size=3 != base.len()=2
1578        let err = apply_delta(base, &delta).unwrap_err();
1579        assert!(err.to_string().contains("source size"));
1580    }
1581}