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 loff = self.layout.large_off_base + slot * 8;
222        let trailer = self.layout.hash_bytes * 2;
223        if loff + 8 > self.storage.len().saturating_sub(trailer) {
224            return Err(Error::CorruptObject(format!(
225                "truncated large offset table in {}",
226                self.idx_path.display()
227            )));
228        }
229        Ok(read_u64_be_slice(&self.storage, loff))
230    }
231
232    /// CRC32 at sorted position `i` (`None` for version-1 indexes).
233    #[must_use]
234    pub fn crc32_at(&self, i: usize) -> Option<u32> {
235        debug_assert!(i < self.layout.count);
236        if self.layout.version != PackIndexVersion::V2 {
237            return None;
238        }
239        let start = self.layout.crc_base + i * 4;
240        Some(read_u32_be_slice(&self.storage, start))
241    }
242
243    /// Sorted-table position for `oid`, or `None` when absent or hash width mismatches.
244    #[must_use]
245    pub fn find_position(&self, oid: &ObjectId) -> Option<usize> {
246        let needle = oid.as_bytes();
247        if needle.len() != self.layout.hash_bytes {
248            return None;
249        }
250        let first = needle[0] as usize;
251        let lo = if first == 0 {
252            0
253        } else {
254            self.fanout[first - 1] as usize
255        };
256        let hi = self.fanout[first] as usize;
257        if lo >= hi || hi > self.layout.count {
258            return None;
259        }
260        self.find_position_in_bucket(lo, hi, needle)
261    }
262
263    /// Pack byte offset for `oid`, if present.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`Error::CorruptObject`] when the matching row encodes an invalid offset.
268    pub fn find_offset(&self, oid: &ObjectId) -> Result<Option<u64>> {
269        match self.find_position(oid) {
270            Some(i) => Ok(Some(self.offset_at(i)?)),
271            None => Ok(None),
272        }
273    }
274
275    /// Like [`Self::find_offset`] but optimized for monotonically non-decreasing `oid` probes.
276    ///
277    /// Updates `cursor` to the matching row on success. When the probe regresses (out-of-order
278    /// input), falls back to fanout + binary search and resets `cursor`.
279    pub fn find_offset_monotonic(&self, oid: &ObjectId, cursor: &mut usize) -> Result<Option<u64>> {
280        const FORWARD_SCAN_MAX: usize = 128;
281        let needle = oid.as_bytes();
282        if needle.len() != self.layout.hash_bytes {
283            return Ok(None);
284        }
285        let len = self.len();
286        if len == 0 {
287            return Ok(None);
288        }
289        if *cursor >= len {
290            *cursor = 0;
291        }
292        if *cursor < len && compare_oid_bytes(self.oid_at(*cursor), needle) == Ordering::Greater {
293            if let Some(pos) = self.find_position(oid) {
294                *cursor = pos;
295                return Ok(Some(self.offset_at(pos)?));
296            }
297            return Ok(None);
298        }
299        let mut pos = *cursor;
300        let scan_start = pos;
301        // Probes that jump past the scan window (walk order over a large pack) would compare
302        // every row in it and fault in index pages a binary search never touches.
303        let window_last = (scan_start + FORWARD_SCAN_MAX).min(len) - 1;
304        if compare_oid_bytes(self.oid_at(window_last), needle) == Ordering::Less {
305            if let Some(found) = self.find_position(oid) {
306                *cursor = found;
307                return Ok(Some(self.offset_at(found)?));
308            }
309            return Ok(None);
310        }
311        while pos < len && pos - scan_start < FORWARD_SCAN_MAX {
312            match compare_oid_bytes(self.oid_at(pos), needle) {
313                Ordering::Less => pos += 1,
314                Ordering::Equal => {
315                    *cursor = pos;
316                    return Ok(Some(self.offset_at(pos)?));
317                }
318                Ordering::Greater => return Ok(None),
319            }
320        }
321        if pos < len {
322            if let Some(found) = self.find_position(oid) {
323                *cursor = found;
324                return Ok(Some(self.offset_at(found)?));
325            }
326            return Ok(None);
327        }
328        Ok(None)
329    }
330    /// Whether this pack index contains the given OID.
331    pub fn contains(&self, oid: &ObjectId) -> Result<bool> {
332        Ok(self.find_offset(oid)?.is_some())
333    }
334
335    /// Iterate rows in OID sort order.
336    pub fn iter(&self) -> PackIndexIter<'_> {
337        PackIndexIter {
338            index: self,
339            next: 0,
340        }
341    }
342
343    /// Smallest pack offset strictly greater than `entry_offset`, capped at `pack_end`.
344    ///
345    /// Uses the cached offset-sorted table, so repeated calls cost a binary search each.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error when the index has a malformed large-offset entry.
350    pub fn next_pack_offset_after(&self, entry_offset: u64, pack_end: u64) -> Result<u64> {
351        self.with_sorted_offsets(|pairs| {
352            let next = pairs.partition_point(|p| p.0 <= entry_offset);
353            pairs
354                .get(next)
355                .map_or(pack_end, |&(off, _)| off.min(pack_end))
356        })
357    }
358
359    /// Run `f` over `(pack offset, index row)` pairs sorted by offset, building and
360    /// caching the table on first use.
361    ///
362    /// The table is decoded from the `.rev` sidecar when one is present and valid
363    /// (checksummed once, no sort), and otherwise built by sorting the index rows.
364    fn with_sorted_offsets<R>(&self, f: impl FnOnce(&[(u64, u32)]) -> R) -> Result<R> {
365        let mut guard = self.offset_lookup.lock().unwrap_or_else(|e| e.into_inner());
366        if let Some(pairs) = guard.as_deref() {
367            return Ok(f(pairs));
368        }
369        let pairs = match self.sorted_offsets_from_rev()? {
370            Some(pairs) => pairs,
371            None => {
372                let mut pairs: Vec<(u64, u32)> = Vec::with_capacity(self.len());
373                for i in 0..self.len() {
374                    pairs.push((self.offset_at(i)?, i as u32));
375                }
376                pairs.sort_unstable_by_key(|p| p.0);
377                pairs
378            }
379        };
380        Ok(f(guard.insert(pairs)))
381    }
382
383    /// Offset-sorted `(pack offset, index row)` pairs from the `.rev` sidecar.
384    ///
385    /// Returns `None` when there is no sidecar, it fails validation, or its order
386    /// does not match the offsets in this index.
387    fn sorted_offsets_from_rev(&self) -> Result<Option<Vec<(u64, u32)>>> {
388        let rev_path = crate::pack_rev::rev_path_for_index(&self.idx_path);
389        let Ok(data) = PackData::open_with_min_len(&rev_path, 12) else {
390            return Ok(None);
391        };
392        let Some(rows) = crate::pack_rev::try_rev_positions_in_pack_order(&data, self.len()) else {
393            return Ok(None);
394        };
395        let mut pairs: Vec<(u64, u32)> = Vec::with_capacity(rows.len());
396        for row in rows {
397            let offset = self.offset_at(row as usize)?;
398            if pairs.last().is_some_and(|&(prev, _)| prev >= offset) {
399                return Ok(None);
400            }
401            pairs.push((offset, row));
402        }
403        Ok(Some(pairs))
404    }
405
406    /// CRC32 for the index row at `entry_offset`, if any.
407    pub fn crc32_for_pack_offset(&self, entry_offset: u64) -> Result<Option<u32>> {
408        Ok(match self.find_position_by_offset_sorted(entry_offset)? {
409            Some(i) => self.crc32_at(i),
410            None => None,
411        })
412    }
413
414    /// Copy with promisor/cruft sidecar flags refreshed from disk markers.
415    #[must_use]
416    pub(crate) fn with_sidecar_flags(&self, is_promisor: bool, is_cruft: bool) -> Self {
417        let mut next = self.clone();
418        next.is_promisor = is_promisor;
419        next.is_cruft = is_cruft;
420        next
421    }
422
423    /// Index row position of the object stored at pack byte `offset`.
424    ///
425    /// Uses an offset-sorted table that is built once per index (from the `.rev`
426    /// sidecar when present) and shared by clones, so each lookup is a binary search.
427    ///
428    /// Returns `Ok(None)` when no object starts at `offset`.
429    ///
430    /// # Errors
431    ///
432    /// Returns [`Error::CorruptObject`] when the index has a malformed large-offset entry.
433    pub fn find_position_by_offset_sorted(&self, offset: u64) -> Result<Option<usize>> {
434        self.with_sorted_offsets(|pairs| {
435            pairs
436                .binary_search_by_key(&offset, |p| p.0)
437                .ok()
438                .map(|i| pairs[i].1 as usize)
439        })
440    }
441
442    fn find_position_in_bucket(
443        &self,
444        mut lo: usize,
445        mut hi: usize,
446        needle: &[u8],
447    ) -> Option<usize> {
448        if hi - lo < 64 {
449            return self.binary_search(lo, hi, needle);
450        }
451
452        // Hashes normally spread evenly within a fanout bucket. Predict a row from the
453        // next three bytes without touching either end of the mapped OID table: those
454        // extra probes otherwise fault in unrelated pages on short, scattered reads.
455        let fraction = u32::from_be_bytes([0, needle[1], needle[2], needle[3]]);
456        let guess = lo + ((u64::from(fraction) * (hi - lo) as u64) >> 24) as usize;
457        let mut step = 1usize;
458        match compare_oid_bytes(self.oid_at(guess), needle) {
459            Ordering::Equal => return Some(guess),
460            Ordering::Less => {
461                lo = guess + 1;
462                while lo < hi {
463                    let pos = guess.saturating_add(step).min(hi - 1);
464                    match compare_oid_bytes(self.oid_at(pos), needle) {
465                        Ordering::Equal => return Some(pos),
466                        Ordering::Greater => {
467                            hi = pos;
468                            break;
469                        }
470                        Ordering::Less => lo = pos + 1,
471                    }
472                    step = step.saturating_mul(2);
473                }
474            }
475            Ordering::Greater => {
476                hi = guess;
477                while lo < hi {
478                    let pos = guess.saturating_sub(step).max(lo);
479                    match compare_oid_bytes(self.oid_at(pos), needle) {
480                        Ordering::Equal => return Some(pos),
481                        Ordering::Less => {
482                            lo = pos + 1;
483                            break;
484                        }
485                        Ordering::Greater => hi = pos,
486                    }
487                    step = step.saturating_mul(2);
488                }
489            }
490        }
491        // Exponential bracketing followed by binary search stays logarithmic even
492        // for clustered, nonuniform hashes or many OIDs sharing the same prefix.
493        self.binary_search(lo, hi, needle)
494    }
495
496    fn binary_search(&self, mut lo: usize, mut hi: usize, needle: &[u8]) -> Option<usize> {
497        while lo < hi {
498            let mid = lo + (hi - lo) / 2;
499            match compare_oid_bytes(self.oid_at(mid), needle) {
500                Ordering::Less => lo = mid + 1,
501                Ordering::Greater => hi = mid,
502                Ordering::Equal => return Some(mid),
503            }
504        }
505        None
506    }
507}
508
509/// Lexicographic compare for fixed-width object ids (hot path for pack index search).
510#[inline]
511fn compare_oid_bytes(a: &[u8], b: &[u8]) -> Ordering {
512    if a.len() == 20 && b.len() == 20 {
513        let a0 = u64::from_be_bytes([a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]]);
514        let b0 = u64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]);
515        if a0 != b0 {
516            return a0.cmp(&b0);
517        }
518        let a1 = u64::from_be_bytes([a[8], a[9], a[10], a[11], a[12], a[13], a[14], a[15]]);
519        let b1 = u64::from_be_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]);
520        if a1 != b1 {
521            return a1.cmp(&b1);
522        }
523        let a2 = u32::from_be_bytes([a[16], a[17], a[18], a[19]]);
524        let b2 = u32::from_be_bytes([b[16], b[17], b[18], b[19]]);
525        return a2.cmp(&b2);
526    }
527    a.cmp(b)
528}
529
530/// Leading OID bytes for interpolation probing (8 bytes — fits in `u64` without overflow).
531const OID_INTERP_PREFIX_LEN: usize = 8;
532
533pub(crate) fn oid_interp_prefix_u64(oid: &[u8]) -> u64 {
534    let take = oid.len().min(OID_INTERP_PREFIX_LEN);
535    let mut v = 0u64;
536    for &b in &oid[..take] {
537        v = (v << 8) | u64::from(b);
538    }
539    for _ in take..OID_INTERP_PREFIX_LEN {
540        v <<= 8;
541    }
542    v
543}
544
545/// True when `entry` stores a SHA-1 OID matching `oid` (SHA-256 pack entries are ignored).
546#[must_use]
547pub fn pack_index_entry_matches_sha1_oid(entry: &PackIndexEntryRef<'_>, oid: &ObjectId) -> bool {
548    entry.oid().len() == 20 && entry.oid() == oid.as_bytes()
549}
550
551/// Compute fanout from a sorted OID list (used when writing v1 indexes in tests).
552#[must_use]
553pub fn compute_fanout_from_oid_slices(entries: &[&[u8]]) -> [u32; 256] {
554    let mut fanout = [0u32; 256];
555    let mut idx = 0usize;
556    for byte in 0u32..256 {
557        let needle = byte as u8;
558        while idx < entries.len() && entries[idx].first().copied().unwrap_or(0) <= needle {
559            idx += 1;
560        }
561        fanout[byte as usize] = u32::try_from(idx).unwrap_or(u32::MAX);
562    }
563    fanout
564}
565
566/// Compute the 256-entry fanout from owned entries (legacy helper for tests).
567#[must_use]
568pub fn compute_fanout_from_entries(entries: &[PackIndexEntry]) -> [u32; 256] {
569    let slices: Vec<&[u8]> = entries.iter().map(|e| e.oid.as_slice()).collect();
570    compute_fanout_from_oid_slices(&slices)
571}
572
573/// Parse pack index bytes (v1 or v2). When `verify` is true, check the trailing checksum.
574pub fn parse_pack_index_bytes(idx_path: &Path, bytes: Vec<u8>, verify: bool) -> Result<PackIndex> {
575    if bytes.len() < 8 {
576        return Err(Error::CorruptObject(format!(
577            "index file {} is too small",
578            idx_path.display()
579        )));
580    }
581    let storage = PackIndexStorage(PackData::from_owned(bytes));
582    parse_pack_index_storage(idx_path, &storage, verify)
583}
584
585/// Memory-map or read a pack index from disk and parse it (lazy structural checks when `verify` is false).
586///
587/// # Errors
588///
589/// Returns [`Error::Io`] or [`Error::CorruptObject`] on failure, and
590/// [`Error::PackIndexChecksumMismatch`] when `verify` is set and the trailer is wrong.
591pub fn open_pack_index_file(idx_path: &Path, verify: bool) -> Result<PackIndex> {
592    let storage = PackIndexStorage(PackData::open_with_min_len(idx_path, IDX_MIN_LEN)?);
593    parse_pack_index_storage(idx_path, &storage, verify)
594}
595
596fn parse_pack_index_storage(
597    idx_path: &Path,
598    storage: &PackIndexStorage,
599    verify: bool,
600) -> Result<PackIndex> {
601    if storage.len() < 8 {
602        return Err(Error::CorruptObject(format!(
603            "index file {} is too small",
604            idx_path.display()
605        )));
606    }
607    let magic = &storage[0..4];
608    if magic == [0xff, b't', b'O', b'c'] {
609        read_pack_index_v2(idx_path, storage, verify)
610    } else {
611        read_pack_index_v1(idx_path, storage, verify)
612    }
613}
614
615fn read_pack_index_v1(
616    idx_path: &Path,
617    storage: &PackIndexStorage,
618    verify: bool,
619) -> Result<PackIndex> {
620    let bytes = storage.deref();
621    let mut pos = 0usize;
622    if bytes.len() < 256 * 4 + 20 {
623        return Err(Error::CorruptObject(format!(
624            "index file {} is too small",
625            idx_path.display()
626        )));
627    }
628    let mut fanout = [0u32; 256];
629    for slot in &mut fanout {
630        *slot = read_u32_be(bytes, &mut pos)?;
631    }
632    check_fanout_monotonic(&fanout, idx_path)?;
633    let object_count = fanout[255] as usize;
634    let record_size = 4 + 20;
635    let need = pos
636        .saturating_add(object_count.saturating_mul(record_size))
637        .saturating_add(20);
638    if bytes.len() < need {
639        return Err(Error::CorruptObject(format!(
640            "truncated idx file {}",
641            idx_path.display()
642        )));
643    }
644    let oid_base = pos;
645    if verify {
646        for i in 0..object_count {
647            let off_start = oid_base + i * record_size;
648            let oid_start = off_start + 4;
649            if i > 0 {
650                let prev_start = oid_base + (i - 1) * record_size + 4;
651                if bytes[prev_start..prev_start + 20] > bytes[oid_start..oid_start + 20] {
652                    return Err(Error::CorruptObject(format!(
653                        "oid lookup out of order in {}",
654                        idx_path.display()
655                    )));
656                }
657            }
658        }
659    }
660    if verify {
661        verify_idx_trailing_checksum(idx_path, bytes, 20)?;
662    }
663    let mut pack_path = idx_path.to_path_buf();
664    pack_path.set_extension("pack");
665    Ok(PackIndex {
666        idx_path: idx_path.to_path_buf(),
667        pack_path,
668        storage: storage.clone(),
669        layout: PackIndexLayout {
670            version: PackIndexVersion::V1,
671            hash_bytes: 20,
672            count: object_count,
673            oid_base,
674            crc_base: 0,
675            off32_base: 0,
676            large_off_base: 0,
677            large_off_count: 0,
678        },
679        fanout,
680        is_promisor: false,
681        is_cruft: false,
682        offset_lookup: Arc::new(Mutex::new(None)),
683    })
684}
685
686fn read_pack_index_v2(
687    idx_path: &Path,
688    storage: &PackIndexStorage,
689    verify: bool,
690) -> Result<PackIndex> {
691    let bytes = storage.deref();
692    if bytes.len() < 8 + 256 * 4 + 40 {
693        return Err(Error::CorruptObject(format!(
694            "index file {} is too small",
695            idx_path.display()
696        )));
697    }
698    let mut pos = 4usize;
699    let version = read_u32_be(bytes, &mut pos)?;
700    if version != 2 {
701        return Err(Error::CorruptObject(format!(
702            "unsupported idx version {version} in {}",
703            idx_path.display()
704        )));
705    }
706    let mut fanout = [0u32; 256];
707    for slot in &mut fanout {
708        *slot = read_u32_be(bytes, &mut pos)?;
709    }
710    check_fanout_monotonic(&fanout, idx_path)?;
711    let object_count = fanout[255] as usize;
712    let idx_file_len = bytes.len();
713    let hash_bytes = detect_idx_hash_bytes_v2(idx_file_len, pos, object_count, idx_path)?;
714    let oid_base = pos;
715    let crc_base = oid_base + object_count * hash_bytes;
716    let off32_base = crc_base + object_count * 4;
717    let need = off32_base + object_count * 4;
718    if bytes.len() < need {
719        return Err(Error::CorruptObject(format!(
720            "truncated idx file {}",
721            idx_path.display()
722        )));
723    }
724    let large_off_base = off32_base + object_count * 4;
725    let large_off_count = if verify {
726        pos = off32_base;
727        let mut count = 0usize;
728        let mut next_large = 0usize;
729        for i in 0..object_count {
730            let v = read_u32_be(bytes, &mut pos)?;
731            if (v & 0x8000_0000) != 0 {
732                let slot = (v & 0x7fff_ffff) as usize;
733                if slot != next_large {
734                    return Err(Error::CorruptObject(format!(
735                        "inconsistent 64b offset index at entry {i} in {}",
736                        idx_path.display()
737                    )));
738                }
739                next_large += 1;
740                count += 1;
741            }
742        }
743        count
744    } else {
745        let tail = hash_bytes * 2;
746        if bytes.len() < large_off_base + tail {
747            return Err(Error::CorruptObject(format!(
748                "truncated idx file {}",
749                idx_path.display()
750            )));
751        }
752        (bytes.len() - large_off_base - tail) / 8
753    };
754    if bytes.len() < large_off_base + large_off_count * 8 + 2 * hash_bytes {
755        return Err(Error::CorruptObject(format!(
756            "truncated large offset table in {}",
757            idx_path.display()
758        )));
759    }
760    if verify {
761        for i in 0..object_count {
762            let start = oid_base + i * hash_bytes;
763            if i > 0 {
764                let prev = oid_base + (i - 1) * hash_bytes;
765                if bytes[prev..prev + hash_bytes] > bytes[start..start + hash_bytes] {
766                    return Err(Error::CorruptObject(format!(
767                        "oid lookup out of order in {}",
768                        idx_path.display()
769                    )));
770                }
771            }
772        }
773    }
774    if verify {
775        verify_idx_trailing_checksum(idx_path, bytes, hash_bytes)?;
776    }
777    let mut pack_path = idx_path.to_path_buf();
778    pack_path.set_extension("pack");
779    Ok(PackIndex {
780        idx_path: idx_path.to_path_buf(),
781        pack_path,
782        storage: storage.clone(),
783        layout: PackIndexLayout {
784            version: PackIndexVersion::V2,
785            hash_bytes,
786            count: object_count,
787            oid_base,
788            crc_base,
789            off32_base,
790            large_off_base,
791            large_off_count,
792        },
793        fanout,
794        is_promisor: false,
795        is_cruft: false,
796        offset_lookup: Arc::new(Mutex::new(None)),
797    })
798}
799
800fn verify_idx_trailing_checksum(idx_path: &Path, bytes: &[u8], hash_bytes: usize) -> Result<()> {
801    let Some(algo) = HashAlgo::from_len(hash_bytes) else {
802        return Err(Error::CorruptObject(format!(
803            "unsupported index hash width {hash_bytes}"
804        )));
805    };
806    verify_trailer(algo, bytes).map_err(|mismatch| Error::PackIndexChecksumMismatch {
807        path: idx_path.to_path_buf(),
808        mismatch,
809    })
810}
811
812fn check_fanout_monotonic(fanout: &[u32; 256], idx_path: &Path) -> Result<()> {
813    let mut prev = 0u32;
814    for &n in fanout {
815        if n < prev {
816            return Err(Error::CorruptObject(format!(
817                "non-monotonic index {}",
818                idx_path.display()
819            )));
820        }
821        prev = n;
822    }
823    Ok(())
824}
825
826fn detect_idx_hash_bytes_v2(
827    idx_file_len: usize,
828    fanout_end: usize,
829    object_count: usize,
830    idx_path: &Path,
831) -> Result<usize> {
832    if object_count == 0 {
833        let sha1_len = fanout_end.saturating_add(2 * 20);
834        let sha256_len = fanout_end.saturating_add(2 * 32);
835        return match idx_file_len {
836            n if n == sha1_len => Ok(20),
837            n if n == sha256_len => Ok(32),
838            _ => Err(Error::CorruptObject(format!(
839                "wrong index v2 file size in {}",
840                idx_path.display()
841            ))),
842        };
843    }
844    for &hb in &[20usize, 32] {
845        let fixed = fanout_end
846            .saturating_add(object_count.saturating_mul(hb + 4 + 4))
847            .saturating_add(2 * hb);
848        if idx_file_len < fixed {
849            continue;
850        }
851        let extra = idx_file_len - fixed;
852        if !extra.is_multiple_of(8) {
853            continue;
854        }
855        if extra / 8 > object_count {
856            continue;
857        }
858        return Ok(hb);
859    }
860    Err(Error::CorruptObject(format!(
861        "wrong index v2 file size in {}",
862        idx_path.display()
863    )))
864}
865
866fn read_u32_be_slice(bytes: &[u8], start: usize) -> u32 {
867    u32::from_be_bytes([
868        bytes[start],
869        bytes[start + 1],
870        bytes[start + 2],
871        bytes[start + 3],
872    ])
873}
874
875fn read_u64_be_slice(bytes: &[u8], start: usize) -> u64 {
876    u64::from_be_bytes([
877        bytes[start],
878        bytes[start + 1],
879        bytes[start + 2],
880        bytes[start + 3],
881        bytes[start + 4],
882        bytes[start + 5],
883        bytes[start + 6],
884        bytes[start + 7],
885    ])
886}
887
888fn read_u32_be(bytes: &[u8], pos: &mut usize) -> Result<u32> {
889    if bytes.len() < *pos + 4 {
890        return Err(Error::CorruptObject(
891            "unexpected end of idx while reading u32".to_owned(),
892        ));
893    }
894    let v = u32::from_be_bytes(
895        bytes[*pos..*pos + 4]
896            .try_into()
897            .map_err(|_| Error::CorruptObject("failed to parse u32".to_owned()))?,
898    );
899    *pos += 4;
900    Ok(v)
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use crate::objects::ObjectId;
907
908    #[test]
909    fn pack_index_api_coverage_smoke() {
910        let oid_a = vec![0x01u8; 20];
911        let oid_b = vec![0x02u8; 20];
912        let fanout = compute_fanout_from_oid_slices(&[&oid_a, &oid_b]);
913        assert_eq!(fanout[1], 1);
914        assert_eq!(fanout[2], 2);
915    }
916
917    #[test]
918    fn find_position_empty_bucket() {
919        let entries = vec![PackIndexEntry {
920            oid: vec![0x05; 20],
921            offset: 1,
922            crc32: None,
923        }];
924        let fanout = compute_fanout_from_entries(&entries);
925        let idx = PackIndex {
926            idx_path: PathBuf::from("t.idx"),
927            pack_path: PathBuf::from("t.pack"),
928            storage: PackIndexStorage(PackData::from_owned(Vec::new())),
929            layout: PackIndexLayout {
930                version: PackIndexVersion::V1,
931                hash_bytes: 20,
932                count: 0,
933                oid_base: 0,
934                crc_base: 0,
935                off32_base: 0,
936                large_off_base: 0,
937                large_off_count: 0,
938            },
939            fanout,
940            is_promisor: false,
941            is_cruft: false,
942            offset_lookup: Arc::new(Mutex::new(None)),
943        };
944        let miss = ObjectId::from_bytes(&[0x06; 20]).unwrap();
945        assert!(idx.find_position(&miss).is_none());
946        let _ = idx.len();
947        let _ = idx.is_empty();
948        let _ = idx.hash_bytes();
949        let _ = idx.iter().next();
950    }
951
952    fn build_v1_idx_bytes(entries: &[(Vec<u8>, u64)]) -> Vec<u8> {
953        let mut sorted = entries.to_vec();
954        sorted.sort_by(|a, b| a.0.cmp(&b.0));
955        let slices: Vec<&[u8]> = sorted.iter().map(|(o, _)| o.as_slice()).collect();
956        let fanout = compute_fanout_from_oid_slices(&slices);
957        let mut body = Vec::new();
958        for f in fanout {
959            body.extend_from_slice(&f.to_be_bytes());
960        }
961        for (oid, off) in sorted {
962            body.extend_from_slice(&(off as u32).to_be_bytes());
963            body.extend_from_slice(&oid);
964        }
965        body.extend_from_slice(&crate::objects::HashAlgo::Sha1.checksum_digest(&body));
966        body
967    }
968
969    #[test]
970    fn find_position_large_fanout_bucket_sha1_v1_no_panic() {
971        let mut rows = Vec::new();
972        for i in 0..64u8 {
973            let mut oid = vec![0x05u8; 20];
974            oid[19] = i;
975            rows.push((oid, u64::from(i) * 12 + 100));
976        }
977        let body = build_v1_idx_bytes(&rows);
978        let idx =
979            parse_pack_index_bytes(Path::new("big-v1.idx"), body, false).expect("parse v1 idx");
980        assert_eq!(idx.len(), 64);
981        for (oid, _) in &rows {
982            let id = ObjectId::from_bytes(oid).expect("oid");
983            assert!(
984                idx.find_position(&id).is_some(),
985                "missing {}",
986                hex::encode(oid)
987            );
988        }
989        let miss = ObjectId::from_bytes(&[0x06; 20]).unwrap();
990        assert!(idx.find_position(&miss).is_none());
991    }
992
993    #[test]
994    fn predicted_lookup_matches_sorted_oracle_across_index_layouts() {
995        for hash_bytes in [20, 32] {
996            // Include empty buckets, evenly spread hashes, and clusters at both extremes.
997            let rows: Vec<_> = [0u8, 0x55, 0xfe, 0xff]
998                .into_iter()
999                .flat_map(|first| {
1000                    (0..1024u32).map(move |i| {
1001                        let mut bytes = vec![0; hash_bytes];
1002                        bytes[..4].copy_from_slice(&i.wrapping_mul(2_654_435_761).to_be_bytes());
1003                        bytes[0] = first;
1004                        bytes[hash_bytes - 4..].copy_from_slice(&(i * 2).to_be_bytes());
1005                        (bytes, 12 + u64::from(i) * 40)
1006                    })
1007                })
1008                .collect();
1009            assert_lookup_oracle(&rows, hash_bytes);
1010        }
1011    }
1012
1013    #[test]
1014    fn predicted_lookup_handles_identical_prefixes_and_bucket_edges() {
1015        for hash_bytes in [20, 32] {
1016            for count in [0, 1, 63, 64, 65, 1025] {
1017                for prefix in [0, 0xff] {
1018                    let rows: Vec<_> = (0..count)
1019                        .map(|i: u32| {
1020                            let mut bytes = vec![prefix; hash_bytes];
1021                            bytes[hash_bytes - 4..].copy_from_slice(&(2 * i + 2).to_be_bytes());
1022                            (bytes, 12 + u64::from(i) * 40)
1023                        })
1024                        .collect();
1025                    assert_lookup_oracle(&rows, hash_bytes);
1026                }
1027            }
1028        }
1029    }
1030
1031    fn assert_lookup_oracle(rows: &[(Vec<u8>, u64)], hash_bytes: usize) {
1032        let tmp = tempfile::tempdir().expect("tempdir");
1033        let path = tmp.path().join("lookup.idx");
1034        let entries: Vec<_> = rows
1035            .iter()
1036            .map(|(bytes, offset)| (ObjectId::from_bytes(bytes).expect("oid"), *offset, 0))
1037            .collect();
1038        crate::pack::write_v2_pack_index_with_trailer(
1039            &path,
1040            &entries,
1041            &vec![0; hash_bytes],
1042            hash_bytes,
1043            None,
1044        )
1045        .expect("write v2");
1046        let mut indexes = vec![open_pack_index_file(&path, true).expect("v2")];
1047        if hash_bytes == 20 {
1048            indexes
1049                .push(parse_pack_index_bytes(&path, build_v1_idx_bytes(rows), true).expect("v1"));
1050        }
1051        let mut sorted: Vec<_> = entries.iter().map(|(oid, _, _)| *oid).collect();
1052        sorted.sort();
1053        let probes = sorted.iter().flat_map(|oid| {
1054            let mut missing = oid.as_bytes().to_vec();
1055            missing[hash_bytes - 1] ^= 1;
1056            [*oid, ObjectId::from_bytes(&missing).expect("nearby oid")]
1057        });
1058        let edges = (0..=255).flat_map(|first| {
1059            let mut low = vec![0; hash_bytes];
1060            low[0] = first;
1061            let mut high = vec![0xff; hash_bytes];
1062            high[0] = first;
1063            [
1064                ObjectId::from_bytes(&low).expect("low oid"),
1065                ObjectId::from_bytes(&high).expect("high oid"),
1066            ]
1067        });
1068        for probe in probes.chain(edges) {
1069            let expected = sorted.binary_search(&probe).ok();
1070            for index in &indexes {
1071                assert_eq!(index.find_position(&probe), expected, "probe {probe}");
1072            }
1073        }
1074    }
1075
1076    #[test]
1077    fn find_offset_monotonic_matches_find_offset_for_any_probe_order() {
1078        // Enough rows that probes jump well past the forward-scan window.
1079        let rows: Vec<(Vec<u8>, u64)> = (0..1200u32)
1080            .map(|i| {
1081                let mut oid = vec![0u8; 20];
1082                oid[..4].copy_from_slice(&i.wrapping_mul(2_654_435_761).to_be_bytes());
1083                oid[19] = 1;
1084                (oid, 12 + u64::from(i) * 40)
1085            })
1086            .collect();
1087        let idx = parse_pack_index_bytes(Path::new("mono.idx"), build_v1_idx_bytes(&rows), false)
1088            .expect("parse v1 idx");
1089        let mut sorted: Vec<ObjectId> = rows
1090            .iter()
1091            .map(|(oid, _)| ObjectId::from_bytes(oid).expect("oid"))
1092            .collect();
1093        sorted.sort();
1094        let missing = |oid: &ObjectId| {
1095            let mut bytes = oid.as_bytes().to_vec();
1096            bytes[19] = 2;
1097            ObjectId::from_bytes(&bytes).expect("oid")
1098        };
1099        let mut probes: Vec<ObjectId> = Vec::new();
1100        probes.extend(sorted.iter().copied());
1101        probes.extend(sorted.iter().step_by(300).copied());
1102        probes.extend(sorted.iter().rev().step_by(7).copied());
1103        probes.extend(sorted.iter().step_by(11).map(missing));
1104        probes.extend(
1105            rows.iter()
1106                .map(|(oid, _)| ObjectId::from_bytes(oid).expect("oid")),
1107        );
1108        let mut cursor = 0usize;
1109        for probe in &probes {
1110            assert_eq!(
1111                idx.find_offset_monotonic(probe, &mut cursor)
1112                    .expect("monotonic"),
1113                idx.find_offset(probe).expect("binary search"),
1114                "probe {probe}"
1115            );
1116        }
1117
1118        let empty = parse_pack_index_bytes(Path::new("empty.idx"), build_v1_idx_bytes(&[]), false)
1119            .expect("parse empty idx");
1120        let mut cursor = 5usize;
1121        assert_eq!(
1122            empty
1123                .find_offset_monotonic(&sorted[0], &mut cursor)
1124                .expect("empty"),
1125            None
1126        );
1127    }
1128
1129    #[test]
1130    fn next_pack_offset_after_matches_linear_scan() {
1131        // Offsets deliberately not in OID order, as in real packs.
1132        let rows: Vec<(Vec<u8>, u64)> = (0..50u8)
1133            .map(|i| {
1134                let mut oid = vec![i; 20];
1135                oid[0] = i.wrapping_mul(37);
1136                (oid, 12 + (u64::from(i) * 7 % 50) * 30)
1137            })
1138            .collect();
1139        let idx = parse_pack_index_bytes(Path::new("order.idx"), build_v1_idx_bytes(&rows), false)
1140            .expect("parse v1 idx");
1141        let offsets: Vec<u64> = rows.iter().map(|r| r.1).collect();
1142        let pack_end = 12 + 50 * 30 - 10;
1143        for probe in (0..pack_end + 40).step_by(5) {
1144            let expected = offsets
1145                .iter()
1146                .copied()
1147                .filter(|&o| o > probe && o <= pack_end)
1148                .min()
1149                .unwrap_or(pack_end);
1150            assert_eq!(
1151                idx.next_pack_offset_after(probe, pack_end).expect("next"),
1152                expected,
1153                "probe {probe}"
1154            );
1155        }
1156        for (i, &off) in offsets.iter().enumerate() {
1157            let pos = idx.find_position_by_offset_sorted(off).expect("lookup");
1158            let pos = pos.expect("every row offset resolves");
1159            assert_eq!(idx.offset_at(pos).expect("offset"), off, "row {i}");
1160        }
1161        assert_eq!(idx.find_position_by_offset_sorted(13).expect("miss"), None);
1162    }
1163
1164    #[test]
1165    fn detect_empty_v2_index_hash_width_from_file_size() {
1166        let fanout_end = 8 + 256 * 4;
1167        assert_eq!(
1168            detect_idx_hash_bytes_v2(1072, fanout_end, 0, Path::new("t.idx")).expect("sha1"),
1169            20
1170        );
1171        assert_eq!(
1172            detect_idx_hash_bytes_v2(1096, fanout_end, 0, Path::new("t.idx")).expect("sha256"),
1173            32
1174        );
1175        assert!(detect_idx_hash_bytes_v2(1073, fanout_end, 0, Path::new("t.idx")).is_err());
1176    }
1177
1178    #[test]
1179    fn duplicate_oid_rows_parse_and_resolve_first_offset() {
1180        use crate::pack::write_v2_pack_index_with_trailer;
1181        use std::fs;
1182
1183        let oid = ObjectId::from_bytes(&[0x05; 20]).unwrap();
1184        let dir = tempfile::tempdir().expect("tempdir");
1185        let idx_path = dir.path().join("dup-oid.idx");
1186        let pack_path = dir.path().join("dup-oid.pack");
1187        fs::write(&pack_path, b"PACK\x00\x00\x00\x02\x00\x00\x00\x00").expect("pack stub");
1188        write_v2_pack_index_with_trailer(
1189            &idx_path,
1190            &[(oid, 100, 0), (oid, 200, 0)],
1191            &[0u8; 20],
1192            20,
1193            None,
1194        )
1195        .expect("write dup rows");
1196        let mut idx =
1197            parse_pack_index_bytes(&idx_path, fs::read(&idx_path).unwrap(), false).expect("parse");
1198        idx.pack_path = pack_path;
1199        assert_eq!(idx.len(), 2);
1200        assert_eq!(idx.offset_at(0).expect("off0"), 100);
1201        assert_eq!(idx.offset_at(1).expect("off1"), 200);
1202        assert_eq!(idx.find_offset(&oid).expect("lookup"), Some(200));
1203    }
1204
1205    #[test]
1206    fn verified_open_reports_idx_trailer_mismatch_as_typed_error() {
1207        use crate::pack::write_v2_pack_index_with_trailer;
1208        use std::fs;
1209
1210        let oid = ObjectId::from_bytes(&[0x07; 20]).unwrap();
1211        let dir = tempfile::tempdir().expect("tempdir");
1212        let idx_path = dir.path().join("trailer.idx");
1213        write_v2_pack_index_with_trailer(&idx_path, &[(oid, 12, 0)], &[0u8; 20], 20, None)
1214            .expect("write idx");
1215        open_pack_index_file(&idx_path, true).expect("intact trailer verifies");
1216
1217        let mut bytes = fs::read(&idx_path).unwrap();
1218        let last = bytes.len() - 1;
1219        bytes[last] ^= 0xff;
1220        fs::write(&idx_path, &bytes).unwrap();
1221
1222        open_pack_index_file(&idx_path, false).expect("unverified open ignores the trailer");
1223        let err = open_pack_index_file(&idx_path, true).expect_err("bad trailer");
1224        let message = err.to_string();
1225        assert!(
1226            message.starts_with("corrupt object: index checksum mismatch for "),
1227            "{message}"
1228        );
1229        let Error::PackIndexChecksumMismatch { path, mismatch } = err else {
1230            panic!("expected PackIndexChecksumMismatch: {message}");
1231        };
1232        assert_eq!(path, idx_path);
1233        assert_eq!(mismatch.found, bytes[bytes.len() - 20..]);
1234        assert_ne!(mismatch.expected, mismatch.found);
1235    }
1236
1237    #[test]
1238    fn find_position_rejects_mismatched_hash_width() {
1239        let body = build_v1_idx_bytes(&[(vec![0x01; 20], 12)]);
1240        let idx = parse_pack_index_bytes(Path::new("w.idx"), body, false).expect("idx");
1241        let wide = ObjectId::from_bytes(&[0x01; 32]).expect("wide");
1242        assert!(idx.find_position(&wide).is_none());
1243    }
1244
1245    #[test]
1246    fn pack_index_entry_matches_sha1_oid_rejects_wide_rows() {
1247        let body = build_v1_idx_bytes(&[(vec![0x01; 20], 12)]);
1248        let idx = parse_pack_index_bytes(Path::new("w.idx"), body, false).expect("idx");
1249        let entry = idx.iter().next().expect("row");
1250        let sha1 = ObjectId::from_bytes(&[0x01; 20]).expect("sha1");
1251        assert!(pack_index_entry_matches_sha1_oid(&entry, &sha1));
1252        let wide = ObjectId::from_bytes(&[0x01; 32]).expect("wide");
1253        assert!(!pack_index_entry_matches_sha1_oid(&entry, &wide));
1254    }
1255
1256    #[test]
1257    fn v2_parse_rejects_inconsistent_large_offset_slot() {
1258        let oid = ObjectId::from_bytes(&[0x01; 20]).unwrap();
1259        let pack_trailer = [0u8; 20];
1260        let mut body = Vec::new();
1261        body.extend_from_slice(b"\xfftOc");
1262        body.extend_from_slice(&2u32.to_be_bytes());
1263        let fanout = compute_fanout_from_oid_slices(&[oid.as_bytes()]);
1264        for f in fanout {
1265            body.extend_from_slice(&f.to_be_bytes());
1266        }
1267        body.extend_from_slice(oid.as_bytes());
1268        body.extend_from_slice(&0u32.to_be_bytes());
1269        body.extend_from_slice(&0x8000_0001u32.to_be_bytes());
1270        body.extend_from_slice(&1000u64.to_be_bytes());
1271        body.extend_from_slice(&pack_trailer);
1272        body.extend_from_slice(&pack_trailer);
1273        parse_pack_index_bytes(Path::new("bad-large.idx"), body.clone(), false)
1274            .expect("lazy open skips large-slot assignment scan");
1275        let err = parse_pack_index_bytes(Path::new("bad-large.idx"), body, true).unwrap_err();
1276        assert!(matches!(err, Error::CorruptObject(_)));
1277    }
1278
1279    #[test]
1280    fn v2_large_offset_table_direct_index_many_rows() {
1281        use crate::pack::write_v2_pack_index_with_trailer;
1282        use std::time::Instant;
1283
1284        const N: u32 = 20_000;
1285        let mut rows: Vec<(ObjectId, u64, u32)> = Vec::with_capacity(N as usize);
1286        for i in 0..N {
1287            let mut bytes = [0u8; 20];
1288            bytes[0] = 0x07;
1289            bytes[1..5].copy_from_slice(&i.to_be_bytes());
1290            let oid = ObjectId::from_bytes(&bytes).expect("oid");
1291            let offset = (1u64 << 31) + u64::from(i) * 64;
1292            rows.push((oid, offset, 0));
1293        }
1294        let last_offset = rows.last().expect("row").1;
1295        let tmp = tempfile::tempdir().expect("tempdir");
1296        let idx_path = tmp.path().join("many-large.idx");
1297        write_v2_pack_index_with_trailer(&idx_path, &rows, &[0u8; 20], 20, None).expect("write");
1298        let bytes = std::fs::read(&idx_path).expect("read");
1299        let lazy = parse_pack_index_bytes(&idx_path, bytes.clone(), false).expect("lazy");
1300        let last_row = (N - 1) as usize;
1301        assert_eq!(lazy.offset_at(last_row).expect("last"), last_offset);
1302        assert_eq!(lazy.offset_at(0).expect("first"), (1u64 << 31));
1303
1304        let t0 = Instant::now();
1305        for _ in 0..50 {
1306            let _ = lazy.offset_at(last_row).expect("last");
1307        }
1308        let last_elapsed = t0.elapsed();
1309        let t1 = Instant::now();
1310        for _ in 0..50 {
1311            let _ = lazy.offset_at(0).expect("first");
1312        }
1313        let first_elapsed = t1.elapsed();
1314        assert!(
1315            last_elapsed <= first_elapsed * 10,
1316            "last row lookup {last_elapsed:?} vs first {first_elapsed:?}"
1317        );
1318
1319        let mut swapped = bytes;
1320        let off32_base = 8 + 256 * 4 + 20 * (N as usize) + 4 * (N as usize);
1321        let second_off32 = off32_base + 4;
1322        swapped[second_off32..second_off32 + 4].copy_from_slice(&0x8000_0000u32.to_be_bytes());
1323        let lazy_swap =
1324            parse_pack_index_bytes(&idx_path, swapped, false).expect("lazy swapped slot");
1325        assert_eq!(lazy_swap.offset_at(1).expect("swapped"), 1u64 << 31);
1326        let mut swapped_for_verify = std::fs::read(&idx_path).expect("read");
1327        swapped_for_verify[second_off32..second_off32 + 4]
1328            .copy_from_slice(&0x8000_0000u32.to_be_bytes());
1329        let err = parse_pack_index_bytes(&idx_path, swapped_for_verify, true).expect_err("verify");
1330        assert!(matches!(err, Error::CorruptObject(_)));
1331    }
1332
1333    #[test]
1334    fn find_position_large_fanout_bucket_sha256_v2_no_panic() {
1335        use crate::pack::write_v2_pack_index_with_trailer;
1336        let mut rows: Vec<(ObjectId, u64, u32)> = Vec::new();
1337        for i in 0..64u8 {
1338            let mut bytes = [0u8; 32];
1339            bytes[0] = 0x07;
1340            bytes[31] = i;
1341            let oid = ObjectId::from_bytes(&bytes).expect("oid");
1342            rows.push((oid, u64::from(i) * 8 + 512, 0));
1343        }
1344        let tmp = tempfile::tempdir().expect("tempdir");
1345        let idx_path = tmp.path().join("big-v2.idx");
1346        write_v2_pack_index_with_trailer(&idx_path, &rows, &[0u8; 32], 32, None).expect("write v2");
1347        let bytes = std::fs::read(&idx_path).expect("read idx");
1348        let idx = parse_pack_index_bytes(&idx_path, bytes, false).expect("parse v2 idx");
1349        assert_eq!(idx.len(), 64);
1350        assert_eq!(idx.hash_bytes(), 32);
1351        for (oid, _, _) in &rows {
1352            assert!(idx.find_position(oid).is_some(), "missing {}", oid.to_hex());
1353        }
1354    }
1355}