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