Skip to main content

grit_lib/
pack_index.rs

1//! Zero-copy in-memory representation of Git pack index (`.idx`) files.
2
3use crate::error::{Error, Result};
4use crate::hash::verify_trailer;
5use crate::objects::{HashAlgo, ObjectId};
6use crate::pack_map::PackData;
7use std::cmp::Ordering;
8use std::ops::Deref;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11
12type OffsetLookupCache = Arc<Mutex<Option<Vec<(u64, u32)>>>>;
13
14/// Minimum bytes for a v1 fanout table (256 × u32).
15const IDX_MIN_LEN: u64 = 256 * 4;
16
17/// Raw bytes backing a parsed pack index (memory-mapped or owned).
18#[derive(Debug, Clone)]
19struct PackIndexStorage(Arc<PackData>);
20
21impl Deref for PackIndexStorage {
22    type Target = [u8];
23
24    fn deref(&self) -> &[u8] {
25        self.0.deref()
26    }
27}
28
29/// Version-1 (legacy) or version-2 pack index on-disk layout.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum PackIndexVersion {
32    V1,
33    V2,
34}
35
36/// Byte offsets of tables inside [`PackIndexStorage`].
37#[derive(Debug, Clone)]
38struct PackIndexLayout {
39    version: PackIndexVersion,
40    hash_bytes: usize,
41    count: usize,
42    oid_base: usize,
43    crc_base: usize,
44    off32_base: usize,
45    large_off_base: usize,
46    large_off_count: usize,
47}
48
49/// Parsed pack index with fanout-accelerated OID lookup and in-place table reads.
50#[derive(Debug, Clone)]
51pub struct PackIndex {
52    /// Absolute path to the `.idx` file.
53    pub idx_path: PathBuf,
54    /// Absolute path to the `.pack` file.
55    pub pack_path: PathBuf,
56    storage: PackIndexStorage,
57    layout: PackIndexLayout,
58    /// 256-entry fanout: cumulative count of objects with first OID byte `<= b`.
59    pub fanout: [u32; 256],
60    /// Sibling `pack-*.promisor` marker was present when this index was prepared.
61    pub is_promisor: bool,
62    /// Sibling `pack-*.mtimes` marker was present when this index was prepared (cruft pack).
63    pub is_cruft: bool,
64    offset_lookup: OffsetLookupCache,
65}
66
67/// Borrowed view of one row in a pack index.
68#[derive(Debug, Clone, Copy)]
69pub struct PackIndexEntryRef<'a> {
70    index: &'a PackIndex,
71    position: usize,
72}
73
74impl<'a> PackIndexEntryRef<'a> {
75    /// Object id bytes (`20` for SHA-1, `32` for SHA-256).
76    #[must_use]
77    pub fn oid(&self) -> &'a [u8] {
78        self.index.oid_at(self.position)
79    }
80
81    /// Byte offset of the object in the corresponding `.pack`.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`Error::CorruptObject`] when the index row encodes an invalid pack offset.
86    pub fn offset(&self) -> Result<u64> {
87        self.index.offset_at(self.position)
88    }
89
90    /// CRC32 of packed entry bytes (v2 only).
91    #[must_use]
92    pub fn crc32(&self) -> Option<u32> {
93        self.index.crc32_at(self.position)
94    }
95}
96
97/// Owned pack index row (collect from [`PackIndex::iter`] when needed).
98#[derive(Debug, Clone)]
99pub struct PackIndexEntry {
100    /// Raw object identifier (`20` bytes for SHA-1, `32` for SHA-256).
101    pub oid: Vec<u8>,
102    /// Byte offset of the object in the corresponding `.pack`.
103    pub offset: u64,
104    /// CRC32 of the raw packed entry bytes (header + payload), from the v2 index CRC table.
105    pub crc32: Option<u32>,
106}
107
108impl TryFrom<PackIndexEntryRef<'_>> for PackIndexEntry {
109    type Error = Error;
110
111    fn try_from(r: PackIndexEntryRef<'_>) -> Result<Self> {
112        Ok(Self {
113            oid: r.oid().to_vec(),
114            offset: r.offset()?,
115            crc32: r.crc32(),
116        })
117    }
118}
119
120/// Iterator over index rows in OID sort order.
121pub struct PackIndexIter<'a> {
122    index: &'a PackIndex,
123    next: usize,
124}
125
126impl<'a> Iterator for PackIndexIter<'a> {
127    type Item = PackIndexEntryRef<'a>;
128
129    fn next(&mut self) -> Option<Self::Item> {
130        if self.next >= self.index.len() {
131            return None;
132        }
133        let pos = self.next;
134        self.next += 1;
135        Some(PackIndexEntryRef {
136            index: self.index,
137            position: pos,
138        })
139    }
140
141    fn size_hint(&self) -> (usize, Option<usize>) {
142        let rem = self.index.len().saturating_sub(self.next);
143        (rem, Some(rem))
144    }
145}
146
147impl ExactSizeIterator for PackIndexIter<'_> {}
148
149impl PackIndex {
150    /// Number of objects in this index.
151    #[must_use]
152    pub fn len(&self) -> usize {
153        self.layout.count
154    }
155
156    /// Whether this index contains no objects.
157    #[must_use]
158    pub fn is_empty(&self) -> bool {
159        self.layout.count == 0
160    }
161
162    /// OID width in bytes (`20` for SHA-1, `32` for SHA-256).
163    #[must_use]
164    pub fn hash_bytes(&self) -> usize {
165        self.layout.hash_bytes
166    }
167
168    /// OID bytes at sorted position `i`.
169    #[must_use]
170    pub fn oid_at(&self, i: usize) -> &[u8] {
171        debug_assert!(i < self.layout.count);
172        let start = match self.layout.version {
173            PackIndexVersion::V1 => {
174                let record = 4 + self.layout.hash_bytes;
175                self.layout.oid_base + i * record + 4
176            }
177            PackIndexVersion::V2 => self.layout.oid_base + i * self.layout.hash_bytes,
178        };
179        let end = start + self.layout.hash_bytes;
180        &self.storage[start..end]
181    }
182
183    /// Pack byte offset at sorted position `i`.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`Error::CorruptObject`] when offset tables are malformed (including lazy-open
188    /// indexes that skipped full verification).
189    pub fn offset_at(&self, i: usize) -> Result<u64> {
190        if i >= self.layout.count {
191            return Err(Error::CorruptObject(format!(
192                "index row {i} out of range in {}",
193                self.idx_path.display()
194            )));
195        }
196        match self.layout.version {
197            PackIndexVersion::V1 => {
198                let start = self.layout.oid_base + i * (4 + self.layout.hash_bytes);
199                Ok(read_u32_be_slice(&self.storage, start) as u64)
200            }
201            PackIndexVersion::V2 => {
202                let start = self.layout.off32_base + i * 4;
203                let raw = read_u32_be_slice(&self.storage, start);
204                self.decode_v2_offset_at(i, raw)
205            }
206        }
207    }
208
209    fn decode_v2_offset_at(&self, row: usize, raw: u32) -> Result<u64> {
210        if (raw & 0x8000_0000) == 0 {
211            return Ok(u64::from(raw));
212        }
213        let slot = (raw & 0x7fff_ffff) as usize;
214        if slot >= self.layout.large_off_count {
215            return Err(Error::CorruptObject(format!(
216                "large offset slot {slot} out of range ({} slots) in {}",
217                self.layout.large_off_count,
218                self.idx_path.display()
219            )));
220        }
221        let expected_slot = self.large_slot_index_for_row(row)?;
222        if slot != expected_slot {
223            return Err(Error::CorruptObject(format!(
224                "inconsistent 64b offset index at entry {row} in {}",
225                self.idx_path.display()
226            )));
227        }
228        let loff = self.layout.large_off_base + slot * 8;
229        let trailer = self.layout.hash_bytes * 2;
230        if loff + 8 > self.storage.len().saturating_sub(trailer) {
231            return Err(Error::CorruptObject(format!(
232                "truncated large offset table in {}",
233                self.idx_path.display()
234            )));
235        }
236        Ok(read_u64_be_slice(&self.storage, loff))
237    }
238
239    fn large_slot_index_for_row(&self, row: usize) -> Result<usize> {
240        debug_assert!(self.layout.version == PackIndexVersion::V2);
241        let mut next = 0usize;
242        for i in 0..row {
243            let start = self.layout.off32_base + i * 4;
244            let raw = read_u32_be_slice(&self.storage, start);
245            if (raw & 0x8000_0000) != 0 {
246                next += 1;
247            }
248        }
249        Ok(next)
250    }
251
252    /// CRC32 at sorted position `i` (`None` for version-1 indexes).
253    #[must_use]
254    pub fn crc32_at(&self, i: usize) -> Option<u32> {
255        debug_assert!(i < self.layout.count);
256        if self.layout.version != PackIndexVersion::V2 {
257            return None;
258        }
259        let start = self.layout.crc_base + i * 4;
260        Some(read_u32_be_slice(&self.storage, start))
261    }
262
263    /// Sorted-table position for `oid`, or `None` when absent or hash width mismatches.
264    #[must_use]
265    pub fn find_position(&self, oid: &ObjectId) -> Option<usize> {
266        let needle = oid.as_bytes();
267        if needle.len() != self.layout.hash_bytes {
268            return None;
269        }
270        let first = needle[0] as usize;
271        let lo = if first == 0 {
272            0
273        } else {
274            self.fanout[first - 1] as usize
275        };
276        let hi = self.fanout[first] as usize;
277        if lo >= hi || hi > self.layout.count {
278            return None;
279        }
280        self.find_position_in_bucket(lo, hi, needle)
281    }
282
283    /// Pack byte offset for `oid`, if present.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`Error::CorruptObject`] when the matching row encodes an invalid offset.
288    pub fn find_offset(&self, oid: &ObjectId) -> Result<Option<u64>> {
289        match self.find_position(oid) {
290            Some(i) => Ok(Some(self.offset_at(i)?)),
291            None => Ok(None),
292        }
293    }
294
295    /// Like [`Self::find_offset`] but optimized for monotonically non-decreasing `oid` probes.
296    ///
297    /// Updates `cursor` to the matching row on success. When the probe regresses (out-of-order
298    /// input), falls back to fanout + binary search and resets `cursor`.
299    pub fn find_offset_monotonic(&self, oid: &ObjectId, cursor: &mut usize) -> Result<Option<u64>> {
300        const FORWARD_SCAN_MAX: usize = 128;
301        let needle = oid.as_bytes();
302        if needle.len() != self.layout.hash_bytes {
303            return Ok(None);
304        }
305        let len = self.len();
306        if len == 0 {
307            return Ok(None);
308        }
309        if *cursor >= len {
310            *cursor = 0;
311        }
312        if *cursor < len && compare_oid_bytes(self.oid_at(*cursor), needle) == Ordering::Greater {
313            if let Some(pos) = self.find_position(oid) {
314                *cursor = pos;
315                return Ok(Some(self.offset_at(pos)?));
316            }
317            return Ok(None);
318        }
319        let mut pos = *cursor;
320        let scan_start = pos;
321        // Probes that jump past the scan window (walk order over a large pack) would compare
322        // every row in it and fault in index pages a binary search never touches.
323        let window_last = (scan_start + FORWARD_SCAN_MAX).min(len) - 1;
324        if compare_oid_bytes(self.oid_at(window_last), needle) == Ordering::Less {
325            if let Some(found) = self.find_position(oid) {
326                *cursor = found;
327                return Ok(Some(self.offset_at(found)?));
328            }
329            return Ok(None);
330        }
331        while pos < len && pos - scan_start < FORWARD_SCAN_MAX {
332            match compare_oid_bytes(self.oid_at(pos), needle) {
333                Ordering::Less => pos += 1,
334                Ordering::Equal => {
335                    *cursor = pos;
336                    return Ok(Some(self.offset_at(pos)?));
337                }
338                Ordering::Greater => return Ok(None),
339            }
340        }
341        if pos < len {
342            if let Some(found) = self.find_position(oid) {
343                *cursor = found;
344                return Ok(Some(self.offset_at(found)?));
345            }
346            return Ok(None);
347        }
348        Ok(None)
349    }
350    /// Whether this pack index contains the given OID.
351    pub fn contains(&self, oid: &ObjectId) -> Result<bool> {
352        Ok(self.find_offset(oid)?.is_some())
353    }
354
355    /// Iterate rows in OID sort order.
356    pub fn iter(&self) -> PackIndexIter<'_> {
357        PackIndexIter {
358            index: self,
359            next: 0,
360        }
361    }
362
363    /// Smallest pack offset strictly greater than `entry_offset`, capped at `pack_end`.
364    ///
365    /// Uses the cached offset-sorted table, so repeated calls cost a binary search each.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error when the index has a malformed large-offset entry.
370    pub fn next_pack_offset_after(&self, entry_offset: u64, pack_end: u64) -> Result<u64> {
371        self.with_sorted_offsets(|pairs| {
372            let next = pairs.partition_point(|p| p.0 <= entry_offset);
373            pairs
374                .get(next)
375                .map_or(pack_end, |&(off, _)| off.min(pack_end))
376        })
377    }
378
379    /// Run `f` over `(pack offset, index row)` pairs sorted by offset, building and
380    /// caching the table on first use.
381    ///
382    /// The table is decoded from the `.rev` sidecar when one is present and valid
383    /// (checksummed once, no sort), and otherwise built by sorting the index rows.
384    fn with_sorted_offsets<R>(&self, f: impl FnOnce(&[(u64, u32)]) -> R) -> Result<R> {
385        let mut guard = self.offset_lookup.lock().unwrap_or_else(|e| e.into_inner());
386        if let Some(pairs) = guard.as_deref() {
387            return Ok(f(pairs));
388        }
389        let pairs = match self.sorted_offsets_from_rev()? {
390            Some(pairs) => pairs,
391            None => {
392                let mut pairs: Vec<(u64, u32)> = Vec::with_capacity(self.len());
393                for i in 0..self.len() {
394                    pairs.push((self.offset_at(i)?, i as u32));
395                }
396                pairs.sort_unstable_by_key(|p| p.0);
397                pairs
398            }
399        };
400        Ok(f(guard.insert(pairs)))
401    }
402
403    /// Offset-sorted `(pack offset, index row)` pairs from the `.rev` sidecar.
404    ///
405    /// Returns `None` when there is no sidecar, it fails validation, or its order
406    /// does not match the offsets in this index.
407    fn sorted_offsets_from_rev(&self) -> Result<Option<Vec<(u64, u32)>>> {
408        let rev_path = crate::pack_rev::rev_path_for_index(&self.idx_path);
409        let Ok(data) = PackData::open_with_min_len(&rev_path, 12) else {
410            return Ok(None);
411        };
412        let Some(rows) = crate::pack_rev::try_rev_positions_in_pack_order(&data, self.len()) else {
413            return Ok(None);
414        };
415        let mut pairs: Vec<(u64, u32)> = Vec::with_capacity(rows.len());
416        for row in rows {
417            let offset = self.offset_at(row as usize)?;
418            if pairs.last().is_some_and(|&(prev, _)| prev >= offset) {
419                return Ok(None);
420            }
421            pairs.push((offset, row));
422        }
423        Ok(Some(pairs))
424    }
425
426    /// CRC32 for the index row at `entry_offset`, if any.
427    pub fn crc32_for_pack_offset(&self, entry_offset: u64) -> Result<Option<u32>> {
428        Ok(match self.find_position_by_offset_sorted(entry_offset)? {
429            Some(i) => self.crc32_at(i),
430            None => None,
431        })
432    }
433
434    /// Copy with promisor/cruft sidecar flags refreshed from disk markers.
435    #[must_use]
436    pub(crate) fn with_sidecar_flags(&self, is_promisor: bool, is_cruft: bool) -> Self {
437        let mut next = self.clone();
438        next.is_promisor = is_promisor;
439        next.is_cruft = is_cruft;
440        next
441    }
442
443    /// Index row position of the object stored at pack byte `offset`.
444    ///
445    /// Uses an offset-sorted table that is built once per index (from the `.rev`
446    /// sidecar when present) and shared by clones, so each lookup is a binary search.
447    ///
448    /// Returns `Ok(None)` when no object starts at `offset`.
449    ///
450    /// # Errors
451    ///
452    /// Returns [`Error::CorruptObject`] when the index has a malformed large-offset entry.
453    pub fn find_position_by_offset_sorted(&self, offset: u64) -> Result<Option<usize>> {
454        self.with_sorted_offsets(|pairs| {
455            pairs
456                .binary_search_by_key(&offset, |p| p.0)
457                .ok()
458                .map(|i| pairs[i].1 as usize)
459        })
460    }
461
462    fn find_position_in_bucket(&self, lo: usize, hi: usize, needle: &[u8]) -> Option<usize> {
463        if hi - lo >= 64 {
464            if let Some(pos) = self.interpolation_search(lo, hi, needle) {
465                return Some(pos);
466            }
467        }
468        self.binary_search(lo, hi, needle)
469    }
470
471    fn binary_search(&self, mut lo: usize, mut hi: usize, needle: &[u8]) -> Option<usize> {
472        while lo < hi {
473            let mid = lo + (hi - lo) / 2;
474            match compare_oid_bytes(self.oid_at(mid), needle) {
475                Ordering::Less => lo = mid + 1,
476                Ordering::Greater => hi = mid,
477                Ordering::Equal => return Some(mid),
478            }
479        }
480        None
481    }
482
483    fn interpolation_search(&self, lo: usize, hi: usize, needle: &[u8]) -> Option<usize> {
484        let mut lo = lo;
485        let mut hi = hi;
486        while lo < hi && needle >= self.oid_at(lo) && needle <= self.oid_at(hi - 1) {
487            let lo_oid = self.oid_at(lo);
488            let hi_oid = self.oid_at(hi - 1);
489            if lo_oid == hi_oid {
490                return if lo_oid == needle { Some(lo) } else { None };
491            }
492            let span = hi - lo - 1;
493            let num = oid_interp_prefix_u64(needle);
494            let lo_val = oid_interp_prefix_u64(lo_oid);
495            let hi_val = oid_interp_prefix_u64(hi_oid);
496            let guess = if hi_val <= lo_val {
497                lo + span / 2
498            } else {
499                let num = u128::from(num.saturating_sub(lo_val));
500                let den = u128::from(hi_val - lo_val);
501                lo.saturating_add(((num * u128::from(span as u64)) / den) as usize)
502            };
503            let pos = guess.min(hi - 1);
504            match compare_oid_bytes(self.oid_at(pos), needle) {
505                Ordering::Equal => return Some(pos),
506                Ordering::Less => lo = pos + 1,
507                Ordering::Greater => hi = pos,
508            }
509        }
510        None
511    }
512}
513
514/// Lexicographic compare for fixed-width object ids (hot path for pack index search).
515#[inline]
516fn compare_oid_bytes(a: &[u8], b: &[u8]) -> Ordering {
517    if a.len() == 20 && b.len() == 20 {
518        let a0 = u64::from_be_bytes([a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]]);
519        let b0 = u64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]);
520        if a0 != b0 {
521            return a0.cmp(&b0);
522        }
523        let a1 = u64::from_be_bytes([a[8], a[9], a[10], a[11], a[12], a[13], a[14], a[15]]);
524        let b1 = u64::from_be_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]);
525        if a1 != b1 {
526            return a1.cmp(&b1);
527        }
528        let a2 = u32::from_be_bytes([a[16], a[17], a[18], a[19]]);
529        let b2 = u32::from_be_bytes([b[16], b[17], b[18], b[19]]);
530        return a2.cmp(&b2);
531    }
532    a.cmp(b)
533}
534
535/// Leading OID bytes for interpolation probing (8 bytes — fits in `u64` without overflow).
536const OID_INTERP_PREFIX_LEN: usize = 8;
537
538pub(crate) fn oid_interp_prefix_u64(oid: &[u8]) -> u64 {
539    let take = oid.len().min(OID_INTERP_PREFIX_LEN);
540    let mut v = 0u64;
541    for &b in &oid[..take] {
542        v = (v << 8) | u64::from(b);
543    }
544    for _ in take..OID_INTERP_PREFIX_LEN {
545        v <<= 8;
546    }
547    v
548}
549
550/// True when `entry` stores a SHA-1 OID matching `oid` (SHA-256 pack entries are ignored).
551#[must_use]
552pub fn pack_index_entry_matches_sha1_oid(entry: &PackIndexEntryRef<'_>, oid: &ObjectId) -> bool {
553    entry.oid().len() == 20 && entry.oid() == oid.as_bytes()
554}
555
556/// Compute fanout from a sorted OID list (used when writing v1 indexes in tests).
557#[must_use]
558pub fn compute_fanout_from_oid_slices(entries: &[&[u8]]) -> [u32; 256] {
559    let mut fanout = [0u32; 256];
560    let mut idx = 0usize;
561    for byte in 0u32..256 {
562        let needle = byte as u8;
563        while idx < entries.len() && entries[idx].first().copied().unwrap_or(0) <= needle {
564            idx += 1;
565        }
566        fanout[byte as usize] = u32::try_from(idx).unwrap_or(u32::MAX);
567    }
568    fanout
569}
570
571/// Compute the 256-entry fanout from owned entries (legacy helper for tests).
572#[must_use]
573pub fn compute_fanout_from_entries(entries: &[PackIndexEntry]) -> [u32; 256] {
574    let slices: Vec<&[u8]> = entries.iter().map(|e| e.oid.as_slice()).collect();
575    compute_fanout_from_oid_slices(&slices)
576}
577
578/// Parse pack index bytes (v1 or v2). When `verify` is true, check the trailing checksum.
579pub fn parse_pack_index_bytes(idx_path: &Path, bytes: Vec<u8>, verify: bool) -> Result<PackIndex> {
580    if bytes.len() < 8 {
581        return Err(Error::CorruptObject(format!(
582            "index file {} is too small",
583            idx_path.display()
584        )));
585    }
586    let storage = PackIndexStorage(PackData::from_owned(bytes));
587    parse_pack_index_storage(idx_path, &storage, verify)
588}
589
590/// Memory-map or read a pack index from disk and parse it (lazy structural checks when `verify` is false).
591///
592/// # Errors
593///
594/// Returns [`Error::Io`] or [`Error::CorruptObject`] on failure, and
595/// [`Error::PackIndexChecksumMismatch`] when `verify` is set and the trailer is wrong.
596pub fn open_pack_index_file(idx_path: &Path, verify: bool) -> Result<PackIndex> {
597    let storage = PackIndexStorage(PackData::open_with_min_len(idx_path, IDX_MIN_LEN)?);
598    parse_pack_index_storage(idx_path, &storage, verify)
599}
600
601fn parse_pack_index_storage(
602    idx_path: &Path,
603    storage: &PackIndexStorage,
604    verify: bool,
605) -> Result<PackIndex> {
606    if storage.len() < 8 {
607        return Err(Error::CorruptObject(format!(
608            "index file {} is too small",
609            idx_path.display()
610        )));
611    }
612    let magic = &storage[0..4];
613    if magic == [0xff, b't', b'O', b'c'] {
614        read_pack_index_v2(idx_path, storage, verify)
615    } else {
616        read_pack_index_v1(idx_path, storage, verify)
617    }
618}
619
620fn read_pack_index_v1(
621    idx_path: &Path,
622    storage: &PackIndexStorage,
623    verify: bool,
624) -> Result<PackIndex> {
625    let bytes = storage.deref();
626    let mut pos = 0usize;
627    if bytes.len() < 256 * 4 + 20 {
628        return Err(Error::CorruptObject(format!(
629            "index file {} is too small",
630            idx_path.display()
631        )));
632    }
633    let mut fanout = [0u32; 256];
634    for slot in &mut fanout {
635        *slot = read_u32_be(bytes, &mut pos)?;
636    }
637    check_fanout_monotonic(&fanout, idx_path)?;
638    let object_count = fanout[255] as usize;
639    let record_size = 4 + 20;
640    let need = pos
641        .saturating_add(object_count.saturating_mul(record_size))
642        .saturating_add(20);
643    if bytes.len() < need {
644        return Err(Error::CorruptObject(format!(
645            "truncated idx file {}",
646            idx_path.display()
647        )));
648    }
649    let oid_base = pos;
650    if verify {
651        for i in 0..object_count {
652            let off_start = oid_base + i * record_size;
653            let oid_start = off_start + 4;
654            if i > 0 {
655                let prev_start = oid_base + (i - 1) * record_size + 4;
656                if bytes[prev_start..prev_start + 20] > bytes[oid_start..oid_start + 20] {
657                    return Err(Error::CorruptObject(format!(
658                        "oid lookup out of order in {}",
659                        idx_path.display()
660                    )));
661                }
662            }
663        }
664    }
665    if verify {
666        verify_idx_trailing_checksum(idx_path, bytes, 20)?;
667    }
668    let mut pack_path = idx_path.to_path_buf();
669    pack_path.set_extension("pack");
670    Ok(PackIndex {
671        idx_path: idx_path.to_path_buf(),
672        pack_path,
673        storage: storage.clone(),
674        layout: PackIndexLayout {
675            version: PackIndexVersion::V1,
676            hash_bytes: 20,
677            count: object_count,
678            oid_base,
679            crc_base: 0,
680            off32_base: 0,
681            large_off_base: 0,
682            large_off_count: 0,
683        },
684        fanout,
685        is_promisor: false,
686        is_cruft: false,
687        offset_lookup: Arc::new(Mutex::new(None)),
688    })
689}
690
691fn read_pack_index_v2(
692    idx_path: &Path,
693    storage: &PackIndexStorage,
694    verify: bool,
695) -> Result<PackIndex> {
696    let bytes = storage.deref();
697    if bytes.len() < 8 + 256 * 4 + 40 {
698        return Err(Error::CorruptObject(format!(
699            "index file {} is too small",
700            idx_path.display()
701        )));
702    }
703    let mut pos = 4usize;
704    let version = read_u32_be(bytes, &mut pos)?;
705    if version != 2 {
706        return Err(Error::CorruptObject(format!(
707            "unsupported idx version {version} in {}",
708            idx_path.display()
709        )));
710    }
711    let mut fanout = [0u32; 256];
712    for slot in &mut fanout {
713        *slot = read_u32_be(bytes, &mut pos)?;
714    }
715    check_fanout_monotonic(&fanout, idx_path)?;
716    let object_count = fanout[255] as usize;
717    let idx_file_len = bytes.len();
718    let hash_bytes = detect_idx_hash_bytes_v2(idx_file_len, pos, object_count, idx_path)?;
719    let oid_base = pos;
720    let crc_base = oid_base + object_count * hash_bytes;
721    let off32_base = crc_base + object_count * 4;
722    let need = off32_base + object_count * 4;
723    if bytes.len() < need {
724        return Err(Error::CorruptObject(format!(
725            "truncated idx file {}",
726            idx_path.display()
727        )));
728    }
729    let large_off_base = off32_base + object_count * 4;
730    let large_off_count = if verify {
731        pos = off32_base;
732        let mut count = 0usize;
733        let mut next_large = 0usize;
734        for i in 0..object_count {
735            let v = read_u32_be(bytes, &mut pos)?;
736            if (v & 0x8000_0000) != 0 {
737                let slot = (v & 0x7fff_ffff) as usize;
738                if slot != next_large {
739                    return Err(Error::CorruptObject(format!(
740                        "inconsistent 64b offset index at entry {i} in {}",
741                        idx_path.display()
742                    )));
743                }
744                next_large += 1;
745                count += 1;
746            }
747        }
748        count
749    } else {
750        let tail = hash_bytes * 2;
751        if bytes.len() < large_off_base + tail {
752            return Err(Error::CorruptObject(format!(
753                "truncated idx file {}",
754                idx_path.display()
755            )));
756        }
757        (bytes.len() - large_off_base - tail) / 8
758    };
759    if bytes.len() < large_off_base + large_off_count * 8 + 2 * hash_bytes {
760        return Err(Error::CorruptObject(format!(
761            "truncated large offset table in {}",
762            idx_path.display()
763        )));
764    }
765    if verify {
766        for i in 0..object_count {
767            let start = oid_base + i * hash_bytes;
768            if i > 0 {
769                let prev = oid_base + (i - 1) * hash_bytes;
770                if bytes[prev..prev + hash_bytes] > bytes[start..start + hash_bytes] {
771                    return Err(Error::CorruptObject(format!(
772                        "oid lookup out of order in {}",
773                        idx_path.display()
774                    )));
775                }
776            }
777        }
778    }
779    if verify {
780        verify_idx_trailing_checksum(idx_path, bytes, hash_bytes)?;
781    }
782    let mut pack_path = idx_path.to_path_buf();
783    pack_path.set_extension("pack");
784    Ok(PackIndex {
785        idx_path: idx_path.to_path_buf(),
786        pack_path,
787        storage: storage.clone(),
788        layout: PackIndexLayout {
789            version: PackIndexVersion::V2,
790            hash_bytes,
791            count: object_count,
792            oid_base,
793            crc_base,
794            off32_base,
795            large_off_base,
796            large_off_count,
797        },
798        fanout,
799        is_promisor: false,
800        is_cruft: false,
801        offset_lookup: Arc::new(Mutex::new(None)),
802    })
803}
804
805fn verify_idx_trailing_checksum(idx_path: &Path, bytes: &[u8], hash_bytes: usize) -> Result<()> {
806    let Some(algo) = HashAlgo::from_len(hash_bytes) else {
807        return Err(Error::CorruptObject(format!(
808            "unsupported index hash width {hash_bytes}"
809        )));
810    };
811    verify_trailer(algo, bytes).map_err(|mismatch| Error::PackIndexChecksumMismatch {
812        path: idx_path.to_path_buf(),
813        mismatch,
814    })
815}
816
817fn check_fanout_monotonic(fanout: &[u32; 256], idx_path: &Path) -> Result<()> {
818    let mut prev = 0u32;
819    for &n in fanout {
820        if n < prev {
821            return Err(Error::CorruptObject(format!(
822                "non-monotonic index {}",
823                idx_path.display()
824            )));
825        }
826        prev = n;
827    }
828    Ok(())
829}
830
831fn detect_idx_hash_bytes_v2(
832    idx_file_len: usize,
833    fanout_end: usize,
834    object_count: usize,
835    idx_path: &Path,
836) -> Result<usize> {
837    if object_count == 0 {
838        let sha1_len = fanout_end.saturating_add(2 * 20);
839        let sha256_len = fanout_end.saturating_add(2 * 32);
840        return match idx_file_len {
841            n if n == sha1_len => Ok(20),
842            n if n == sha256_len => Ok(32),
843            _ => Err(Error::CorruptObject(format!(
844                "wrong index v2 file size in {}",
845                idx_path.display()
846            ))),
847        };
848    }
849    for &hb in &[20usize, 32] {
850        let fixed = fanout_end
851            .saturating_add(object_count.saturating_mul(hb + 4 + 4))
852            .saturating_add(2 * hb);
853        if idx_file_len < fixed {
854            continue;
855        }
856        let extra = idx_file_len - fixed;
857        if !extra.is_multiple_of(8) {
858            continue;
859        }
860        if extra / 8 > object_count {
861            continue;
862        }
863        return Ok(hb);
864    }
865    Err(Error::CorruptObject(format!(
866        "wrong index v2 file size in {}",
867        idx_path.display()
868    )))
869}
870
871fn read_u32_be_slice(bytes: &[u8], start: usize) -> u32 {
872    u32::from_be_bytes([
873        bytes[start],
874        bytes[start + 1],
875        bytes[start + 2],
876        bytes[start + 3],
877    ])
878}
879
880fn read_u64_be_slice(bytes: &[u8], start: usize) -> u64 {
881    u64::from_be_bytes([
882        bytes[start],
883        bytes[start + 1],
884        bytes[start + 2],
885        bytes[start + 3],
886        bytes[start + 4],
887        bytes[start + 5],
888        bytes[start + 6],
889        bytes[start + 7],
890    ])
891}
892
893fn read_u32_be(bytes: &[u8], pos: &mut usize) -> Result<u32> {
894    if bytes.len() < *pos + 4 {
895        return Err(Error::CorruptObject(
896            "unexpected end of idx while reading u32".to_owned(),
897        ));
898    }
899    let v = u32::from_be_bytes(
900        bytes[*pos..*pos + 4]
901            .try_into()
902            .map_err(|_| Error::CorruptObject("failed to parse u32".to_owned()))?,
903    );
904    *pos += 4;
905    Ok(v)
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use crate::objects::ObjectId;
912
913    #[test]
914    fn pack_index_api_coverage_smoke() {
915        let oid_a = vec![0x01u8; 20];
916        let oid_b = vec![0x02u8; 20];
917        let fanout = compute_fanout_from_oid_slices(&[&oid_a, &oid_b]);
918        assert_eq!(fanout[1], 1);
919        assert_eq!(fanout[2], 2);
920    }
921
922    #[test]
923    fn find_position_empty_bucket() {
924        let entries = vec![PackIndexEntry {
925            oid: vec![0x05; 20],
926            offset: 1,
927            crc32: None,
928        }];
929        let fanout = compute_fanout_from_entries(&entries);
930        let idx = PackIndex {
931            idx_path: PathBuf::from("t.idx"),
932            pack_path: PathBuf::from("t.pack"),
933            storage: PackIndexStorage(PackData::from_owned(Vec::new())),
934            layout: PackIndexLayout {
935                version: PackIndexVersion::V1,
936                hash_bytes: 20,
937                count: 0,
938                oid_base: 0,
939                crc_base: 0,
940                off32_base: 0,
941                large_off_base: 0,
942                large_off_count: 0,
943            },
944            fanout,
945            is_promisor: false,
946            is_cruft: false,
947            offset_lookup: Arc::new(Mutex::new(None)),
948        };
949        let miss = ObjectId::from_bytes(&[0x06; 20]).unwrap();
950        assert!(idx.find_position(&miss).is_none());
951        let _ = idx.len();
952        let _ = idx.is_empty();
953        let _ = idx.hash_bytes();
954        let _ = idx.iter().next();
955    }
956
957    fn build_v1_idx_bytes(entries: &[(Vec<u8>, u64)]) -> Vec<u8> {
958        let mut sorted = entries.to_vec();
959        sorted.sort_by(|a, b| a.0.cmp(&b.0));
960        let slices: Vec<&[u8]> = sorted.iter().map(|(o, _)| o.as_slice()).collect();
961        let fanout = compute_fanout_from_oid_slices(&slices);
962        let mut body = Vec::new();
963        for f in fanout {
964            body.extend_from_slice(&f.to_be_bytes());
965        }
966        for (oid, off) in sorted {
967            body.extend_from_slice(&(off as u32).to_be_bytes());
968            body.extend_from_slice(&oid);
969        }
970        body.extend_from_slice(&crate::objects::HashAlgo::Sha1.checksum_digest(&body));
971        body
972    }
973
974    #[test]
975    fn find_position_large_fanout_bucket_sha1_v1_no_panic() {
976        let mut rows = Vec::new();
977        for i in 0..64u8 {
978            let mut oid = vec![0x05u8; 20];
979            oid[19] = i;
980            rows.push((oid, u64::from(i) * 12 + 100));
981        }
982        let body = build_v1_idx_bytes(&rows);
983        let idx =
984            parse_pack_index_bytes(Path::new("big-v1.idx"), body, false).expect("parse v1 idx");
985        assert_eq!(idx.len(), 64);
986        for (oid, _) in &rows {
987            let id = ObjectId::from_bytes(oid).expect("oid");
988            assert!(
989                idx.find_position(&id).is_some(),
990                "missing {}",
991                hex::encode(oid)
992            );
993        }
994        let miss = ObjectId::from_bytes(&[0x06; 20]).unwrap();
995        assert!(idx.find_position(&miss).is_none());
996    }
997
998    #[test]
999    fn find_offset_monotonic_matches_find_offset_for_any_probe_order() {
1000        // Enough rows that probes jump well past the forward-scan window.
1001        let rows: Vec<(Vec<u8>, u64)> = (0..1200u32)
1002            .map(|i| {
1003                let mut oid = vec![0u8; 20];
1004                oid[..4].copy_from_slice(&i.wrapping_mul(2_654_435_761).to_be_bytes());
1005                oid[19] = 1;
1006                (oid, 12 + u64::from(i) * 40)
1007            })
1008            .collect();
1009        let idx = parse_pack_index_bytes(Path::new("mono.idx"), build_v1_idx_bytes(&rows), false)
1010            .expect("parse v1 idx");
1011        let mut sorted: Vec<ObjectId> = rows
1012            .iter()
1013            .map(|(oid, _)| ObjectId::from_bytes(oid).expect("oid"))
1014            .collect();
1015        sorted.sort();
1016        let missing = |oid: &ObjectId| {
1017            let mut bytes = oid.as_bytes().to_vec();
1018            bytes[19] = 2;
1019            ObjectId::from_bytes(&bytes).expect("oid")
1020        };
1021        let mut probes: Vec<ObjectId> = Vec::new();
1022        probes.extend(sorted.iter().copied());
1023        probes.extend(sorted.iter().step_by(300).copied());
1024        probes.extend(sorted.iter().rev().step_by(7).copied());
1025        probes.extend(sorted.iter().step_by(11).map(missing));
1026        probes.extend(
1027            rows.iter()
1028                .map(|(oid, _)| ObjectId::from_bytes(oid).expect("oid")),
1029        );
1030        let mut cursor = 0usize;
1031        for probe in &probes {
1032            assert_eq!(
1033                idx.find_offset_monotonic(probe, &mut cursor)
1034                    .expect("monotonic"),
1035                idx.find_offset(probe).expect("binary search"),
1036                "probe {probe}"
1037            );
1038        }
1039
1040        let empty = parse_pack_index_bytes(Path::new("empty.idx"), build_v1_idx_bytes(&[]), false)
1041            .expect("parse empty idx");
1042        let mut cursor = 5usize;
1043        assert_eq!(
1044            empty
1045                .find_offset_monotonic(&sorted[0], &mut cursor)
1046                .expect("empty"),
1047            None
1048        );
1049    }
1050
1051    #[test]
1052    fn next_pack_offset_after_matches_linear_scan() {
1053        // Offsets deliberately not in OID order, as in real packs.
1054        let rows: Vec<(Vec<u8>, u64)> = (0..50u8)
1055            .map(|i| {
1056                let mut oid = vec![i; 20];
1057                oid[0] = i.wrapping_mul(37);
1058                (oid, 12 + (u64::from(i) * 7 % 50) * 30)
1059            })
1060            .collect();
1061        let idx = parse_pack_index_bytes(Path::new("order.idx"), build_v1_idx_bytes(&rows), false)
1062            .expect("parse v1 idx");
1063        let offsets: Vec<u64> = rows.iter().map(|r| r.1).collect();
1064        let pack_end = 12 + 50 * 30 - 10;
1065        for probe in (0..pack_end + 40).step_by(5) {
1066            let expected = offsets
1067                .iter()
1068                .copied()
1069                .filter(|&o| o > probe && o <= pack_end)
1070                .min()
1071                .unwrap_or(pack_end);
1072            assert_eq!(
1073                idx.next_pack_offset_after(probe, pack_end).expect("next"),
1074                expected,
1075                "probe {probe}"
1076            );
1077        }
1078        for (i, &off) in offsets.iter().enumerate() {
1079            let pos = idx.find_position_by_offset_sorted(off).expect("lookup");
1080            let pos = pos.expect("every row offset resolves");
1081            assert_eq!(idx.offset_at(pos).expect("offset"), off, "row {i}");
1082        }
1083        assert_eq!(idx.find_position_by_offset_sorted(13).expect("miss"), None);
1084    }
1085
1086    #[test]
1087    fn detect_empty_v2_index_hash_width_from_file_size() {
1088        let fanout_end = 8 + 256 * 4;
1089        assert_eq!(
1090            detect_idx_hash_bytes_v2(1072, fanout_end, 0, Path::new("t.idx")).expect("sha1"),
1091            20
1092        );
1093        assert_eq!(
1094            detect_idx_hash_bytes_v2(1096, fanout_end, 0, Path::new("t.idx")).expect("sha256"),
1095            32
1096        );
1097        assert!(detect_idx_hash_bytes_v2(1073, fanout_end, 0, Path::new("t.idx")).is_err());
1098    }
1099
1100    #[test]
1101    fn duplicate_oid_rows_parse_and_resolve_first_offset() {
1102        use crate::pack::write_v2_pack_index_with_trailer;
1103        use std::fs;
1104
1105        let oid = ObjectId::from_bytes(&[0x05; 20]).unwrap();
1106        let dir = tempfile::tempdir().expect("tempdir");
1107        let idx_path = dir.path().join("dup-oid.idx");
1108        let pack_path = dir.path().join("dup-oid.pack");
1109        fs::write(&pack_path, b"PACK\x00\x00\x00\x02\x00\x00\x00\x00").expect("pack stub");
1110        write_v2_pack_index_with_trailer(
1111            &idx_path,
1112            &[(oid, 100, 0), (oid, 200, 0)],
1113            &[0u8; 20],
1114            20,
1115            None,
1116        )
1117        .expect("write dup rows");
1118        let mut idx =
1119            parse_pack_index_bytes(&idx_path, fs::read(&idx_path).unwrap(), false).expect("parse");
1120        idx.pack_path = pack_path;
1121        assert_eq!(idx.len(), 2);
1122        assert_eq!(idx.offset_at(0).expect("off0"), 100);
1123        assert_eq!(idx.offset_at(1).expect("off1"), 200);
1124        assert_eq!(idx.find_offset(&oid).expect("lookup"), Some(200));
1125    }
1126
1127    #[test]
1128    fn verified_open_reports_idx_trailer_mismatch_as_typed_error() {
1129        use crate::pack::write_v2_pack_index_with_trailer;
1130        use std::fs;
1131
1132        let oid = ObjectId::from_bytes(&[0x07; 20]).unwrap();
1133        let dir = tempfile::tempdir().expect("tempdir");
1134        let idx_path = dir.path().join("trailer.idx");
1135        write_v2_pack_index_with_trailer(&idx_path, &[(oid, 12, 0)], &[0u8; 20], 20, None)
1136            .expect("write idx");
1137        open_pack_index_file(&idx_path, true).expect("intact trailer verifies");
1138
1139        let mut bytes = fs::read(&idx_path).unwrap();
1140        let last = bytes.len() - 1;
1141        bytes[last] ^= 0xff;
1142        fs::write(&idx_path, &bytes).unwrap();
1143
1144        open_pack_index_file(&idx_path, false).expect("unverified open ignores the trailer");
1145        let err = open_pack_index_file(&idx_path, true).expect_err("bad trailer");
1146        let message = err.to_string();
1147        assert!(
1148            message.starts_with("corrupt object: index checksum mismatch for "),
1149            "{message}"
1150        );
1151        let Error::PackIndexChecksumMismatch { path, mismatch } = err else {
1152            panic!("expected PackIndexChecksumMismatch: {message}");
1153        };
1154        assert_eq!(path, idx_path);
1155        assert_eq!(mismatch.found, bytes[bytes.len() - 20..]);
1156        assert_ne!(mismatch.expected, mismatch.found);
1157    }
1158
1159    #[test]
1160    fn find_position_rejects_mismatched_hash_width() {
1161        let body = build_v1_idx_bytes(&[(vec![0x01; 20], 12)]);
1162        let idx = parse_pack_index_bytes(Path::new("w.idx"), body, false).expect("idx");
1163        let wide = ObjectId::from_bytes(&[0x01; 32]).expect("wide");
1164        assert!(idx.find_position(&wide).is_none());
1165    }
1166
1167    #[test]
1168    fn pack_index_entry_matches_sha1_oid_rejects_wide_rows() {
1169        let body = build_v1_idx_bytes(&[(vec![0x01; 20], 12)]);
1170        let idx = parse_pack_index_bytes(Path::new("w.idx"), body, false).expect("idx");
1171        let entry = idx.iter().next().expect("row");
1172        let sha1 = ObjectId::from_bytes(&[0x01; 20]).expect("sha1");
1173        assert!(pack_index_entry_matches_sha1_oid(&entry, &sha1));
1174        let wide = ObjectId::from_bytes(&[0x01; 32]).expect("wide");
1175        assert!(!pack_index_entry_matches_sha1_oid(&entry, &wide));
1176    }
1177
1178    #[test]
1179    fn v2_parse_rejects_inconsistent_large_offset_slot() {
1180        let oid = ObjectId::from_bytes(&[0x01; 20]).unwrap();
1181        let pack_trailer = [0u8; 20];
1182        let mut body = Vec::new();
1183        body.extend_from_slice(b"\xfftOc");
1184        body.extend_from_slice(&2u32.to_be_bytes());
1185        let fanout = compute_fanout_from_oid_slices(&[oid.as_bytes()]);
1186        for f in fanout {
1187            body.extend_from_slice(&f.to_be_bytes());
1188        }
1189        body.extend_from_slice(oid.as_bytes());
1190        body.extend_from_slice(&0u32.to_be_bytes());
1191        body.extend_from_slice(&0x8000_0001u32.to_be_bytes());
1192        body.extend_from_slice(&1000u64.to_be_bytes());
1193        body.extend_from_slice(&pack_trailer);
1194        body.extend_from_slice(&pack_trailer);
1195        parse_pack_index_bytes(Path::new("bad-large.idx"), body.clone(), false)
1196            .expect("lazy open skips large-slot assignment scan");
1197        let err = parse_pack_index_bytes(Path::new("bad-large.idx"), body, true).unwrap_err();
1198        assert!(matches!(err, Error::CorruptObject(_)));
1199    }
1200
1201    #[test]
1202    fn find_position_large_fanout_bucket_sha256_v2_no_panic() {
1203        use crate::pack::write_v2_pack_index_with_trailer;
1204        let mut rows: Vec<(ObjectId, u64, u32)> = Vec::new();
1205        for i in 0..64u8 {
1206            let mut bytes = [0u8; 32];
1207            bytes[0] = 0x07;
1208            bytes[31] = i;
1209            let oid = ObjectId::from_bytes(&bytes).expect("oid");
1210            rows.push((oid, u64::from(i) * 8 + 512, 0));
1211        }
1212        let tmp = tempfile::tempdir().expect("tempdir");
1213        let idx_path = tmp.path().join("big-v2.idx");
1214        write_v2_pack_index_with_trailer(&idx_path, &rows, &[0u8; 32], 32, None).expect("write v2");
1215        let bytes = std::fs::read(&idx_path).expect("read idx");
1216        let idx = parse_pack_index_bytes(&idx_path, bytes, false).expect("parse v2 idx");
1217        assert_eq!(idx.len(), 64);
1218        assert_eq!(idx.hash_bytes(), 32);
1219        for (oid, _, _) in &rows {
1220            assert!(idx.find_position(oid).is_some(), "missing {}", oid.to_hex());
1221        }
1222    }
1223}