Skip to main content

heddle_pack/store/pack/
pack_reader.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pack reader for extracting objects from packfiles.
3
4#[cfg(test)]
5use std::sync::atomic::{AtomicUsize, Ordering};
6use std::{
7    collections::{BTreeSet, HashMap, HashSet},
8    fs::File,
9    io::Read,
10    path::Path,
11};
12
13use bytes::Bytes;
14use heddle_format::delta::{DeltaDecoder, MAX_DELTA_OUTPUT_SIZE};
15
16use super::{
17    ObjectType, PackObjectId, PackObjectRecord, append_container_checksum,
18    decode_tagged_entry_header, decompress_pack_payload, has_zstd_magic, pack_container_spec,
19    pack_index::PackIndex, varint, verify_container, verify_container_layout,
20    write_container_header,
21};
22use crate::{
23    object::ContentHash,
24    store::{Result, StoreError},
25};
26
27const MAX_PACK_DELTA_OUTPUT_SIZE: usize = MAX_DELTA_OUTPUT_SIZE;
28const MAX_DELTA_CHAIN_DEPTH: usize = 50;
29const MMAP_THRESHOLD_BYTES: u64 = 256 * 1024;
30
31type DecodedCompactObject = (PackObjectId, ObjectType, Vec<u8>);
32type DecodedCompactObjects = Vec<DecodedCompactObject>;
33
34/// Physical read tier for an indexed pack object.
35///
36/// Hot records have one independently addressable record per logical object.
37/// Solid-frame records share one payload across several logical objects and
38/// therefore require a frame decompression on a cache-cold read.
39#[derive(Debug, Clone, Copy, Eq, PartialEq)]
40pub enum PackReadTier {
41    /// Independently addressable, random-access record.
42    Hot,
43    /// Compact payload shared by several tree or state ids.
44    SolidFrame,
45}
46
47fn read_file_bytes_for_pack(path: &Path) -> Result<Bytes> {
48    let file = File::open(path)?;
49    let len = file.metadata()?.len();
50    if len == 0 {
51        return Ok(Bytes::new());
52    }
53    if len >= MMAP_THRESHOLD_BYTES {
54        let mmap = unsafe { memmap2::MmapOptions::new().map(&file)? };
55        if mmap.len() != checked_file_len_to_usize(len)? {
56            return Err(StoreError::InvalidObject(
57                "pack file size changed during memory mapping".to_string(),
58            ));
59        }
60        return Ok(Bytes::from_owner(mmap));
61    }
62    let mut data = Vec::with_capacity(checked_file_len_to_usize(len)?);
63    let mut reader = file;
64    reader.read_to_end(&mut data)?;
65    Ok(Bytes::from(data))
66}
67
68fn checked_file_len_to_usize(len: u64) -> Result<usize> {
69    usize::try_from(len).map_err(|_| {
70        StoreError::InvalidObject(format!("file length {len} exceeds platform limits"))
71    })
72}
73
74/// Pack reader for extracting objects.
75///
76/// `data` is a refcounted [`Bytes`] view of the pack file. For
77/// uncompressed entries we hand back a zero-copy `Bytes::slice` into
78/// this buffer — no per-blob memcpy, no per-blob allocation. Mmap-
79/// backed `Bytes` (via [`Bytes::from_owner`] on the
80/// `memmap2::Mmap`) survives across reads without copying the
81/// whole pack into the heap.
82enum PackData<'a> {
83    Borrowed(&'a [u8]),
84    Owned(Bytes),
85}
86
87impl<'a> PackData<'a> {
88    fn as_slice(&self) -> &[u8] {
89        match self {
90            Self::Borrowed(data) => data,
91            Self::Owned(data) => data,
92        }
93    }
94
95    fn slice(&self, range: std::ops::Range<usize>) -> Bytes {
96        match self {
97            Self::Borrowed(data) => Bytes::copy_from_slice(&data[range]),
98            Self::Owned(data) => data.slice(range),
99        }
100    }
101}
102
103pub struct PackReader<'a> {
104    data: PackData<'a>,
105    index: PackIndex,
106    aliased_offsets: HashSet<u64>,
107    content_end: usize,
108    #[cfg(test)]
109    compact_frame_reads: AtomicUsize,
110}
111
112#[derive(Debug, Clone)]
113pub struct EncodedPackSubset {
114    pub pack_data: Vec<u8>,
115    pub index_data: Vec<u8>,
116    pub encoded_bytes_copied: u64,
117}
118
119impl PackReader<'static> {
120    /// Open a pack file. mmap-backed when the pack is large enough
121    /// to benefit (the same threshold the loose-blob path uses for
122    /// its own mmap decision); read-into-heap otherwise.
123    pub fn open(pack_path: &Path, index_path: &Path) -> Result<Self> {
124        Self::open_with_verification(pack_path, index_path, true)
125    }
126
127    pub(super) fn open_lazy(pack_path: &Path, index_path: &Path) -> Result<Self> {
128        Self::open_with_verification(pack_path, index_path, false)
129    }
130
131    fn open_with_verification(
132        pack_path: &Path,
133        index_path: &Path,
134        verify_checksum: bool,
135    ) -> Result<Self> {
136        let pack_bytes = read_file_bytes_for_pack(pack_path)?;
137        let index_data = read_file_bytes_for_pack(index_path)?;
138        let (_, _, content_end) = if verify_checksum {
139            verify_container(&pack_bytes, pack_container_spec())?
140        } else {
141            verify_container_layout(&pack_bytes, pack_container_spec())?
142        };
143        let index = PackIndex::from_owned_bytes(index_data)?;
144        let aliased_offsets = index.aliased_offsets()?;
145        Ok(Self {
146            data: PackData::Owned(pack_bytes),
147            index,
148            aliased_offsets,
149            content_end,
150            #[cfg(test)]
151            compact_frame_reads: AtomicUsize::new(0),
152        })
153    }
154
155    pub fn from_bytes(pack_data: impl Into<Bytes>, index_data: impl AsRef<[u8]>) -> Result<Self> {
156        let pack_data = pack_data.into();
157        let (_, _, content_end) = verify_container(&pack_data, pack_container_spec())?;
158        let index = PackIndex::from_bytes(index_data.as_ref())?;
159        let aliased_offsets = index.aliased_offsets()?;
160        Ok(Self {
161            data: PackData::Owned(pack_data),
162            index,
163            aliased_offsets,
164            content_end,
165            #[cfg(test)]
166            compact_frame_reads: AtomicUsize::new(0),
167        })
168    }
169}
170
171impl<'a> PackReader<'a> {
172    pub fn from_slice(pack_data: &'a [u8], index_data: impl AsRef<[u8]>) -> Result<Self> {
173        let (_, _, content_end) = verify_container(pack_data, pack_container_spec())?;
174        let index = PackIndex::from_bytes(index_data.as_ref())?;
175        let aliased_offsets = index.aliased_offsets()?;
176        Ok(Self {
177            data: PackData::Borrowed(pack_data),
178            index,
179            aliased_offsets,
180            content_end,
181            #[cfg(test)]
182            compact_frame_reads: AtomicUsize::new(0),
183        })
184    }
185
186    /// List all object ids in this pack.
187    pub fn list_ids(&self) -> Result<Vec<PackObjectId>> {
188        self.index.ids()
189    }
190
191    /// List logical ids together with their physical read tier.
192    ///
193    /// A shared compact frame is represented by several index aliases at one
194    /// record offset. Direct records have a unique offset and form the hot,
195    /// random-access tier. A one-object compact frame is intentionally treated
196    /// as hot here: it has no read amplification over a direct record.
197    pub(super) fn indexed_read_tiers(&self) -> Result<Vec<(PackObjectId, PackReadTier)>> {
198        let entries = self.index.entries()?;
199        let mut aliases = HashMap::<u64, usize>::with_capacity(entries.len());
200        for entry in &entries {
201            *aliases.entry(entry.offset).or_default() += 1;
202        }
203        Ok(entries
204            .into_iter()
205            .map(|entry| {
206                let tier = if aliases[&entry.offset] > 1 {
207                    PackReadTier::SolidFrame
208                } else {
209                    PackReadTier::Hot
210                };
211                (entry.id, tier)
212            })
213            .collect())
214    }
215
216    #[cfg(test)]
217    pub(super) fn compact_frame_read_count(&self) -> usize {
218        self.compact_frame_reads.load(Ordering::Relaxed)
219    }
220
221    #[cfg(test)]
222    fn record_compact_frame_read(&self) {
223        self.compact_frame_reads.fetch_add(1, Ordering::Relaxed);
224    }
225
226    pub fn list_hashes(&self) -> Result<Vec<ContentHash>> {
227        Ok(self
228            .list_ids()?
229            .into_iter()
230            .filter_map(|id| match id {
231                PackObjectId::Hash(hash) => Some(hash),
232                PackObjectId::StateId(_) => None,
233            })
234            .collect())
235    }
236
237    pub fn has_object(&self, id: &PackObjectId) -> Result<bool> {
238        Ok(self.index.find(id)?.is_some())
239    }
240
241    /// Compressed payload bytes used by unique physical records of `obj_type`.
242    ///
243    /// Shared compact frames have one record offset indexed by many logical
244    /// ids, so offsets are deduplicated before bytes are counted.
245    pub fn encoded_payload_bytes(&self, obj_type: ObjectType) -> Result<u64> {
246        let mut offsets = BTreeSet::new();
247        for id in self.index.ids()? {
248            if let Some(offset) = self.index.find(&id)? {
249                offsets.insert(checked_index_offset(offset)?);
250            }
251        }
252        let mut bytes = 0u64;
253        for offset in offsets {
254            let header = decode_tagged_entry_header(self.content_from(offset)?)?;
255            if header.obj_type == obj_type {
256                bytes = bytes.saturating_add(header.compressed_size as u64);
257            }
258        }
259        Ok(bytes)
260    }
261
262    /// Visit every logical object while decoding each shared frame once.
263    ///
264    /// The index-to-frame membership is checked exactly before objects are
265    /// yielded, closing both missing-entry and stale-alias corruption paths.
266    pub fn visit_objects(
267        &self,
268        mut visitor: impl FnMut(PackObjectId, ObjectType, &[u8]) -> Result<()>,
269    ) -> Result<()> {
270        let mut locations = std::collections::BTreeMap::<usize, Vec<PackObjectId>>::new();
271        for id in self.index.ids()? {
272            let offset = self
273                .index
274                .find(&id)?
275                .ok_or_else(|| StoreError::InvalidObject("indexed object disappeared".into()))?;
276            locations
277                .entry(checked_index_offset(offset)?)
278                .or_default()
279                .push(id);
280        }
281        for (offset, indexed_ids) in locations {
282            if let Some(objects) = self.read_compact_objects_at(offset)? {
283                let actual = objects.iter().map(|(id, _, _)| *id).collect::<HashSet<_>>();
284                let indexed = indexed_ids.iter().copied().collect::<HashSet<_>>();
285                if actual != indexed
286                    || actual.len() != objects.len()
287                    || indexed.len() != indexed_ids.len()
288                {
289                    return Err(StoreError::InvalidObject(
290                        "compact frame object set differs from its index".into(),
291                    ));
292                }
293                for (id, object_type, data) in objects {
294                    visitor(id, object_type, &data)?;
295                }
296                continue;
297            }
298            if indexed_ids.len() != 1 {
299                return Err(StoreError::InvalidObject(
300                    "ordinary pack record is indexed by multiple object ids".into(),
301                ));
302            }
303            let id = indexed_ids[0];
304            let (object_type, data) = self
305                .get_object(&id)?
306                .ok_or_else(|| StoreError::InvalidObject("indexed object is missing".into()))?;
307            visitor(id, object_type, &data)?;
308        }
309        Ok(())
310    }
311
312    /// Copy a validated subset of non-delta encoded entries into a standalone
313    /// hosted transport pack without decoding or recompressing their bodies.
314    ///
315    /// `Ok(None)` is a safe fallback signal: an expected object is absent,
316    /// duplicated, has a different type/size, is delta encoded, or names a
317    /// repository-local entry that may not cross the hosted pack boundary.
318    pub fn copy_hosted_encoded_subset(
319        &self,
320        expected: &[(PackObjectId, ObjectType, u64)],
321    ) -> Result<Option<EncodedPackSubset>> {
322        if expected.is_empty() {
323            return Ok(None);
324        }
325        let mut unique = HashSet::with_capacity(expected.len());
326        if expected.iter().any(|(id, obj_type, _)| {
327            !unique.insert(*id)
328                || matches!(
329                    obj_type,
330                    ObjectType::Delta | ObjectType::StateAttachment | ObjectType::SnapshotCommit
331                )
332        }) {
333            return Ok(None);
334        }
335
336        let mut pack_data = Vec::new();
337        write_container_header(&mut pack_data, pack_container_spec(), expected.len() as u64);
338        let mut index = PackIndex::new();
339        let mut encoded_bytes_copied = 0u64;
340        for (expected_id, expected_type, expected_size) in expected {
341            let Some(offset) = self.index.find(expected_id)? else {
342                return Ok(None);
343            };
344            let offset = checked_index_offset(offset)?;
345            if offset >= self.content_end {
346                return Err(StoreError::InvalidObject(
347                    "Entry offset out of bounds".to_string(),
348                ));
349            }
350            let header = decode_tagged_entry_header(self.content_from(offset)?)?;
351            if self.read_compact_objects_at(offset)?.is_some() {
352                return Ok(None);
353            }
354            let expected_size = usize::try_from(*expected_size).ok();
355            if header.id != *expected_id
356                || header.obj_type != *expected_type
357                || Some(header.uncompressed_size) != expected_size
358                || matches!(
359                    header.obj_type,
360                    ObjectType::Delta | ObjectType::StateAttachment | ObjectType::SnapshotCommit
361                )
362            {
363                return Ok(None);
364            }
365            let encoded_len = header
366                .header_len
367                .checked_add(header.compressed_size)
368                .ok_or_else(|| {
369                    StoreError::InvalidObject("pack entry length overflow".to_string())
370                })?;
371            let encoded_end = offset
372                .checked_add(encoded_len)
373                .ok_or_else(|| StoreError::InvalidObject("pack entry end overflow".to_string()))?;
374            if encoded_end > self.content_end {
375                return Err(StoreError::InvalidObject(
376                    "pack entry extends beyond content boundary".to_string(),
377                ));
378            }
379            let output_offset = u64::try_from(pack_data.len()).map_err(|_| {
380                StoreError::InvalidObject("reused pack offset exceeds u64".to_string())
381            })?;
382            index.add(*expected_id, output_offset);
383            pack_data.extend_from_slice(&self.data.as_slice()[offset..encoded_end]);
384            encoded_bytes_copied = encoded_bytes_copied
385                .checked_add(u64::try_from(encoded_len).map_err(|_| {
386                    StoreError::InvalidObject("encoded pack entry length exceeds u64".to_string())
387                })?)
388                .ok_or_else(|| {
389                    StoreError::InvalidObject("encoded reused byte count overflow".to_string())
390                })?;
391        }
392        index.sort();
393        append_container_checksum(&mut pack_data);
394        Ok(Some(EncodedPackSubset {
395            pack_data,
396            index_data: index.to_bytes(),
397            encoded_bytes_copied,
398        }))
399    }
400
401    /// Get an object from the pack.
402    ///
403    /// Verifies that the tagged id at the indexed offset matches
404    /// `id` before returning. A stale `.idx` file (e.g., overwritten
405    /// in place after a pack rebuild) can otherwise route a request
406    /// for hash `A` to a record physically located at hash `B`'s
407    /// offset — same shape, different content, no error signal.
408    /// This cheap 32-byte id comparison catches that without paying
409    /// a full content-hash recompute on every read; corruption
410    /// strictly *inside* the record body is a separate failure mode
411    /// surfaced via the consumer-side hash verify (see
412    /// `FsStore::loose_blob_path` for the blob equivalent).
413    pub fn get_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
414        let offset = match self.index.find(id)? {
415            Some(offset) => checked_index_offset(offset)?,
416            None => return Ok(None),
417        };
418
419        let record = self.read_record_at_depth(id, offset, 0)?;
420        Ok(Some((record.obj_type, record.data)))
421    }
422
423    pub fn get_hashed_object(&self, hash: &ContentHash) -> Result<Option<(ObjectType, Vec<u8>)>> {
424        self.get_object(&PackObjectId::Hash(*hash))
425    }
426
427    /// Read an object's logical type from pack headers without reading or
428    /// decoding its payload.
429    ///
430    /// Delta entries inherit the type of their base, so this follows only the
431    /// tagged base ids and headers until it reaches a non-delta entry. No
432    /// compressed or delta payload bytes are decoded. Missing hashes return
433    /// `Ok(None)`.
434    pub fn get_hashed_object_type(&self, hash: &ContentHash) -> Result<Option<ObjectType>> {
435        let id = PackObjectId::Hash(*hash);
436        let Some(offset) = self.index.find(&id)? else {
437            return Ok(None);
438        };
439        self.read_object_type_at_depth(&id, checked_index_offset(offset)?, 0)
440            .map(Some)
441    }
442
443    /// Zero-copy fast path: when the entry is non-delta and stored
444    /// uncompressed, returns `Bytes::slice` into the pack's
445    /// (mmap-backed) buffer — no allocation, no memcpy. Compressed
446    /// or delta entries fall back to `get_object` and wrap the
447    /// resulting `Vec<u8>` in a `Bytes` (one Arc, no body copy).
448    ///
449    /// Use this from the hot read path. The 10 MB benchmark gap
450    /// between the mount and vanilla FS at the 1 MB+ tier is the
451    /// per-blob memcpy this method eliminates.
452    pub fn get_object_bytes(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Bytes)>> {
453        let Some(offset) = self.index.find(id)? else {
454            return Ok(None);
455        };
456        let offset = checked_index_offset(offset)?;
457        if offset >= self.content_end {
458            return Err(StoreError::InvalidObject(
459                "Entry offset out of bounds".to_string(),
460            ));
461        }
462
463        // Verify the tagged id at the indexed offset matches the
464        // requested id — guards against stale-index misrouting (see
465        // `get_object` for the long-form rationale). 32-byte
466        // compare; cheaper than the size+varint decode that follows.
467        let (record_id, id_len) = PackObjectId::decode_tagged(self.content_from(offset)?)?;
468        let header_start = checked_index_add(offset, id_len, "record header start")?;
469        let (obj_type, uncompressed_size, type_len) =
470            varint::decode_type_and_size(self.content_from(header_start)?).ok_or_else(|| {
471                StoreError::InvalidObject("Truncated type+size varint".to_string())
472            })?;
473        let uncompressed_size = checked_decoded_size("uncompressed_size", uncompressed_size)?;
474        let varint_start = checked_index_add(header_start, type_len, "compressed_size start")?;
475        let (compressed_size, comp_len) = varint::decode_varint(self.content_from(varint_start)?)
476            .ok_or_else(truncated_compressed_size_varint)?;
477        let compressed_size = checked_decoded_size("compressed_size", compressed_size)?;
478
479        // Fast path: non-delta entry stored uncompressed. The most
480        // common shape for snapshot-time packs (the builder skips
481        // the delta search for unrelated blobs).
482        if record_id == *id && obj_type != ObjectType::Delta && compressed_size == uncompressed_size
483        {
484            let data_start = checked_index_add(varint_start, comp_len, "entry data start")?;
485            let data_end = checked_data_end(data_start, compressed_size, self.content_end)?;
486            let data = &self.data.as_slice()[data_start..data_end];
487            if !is_compact_frame(data) {
488                return Ok(Some((obj_type, self.data.slice(data_start..data_end))));
489            }
490        }
491
492        // Slow path: defer to the full record reader (it handles
493        // decompression + delta chains) and Bytes-wrap the Vec.
494        // Bytes::from(Vec) is a single Arc allocation, no body copy.
495        let record = self.read_record_at_depth(id, offset, 0)?;
496        Ok(Some((record.obj_type, Bytes::from(record.data))))
497    }
498
499    pub fn get_hashed_object_bytes(
500        &self,
501        hash: &ContentHash,
502    ) -> Result<Option<(ObjectType, Bytes)>> {
503        self.get_object_bytes(&PackObjectId::Hash(*hash))
504    }
505
506    /// Read just the type+size header for an object without
507    /// decompressing its payload. Returns `Ok(None)` when the object
508    /// isn't in this pack.
509    ///
510    /// For non-delta entries this is one varint decode at the indexed
511    /// offset — much cheaper than `get_object`. Delta entries fall
512    /// back to a full read because their *resolved* size requires
513    /// chasing the base; in practice deltas are rare in the directory
514    /// listing hot path so the fallback is acceptable.
515    pub fn get_hashed_object_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
516        let id = PackObjectId::Hash(*hash);
517        let Some(offset) = self.index.find(&id)? else {
518            return Ok(None);
519        };
520        let offset = checked_index_offset(offset)?;
521        if offset >= self.content_end {
522            return Err(StoreError::InvalidObject(
523                "Entry offset out of bounds".to_string(),
524            ));
525        }
526        let (record_id, id_len) = PackObjectId::decode_tagged(self.content_from(offset)?)?;
527        let header_start = checked_index_add(offset, id_len, "record header start")?;
528        let (obj_type, uncompressed_size, _type_len) = super::varint::decode_type_and_size(
529            self.content_from(header_start)?,
530        )
531        .ok_or_else(|| StoreError::InvalidObject("Truncated type+size varint".to_string()))?;
532        if (obj_type == ObjectType::Blob && self.aliased_offsets.contains(&(offset as u64)))
533            || matches!(obj_type, ObjectType::Tree | ObjectType::State)
534        {
535            let Some((_, data)) = self.get_object(&id)? else {
536                return Ok(None);
537            };
538            return Ok(Some(data.len() as u64));
539        }
540        verify_record_id_matches(&id, &record_id)?;
541        if obj_type == ObjectType::Delta {
542            // Delta entries record the *resolved* output size in the
543            // type+size varint already (see `read_record_at_depth`'s
544            // size-mismatch check), so we can still return without
545            // decompressing the payload.
546            return Ok(Some(uncompressed_size));
547        }
548        Ok(Some(uncompressed_size))
549    }
550
551    fn read_object_type_at_depth(
552        &self,
553        requested_id: &PackObjectId,
554        offset: usize,
555        depth: usize,
556    ) -> Result<ObjectType> {
557        if depth > MAX_DELTA_CHAIN_DEPTH {
558            return Err(StoreError::InvalidObject(format!(
559                "Delta chain depth {depth} exceeds max {MAX_DELTA_CHAIN_DEPTH}"
560            )));
561        }
562        if offset >= self.content_end {
563            return Err(StoreError::InvalidObject(
564                "Entry offset out of bounds".to_string(),
565            ));
566        }
567
568        let header = decode_tagged_entry_header(self.content_from(offset)?)?;
569        if header.id != *requested_id {
570            return self
571                .read_record_at_depth(requested_id, offset, depth)
572                .map(|record| record.obj_type);
573        }
574        if header.obj_type != ObjectType::Delta {
575            return Ok(header.obj_type);
576        }
577
578        let base_hash = Self::require_delta_base_hash(header.delta_base)?;
579        let base_id = PackObjectId::Hash(base_hash);
580        let base_offset = self
581            .index
582            .find(&base_id)?
583            .ok_or_else(|| StoreError::NotFound(base_hash.to_string()))?;
584        self.read_object_type_at_depth(&base_id, checked_index_offset(base_offset)?, depth + 1)
585    }
586
587    fn read_record_at_depth(
588        &self,
589        requested_id: &PackObjectId,
590        offset: usize,
591        depth: usize,
592    ) -> Result<PackObjectRecord> {
593        if offset >= self.content_end {
594            return Err(StoreError::InvalidObject(
595                "Entry offset out of bounds".to_string(),
596            ));
597        }
598
599        let (id, id_len) = PackObjectId::decode_tagged(self.content_from(offset)?)?;
600        let header_start = checked_index_add(offset, id_len, "record header start")?;
601
602        let (obj_type, uncompressed_size, type_len) =
603            varint::decode_type_and_size(self.content_from(header_start)?).ok_or_else(|| {
604                StoreError::InvalidObject("Truncated type+size varint".to_string())
605            })?;
606        let uncompressed_size = checked_decoded_size("uncompressed_size", uncompressed_size)?;
607
608        let varint_start = checked_index_add(header_start, type_len, "compressed_size start")?;
609        let (compressed_size, comp_len) = varint::decode_varint(self.content_from(varint_start)?)
610            .ok_or_else(truncated_compressed_size_varint)?;
611        let compressed_size = checked_decoded_size("compressed_size", compressed_size)?;
612
613        let mut data_start = checked_index_add(varint_start, comp_len, "entry data start")?;
614
615        // Delta entries carry a tagged base id in pack v2.
616        let base_id = if obj_type == ObjectType::Delta {
617            let (base_id, base_len) = PackObjectId::decode_tagged(self.content_from(data_start)?)?;
618            data_start = checked_index_add(data_start, base_len, "delta data start")?;
619            Some(base_id)
620        } else {
621            None
622        };
623
624        let data_end = checked_data_end(data_start, compressed_size, self.content_end)?;
625
626        let stored_data = &self.data.as_slice()[data_start..data_end];
627
628        // Raw zstd (no wrapper). For non-delta entries, decompress
629        // if sizes differ. For delta entries, the stored data IS the delta
630        // payload (possibly zstd-compressed); check for zstd magic.
631        let decompressed = if obj_type == ObjectType::Delta {
632            if has_zstd_magic(stored_data) {
633                decompress_pack_payload(stored_data, 0)?
634            } else {
635                stored_data.to_vec()
636            }
637        } else if compressed_size != uncompressed_size {
638            decompress_pack_payload(stored_data, uncompressed_size)?
639        } else {
640            stored_data.to_vec()
641        };
642
643        let shared_blob =
644            obj_type == ObjectType::Blob && self.aliased_offsets.contains(&(offset as u64));
645        if obj_type != ObjectType::Delta && (shared_blob || is_compact_frame(&decompressed)) {
646            #[cfg(test)]
647            self.record_compact_frame_read();
648            if let Some(data) =
649                decode_compact_object(requested_id, obj_type, &decompressed, shared_blob)?
650            {
651                return Ok(PackObjectRecord {
652                    id: *requested_id,
653                    obj_type,
654                    data,
655                    delta_base: None,
656                    path_hint: None,
657                });
658            }
659        }
660        verify_record_id_matches(requested_id, &id)?;
661        let (resolved_type, final_data) = if obj_type == ObjectType::Delta {
662            self.read_delta_record(base_id, &decompressed, uncompressed_size, depth)?
663        } else {
664            (obj_type, decompressed)
665        };
666
667        if final_data.len() != uncompressed_size {
668            return Err(StoreError::InvalidObject(format!(
669                "Size mismatch: expected {}, got {}",
670                uncompressed_size,
671                final_data.len()
672            )));
673        }
674
675        Ok(PackObjectRecord {
676            id,
677            obj_type: resolved_type,
678            data: final_data,
679            delta_base: None,
680            path_hint: None,
681        })
682    }
683
684    fn read_compact_objects_at(&self, offset: usize) -> Result<Option<DecodedCompactObjects>> {
685        if offset >= self.content_end {
686            return Err(StoreError::InvalidObject(
687                "Entry offset out of bounds".to_string(),
688            ));
689        }
690        let header = decode_tagged_entry_header(self.content_from(offset)?)?;
691        if !matches!(
692            header.obj_type,
693            ObjectType::Blob | ObjectType::Tree | ObjectType::State
694        ) {
695            return Ok(None);
696        }
697        let shared_blob =
698            header.obj_type == ObjectType::Blob && self.aliased_offsets.contains(&(offset as u64));
699        if header.obj_type == ObjectType::Blob && !shared_blob {
700            return Ok(None);
701        }
702        let data_start = checked_index_add(offset, header.header_len, "entry data start")?;
703        let data_end = checked_data_end(data_start, header.compressed_size, self.content_end)?;
704        let stored = &self.data.as_slice()[data_start..data_end];
705        let data = if header.compressed_size != header.uncompressed_size {
706            decompress_pack_payload(stored, header.uncompressed_size)?
707        } else {
708            stored.to_vec()
709        };
710        if data.len() != header.uncompressed_size {
711            return Err(StoreError::InvalidObject(format!(
712                "Size mismatch: expected {}, got {}",
713                header.uncompressed_size,
714                data.len()
715            )));
716        }
717        #[cfg(test)]
718        if shared_blob || is_compact_frame(&data) {
719            self.record_compact_frame_read();
720        }
721        decode_compact_objects(header.obj_type, &data, shared_blob)
722    }
723
724    fn read_delta_record(
725        &self,
726        base_id: Option<PackObjectId>,
727        delta: &[u8],
728        uncompressed_size: usize,
729        depth: usize,
730    ) -> Result<(ObjectType, Vec<u8>)> {
731        if depth > MAX_DELTA_CHAIN_DEPTH {
732            return Err(StoreError::InvalidObject(format!(
733                "Delta chain depth {} exceeds max {}",
734                depth, MAX_DELTA_CHAIN_DEPTH
735            )));
736        }
737
738        if uncompressed_size > MAX_PACK_DELTA_OUTPUT_SIZE {
739            return Err(StoreError::InvalidObject(format!(
740                "Delta output size {} exceeds max {}",
741                uncompressed_size, MAX_PACK_DELTA_OUTPUT_SIZE
742            )));
743        }
744
745        let base_hash = Self::require_delta_base_hash(base_id)?;
746        let base_offset = self
747            .index
748            .find(&PackObjectId::Hash(base_hash))?
749            .ok_or_else(|| StoreError::NotFound(base_hash.to_string()))?;
750        let base_offset = checked_index_offset(base_offset)?;
751        let base_id = PackObjectId::Hash(base_hash);
752        let base_record = self.read_record_at_depth(&base_id, base_offset, depth + 1)?;
753        let base_type = base_record.obj_type;
754        let base_data = base_record.data;
755
756        let decoded = DeltaDecoder::decode(&base_data, delta, uncompressed_size)
757            .map_err(|error| StoreError::InvalidObject(format!("Delta decode failed: {error}")))?;
758
759        Ok((base_type, decoded))
760    }
761
762    fn require_delta_base_hash(base_id: Option<PackObjectId>) -> Result<ContentHash> {
763        match base_id {
764            Some(PackObjectId::Hash(hash)) => Ok(hash),
765            Some(PackObjectId::StateId(_)) => Err(StoreError::InvalidObject(
766                "pack delta base must be hash-backed content".into(),
767            )),
768            None => Err(StoreError::InvalidObject(
769                "pack object type is Delta but base hash is missing".into(),
770            )),
771        }
772    }
773
774    fn content_from(&self, offset: usize) -> Result<&[u8]> {
775        if offset > self.content_end {
776            return Err(StoreError::InvalidObject(
777                "Entry header out of bounds".to_string(),
778            ));
779        }
780        Ok(&self.data.as_slice()[offset..self.content_end])
781    }
782}
783
784fn checked_index_offset(offset: u64) -> Result<usize> {
785    usize::try_from(offset)
786        .map_err(|_| StoreError::InvalidObject("Entry offset exceeds platform limits".to_string()))
787}
788
789fn checked_decoded_size(field: &str, size: u64) -> Result<usize> {
790    let size = usize::try_from(size).map_err(|_| {
791        StoreError::InvalidObject(format!("Decoded {field} exceeds platform limits"))
792    })?;
793    if field == "uncompressed_size" && size > super::shared::MAX_PACK_OBJECT_OUTPUT_SIZE {
794        return Err(StoreError::InvalidObject(format!(
795            "Pack object output size {size} exceeds max {}",
796            super::shared::MAX_PACK_OBJECT_OUTPUT_SIZE
797        )));
798    }
799    Ok(size)
800}
801
802fn checked_index_add(start: usize, len: usize, field: &str) -> Result<usize> {
803    start.checked_add(len).ok_or_else(|| {
804        StoreError::InvalidObject(format!("{field} offset overflows platform limits"))
805    })
806}
807
808fn checked_data_end(
809    data_start: usize,
810    compressed_size: usize,
811    content_end: usize,
812) -> Result<usize> {
813    let data_end = data_start.checked_add(compressed_size).ok_or_else(|| {
814        StoreError::InvalidObject("Entry data range overflows platform limits".to_string())
815    })?;
816    if data_end > content_end {
817        return Err(StoreError::InvalidObject(
818            "Entry data out of bounds".to_string(),
819        ));
820    }
821    Ok(data_end)
822}
823
824fn truncated_compressed_size_varint() -> StoreError {
825    StoreError::InvalidObject("Truncated compressed_size varint".to_string())
826}
827
828/// Reject a record whose tagged id at the indexed offset doesn't
829/// match the id the caller asked for. The pack format stores its
830/// records `[tagged_id, type+size, compressed_size, payload]` so the
831/// tagged id is the cheapest available authenticator of "we landed
832/// on the right record"; a stale or hand-edited `.idx` that points
833/// at the *wrong* record produces a mismatch here and we surface it
834/// as a real error instead of silently routing the caller to whatever
835/// bytes happened to be at the bad offset.
836fn verify_record_id_matches(requested: &PackObjectId, found: &PackObjectId) -> Result<()> {
837    if requested == found {
838        return Ok(());
839    }
840    Err(StoreError::InvalidObject(format!(
841        "pack index routed lookup for {requested:?} to record tagged {found:?} \
842         — index is stale or corrupt; the loose-store path will re-promote on \
843         the next read"
844    )))
845}
846
847fn is_compact_frame(data: &[u8]) -> bool {
848    heddle_object_model::compact::is_blob_frame(data)
849        || heddle_object_model::compact::is_tree_frame(data)
850        || heddle_object_model::compact::is_state_frame(data)
851}
852
853fn decode_compact_object(
854    requested_id: &PackObjectId,
855    obj_type: ObjectType,
856    data: &[u8],
857    require_blob_frame: bool,
858) -> Result<Option<Vec<u8>>> {
859    let Some(objects) = decode_compact_objects(obj_type, data, require_blob_frame)? else {
860        return Ok(None);
861    };
862    objects
863        .into_iter()
864        .find_map(|(id, _, bytes)| (id == *requested_id).then_some(bytes))
865        .map(Some)
866        .ok_or_else(|| compact_index_miss(requested_id))
867}
868
869fn decode_compact_objects(
870    obj_type: ObjectType,
871    data: &[u8],
872    require_blob_frame: bool,
873) -> Result<Option<DecodedCompactObjects>> {
874    match obj_type {
875        ObjectType::Blob if require_blob_frame => {
876            heddle_object_model::compact::decode_blob_frame(data)
877                .map_err(|error| StoreError::InvalidObject(error.to_string()))?
878                .into_iter()
879                .map(|(hash, body)| Ok((PackObjectId::Hash(hash), ObjectType::Blob, body.to_vec())))
880                .collect::<Result<Vec<_>>>()
881                .map(Some)
882        }
883        ObjectType::Blob => Ok(None),
884        ObjectType::Tree if heddle_object_model::compact::is_tree_frame(data) => {
885            heddle_object_model::compact::decode_tree_frame(data)
886                .map_err(|error| StoreError::InvalidObject(error.to_string()))?
887                .into_iter()
888                .map(|tree| {
889                    let id = PackObjectId::Hash(tree.hash());
890                    let bytes = rmp_serde::to_vec_named(&tree)
891                        .map_err(|error| StoreError::InvalidObject(error.to_string()))?;
892                    Ok((id, ObjectType::Tree, bytes))
893                })
894                .collect::<Result<Vec<_>>>()
895                .map(Some)
896        }
897        ObjectType::State if heddle_object_model::compact::is_state_frame(data) => {
898            heddle_object_model::compact::decode_state_frame(data)
899                .map_err(|error| StoreError::InvalidObject(error.to_string()))?
900                .into_iter()
901                .map(|state| {
902                    let id = PackObjectId::StateId(state.state_id);
903                    let bytes = rmp_serde::to_vec_named(&state)
904                        .map_err(|error| StoreError::InvalidObject(error.to_string()))?;
905                    Ok((id, ObjectType::State, bytes))
906                })
907                .collect::<Result<Vec<_>>>()
908                .map(Some)
909        }
910        _ if is_compact_frame(data) => Err(StoreError::InvalidObject(
911            "compact frame magic does not match its pack object type".into(),
912        )),
913        _ => Ok(None),
914    }
915}
916
917fn compact_index_miss(id: &PackObjectId) -> StoreError {
918    StoreError::InvalidObject(format!(
919        "compact frame does not contain indexed object {id:?}"
920    ))
921}
922
923#[cfg(test)]
924mod tests {
925    use super::{PackObjectId, PackReader, verify_record_id_matches};
926    use crate::{object::ContentHash, store::StoreError};
927
928    #[test]
929    fn test_require_delta_base_hash_rejects_missing_hash() {
930        let error =
931            PackReader::require_delta_base_hash(None).expect_err("missing hash should fail");
932
933        assert!(
934            matches!(error, StoreError::InvalidObject(message) if message == "pack object type is Delta but base hash is missing")
935        );
936    }
937
938    #[test]
939    fn verify_record_id_matches_accepts_identical_ids() {
940        let id = PackObjectId::Hash(ContentHash::from_bytes([7u8; 32]));
941        verify_record_id_matches(&id, &id).expect("matching ids must verify");
942    }
943
944    #[test]
945    fn verify_record_id_matches_rejects_mismatched_ids() {
946        let asked = PackObjectId::Hash(ContentHash::from_bytes([7u8; 32]));
947        let found = PackObjectId::Hash(ContentHash::from_bytes([8u8; 32]));
948        let error = verify_record_id_matches(&asked, &found)
949            .expect_err("mismatched record id must error rather than silently route");
950        assert!(
951            matches!(&error, StoreError::InvalidObject(message) if message.contains("stale or corrupt")),
952            "stale-index mismatch must surface as InvalidObject with the diagnostic phrase, got: {error:?}",
953        );
954    }
955}