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, PackLogicalId, PackObjectId, PackObjectRecord, PackRepresentationHash,
18    append_container_checksum, decode_tagged_entry_header, decompress_pack_payload, has_zstd_magic,
19    pack_container_spec, pack_identity::LogicalIdBuilder, pack_index::PackIndex, varint,
20    verify_supported_container, verify_supported_container_layout, 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_supported_container(&pack_bytes)?
140        } else {
141            verify_supported_container_layout(&pack_bytes)?
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_supported_container(&pack_data)?;
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_supported_container(pack_data)?;
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    /// Compute this pack's root-spool-scoped logical identity.
192    ///
193    /// Every logical object is decoded so delta and compact-frame physical
194    /// choices cannot affect the result. The visit also validates exact index
195    /// membership before an identity is returned.
196    pub fn logical_id(&self) -> Result<PackLogicalId> {
197        let mut identity = LogicalIdBuilder::new();
198        self.visit_objects(|id, object_type, data| {
199            identity.push(id, object_type, data);
200            Ok(())
201        })?;
202        Ok(identity.finish())
203    }
204
205    /// Hash the exact finalized pack bytes used by this reader.
206    pub fn representation_hash(&self) -> PackRepresentationHash {
207        PackRepresentationHash::compute(self.data.as_slice())
208    }
209
210    /// List logical ids together with their physical read tier.
211    ///
212    /// A shared compact frame is represented by several index aliases at one
213    /// record offset. Direct records have a unique offset and form the hot,
214    /// random-access tier. A one-object compact frame is intentionally treated
215    /// as hot here: it has no read amplification over a direct record.
216    pub(super) fn indexed_read_tiers(&self) -> Result<Vec<(PackObjectId, PackReadTier)>> {
217        let entries = self.index.entries()?;
218        let mut aliases = HashMap::<u64, usize>::with_capacity(entries.len());
219        for entry in &entries {
220            *aliases.entry(entry.offset).or_default() += 1;
221        }
222        Ok(entries
223            .into_iter()
224            .map(|entry| {
225                let tier = if aliases[&entry.offset] > 1 {
226                    PackReadTier::SolidFrame
227                } else {
228                    PackReadTier::Hot
229                };
230                (entry.id, tier)
231            })
232            .collect())
233    }
234
235    /// Point-membership probe backed by the sorted pack index. This avoids
236    /// enumerating every object merely to locate one hot-path tree or state.
237    pub(super) fn contains_object(&self, id: &PackObjectId) -> Result<bool> {
238        Ok(self.index.find(id)?.is_some())
239    }
240
241    #[cfg(test)]
242    pub(super) fn compact_frame_read_count(&self) -> usize {
243        self.compact_frame_reads.load(Ordering::Relaxed)
244    }
245
246    #[cfg(test)]
247    fn record_compact_frame_read(&self) {
248        self.compact_frame_reads.fetch_add(1, Ordering::Relaxed);
249    }
250
251    pub fn list_hashes(&self) -> Result<Vec<ContentHash>> {
252        Ok(self
253            .list_ids()?
254            .into_iter()
255            .filter_map(|id| match id {
256                PackObjectId::Hash(hash) => Some(hash),
257                PackObjectId::StateId(_) | PackObjectId::AnnotatedTag(_) => None,
258            })
259            .collect())
260    }
261
262    pub fn has_object(&self, id: &PackObjectId) -> Result<bool> {
263        Ok(self.index.find(id)?.is_some())
264    }
265
266    /// Compressed payload bytes used by unique physical records of `obj_type`.
267    ///
268    /// Shared compact frames have one record offset indexed by many logical
269    /// ids, so offsets are deduplicated before bytes are counted.
270    pub fn encoded_payload_bytes(&self, obj_type: ObjectType) -> Result<u64> {
271        let mut offsets = BTreeSet::new();
272        for id in self.index.ids()? {
273            if let Some(offset) = self.index.find(&id)? {
274                offsets.insert(checked_index_offset(offset)?);
275            }
276        }
277        let mut bytes = 0u64;
278        for offset in offsets {
279            let header = decode_tagged_entry_header(self.content_from(offset)?)?;
280            if header.obj_type == obj_type {
281                bytes = bytes.saturating_add(header.compressed_size as u64);
282            }
283        }
284        Ok(bytes)
285    }
286
287    /// Visit every logical object while decoding each shared frame once.
288    ///
289    /// The index-to-frame membership is checked exactly before objects are
290    /// yielded, closing both missing-entry and stale-alias corruption paths.
291    pub fn visit_objects(
292        &self,
293        mut visitor: impl FnMut(PackObjectId, ObjectType, &[u8]) -> Result<()>,
294    ) -> Result<()> {
295        let mut locations = std::collections::BTreeMap::<usize, Vec<PackObjectId>>::new();
296        for id in self.index.ids()? {
297            let offset = self
298                .index
299                .find(&id)?
300                .ok_or_else(|| StoreError::InvalidObject("indexed object disappeared".into()))?;
301            locations
302                .entry(checked_index_offset(offset)?)
303                .or_default()
304                .push(id);
305        }
306        for (offset, indexed_ids) in locations {
307            if let Some(objects) = self.read_compact_objects_at(offset)? {
308                let actual = objects.iter().map(|(id, _, _)| *id).collect::<HashSet<_>>();
309                let indexed = indexed_ids.iter().copied().collect::<HashSet<_>>();
310                if actual != indexed
311                    || actual.len() != objects.len()
312                    || indexed.len() != indexed_ids.len()
313                {
314                    return Err(StoreError::InvalidObject(
315                        "compact frame object set differs from its index".into(),
316                    ));
317                }
318                for (id, object_type, data) in objects {
319                    visitor(id, object_type, &data)?;
320                }
321                continue;
322            }
323            if indexed_ids.len() != 1 {
324                return Err(StoreError::InvalidObject(
325                    "ordinary pack record is indexed by multiple object ids".into(),
326                ));
327            }
328            let id = indexed_ids[0];
329            let (object_type, data) = self
330                .get_object(&id)?
331                .ok_or_else(|| StoreError::InvalidObject("indexed object is missing".into()))?;
332            visitor(id, object_type, &data)?;
333        }
334        Ok(())
335    }
336
337    /// Copy a validated subset of non-delta encoded entries into a standalone
338    /// hosted transport pack without decoding or recompressing their bodies.
339    ///
340    /// `Ok(None)` is a safe fallback signal: an expected object is absent,
341    /// duplicated, has a different type/size, is delta encoded, or names a
342    /// repository-local entry that may not cross the hosted pack boundary.
343    pub fn copy_hosted_encoded_subset(
344        &self,
345        expected: &[(PackObjectId, ObjectType, u64)],
346    ) -> Result<Option<EncodedPackSubset>> {
347        if expected.is_empty() {
348            return Ok(None);
349        }
350        let mut unique = HashSet::with_capacity(expected.len());
351        if expected.iter().any(|(id, obj_type, _)| {
352            !unique.insert(*id)
353                || matches!(
354                    obj_type,
355                    ObjectType::Delta | ObjectType::StateAttachment | ObjectType::SnapshotCommit
356                )
357        }) {
358            return Ok(None);
359        }
360
361        let mut pack_data = Vec::new();
362        write_container_header(&mut pack_data, pack_container_spec(), expected.len() as u64);
363        let mut index = PackIndex::new();
364        let mut encoded_bytes_copied = 0u64;
365        for (expected_id, expected_type, expected_size) in expected {
366            let Some(offset) = self.index.find(expected_id)? else {
367                return Ok(None);
368            };
369            let offset = checked_index_offset(offset)?;
370            if offset >= self.content_end {
371                return Err(StoreError::InvalidObject(
372                    "Entry offset out of bounds".to_string(),
373                ));
374            }
375            let header = decode_tagged_entry_header(self.content_from(offset)?)?;
376            if self.read_compact_objects_at(offset)?.is_some() {
377                return Ok(None);
378            }
379            let expected_size = usize::try_from(*expected_size).ok();
380            if header.id != *expected_id
381                || header.obj_type != *expected_type
382                || Some(header.uncompressed_size) != expected_size
383                || matches!(
384                    header.obj_type,
385                    ObjectType::Delta | ObjectType::StateAttachment | ObjectType::SnapshotCommit
386                )
387            {
388                return Ok(None);
389            }
390            let encoded_len = header
391                .header_len
392                .checked_add(header.compressed_size)
393                .ok_or_else(|| {
394                    StoreError::InvalidObject("pack entry length overflow".to_string())
395                })?;
396            let encoded_end = offset
397                .checked_add(encoded_len)
398                .ok_or_else(|| StoreError::InvalidObject("pack entry end overflow".to_string()))?;
399            if encoded_end > self.content_end {
400                return Err(StoreError::InvalidObject(
401                    "pack entry extends beyond content boundary".to_string(),
402                ));
403            }
404            let output_offset = u64::try_from(pack_data.len()).map_err(|_| {
405                StoreError::InvalidObject("reused pack offset exceeds u64".to_string())
406            })?;
407            index.add(*expected_id, output_offset);
408            pack_data.extend_from_slice(&self.data.as_slice()[offset..encoded_end]);
409            encoded_bytes_copied = encoded_bytes_copied
410                .checked_add(u64::try_from(encoded_len).map_err(|_| {
411                    StoreError::InvalidObject("encoded pack entry length exceeds u64".to_string())
412                })?)
413                .ok_or_else(|| {
414                    StoreError::InvalidObject("encoded reused byte count overflow".to_string())
415                })?;
416        }
417        index.sort();
418        append_container_checksum(&mut pack_data);
419        Ok(Some(EncodedPackSubset {
420            pack_data,
421            index_data: index.to_bytes(),
422            encoded_bytes_copied,
423        }))
424    }
425
426    /// Get an object from the pack.
427    ///
428    /// Verifies that the tagged id at the indexed offset matches
429    /// `id` before returning. A stale `.idx` file (e.g., overwritten
430    /// in place after a pack rebuild) can otherwise route a request
431    /// for hash `A` to a record physically located at hash `B`'s
432    /// offset — same shape, different content, no error signal.
433    /// This cheap 32-byte id comparison catches that without paying
434    /// a full content-hash recompute on every read; corruption
435    /// strictly *inside* the record body is a separate failure mode
436    /// surfaced via the consumer-side hash verify (see
437    /// `FsStore::loose_blob_path` for the blob equivalent).
438    pub fn get_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
439        let offset = match self.index.find(id)? {
440            Some(offset) => checked_index_offset(offset)?,
441            None => return Ok(None),
442        };
443
444        let record = self.read_record_at_depth(id, offset, 0)?;
445        Ok(Some((record.obj_type, record.data)))
446    }
447
448    pub fn get_hashed_object(&self, hash: &ContentHash) -> Result<Option<(ObjectType, Vec<u8>)>> {
449        self.get_object(&PackObjectId::Hash(*hash))
450    }
451
452    /// Read an object's logical type from pack headers without reading or
453    /// decoding its payload.
454    ///
455    /// Delta entries inherit the type of their base, so this follows only the
456    /// tagged base ids and headers until it reaches a non-delta entry. No
457    /// compressed or delta payload bytes are decoded. Missing hashes return
458    /// `Ok(None)`.
459    pub fn get_hashed_object_type(&self, hash: &ContentHash) -> Result<Option<ObjectType>> {
460        let id = PackObjectId::Hash(*hash);
461        let Some(offset) = self.index.find(&id)? else {
462            return Ok(None);
463        };
464        self.read_object_type_at_depth(&id, checked_index_offset(offset)?, 0)
465            .map(Some)
466    }
467
468    /// Zero-copy fast path: when the entry is non-delta and stored
469    /// uncompressed, returns `Bytes::slice` into the pack's
470    /// (mmap-backed) buffer — no allocation, no memcpy. Compressed
471    /// or delta entries fall back to `get_object` and wrap the
472    /// resulting `Vec<u8>` in a `Bytes` (one Arc, no body copy).
473    ///
474    /// Use this from the hot read path. The 10 MB benchmark gap
475    /// between the mount and vanilla FS at the 1 MB+ tier is the
476    /// per-blob memcpy this method eliminates.
477    pub fn get_object_bytes(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Bytes)>> {
478        let Some(offset) = self.index.find(id)? else {
479            return Ok(None);
480        };
481        let offset = checked_index_offset(offset)?;
482        if offset >= self.content_end {
483            return Err(StoreError::InvalidObject(
484                "Entry offset out of bounds".to_string(),
485            ));
486        }
487
488        // Verify the tagged id at the indexed offset matches the
489        // requested id — guards against stale-index misrouting (see
490        // `get_object` for the long-form rationale). 32-byte
491        // compare; cheaper than the size+varint decode that follows.
492        let (record_id, id_len) = PackObjectId::decode_tagged(self.content_from(offset)?)?;
493        let header_start = checked_index_add(offset, id_len, "record header start")?;
494        let (encoded_type, uncompressed_size, type_len) =
495            varint::decode_type_and_size(self.content_from(header_start)?).ok_or_else(|| {
496                StoreError::InvalidObject("Truncated type+size varint".to_string())
497            })?;
498        let obj_type = decoded_entry_type(record_id, encoded_type)?;
499        let uncompressed_size = checked_decoded_size("uncompressed_size", uncompressed_size)?;
500        let varint_start = checked_index_add(header_start, type_len, "compressed_size start")?;
501        let (compressed_size, comp_len) = varint::decode_varint(self.content_from(varint_start)?)
502            .ok_or_else(truncated_compressed_size_varint)?;
503        let compressed_size = checked_decoded_size("compressed_size", compressed_size)?;
504
505        // Fast path: non-delta entry stored uncompressed. The most
506        // common shape for snapshot-time packs (the builder skips
507        // the delta search for unrelated blobs).
508        if record_id == *id && obj_type != ObjectType::Delta && compressed_size == uncompressed_size
509        {
510            let data_start = checked_index_add(varint_start, comp_len, "entry data start")?;
511            let data_end = checked_data_end(data_start, compressed_size, self.content_end)?;
512            let data = &self.data.as_slice()[data_start..data_end];
513            if !is_compact_frame(data) {
514                return Ok(Some((obj_type, self.data.slice(data_start..data_end))));
515            }
516        }
517
518        // Slow path: defer to the full record reader (it handles
519        // decompression + delta chains) and Bytes-wrap the Vec.
520        // Bytes::from(Vec) is a single Arc allocation, no body copy.
521        let record = self.read_record_at_depth(id, offset, 0)?;
522        Ok(Some((record.obj_type, Bytes::from(record.data))))
523    }
524
525    pub fn get_hashed_object_bytes(
526        &self,
527        hash: &ContentHash,
528    ) -> Result<Option<(ObjectType, Bytes)>> {
529        self.get_object_bytes(&PackObjectId::Hash(*hash))
530    }
531
532    /// Read just the type+size header for an object without
533    /// decompressing its payload. Returns `Ok(None)` when the object
534    /// isn't in this pack.
535    ///
536    /// For non-delta entries this is one varint decode at the indexed
537    /// offset — much cheaper than `get_object`. Delta entries fall
538    /// back to a full read because their *resolved* size requires
539    /// chasing the base; in practice deltas are rare in the directory
540    /// listing hot path so the fallback is acceptable.
541    pub fn get_hashed_object_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
542        let id = PackObjectId::Hash(*hash);
543        let Some(offset) = self.index.find(&id)? else {
544            return Ok(None);
545        };
546        let offset = checked_index_offset(offset)?;
547        if offset >= self.content_end {
548            return Err(StoreError::InvalidObject(
549                "Entry offset out of bounds".to_string(),
550            ));
551        }
552        let (record_id, id_len) = PackObjectId::decode_tagged(self.content_from(offset)?)?;
553        let header_start = checked_index_add(offset, id_len, "record header start")?;
554        let (obj_type, uncompressed_size, _type_len) = super::varint::decode_type_and_size(
555            self.content_from(header_start)?,
556        )
557        .ok_or_else(|| StoreError::InvalidObject("Truncated type+size varint".to_string()))?;
558        if (obj_type == ObjectType::Blob && self.aliased_offsets.contains(&(offset as u64)))
559            || matches!(obj_type, ObjectType::Tree | ObjectType::State)
560        {
561            let Some((_, data)) = self.get_object(&id)? else {
562                return Ok(None);
563            };
564            return Ok(Some(data.len() as u64));
565        }
566        verify_record_id_matches(&id, &record_id)?;
567        if obj_type == ObjectType::Delta {
568            // Delta entries record the *resolved* output size in the
569            // type+size varint already (see `read_record_at_depth`'s
570            // size-mismatch check), so we can still return without
571            // decompressing the payload.
572            return Ok(Some(uncompressed_size));
573        }
574        Ok(Some(uncompressed_size))
575    }
576
577    fn read_object_type_at_depth(
578        &self,
579        requested_id: &PackObjectId,
580        offset: usize,
581        depth: usize,
582    ) -> Result<ObjectType> {
583        if depth > MAX_DELTA_CHAIN_DEPTH {
584            return Err(StoreError::InvalidObject(format!(
585                "Delta chain depth {depth} exceeds max {MAX_DELTA_CHAIN_DEPTH}"
586            )));
587        }
588        if offset >= self.content_end {
589            return Err(StoreError::InvalidObject(
590                "Entry offset out of bounds".to_string(),
591            ));
592        }
593
594        let header = decode_tagged_entry_header(self.content_from(offset)?)?;
595        if header.id != *requested_id {
596            return self
597                .read_record_at_depth(requested_id, offset, depth)
598                .map(|record| record.obj_type);
599        }
600        if header.obj_type != ObjectType::Delta {
601            return Ok(header.obj_type);
602        }
603
604        let base_hash = Self::require_delta_base_hash(header.delta_base)?;
605        let base_id = PackObjectId::Hash(base_hash);
606        let base_offset = self
607            .index
608            .find(&base_id)?
609            .ok_or_else(|| StoreError::NotFound(base_hash.to_string()))?;
610        self.read_object_type_at_depth(&base_id, checked_index_offset(base_offset)?, depth + 1)
611    }
612
613    fn read_record_at_depth(
614        &self,
615        requested_id: &PackObjectId,
616        offset: usize,
617        depth: usize,
618    ) -> Result<PackObjectRecord> {
619        if offset >= self.content_end {
620            return Err(StoreError::InvalidObject(
621                "Entry offset out of bounds".to_string(),
622            ));
623        }
624
625        let (id, id_len) = PackObjectId::decode_tagged(self.content_from(offset)?)?;
626        let header_start = checked_index_add(offset, id_len, "record header start")?;
627
628        let (encoded_type, uncompressed_size, type_len) =
629            varint::decode_type_and_size(self.content_from(header_start)?).ok_or_else(|| {
630                StoreError::InvalidObject("Truncated type+size varint".to_string())
631            })?;
632        let obj_type = decoded_entry_type(id, encoded_type)?;
633        let uncompressed_size = checked_decoded_size("uncompressed_size", uncompressed_size)?;
634
635        let varint_start = checked_index_add(header_start, type_len, "compressed_size start")?;
636        let (compressed_size, comp_len) = varint::decode_varint(self.content_from(varint_start)?)
637            .ok_or_else(truncated_compressed_size_varint)?;
638        let compressed_size = checked_decoded_size("compressed_size", compressed_size)?;
639
640        let mut data_start = checked_index_add(varint_start, comp_len, "entry data start")?;
641
642        // Delta entries carry a tagged base id in pack v2.
643        let base_id = if obj_type == ObjectType::Delta {
644            let (base_id, base_len) = PackObjectId::decode_tagged(self.content_from(data_start)?)?;
645            data_start = checked_index_add(data_start, base_len, "delta data start")?;
646            Some(base_id)
647        } else {
648            None
649        };
650
651        let data_end = checked_data_end(data_start, compressed_size, self.content_end)?;
652
653        let stored_data = &self.data.as_slice()[data_start..data_end];
654
655        // Raw zstd (no wrapper). For non-delta entries, decompress
656        // if sizes differ. For delta entries, the stored data IS the delta
657        // payload (possibly zstd-compressed); check for zstd magic.
658        let decompressed = if obj_type == ObjectType::Delta {
659            if has_zstd_magic(stored_data) {
660                decompress_pack_payload(stored_data, 0)?
661            } else {
662                stored_data.to_vec()
663            }
664        } else if compressed_size != uncompressed_size {
665            decompress_pack_payload(stored_data, uncompressed_size)?
666        } else {
667            stored_data.to_vec()
668        };
669
670        let shared_blob =
671            obj_type == ObjectType::Blob && self.aliased_offsets.contains(&(offset as u64));
672        if obj_type != ObjectType::Delta && (shared_blob || is_compact_frame(&decompressed)) {
673            #[cfg(test)]
674            self.record_compact_frame_read();
675            if let Some(data) =
676                decode_compact_object(requested_id, obj_type, &decompressed, shared_blob)?
677            {
678                return Ok(PackObjectRecord {
679                    id: *requested_id,
680                    obj_type,
681                    data,
682                    delta_base: None,
683                    path_hint: None,
684                });
685            }
686        }
687        verify_record_id_matches(requested_id, &id)?;
688        let (resolved_type, final_data) = if obj_type == ObjectType::Delta {
689            self.read_delta_record(base_id, &decompressed, uncompressed_size, depth)?
690        } else {
691            (obj_type, decompressed)
692        };
693
694        if final_data.len() != uncompressed_size {
695            return Err(StoreError::InvalidObject(format!(
696                "Size mismatch: expected {}, got {}",
697                uncompressed_size,
698                final_data.len()
699            )));
700        }
701
702        Ok(PackObjectRecord {
703            id,
704            obj_type: resolved_type,
705            data: final_data,
706            delta_base: None,
707            path_hint: None,
708        })
709    }
710
711    fn read_compact_objects_at(&self, offset: usize) -> Result<Option<DecodedCompactObjects>> {
712        if offset >= self.content_end {
713            return Err(StoreError::InvalidObject(
714                "Entry offset out of bounds".to_string(),
715            ));
716        }
717        let header = decode_tagged_entry_header(self.content_from(offset)?)?;
718        if !matches!(
719            header.obj_type,
720            ObjectType::Blob | ObjectType::Tree | ObjectType::State
721        ) {
722            return Ok(None);
723        }
724        let shared_blob =
725            header.obj_type == ObjectType::Blob && self.aliased_offsets.contains(&(offset as u64));
726        if header.obj_type == ObjectType::Blob && !shared_blob {
727            return Ok(None);
728        }
729        let data_start = checked_index_add(offset, header.header_len, "entry data start")?;
730        let data_end = checked_data_end(data_start, header.compressed_size, self.content_end)?;
731        let stored = &self.data.as_slice()[data_start..data_end];
732        let data = if header.compressed_size != header.uncompressed_size {
733            decompress_pack_payload(stored, header.uncompressed_size)?
734        } else {
735            stored.to_vec()
736        };
737        if data.len() != header.uncompressed_size {
738            return Err(StoreError::InvalidObject(format!(
739                "Size mismatch: expected {}, got {}",
740                header.uncompressed_size,
741                data.len()
742            )));
743        }
744        #[cfg(test)]
745        if shared_blob || is_compact_frame(&data) {
746            self.record_compact_frame_read();
747        }
748        decode_compact_objects(header.obj_type, &data, shared_blob)
749    }
750
751    fn read_delta_record(
752        &self,
753        base_id: Option<PackObjectId>,
754        delta: &[u8],
755        uncompressed_size: usize,
756        depth: usize,
757    ) -> Result<(ObjectType, Vec<u8>)> {
758        if depth > MAX_DELTA_CHAIN_DEPTH {
759            return Err(StoreError::InvalidObject(format!(
760                "Delta chain depth {} exceeds max {}",
761                depth, MAX_DELTA_CHAIN_DEPTH
762            )));
763        }
764
765        if uncompressed_size > MAX_PACK_DELTA_OUTPUT_SIZE {
766            return Err(StoreError::InvalidObject(format!(
767                "Delta output size {} exceeds max {}",
768                uncompressed_size, MAX_PACK_DELTA_OUTPUT_SIZE
769            )));
770        }
771
772        let base_hash = Self::require_delta_base_hash(base_id)?;
773        let base_offset = self
774            .index
775            .find(&PackObjectId::Hash(base_hash))?
776            .ok_or_else(|| StoreError::NotFound(base_hash.to_string()))?;
777        let base_offset = checked_index_offset(base_offset)?;
778        let base_id = PackObjectId::Hash(base_hash);
779        let base_record = self.read_record_at_depth(&base_id, base_offset, depth + 1)?;
780        let base_type = base_record.obj_type;
781        let base_data = base_record.data;
782
783        let decoded = DeltaDecoder::decode(&base_data, delta, uncompressed_size)
784            .map_err(|error| StoreError::InvalidObject(format!("Delta decode failed: {error}")))?;
785
786        Ok((base_type, decoded))
787    }
788
789    fn require_delta_base_hash(base_id: Option<PackObjectId>) -> Result<ContentHash> {
790        match base_id {
791            Some(PackObjectId::Hash(hash)) => Ok(hash),
792            Some(PackObjectId::StateId(_) | PackObjectId::AnnotatedTag(_)) => Err(
793                StoreError::InvalidObject("pack delta base must be hash-backed content".into()),
794            ),
795            None => Err(StoreError::InvalidObject(
796                "pack object type is Delta but base hash is missing".into(),
797            )),
798        }
799    }
800
801    fn content_from(&self, offset: usize) -> Result<&[u8]> {
802        if offset > self.content_end {
803            return Err(StoreError::InvalidObject(
804                "Entry header out of bounds".to_string(),
805            ));
806        }
807        Ok(&self.data.as_slice()[offset..self.content_end])
808    }
809}
810
811fn checked_index_offset(offset: u64) -> Result<usize> {
812    usize::try_from(offset)
813        .map_err(|_| StoreError::InvalidObject("Entry offset exceeds platform limits".to_string()))
814}
815
816fn checked_decoded_size(field: &str, size: u64) -> Result<usize> {
817    let size = usize::try_from(size).map_err(|_| {
818        StoreError::InvalidObject(format!("Decoded {field} exceeds platform limits"))
819    })?;
820    if field == "uncompressed_size" && size > super::shared::MAX_PACK_OBJECT_OUTPUT_SIZE {
821        return Err(StoreError::InvalidObject(format!(
822            "Pack object output size {size} exceeds max {}",
823            super::shared::MAX_PACK_OBJECT_OUTPUT_SIZE
824        )));
825    }
826    Ok(size)
827}
828
829fn checked_index_add(start: usize, len: usize, field: &str) -> Result<usize> {
830    start.checked_add(len).ok_or_else(|| {
831        StoreError::InvalidObject(format!("{field} offset overflows platform limits"))
832    })
833}
834
835fn checked_data_end(
836    data_start: usize,
837    compressed_size: usize,
838    content_end: usize,
839) -> Result<usize> {
840    let data_end = data_start.checked_add(compressed_size).ok_or_else(|| {
841        StoreError::InvalidObject("Entry data range overflows platform limits".to_string())
842    })?;
843    if data_end > content_end {
844        return Err(StoreError::InvalidObject(
845            "Entry data out of bounds".to_string(),
846        ));
847    }
848    Ok(data_end)
849}
850
851fn truncated_compressed_size_varint() -> StoreError {
852    StoreError::InvalidObject("Truncated compressed_size varint".to_string())
853}
854
855fn decoded_entry_type(id: PackObjectId, encoded: ObjectType) -> Result<ObjectType> {
856    if matches!(id, PackObjectId::AnnotatedTag(_)) {
857        if encoded != ObjectType::Blob {
858            return Err(StoreError::InvalidObject(
859                "annotated-tag pack entry has invalid encoded type".to_string(),
860            ));
861        }
862        Ok(ObjectType::AnnotatedTag)
863    } else {
864        Ok(encoded)
865    }
866}
867
868/// Reject a record whose tagged id at the indexed offset doesn't
869/// match the id the caller asked for. The pack format stores its
870/// records `[tagged_id, type+size, compressed_size, payload]` so the
871/// tagged id is the cheapest available authenticator of "we landed
872/// on the right record"; a stale or hand-edited `.idx` that points
873/// at the *wrong* record produces a mismatch here and we surface it
874/// as a real error instead of silently routing the caller to whatever
875/// bytes happened to be at the bad offset.
876fn verify_record_id_matches(requested: &PackObjectId, found: &PackObjectId) -> Result<()> {
877    if requested == found {
878        return Ok(());
879    }
880    Err(StoreError::InvalidObject(format!(
881        "pack index routed lookup for {requested:?} to record tagged {found:?} \
882         — index is stale or corrupt; the loose-store path will re-promote on \
883         the next read"
884    )))
885}
886
887fn is_compact_frame(data: &[u8]) -> bool {
888    heddle_object_model::compact::is_blob_frame(data)
889        || heddle_object_model::compact::is_tree_frame(data)
890        || heddle_object_model::compact::is_state_frame(data)
891}
892
893fn decode_compact_object(
894    requested_id: &PackObjectId,
895    obj_type: ObjectType,
896    data: &[u8],
897    require_blob_frame: bool,
898) -> Result<Option<Vec<u8>>> {
899    match (obj_type, requested_id) {
900        (ObjectType::Tree, PackObjectId::Hash(hash))
901            if heddle_object_model::compact::is_tree_frame(data) =>
902        {
903            let tree = heddle_object_model::compact::extract_tree(data, *hash)
904                .map_err(|error| compact_extract_error(requested_id, error))?;
905            rmp_serde::to_vec_named(&tree)
906                .map(Some)
907                .map_err(|error| StoreError::InvalidObject(error.to_string()))
908        }
909        (ObjectType::State, PackObjectId::StateId(id))
910            if heddle_object_model::compact::is_state_frame(data) =>
911        {
912            let state = heddle_object_model::compact::extract_state(data, *id)
913                .map_err(|error| compact_extract_error(requested_id, error))?;
914            rmp_serde::to_vec_named(&state)
915                .map(Some)
916                .map_err(|error| StoreError::InvalidObject(error.to_string()))
917        }
918        _ => {
919            let Some(objects) = decode_compact_objects(obj_type, data, require_blob_frame)? else {
920                return Ok(None);
921            };
922            objects
923                .into_iter()
924                .find_map(|(id, _, bytes)| (id == *requested_id).then_some(bytes))
925                .map(Some)
926                .ok_or_else(|| compact_index_miss(requested_id))
927        }
928    }
929}
930
931fn compact_extract_error(
932    id: &PackObjectId,
933    error: heddle_object_model::compact::CompactError,
934) -> StoreError {
935    if matches!(error, heddle_object_model::compact::CompactError::Missing) {
936        compact_index_miss(id)
937    } else {
938        StoreError::InvalidObject(error.to_string())
939    }
940}
941
942fn decode_compact_objects(
943    obj_type: ObjectType,
944    data: &[u8],
945    require_blob_frame: bool,
946) -> Result<Option<DecodedCompactObjects>> {
947    match obj_type {
948        ObjectType::Blob if require_blob_frame => {
949            heddle_object_model::compact::decode_blob_frame(data)
950                .map_err(|error| StoreError::InvalidObject(error.to_string()))?
951                .into_iter()
952                .map(|(hash, body)| Ok((PackObjectId::Hash(hash), ObjectType::Blob, body.to_vec())))
953                .collect::<Result<Vec<_>>>()
954                .map(Some)
955        }
956        ObjectType::Blob => Ok(None),
957        ObjectType::Tree if heddle_object_model::compact::is_tree_frame(data) => {
958            heddle_object_model::compact::decode_tree_frame(data)
959                .map_err(|error| StoreError::InvalidObject(error.to_string()))?
960                .into_iter()
961                .map(|tree| {
962                    let id = PackObjectId::Hash(tree.hash());
963                    let bytes = rmp_serde::to_vec_named(&tree)
964                        .map_err(|error| StoreError::InvalidObject(error.to_string()))?;
965                    Ok((id, ObjectType::Tree, bytes))
966                })
967                .collect::<Result<Vec<_>>>()
968                .map(Some)
969        }
970        ObjectType::State if heddle_object_model::compact::is_state_frame(data) => {
971            heddle_object_model::compact::decode_state_frame(data)
972                .map_err(|error| StoreError::InvalidObject(error.to_string()))?
973                .into_iter()
974                .map(|state| {
975                    let id = PackObjectId::StateId(state.state_id);
976                    let bytes = rmp_serde::to_vec_named(&state)
977                        .map_err(|error| StoreError::InvalidObject(error.to_string()))?;
978                    Ok((id, ObjectType::State, bytes))
979                })
980                .collect::<Result<Vec<_>>>()
981                .map(Some)
982        }
983        _ if is_compact_frame(data) => Err(StoreError::InvalidObject(
984            "compact frame magic does not match its pack object type".into(),
985        )),
986        _ => Ok(None),
987    }
988}
989
990fn compact_index_miss(id: &PackObjectId) -> StoreError {
991    StoreError::InvalidObject(format!(
992        "compact frame does not contain indexed object {id:?}"
993    ))
994}
995
996#[cfg(test)]
997mod tests {
998    use super::{PackObjectId, PackReader, verify_record_id_matches};
999    use crate::{object::ContentHash, store::StoreError};
1000
1001    #[test]
1002    fn test_require_delta_base_hash_rejects_missing_hash() {
1003        let error =
1004            PackReader::require_delta_base_hash(None).expect_err("missing hash should fail");
1005
1006        assert!(
1007            matches!(error, StoreError::InvalidObject(message) if message == "pack object type is Delta but base hash is missing")
1008        );
1009    }
1010
1011    #[test]
1012    fn verify_record_id_matches_accepts_identical_ids() {
1013        let id = PackObjectId::Hash(ContentHash::from_bytes([7u8; 32]));
1014        verify_record_id_matches(&id, &id).expect("matching ids must verify");
1015    }
1016
1017    #[test]
1018    fn verify_record_id_matches_rejects_mismatched_ids() {
1019        let asked = PackObjectId::Hash(ContentHash::from_bytes([7u8; 32]));
1020        let found = PackObjectId::Hash(ContentHash::from_bytes([8u8; 32]));
1021        let error = verify_record_id_matches(&asked, &found)
1022            .expect_err("mismatched record id must error rather than silently route");
1023        assert!(
1024            matches!(&error, StoreError::InvalidObject(message) if message.contains("stale or corrupt")),
1025            "stale-index mismatch must surface as InvalidObject with the diagnostic phrase, got: {error:?}",
1026        );
1027    }
1028}