1use std::{collections::HashSet, fs::File, io::Read, path::Path};
5
6use bytes::Bytes;
7use heddle_format::delta::{DeltaDecoder, MAX_DELTA_OUTPUT_SIZE};
8
9use super::{
10 ObjectType, PackObjectId, PackObjectRecord, append_container_checksum,
11 decode_tagged_entry_header, decompress_pack_payload, has_zstd_magic, pack_container_spec,
12 pack_index::PackIndex, varint, verify_container, write_container_header,
13};
14use crate::{
15 object::ContentHash,
16 store::{Result, StoreError},
17};
18
19const MAX_PACK_DELTA_OUTPUT_SIZE: usize = MAX_DELTA_OUTPUT_SIZE;
20const MAX_DELTA_CHAIN_DEPTH: usize = 50;
21const MMAP_THRESHOLD_BYTES: u64 = 256 * 1024;
22
23fn read_file_bytes_for_pack(path: &Path) -> Result<Bytes> {
24 let file = File::open(path)?;
25 let len = file.metadata()?.len();
26 if len == 0 {
27 return Ok(Bytes::new());
28 }
29 if len >= MMAP_THRESHOLD_BYTES {
30 let mmap = unsafe { memmap2::MmapOptions::new().map(&file)? };
31 if mmap.len() != checked_file_len_to_usize(len)? {
32 return Err(StoreError::InvalidObject(
33 "pack file size changed during memory mapping".to_string(),
34 ));
35 }
36 return Ok(Bytes::from_owner(mmap));
37 }
38 let mut data = Vec::with_capacity(checked_file_len_to_usize(len)?);
39 let mut reader = file;
40 reader.read_to_end(&mut data)?;
41 Ok(Bytes::from(data))
42}
43
44fn checked_file_len_to_usize(len: u64) -> Result<usize> {
45 usize::try_from(len).map_err(|_| {
46 StoreError::InvalidObject(format!("file length {len} exceeds platform limits"))
47 })
48}
49
50enum PackData<'a> {
59 Borrowed(&'a [u8]),
60 Owned(Bytes),
61}
62
63impl<'a> PackData<'a> {
64 fn as_slice(&self) -> &[u8] {
65 match self {
66 Self::Borrowed(data) => data,
67 Self::Owned(data) => data,
68 }
69 }
70
71 fn slice(&self, range: std::ops::Range<usize>) -> Bytes {
72 match self {
73 Self::Borrowed(data) => Bytes::copy_from_slice(&data[range]),
74 Self::Owned(data) => data.slice(range),
75 }
76 }
77}
78
79pub struct PackReader<'a> {
80 data: PackData<'a>,
81 index: PackIndex,
82 content_end: usize,
83}
84
85#[derive(Debug, Clone)]
86pub struct EncodedPackSubset {
87 pub pack_data: Vec<u8>,
88 pub index_data: Vec<u8>,
89 pub encoded_bytes_copied: u64,
90}
91
92impl PackReader<'static> {
93 pub fn open(pack_path: &Path, index_path: &Path) -> Result<Self> {
97 let pack_bytes = read_file_bytes_for_pack(pack_path)?;
98 let index_data = std::fs::read(index_path)?;
99 let (_, _, content_end) = verify_container(&pack_bytes, pack_container_spec())?;
100 let index = PackIndex::from_bytes(&index_data)?;
101 Ok(Self {
102 data: PackData::Owned(pack_bytes),
103 index,
104 content_end,
105 })
106 }
107
108 pub fn from_bytes(pack_data: impl Into<Bytes>, index_data: impl AsRef<[u8]>) -> Result<Self> {
109 let pack_data = pack_data.into();
110 let (_, _, content_end) = verify_container(&pack_data, pack_container_spec())?;
111 let index = PackIndex::from_bytes(index_data.as_ref())?;
112 Ok(Self {
113 data: PackData::Owned(pack_data),
114 index,
115 content_end,
116 })
117 }
118}
119
120impl<'a> PackReader<'a> {
121 pub fn from_slice(pack_data: &'a [u8], index_data: impl AsRef<[u8]>) -> Result<Self> {
122 let (_, _, content_end) = verify_container(pack_data, pack_container_spec())?;
123 let index = PackIndex::from_bytes(index_data.as_ref())?;
124 Ok(Self {
125 data: PackData::Borrowed(pack_data),
126 index,
127 content_end,
128 })
129 }
130
131 pub fn list_ids(&self) -> Vec<PackObjectId> {
133 self.index.ids()
134 }
135
136 pub fn list_hashes(&self) -> Vec<ContentHash> {
137 self.list_ids()
138 .into_iter()
139 .filter_map(|id| match id {
140 PackObjectId::Hash(hash) => Some(hash),
141 PackObjectId::StateId(_) => None,
142 })
143 .collect()
144 }
145
146 pub fn has_object(&self, id: &PackObjectId) -> bool {
147 self.index.find(id).is_some()
148 }
149
150 pub fn copy_hosted_encoded_subset(
157 &self,
158 expected: &[(PackObjectId, ObjectType, u64)],
159 ) -> Result<Option<EncodedPackSubset>> {
160 if expected.is_empty() {
161 return Ok(None);
162 }
163 let mut unique = HashSet::with_capacity(expected.len());
164 if expected.iter().any(|(id, obj_type, _)| {
165 !unique.insert(*id)
166 || matches!(
167 obj_type,
168 ObjectType::Delta | ObjectType::StateAttachment | ObjectType::SnapshotCommit
169 )
170 }) {
171 return Ok(None);
172 }
173
174 let mut pack_data = Vec::new();
175 write_container_header(&mut pack_data, pack_container_spec(), expected.len() as u64);
176 let mut index = PackIndex::new();
177 let mut encoded_bytes_copied = 0u64;
178 for (expected_id, expected_type, expected_size) in expected {
179 let Some(offset) = self.index.find(expected_id) else {
180 return Ok(None);
181 };
182 let offset = checked_index_offset(offset)?;
183 if offset >= self.content_end {
184 return Err(StoreError::InvalidObject(
185 "Entry offset out of bounds".to_string(),
186 ));
187 }
188 let header = decode_tagged_entry_header(self.content_from(offset)?)?;
189 let expected_size = usize::try_from(*expected_size).ok();
190 if header.id != *expected_id
191 || header.obj_type != *expected_type
192 || Some(header.uncompressed_size) != expected_size
193 || matches!(
194 header.obj_type,
195 ObjectType::Delta | ObjectType::StateAttachment | ObjectType::SnapshotCommit
196 )
197 {
198 return Ok(None);
199 }
200 let encoded_len = header
201 .header_len
202 .checked_add(header.compressed_size)
203 .ok_or_else(|| {
204 StoreError::InvalidObject("pack entry length overflow".to_string())
205 })?;
206 let encoded_end = offset
207 .checked_add(encoded_len)
208 .ok_or_else(|| StoreError::InvalidObject("pack entry end overflow".to_string()))?;
209 if encoded_end > self.content_end {
210 return Err(StoreError::InvalidObject(
211 "pack entry extends beyond content boundary".to_string(),
212 ));
213 }
214 let output_offset = u64::try_from(pack_data.len()).map_err(|_| {
215 StoreError::InvalidObject("reused pack offset exceeds u64".to_string())
216 })?;
217 index.add(*expected_id, output_offset);
218 pack_data.extend_from_slice(&self.data.as_slice()[offset..encoded_end]);
219 encoded_bytes_copied = encoded_bytes_copied
220 .checked_add(u64::try_from(encoded_len).map_err(|_| {
221 StoreError::InvalidObject("encoded pack entry length exceeds u64".to_string())
222 })?)
223 .ok_or_else(|| {
224 StoreError::InvalidObject("encoded reused byte count overflow".to_string())
225 })?;
226 }
227 index.sort();
228 append_container_checksum(&mut pack_data);
229 Ok(Some(EncodedPackSubset {
230 pack_data,
231 index_data: index.to_bytes(),
232 encoded_bytes_copied,
233 }))
234 }
235
236 pub fn get_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
249 let offset = match self.index.find(id) {
250 Some(offset) => checked_index_offset(offset)?,
251 None => return Ok(None),
252 };
253
254 let record = self.read_record_at_depth(offset, 0)?;
255 verify_record_id_matches(id, &record.id)?;
256 Ok(Some((record.obj_type, record.data)))
257 }
258
259 pub fn get_hashed_object(&self, hash: &ContentHash) -> Result<Option<(ObjectType, Vec<u8>)>> {
260 self.get_object(&PackObjectId::Hash(*hash))
261 }
262
263 pub fn get_object_bytes(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Bytes)>> {
273 let Some(offset) = self.index.find(id) else {
274 return Ok(None);
275 };
276 let offset = checked_index_offset(offset)?;
277 if offset >= self.content_end {
278 return Err(StoreError::InvalidObject(
279 "Entry offset out of bounds".to_string(),
280 ));
281 }
282
283 let (record_id, id_len) = PackObjectId::decode_tagged(self.content_from(offset)?)?;
288 verify_record_id_matches(id, &record_id)?;
289 let header_start = checked_index_add(offset, id_len, "record header start")?;
290 let (obj_type, uncompressed_size, type_len) =
291 varint::decode_type_and_size(self.content_from(header_start)?).ok_or_else(|| {
292 StoreError::InvalidObject("Truncated type+size varint".to_string())
293 })?;
294 let uncompressed_size = checked_decoded_size("uncompressed_size", uncompressed_size)?;
295 let varint_start = checked_index_add(header_start, type_len, "compressed_size start")?;
296 let (compressed_size, comp_len) = varint::decode_varint(self.content_from(varint_start)?)
297 .ok_or_else(truncated_compressed_size_varint)?;
298 let compressed_size = checked_decoded_size("compressed_size", compressed_size)?;
299
300 if obj_type != ObjectType::Delta && compressed_size == uncompressed_size {
304 let data_start = checked_index_add(varint_start, comp_len, "entry data start")?;
305 let data_end = checked_data_end(data_start, compressed_size, self.content_end)?;
306 return Ok(Some((obj_type, self.data.slice(data_start..data_end))));
307 }
308
309 let record = self.read_record_at_depth(offset, 0)?;
313 Ok(Some((record.obj_type, Bytes::from(record.data))))
314 }
315
316 pub fn get_hashed_object_bytes(
317 &self,
318 hash: &ContentHash,
319 ) -> Result<Option<(ObjectType, Bytes)>> {
320 self.get_object_bytes(&PackObjectId::Hash(*hash))
321 }
322
323 pub fn get_hashed_object_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
333 let id = PackObjectId::Hash(*hash);
334 let Some(offset) = self.index.find(&id) else {
335 return Ok(None);
336 };
337 let offset = checked_index_offset(offset)?;
338 if offset >= self.content_end {
339 return Err(StoreError::InvalidObject(
340 "Entry offset out of bounds".to_string(),
341 ));
342 }
343 let (record_id, id_len) = PackObjectId::decode_tagged(self.content_from(offset)?)?;
344 verify_record_id_matches(&id, &record_id)?;
345 let header_start = checked_index_add(offset, id_len, "record header start")?;
346 let (obj_type, uncompressed_size, _type_len) = super::varint::decode_type_and_size(
347 self.content_from(header_start)?,
348 )
349 .ok_or_else(|| StoreError::InvalidObject("Truncated type+size varint".to_string()))?;
350 if obj_type == ObjectType::Delta {
351 return Ok(Some(uncompressed_size));
356 }
357 Ok(Some(uncompressed_size))
358 }
359
360 fn read_record_at_depth(&self, offset: usize, depth: usize) -> Result<PackObjectRecord> {
361 if offset >= self.content_end {
362 return Err(StoreError::InvalidObject(
363 "Entry offset out of bounds".to_string(),
364 ));
365 }
366
367 let (id, id_len) = PackObjectId::decode_tagged(self.content_from(offset)?)?;
368 let header_start = checked_index_add(offset, id_len, "record header start")?;
369
370 let (obj_type, uncompressed_size, type_len) =
371 varint::decode_type_and_size(self.content_from(header_start)?).ok_or_else(|| {
372 StoreError::InvalidObject("Truncated type+size varint".to_string())
373 })?;
374 let uncompressed_size = checked_decoded_size("uncompressed_size", uncompressed_size)?;
375
376 let varint_start = checked_index_add(header_start, type_len, "compressed_size start")?;
377 let (compressed_size, comp_len) = varint::decode_varint(self.content_from(varint_start)?)
378 .ok_or_else(truncated_compressed_size_varint)?;
379 let compressed_size = checked_decoded_size("compressed_size", compressed_size)?;
380
381 let mut data_start = checked_index_add(varint_start, comp_len, "entry data start")?;
382
383 let base_id = if obj_type == ObjectType::Delta {
385 let (base_id, base_len) = PackObjectId::decode_tagged(self.content_from(data_start)?)?;
386 data_start = checked_index_add(data_start, base_len, "delta data start")?;
387 Some(base_id)
388 } else {
389 None
390 };
391
392 let data_end = checked_data_end(data_start, compressed_size, self.content_end)?;
393
394 let stored_data = &self.data.as_slice()[data_start..data_end];
395
396 let decompressed = if obj_type == ObjectType::Delta {
400 if has_zstd_magic(stored_data) {
401 decompress_pack_payload(stored_data, 0)?
402 } else {
403 stored_data.to_vec()
404 }
405 } else if compressed_size != uncompressed_size {
406 decompress_pack_payload(stored_data, uncompressed_size)?
407 } else {
408 stored_data.to_vec()
409 };
410
411 let (resolved_type, final_data) = if obj_type == ObjectType::Delta {
412 self.read_delta_record(base_id, &decompressed, uncompressed_size, depth)?
413 } else {
414 (obj_type, decompressed)
415 };
416
417 if final_data.len() != uncompressed_size {
418 return Err(StoreError::InvalidObject(format!(
419 "Size mismatch: expected {}, got {}",
420 uncompressed_size,
421 final_data.len()
422 )));
423 }
424
425 Ok(PackObjectRecord {
426 id,
427 obj_type: resolved_type,
428 data: final_data,
429 delta_base: None,
430 path_hint: None,
431 })
432 }
433
434 fn read_delta_record(
435 &self,
436 base_id: Option<PackObjectId>,
437 delta: &[u8],
438 uncompressed_size: usize,
439 depth: usize,
440 ) -> Result<(ObjectType, Vec<u8>)> {
441 if depth > MAX_DELTA_CHAIN_DEPTH {
442 return Err(StoreError::InvalidObject(format!(
443 "Delta chain depth {} exceeds max {}",
444 depth, MAX_DELTA_CHAIN_DEPTH
445 )));
446 }
447
448 if uncompressed_size > MAX_PACK_DELTA_OUTPUT_SIZE {
449 return Err(StoreError::InvalidObject(format!(
450 "Delta output size {} exceeds max {}",
451 uncompressed_size, MAX_PACK_DELTA_OUTPUT_SIZE
452 )));
453 }
454
455 let base_hash = Self::require_delta_base_hash(base_id)?;
456 let base_offset = self
457 .index
458 .find(&PackObjectId::Hash(base_hash))
459 .ok_or_else(|| StoreError::NotFound(base_hash.to_string()))?;
460 let base_offset = checked_index_offset(base_offset)?;
461 let base_record = self.read_record_at_depth(base_offset, depth + 1)?;
462 let base_type = base_record.obj_type;
463 let base_data = base_record.data;
464
465 let decoded = DeltaDecoder::decode(&base_data, delta, uncompressed_size)
466 .map_err(|error| StoreError::InvalidObject(format!("Delta decode failed: {error}")))?;
467
468 Ok((base_type, decoded))
469 }
470
471 fn require_delta_base_hash(base_id: Option<PackObjectId>) -> Result<ContentHash> {
472 match base_id {
473 Some(PackObjectId::Hash(hash)) => Ok(hash),
474 Some(PackObjectId::StateId(_)) => Err(StoreError::InvalidObject(
475 "pack delta base must be hash-backed content".into(),
476 )),
477 None => Err(StoreError::InvalidObject(
478 "pack object type is Delta but base hash is missing".into(),
479 )),
480 }
481 }
482
483 fn content_from(&self, offset: usize) -> Result<&[u8]> {
484 if offset > self.content_end {
485 return Err(StoreError::InvalidObject(
486 "Entry header out of bounds".to_string(),
487 ));
488 }
489 Ok(&self.data.as_slice()[offset..self.content_end])
490 }
491}
492
493fn checked_index_offset(offset: u64) -> Result<usize> {
494 usize::try_from(offset)
495 .map_err(|_| StoreError::InvalidObject("Entry offset exceeds platform limits".to_string()))
496}
497
498fn checked_decoded_size(field: &str, size: u64) -> Result<usize> {
499 let size = usize::try_from(size).map_err(|_| {
500 StoreError::InvalidObject(format!("Decoded {field} exceeds platform limits"))
501 })?;
502 if field == "uncompressed_size" && size > super::shared::MAX_PACK_OBJECT_OUTPUT_SIZE {
503 return Err(StoreError::InvalidObject(format!(
504 "Pack object output size {size} exceeds max {}",
505 super::shared::MAX_PACK_OBJECT_OUTPUT_SIZE
506 )));
507 }
508 Ok(size)
509}
510
511fn checked_index_add(start: usize, len: usize, field: &str) -> Result<usize> {
512 start.checked_add(len).ok_or_else(|| {
513 StoreError::InvalidObject(format!("{field} offset overflows platform limits"))
514 })
515}
516
517fn checked_data_end(
518 data_start: usize,
519 compressed_size: usize,
520 content_end: usize,
521) -> Result<usize> {
522 let data_end = data_start.checked_add(compressed_size).ok_or_else(|| {
523 StoreError::InvalidObject("Entry data range overflows platform limits".to_string())
524 })?;
525 if data_end > content_end {
526 return Err(StoreError::InvalidObject(
527 "Entry data out of bounds".to_string(),
528 ));
529 }
530 Ok(data_end)
531}
532
533fn truncated_compressed_size_varint() -> StoreError {
534 StoreError::InvalidObject("Truncated compressed_size varint".to_string())
535}
536
537fn verify_record_id_matches(requested: &PackObjectId, found: &PackObjectId) -> Result<()> {
546 if requested == found {
547 return Ok(());
548 }
549 Err(StoreError::InvalidObject(format!(
550 "pack index routed lookup for {requested:?} to record tagged {found:?} \
551 — index is stale or corrupt; the loose-store path will re-promote on \
552 the next read"
553 )))
554}
555
556#[cfg(test)]
557mod tests {
558 use super::{PackObjectId, PackReader, verify_record_id_matches};
559 use crate::{object::ContentHash, store::StoreError};
560
561 #[test]
562 fn test_require_delta_base_hash_rejects_missing_hash() {
563 let error =
564 PackReader::require_delta_base_hash(None).expect_err("missing hash should fail");
565
566 assert!(
567 matches!(error, StoreError::InvalidObject(message) if message == "pack object type is Delta but base hash is missing")
568 );
569 }
570
571 #[test]
572 fn verify_record_id_matches_accepts_identical_ids() {
573 let id = PackObjectId::Hash(ContentHash::from_bytes([7u8; 32]));
574 verify_record_id_matches(&id, &id).expect("matching ids must verify");
575 }
576
577 #[test]
578 fn verify_record_id_matches_rejects_mismatched_ids() {
579 let asked = PackObjectId::Hash(ContentHash::from_bytes([7u8; 32]));
580 let found = PackObjectId::Hash(ContentHash::from_bytes([8u8; 32]));
581 let error = verify_record_id_matches(&asked, &found)
582 .expect_err("mismatched record id must error rather than silently route");
583 assert!(
584 matches!(&error, StoreError::InvalidObject(message) if message.contains("stale or corrupt")),
585 "stale-index mismatch must surface as InvalidObject with the diagnostic phrase, got: {error:?}",
586 );
587 }
588}