1use std::{
62 collections::HashSet,
63 fs::{File, OpenOptions},
64 io::{self, BufWriter, Cursor, Read, Seek, SeekFrom, Write},
65 path::PathBuf,
66};
67
68use heddle_format::compression::CompressionConfig;
69
70use super::{ObjectType, PackObjectId, PackStats, pack_container_spec, write_container_header};
71
72#[cfg(feature = "zstd")]
78const CSIZE_PLACEHOLDER_LEN: usize = 10;
79use crate::{
80 object::ContentHash,
81 store::{Result, StoreError},
82};
83
84const BUCKETS_PER_VARIANT: usize = 256;
90const TOTAL_BUCKETS: usize = BUCKETS_PER_VARIANT * 2;
92const MAX_OPEN_BUCKET_WRITERS: usize = 32;
96
97const HASH_VARIANT: usize = 0;
101const CHANGEID_VARIANT: usize = 1;
102
103pub trait SyncData {
109 fn sync_data_for_durability(&mut self) -> io::Result<()>;
110}
111
112impl SyncData for File {
113 fn sync_data_for_durability(&mut self) -> io::Result<()> {
114 self.sync_all()
115 }
116}
117
118impl SyncData for Cursor<Vec<u8>> {
119 fn sync_data_for_durability(&mut self) -> io::Result<()> {
120 Ok(())
121 }
122}
123
124pub struct StreamingPackBuilder<W: Write + Read + Seek> {
127 pack_writer: Option<BufWriter<W>>,
133 header_offset: u64,
137 pack_position: u64,
140 record_count: u64,
141 object_count: u64,
142 declared_object_count: Option<u64>,
143 total_uncompressed: u64,
144 total_compressed: u64,
145 #[cfg_attr(not(feature = "zstd"), allow(dead_code))]
150 compression: CompressionConfig,
151 bucket_dir: PathBuf,
154 bucket_writers: Vec<Option<BucketWriter>>,
158 open_bucket_writers: usize,
159 bucket_access_tick: u64,
160 bucket_paths: Vec<PathBuf>,
161 index_path: PathBuf,
165 finalized: bool,
168 durable: bool,
169}
170
171struct BucketWriter {
172 writer: BufWriter<File>,
173 last_used: u64,
174}
175
176#[cfg(feature = "zstd")]
177struct CountingWriter<'a, W: Write> {
178 inner: &'a mut W,
179 written: u64,
180}
181
182#[cfg(feature = "zstd")]
183impl<'a, W: Write> CountingWriter<'a, W> {
184 fn new(inner: &'a mut W) -> Self {
185 Self { inner, written: 0 }
186 }
187}
188
189#[cfg(feature = "zstd")]
190impl<W: Write> Write for CountingWriter<'_, W> {
191 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
192 let written = self.inner.write(buf)?;
193 self.written = self.written.saturating_add(written as u64);
194 Ok(written)
195 }
196
197 fn flush(&mut self) -> std::io::Result<()> {
198 self.inner.flush()
199 }
200}
201
202impl<W: Write + Read + Seek + SyncData> StreamingPackBuilder<W> {
203 pub fn new(
219 pack_writer: W,
220 index_path: PathBuf,
221 compression: CompressionConfig,
222 bucket_dir: PathBuf,
223 ) -> Result<Self> {
224 Self::new_inner(pack_writer, index_path, compression, bucket_dir, None, true)
225 }
226
227 pub fn new_with_object_count(
234 pack_writer: W,
235 index_path: PathBuf,
236 compression: CompressionConfig,
237 bucket_dir: PathBuf,
238 object_count: u64,
239 ) -> Result<Self> {
240 Self::new_inner(
241 pack_writer,
242 index_path,
243 compression,
244 bucket_dir,
245 Some(object_count),
246 true,
247 )
248 }
249
250 pub fn new_with_object_count_ephemeral(
257 pack_writer: W,
258 index_path: PathBuf,
259 compression: CompressionConfig,
260 bucket_dir: PathBuf,
261 object_count: u64,
262 ) -> Result<Self> {
263 Self::new_inner(
264 pack_writer,
265 index_path,
266 compression,
267 bucket_dir,
268 Some(object_count),
269 false,
270 )
271 }
272
273 fn new_inner(
274 mut pack_writer: W,
275 index_path: PathBuf,
276 compression: CompressionConfig,
277 bucket_dir: PathBuf,
278 declared_object_count: Option<u64>,
279 durable: bool,
280 ) -> Result<Self> {
281 if durable {
282 heddle_fs_prims::fs_atomic::create_dir_all_durable(&bucket_dir)
283 .map_err(StoreError::from)?;
284 } else {
285 std::fs::create_dir_all(&bucket_dir).map_err(StoreError::from)?;
286 }
287 let header_offset = pack_writer.stream_position().map_err(StoreError::from)?;
288
289 let mut header_bytes = Vec::with_capacity(16);
294 write_container_header(
295 &mut header_bytes,
296 pack_container_spec(),
297 declared_object_count.unwrap_or(0),
298 );
299 pack_writer
300 .write_all(&header_bytes)
301 .map_err(StoreError::from)?;
302
303 let bucket_paths: Vec<PathBuf> = (0..TOTAL_BUCKETS)
304 .map(|i| {
305 let variant = if i < BUCKETS_PER_VARIANT { 'h' } else { 'c' };
306 let prefix = i % BUCKETS_PER_VARIANT;
307 bucket_dir.join(format!("bucket-{variant}-{prefix:02x}"))
308 })
309 .collect();
310 for path in &bucket_paths {
311 let _ = std::fs::remove_file(path);
312 }
313
314 Ok(Self {
315 pack_writer: Some(BufWriter::new(pack_writer)),
316 header_offset,
317 pack_position: header_offset + header_bytes.len() as u64,
318 record_count: 0,
319 object_count: 0,
320 declared_object_count,
321 total_uncompressed: 0,
322 total_compressed: 0,
323 compression,
324 bucket_dir,
325 bucket_writers: (0..TOTAL_BUCKETS).map(|_| None).collect(),
326 open_bucket_writers: 0,
327 bucket_access_tick: 0,
328 bucket_paths,
329 index_path,
330 finalized: false,
331 durable,
332 })
333 }
334
335 pub fn flush_pack(&mut self) -> Result<()> {
340 if let Some(writer) = self.pack_writer.as_mut() {
341 writer.flush().map_err(StoreError::from)?;
342 }
343 Ok(())
344 }
345
346 pub fn add(&mut self, hash: ContentHash, obj_type: ObjectType, data: Vec<u8>) -> Result<()> {
348 self.add_id(PackObjectId::Hash(hash), obj_type, data)
349 }
350
351 pub fn add_id(&mut self, id: PackObjectId, obj_type: ObjectType, data: Vec<u8>) -> Result<()> {
374 let pw = self
379 .pack_writer
380 .as_mut()
381 .expect("add_id called after finalize");
382 let entry_start = self.pack_position;
383 let offset = entry_start
384 .checked_sub(self.header_offset)
385 .expect("header_offset should never be past current position");
386
387 self.total_uncompressed += data.len() as u64;
388
389 let mut header_buf = Vec::with_capacity(40);
392 id.encode_tagged(&mut header_buf);
393 super::varint::encode_type_and_size(obj_type, data.len() as u64, &mut header_buf);
394 pw.write_all(&header_buf).map_err(StoreError::from)?;
395 self.pack_position = self
396 .pack_position
397 .checked_add(header_buf.len() as u64)
398 .ok_or_else(|| {
399 StoreError::InvalidObject("streaming pack position overflow".to_string())
400 })?;
401 #[cfg(feature = "zstd")]
405 let csize_pos = entry_start + header_buf.len() as u64;
406
407 let want_compress: bool;
420 #[cfg(feature = "zstd")]
421 {
422 want_compress = self.compression.enabled && data.len() >= self.compression.min_size;
423 }
424 #[cfg(not(feature = "zstd"))]
425 {
426 want_compress = false;
427 }
428 if !want_compress {
429 let mut csize_buf = Vec::with_capacity(10);
432 super::varint::encode_varint(data.len() as u64, &mut csize_buf);
433 pw.write_all(&csize_buf).map_err(StoreError::from)?;
434 self.pack_position = self
435 .pack_position
436 .checked_add(csize_buf.len() as u64)
437 .ok_or_else(|| {
438 StoreError::InvalidObject("streaming pack position overflow".to_string())
439 })?;
440 pw.write_all(&data).map_err(StoreError::from)?;
441 self.pack_position = self
442 .pack_position
443 .checked_add(data.len() as u64)
444 .ok_or_else(|| {
445 StoreError::InvalidObject("streaming pack position overflow".to_string())
446 })?;
447 self.total_compressed += data.len() as u64;
448 } else {
449 #[cfg(feature = "zstd")]
450 {
451 pw.write_all(&[0u8; CSIZE_PLACEHOLDER_LEN])
454 .map_err(StoreError::from)?;
455 self.pack_position = self
456 .pack_position
457 .checked_add(CSIZE_PLACEHOLDER_LEN as u64)
458 .ok_or_else(|| {
459 StoreError::InvalidObject("streaming pack position overflow".to_string())
460 })?;
461 let body_start = self.pack_position;
462 let compressed_size;
463 {
464 let mut counting = CountingWriter::new(&mut *pw);
465 let mut enc =
466 zstd::stream::write::Encoder::new(&mut counting, self.compression.level)
467 .map_err(StoreError::from)?;
468 enc.set_pledged_src_size(Some(data.len() as u64))
473 .map_err(StoreError::from)?;
474 enc.write_all(&data).map_err(StoreError::from)?;
475 enc.finish().map_err(StoreError::from)?;
476 compressed_size = counting.written;
477 }
478 self.pack_position =
479 self.pack_position
480 .checked_add(compressed_size)
481 .ok_or_else(|| {
482 StoreError::InvalidObject(
483 "streaming pack position overflow".to_string(),
484 )
485 })?;
486 let body_end = body_start.checked_add(compressed_size).ok_or_else(|| {
487 StoreError::InvalidObject("streaming pack position overflow".to_string())
488 })?;
489 self.total_compressed += compressed_size;
490
491 let mut csize_bytes = [0u8; CSIZE_PLACEHOLDER_LEN];
496 encode_varint_padded_to_10(compressed_size, &mut csize_bytes);
497 pw.flush().map_err(StoreError::from)?;
498 let inner = pw.get_mut();
499 inner
500 .seek(SeekFrom::Start(csize_pos))
501 .map_err(StoreError::from)?;
502 inner.write_all(&csize_bytes).map_err(StoreError::from)?;
503 inner
504 .seek(SeekFrom::Start(body_end))
505 .map_err(StoreError::from)?;
506 }
507 #[cfg(not(feature = "zstd"))]
508 {
509 unreachable!("compression branch reached without `zstd` feature");
512 }
513 }
514
515 self.add_index_entry(id, offset)?;
516 self.record_count += 1;
517 self.object_count += 1;
518 Ok(())
519 }
520
521 pub fn add_shared_frame(
528 &mut self,
529 ids: &[PackObjectId],
530 obj_type: ObjectType,
531 uncompressed_size: usize,
532 stored_data: &[u8],
533 ) -> Result<()> {
534 if ids.is_empty() {
535 return Err(StoreError::InvalidObject(
536 "compact frame must contain at least one object".to_string(),
537 ));
538 }
539 if !matches!(
540 obj_type,
541 ObjectType::Blob | ObjectType::Tree | ObjectType::State
542 ) {
543 return Err(StoreError::InvalidObject(
544 "shared compact frames may contain only blobs, trees, or states".to_string(),
545 ));
546 }
547 let unique = ids.iter().copied().collect::<HashSet<_>>();
548 if unique.len() != ids.len() {
549 return Err(StoreError::InvalidObject(
550 "compact frame contains duplicate object ids".to_string(),
551 ));
552 }
553 if ids.iter().any(|id| {
554 !matches!(
555 (obj_type, id),
556 (ObjectType::Blob | ObjectType::Tree, PackObjectId::Hash(_))
557 | (ObjectType::State, PackObjectId::StateId(_))
558 )
559 }) {
560 return Err(StoreError::InvalidObject(
561 "compact frame id kind does not match its object type".to_string(),
562 ));
563 }
564
565 let entry_start = self.pack_position;
566 let offset = entry_start
567 .checked_sub(self.header_offset)
568 .expect("header offset should precede compact frame");
569 let mut header = Vec::with_capacity(48);
570 ids[0].encode_tagged(&mut header);
571 super::varint::encode_type_and_size(obj_type, uncompressed_size as u64, &mut header);
572 super::varint::encode_varint(stored_data.len() as u64, &mut header);
573 let writer = self
574 .pack_writer
575 .as_mut()
576 .expect("add_shared_frame called after finalize");
577 writer.write_all(&header).map_err(StoreError::from)?;
578 writer.write_all(stored_data).map_err(StoreError::from)?;
579 self.pack_position = self
580 .pack_position
581 .checked_add((header.len() + stored_data.len()) as u64)
582 .ok_or_else(|| {
583 StoreError::InvalidObject("streaming pack position overflow".to_string())
584 })?;
585 self.total_uncompressed = self
586 .total_uncompressed
587 .saturating_add(uncompressed_size as u64);
588 self.total_compressed = self
589 .total_compressed
590 .saturating_add(stored_data.len() as u64);
591 self.record_count = self
592 .record_count
593 .checked_add(1)
594 .ok_or_else(|| StoreError::InvalidObject("pack record count overflow".to_string()))?;
595 for id in ids {
596 self.add_index_entry(*id, offset)?;
597 }
598 self.object_count = self
599 .object_count
600 .checked_add(ids.len() as u64)
601 .ok_or_else(|| StoreError::InvalidObject("pack object count overflow".to_string()))?;
602 Ok(())
603 }
604
605 fn add_index_entry(&mut self, id: PackObjectId, offset: u64) -> Result<()> {
606 let bucket_idx = bucket_index_for(&id);
607 let bucket = self.get_or_open_bucket(bucket_idx)?;
608 let mut entry = Vec::with_capacity(33 + 8);
609 id.encode_tagged(&mut entry);
610 entry.extend_from_slice(&offset.to_be_bytes());
611 bucket.write_all(&entry).map_err(StoreError::from)
612 }
613
614 fn get_or_open_bucket(&mut self, idx: usize) -> Result<&mut BufWriter<File>> {
615 self.bucket_access_tick = self.bucket_access_tick.wrapping_add(1);
616 let last_used = self.bucket_access_tick;
617 if self.bucket_writers[idx].is_none() {
618 if self.open_bucket_writers >= MAX_OPEN_BUCKET_WRITERS {
619 self.evict_lru_bucket()?;
620 }
621 let path = &self.bucket_paths[idx];
622 let f = OpenOptions::new()
623 .create(true)
624 .append(true)
625 .open(path)
626 .map_err(StoreError::from)?;
627 self.bucket_writers[idx] = Some(BucketWriter {
628 writer: BufWriter::new(f),
629 last_used,
630 });
631 self.open_bucket_writers += 1;
632 } else if let Some(bucket) = self.bucket_writers[idx].as_mut() {
633 bucket.last_used = last_used;
634 }
635 Ok(&mut self.bucket_writers[idx]
636 .as_mut()
637 .expect("just inserted above")
638 .writer)
639 }
640
641 fn evict_lru_bucket(&mut self) -> Result<()> {
642 let Some((idx, _)) = self
643 .bucket_writers
644 .iter()
645 .enumerate()
646 .filter_map(|(idx, bucket)| bucket.as_ref().map(|bucket| (idx, bucket.last_used)))
647 .min_by_key(|(_, last_used)| *last_used)
648 else {
649 return Ok(());
650 };
651
652 if let Some(mut bucket) = self.bucket_writers[idx].take() {
653 bucket.writer.flush().map_err(StoreError::from)?;
654 self.open_bucket_writers -= 1;
655 }
656 Ok(())
657 }
658
659 pub fn finalize(mut self) -> Result<(W, PackStats)> {
670 for bucket in self.bucket_writers.iter_mut().flatten() {
673 bucket.writer.flush().map_err(StoreError::from)?;
674 }
675 for slot in self.bucket_writers.iter_mut() {
678 *slot = None;
679 }
680 self.open_bucket_writers = 0;
681
682 let bw = self
686 .pack_writer
687 .take()
688 .expect("finalize called twice — pack_writer already consumed");
689 let mut writer = bw
690 .into_inner()
691 .map_err(|e| StoreError::from(std::io::Error::other(e.to_string())))?;
692 if let Some(expected) = self.declared_object_count {
693 if expected != self.record_count {
694 return Err(StoreError::InvalidObject(format!(
695 "streaming pack declared {expected} record(s) but added {}",
696 self.record_count
697 )));
698 }
699 } else {
700 writer
701 .seek(SeekFrom::Start(self.header_offset))
702 .map_err(StoreError::from)?;
703 let mut header_bytes = Vec::with_capacity(16);
704 write_container_header(&mut header_bytes, pack_container_spec(), self.record_count);
705 writer.write_all(&header_bytes).map_err(StoreError::from)?;
706 }
707
708 writer
713 .seek(SeekFrom::Start(self.header_offset))
714 .map_err(StoreError::from)?;
715 let mut hasher = blake3::Hasher::new();
716 let mut buf = vec![0u8; 64 * 1024];
717 loop {
718 let n = writer.read(&mut buf).map_err(StoreError::from)?;
719 if n == 0 {
720 break;
721 }
722 hasher.update(&buf[..n]);
723 }
724 let checksum = hasher.finalize();
725
726 writer.seek(SeekFrom::End(0)).map_err(StoreError::from)?;
728 writer
729 .write_all(checksum.as_bytes())
730 .map_err(StoreError::from)?;
731 writer.flush().map_err(StoreError::from)?;
732 if self.durable {
734 writer
735 .sync_data_for_durability()
736 .map_err(StoreError::from)?;
737 }
738
739 let idx_file = File::create(&self.index_path).map_err(StoreError::from)?;
752 let mut idx_writer = BufWriter::new(idx_file);
753 write_index_header(&mut idx_writer, self.object_count)?;
754 let mut entries_written: u64 = 0;
755 for path in self.bucket_paths.iter() {
756 if !path.exists() {
757 continue;
758 }
759 let bucket_bytes = std::fs::read(path).map_err(StoreError::from)?;
760 let mut entries = decode_bucket_file(&bucket_bytes)?;
761 entries.sort_by_key(|(id, _)| *id);
766 for (id, offset) in entries {
767 write_index_entry(&mut idx_writer, id, offset)?;
768 entries_written += 1;
769 }
770 }
771 idx_writer.flush().map_err(StoreError::from)?;
772 let idx_file = idx_writer
774 .into_inner()
775 .map_err(|e| StoreError::from(std::io::Error::other(e.to_string())))?;
776 if self.durable {
777 idx_file.sync_all().map_err(StoreError::from)?;
778 if let Some(parent) = self.index_path.parent() {
779 heddle_fs_prims::fs_atomic::sync_directory(parent).map_err(StoreError::from)?;
780 }
781 }
782 debug_assert_eq!(
783 entries_written, self.object_count,
784 "streaming index entry count drifted from add() count"
785 );
786
787 for path in self.bucket_paths.iter() {
792 let _ = std::fs::remove_file(path);
793 }
794 let _ = std::fs::remove_dir(&self.bucket_dir);
795 self.finalized = true;
796
797 let stats = PackStats {
798 object_count: self.object_count,
799 total_uncompressed: self.total_uncompressed,
800 total_compressed: self.total_compressed,
801 delta_count: 0,
802 compression_ratio: if self.total_uncompressed == 0 {
803 0.0
804 } else {
805 self.total_compressed as f64 / self.total_uncompressed as f64
806 },
807 };
808
809 Ok((writer, stats))
810 }
811}
812
813fn write_index_header<W: Write>(out: &mut W, count: u64) -> Result<()> {
818 super::pack_index::index_header().write_to(out, count)
819}
820
821fn write_index_entry<W: Write>(out: &mut W, id: PackObjectId, offset: u64) -> Result<()> {
824 let buf = super::pack_index::encode_index_entry(id, offset);
825 out.write_all(&buf).map_err(StoreError::from)
826}
827
828#[cfg(feature = "zstd")]
841fn encode_varint_padded_to_10(value: u64, out: &mut [u8; 10]) {
842 let mut v = value;
843 for slot in out.iter_mut().take(9) {
844 *slot = 0x80 | ((v & 0x7F) as u8);
845 v >>= 7;
846 }
847 out[9] = (v & 0x7F) as u8;
848}
849
850impl<W: Write + Read + Seek> Drop for StreamingPackBuilder<W> {
851 fn drop(&mut self) {
852 if self.finalized {
853 return;
854 }
855 for path in self.bucket_paths.iter() {
858 let _ = std::fs::remove_file(path);
859 }
860 let _ = std::fs::remove_dir(&self.bucket_dir);
861 }
862}
863
864fn bucket_index_for(id: &PackObjectId) -> usize {
868 match id {
869 PackObjectId::Hash(h) => HASH_VARIANT * BUCKETS_PER_VARIANT + h.as_bytes()[0] as usize,
870 PackObjectId::StateId(c) => {
871 CHANGEID_VARIANT * BUCKETS_PER_VARIANT + c.as_bytes()[0] as usize
872 }
873 }
874}
875
876fn decode_bucket_file(bytes: &[u8]) -> Result<Vec<(PackObjectId, u64)>> {
881 let mut out = Vec::new();
882 let mut pos = 0;
883 while pos < bytes.len() {
884 let (id, id_len) = PackObjectId::decode_tagged(&bytes[pos..])?;
885 pos += id_len;
886 if pos + 8 > bytes.len() {
887 return Err(StoreError::InvalidObject(
888 "streaming bucket entry truncated at offset".to_string(),
889 ));
890 }
891 let offset = u64::from_be_bytes(bytes[pos..pos + 8].try_into().map_err(|_| {
892 StoreError::InvalidObject("streaming bucket bad offset slice".to_string())
893 })?);
894 pos += 8;
895 out.push((id, offset));
896 }
897 Ok(out)
898}
899
900#[cfg(test)]
903mod tests {
904 use std::io::Cursor;
905
906 use super::*;
907 use crate::{
908 object::StateId,
909 store::pack::{PackReader, PackStats},
910 };
911
912 fn deterministic_hash(seed: u8) -> ContentHash {
913 let mut bytes = [0u8; 32];
917 bytes[0] = seed;
918 for (i, b) in bytes.iter_mut().enumerate().skip(1) {
919 *b = seed.wrapping_mul(31).wrapping_add(i as u8);
920 }
921 ContentHash::from_bytes(bytes)
922 }
923
924 fn deterministic_state_id(seed: u8) -> StateId {
925 let mut bytes = [0u8; 32];
926 bytes[0] = seed;
927 for (i, b) in bytes.iter_mut().enumerate().skip(1) {
928 *b = seed.wrapping_add(i as u8 * 7);
929 }
930 StateId::from_bytes(bytes)
931 }
932
933 fn fresh_builder(
938 tmp: &tempfile::TempDir,
939 ) -> (StreamingPackBuilder<Cursor<Vec<u8>>>, PathBuf, PathBuf) {
940 let bucket_dir = tmp.path().join("buckets");
941 let index_path = tmp.path().join("test.idx");
942 let cursor = Cursor::new(Vec::<u8>::new());
943 let b = StreamingPackBuilder::new(
944 cursor,
945 index_path.clone(),
946 CompressionConfig::default(),
947 bucket_dir.clone(),
948 )
949 .unwrap();
950 (b, bucket_dir, index_path)
951 }
952
953 fn finalize_cursor(
958 b: StreamingPackBuilder<Cursor<Vec<u8>>>,
959 index_path: &std::path::Path,
960 ) -> (Vec<u8>, Vec<u8>, PackStats) {
961 let (cursor, stats) = b.finalize().unwrap();
962 let index_bytes = std::fs::read(index_path).unwrap();
963 (cursor.into_inner(), index_bytes, stats)
964 }
965
966 #[test]
967 fn empty_pack_finalizes_to_valid_zero_count_pack() {
968 let tmp = tempfile::TempDir::new().unwrap();
969 let (b, bucket_dir, idx_path) = fresh_builder(&tmp);
970 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
971
972 assert_eq!(stats.object_count, 0);
973 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
975 assert!(reader.list_ids().unwrap().is_empty());
976 assert!(
978 !bucket_dir.exists(),
979 "bucket dir should be cleaned on successful finalize"
980 );
981 }
982
983 #[test]
984 fn single_blob_with_hash_id_round_trips() {
985 let tmp = tempfile::TempDir::new().unwrap();
986 let (mut b, _, idx_path) = fresh_builder(&tmp);
987 let hash = deterministic_hash(0x42);
988 let payload = b"hello, streaming pack".to_vec();
989 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
990 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
991
992 assert_eq!(stats.object_count, 1);
993 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
994 let id = PackObjectId::Hash(hash);
995 assert!(reader.has_object(&id).unwrap());
996 let (got_type, got_data) = reader.get_object(&id).unwrap().unwrap();
997 assert_eq!(got_type, ObjectType::Blob);
998 assert_eq!(got_data, payload);
999 }
1000
1001 #[test]
1002 fn single_state_with_change_id_round_trips() {
1003 let tmp = tempfile::TempDir::new().unwrap();
1004 let (mut b, _, idx_path) = fresh_builder(&tmp);
1005 let cid = deterministic_state_id(0xa5);
1006 let payload = b"serialized-state-bytes".to_vec();
1007 b.add_id(
1008 PackObjectId::StateId(cid),
1009 ObjectType::State,
1010 payload.clone(),
1011 )
1012 .unwrap();
1013 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1014
1015 assert_eq!(stats.object_count, 1);
1016 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1017 let id = PackObjectId::StateId(cid);
1018 let (ty, data) = reader.get_object(&id).unwrap().unwrap();
1019 assert_eq!(ty, ObjectType::State);
1020 assert_eq!(data, payload);
1021 }
1022
1023 #[test]
1024 fn shared_compact_tree_frame_reconstructs_each_indexed_object() {
1025 use crate::object::{Tree, TreeEntry};
1026
1027 let tmp = tempfile::TempDir::new().unwrap();
1028 let (mut builder, _, index_path) = fresh_builder(&tmp);
1029 let blob = deterministic_hash(0x33);
1030 let trees = vec![
1031 Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1032 Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1033 ];
1034 let ids = trees
1035 .iter()
1036 .map(|tree| PackObjectId::Hash(tree.hash()))
1037 .collect::<Vec<_>>();
1038 let frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1039 builder
1040 .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1041 .unwrap();
1042 let (pack, index, stats) = finalize_cursor(builder, &index_path);
1043 let reader = PackReader::from_bytes(pack, index).unwrap();
1044
1045 assert_eq!(stats.object_count, 2);
1046 assert_eq!(
1047 reader.encoded_payload_bytes(ObjectType::Tree).unwrap(),
1048 frame.len() as u64
1049 );
1050 let hosted = ids
1051 .iter()
1052 .zip(&trees)
1053 .map(|(id, tree)| {
1054 (
1055 *id,
1056 ObjectType::Tree,
1057 rmp_serde::to_vec_named(tree).unwrap().len() as u64,
1058 )
1059 })
1060 .collect::<Vec<_>>();
1061 assert!(
1062 reader
1063 .copy_hosted_encoded_subset(&hosted)
1064 .unwrap()
1065 .is_none(),
1066 "repository-local compact frames must use the hosted fallback"
1067 );
1068 for (id, tree) in ids.iter().zip(&trees) {
1069 let (object_type, bytes) = reader.get_object(id).unwrap().unwrap();
1070 assert_eq!(object_type, ObjectType::Tree);
1071 assert_eq!(bytes, rmp_serde::to_vec_named(tree).unwrap());
1072 }
1073 }
1074
1075 #[test]
1076 fn compact_tree_extraction_rejects_an_index_alias_with_the_wrong_typed_hash() {
1077 use crate::object::{Tree, TreeEntry};
1078
1079 let tmp = tempfile::TempDir::new().unwrap();
1080 let (mut builder, _, index_path) = fresh_builder(&tmp);
1081 let blob = deterministic_hash(0x34);
1082 let trees = vec![
1083 Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1084 Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1085 ];
1086 let wrong_hash = ContentHash::compute_typed("tree", b"not the second tree");
1087 assert_ne!(wrong_hash, trees[1].hash());
1088 let ids = vec![
1089 PackObjectId::Hash(trees[0].hash()),
1090 PackObjectId::Hash(wrong_hash),
1091 ];
1092 let frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1093 builder
1094 .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1095 .unwrap();
1096 let (pack, index, _) = finalize_cursor(builder, &index_path);
1097 let reader = PackReader::from_bytes(pack, index).unwrap();
1098
1099 let error = reader.get_object(&ids[1]).unwrap_err();
1100 assert!(
1101 error
1102 .to_string()
1103 .contains("does not contain indexed object"),
1104 "extraction must derive and verify the tree's typed hash: {error}"
1105 );
1106 }
1107
1108 #[test]
1109 fn shared_lineage_blob_frame_reconstructs_each_indexed_object() {
1110 let tmp = tempfile::TempDir::new().unwrap();
1111 let (mut builder, _, index_path) = fresh_builder(&tmp);
1112 let bodies = [b"newest version".as_slice(), b"older version".as_slice()];
1113 let ids = bodies
1114 .iter()
1115 .map(|body| PackObjectId::Hash(ContentHash::compute_typed("blob", body)))
1116 .collect::<Vec<_>>();
1117 let frame = heddle_object_model::compact::encode_blob_frame(&bodies).unwrap();
1118 builder
1119 .add_shared_frame(&ids, ObjectType::Blob, frame.len(), &frame)
1120 .unwrap();
1121 let (pack, index, stats) = finalize_cursor(builder, &index_path);
1122 let reader = PackReader::from_bytes(pack, index).unwrap();
1123
1124 assert_eq!(stats.object_count, bodies.len() as u64);
1125 assert_eq!(
1126 reader.encoded_payload_bytes(ObjectType::Blob).unwrap(),
1127 frame.len() as u64
1128 );
1129 for (id, expected) in ids.iter().zip(bodies) {
1130 let (object_type, actual) = reader.get_object(id).unwrap().unwrap();
1131 assert_eq!(object_type, ObjectType::Blob);
1132 assert_eq!(actual, expected);
1133 let PackObjectId::Hash(hash) = id else {
1134 unreachable!("blob ids are hashes")
1135 };
1136 assert_eq!(
1137 reader.get_hashed_object_type(hash).unwrap(),
1138 Some(ObjectType::Blob)
1139 );
1140 assert_eq!(
1141 reader.get_hashed_object_size(hash).unwrap(),
1142 Some(expected.len() as u64)
1143 );
1144 }
1145 }
1146
1147 #[test]
1148 fn ordinary_blob_starting_with_frame_magic_remains_ordinary() {
1149 let tmp = tempfile::TempDir::new().unwrap();
1150 let (mut builder, _, index_path) = fresh_builder(&tmp);
1151 let body = b"HCB2 arbitrary user content".to_vec();
1152 let hash = ContentHash::compute_typed("blob", &body);
1153 builder.add(hash, ObjectType::Blob, body.clone()).unwrap();
1154 let (pack, index, _) = finalize_cursor(builder, &index_path);
1155 let reader = PackReader::from_bytes(pack, index).unwrap();
1156
1157 assert_eq!(
1158 reader.get_object(&PackObjectId::Hash(hash)).unwrap(),
1159 Some((ObjectType::Blob, body))
1160 );
1161 }
1162
1163 #[test]
1164 fn corrupt_compact_frame_byte_invalidates_every_contained_object() {
1165 use crate::object::{Tree, TreeEntry};
1166
1167 let tmp = tempfile::TempDir::new().unwrap();
1168 let (mut builder, _, index_path) = fresh_builder(&tmp);
1169 let blob = deterministic_hash(0x44);
1170 let trees = vec![
1171 Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1172 Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1173 ];
1174 let ids = trees
1175 .iter()
1176 .map(|tree| PackObjectId::Hash(tree.hash()))
1177 .collect::<Vec<_>>();
1178 let mut frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1179 let corrupt_at = frame.len() / 2;
1180 frame[corrupt_at] ^= 0x01;
1181 builder
1182 .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1183 .unwrap();
1184 let (pack, index, _) = finalize_cursor(builder, &index_path);
1185 let reader = PackReader::from_bytes(pack, index).unwrap();
1186
1187 for id in ids {
1188 let error = reader.get_object(&id).unwrap_err();
1189 assert!(
1190 error
1191 .to_string()
1192 .contains("compact frame checksum mismatch"),
1193 "unexpected error for {id:?}: {error}"
1194 );
1195 }
1196 }
1197
1198 #[test]
1199 fn corrupt_blob_frame_byte_invalidates_every_contained_object() {
1200 let tmp = tempfile::TempDir::new().unwrap();
1201 let (mut builder, _, index_path) = fresh_builder(&tmp);
1202 let bodies = [b"newest version".as_slice(), b"older version".as_slice()];
1203 let ids = bodies
1204 .iter()
1205 .map(|body| PackObjectId::Hash(ContentHash::compute_typed("blob", body)))
1206 .collect::<Vec<_>>();
1207 let mut frame = heddle_object_model::compact::encode_blob_frame(&bodies).unwrap();
1208 let corrupt_at = frame.len() / 2;
1209 frame[corrupt_at] ^= 0x01;
1210 builder
1211 .add_shared_frame(&ids, ObjectType::Blob, frame.len(), &frame)
1212 .unwrap();
1213 let (pack, index, _) = finalize_cursor(builder, &index_path);
1214 let reader = PackReader::from_bytes(pack, index).unwrap();
1215
1216 for id in ids {
1217 let error = reader.get_object(&id).unwrap_err();
1218 assert!(
1219 error
1220 .to_string()
1221 .contains("compact frame checksum mismatch"),
1222 "unexpected error for {id:?}: {error}"
1223 );
1224 }
1225 }
1226
1227 #[test]
1228 fn mixed_hash_and_changeid_ids_all_retrievable() {
1229 let tmp = tempfile::TempDir::new().unwrap();
1230 let (mut b, _, idx_path) = fresh_builder(&tmp);
1231 let blob_hash = deterministic_hash(0x10);
1232 let tree_hash = deterministic_hash(0x20);
1233 let state_cid = deterministic_state_id(0x80);
1234
1235 b.add(blob_hash, ObjectType::Blob, b"blob-bytes".to_vec())
1236 .unwrap();
1237 b.add(tree_hash, ObjectType::Tree, b"serialized-tree".to_vec())
1238 .unwrap();
1239 b.add_id(
1240 PackObjectId::StateId(state_cid),
1241 ObjectType::State,
1242 b"serialized-state".to_vec(),
1243 )
1244 .unwrap();
1245
1246 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1247 assert_eq!(stats.object_count, 3);
1248 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1249 assert_eq!(
1250 reader
1251 .get_object(&PackObjectId::Hash(blob_hash))
1252 .unwrap()
1253 .unwrap()
1254 .1,
1255 b"blob-bytes".to_vec()
1256 );
1257 assert_eq!(
1258 reader
1259 .get_object(&PackObjectId::Hash(tree_hash))
1260 .unwrap()
1261 .unwrap()
1262 .1,
1263 b"serialized-tree".to_vec()
1264 );
1265 assert_eq!(
1266 reader
1267 .get_object(&PackObjectId::StateId(state_cid))
1268 .unwrap()
1269 .unwrap()
1270 .1,
1271 b"serialized-state".to_vec()
1272 );
1273 }
1274
1275 #[test]
1276 fn ten_thousand_objects_round_trip_correctly() {
1277 let tmp = tempfile::TempDir::new().unwrap();
1281 let (mut b, _, idx_path) = fresh_builder(&tmp);
1282 let mut hashes = Vec::with_capacity(10_000);
1283 for i in 0..10_000u32 {
1284 let h = blake3::hash(&i.to_le_bytes());
1287 let hash = ContentHash::from_bytes(*h.as_bytes());
1288 hashes.push(hash);
1289 b.add(hash, ObjectType::Blob, format!("payload-{i}").into_bytes())
1290 .unwrap();
1291 }
1292 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1293 assert_eq!(stats.object_count, 10_000);
1294
1295 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1296 assert_eq!(reader.list_ids().unwrap().len(), 10_000);
1297 for i in [0, 1, 99, 1234, 5_000, 9_999] {
1299 let id = PackObjectId::Hash(hashes[i]);
1300 let (_ty, data) = reader.get_object(&id).unwrap().unwrap();
1301 assert_eq!(data, format!("payload-{i}").into_bytes());
1302 }
1303 }
1304
1305 #[test]
1306 fn bucket_writers_are_lru_capped_below_fd_limit() {
1307 let tmp = tempfile::TempDir::new().unwrap();
1308 let (mut b, _bucket_dir, idx_path) = fresh_builder(&tmp);
1309 let mut ids = Vec::new();
1310
1311 for i in 0..BUCKETS_PER_VARIANT {
1312 let hash = deterministic_hash(i as u8);
1313 ids.push(PackObjectId::Hash(hash));
1314 b.add(hash, ObjectType::Blob, format!("hash-{i}").into_bytes())
1315 .unwrap();
1316 assert!(
1317 b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1318 "open bucket writers should stay capped"
1319 );
1320 }
1321
1322 for i in 0..BUCKETS_PER_VARIANT {
1323 let cid = deterministic_state_id(i as u8);
1324 ids.push(PackObjectId::StateId(cid));
1325 b.add_id(
1326 PackObjectId::StateId(cid),
1327 ObjectType::State,
1328 format!("state-{i}").into_bytes(),
1329 )
1330 .unwrap();
1331 assert!(
1332 b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1333 "open bucket writers should stay capped"
1334 );
1335 }
1336
1337 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1338 assert_eq!(stats.object_count, TOTAL_BUCKETS as u64);
1339 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1340 for id in ids {
1341 assert!(reader.has_object(&id).unwrap(), "missing id {id:?}");
1342 }
1343 }
1344
1345 #[test]
1346 fn index_id_sort_order_matches_packbuilder_output() {
1347 use crate::store::pack::PackBuilder;
1355 let payloads: Vec<(PackObjectId, ObjectType, Vec<u8>)> = (0..200u32)
1356 .map(|i| {
1357 let h = blake3::hash(&i.to_le_bytes());
1358 (
1359 PackObjectId::Hash(ContentHash::from_bytes(*h.as_bytes())),
1360 if i % 3 == 0 {
1361 ObjectType::Tree
1362 } else {
1363 ObjectType::Blob
1364 },
1365 format!("body-{i}").into_bytes(),
1366 )
1367 })
1368 .collect();
1369
1370 let compression = CompressionConfig {
1373 max_delta_size: 0,
1374 ..CompressionConfig::default()
1375 };
1376 let mut classic = PackBuilder::new(compression);
1377 for (id, ty, data) in payloads.iter() {
1378 classic.add_id(*id, *ty, data.clone());
1379 }
1380 let (classic_pack, classic_index, _) = classic.build().unwrap();
1381 let classic_reader = PackReader::from_bytes(classic_pack, classic_index).unwrap();
1382
1383 let tmp = tempfile::TempDir::new().unwrap();
1384 let bucket_dir = tmp.path().join("buckets");
1385 let idx_path = tmp.path().join("test.idx");
1386 let cursor = Cursor::new(Vec::<u8>::new());
1387 let mut streaming =
1388 StreamingPackBuilder::new(cursor, idx_path.clone(), compression, bucket_dir).unwrap();
1389 for (id, ty, data) in payloads.iter() {
1390 streaming.add_id(*id, *ty, data.clone()).unwrap();
1391 }
1392 let (streaming_pack, streaming_index, _) = finalize_cursor(streaming, &idx_path);
1393 let streaming_reader = PackReader::from_bytes(streaming_pack, streaming_index).unwrap();
1394
1395 assert_eq!(
1398 streaming_reader.list_ids().unwrap(),
1399 classic_reader.list_ids().unwrap(),
1400 "streaming and classic indices should report the same id sequence"
1401 );
1402 for (id, _ty, want) in payloads.iter().take(10).chain(payloads.iter().skip(190)) {
1405 let (_, got) = streaming_reader.get_object(id).unwrap().unwrap();
1406 assert_eq!(&got, want);
1407 let (_, classic_got) = classic_reader.get_object(id).unwrap().unwrap();
1408 assert_eq!(got, classic_got);
1409 }
1410 }
1411
1412 #[test]
1413 fn corrupted_pack_fails_checksum_verification() {
1414 let tmp = tempfile::TempDir::new().unwrap();
1415 let (mut b, _, idx_path) = fresh_builder(&tmp);
1416 b.add(
1417 deterministic_hash(0x01),
1418 ObjectType::Blob,
1419 b"some bytes".to_vec(),
1420 )
1421 .unwrap();
1422 let (mut pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1423 let body_byte = 18; pack_data[body_byte] ^= 0xff;
1426 let result = PackReader::from_bytes(pack_data, index_data);
1427 assert!(
1428 result.is_err(),
1429 "PackReader should reject pack with mutated body"
1430 );
1431 }
1432
1433 #[test]
1434 fn pack_count_in_header_matches_index_entry_count() {
1435 let tmp = tempfile::TempDir::new().unwrap();
1436 let (mut b, _, idx_path) = fresh_builder(&tmp);
1437 for i in 0..7u8 {
1438 b.add(
1439 deterministic_hash(i),
1440 ObjectType::Blob,
1441 format!("p{i}").into_bytes(),
1442 )
1443 .unwrap();
1444 }
1445 let (pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1446 let count = u64::from_be_bytes(pack_data[8..16].try_into().unwrap());
1448 assert_eq!(count, 7);
1449 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1450 assert_eq!(reader.list_ids().unwrap().len(), 7);
1451 }
1452
1453 #[test]
1454 fn declared_pack_count_is_written_before_finalize() {
1455 let tmp = tempfile::TempDir::new().unwrap();
1456 let bucket_dir = tmp.path().join("buckets");
1457 let idx_path = tmp.path().join("test.idx");
1458 let cursor = Cursor::new(Vec::<u8>::new());
1459 let mut b = StreamingPackBuilder::new_with_object_count(
1460 cursor,
1461 idx_path.clone(),
1462 CompressionConfig::default(),
1463 bucket_dir,
1464 2,
1465 )
1466 .unwrap();
1467
1468 b.flush_pack().unwrap();
1469 let initial = b.pack_writer.as_ref().unwrap().get_ref().get_ref().clone();
1470 assert_eq!(u64::from_be_bytes(initial[8..16].try_into().unwrap()), 2);
1471
1472 let hash = deterministic_hash(0x40);
1473 b.add(hash, ObjectType::Blob, b"known-count-entry".to_vec())
1474 .unwrap();
1475 b.flush_pack().unwrap();
1476 let after_add = b.pack_writer.as_ref().unwrap().get_ref().get_ref().clone();
1477 assert_eq!(u64::from_be_bytes(after_add[8..16].try_into().unwrap()), 2);
1478
1479 let second_hash = deterministic_hash(0x41);
1480 b.add(second_hash, ObjectType::Blob, b"second-entry".to_vec())
1481 .unwrap();
1482 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1483
1484 assert_eq!(stats.object_count, 2);
1485 assert_eq!(u64::from_be_bytes(pack_data[8..16].try_into().unwrap()), 2);
1486 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1487 assert!(reader.has_object(&PackObjectId::Hash(hash)).unwrap());
1488 assert!(reader.has_object(&PackObjectId::Hash(second_hash)).unwrap());
1489 }
1490
1491 #[test]
1492 fn declared_pack_count_mismatch_fails_finalize() {
1493 let tmp = tempfile::TempDir::new().unwrap();
1494 let bucket_dir = tmp.path().join("buckets");
1495 let idx_path = tmp.path().join("test.idx");
1496 let cursor = Cursor::new(Vec::<u8>::new());
1497 let mut b = StreamingPackBuilder::new_with_object_count(
1498 cursor,
1499 idx_path,
1500 CompressionConfig::default(),
1501 bucket_dir,
1502 2,
1503 )
1504 .unwrap();
1505
1506 b.add(
1507 deterministic_hash(0x50),
1508 ObjectType::Blob,
1509 b"only-entry".to_vec(),
1510 )
1511 .unwrap();
1512 let error = b.finalize().unwrap_err();
1513
1514 assert!(
1515 error
1516 .to_string()
1517 .contains("streaming pack declared 2 record(s) but added 1")
1518 );
1519 }
1520
1521 #[test]
1522 fn bucket_files_are_cleaned_on_successful_finalize() {
1523 let tmp = tempfile::TempDir::new().unwrap();
1524 let bucket_dir = tmp.path().join("buckets");
1525 let idx_path = tmp.path().join("test.idx");
1526 let cursor = Cursor::new(Vec::<u8>::new());
1527 let mut b = StreamingPackBuilder::new(
1528 cursor,
1529 idx_path.clone(),
1530 CompressionConfig::default(),
1531 bucket_dir.clone(),
1532 )
1533 .unwrap();
1534 for i in 0..50u8 {
1535 b.add(deterministic_hash(i), ObjectType::Blob, vec![i; 32])
1536 .unwrap();
1537 }
1538 assert!(bucket_dir.exists());
1540 let bucket_count = std::fs::read_dir(&bucket_dir).unwrap().count();
1541 assert!(bucket_count > 0, "bucket dir should hold some files");
1542 let _ = finalize_cursor(b, &idx_path);
1543 assert!(
1544 !bucket_dir.exists(),
1545 "bucket dir should be removed on finalize"
1546 );
1547 }
1548
1549 #[test]
1550 fn bucket_files_are_cleaned_on_drop_without_finalize() {
1551 let tmp = tempfile::TempDir::new().unwrap();
1552 let bucket_dir = tmp.path().join("buckets");
1553 let idx_path = tmp.path().join("test.idx");
1554 {
1555 let cursor = Cursor::new(Vec::<u8>::new());
1556 let mut b = StreamingPackBuilder::new(
1557 cursor,
1558 idx_path.clone(),
1559 CompressionConfig::default(),
1560 bucket_dir.clone(),
1561 )
1562 .unwrap();
1563 for i in 0..10u8 {
1564 b.add(deterministic_hash(i), ObjectType::Blob, vec![0; 32])
1565 .unwrap();
1566 }
1567 assert!(bucket_dir.exists());
1568 }
1570 assert!(
1571 !idx_path.exists(),
1572 "no index file should have been created without finalize"
1573 );
1574 assert!(
1575 !bucket_dir.exists(),
1576 "bucket dir should be removed on Drop when finalize never ran"
1577 );
1578 }
1579
1580 #[test]
1581 fn large_blob_streams_to_disk_without_double_buffering() {
1582 let tmp = tempfile::TempDir::new().unwrap();
1587 let bucket_dir = tmp.path().join("buckets");
1588 let pack_path = tmp.path().join("pack.dat");
1589 let idx_path = tmp.path().join("pack.idx");
1590 let file = std::fs::OpenOptions::new()
1591 .read(true)
1592 .write(true)
1593 .create(true)
1594 .truncate(true)
1595 .open(&pack_path)
1596 .unwrap();
1597 let mut b = StreamingPackBuilder::new(
1598 file,
1599 idx_path.clone(),
1600 CompressionConfig::default(),
1601 bucket_dir,
1602 )
1603 .unwrap();
1604 let payload: Vec<u8> = (0..4 * 1024 * 1024u32).map(|i| (i & 0xff) as u8).collect();
1605 let hash = deterministic_hash(0xff);
1606 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1607 let (_, stats) = b.finalize().unwrap();
1608 let index_data = std::fs::read(&idx_path).unwrap();
1609 assert_eq!(stats.object_count, 1);
1610 let pack_bytes = std::fs::read(&pack_path).unwrap();
1611 let reader = PackReader::from_bytes(pack_bytes, index_data).unwrap();
1614 let (_ty, got) = reader
1615 .get_object(&PackObjectId::Hash(hash))
1616 .unwrap()
1617 .unwrap();
1618 assert_eq!(got, payload);
1619 }
1620
1621 #[test]
1622 fn bucket_distribution_for_random_hashes_is_roughly_uniform() {
1623 let tmp = tempfile::TempDir::new().unwrap();
1630 let bucket_dir = tmp.path().join("buckets");
1631 let idx_path = tmp.path().join("test.idx");
1632 let cursor = Cursor::new(Vec::<u8>::new());
1633 let mut b = StreamingPackBuilder::new(
1634 cursor,
1635 idx_path.clone(),
1636 CompressionConfig::default(),
1637 bucket_dir.clone(),
1638 )
1639 .unwrap();
1640 for i in 0..1024u32 {
1641 let h = blake3::hash(&i.to_le_bytes());
1642 let hash = ContentHash::from_bytes(*h.as_bytes());
1643 b.add(hash, ObjectType::Blob, b"x".to_vec()).unwrap();
1644 }
1645 b.pack_writer.as_mut().unwrap().flush().unwrap();
1647 let mut max_entries = 0usize;
1648 let entry_size = 33 + 8;
1651 for path in b.bucket_paths.iter() {
1652 if path.exists() {
1653 let size = std::fs::metadata(path).unwrap().len() as usize;
1654 let entries = size / entry_size;
1655 if entries > max_entries {
1656 max_entries = entries;
1657 }
1658 }
1659 }
1660 assert!(
1663 max_entries <= 16,
1664 "max bucket has {max_entries} entries; uniform expected ~4"
1665 );
1666 let _ = finalize_cursor(b, &idx_path);
1667 }
1668
1669 #[test]
1670 fn finalize_returns_correct_stats() {
1671 let tmp = tempfile::TempDir::new().unwrap();
1672 let (mut b, _, idx_path) = fresh_builder(&tmp);
1673 let payload = vec![0xabu8; 1024];
1674 for i in 0..5u8 {
1675 b.add(deterministic_hash(i), ObjectType::Blob, payload.clone())
1676 .unwrap();
1677 }
1678 let (_, _, stats) = finalize_cursor(b, &idx_path);
1679 assert_eq!(stats.object_count, 5);
1680 assert_eq!(stats.total_uncompressed, 5 * 1024);
1681 assert!(stats.total_compressed > 0);
1682 assert!(stats.compression_ratio > 0.0);
1683 assert_eq!(stats.delta_count, 0, "streaming builder never deltas");
1684 }
1685
1686 #[cfg(feature = "zstd")]
1687 #[test]
1688 fn streaming_compression_roundtrips_through_zstd_frame() {
1689 let tmp = tempfile::TempDir::new().unwrap();
1697 let (mut b, _, idx_path) = fresh_builder(&tmp);
1698 let payload = vec![0u8; 64 * 1024];
1701 let hash = deterministic_hash(0x77);
1702 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1703 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1704 assert!(
1705 stats.total_compressed < stats.total_uncompressed,
1706 "expected compression ratio < 1.0, got {}/{}",
1707 stats.total_compressed,
1708 stats.total_uncompressed
1709 );
1710 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1711 let (_ty, got) = reader
1712 .get_object(&PackObjectId::Hash(hash))
1713 .unwrap()
1714 .unwrap();
1715 assert_eq!(got, payload);
1716 }
1717
1718 #[cfg(feature = "zstd")]
1719 #[test]
1720 fn padded_varint_decodes_to_original_value_for_canonical_decoder() {
1721 let cases: &[u64] = &[0, 1, 127, 128, 4096, 1_000_000, 1_000_000_000_000, u64::MAX];
1727 for &value in cases {
1728 let mut buf = [0u8; 10];
1729 super::encode_varint_padded_to_10(value, &mut buf);
1730 let (decoded, consumed) = super::super::varint::decode_varint(&buf)
1731 .expect("padded varint should always decode");
1732 assert_eq!(decoded, value, "varint roundtrip failed for {value}");
1733 assert_eq!(
1734 consumed, 10,
1735 "padded encoding should consume all 10 bytes for {value}"
1736 );
1737 }
1738 }
1739
1740 #[cfg(feature = "zstd")]
1741 #[test]
1742 fn streaming_path_does_not_buffer_compressed_payload_in_memory() {
1743 let tmp = tempfile::TempDir::new().unwrap();
1756 let bucket_dir = tmp.path().join("buckets");
1757 let pack_path = tmp.path().join("pack.dat");
1758 let idx_path = tmp.path().join("pack.idx");
1759 let file = std::fs::OpenOptions::new()
1760 .read(true)
1761 .write(true)
1762 .create(true)
1763 .truncate(true)
1764 .open(&pack_path)
1765 .unwrap();
1766 let mut b = StreamingPackBuilder::new(
1767 file,
1768 idx_path.clone(),
1769 CompressionConfig::default(),
1770 bucket_dir,
1771 )
1772 .unwrap();
1773 let payload = vec![0xa5u8; 8 * 1024 * 1024];
1774 let hash = deterministic_hash(0x66);
1775 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1776 let mid_size = std::fs::metadata(&pack_path).unwrap().len();
1780 assert!(
1781 mid_size > 16 + 40,
1782 "pack file should hold real entry data after add; size={mid_size}"
1783 );
1784 let (_, _) = b.finalize().unwrap();
1785 let pack_bytes = std::fs::read(&pack_path).unwrap();
1786 let index_bytes = std::fs::read(&idx_path).unwrap();
1787 let reader = PackReader::from_bytes(pack_bytes, index_bytes).unwrap();
1788 let (_ty, got) = reader
1789 .get_object(&PackObjectId::Hash(hash))
1790 .unwrap()
1791 .unwrap();
1792 assert_eq!(got, payload);
1793 }
1794
1795 #[test]
1796 fn list_ids_returns_all_added_ids_sorted() {
1797 let tmp = tempfile::TempDir::new().unwrap();
1798 let (mut b, _, idx_path) = fresh_builder(&tmp);
1799 let mut added: Vec<PackObjectId> = Vec::new();
1800 for seed in [0x05u8, 0xa0, 0x12, 0x9f, 0x33] {
1802 let id = PackObjectId::Hash(deterministic_hash(seed));
1803 b.add_id(id, ObjectType::Blob, vec![seed; 4]).unwrap();
1804 added.push(id);
1805 }
1806 for seed in [0x80u8, 0x10, 0xff] {
1807 let id = PackObjectId::StateId(deterministic_state_id(seed));
1808 b.add_id(id, ObjectType::State, vec![seed; 4]).unwrap();
1809 added.push(id);
1810 }
1811 let (pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1812 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1813 let mut got = reader.list_ids().unwrap();
1814 let mut sorted = got.clone();
1817 sorted.sort();
1818 assert_eq!(got, sorted, "list_ids must come back sorted");
1819 added.sort();
1821 got.sort();
1822 assert_eq!(got, added);
1823 }
1824}