1#[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#[derive(Debug, Clone, Copy, Eq, PartialEq)]
40pub enum PackReadTier {
41 Hot,
43 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
74enum 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 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 pub fn list_ids(&self) -> Result<Vec<PackObjectId>> {
188 self.index.ids()
189 }
190
191 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 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 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 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 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 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 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 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 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 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 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 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 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 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
828fn 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}