Skip to main content

git_remote_object_store/packchain/
read.rs

1//! Direct file access against a packchain remote (issue #65).
2//!
3//! [`read_blob`] is the differentiated value-add of the packchain
4//! engine: a caller fetches a single file at a ref's tip without
5//! cloning, materialising a working tree, or invoking git. The
6//! lookup walks the on-bucket artefacts the Phase 2 push wrote:
7//!
8//! 1. `chain.json` to verify the ref exists.
9//! 2. `path-index.json` to resolve `path` → blob SHA at tip.
10//! 3. Each segment's `.idx` (newest-first) to locate the blob's pack
11//!    entry.
12//! 4. A ranged GET against the matching `.pack` to fetch the entry
13//!    bytes, zlib-decompressed (and delta-applied, if applicable).
14//!
15//! The pack-index parses are amortised across calls via
16//! [`PackIndexCache`], a byte-bounded LRU keyed by
17//! `(prefix, content-sha)`. Single-shot callers can pass
18//! `&PackIndexCache::default()` and let the cache GC at drop.
19//!
20//! ## Delta resolution
21//!
22//! Pack entries may be deltas against a base elsewhere in the chain.
23//! `OFS_DELTA` resolves within the same pack via a relative back-offset;
24//! `REF_DELTA` resolves to a SHA which may live in any pack in the
25//! chain. The walker recurses, capped at [`MAX_DELTA_DEPTH`] (matching
26//! git's own limit) so a corrupted chain with a delta cycle aborts
27//! cleanly instead of looping forever.
28//!
29//! ## What this module does *not* do
30//!
31//! - **No on-disk cache**: indices live in memory only. CI agents that
32//!   want cross-process amortisation should layer their own.
33//! - **No directory listings**: [`read_blob`] is single-file. The
34//!   nested-tree shape supports listing cleanly, but it's a separate
35//!   API and out of scope for issue #65.
36
37use std::collections::{BTreeMap, HashMap, VecDeque};
38use std::sync::{Arc, Mutex};
39
40use bytes::Bytes;
41use gix_pack::data::entry::Header as EntryHeader;
42use tracing::{debug, warn};
43
44use crate::git::RefName;
45use crate::object_store::{ObjectStore, ObjectStoreError};
46use crate::remote::Remote;
47use crate::url::StorageEngine;
48
49use super::PackchainError;
50use super::keys::{pack_idx_key, pack_key};
51use super::manifest::{load_chain, load_path_index};
52use super::retry::{
53    PACK_MISSING_MAX_RETRIES, PACK_MISSING_RETRY_BACKOFFS, chain_references_pack_key,
54};
55use super::schema::{ChainManifest, ChainSegment, PathNode, Sha40};
56
57/// Hard cap on delta-chain depth, matching git's own
58/// `pack.deltaCacheLimit`-adjacent recursion limit. A correctly built
59/// chain won't approach this; tripping the cap means the pack is
60/// corrupted (a cycle) or pathologically deep, and either way
61/// stopping is the right call.
62pub const MAX_DELTA_DEPTH: u32 = 50;
63
64/// Default in-memory budget for [`PackIndexCache`] (64 MiB), matching
65/// the cap the issue #65 plan calls out. Covers a chain of dozens of
66/// large packs without thrashing.
67pub const DEFAULT_CACHE_CAPACITY_BYTES: u64 = 64 * 1024 * 1024;
68
69/// Upper safety bound on the bytes a single ranged GET may request,
70/// covering both the terminal-entry path in [`fetch_entry_bytes`] and
71/// the fallback widening in [`inflate_with_retry`]. Past this point
72/// we surface a typed error rather than pulling unbounded bytes — a
73/// single multi-GiB blob in a code repo is overwhelmingly likely to
74/// be a misuse (git-LFS material) rather than a legitimate
75/// `read_blob` target.
76const MAX_RANGE_BYTES: u64 = 1024 * 1024 * 1024;
77
78/// Hard cap on a single decompressed pack object (1 GiB), enforced
79/// against attacker-controlled values from the pack entry header
80/// (`decompressed_size`) and the delta dst-size header. A malicious
81/// bucket can craft these to claim huge sizes; without a cap, we
82/// would `vec![0u8; n]` or `Vec::with_capacity(n)` for that many
83/// bytes and either panic or thrash. 1 GiB matches [`MAX_RANGE_BYTES`]
84/// and exceeds any realistic source-tree blob; LFS material lives in
85/// the LFS path, not [`read_blob`].
86const MAX_DECOMPRESSED_BYTES: u64 = 1024 * 1024 * 1024;
87
88/// Maximum number of times the fallback range may expand before the
89/// reader gives up with [`PackchainError::MalformedPackEntry`]. Each
90/// expansion doubles the range, so 6 retries cover up to ~1 GiB.
91const MAX_RANGE_EXPANSIONS: u32 = 6;
92
93/// In-process LRU cache of decoded pack indices keyed by
94/// `(prefix, content-sha)`.
95///
96/// Capacity is bounded by **byte size**, not entry count: a single 1 GB
97/// pack carries an .idx file of multiple MiB, so an entry-count cap
98/// would either over- or under-budget for realistic chains. Eviction
99/// is least-recently-used.
100///
101/// The cache is `Send + Sync` and shareable across [`read_blob`] calls.
102/// Multiple concurrent calls block briefly on the inner mutex during
103/// lookup / insert; the inflate / range-GET work happens outside the
104/// lock so contention stays bounded.
105///
106/// ## LRU bookkeeping cost
107///
108/// `get` and `insert` walk the order [`VecDeque`] via `iter().position`
109/// to move the touched key to the back — **O(n) in the cache size**.
110/// For typical packchain workloads (single-digit indices in flight),
111/// the constant factor dominates and this is faster than a true O(1)
112/// linked-list LRU. If a workload starts seeing hundreds of cached
113/// indices, this should be revisited (e.g. swap to the `lru` crate or
114/// hand-roll a `HashMap` + intrusive doubly-linked list). The simple
115/// shape is intentional for now.
116///
117/// # Example
118///
119/// ```no_run
120/// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
121/// use git_remote_object_store::{packchain::PackIndexCache, Remote};
122///
123/// let remote = Remote::connect("s3+https://bucket/repo?engine=packchain").await?;
124/// let cache = PackIndexCache::default();
125/// let bytes = git_remote_object_store::packchain::read_blob(
126///     &remote,
127///     "refs/heads/main",
128///     "src/main.rs",
129///     &cache,
130/// ).await?;
131/// println!("{}", String::from_utf8_lossy(&bytes));
132/// # Ok(())
133/// # }
134/// ```
135pub struct PackIndexCache {
136    inner: Mutex<CacheInner>,
137    capacity_bytes: u64,
138}
139
140struct CacheInner {
141    /// Owned indices keyed by `(prefix, content-sha)`.
142    ///
143    /// `Arc` lets [`read_blob`] hold a long-lived reference to the
144    /// index while the cache lock is dropped, so the inflate /
145    /// range-GET work below doesn't block sibling cache lookups.
146    map: HashMap<CacheKey, Arc<CachedIndex>>,
147    /// LRU order — front is least-recently-used, back is most-recent.
148    order: VecDeque<CacheKey>,
149    total_bytes: u64,
150}
151
152type CacheKey = (String, Sha40);
153
154struct CachedIndex {
155    /// Parsed .idx file owning its bytes (in-memory parse via
156    /// [`gix_pack::index::File::from_data`]).
157    file: gix_pack::index::File<Vec<u8>>,
158    /// Pre-sorted ascending pack offsets. Used to derive the
159    /// next-offset upper bound for a ranged GET against the matching
160    /// pack file. Computed once at insert.
161    sorted_offsets: Vec<u64>,
162    /// Approximate resident byte count (the .idx body plus the offsets
163    /// vector). Used for the LRU byte-budget bookkeeping.
164    bytes: u64,
165}
166
167impl PackIndexCache {
168    /// Construct a cache with the requested byte budget.
169    ///
170    /// `capacity_bytes` of zero disables caching (every lookup misses).
171    /// Use [`Self::default`] for the standard 64 MiB budget.
172    #[must_use]
173    pub fn new(capacity_bytes: u64) -> Self {
174        Self {
175            inner: Mutex::new(CacheInner {
176                map: HashMap::new(),
177                order: VecDeque::new(),
178                total_bytes: 0,
179            }),
180            capacity_bytes,
181        }
182    }
183
184    /// Total resident bytes accounted for by the cache.
185    ///
186    /// # Panics
187    ///
188    /// Panics only if a previous holder of the inner mutex panicked
189    /// while mutating cache state — an invariant violation that would
190    /// be unsafe to silently recover from.
191    #[must_use]
192    pub fn resident_bytes(&self) -> u64 {
193        self.lock().total_bytes
194    }
195
196    /// Number of cached entries.
197    ///
198    /// # Panics
199    ///
200    /// See [`Self::resident_bytes`].
201    #[must_use]
202    pub fn len(&self) -> usize {
203        self.lock().map.len()
204    }
205
206    /// Whether the cache currently holds zero entries.
207    #[must_use]
208    pub fn is_empty(&self) -> bool {
209        self.len() == 0
210    }
211
212    fn lock(&self) -> std::sync::MutexGuard<'_, CacheInner> {
213        self.inner.lock().expect("cache mutex poisoned")
214    }
215
216    fn get(&self, key: &CacheKey) -> Option<Arc<CachedIndex>> {
217        let mut inner = self.lock();
218        let entry = inner.map.get(key).cloned()?;
219        // Move to most-recently-used position.
220        remove_from_order(&mut inner.order, key);
221        inner.order.push_back(key.clone());
222        Some(entry)
223    }
224
225    fn insert(&self, key: CacheKey, value: Arc<CachedIndex>) {
226        let mut inner = self.lock();
227        let bytes = value.bytes;
228        // Replace existing entry's accounting if present.
229        if let Some(prev) = inner.map.remove(&key) {
230            inner.total_bytes = inner.total_bytes.saturating_sub(prev.bytes);
231            remove_from_order(&mut inner.order, &key);
232        }
233        // If a single entry exceeds the budget, refuse to cache it
234        // (otherwise we'd evict everything and still overshoot).
235        if bytes > self.capacity_bytes {
236            return;
237        }
238        // Evict oldest until the new entry fits.
239        while inner.total_bytes + bytes > self.capacity_bytes {
240            let Some(oldest) = inner.order.pop_front() else {
241                break;
242            };
243            if let Some(removed) = inner.map.remove(&oldest) {
244                inner.total_bytes = inner.total_bytes.saturating_sub(removed.bytes);
245            }
246        }
247        inner.total_bytes += bytes;
248        inner.order.push_back(key.clone());
249        inner.map.insert(key, value);
250    }
251}
252
253fn remove_from_order(order: &mut VecDeque<CacheKey>, key: &CacheKey) {
254    if let Some(pos) = order.iter().position(|k| k == key) {
255        order.remove(pos);
256    }
257}
258
259impl Default for PackIndexCache {
260    fn default() -> Self {
261        Self::new(DEFAULT_CACHE_CAPACITY_BYTES)
262    }
263}
264
265impl std::fmt::Debug for PackIndexCache {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        // Custom impl avoids deadlock-prone `Mutex` Debug while still
268        // exposing operationally interesting state: the static budget,
269        // current accounting, and current entry count. The `inner`
270        // field is *not* surfaced by design — it's an implementation
271        // detail and would print the entire cache contents.
272        f.debug_struct("PackIndexCache")
273            .field("capacity_bytes", &self.capacity_bytes)
274            .field("resident_bytes", &self.resident_bytes())
275            .field("entries", &self.len())
276            .finish_non_exhaustive()
277    }
278}
279
280/// Read the contents of `path` at `ref_name`'s tip from a packchain
281/// remote.
282///
283/// Walks `chain.json` + `path-index.json` to resolve `path` → blob
284/// SHA, then consults each segment's `.idx` newest-first for the
285/// blob's pack entry. The matching entry's bytes are fetched via a
286/// ranged GET, zlib-decompressed, and (when the entry is a delta)
287/// recursively resolved against its base. The entry's eventual blob
288/// payload is returned as an owned [`Bytes`].
289///
290/// `cache` amortises pack-index parsing across calls within the same
291/// process. Long-running consumers (CI agents, build systems) should
292/// keep one [`PackIndexCache`] for the lifetime of the process so the
293/// per-call cost is one or two API calls plus a zlib inflate; one-shot
294/// callers can pass `&PackIndexCache::default()` and discard.
295///
296/// # Errors
297///
298/// - [`PackchainError::WrongEngine`] when the remote's engine is not
299///   [`StorageEngine::Packchain`].
300/// - [`PackchainError::ChainAbsent`] when the branch is unknown to
301///   the bucket.
302/// - [`PackchainError::PathIndexAbsent`] when `chain.json` exists but
303///   `path-index.json` does not (a partially crashed first push).
304/// - [`PackchainError::TransientChainPathIndexMismatch`] when both
305///   exist but `path_index.tip != chain.tip` — a brief window during
306///   a concurrent push or compact where `chain.json` has been
307///   overwritten ahead of `path-index.json`. Retry-shaped: a
308///   subsequent read converges once the writer finishes.
309/// - [`PackchainError::MalformedPath`] for `..` segments, leading
310///   `/`, empty path, or empty segments (consecutive slashes).
311/// - [`PackchainError::PathNotFound`] when the path does not exist
312///   in the resolved tree.
313/// - [`PackchainError::PathNotABlob`] when the path resolves to a
314///   directory rather than a file.
315/// - [`PackchainError::BlobNotInChain`] when the path-index named a
316///   blob SHA absent from every pack referenced by `chain.json`.
317/// - [`PackchainError::DeltaTooDeep`] / [`PackchainError::MalformedDelta`]
318///   / [`PackchainError::MalformedPackEntry`] / [`PackchainError::Decompress`]
319///   for pack-corruption shapes.
320/// - [`PackchainError::PackMissing`], [`PackchainError::Store`], or
321///   [`PackchainError::Io`] for transport / I/O failures.
322/// - [`PackchainError::ConcurrentGcRetriesExhausted`] when a
323///   vigorous concurrent `manage gc sweep` kept deleting packs the
324///   reader had just discovered, exhausting the internal retry
325///   schedule. Callers should re-invoke `read_blob` after the
326///   compaction settles.
327pub async fn read_blob(
328    remote: &Remote,
329    ref_name: &str,
330    path: &str,
331    cache: &PackIndexCache,
332) -> Result<Bytes, PackchainError> {
333    if remote.engine() != StorageEngine::Packchain {
334        return Err(PackchainError::WrongEngine {
335            found: remote.engine(),
336        });
337    }
338
339    let segments = parse_path(path)?;
340    let remote_ref = RefName::new(ref_name).map_err(|_| PackchainError::InvalidRefName {
341        name: ref_name.to_owned(),
342    })?;
343    // `Remote::prefix()` returns `""` for bucket-root remotes; the
344    // engine-wide convention (post-#103) is `None` for "no prefix"
345    // and `Some(non-empty)` otherwise. Normalise here so cache keys
346    // and any downstream `Some(p) if !p.is_empty()` matches agree
347    // with the rest of the codebase.
348    let prefix_opt = (!remote.prefix().is_empty()).then(|| remote.prefix());
349
350    let chain = load_chain(remote.store(), prefix_opt, &remote_ref)
351        .await?
352        .ok_or_else(|| PackchainError::ChainAbsent {
353            ref_name: ref_name.to_owned(),
354        })?;
355
356    let path_index = load_path_index(remote.store(), prefix_opt, &remote_ref)
357        .await?
358        .ok_or_else(|| PackchainError::PathIndexAbsent {
359            ref_name: ref_name.to_owned(),
360        })?;
361
362    // Writers PUT chain.json before path-index.json (see the engine
363    // module doc on the linearisation point and issue #114). A crash
364    // or in-flight push between the two PUTs leaves a stale
365    // `path_index.tip` paired with a fresh `chain.tip`; resolving a
366    // path through the stale path-index would yield a blob SHA that
367    // names a different file than the caller intended (or one absent
368    // from the new chain entirely). Refuse to do that — surface the
369    // mismatch as a typed transient error so the caller can retry,
370    // and so `BlobNotInChain` keeps its honest "bucket corruption"
371    // meaning rather than masking a race window.
372    if path_index.tip != chain.tip {
373        return Err(PackchainError::TransientChainPathIndexMismatch {
374            ref_name: ref_name.to_owned(),
375            chain_tip: chain.tip.as_str().to_owned(),
376            path_index_tip: path_index.tip.as_str().to_owned(),
377        });
378    }
379
380    let blob_sha = walk_path(&path_index.tree, &segments, ref_name, path)?;
381
382    debug!(
383        ref_name = %ref_name,
384        path = %path,
385        blob = %blob_sha.as_str(),
386        segments = chain.segments.len(),
387        "read_blob: resolved path to blob, scanning chain"
388    );
389
390    let blob_oid = sha40_to_object_id(&blob_sha);
391    let result = read_with_pack_missing_retries(
392        remote.store(),
393        prefix_opt,
394        &remote_ref,
395        ref_name,
396        chain,
397        &blob_oid,
398        cache,
399    )
400    .await;
401    let blob_not_in_chain = || PackchainError::BlobNotInChain {
402        sha: blob_sha.as_str().to_owned(),
403        path: path.to_owned(),
404    };
405    match result {
406        Ok(ResolvedObject {
407            payload,
408            kind: ObjectKind::Blob,
409        }) => Ok(Bytes::from(payload)),
410        // path-index pointed at a non-blob — bucket inconsistency.
411        Ok(_) => Err(blob_not_in_chain()),
412        // Inner walker returns BlobNotInChain with an empty path field
413        // (it doesn't know the caller's path); replace with one that
414        // carries the caller's path for diagnostic clarity. Inner
415        // BlobNotInChain values for *other* shas (delta-base lookups)
416        // pass through unchanged.
417        Err(PackchainError::BlobNotInChain { sha, .. }) if sha == blob_sha.as_str() => {
418            Err(blob_not_in_chain())
419        }
420        Err(e) => Err(e),
421    }
422}
423
424/// Pack-read driver for [`read_blob`]. Walks the supplied chain for
425/// `blob_oid`; on a [`PackchainError::PackMissing`] caused by a
426/// concurrent `manage gc sweep` (detected by reloading `chain.json`
427/// and observing that the failing key is no longer referenced), waits
428/// a backoff and retries against the fresh chain. After
429/// [`PACK_MISSING_MAX_RETRIES`] retries gives up with
430/// [`PackchainError::ConcurrentGcRetriesExhausted`] (issue #136).
431///
432/// Path-index is **not** reloaded across retries — the original
433/// `blob_oid` represents the snapshot the caller asked about, and
434/// compaction preserves blob content addressing so the same SHA
435/// resolves in any compatible chain. A new push that overwrote
436/// path-index between the initial load and the retry would resolve
437/// to a different blob SHA, but the read is still well-defined at
438/// the snapshot point (the path-as-of-the-initial-load) and that is
439/// the right semantics for a stateless point-in-time read.
440///
441/// Non-`PackMissing` errors (parse, decompress, transport, etc.)
442/// pass through immediately — they are not retryable in this
443/// fashion and would otherwise wait through the backoff schedule
444/// for no reason. `BlobNotInChain` is also a passthrough so the
445/// caller can attach the user-facing `path` field.
446async fn read_with_pack_missing_retries(
447    store: &dyn ObjectStore,
448    prefix: Option<&str>,
449    remote_ref: &RefName,
450    ref_name: &str,
451    initial_chain: ChainManifest,
452    blob_oid: &gix_hash::ObjectId,
453    cache: &PackIndexCache,
454) -> Result<ResolvedObject, PackchainError> {
455    let mut current_chain = initial_chain;
456    let mut attempt: u32 = 0;
457    loop {
458        let mut depth = 0u32;
459        let result = read_object_from_chain(
460            store,
461            prefix,
462            &current_chain.segments,
463            blob_oid,
464            cache,
465            &mut depth,
466        )
467        .await;
468        let missing_key = match result {
469            Ok(resolved) => return Ok(resolved),
470            Err(PackchainError::PackMissing { key }) => key,
471            Err(e) => return Err(e),
472        };
473        // PackMissing — distinguish concurrent GC (retryable) from
474        // genuine bucket inconsistency (data loss → fail fast).
475        let reloaded = load_chain(store, prefix, remote_ref)
476            .await?
477            .ok_or_else(|| PackchainError::ChainAbsent {
478                ref_name: ref_name.to_owned(),
479            })?;
480        if chain_references_pack_key(&reloaded, prefix, &missing_key)? {
481            // The reloaded chain still names this pack — the bucket
482            // is genuinely missing data the chain still references.
483            // Surface the original PackMissing without retrying.
484            return Err(PackchainError::PackMissing { key: missing_key });
485        }
486        if attempt >= PACK_MISSING_MAX_RETRIES {
487            warn!(
488                ref_name = %ref_name,
489                last_missing_key = %missing_key,
490                attempts = attempt,
491                "read_blob: exhausted pack-missing retries against concurrent GC"
492            );
493            return Err(PackchainError::ConcurrentGcRetriesExhausted {
494                last_missing_key: missing_key,
495                attempts: attempt,
496            });
497        }
498        debug!(
499            ref_name = %ref_name,
500            missing_key = %missing_key,
501            attempt = attempt,
502            "read_blob: PackMissing on chain no longer references the pack — retrying after GC race"
503        );
504        tokio::time::sleep(PACK_MISSING_RETRY_BACKOFFS[attempt as usize]).await;
505        attempt += 1;
506        current_chain = reloaded;
507    }
508}
509
510/// Decoded pack object — the kind discriminates blobs from other
511/// types so [`read_blob`] can refuse to return a tree as a "blob".
512#[derive(Debug)]
513struct ResolvedObject {
514    payload: Vec<u8>,
515    kind: ObjectKind,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519enum ObjectKind {
520    Blob,
521    Commit,
522    Tree,
523    Tag,
524}
525
526impl ObjectKind {
527    /// Project to the `gix-object` kind so the resolved payload can be
528    /// re-hashed through [`gix::objs::compute_hash`] for the read-time
529    /// content-integrity check. The two enums are 1:1; this is a total,
530    /// infallible mapping.
531    fn to_gix_kind(self) -> gix::objs::Kind {
532        match self {
533            Self::Blob => gix::objs::Kind::Blob,
534            Self::Commit => gix::objs::Kind::Commit,
535            Self::Tree => gix::objs::Kind::Tree,
536            Self::Tag => gix::objs::Kind::Tag,
537        }
538    }
539}
540
541/// Validate `path` and split it on `/`.
542///
543/// Rejects shapes that don't map to git tree semantics: empty paths,
544/// `/`-prefixed (absolute), `..` segments, and empty segments
545/// (consecutive slashes / trailing slashes).
546fn parse_path(path: &str) -> Result<Vec<&str>, PackchainError> {
547    if path.is_empty() {
548        return Err(PackchainError::MalformedPath {
549            path: path.to_owned(),
550            reason: "empty path",
551        });
552    }
553    if path.starts_with('/') {
554        return Err(PackchainError::MalformedPath {
555            path: path.to_owned(),
556            reason: "absolute paths are not allowed",
557        });
558    }
559    let segments: Vec<&str> = path.split('/').collect();
560    for seg in &segments {
561        if seg.is_empty() {
562            return Err(PackchainError::MalformedPath {
563                path: path.to_owned(),
564                reason: "empty segment (consecutive or trailing slash)",
565            });
566        }
567        if *seg == ".." {
568            return Err(PackchainError::MalformedPath {
569                path: path.to_owned(),
570                reason: "`..` segments are not allowed",
571            });
572        }
573        if *seg == "." {
574            return Err(PackchainError::MalformedPath {
575                path: path.to_owned(),
576                reason: "`.` segments are not allowed",
577            });
578        }
579    }
580    Ok(segments)
581}
582
583/// Walk the nested path-index tree following `segments`. Returns the
584/// terminal blob's SHA on success.
585fn walk_path(
586    root: &BTreeMap<String, PathNode>,
587    segments: &[&str],
588    ref_name: &str,
589    path: &str,
590) -> Result<Sha40, PackchainError> {
591    let path_not_found = || PackchainError::PathNotFound {
592        ref_name: ref_name.to_owned(),
593        path: path.to_owned(),
594    };
595    // Splitting up front asserts the invariant `parse_path` guarantees
596    // (segments is non-empty) and lets the rest of the function be a
597    // straight walk-then-leaf-check with no unreachable fallthrough.
598    let (last_seg, prefix_segs) = segments
599        .split_last()
600        .expect("parse_path guarantees at least one segment");
601    let mut current = root;
602    for seg in prefix_segs {
603        // A mid-path blob (`a/file.txt/extra`) and a missing key both
604        // mean the caller's path doesn't resolve in this tree.
605        let Some(PathNode::Tree(children)) = current.get(*seg) else {
606            return Err(path_not_found());
607        };
608        current = children;
609    }
610    match current.get(*last_seg) {
611        Some(PathNode::Blob(sha)) => Ok(sha.clone()),
612        Some(PathNode::Tree(_)) => Err(PackchainError::PathNotABlob {
613            path: path.to_owned(),
614        }),
615        None => Err(path_not_found()),
616    }
617}
618
619fn sha40_to_object_id(sha: &Sha40) -> gix_hash::ObjectId {
620    // Sha40 invariant: exactly 40 lowercase hex characters. The
621    // gix_hash parser accepts that shape unconditionally, so the
622    // unwrap-via-expect is documenting the invariant rather than
623    // introducing a panic site (see .claude/rules/rust.md).
624    gix_hash::ObjectId::from_hex(sha.as_str().as_bytes())
625        .expect("Sha40 is always 40 lowercase hex by construction")
626}
627
628/// Locate `target_oid` in the chain (newest-first) and decode its
629/// pack entry, applying delta resolution as needed.
630async fn read_object_from_chain(
631    store: &dyn ObjectStore,
632    prefix: Option<&str>,
633    segments: &[ChainSegment],
634    target_oid: &gix_hash::ObjectId,
635    cache: &PackIndexCache,
636    depth: &mut u32,
637) -> Result<ResolvedObject, PackchainError> {
638    // Note: the delta-depth guard lives in `decode_entry`, the single
639    // chokepoint every recursive resolution path traverses. Putting it
640    // here would miss the OFS_DELTA branch, which recurses through
641    // `decode_entry` directly without coming back via this function
642    // (issue #83).
643    for segment in segments {
644        let content_sha = super::keys::segment_pack_sha(segment)?;
645        let idx = load_index(store, prefix, &content_sha, cache).await?;
646        let Some(entry_index) = idx.file.lookup(target_oid) else {
647            continue;
648        };
649        let pack_offset = idx.file.pack_offset_at_index(entry_index);
650        let bytes = fetch_entry_bytes(store, prefix, &content_sha, pack_offset, &idx).await?;
651        let resolved = Box::pin(decode_entry(
652            store,
653            prefix,
654            segments,
655            &content_sha,
656            pack_offset,
657            &bytes,
658            cache,
659            depth,
660        ))
661        .await?;
662        // Defense-in-depth (issue #247): the `.idx` OID→offset table and
663        // the pack trailer are only validated at build time. A tampered
664        // or corrupt bucket can rewrite an entry's bytes (or remap the
665        // `.idx`) so this offset decodes to content that is *not*
666        // `target_oid`. Re-hash the reconstituted object here — the
667        // single resolution boundary every entry kind funnels through
668        // (non-delta, OFS_DELTA, and REF_DELTA all return via this
669        // `Ok`) — and refuse to hand back mismatched bytes. The hash is
670        // O(payload) but the payload is already capped at
671        // `MAX_DECOMPRESSED_BYTES`, so the cost is bounded.
672        verify_content_hash(target_oid, &resolved)?;
673        return Ok(resolved);
674    }
675    Err(PackchainError::BlobNotInChain {
676        // `gix_hash::ObjectId: Display` already produces 40-lowercase-hex
677        // (`Display` → `to_hex()` → `HexDisplay`).
678        sha: target_oid.to_string(),
679        path: String::new(),
680    })
681}
682
683/// Re-hash a fully reconstituted object and confirm its git object id
684/// equals the OID the caller resolved via the `.idx` (issue #247).
685///
686/// [`gix::objs::compute_hash`] frames the canonical loose-object header
687/// (`<kind> <len>\0`) ahead of the payload and hashes the lot with the
688/// chain's hash kind (SHA-1 today, matching the `gix_hash::Kind::Sha1`
689/// the `.idx`/pack layer is parsed with). It borrows the payload, so no
690/// extra copy of the (already-capped) bytes is made. On mismatch the
691/// caller must treat the bytes as untrusted and refuse to return them.
692fn verify_content_hash(
693    target_oid: &gix_hash::ObjectId,
694    resolved: &ResolvedObject,
695) -> Result<(), PackchainError> {
696    let actual = gix::objs::compute_hash(
697        gix_hash::Kind::Sha1,
698        resolved.kind.to_gix_kind(),
699        &resolved.payload,
700    )
701    .map_err(|e| PackchainError::MalformedPackEntry {
702        offset: 0,
703        reason: format!("content-hash computation failed: {e}"),
704    })?;
705    if &actual != target_oid {
706        return Err(PackchainError::ContentHashMismatch {
707            expected: target_oid.to_string(),
708            actual: actual.to_string(),
709        });
710    }
711    Ok(())
712}
713
714async fn load_index(
715    store: &dyn ObjectStore,
716    prefix: Option<&str>,
717    content_sha: &Sha40,
718    cache: &PackIndexCache,
719) -> Result<Arc<CachedIndex>, PackchainError> {
720    let key = (prefix.unwrap_or("").to_owned(), content_sha.clone());
721    if let Some(hit) = cache.get(&key) {
722        return Ok(hit);
723    }
724
725    let idx_key = pack_idx_key(prefix, content_sha);
726    let idx_bytes = match store.get_bytes(&idx_key).await {
727        Ok(b) => b,
728        Err(ObjectStoreError::NotFound(_)) => {
729            return Err(PackchainError::PackMissing { key: idx_key });
730        }
731        Err(e) => return Err(PackchainError::Store(e)),
732    };
733
734    let owned: Vec<u8> = idx_bytes.to_vec();
735    let owned_len = owned.len() as u64;
736    let path = std::path::PathBuf::from(idx_key);
737    let file =
738        gix_pack::index::File::from_data(owned, path, gix_hash::Kind::Sha1).map_err(|e| {
739            PackchainError::MalformedPackEntry {
740                offset: 0,
741                reason: format!("idx parse: {e}"),
742            }
743        })?;
744    let sorted_offsets = file.sorted_offsets();
745    let offsets_bytes = (sorted_offsets.len() as u64).saturating_mul(8);
746    let cached = Arc::new(CachedIndex {
747        file,
748        sorted_offsets,
749        bytes: owned_len.saturating_add(offsets_bytes),
750    });
751    cache.insert(key, Arc::clone(&cached));
752    Ok(cached)
753}
754
755/// Range-GET the pack bytes for the entry starting at `pack_offset`.
756///
757/// Bounds are derived from `idx.sorted_offsets`: the next-greater
758/// offset is the entry's end. When `pack_offset` is the highest
759/// recorded offset (the last entry), the actual entry end is the
760/// trailer position — which we don't know without an extra round
761/// trip. Strategy:
762///
763/// 1. If `next_offset` is known, range-GET `[pack_offset, next_offset)`.
764/// 2. Otherwise, `HEAD` the pack to learn its length, then range-GET
765///    `[pack_offset, pack_len)`. The HEAD round-trip only fires for
766///    the very last entry in a pack — every other entry's bound is
767///    already in `sorted_offsets`. Both branches enforce
768///    [`MAX_RANGE_BYTES`]; a terminal-entry tail above the cap is
769///    rejected as [`PackchainError::MalformedPackEntry`] rather than
770///    pulled in full.
771async fn fetch_entry_bytes(
772    store: &dyn ObjectStore,
773    prefix: Option<&str>,
774    content_sha: &Sha40,
775    pack_offset: u64,
776    idx: &CachedIndex,
777) -> Result<Bytes, PackchainError> {
778    let pack = pack_key(prefix, content_sha);
779    let next_offset = idx
780        .sorted_offsets
781        .iter()
782        .copied()
783        .find(|&o| o > pack_offset);
784    let end = if let Some(end) = next_offset {
785        end
786    } else {
787        // Last entry in the pack — learn pack length via HEAD so the
788        // range can be bounded the same way as non-terminal entries.
789        let meta = match store.head(&pack).await {
790            Ok(m) => m,
791            Err(ObjectStoreError::NotFound(_)) => {
792                return Err(PackchainError::PackMissing { key: pack });
793            }
794            Err(e) => return Err(PackchainError::Store(e)),
795        };
796        if pack_offset >= meta.size {
797            return Err(PackchainError::MalformedPackEntry {
798                offset: pack_offset,
799                reason: "entry offset beyond pack EOF".to_owned(),
800            });
801        }
802        meta.size
803    };
804    let span = end.saturating_sub(pack_offset);
805    if span > MAX_RANGE_BYTES {
806        return Err(PackchainError::MalformedPackEntry {
807            offset: pack_offset,
808            reason: format!("entry range {span} bytes exceeds {MAX_RANGE_BYTES}-byte cap"),
809        });
810    }
811    match store.get_bytes_range(&pack, pack_offset..end).await {
812        Ok(b) => Ok(b),
813        Err(ObjectStoreError::NotFound(_)) => Err(PackchainError::PackMissing { key: pack }),
814        Err(e) => Err(PackchainError::Store(e)),
815    }
816}
817
818#[allow(clippy::too_many_arguments)]
819async fn decode_entry(
820    store: &dyn ObjectStore,
821    prefix: Option<&str>,
822    chain: &[ChainSegment],
823    content_sha: &Sha40,
824    pack_offset: u64,
825    raw: &[u8],
826    cache: &PackIndexCache,
827    depth: &mut u32,
828) -> Result<ResolvedObject, PackchainError> {
829    // Single chokepoint for the delta-depth budget: every recursive
830    // delta resolution path — REF_DELTA via `read_object_from_chain`
831    // and OFS_DELTA via the direct recursion below — re-enters here.
832    // Guarding only `read_object_from_chain` (the previous shape) let
833    // a pure-OFS_DELTA chain stack-overflow because OFS_DELTA never
834    // re-routed through it (issue #83).
835    if *depth > MAX_DELTA_DEPTH {
836        return Err(PackchainError::DeltaTooDeep {
837            max: MAX_DELTA_DEPTH,
838        });
839    }
840    *depth += 1;
841
842    let entry =
843        gix_pack::data::Entry::from_bytes(raw, pack_offset, gix_hash::Kind::Sha1.len_in_bytes())
844            .map_err(|e| PackchainError::MalformedPackEntry {
845                offset: pack_offset,
846                reason: e.to_string(),
847            })?;
848
849    // `data_offset` is absolute (pack_offset + header_size). Convert
850    // to an index into our locally-fetched buffer. Both casts must
851    // succeed: header_size is the number of bytes the entry header
852    // consumed (always tiny), and decompressed_size came from the
853    // entry header itself (capped by the pack format at u32-ish).
854    let header_size: usize = usize::try_from(entry.data_offset - pack_offset).map_err(|_| {
855        PackchainError::MalformedPackEntry {
856            offset: pack_offset,
857            reason: "entry header size exceeds usize".to_owned(),
858        }
859    })?;
860    // Reject pack-header-driven sizes above the hard cap *before*
861    // converting to `usize` and allocating. A malicious bucket can
862    // claim arbitrary `decompressed_size`; without this guard we'd
863    // `vec![0u8; n]` for that many bytes in `inflate_to`.
864    if entry.decompressed_size > MAX_DECOMPRESSED_BYTES {
865        return Err(PackchainError::MalformedPackEntry {
866            offset: pack_offset,
867            reason: format!(
868                "decompressed object size {} exceeds {}-byte cap",
869                entry.decompressed_size, MAX_DECOMPRESSED_BYTES
870            ),
871        });
872    }
873    let decompressed_size: usize = usize::try_from(entry.decompressed_size).map_err(|_| {
874        PackchainError::MalformedPackEntry {
875            offset: pack_offset,
876            reason: "decompressed object size exceeds usize".to_owned(),
877        }
878    })?;
879
880    let inflated = inflate_with_retry(
881        store,
882        prefix,
883        content_sha,
884        pack_offset,
885        raw,
886        header_size,
887        decompressed_size,
888    )
889    .await?;
890
891    match entry.header {
892        EntryHeader::Blob => Ok(ResolvedObject {
893            payload: inflated,
894            kind: ObjectKind::Blob,
895        }),
896        EntryHeader::Commit => Ok(ResolvedObject {
897            payload: inflated,
898            kind: ObjectKind::Commit,
899        }),
900        EntryHeader::Tree => Ok(ResolvedObject {
901            payload: inflated,
902            kind: ObjectKind::Tree,
903        }),
904        EntryHeader::Tag => Ok(ResolvedObject {
905            payload: inflated,
906            kind: ObjectKind::Tag,
907        }),
908        EntryHeader::OfsDelta { base_distance } => {
909            let base_offset = pack_offset.checked_sub(base_distance).ok_or(
910                PackchainError::MalformedPackEntry {
911                    offset: pack_offset,
912                    reason: "ofs-delta base distance underflows pack offset".to_owned(),
913                },
914            )?;
915            let idx = load_index(store, prefix, content_sha, cache).await?;
916            let base_bytes =
917                fetch_entry_bytes(store, prefix, content_sha, base_offset, &idx).await?;
918            let base = Box::pin(decode_entry(
919                store,
920                prefix,
921                chain,
922                content_sha,
923                base_offset,
924                &base_bytes,
925                cache,
926                depth,
927            ))
928            .await?;
929            apply_delta(&base, &inflated)
930        }
931        EntryHeader::RefDelta { base_id } => {
932            let base = Box::pin(read_object_from_chain(
933                store, prefix, chain, &base_id, cache, depth,
934            ))
935            .await?;
936            apply_delta(&base, &inflated)
937        }
938    }
939}
940
941/// Inflate the entry's compressed payload, widening the range and
942/// retrying when the locally-fetched buffer is short of the zlib
943/// stream end. Only fires for the very last entry in a pack — every
944/// other entry's range is bounded by [`CachedIndex::sorted_offsets`].
945async fn inflate_with_retry(
946    store: &dyn ObjectStore,
947    prefix: Option<&str>,
948    content_sha: &Sha40,
949    pack_offset: u64,
950    raw: &[u8],
951    header_size: usize,
952    decompressed_size: usize,
953) -> Result<Vec<u8>, PackchainError> {
954    // Own the wider buffer as `Bytes` when a retry has fetched more.
955    // `Bytes` is Arc-backed so storing it (vs `Vec<u8>`) avoids the
956    // `.to_vec()` copy on every retry. The `&buf[header_size..]`
957    // re-borrow below auto-derefs through `Bytes`'s `Deref<Target=[u8]>`
958    // — we're not constructing a `Bytes::slice`, just indexing into
959    // the existing buffer.
960    let mut current_buffer: Option<Bytes> = None;
961    let mut current_end = pack_offset.saturating_add(raw.len() as u64);
962    let mut expansions = 0u32;
963    loop {
964        let compressed: &[u8] = match &current_buffer {
965            Some(buf) => &buf[header_size..],
966            None => &raw[header_size..],
967        };
968        match inflate_to(compressed, decompressed_size) {
969            Ok(v) => return Ok(v),
970            Err(InflateOutcome::NeedMoreInput) => {
971                if expansions >= MAX_RANGE_EXPANSIONS {
972                    return Err(PackchainError::MalformedPackEntry {
973                        offset: pack_offset,
974                        reason: "ran out of compressed bytes after maximum range expansion"
975                            .to_owned(),
976                    });
977                }
978                let next_size = ((current_end - pack_offset) * 2).min(MAX_RANGE_BYTES);
979                if next_size <= current_end - pack_offset {
980                    return Err(PackchainError::MalformedPackEntry {
981                        offset: pack_offset,
982                        reason: "range expansion hit safety cap".to_owned(),
983                    });
984                }
985                let new_end = pack_offset + next_size;
986                let pack = pack_key(prefix, content_sha);
987                let bytes = match store.get_bytes_range(&pack, pack_offset..new_end).await {
988                    Ok(b) => b,
989                    Err(ObjectStoreError::NotFound(_)) => {
990                        return Err(PackchainError::PackMissing { key: pack });
991                    }
992                    Err(ObjectStoreError::RangeNotSatisfiable { .. }) => {
993                        return Err(PackchainError::MalformedPackEntry {
994                            offset: pack_offset,
995                            reason: "zlib stream truncated at pack EOF".to_owned(),
996                        });
997                    }
998                    Err(e) => return Err(PackchainError::Store(e)),
999                };
1000                current_buffer = Some(bytes);
1001                current_end = new_end;
1002                expansions += 1;
1003            }
1004            Err(InflateOutcome::Failed) => {
1005                return Err(PackchainError::Decompress {
1006                    offset: pack_offset,
1007                });
1008            }
1009        }
1010    }
1011}
1012
1013/// One-shot zlib inflate into a buffer of the announced decompressed
1014/// size. `gix_features::zlib::Inflate` handles the actual decode; the
1015/// outer return distinguishes "need more input" (caller can widen the
1016/// range) from "stream is broken".
1017fn inflate_to(input: &[u8], announced_size: usize) -> Result<Vec<u8>, InflateOutcome> {
1018    use gix::features::zlib::{FlushDecompress, Status};
1019
1020    let mut state = gix::features::zlib::Decompress::new();
1021    let mut out = vec![0u8; announced_size];
1022    match state.decompress(input, &mut out, FlushDecompress::Finish) {
1023        Ok(Status::StreamEnd) => {
1024            let produced =
1025                usize::try_from(state.total_out()).map_err(|_| InflateOutcome::Failed)?;
1026            if produced != announced_size {
1027                return Err(InflateOutcome::Failed);
1028            }
1029            Ok(out)
1030        }
1031        Ok(Status::Ok | Status::BufError) => Err(InflateOutcome::NeedMoreInput),
1032        Err(_) => Err(InflateOutcome::Failed),
1033    }
1034}
1035
1036enum InflateOutcome {
1037    NeedMoreInput,
1038    Failed,
1039}
1040
1041/// Apply a git pack-format delta to `base`, returning the
1042/// reconstituted object with the same kind as `base`.
1043fn apply_delta(base: &ResolvedObject, delta: &[u8]) -> Result<ResolvedObject, PackchainError> {
1044    let mut cursor = 0usize;
1045    let (src_size, n) = read_size_varint(delta, cursor).ok_or(PackchainError::MalformedDelta {
1046        reason: "truncated source size header",
1047    })?;
1048    cursor += n;
1049    let (dst_size, n) = read_size_varint(delta, cursor).ok_or(PackchainError::MalformedDelta {
1050        reason: "truncated destination size header",
1051    })?;
1052    cursor += n;
1053    if src_size != base.payload.len() as u64 {
1054        return Err(PackchainError::MalformedDelta {
1055            reason: "delta source size does not match base object size",
1056        });
1057    }
1058    // Cap dst-size before allocating: it comes from the delta's
1059    // varint header (attacker-controlled in a malicious bucket).
1060    // Without this guard, `Vec::with_capacity(huge)` would panic
1061    // or thrash. Same cap as the entry-header path uses.
1062    if dst_size > MAX_DECOMPRESSED_BYTES {
1063        return Err(PackchainError::MalformedDelta {
1064            reason: "delta destination size exceeds 1 GiB cap",
1065        });
1066    }
1067    let dst_size_usize = usize::try_from(dst_size).map_err(|_| PackchainError::MalformedDelta {
1068        reason: "delta destination size exceeds usize",
1069    })?;
1070    let mut out = Vec::with_capacity(dst_size_usize);
1071    while cursor < delta.len() {
1072        let op = delta[cursor];
1073        cursor += 1;
1074        if op & 0x80 != 0 {
1075            apply_delta_copy_op(op, delta, &mut cursor, &base.payload, &mut out)?;
1076        } else if op == 0 {
1077            return Err(PackchainError::MalformedDelta {
1078                reason: "reserved zero opcode",
1079            });
1080        } else {
1081            apply_delta_insert_op(op, delta, &mut cursor, &mut out)?;
1082        }
1083        // Bound per-op growth so a malicious delta cannot grow `out`
1084        // without limit between the dst_size header check and the
1085        // post-loop equality check. Mirrors git's `patch-delta.c`
1086        // `size -= cp_size` invariant (any op that would push past
1087        // the announced destination size is rejected immediately).
1088        if out.len() > dst_size_usize {
1089            return Err(PackchainError::MalformedDelta {
1090                reason: "produced object exceeds announced destination size",
1091            });
1092        }
1093    }
1094    if out.len() as u64 != dst_size {
1095        return Err(PackchainError::MalformedDelta {
1096            reason: "produced object does not match announced destination size",
1097        });
1098    }
1099    Ok(ResolvedObject {
1100        payload: out,
1101        kind: base.kind,
1102    })
1103}
1104
1105/// Decode a packed-bitfield operand: for each set bit in `bitmask`,
1106/// consume the next byte of `delta` and OR it in at `bit_index * 8`.
1107fn read_packed_operand(
1108    delta: &[u8],
1109    cursor: &mut usize,
1110    bitmask: u8,
1111    bits: u8,
1112    truncated_reason: &'static str,
1113) -> Result<u32, PackchainError> {
1114    let mut value = 0u32;
1115    for shift in 0..bits {
1116        if bitmask & (1 << shift) != 0 {
1117            let byte = *delta.get(*cursor).ok_or(PackchainError::MalformedDelta {
1118                reason: truncated_reason,
1119            })?;
1120            value |= u32::from(byte) << (u32::from(shift) * 8);
1121            *cursor += 1;
1122        }
1123    }
1124    Ok(value)
1125}
1126
1127/// Git's documented default copy size when the delta's size operand is
1128/// zero (`pack-format.txt`: "if the size is zero, it is assumed to be
1129/// 0x10000").
1130const GIT_DELTA_DEFAULT_COPY_SIZE: u32 = 0x1_0000;
1131
1132/// Handle a copy-from-base opcode (high bit set). Low 4 bits of `op`
1133/// signal which offset bytes follow; the next 3 bits signal which size
1134/// bytes follow. A zero size means git's documented default
1135/// ([`GIT_DELTA_DEFAULT_COPY_SIZE`]).
1136fn apply_delta_copy_op(
1137    op: u8,
1138    delta: &[u8],
1139    cursor: &mut usize,
1140    base: &[u8],
1141    out: &mut Vec<u8>,
1142) -> Result<(), PackchainError> {
1143    let copy_offset = read_packed_operand(delta, cursor, op, 4, "truncated delta copy offset")?;
1144    let mut copy_size =
1145        read_packed_operand(delta, cursor, op >> 4, 3, "truncated delta copy size")?;
1146    if copy_size == 0 {
1147        copy_size = GIT_DELTA_DEFAULT_COPY_SIZE;
1148    }
1149    let start = copy_offset as usize;
1150    let end = start
1151        .checked_add(copy_size as usize)
1152        .ok_or(PackchainError::MalformedDelta {
1153            reason: "copy span overflow",
1154        })?;
1155    if end > base.len() {
1156        return Err(PackchainError::MalformedDelta {
1157            reason: "copy span exceeds base object",
1158        });
1159    }
1160    out.extend_from_slice(&base[start..end]);
1161    Ok(())
1162}
1163
1164/// Handle an insert opcode. Low 7 bits of `op` are the literal length;
1165/// that many bytes follow and are copied verbatim into `out`.
1166fn apply_delta_insert_op(
1167    op: u8,
1168    delta: &[u8],
1169    cursor: &mut usize,
1170    out: &mut Vec<u8>,
1171) -> Result<(), PackchainError> {
1172    let len = op as usize;
1173    let end = cursor
1174        .checked_add(len)
1175        .ok_or(PackchainError::MalformedDelta {
1176            reason: "insert span overflow",
1177        })?;
1178    if end > delta.len() {
1179        return Err(PackchainError::MalformedDelta {
1180            reason: "insert span exceeds delta payload",
1181        });
1182    }
1183    out.extend_from_slice(&delta[*cursor..end]);
1184    *cursor = end;
1185    Ok(())
1186}
1187
1188/// Read the variable-length size encoding used at the head of a delta
1189/// payload (LEB128-ish: 7 bits per byte, MSB = continuation).
1190fn read_size_varint(data: &[u8], mut cursor: usize) -> Option<(u64, usize)> {
1191    let start = cursor;
1192    let mut value: u64 = 0;
1193    let mut shift = 0u32;
1194    loop {
1195        let byte = *data.get(cursor)?;
1196        cursor += 1;
1197        value |= u64::from(byte & 0x7f).checked_shl(shift)?;
1198        if byte & 0x80 == 0 {
1199            return Some((value, cursor - start));
1200        }
1201        shift += 7;
1202        if shift >= 64 {
1203            return None;
1204        }
1205    }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210    use super::*;
1211
1212    fn sha40(s: &str) -> Sha40 {
1213        Sha40::try_new(s).expect("test fixture sha is valid")
1214    }
1215
1216    #[test]
1217    fn parse_path_rejects_empty() {
1218        let err = parse_path("").unwrap_err();
1219        assert!(matches!(err, PackchainError::MalformedPath { .. }));
1220    }
1221
1222    #[test]
1223    fn parse_path_rejects_absolute() {
1224        let err = parse_path("/etc/passwd").unwrap_err();
1225        let PackchainError::MalformedPath { reason, .. } = err else {
1226            panic!("expected MalformedPath");
1227        };
1228        assert!(reason.contains("absolute"));
1229    }
1230
1231    #[test]
1232    fn parse_path_rejects_dotdot() {
1233        let err = parse_path("src/../etc").unwrap_err();
1234        assert!(matches!(err, PackchainError::MalformedPath { .. }));
1235    }
1236
1237    #[test]
1238    fn parse_path_rejects_dot() {
1239        let err = parse_path("./src").unwrap_err();
1240        assert!(matches!(err, PackchainError::MalformedPath { .. }));
1241    }
1242
1243    #[test]
1244    fn parse_path_rejects_double_slash() {
1245        let err = parse_path("src//main.rs").unwrap_err();
1246        assert!(matches!(err, PackchainError::MalformedPath { .. }));
1247    }
1248
1249    #[test]
1250    fn parse_path_rejects_trailing_slash() {
1251        let err = parse_path("src/main.rs/").unwrap_err();
1252        assert!(matches!(err, PackchainError::MalformedPath { .. }));
1253    }
1254
1255    #[test]
1256    fn parse_path_accepts_nested() {
1257        let segs = parse_path("src/lib/mod.rs").unwrap();
1258        assert_eq!(segs, vec!["src", "lib", "mod.rs"]);
1259    }
1260
1261    #[test]
1262    fn parse_path_accepts_single_segment() {
1263        let segs = parse_path("Cargo.toml").unwrap();
1264        assert_eq!(segs, vec!["Cargo.toml"]);
1265    }
1266
1267    const SHA_A: &str = "0123456789abcdef0123456789abcdef01234567";
1268    const SHA_B: &str = "fedcba9876543210fedcba9876543210fedcba98";
1269    const SHA_C: &str = "1111111111111111111111111111111111111111";
1270
1271    #[test]
1272    fn walk_path_finds_top_level_blob() {
1273        let mut tree = BTreeMap::new();
1274        tree.insert("Cargo.toml".to_owned(), PathNode::Blob(sha40(SHA_A)));
1275        let segs = parse_path("Cargo.toml").unwrap();
1276        let result = walk_path(&tree, &segs, "refs/heads/main", "Cargo.toml").unwrap();
1277        assert_eq!(result.as_str(), SHA_A);
1278    }
1279
1280    #[test]
1281    fn walk_path_descends_subtree() {
1282        let mut subtree = BTreeMap::new();
1283        subtree.insert("main.rs".to_owned(), PathNode::Blob(sha40(SHA_A)));
1284        let mut tree = BTreeMap::new();
1285        tree.insert("src".to_owned(), PathNode::Tree(subtree));
1286        let segs = parse_path("src/main.rs").unwrap();
1287        let result = walk_path(&tree, &segs, "refs/heads/main", "src/main.rs").unwrap();
1288        assert_eq!(result.as_str(), SHA_A);
1289    }
1290
1291    #[test]
1292    fn walk_path_missing_returns_path_not_found() {
1293        let mut tree = BTreeMap::new();
1294        tree.insert("Cargo.toml".to_owned(), PathNode::Blob(sha40(SHA_A)));
1295        let segs = parse_path("missing.txt").unwrap();
1296        let err = walk_path(&tree, &segs, "refs/heads/main", "missing.txt").unwrap_err();
1297        assert!(matches!(err, PackchainError::PathNotFound { .. }));
1298    }
1299
1300    #[test]
1301    fn walk_path_directory_returns_path_not_a_blob() {
1302        let mut subtree = BTreeMap::new();
1303        subtree.insert("main.rs".to_owned(), PathNode::Blob(sha40(SHA_A)));
1304        let mut tree = BTreeMap::new();
1305        tree.insert("src".to_owned(), PathNode::Tree(subtree));
1306        let segs = parse_path("src").unwrap();
1307        let err = walk_path(&tree, &segs, "refs/heads/main", "src").unwrap_err();
1308        assert!(matches!(err, PackchainError::PathNotABlob { .. }));
1309    }
1310
1311    #[test]
1312    fn walk_path_through_blob_returns_not_found() {
1313        let mut tree = BTreeMap::new();
1314        tree.insert("Cargo.toml".to_owned(), PathNode::Blob(sha40(SHA_A)));
1315        let segs = parse_path("Cargo.toml/extra").unwrap();
1316        let err = walk_path(&tree, &segs, "refs/heads/main", "Cargo.toml/extra").unwrap_err();
1317        assert!(matches!(err, PackchainError::PathNotFound { .. }));
1318    }
1319
1320    #[test]
1321    fn read_size_varint_single_byte() {
1322        let (v, n) = read_size_varint(&[0x05], 0).unwrap();
1323        assert_eq!(v, 5);
1324        assert_eq!(n, 1);
1325    }
1326
1327    #[test]
1328    fn read_size_varint_multi_byte() {
1329        // 0x83 = 0b10000011 → low 7 bits 3, continuation set.
1330        // 0x02 = 0b00000010 → low 7 bits 2, no continuation.
1331        // Decoded: 3 | (2 << 7) = 3 | 256 = 259.
1332        let (v, n) = read_size_varint(&[0x83, 0x02], 0).unwrap();
1333        assert_eq!(v, 259);
1334        assert_eq!(n, 2);
1335    }
1336
1337    #[test]
1338    fn read_size_varint_truncated() {
1339        // Continuation bit set on last available byte.
1340        assert!(read_size_varint(&[0x80], 0).is_none());
1341    }
1342
1343    #[test]
1344    fn cache_default_starts_empty() {
1345        // Capacity (`capacity_bytes`) is not part of the public API
1346        // surface, so this test only covers what is observable: a
1347        // freshly-defaulted cache has zero entries and zero resident
1348        // bytes. The 64 MiB default value itself is checked by the
1349        // single-entry budget check in `cache_default_rejects_oversize_entry`.
1350        let cache = PackIndexCache::default();
1351        assert_eq!(cache.len(), 0);
1352        assert!(cache.is_empty());
1353        assert_eq!(cache.resident_bytes(), 0);
1354    }
1355
1356    /// Pin the default capacity (`DEFAULT_CACHE_CAPACITY_BYTES`) by
1357    /// observing the boundary the public API exposes: an entry one
1358    /// byte over the documented 64 MiB cap is silently rejected, an
1359    /// entry exactly at the cap is accepted. A regression that
1360    /// changed the default to a different power of two would flip
1361    /// one of these two assertions.
1362    #[test]
1363    fn cache_default_enforces_64mib_capacity() {
1364        let cache = PackIndexCache::default();
1365        // Just over: rejected.
1366        cache.insert(
1367            ("p".into(), sha40(SHA_A)),
1368            Arc::new(make_dummy_index(DEFAULT_CACHE_CAPACITY_BYTES + 1)),
1369        );
1370        assert_eq!(cache.len(), 0, "entry over 64 MiB must be rejected");
1371        // Exactly at: accepted.
1372        cache.insert(
1373            ("p".into(), sha40(SHA_B)),
1374            Arc::new(make_dummy_index(DEFAULT_CACHE_CAPACITY_BYTES)),
1375        );
1376        assert_eq!(cache.len(), 1, "entry at 64 MiB must be accepted");
1377    }
1378
1379    #[test]
1380    fn cache_explicit_capacity_zero_disables_caching() {
1381        let cache = PackIndexCache::new(0);
1382        // Inserting any non-empty entry must be a no-op (single-entry
1383        // budget check).
1384        let dummy = make_dummy_index(1_024);
1385        cache.insert(("p".into(), sha40(SHA_A)), Arc::new(dummy));
1386        assert_eq!(cache.len(), 0);
1387    }
1388
1389    #[test]
1390    fn cache_evicts_lru_when_over_capacity() {
1391        let cache = PackIndexCache::new(3_000);
1392        cache.insert(
1393            ("p".into(), sha40(SHA_A)),
1394            Arc::new(make_dummy_index(1_000)),
1395        );
1396        cache.insert(
1397            ("p".into(), sha40(SHA_B)),
1398            Arc::new(make_dummy_index(1_000)),
1399        );
1400        cache.insert(
1401            ("p".into(), sha40(SHA_C)),
1402            Arc::new(make_dummy_index(1_000)),
1403        );
1404        assert_eq!(cache.len(), 3);
1405        assert_eq!(cache.resident_bytes(), 3_000);
1406
1407        // Touch SHA_A so SHA_B becomes LRU. Then insert a fourth entry
1408        // that pushes us over capacity — SHA_B must be evicted.
1409        let _ = cache.get(&("p".into(), sha40(SHA_A)));
1410        cache.insert(
1411            (
1412                "p".into(),
1413                sha40("dddddddddddddddddddddddddddddddddddddddd"),
1414            ),
1415            Arc::new(make_dummy_index(1_000)),
1416        );
1417        assert_eq!(cache.len(), 3);
1418        assert!(cache.get(&("p".into(), sha40(SHA_A))).is_some());
1419        assert!(cache.get(&("p".into(), sha40(SHA_B))).is_none());
1420    }
1421
1422    #[test]
1423    fn cache_repeated_inserts_replace_accounting() {
1424        let cache = PackIndexCache::new(10_000);
1425        let key: CacheKey = ("p".into(), sha40(SHA_A));
1426        cache.insert(key.clone(), Arc::new(make_dummy_index(1_000)));
1427        cache.insert(key.clone(), Arc::new(make_dummy_index(2_500)));
1428        assert_eq!(cache.len(), 1);
1429        assert_eq!(cache.resident_bytes(), 2_500);
1430    }
1431
1432    /// Construct a [`CachedIndex`] without a real .idx file, only for
1433    /// exercising the LRU bookkeeping. The `file` field is left
1434    /// uninitialised by parsing a minimal hand-crafted v2 idx; this is
1435    /// not used by the cache-mechanics tests.
1436    fn make_dummy_index(bytes: u64) -> CachedIndex {
1437        // A minimal v2 idx that gix_pack accepts: signature, version,
1438        // 256 fan-out entries (all zero — zero objects), and a 20-byte
1439        // pack-trailer + 20-byte idx-trailer at the end.
1440        let mut data = Vec::with_capacity(8 + 256 * 4 + 40);
1441        data.extend_from_slice(b"\xfftOc"); // V2 signature
1442        data.extend_from_slice(&2u32.to_be_bytes()); // version 2
1443        for _ in 0..256 {
1444            data.extend_from_slice(&0u32.to_be_bytes()); // fan-out: 0 objects under each leading byte
1445        }
1446        data.extend_from_slice(&[0u8; 20]); // pack trailer placeholder
1447        data.extend_from_slice(&[0u8; 20]); // idx trailer placeholder
1448        let file = gix_pack::index::File::from_data(
1449            data,
1450            std::path::PathBuf::from("dummy.idx"),
1451            gix_hash::Kind::Sha1,
1452        )
1453        .expect("hand-crafted minimal v2 idx parses");
1454        CachedIndex {
1455            file,
1456            sorted_offsets: Vec::new(),
1457            bytes,
1458        }
1459    }
1460
1461    #[test]
1462    fn sha40_to_object_id_roundtrips() {
1463        let sha = sha40(SHA_A);
1464        let oid = sha40_to_object_id(&sha);
1465        assert_eq!(oid.to_string(), SHA_A);
1466    }
1467
1468    // --- apply_delta -------------------------------------------------------
1469    //
1470    // Hand-craft delta payloads (per the git delta format) and verify
1471    // [`apply_delta`] reconstructs the right output. Without this,
1472    // OFS_DELTA / REF_DELTA paths in [`decode_entry`] are not exercised
1473    // by any test — the integration suite uses small text files that
1474    // gix-pack does not delta-encode.
1475
1476    fn base_blob(payload: &[u8]) -> ResolvedObject {
1477        ResolvedObject {
1478            payload: payload.to_vec(),
1479            kind: ObjectKind::Blob,
1480        }
1481    }
1482
1483    /// Encode a single varint per the delta header format (LEB128-ish:
1484    /// 7 bits per byte, MSB = continuation).
1485    fn varint(mut value: u64) -> Vec<u8> {
1486        let mut out = Vec::new();
1487        loop {
1488            let byte = (value & 0x7f) as u8;
1489            value >>= 7;
1490            if value == 0 {
1491                out.push(byte);
1492                return out;
1493            }
1494            out.push(byte | 0x80);
1495        }
1496    }
1497
1498    #[test]
1499    fn apply_delta_insert_only_round_trips() {
1500        // Empty base, delta is pure-insert. Reconstructed payload
1501        // must be byte-equal to the literal data the insert opcode
1502        // carries.
1503        let base = base_blob(b"");
1504        let literal = b"Hello, packchain!";
1505        let mut delta = Vec::new();
1506        delta.extend_from_slice(&varint(0)); // src_size
1507        delta.extend_from_slice(&varint(literal.len() as u64)); // dst_size
1508        // Insert opcode: low 7 bits = literal length. The literal is
1509        // 17 bytes here, so the cast to u8 is the desired narrow.
1510        delta.push(u8::try_from(literal.len()).expect("test literal fits in 7 bits"));
1511        delta.extend_from_slice(literal);
1512        let out = apply_delta(&base, &delta).expect("insert-only delta applies");
1513        assert_eq!(out.payload, literal);
1514        assert_eq!(out.kind, ObjectKind::Blob);
1515    }
1516
1517    #[test]
1518    fn apply_delta_copy_only_round_trips() {
1519        // Copy first 5 bytes from a 10-byte base.
1520        let base = base_blob(b"abcdefghij");
1521        let mut delta = Vec::new();
1522        delta.extend_from_slice(&varint(10)); // src_size
1523        delta.extend_from_slice(&varint(5)); // dst_size
1524        // Copy opcode: MSB=1; bit0 set (1 byte of offset follows);
1525        // bit4 set (1 byte of size follows).
1526        delta.push(0b1001_0001);
1527        delta.push(0); // offset = 0
1528        delta.push(5); // size = 5
1529        let out = apply_delta(&base, &delta).expect("copy-only delta applies");
1530        assert_eq!(out.payload, b"abcde");
1531    }
1532
1533    #[test]
1534    fn apply_delta_mixed_copy_and_insert_round_trips() {
1535        // Reconstruct "HELLO world" by copying "HELLO" from the base
1536        // and inserting " world".
1537        let base = base_blob(b"HELLO!?");
1538        let mut delta = Vec::new();
1539        delta.extend_from_slice(&varint(7)); // src_size
1540        delta.extend_from_slice(&varint(11)); // dst_size: "HELLO world"
1541        // Copy 5 bytes from offset 0.
1542        delta.push(0b1001_0001);
1543        delta.push(0);
1544        delta.push(5);
1545        // Insert 6 literal bytes.
1546        let literal = b" world";
1547        delta.push(u8::try_from(literal.len()).expect("test literal fits in 7 bits"));
1548        delta.extend_from_slice(literal);
1549        let out = apply_delta(&base, &delta).expect("mixed delta applies");
1550        assert_eq!(out.payload, b"HELLO world");
1551    }
1552
1553    #[test]
1554    fn apply_delta_preserves_base_kind() {
1555        // A delta against a Tree base must produce a Tree result —
1556        // delta application doesn't change object kind. Confirms the
1557        // `kind: base.kind` line at the bottom of `apply_delta`.
1558        let base = ResolvedObject {
1559            payload: b"x".to_vec(),
1560            kind: ObjectKind::Tree,
1561        };
1562        let mut delta = Vec::new();
1563        delta.extend_from_slice(&varint(1));
1564        delta.extend_from_slice(&varint(1));
1565        delta.push(0b1001_0001);
1566        delta.push(0);
1567        delta.push(1);
1568        let out = apply_delta(&base, &delta).expect("kind-preserving delta applies");
1569        assert_eq!(out.kind, ObjectKind::Tree);
1570    }
1571
1572    #[test]
1573    fn apply_delta_rejects_source_size_mismatch() {
1574        // Delta claims source size 99, base is 1 byte. Must reject
1575        // before producing output.
1576        let base = base_blob(b"x");
1577        let mut delta = Vec::new();
1578        delta.extend_from_slice(&varint(99));
1579        delta.extend_from_slice(&varint(1));
1580        delta.push(1);
1581        delta.push(b'y');
1582        let err = apply_delta(&base, &delta).expect_err("size mismatch must fail");
1583        assert!(
1584            matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("source size")),
1585            "expected MalformedDelta source-size mismatch, got {err:?}",
1586        );
1587    }
1588
1589    #[test]
1590    fn apply_delta_rejects_copy_past_base_end() {
1591        // Copy opcode asks for bytes [3..8) from a 4-byte base. Bounds
1592        // check must fire.
1593        let base = base_blob(b"abcd");
1594        let mut delta = Vec::new();
1595        delta.extend_from_slice(&varint(4));
1596        delta.extend_from_slice(&varint(5));
1597        delta.push(0b1001_0001);
1598        delta.push(3); // offset = 3
1599        delta.push(5); // size = 5 → end = 8 > 4
1600        let err = apply_delta(&base, &delta).expect_err("out-of-range copy must fail");
1601        assert!(
1602            matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("copy span")),
1603            "expected MalformedDelta copy-span error, got {err:?}",
1604        );
1605    }
1606
1607    #[test]
1608    fn apply_delta_rejects_dst_size_over_cap() {
1609        // dst_size header above MAX_DECOMPRESSED_BYTES must reject
1610        // before allocating.
1611        let base = base_blob(b"");
1612        let mut delta = Vec::new();
1613        delta.extend_from_slice(&varint(0));
1614        delta.extend_from_slice(&varint(MAX_DECOMPRESSED_BYTES + 1));
1615        let err = apply_delta(&base, &delta).expect_err("oversize dst must fail");
1616        assert!(
1617            matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("1 GiB cap")),
1618            "expected MalformedDelta cap error, got {err:?}",
1619        );
1620    }
1621
1622    #[test]
1623    fn apply_delta_rejects_reserved_zero_opcode() {
1624        // 0x00 is reserved per the git delta format.
1625        let base = base_blob(b"");
1626        let mut delta = Vec::new();
1627        delta.extend_from_slice(&varint(0));
1628        delta.extend_from_slice(&varint(0));
1629        delta.push(0); // reserved opcode
1630        let err = apply_delta(&base, &delta).expect_err("reserved opcode must fail");
1631        assert!(
1632            matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("zero opcode")),
1633            "expected MalformedDelta reserved-opcode error, got {err:?}",
1634        );
1635    }
1636
1637    #[test]
1638    fn apply_delta_copy_size_zero_substitutes_default() {
1639        // Copy opcode with NO size operand bytes (high bits 4..6 of op
1640        // all zero) — the decoded size is zero, which per git's spec
1641        // must be substituted with `GIT_DELTA_DEFAULT_COPY_SIZE`
1642        // (0x10000). We can't easily verify the produced length without
1643        // a >=64 KiB base, so use a small base and check the substitution
1644        // fires by observing the bounds-check failure: with the default
1645        // substituted, the span (offset 0, size 0x10000) overshoots the
1646        // 1-byte base and triggers "copy span exceeds base object". If
1647        // the substitution were skipped, the span would be empty and
1648        // the post-loop "destination size" check would fire instead.
1649        let base = base_blob(b"x");
1650        let mut delta = Vec::new();
1651        delta.extend_from_slice(&varint(1));
1652        delta.extend_from_slice(&varint(2)); // dst_size irrelevant; copy errors first
1653        // Copy opcode: MSB=1, bit0 set (1 byte of offset follows), all
1654        // size bits (4..6) cleared.
1655        delta.push(0b1000_0001);
1656        delta.push(0); // offset = 0
1657        let err = apply_delta(&base, &delta)
1658            .expect_err("default-size substitution must fail bounds check");
1659        assert!(
1660            matches!(&err, PackchainError::MalformedDelta { reason } if reason.contains("copy span exceeds base")),
1661            "expected copy-span-exceeds-base (proves default size was substituted), got {err:?}",
1662        );
1663    }
1664
1665    #[test]
1666    fn apply_delta_rejects_dst_size_undershoot() {
1667        // delta finishes (no more opcodes) but produced output is
1668        // shorter than the announced dst_size. The post-loop check
1669        // must catch this.
1670        let base = base_blob(b"abcdef");
1671        let mut delta = Vec::new();
1672        delta.extend_from_slice(&varint(6));
1673        delta.extend_from_slice(&varint(10)); // claim 10
1674        // ... but only emit 3 bytes via copy.
1675        delta.push(0b1001_0001);
1676        delta.push(0);
1677        delta.push(3);
1678        let err = apply_delta(&base, &delta).expect_err("undershoot must fail");
1679        assert!(
1680            matches!(err, PackchainError::MalformedDelta { reason } if reason.contains("destination size")),
1681            "expected MalformedDelta undershoot error, got {err:?}",
1682        );
1683    }
1684
1685    #[test]
1686    fn apply_delta_rejects_overshoot() {
1687        // Delta announces dst_size=4 but emits 8 bytes via a single
1688        // copy op. Without the per-op bound, `out` would grow past
1689        // the announced size and only get caught by the post-loop
1690        // equality check — a malicious delta with a multi-TiB total
1691        // could OOM the helper before reaching that point. The
1692        // per-op bound must reject as soon as `out.len()` exceeds
1693        // `dst_size_usize`.
1694        let base = base_blob(b"abcdefgh");
1695        let mut delta = Vec::new();
1696        delta.extend_from_slice(&varint(8)); // src_size
1697        delta.extend_from_slice(&varint(4)); // dst_size — under-claim
1698        // Copy 8 bytes from offset 0 (overshoots dst_size).
1699        delta.push(0b1001_0001);
1700        delta.push(0);
1701        delta.push(8);
1702        let err = apply_delta(&base, &delta).expect_err("overshoot must fail");
1703        assert!(
1704            matches!(
1705                err,
1706                PackchainError::MalformedDelta {
1707                    reason: "produced object exceeds announced destination size"
1708                }
1709            ),
1710            "expected MalformedDelta overshoot error, got {err:?}",
1711        );
1712    }
1713
1714    #[test]
1715    fn apply_delta_overshoot_check_fires_after_single_default_size_copy() {
1716        // Aggressive variant: small base (1 byte, repeated 16 times so
1717        // copy size 0x1_0000 stays in-bounds against the base), and a
1718        // copy opcode with size operand bits cleared so it falls back
1719        // to GIT_DELTA_DEFAULT_COPY_SIZE (0x1_0000 = 64 KiB). A single
1720        // op therefore emits 64 KiB, which must trip the per-op bound
1721        // when dst_size is set to 4. This proves the check fires
1722        // after the FIRST op, not just at end-of-loop — a chain of
1723        // such ops in a real attack would otherwise blow through
1724        // memory before the post-loop check ever ran.
1725        // Heap allocation avoids large_stack_arrays clippy lint (16 KiB cap).
1726        let base_payload = vec![b'x'; 0x1_0000];
1727        let base = base_blob(&base_payload);
1728        let mut delta = Vec::new();
1729        delta.extend_from_slice(&varint(0x1_0000)); // src_size matches base
1730        delta.extend_from_slice(&varint(4)); // dst_size — tiny
1731        // Copy opcode: MSB=1, bit0 set (1 byte of offset follows),
1732        // size bits (4..6) cleared so default 0x1_0000 substitutes.
1733        delta.push(0b1000_0001);
1734        delta.push(0); // offset = 0
1735        let err = apply_delta(&base, &delta).expect_err("default-size overshoot must fail");
1736        assert!(
1737            matches!(
1738                err,
1739                PackchainError::MalformedDelta {
1740                    reason: "produced object exceeds announced destination size"
1741                }
1742            ),
1743            "expected MalformedDelta overshoot error after first op, got {err:?}",
1744        );
1745    }
1746
1747    #[test]
1748    fn apply_delta_exact_match_does_not_trip_overshoot_check() {
1749        // Boundary: a delta that exactly fills dst_size must succeed.
1750        // The per-op bound rejects only `>`, never `==`, so an exact
1751        // match flows through to the post-loop equality check.
1752        let base = base_blob(b"abcd");
1753        let mut delta = Vec::new();
1754        delta.extend_from_slice(&varint(4));
1755        delta.extend_from_slice(&varint(4));
1756        delta.push(0b1001_0001);
1757        delta.push(0);
1758        delta.push(4);
1759        let out = apply_delta(&base, &delta).expect("exact-match delta applies");
1760        assert_eq!(out.payload, b"abcd");
1761    }
1762
1763    // --- delta-depth guard (issue #83) -------------------------------------
1764    //
1765    // The fix moves the depth guard from `read_object_from_chain` into
1766    // `decode_entry`, the single chokepoint every recursive resolution
1767    // path traverses. These tests exercise both the boundary and the
1768    // OFS_DELTA bypass that the old shape allowed.
1769    //
1770    // The synthesised packs use bounded payloads (small literal byte
1771    // strings), so the `as usize` / `as u8` casts cannot truncate at
1772    // runtime. Suppressing the lints here keeps the test setup direct;
1773    // production code paths use `try_from`.
1774
1775    use crate::object_store::mock::MockStore;
1776    use flate2::Compression;
1777    use flate2::write::ZlibEncoder;
1778    use std::io::Write;
1779
1780    /// Encode a pack-entry header per gix-pack's canonical encoding:
1781    /// 4-bit type tag + 4-bit low size, then 7-bit continuation bytes
1782    /// for the upper bits of `size`. This is the inverse of
1783    /// `gix_pack::data::entry::decode::parse_header_info`.
1784    #[allow(clippy::cast_possible_truncation)]
1785    fn encode_pack_entry_header(type_id: u8, mut size: u64) -> Vec<u8> {
1786        let mut out = Vec::new();
1787        let low4 = (size & 0x0f) as u8;
1788        size >>= 4;
1789        let mut byte = (type_id << 4) | low4;
1790        if size != 0 {
1791            byte |= 0x80;
1792        }
1793        out.push(byte);
1794        while size != 0 {
1795            let mut next = (size & 0x7f) as u8;
1796            size >>= 7;
1797            if size != 0 {
1798                next |= 0x80;
1799            }
1800            out.push(next);
1801        }
1802        out
1803    }
1804
1805    /// Encode an `OFS_DELTA` `base_distance` per the gix-pack
1806    /// `parse_leb64` shape (offset-LEB128, with implicit `+1` between
1807    /// continuation bytes).
1808    #[allow(clippy::cast_possible_truncation)]
1809    fn encode_ofs_delta_distance(distance: u64) -> Vec<u8> {
1810        // The decoder's invariant: `value = ((((b0 & 0x7f) + 1) << 7) |
1811        // (b1 & 0x7f) + 1) << 7) | ...` — i.e. each continuation step
1812        // adds one before shifting. Build the byte sequence by
1813        // repeatedly subtracting one and shifting right, so the decoder
1814        // reconstructs the original distance.
1815        let mut bytes = Vec::new();
1816        let mut v = distance;
1817        bytes.push((v & 0x7f) as u8);
1818        v >>= 7;
1819        while v != 0 {
1820            v -= 1;
1821            bytes.push(((v & 0x7f) as u8) | 0x80);
1822            v >>= 7;
1823        }
1824        bytes.reverse();
1825        bytes
1826    }
1827
1828    fn zlib_compress(data: &[u8]) -> Vec<u8> {
1829        let mut e = ZlibEncoder::new(Vec::new(), Compression::default());
1830        e.write_all(data).expect("zlib encode");
1831        e.finish().expect("zlib finish")
1832    }
1833
1834    /// Build a delta payload with the canonical varint header and a
1835    /// single-insert opcode that copies `payload` literally. The result
1836    /// reconstructs to `payload` regardless of the base content (the
1837    /// source-size check still runs against the base, so callers must
1838    /// pass `base_size` matching their base).
1839    #[allow(clippy::cast_possible_truncation)]
1840    fn make_insert_delta(base_size: u64, payload: &[u8]) -> Vec<u8> {
1841        let mut d = Vec::new();
1842        // src_size and dst_size as size-varint (low-bit-first, MSB
1843        // continuation), per `read_size_varint`.
1844        let put_varint = |mut v: u64, buf: &mut Vec<u8>| loop {
1845            let byte = (v & 0x7f) as u8;
1846            v >>= 7;
1847            if v == 0 {
1848                buf.push(byte);
1849                return;
1850            }
1851            buf.push(byte | 0x80);
1852        };
1853        put_varint(base_size, &mut d);
1854        put_varint(payload.len() as u64, &mut d);
1855        // Insert opcode: low 7 bits are length. Tests use small literals.
1856        assert!(payload.len() < 0x80, "test literal too long for one insert");
1857        d.push(payload.len() as u8);
1858        d.extend_from_slice(payload);
1859        d
1860    }
1861
1862    /// Append a complete pack entry (header + zlib-compressed payload)
1863    /// to `pack` and record the entry's start offset in `offsets`.
1864    #[allow(clippy::cast_possible_truncation)]
1865    fn push_pack_entry(
1866        pack: &mut Vec<u8>,
1867        offsets: &mut Vec<u64>,
1868        type_id: u8,
1869        ofs_delta_distance: Option<u64>,
1870        decompressed_payload: &[u8],
1871    ) {
1872        let start = pack.len() as u64;
1873        offsets.push(start);
1874        pack.extend(encode_pack_entry_header(
1875            type_id,
1876            decompressed_payload.len() as u64,
1877        ));
1878        if let Some(d) = ofs_delta_distance {
1879            pack.extend(encode_ofs_delta_distance(d));
1880        }
1881        pack.extend(zlib_compress(decompressed_payload));
1882    }
1883
1884    /// Wire up a `PackIndexCache` with a hand-rolled `CachedIndex`
1885    /// whose only contract with the test is `sorted_offsets`. The
1886    /// stored idx file is a zero-entry v2 stub; tests call
1887    /// `decode_entry` directly so the file-side lookup is never used.
1888    fn install_cached_index(
1889        cache: &PackIndexCache,
1890        prefix: &str,
1891        content_sha: &Sha40,
1892        offsets: Vec<u64>,
1893    ) {
1894        let cached = CachedIndex {
1895            file: minimal_v2_idx(),
1896            sorted_offsets: offsets,
1897            bytes: 1_024,
1898        };
1899        cache.insert((prefix.to_owned(), content_sha.clone()), Arc::new(cached));
1900    }
1901
1902    fn minimal_v2_idx() -> gix_pack::index::File<Vec<u8>> {
1903        let mut data = Vec::with_capacity(8 + 256 * 4 + 40);
1904        data.extend_from_slice(b"\xfftOc");
1905        data.extend_from_slice(&2u32.to_be_bytes());
1906        for _ in 0..256 {
1907            data.extend_from_slice(&0u32.to_be_bytes());
1908        }
1909        data.extend_from_slice(&[0u8; 20]);
1910        data.extend_from_slice(&[0u8; 20]);
1911        gix_pack::index::File::from_data(
1912            data,
1913            std::path::PathBuf::from("dummy.idx"),
1914            gix_hash::Kind::Sha1,
1915        )
1916        .expect("hand-crafted minimal v2 idx parses")
1917    }
1918
1919    /// `decode_entry` is the single chokepoint for the depth budget:
1920    /// invoking it with `*depth > MAX_DELTA_DEPTH` must fail before
1921    /// any decode work happens. This is what catches a recursive
1922    /// caller (`REF_DELTA` via `read_object_from_chain`, or `OFS_DELTA`
1923    /// directly) blowing past the cap.
1924    #[tokio::test]
1925    async fn decode_entry_rejects_when_depth_already_over_cap() {
1926        let store = MockStore::new();
1927        let cache = PackIndexCache::default();
1928        let chain: Vec<ChainSegment> = Vec::new();
1929        let content_sha = sha40(SHA_A);
1930        // Any well-formed Blob entry — depth check fires first, so
1931        // contents are immaterial.
1932        let mut pack = Vec::new();
1933        let mut offsets = Vec::new();
1934        push_pack_entry(&mut pack, &mut offsets, 3 /* BLOB */, None, b"x");
1935
1936        let mut depth = MAX_DELTA_DEPTH + 1;
1937        let err = decode_entry(
1938            &store,
1939            None,
1940            &chain,
1941            &content_sha,
1942            offsets[0],
1943            &pack[usize::try_from(offsets[0]).unwrap()..],
1944            &cache,
1945            &mut depth,
1946        )
1947        .await
1948        .expect_err("over-cap depth must fail");
1949        assert!(
1950            matches!(err, PackchainError::DeltaTooDeep { max } if max == MAX_DELTA_DEPTH),
1951            "expected DeltaTooDeep, got {err:?}",
1952        );
1953    }
1954
1955    /// At exactly `*depth == MAX_DELTA_DEPTH` and a non-delta entry,
1956    /// `decode_entry` must succeed: a non-delta base reached at the
1957    /// boundary is the deepest legal point in the chain. This is the
1958    /// off-by-one boundary opposite the failing case above.
1959    #[tokio::test]
1960    async fn decode_entry_at_cap_with_non_delta_base_succeeds() {
1961        let store = MockStore::new();
1962        let cache = PackIndexCache::default();
1963        let chain: Vec<ChainSegment> = Vec::new();
1964        let content_sha = sha40(SHA_A);
1965        let mut pack = Vec::new();
1966        let mut offsets = Vec::new();
1967        push_pack_entry(
1968            &mut pack,
1969            &mut offsets,
1970            3, /* BLOB */
1971            None,
1972            b"deepest-base",
1973        );
1974
1975        let mut depth = MAX_DELTA_DEPTH;
1976        let resolved = decode_entry(
1977            &store,
1978            None,
1979            &chain,
1980            &content_sha,
1981            offsets[0],
1982            &pack[usize::try_from(offsets[0]).unwrap()..],
1983            &cache,
1984            &mut depth,
1985        )
1986        .await
1987        .expect("blob at MAX boundary must decode");
1988        assert_eq!(resolved.payload, b"deepest-base");
1989        assert_eq!(resolved.kind, ObjectKind::Blob);
1990    }
1991
1992    /// Pre-fix regression: a pure-`OFS_DELTA` chain bypassed the depth
1993    /// guard because the recursive call hopped through `decode_entry`
1994    /// directly without re-entering `read_object_from_chain`. With the
1995    /// guard moved into `decode_entry`, a 2-entry pack (a base blob +
1996    /// one `OFS_DELTA` pointing at it) entered with
1997    /// `depth = MAX_DELTA_DEPTH` must fail on the recursive call to the
1998    /// base, even though the outer entry is itself a single layer.
1999    ///
2000    /// Synthesised in-memory pack — does NOT actually approach a real
2001    /// stack-overflow depth, so this test would behave identically (and
2002    /// pass) on the unfixed code's `REF_DELTA` path. It catches the
2003    /// `OFS_DELTA` bypass specifically.
2004    #[tokio::test]
2005    async fn ofs_delta_recursion_consumes_depth_budget() {
2006        let store = MockStore::new();
2007        let cache = PackIndexCache::default();
2008        let chain: Vec<ChainSegment> = Vec::new();
2009        let content_sha = sha40(SHA_A);
2010
2011        let base_payload = b"base-blob";
2012        let mut pack = Vec::new();
2013        let mut offsets = Vec::new();
2014        // Entry 0: BLOB base.
2015        push_pack_entry(&mut pack, &mut offsets, 3, None, base_payload);
2016        // Entry 1: OFS_DELTA pointing back to entry 0. The encoded
2017        // distance per gix-pack is `entry_offset - base_offset`; entry
2018        // 1's start is `pack.len()` before the push, so capture it
2019        // explicitly.
2020        let delta = make_insert_delta(base_payload.len() as u64, b"reconstructed");
2021        let entry1_start = pack.len() as u64;
2022        let distance = entry1_start - offsets[0];
2023        push_pack_entry(&mut pack, &mut offsets, 6, Some(distance), &delta);
2024
2025        // Plant the pack body in the store under the canonical key so
2026        // `fetch_entry_bytes` can range-GET the base. The cache is
2027        // pre-populated with the offsets so no .idx round-trip is
2028        // needed.
2029        store.insert(pack_key(None, &content_sha), Bytes::from(pack.clone()));
2030        install_cached_index(&cache, "", &content_sha, offsets.clone());
2031
2032        // Enter at exactly MAX_DELTA_DEPTH so the OFS_DELTA pass bumps
2033        // the budget to MAX+1 and the recursive base decode trips the
2034        // guard.
2035        let mut depth = MAX_DELTA_DEPTH;
2036        let err = decode_entry(
2037            &store,
2038            None,
2039            &chain,
2040            &content_sha,
2041            offsets[1],
2042            &pack[usize::try_from(offsets[1]).unwrap()..],
2043            &cache,
2044            &mut depth,
2045        )
2046        .await
2047        .expect_err("OFS_DELTA recursion must trip the depth guard");
2048        assert!(
2049            matches!(err, PackchainError::DeltaTooDeep { max } if max == MAX_DELTA_DEPTH),
2050            "expected DeltaTooDeep from OFS_DELTA recursion, got {err:?}",
2051        );
2052    }
2053
2054    // --- terminal-entry size cap (issue #115) ------------------------------
2055    //
2056    // `fetch_entry_bytes` previously fell back to `get_bytes(&pack)` for
2057    // the last entry in a pack — unbounded by `MAX_RANGE_BYTES`. The fix
2058    // routes the terminal entry through a `HEAD` + ranged GET, enforcing
2059    // the same cap as non-terminal entries.
2060
2061    /// Delegates everything to an inner `MockStore` except `head`, which
2062    /// returns a synthetic `size` chosen by the test. Lets us exercise
2063    /// the `> MAX_RANGE_BYTES` branch without actually allocating a
2064    /// multi-GiB body.
2065    struct FakeSizeStore {
2066        inner: MockStore,
2067        fake_size: u64,
2068    }
2069
2070    #[async_trait::async_trait]
2071    impl ObjectStore for FakeSizeStore {
2072        async fn list(
2073            &self,
2074            prefix: &str,
2075        ) -> Result<Vec<crate::object_store::ObjectMeta>, ObjectStoreError> {
2076            self.inner.list(prefix).await
2077        }
2078        async fn get_to_file(
2079            &self,
2080            key: &str,
2081            dest: &std::path::Path,
2082            opts: crate::object_store::GetOpts,
2083        ) -> Result<(), ObjectStoreError> {
2084            self.inner.get_to_file(key, dest, opts).await
2085        }
2086        async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
2087            self.inner.get_bytes(key).await
2088        }
2089        async fn get_bytes_range(
2090            &self,
2091            key: &str,
2092            range: std::ops::Range<u64>,
2093        ) -> Result<Bytes, ObjectStoreError> {
2094            self.inner.get_bytes_range(key, range).await
2095        }
2096        async fn put_bytes(
2097            &self,
2098            key: &str,
2099            body: Bytes,
2100            opts: crate::object_store::PutOpts,
2101        ) -> Result<(), ObjectStoreError> {
2102            self.inner.put_bytes(key, body, opts).await
2103        }
2104        async fn put_if_absent(&self, key: &str, body: Bytes) -> Result<bool, ObjectStoreError> {
2105            self.inner.put_if_absent(key, body).await
2106        }
2107        async fn head(
2108            &self,
2109            key: &str,
2110        ) -> Result<crate::object_store::ObjectMeta, ObjectStoreError> {
2111            // Forward NotFound from the inner store, but report
2112            // `fake_size` on success regardless of the real body length.
2113            let meta = self.inner.head(key).await?;
2114            Ok(crate::object_store::ObjectMeta {
2115                size: self.fake_size,
2116                ..meta
2117            })
2118        }
2119        async fn copy(&self, src: &str, dst: &str) -> Result<(), ObjectStoreError> {
2120            self.inner.copy(src, dst).await
2121        }
2122        async fn delete(&self, key: &str) -> Result<(), ObjectStoreError> {
2123            self.inner.delete(key).await
2124        }
2125    }
2126
2127    /// Happy path: the last entry's span fits under the cap, so
2128    /// `fetch_entry_bytes` issues a single bounded ranged GET and
2129    /// returns the tail of the pack starting at `pack_offset`.
2130    #[tokio::test]
2131    async fn fetch_entry_bytes_terminal_entry_under_cap_succeeds() {
2132        let store = MockStore::new();
2133        let cache = PackIndexCache::default();
2134        let content_sha = sha40(SHA_A);
2135
2136        // Plant a tiny pack body. The entry shape doesn't matter — we
2137        // only assert on the bytes `fetch_entry_bytes` returns.
2138        let body: &[u8] = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09";
2139        store.insert(pack_key(None, &content_sha), Bytes::from(body.to_vec()));
2140        // Pretend the only entry starts at offset 2 — sorted_offsets
2141        // contains it and nothing greater, so `next_offset` is `None`
2142        // and the terminal-entry branch fires.
2143        install_cached_index(&cache, "", &content_sha, vec![2]);
2144        let idx = cache
2145            .get(&(String::new(), content_sha.clone()))
2146            .expect("cache hit");
2147
2148        let got = fetch_entry_bytes(&store, None, &content_sha, 2, &idx)
2149            .await
2150            .expect("terminal entry under cap must succeed");
2151        assert_eq!(got.as_ref(), &body[2..]);
2152    }
2153
2154    /// Security regression for issue #115: when the implied terminal
2155    /// range exceeds `MAX_RANGE_BYTES`, the fetcher must fail with a
2156    /// typed error rather than allocate a multi-GiB buffer.
2157    #[tokio::test]
2158    async fn fetch_entry_bytes_terminal_entry_over_cap_rejected() {
2159        let inner = MockStore::new();
2160        let cache = PackIndexCache::default();
2161        let content_sha = sha40(SHA_A);
2162
2163        // The wrapper's `head` will report `MAX_RANGE_BYTES + 1`, so the
2164        // implied span `(end - pack_offset)` with `pack_offset = 0`
2165        // exceeds the cap. The body is a stub — `get_bytes_range` must
2166        // never be called.
2167        inner.insert(pack_key(None, &content_sha), Bytes::from_static(b"stub"));
2168        install_cached_index(&cache, "", &content_sha, vec![0]);
2169        let idx = cache
2170            .get(&(String::new(), content_sha.clone()))
2171            .expect("cache hit");
2172
2173        let store = FakeSizeStore {
2174            inner,
2175            fake_size: MAX_RANGE_BYTES + 1,
2176        };
2177
2178        let err = fetch_entry_bytes(&store, None, &content_sha, 0, &idx)
2179            .await
2180            .expect_err("terminal entry above cap must be rejected");
2181        assert!(
2182            matches!(
2183                err,
2184                PackchainError::MalformedPackEntry { offset: 0, ref reason }
2185                    if reason.contains("exceeds") && reason.contains("cap")
2186            ),
2187            "expected MalformedPackEntry size-cap error, got {err:?}",
2188        );
2189    }
2190
2191    /// `pack_offset >= pack_len` on the terminal branch is an
2192    /// out-of-bounds index. The fetcher must report it as
2193    /// `MalformedPackEntry` rather than issue a zero-length range GET.
2194    #[tokio::test]
2195    async fn fetch_entry_bytes_terminal_entry_offset_past_eof_rejected() {
2196        let store = MockStore::new();
2197        let cache = PackIndexCache::default();
2198        let content_sha = sha40(SHA_A);
2199
2200        store.insert(pack_key(None, &content_sha), Bytes::from_static(b"abc"));
2201        // `sorted_offsets` contains an offset at/past the body length,
2202        // so `next_offset` is `None` and the terminal branch fires.
2203        install_cached_index(&cache, "", &content_sha, vec![100]);
2204        let idx = cache
2205            .get(&(String::new(), content_sha.clone()))
2206            .expect("cache hit");
2207
2208        let err = fetch_entry_bytes(&store, None, &content_sha, 100, &idx)
2209            .await
2210            .expect_err("offset beyond EOF must be rejected");
2211        assert!(
2212            matches!(
2213                err,
2214                PackchainError::MalformedPackEntry { offset: 100, ref reason }
2215                    if reason.contains("beyond pack EOF")
2216            ),
2217            "expected MalformedPackEntry EOF error, got {err:?}",
2218        );
2219    }
2220
2221    /// Below the cap, the same `OFS_DELTA` shape decodes cleanly. This
2222    /// pins the positive case so the boundary test above is meaningful
2223    /// (without it, a regression that always returned `DeltaTooDeep`
2224    /// would still pass the over-cap assertion).
2225    #[tokio::test]
2226    async fn ofs_delta_below_cap_decodes() {
2227        let store = MockStore::new();
2228        let cache = PackIndexCache::default();
2229        let chain: Vec<ChainSegment> = Vec::new();
2230        let content_sha = sha40(SHA_A);
2231
2232        let base_payload = b"base";
2233        let mut pack = Vec::new();
2234        let mut offsets = Vec::new();
2235        push_pack_entry(&mut pack, &mut offsets, 3, None, base_payload);
2236        let delta = make_insert_delta(base_payload.len() as u64, b"hi");
2237        let entry1_start = pack.len() as u64;
2238        let distance = entry1_start - offsets[0];
2239        push_pack_entry(&mut pack, &mut offsets, 6, Some(distance), &delta);
2240
2241        store.insert(pack_key(None, &content_sha), Bytes::from(pack.clone()));
2242        install_cached_index(&cache, "", &content_sha, offsets.clone());
2243
2244        let mut depth = 0u32;
2245        let resolved = decode_entry(
2246            &store,
2247            None,
2248            &chain,
2249            &content_sha,
2250            offsets[1],
2251            &pack[usize::try_from(offsets[1]).unwrap()..],
2252            &cache,
2253            &mut depth,
2254        )
2255        .await
2256        .expect("OFS_DELTA decodes below cap");
2257        assert_eq!(resolved.payload, b"hi");
2258        assert_eq!(resolved.kind, ObjectKind::Blob);
2259    }
2260
2261    // --- concurrent-GC retry loop (issue #136) -----------------------------
2262    //
2263    // `read_blob` must transparently retry `PackMissing` failures that
2264    // were caused by a concurrent `manage gc sweep` deleting compacted-
2265    // away packs. The retry reloads `chain.json` and inspects whether
2266    // the missing key is still referenced (data loss → fail fast) or
2267    // gone (GC → retry).
2268
2269    use crate::packchain::keys::chain_key;
2270    use crate::packchain::schema::ChainManifest;
2271    use std::sync::atomic::{AtomicUsize, Ordering};
2272
2273    fn make_chain_with(tip_hex: &str, pack_sha_hex: &str) -> ChainManifest {
2274        ChainManifest {
2275            v: ChainManifest::SCHEMA_VERSION,
2276            tip: sha40(tip_hex),
2277            full_at: sha40(tip_hex),
2278            segments: vec![ChainSegment {
2279                sha: sha40(tip_hex),
2280                parent_sha: None,
2281                pack: format!("packs/{pack_sha_hex}.pack"),
2282                bytes: 1_024,
2283            }],
2284        }
2285    }
2286
2287    #[test]
2288    fn chain_references_pack_key_matches_pack_and_idx_keys() {
2289        let chain = make_chain_with(SHA_A, SHA_B);
2290        // Both `.pack` and `.idx` belonging to the same content-sha
2291        // are considered referenced.
2292        assert!(chain_references_pack_key(&chain, None, &format!("packs/{SHA_B}.pack")).unwrap());
2293        assert!(chain_references_pack_key(&chain, None, &format!("packs/{SHA_B}.idx")).unwrap());
2294    }
2295
2296    #[test]
2297    fn chain_references_pack_key_returns_false_for_unreferenced_pack() {
2298        let chain = make_chain_with(SHA_A, SHA_B);
2299        assert!(!chain_references_pack_key(&chain, None, &format!("packs/{SHA_C}.pack")).unwrap());
2300        assert!(!chain_references_pack_key(&chain, None, &format!("packs/{SHA_C}.idx")).unwrap());
2301    }
2302
2303    #[test]
2304    fn chain_references_pack_key_respects_prefix() {
2305        let chain = make_chain_with(SHA_A, SHA_B);
2306        // With a prefix, the membership check is against the
2307        // prefix-joined key — an un-prefixed key for the same
2308        // content-sha is *not* a match (the join differs).
2309        assert!(
2310            chain_references_pack_key(&chain, Some("repo"), &format!("repo/packs/{SHA_B}.pack"))
2311                .unwrap()
2312        );
2313        assert!(
2314            !chain_references_pack_key(&chain, Some("repo"), &format!("packs/{SHA_B}.pack"))
2315                .unwrap()
2316        );
2317    }
2318
2319    #[test]
2320    fn chain_references_pack_key_returns_false_for_malformed_missing_key() {
2321        // Commit 8fbe693's refactor short-circuits on a missing_key
2322        // that fails to parse as `[<prefix>/]packs/<sha>.{pack,idx}` —
2323        // the function returns Ok(false) without iterating segments.
2324        // This pins that behavior so a future change that re-routes
2325        // malformed keys through the per-segment path (or that
2326        // surfaces a parse error to the caller) fails this test.
2327        let chain = make_chain_with(SHA_A, SHA_B);
2328        // No `packs/` segment.
2329        assert!(!chain_references_pack_key(&chain, None, "weird/key").unwrap());
2330        // `packs/` present but non-hex stem — fails Sha40 validation.
2331        assert!(!chain_references_pack_key(&chain, None, "packs/not-a-sha.pack").unwrap());
2332        // Wrong extension.
2333        assert!(!chain_references_pack_key(&chain, None, &format!("packs/{SHA_B}.bin")).unwrap());
2334        // Empty key.
2335        assert!(!chain_references_pack_key(&chain, None, "").unwrap());
2336    }
2337
2338    /// `ObjectStore` wrapper that serves a list of `chain.json` bodies
2339    /// in order: call 0 returns `bodies[0]`, call 1 returns `bodies[1]`,
2340    /// and so on. The last entry is repeated for any further calls.
2341    /// All other keys go through to the inner [`MockStore`].
2342    ///
2343    /// Lets a test simulate compact+sweep happening *between* the
2344    /// reader's chain reloads: the reader observes a new chain (a
2345    /// different segment set) on each retry without the test needing
2346    /// to race a real concurrent task.
2347    struct EvolvingChainStore {
2348        inner: MockStore,
2349        chain_key: String,
2350        bodies: Vec<Bytes>,
2351        calls: AtomicUsize,
2352        /// Counts `get_bytes` calls whose key ends in
2353        /// `/path-index.json`. Used by the issue-#136 contract test
2354        /// (`read_with_pack_missing_retries_does_not_reload_path_index`)
2355        /// to prove the retry path never re-reads path-index — only
2356        /// `chain.json` is reloaded.
2357        path_index_calls: AtomicUsize,
2358    }
2359
2360    impl EvolvingChainStore {
2361        fn new(inner: MockStore, chain_key: String, bodies: Vec<Bytes>) -> Self {
2362            assert!(!bodies.is_empty(), "must supply at least one chain body");
2363            Self {
2364                inner,
2365                chain_key,
2366                bodies,
2367                calls: AtomicUsize::new(0),
2368                path_index_calls: AtomicUsize::new(0),
2369            }
2370        }
2371
2372        fn chain_calls(&self) -> usize {
2373            self.calls.load(Ordering::SeqCst)
2374        }
2375
2376        fn path_index_calls(&self) -> usize {
2377            self.path_index_calls.load(Ordering::SeqCst)
2378        }
2379    }
2380
2381    // Note: `put_path` is intentionally omitted from the forward list
2382    // to preserve the original behavior (trait default → `Self::put_bytes`
2383    // → `inner.put_bytes`), which bypasses `MockStore::put_path`. The
2384    // test never invokes `put_path` on this decorator, so the bypass is
2385    // a no-op in practice but kept for byte-for-byte parity.
2386    crate::delegate_to_inner_impl! {
2387        impl ObjectStore for EvolvingChainStore {
2388            forward: list, get_to_file, get_bytes_range,
2389                     put_bytes, put_if_absent,
2390                     head, copy, delete;
2391
2392            async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
2393                if key == self.chain_key {
2394                    let idx = self.calls.fetch_add(1, Ordering::SeqCst);
2395                    let pick = idx.min(self.bodies.len() - 1);
2396                    return Ok(self.bodies[pick].clone());
2397                }
2398                if key.ends_with("/path-index.json") {
2399                    self.path_index_calls.fetch_add(1, Ordering::SeqCst);
2400                }
2401                self.inner.get_bytes(key).await
2402            }
2403        }
2404    }
2405
2406    /// Build a real v2 `.idx` carrying a single object: `target_sha` at
2407    /// pack offset `pack_offset`. `gix_pack`'s parser validates the
2408    /// fanout and table sizes, so this must match the format exactly.
2409    fn build_one_object_v2_idx(target_sha: &Sha40, pack_offset: u32) -> Vec<u8> {
2410        // Decode the 40-hex sha into 20 raw bytes. `gix_hash::ObjectId`
2411        // already does this for us — re-use it rather than hand-rolling
2412        // a hex parser.
2413        let oid = sha40_to_object_id(target_sha);
2414        let sha_bytes = oid.as_bytes();
2415        let first_byte = sha_bytes[0];
2416
2417        let mut data = Vec::with_capacity(8 + 256 * 4 + 20 + 4 + 4 + 20 + 20);
2418        // V2 magic + version.
2419        data.extend_from_slice(b"\xfftOc");
2420        data.extend_from_slice(&2u32.to_be_bytes());
2421        // Fanout: cumulative count of objects with sha[0] <= i. With a
2422        // single object whose first byte is `first_byte`, every entry
2423        // from `first_byte` onwards is 1. `u8::try_from(i)` cannot fail
2424        // for `i in 0u16..256`.
2425        for i in 0u16..256 {
2426            let count = u32::from(u8::try_from(i).expect("0..256 fits in u8") >= first_byte);
2427            data.extend_from_slice(&count.to_be_bytes());
2428        }
2429        // Names table (20 bytes per sha).
2430        data.extend_from_slice(sha_bytes);
2431        // CRC32 table (one u32; value is unused by `lookup`).
2432        data.extend_from_slice(&0u32.to_be_bytes());
2433        // Offset table (one u32; MSB clear → 32-bit absolute offset).
2434        data.extend_from_slice(&pack_offset.to_be_bytes());
2435        // Pack trailer (20-byte sha placeholder) + idx trailer
2436        // (20-byte sha placeholder). The parser doesn't validate the
2437        // content of either against the body for `from_data`.
2438        data.extend_from_slice(&[0u8; 20]);
2439        data.extend_from_slice(&[0u8; 20]);
2440        data
2441    }
2442
2443    /// Compute the real git blob OID for `payload` — the SHA-1 of
2444    /// `blob <len>\0` + payload. Tests that drive the full
2445    /// `read_object_from_chain` path must register this exact OID in the
2446    /// `.idx` (rather than an arbitrary sha) so the read-time
2447    /// content-hash check (issue #247) accepts the decoded bytes.
2448    fn blob_oid_for(payload: &[u8]) -> Sha40 {
2449        let oid = gix::objs::compute_hash(gix_hash::Kind::Sha1, gix::objs::Kind::Blob, payload)
2450            .expect("blob hash");
2451        Sha40::from_oid(&oid).expect("oid is 40-hex by construction")
2452    }
2453
2454    /// Convenience: turn the `tip_hex` and `pack_sha_hex` strings into
2455    /// a serialised chain.json `Bytes` body the wrapper can return.
2456    fn chain_json_bytes(tip_hex: &str, pack_sha_hex: &str) -> Bytes {
2457        let json = make_chain_with(tip_hex, pack_sha_hex)
2458            .to_json_pretty()
2459            .expect("chain serialise");
2460        Bytes::from(json)
2461    }
2462
2463    /// GC-race retry succeeds: the initial chain points at pack P1
2464    /// (absent from the store — simulating "already deleted by a
2465    /// concurrent sweep"); the reloaded chain points at pack P2 which
2466    /// is fully present (real one-object `.idx` + zlib-compressed
2467    /// blob entry). The retry must find the blob via P2 and return
2468    /// the payload.
2469    #[tokio::test]
2470    async fn read_with_pack_missing_retries_succeeds_after_chain_reload() {
2471        let inner = MockStore::new();
2472        let cache = PackIndexCache::default();
2473
2474        let p1_sha = sha40(SHA_A);
2475        let p2_sha = sha40(SHA_B);
2476        let blob_payload = b"recovered blob";
2477        // The blob's real git OID. It must be the genuine
2478        // `blob <len>\0`+payload SHA-1 so the read-time content-hash
2479        // check (issue #247) accepts the decoded entry; the same sha is
2480        // registered in the .idx names table and used as `target_oid`
2481        // so `gix_pack::index::File::lookup` returns the single object.
2482        let blob_oid_sha = blob_oid_for(blob_payload);
2483        let blob_oid = sha40_to_object_id(&blob_oid_sha);
2484
2485        // Build a P2 pack containing one Blob entry at offset 0.
2486        let mut pack = Vec::new();
2487        let mut offsets = Vec::new();
2488        push_pack_entry(
2489            &mut pack,
2490            &mut offsets,
2491            3, /* BLOB */
2492            None,
2493            blob_payload,
2494        );
2495        inner.insert(pack_key(None, &p2_sha), Bytes::from(pack.clone()));
2496
2497        // Build the corresponding one-object v2 .idx for P2 and plant
2498        // it under the canonical idx key. `read_object_from_chain`
2499        // will load it via `load_index`.
2500        let idx_bytes = build_one_object_v2_idx(&blob_oid_sha, 0);
2501        inner.insert(pack_idx_key(None, &p2_sha), Bytes::from(idx_bytes));
2502
2503        // The reader enters the helper carrying chain v1 (refs P1,
2504        // absent from the store). When it reloads `chain.json` after
2505        // the first PackMissing, the wrapper serves v2 (refs P2 with
2506        // a working idx + pack), simulating a compact+sweep that
2507        // happened between the reader's two loads.
2508        let chain_key = chain_key(None, "refs/heads/main");
2509        let v1 = chain_json_bytes(SHA_A, p1_sha.as_str());
2510        let v2 = chain_json_bytes(SHA_A, p2_sha.as_str());
2511        let store = EvolvingChainStore::new(inner, chain_key, vec![v2]);
2512
2513        let initial = ChainManifest::from_json_bytes(&v1).expect("chain v1 parses");
2514        let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2515
2516        let resolved = read_with_pack_missing_retries(
2517            &store,
2518            None,
2519            &remote_ref,
2520            "refs/heads/main",
2521            initial,
2522            &blob_oid,
2523            &cache,
2524        )
2525        .await
2526        .expect("retry must succeed after chain reload");
2527        assert_eq!(resolved.payload, blob_payload);
2528        assert_eq!(resolved.kind, ObjectKind::Blob);
2529        // Exactly one chain reload was needed — the first read against
2530        // the initial in-memory chain, then one reload that swapped to
2531        // v2 referencing P2.
2532        assert_eq!(
2533            store.chain_calls(),
2534            1,
2535            "exactly one chain reload should have fired"
2536        );
2537        // Issue #136 contract: the retry path only reloads `chain.json`.
2538        // Path-index is loaded once by `read_blob` before this helper is
2539        // entered (the original `blob_oid` represents the snapshot the
2540        // caller asked about), and compaction preserves blob content
2541        // addressing — so reloading path-index on retry would silently
2542        // re-resolve the path against a newer tip's tree, which is the
2543        // wrong semantics for a stateless point-in-time read.
2544        // `read_with_pack_missing_retries` itself never touches
2545        // path-index; pin that with a counter assertion.
2546        assert_eq!(
2547            store.path_index_calls(),
2548            0,
2549            "retry path must not reload path-index.json",
2550        );
2551    }
2552
2553    /// Companion to `succeeds_after_chain_reload`: pins the
2554    /// "path-index is NOT reloaded across retries" half of the
2555    /// `read_with_pack_missing_retries` contract documented at issue
2556    /// #136. A regression that re-resolved the blob OID via a fresh
2557    /// path-index load between retries would resolve to a different
2558    /// blob SHA on a force-pushed tree and surface as a stale-read
2559    /// bug. We arm a single pack-missing retry, count `get_bytes`
2560    /// calls against `path-index.json` through the `EvolvingChainStore`
2561    /// counter, and assert the count stays at zero.
2562    #[tokio::test]
2563    async fn read_with_pack_missing_retries_does_not_reload_path_index() {
2564        let inner = MockStore::new();
2565        let cache = PackIndexCache::default();
2566
2567        let p1_sha = sha40(SHA_A);
2568        let p2_sha = sha40(SHA_B);
2569        let blob_payload = b"recovered blob";
2570        // Real blob OID so the content-hash check (issue #247) accepts
2571        // the decoded entry on the retry path.
2572        let blob_oid_sha = blob_oid_for(blob_payload);
2573        let blob_oid = sha40_to_object_id(&blob_oid_sha);
2574
2575        // Build the P2 pack + idx so the retry's read finds the blob.
2576        let mut pack = Vec::new();
2577        let mut offsets = Vec::new();
2578        push_pack_entry(
2579            &mut pack,
2580            &mut offsets,
2581            3, /* BLOB */
2582            None,
2583            blob_payload,
2584        );
2585        inner.insert(pack_key(None, &p2_sha), Bytes::from(pack));
2586        let idx_bytes = build_one_object_v2_idx(&blob_oid_sha, 0);
2587        inner.insert(pack_idx_key(None, &p2_sha), Bytes::from(idx_bytes));
2588
2589        // Pre-write a sentinel `path-index.json` body so any spurious
2590        // load would consume it (and bump the counter) rather than
2591        // silently 404 and slip past the assertion.
2592        inner.insert("refs/heads/main/path-index.json", Bytes::from_static(b"{}"));
2593
2594        // Initial chain refs P1 (absent), reloaded chain refs P2 (present).
2595        let chain_key = chain_key(None, "refs/heads/main");
2596        let v1 = chain_json_bytes(SHA_A, p1_sha.as_str());
2597        let v2 = chain_json_bytes(SHA_A, p2_sha.as_str());
2598        let store = EvolvingChainStore::new(inner, chain_key, vec![v2]);
2599
2600        let initial = ChainManifest::from_json_bytes(&v1).expect("chain v1 parses");
2601        let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2602
2603        let resolved = read_with_pack_missing_retries(
2604            &store,
2605            None,
2606            &remote_ref,
2607            "refs/heads/main",
2608            initial,
2609            &blob_oid,
2610            &cache,
2611        )
2612        .await
2613        .expect("retry must succeed");
2614        assert_eq!(resolved.payload, blob_payload);
2615        // The retry fired (chain reload count == 1).
2616        assert_eq!(store.chain_calls(), 1);
2617        // Critical contract: path-index.json is never re-loaded by the
2618        // retry path. `read_with_pack_missing_retries` operates on the
2619        // already-resolved `blob_oid`; reloading path-index would
2620        // re-resolve the path against a possibly-newer tree.
2621        assert_eq!(
2622            store.path_index_calls(),
2623            0,
2624            "retry path read path-index.json {} times; must be zero",
2625            store.path_index_calls(),
2626        );
2627    }
2628
2629    /// `PackMissing` where the reload still references the same pack
2630    /// means genuine data loss — the bucket is missing a pack
2631    /// `chain.json` still names. The reader must fail fast with
2632    /// `PackMissing` rather than retry forever or upgrade to the
2633    /// concurrent-GC retry-exhausted variant.
2634    #[tokio::test]
2635    async fn read_with_pack_missing_retries_fails_fast_when_chain_still_references_missing_pack() {
2636        let inner = MockStore::new();
2637        let cache = PackIndexCache::default();
2638
2639        let p1_sha = sha40(SHA_A);
2640        let blob_oid = sha40_to_object_id(&sha40(SHA_C));
2641
2642        // Initial and reloaded chain both reference the same missing
2643        // pack — the "data loss" scenario, distinct from GC.
2644        let chain_key = chain_key(None, "refs/heads/main");
2645        let body = chain_json_bytes(SHA_A, p1_sha.as_str());
2646        let store = EvolvingChainStore::new(inner, chain_key, vec![body.clone()]);
2647        let initial = ChainManifest::from_json_bytes(&body).expect("chain parses");
2648        let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2649
2650        let err = read_with_pack_missing_retries(
2651            &store,
2652            None,
2653            &remote_ref,
2654            "refs/heads/main",
2655            initial,
2656            &blob_oid,
2657            &cache,
2658        )
2659        .await
2660        .expect_err("missing pack still in chain must fail fast");
2661        match err {
2662            PackchainError::PackMissing { key } => {
2663                assert!(
2664                    key.contains(&format!("packs/{SHA_A}")),
2665                    "PackMissing key should name the missing pack, got {key}",
2666                );
2667            }
2668            other => panic!("expected fail-fast PackMissing, got {other:?}"),
2669        }
2670        // Exactly one chain reload was issued (to verify the missing
2671        // key was still referenced) — no further reloads or sleeps.
2672        assert_eq!(store.chain_calls(), 1);
2673    }
2674
2675    /// Retries are exhausted when each reload shows a *different*
2676    /// missing pack — i.e. compact+sweep keeps outpacing the reader.
2677    /// After `PACK_MISSING_MAX_RETRIES` retries the call surfaces
2678    /// `ConcurrentGcRetriesExhausted` with the last observed key.
2679    #[tokio::test(start_paused = true)]
2680    async fn read_with_pack_missing_retries_surfaces_exhausted_after_max_retries() {
2681        // `start_paused` lets the tokio runtime auto-advance time
2682        // through the backoff sleeps so the test doesn't pay the
2683        // wall-clock 2.6 s worst case.
2684        let inner = MockStore::new();
2685        let cache = PackIndexCache::default();
2686
2687        let blob_oid = sha40_to_object_id(&sha40(SHA_C));
2688
2689        // Five distinct chain versions, each referencing a different
2690        // missing pack. The initial in-memory chain is v1; the
2691        // wrapper serves v2..v5 on successive reloads. The reader's
2692        // walk is:
2693        //   - attempt 0 with v1 → PackMissing(P0); reload → v2 (refs P1)
2694        //   - attempt 1 with v2 → PackMissing(P1); reload → v3 (refs P2)
2695        //   - attempt 2 with v3 → PackMissing(P2); reload → v4 (refs P3)
2696        //   - attempt 3 with v4 → PackMissing(P3); reload → v5 (refs P4)
2697        //     → attempt >= MAX → exhausted with key for P3.
2698        let pack_shas = [
2699            "0000000000000000000000000000000000000000",
2700            "1111111111111111111111111111111111111111",
2701            "2222222222222222222222222222222222222222",
2702            "3333333333333333333333333333333333333333",
2703            "4444444444444444444444444444444444444444",
2704        ];
2705        let chain_key = chain_key(None, "refs/heads/main");
2706        let v1 = chain_json_bytes(SHA_A, pack_shas[0]);
2707        let reload_bodies: Vec<Bytes> = pack_shas[1..]
2708            .iter()
2709            .map(|sha| chain_json_bytes(SHA_A, sha))
2710            .collect();
2711        let initial = ChainManifest::from_json_bytes(&v1).expect("chain v1 parses");
2712        let store = EvolvingChainStore::new(inner, chain_key, reload_bodies);
2713        let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2714
2715        let err = read_with_pack_missing_retries(
2716            &store,
2717            None,
2718            &remote_ref,
2719            "refs/heads/main",
2720            initial,
2721            &blob_oid,
2722            &cache,
2723        )
2724        .await
2725        .expect_err("exhausted retries must error");
2726        match err {
2727            PackchainError::ConcurrentGcRetriesExhausted {
2728                last_missing_key,
2729                attempts,
2730            } => {
2731                // The last attempt was against v4 referencing P3
2732                // (the fourth pack in our table). `attempts` records
2733                // retries beyond the initial attempt: 3.
2734                assert_eq!(attempts, PACK_MISSING_MAX_RETRIES);
2735                assert!(
2736                    last_missing_key.contains(pack_shas[3]),
2737                    "last missing key should name pack[3], got {last_missing_key}"
2738                );
2739            }
2740            other => panic!("expected ConcurrentGcRetriesExhausted, got {other:?}"),
2741        }
2742        // Exactly MAX_RETRIES + 1 reloads: one verification per
2743        // attempted read. Each reload showed the missing pack absent
2744        // from the freshly-loaded chain so the loop kept retrying
2745        // (or, on the final reload, recorded "exhausted").
2746        assert_eq!(
2747            store.chain_calls(),
2748            usize::try_from(PACK_MISSING_MAX_RETRIES + 1).unwrap()
2749        );
2750    }
2751
2752    /// Non-PackMissing errors are not retried — they pass through
2753    /// directly. Otherwise a transport error or a malformed pack would
2754    /// wait through the backoff schedule for no reason.
2755    #[tokio::test]
2756    async fn read_with_pack_missing_retries_does_not_retry_on_non_pack_missing_errors() {
2757        let inner = MockStore::new();
2758        let cache = PackIndexCache::default();
2759
2760        // Plant a malformed `.idx` for the chain's pack. `load_index`
2761        // will parse-fail with `MalformedPackEntry`, *not*
2762        // `PackMissing`, so the retry path must not fire.
2763        let p1_sha = sha40(SHA_A);
2764        inner.insert(
2765            pack_idx_key(None, &p1_sha),
2766            Bytes::from_static(b"not a real idx"),
2767        );
2768
2769        let chain_key = chain_key(None, "refs/heads/main");
2770        let body = chain_json_bytes(SHA_A, p1_sha.as_str());
2771        let store = EvolvingChainStore::new(inner, chain_key, vec![body.clone()]);
2772        let initial = ChainManifest::from_json_bytes(&body).expect("chain parses");
2773        let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2774        let blob_oid = sha40_to_object_id(&sha40(SHA_C));
2775
2776        let err = read_with_pack_missing_retries(
2777            &store,
2778            None,
2779            &remote_ref,
2780            "refs/heads/main",
2781            initial,
2782            &blob_oid,
2783            &cache,
2784        )
2785        .await
2786        .expect_err("malformed idx must surface immediately");
2787        assert!(
2788            matches!(err, PackchainError::MalformedPackEntry { .. }),
2789            "expected MalformedPackEntry passthrough, got {err:?}"
2790        );
2791        // No chain reloads — the error path did not enter the retry
2792        // branch at all.
2793        assert_eq!(store.chain_calls(), 0);
2794    }
2795
2796    /// Regression for #136: a chain reload that itself errors (transport
2797    /// failure on `chain.json`) must surface as `PackchainError::Store`
2798    /// — NOT be converted into `ConcurrentGcRetriesExhausted` and NOT
2799    /// swallowed back into the original `PackMissing`. The retry loop
2800    /// uses `?` on `load_chain`, so a network fault during reload
2801    /// short-circuits to the wrapped store error.
2802    ///
2803    /// Setup: initial in-memory chain refs P1; the store has no pack
2804    /// for P1 (so the first read fails with `PackMissing`), and a
2805    /// one-shot `NetworkOnGetBytes` fault armed on the chain key
2806    /// fires when the retry loop calls `load_chain`.
2807    #[tokio::test]
2808    async fn read_with_pack_missing_retries_surfaces_chain_reload_error() {
2809        use crate::object_store::mock::Fault;
2810
2811        let store = MockStore::new();
2812        let cache = PackIndexCache::default();
2813
2814        let p1_sha = sha40(SHA_A);
2815        let blob_oid = sha40_to_object_id(&sha40(SHA_C));
2816
2817        // Initial chain refs P1, which is absent from the store. The
2818        // first pack-read will surface PackMissing, sending the loop
2819        // into its reload branch.
2820        let chain_key_str = chain_key(None, "refs/heads/main");
2821        let body = chain_json_bytes(SHA_A, p1_sha.as_str());
2822        let initial = ChainManifest::from_json_bytes(&body).expect("chain v1 parses");
2823        let remote_ref = RefName::new("refs/heads/main").expect("ref name valid");
2824
2825        // Arm a network fault on the chain key. `load_chain` calls
2826        // `store.get_bytes(chain_key)`; the fault fires there and the
2827        // wrapped error must propagate out of the retry helper.
2828        store.arm(Fault::NetworkOnGetBytes { key: chain_key_str });
2829
2830        let err = read_with_pack_missing_retries(
2831            &store,
2832            None,
2833            &remote_ref,
2834            "refs/heads/main",
2835            initial,
2836            &blob_oid,
2837            &cache,
2838        )
2839        .await
2840        .expect_err("chain reload failure must surface as an error");
2841
2842        assert!(
2843            matches!(err, PackchainError::Store(_)),
2844            "expected PackchainError::Store wrapping the chain-reload transport error; \
2845             a regression that swallowed the reload error would yield \
2846             ConcurrentGcRetriesExhausted or the original PackMissing instead. got {err:?}"
2847        );
2848        // Fault was consumed exactly once by the single reload attempt.
2849        assert_eq!(
2850            store.pending_faults(),
2851            0,
2852            "armed chain-reload fault must have fired exactly once"
2853        );
2854    }
2855
2856    // --- read-time content-hash verification (issue #247) ------------------
2857    //
2858    // `read_object_from_chain` re-hashes the reconstituted object and
2859    // refuses to return bytes whose git OID disagrees with the OID the
2860    // caller resolved via the `.idx`. These tests pin both the unit-level
2861    // `verify_content_hash` boundary and the full-path behaviour when a
2862    // tampered `.idx` maps an OID to mismatched content.
2863
2864    /// `verify_content_hash` accepts a payload whose real git blob OID
2865    /// matches `target_oid`. Without this positive case a regression that
2866    /// always returned `ContentHashMismatch` would still pass the
2867    /// negative tests below.
2868    #[test]
2869    fn verify_content_hash_accepts_matching_blob() {
2870        let payload = b"the quick brown fox";
2871        let oid = sha40_to_object_id(&blob_oid_for(payload));
2872        let resolved = ResolvedObject {
2873            payload: payload.to_vec(),
2874            kind: ObjectKind::Blob,
2875        };
2876        verify_content_hash(&oid, &resolved).expect("matching content must verify");
2877    }
2878
2879    /// Empty-blob boundary: git's empty blob hashes
2880    /// `blob 0\0` (the well-known `e69de29…` OID). The framing must
2881    /// include the zero-length header, so an empty payload still
2882    /// verifies against the correct OID.
2883    #[test]
2884    fn verify_content_hash_accepts_empty_blob() {
2885        let payload = b"";
2886        let oid = sha40_to_object_id(&blob_oid_for(payload));
2887        assert_eq!(
2888            oid.to_string(),
2889            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
2890            "empty blob must hash to git's canonical empty-blob OID",
2891        );
2892        let resolved = ResolvedObject {
2893            payload: payload.to_vec(),
2894            kind: ObjectKind::Blob,
2895        };
2896        verify_content_hash(&oid, &resolved).expect("empty blob must verify");
2897    }
2898
2899    /// `verify_content_hash` rejects a payload whose real OID differs
2900    /// from `target_oid`, surfacing `ContentHashMismatch` with both the
2901    /// expected and actual OIDs.
2902    #[test]
2903    fn verify_content_hash_rejects_mismatched_content() {
2904        // `target_oid` names a different blob than `resolved` carries.
2905        let expected_oid = sha40_to_object_id(&blob_oid_for(b"intended content"));
2906        let resolved = ResolvedObject {
2907            payload: b"tampered content".to_vec(),
2908            kind: ObjectKind::Blob,
2909        };
2910        let err = verify_content_hash(&expected_oid, &resolved)
2911            .expect_err("mismatched content must be rejected");
2912        let PackchainError::ContentHashMismatch { expected, actual } = err else {
2913            panic!("expected ContentHashMismatch, got {err:?}");
2914        };
2915        assert_eq!(expected, expected_oid.to_string());
2916        let actual_oid = sha40_to_object_id(&blob_oid_for(b"tampered content"));
2917        assert_eq!(actual, actual_oid.to_string());
2918        assert_ne!(expected, actual);
2919    }
2920
2921    /// Full-path security regression: a `.idx` maps `target_oid` to an
2922    /// offset whose entry decodes to *different* content. The build-time
2923    /// `.idx`/pack-trailer checks are bypassed (the bucket is tampered),
2924    /// so `read_object_from_chain` must catch the divergence at read time
2925    /// and surface `ContentHashMismatch` rather than return wrong bytes.
2926    #[tokio::test]
2927    async fn read_object_from_chain_rejects_idx_mapped_wrong_content() {
2928        let inner = MockStore::new();
2929        let cache = PackIndexCache::default();
2930        let pack_sha = sha40(SHA_A);
2931
2932        // The pack entry actually decodes to "actual stored bytes"…
2933        let actual_payload = b"actual stored bytes";
2934        let mut pack = Vec::new();
2935        let mut offsets = Vec::new();
2936        push_pack_entry(
2937            &mut pack,
2938            &mut offsets,
2939            3, /* BLOB */
2940            None,
2941            actual_payload,
2942        );
2943        inner.insert(pack_key(None, &pack_sha), Bytes::from(pack));
2944
2945        // …but the `.idx` claims that offset holds the blob whose OID is
2946        // the hash of "what the caller wanted" — a tampered/corrupt
2947        // mapping. The lookup will resolve `target_oid` to offset 0,
2948        // which decodes to `actual_payload`, whose hash differs.
2949        let lie_oid_sha = blob_oid_for(b"what the caller wanted");
2950        let idx_bytes = build_one_object_v2_idx(&lie_oid_sha, 0);
2951        inner.insert(pack_idx_key(None, &pack_sha), Bytes::from(idx_bytes));
2952
2953        let chain = make_chain_with(SHA_A, pack_sha.as_str());
2954        let target_oid = sha40_to_object_id(&lie_oid_sha);
2955        let mut depth = 0u32;
2956        let err = read_object_from_chain(
2957            &inner,
2958            None,
2959            &chain.segments,
2960            &target_oid,
2961            &cache,
2962            &mut depth,
2963        )
2964        .await
2965        .expect_err("idx-mapped wrong content must be rejected");
2966        let PackchainError::ContentHashMismatch { expected, actual } = err else {
2967            panic!("expected ContentHashMismatch, got {err:?}");
2968        };
2969        assert_eq!(
2970            expected,
2971            target_oid.to_string(),
2972            "expected OID must be the caller's requested OID",
2973        );
2974        let actual_oid = sha40_to_object_id(&blob_oid_for(actual_payload));
2975        assert_eq!(
2976            actual,
2977            actual_oid.to_string(),
2978            "actual OID must be the hash of the bytes really stored",
2979        );
2980    }
2981
2982    /// Companion positive case: when the `.idx` maps `target_oid` to an
2983    /// entry that genuinely decodes to that blob, `read_object_from_chain`
2984    /// returns the payload. This proves the negative test above fails
2985    /// specifically on the hash mismatch, not on some unrelated wiring.
2986    #[tokio::test]
2987    async fn read_object_from_chain_returns_matching_content() {
2988        let inner = MockStore::new();
2989        let cache = PackIndexCache::default();
2990        let pack_sha = sha40(SHA_A);
2991
2992        let payload = b"honest stored bytes";
2993        let mut pack = Vec::new();
2994        let mut offsets = Vec::new();
2995        push_pack_entry(&mut pack, &mut offsets, 3 /* BLOB */, None, payload);
2996        inner.insert(pack_key(None, &pack_sha), Bytes::from(pack));
2997
2998        let oid_sha = blob_oid_for(payload);
2999        let idx_bytes = build_one_object_v2_idx(&oid_sha, 0);
3000        inner.insert(pack_idx_key(None, &pack_sha), Bytes::from(idx_bytes));
3001
3002        let chain = make_chain_with(SHA_A, pack_sha.as_str());
3003        let target_oid = sha40_to_object_id(&oid_sha);
3004        let mut depth = 0u32;
3005        let resolved = read_object_from_chain(
3006            &inner,
3007            None,
3008            &chain.segments,
3009            &target_oid,
3010            &cache,
3011            &mut depth,
3012        )
3013        .await
3014        .expect("matching content must resolve");
3015        assert_eq!(resolved.payload, payload);
3016        assert_eq!(resolved.kind, ObjectKind::Blob);
3017    }
3018}