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 if compressed_size == data.len() as u64 {
519 inner
520 .seek(SeekFrom::Start(body_start))
521 .map_err(StoreError::from)?;
522 inner.write_all(&data).map_err(StoreError::from)?;
523 }
524 inner
525 .seek(SeekFrom::Start(body_end))
526 .map_err(StoreError::from)?;
527 }
528 #[cfg(not(feature = "zstd"))]
529 {
530 unreachable!("compression branch reached without `zstd` feature");
533 }
534 }
535
536 self.add_index_entry(id, offset)?;
537 self.record_count += 1;
538 self.object_count += 1;
539 Ok(())
540 }
541
542 pub fn add_shared_frame(
549 &mut self,
550 ids: &[PackObjectId],
551 obj_type: ObjectType,
552 uncompressed_size: usize,
553 stored_data: &[u8],
554 ) -> Result<()> {
555 if ids.is_empty() {
556 return Err(StoreError::InvalidObject(
557 "compact frame must contain at least one object".to_string(),
558 ));
559 }
560 if !matches!(
561 obj_type,
562 ObjectType::Blob | ObjectType::Tree | ObjectType::State
563 ) {
564 return Err(StoreError::InvalidObject(
565 "shared compact frames may contain only blobs, trees, or states".to_string(),
566 ));
567 }
568 let unique = ids.iter().copied().collect::<HashSet<_>>();
569 if unique.len() != ids.len() {
570 return Err(StoreError::InvalidObject(
571 "compact frame contains duplicate object ids".to_string(),
572 ));
573 }
574 if ids.iter().any(|id| {
575 !matches!(
576 (obj_type, id),
577 (ObjectType::Blob | ObjectType::Tree, PackObjectId::Hash(_))
578 | (ObjectType::State, PackObjectId::StateId(_))
579 )
580 }) {
581 return Err(StoreError::InvalidObject(
582 "compact frame id kind does not match its object type".to_string(),
583 ));
584 }
585
586 let entry_start = self.pack_position;
587 let offset = entry_start
588 .checked_sub(self.header_offset)
589 .expect("header offset should precede compact frame");
590 let mut header = Vec::with_capacity(48);
591 ids[0].encode_tagged(&mut header);
592 super::varint::encode_type_and_size(obj_type, uncompressed_size as u64, &mut header);
593 super::varint::encode_varint(stored_data.len() as u64, &mut header);
594 let writer = self
595 .pack_writer
596 .as_mut()
597 .expect("add_shared_frame called after finalize");
598 writer.write_all(&header).map_err(StoreError::from)?;
599 writer.write_all(stored_data).map_err(StoreError::from)?;
600 self.pack_position = self
601 .pack_position
602 .checked_add((header.len() + stored_data.len()) as u64)
603 .ok_or_else(|| {
604 StoreError::InvalidObject("streaming pack position overflow".to_string())
605 })?;
606 self.total_uncompressed = self
607 .total_uncompressed
608 .saturating_add(uncompressed_size as u64);
609 self.total_compressed = self
610 .total_compressed
611 .saturating_add(stored_data.len() as u64);
612 self.record_count = self
613 .record_count
614 .checked_add(1)
615 .ok_or_else(|| StoreError::InvalidObject("pack record count overflow".to_string()))?;
616 for id in ids {
617 self.add_index_entry(*id, offset)?;
618 }
619 self.object_count = self
620 .object_count
621 .checked_add(ids.len() as u64)
622 .ok_or_else(|| StoreError::InvalidObject("pack object count overflow".to_string()))?;
623 Ok(())
624 }
625
626 fn add_index_entry(&mut self, id: PackObjectId, offset: u64) -> Result<()> {
627 let bucket_idx = bucket_index_for(&id);
628 let bucket = self.get_or_open_bucket(bucket_idx)?;
629 let mut entry = Vec::with_capacity(33 + 8);
630 id.encode_tagged(&mut entry);
631 entry.extend_from_slice(&offset.to_be_bytes());
632 bucket.write_all(&entry).map_err(StoreError::from)
633 }
634
635 fn get_or_open_bucket(&mut self, idx: usize) -> Result<&mut BufWriter<File>> {
636 self.bucket_access_tick = self.bucket_access_tick.wrapping_add(1);
637 let last_used = self.bucket_access_tick;
638 if self.bucket_writers[idx].is_none() {
639 if self.open_bucket_writers >= MAX_OPEN_BUCKET_WRITERS {
640 self.evict_lru_bucket()?;
641 }
642 let path = &self.bucket_paths[idx];
643 let f = OpenOptions::new()
644 .create(true)
645 .append(true)
646 .open(path)
647 .map_err(StoreError::from)?;
648 self.bucket_writers[idx] = Some(BucketWriter {
649 writer: BufWriter::new(f),
650 last_used,
651 });
652 self.open_bucket_writers += 1;
653 } else if let Some(bucket) = self.bucket_writers[idx].as_mut() {
654 bucket.last_used = last_used;
655 }
656 Ok(&mut self.bucket_writers[idx]
657 .as_mut()
658 .expect("just inserted above")
659 .writer)
660 }
661
662 fn evict_lru_bucket(&mut self) -> Result<()> {
663 let Some((idx, _)) = self
664 .bucket_writers
665 .iter()
666 .enumerate()
667 .filter_map(|(idx, bucket)| bucket.as_ref().map(|bucket| (idx, bucket.last_used)))
668 .min_by_key(|(_, last_used)| *last_used)
669 else {
670 return Ok(());
671 };
672
673 if let Some(mut bucket) = self.bucket_writers[idx].take() {
674 bucket.writer.flush().map_err(StoreError::from)?;
675 self.open_bucket_writers -= 1;
676 }
677 Ok(())
678 }
679
680 pub fn finalize(mut self) -> Result<(W, PackStats)> {
691 for bucket in self.bucket_writers.iter_mut().flatten() {
694 bucket.writer.flush().map_err(StoreError::from)?;
695 }
696 for slot in self.bucket_writers.iter_mut() {
699 *slot = None;
700 }
701 self.open_bucket_writers = 0;
702
703 let bw = self
707 .pack_writer
708 .take()
709 .expect("finalize called twice — pack_writer already consumed");
710 let mut writer = bw
711 .into_inner()
712 .map_err(|e| StoreError::from(std::io::Error::other(e.to_string())))?;
713 if let Some(expected) = self.declared_object_count {
714 if expected != self.record_count {
715 return Err(StoreError::InvalidObject(format!(
716 "streaming pack declared {expected} record(s) but added {}",
717 self.record_count
718 )));
719 }
720 } else {
721 writer
722 .seek(SeekFrom::Start(self.header_offset))
723 .map_err(StoreError::from)?;
724 let mut header_bytes = Vec::with_capacity(16);
725 write_container_header(&mut header_bytes, pack_container_spec(), self.record_count);
726 writer.write_all(&header_bytes).map_err(StoreError::from)?;
727 }
728
729 writer
734 .seek(SeekFrom::Start(self.header_offset))
735 .map_err(StoreError::from)?;
736 let mut hasher = blake3::Hasher::new();
737 let mut buf = vec![0u8; 64 * 1024];
738 loop {
739 let n = writer.read(&mut buf).map_err(StoreError::from)?;
740 if n == 0 {
741 break;
742 }
743 hasher.update(&buf[..n]);
744 }
745 let checksum = hasher.finalize();
746
747 writer.seek(SeekFrom::End(0)).map_err(StoreError::from)?;
749 writer
750 .write_all(checksum.as_bytes())
751 .map_err(StoreError::from)?;
752 writer.flush().map_err(StoreError::from)?;
753 if self.durable {
755 writer
756 .sync_data_for_durability()
757 .map_err(StoreError::from)?;
758 }
759
760 let idx_file = File::create(&self.index_path).map_err(StoreError::from)?;
773 let mut idx_writer = BufWriter::new(idx_file);
774 write_index_header(&mut idx_writer, self.object_count)?;
775 let mut entries_written: u64 = 0;
776 for path in self.bucket_paths.iter() {
777 if !path.exists() {
778 continue;
779 }
780 let bucket_bytes = std::fs::read(path).map_err(StoreError::from)?;
781 let mut entries = decode_bucket_file(&bucket_bytes)?;
782 entries.sort_by_key(|(id, _)| *id);
787 for (id, offset) in entries {
788 write_index_entry(&mut idx_writer, id, offset)?;
789 entries_written += 1;
790 }
791 }
792 idx_writer.flush().map_err(StoreError::from)?;
793 let idx_file = idx_writer
795 .into_inner()
796 .map_err(|e| StoreError::from(std::io::Error::other(e.to_string())))?;
797 if self.durable {
798 idx_file.sync_all().map_err(StoreError::from)?;
799 if let Some(parent) = self.index_path.parent() {
800 heddle_fs_prims::fs_atomic::sync_directory(parent).map_err(StoreError::from)?;
801 }
802 }
803 debug_assert_eq!(
804 entries_written, self.object_count,
805 "streaming index entry count drifted from add() count"
806 );
807
808 for path in self.bucket_paths.iter() {
813 let _ = std::fs::remove_file(path);
814 }
815 let _ = std::fs::remove_dir(&self.bucket_dir);
816 self.finalized = true;
817
818 let stats = PackStats {
819 object_count: self.object_count,
820 total_uncompressed: self.total_uncompressed,
821 total_compressed: self.total_compressed,
822 delta_count: 0,
823 compression_ratio: if self.total_uncompressed == 0 {
824 0.0
825 } else {
826 self.total_compressed as f64 / self.total_uncompressed as f64
827 },
828 };
829
830 Ok((writer, stats))
831 }
832}
833
834fn write_index_header<W: Write>(out: &mut W, count: u64) -> Result<()> {
839 super::pack_index::index_header().write_to(out, count)
840}
841
842fn write_index_entry<W: Write>(out: &mut W, id: PackObjectId, offset: u64) -> Result<()> {
845 let buf = super::pack_index::encode_index_entry(id, offset);
846 out.write_all(&buf).map_err(StoreError::from)
847}
848
849#[cfg(feature = "zstd")]
862fn encode_varint_padded_to_10(value: u64, out: &mut [u8; 10]) {
863 let mut v = value;
864 for slot in out.iter_mut().take(9) {
865 *slot = 0x80 | ((v & 0x7F) as u8);
866 v >>= 7;
867 }
868 out[9] = (v & 0x7F) as u8;
869}
870
871impl<W: Write + Read + Seek> Drop for StreamingPackBuilder<W> {
872 fn drop(&mut self) {
873 if self.finalized {
874 return;
875 }
876 for path in self.bucket_paths.iter() {
879 let _ = std::fs::remove_file(path);
880 }
881 let _ = std::fs::remove_dir(&self.bucket_dir);
882 }
883}
884
885fn bucket_index_for(id: &PackObjectId) -> usize {
889 match id {
890 PackObjectId::Hash(h) => HASH_VARIANT * BUCKETS_PER_VARIANT + h.as_bytes()[0] as usize,
891 PackObjectId::StateId(c) => {
892 CHANGEID_VARIANT * BUCKETS_PER_VARIANT + c.as_bytes()[0] as usize
893 }
894 PackObjectId::AnnotatedTag(hash) => {
895 ANNOTATED_TAG_VARIANT * BUCKETS_PER_VARIANT + hash.as_bytes()[0] as usize
896 }
897 }
898}
899
900fn decode_bucket_file(bytes: &[u8]) -> Result<Vec<(PackObjectId, u64)>> {
905 let mut out = Vec::new();
906 let mut pos = 0;
907 while pos < bytes.len() {
908 let (id, id_len) = PackObjectId::decode_tagged(&bytes[pos..])?;
909 pos += id_len;
910 if pos + 8 > bytes.len() {
911 return Err(StoreError::InvalidObject(
912 "streaming bucket entry truncated at offset".to_string(),
913 ));
914 }
915 let offset = u64::from_be_bytes(bytes[pos..pos + 8].try_into().map_err(|_| {
916 StoreError::InvalidObject("streaming bucket bad offset slice".to_string())
917 })?);
918 pos += 8;
919 out.push((id, offset));
920 }
921 Ok(out)
922}
923
924#[cfg(test)]
927mod tests {
928 use std::io::Cursor;
929
930 use super::*;
931 use crate::{
932 object::StateId,
933 store::pack::{PackReader, PackStats},
934 };
935
936 fn deterministic_hash(seed: u8) -> ContentHash {
937 let mut bytes = [0u8; 32];
941 bytes[0] = seed;
942 for (i, b) in bytes.iter_mut().enumerate().skip(1) {
943 *b = seed.wrapping_mul(31).wrapping_add(i as u8);
944 }
945 ContentHash::from_bytes(bytes)
946 }
947
948 fn deterministic_state_id(seed: u8) -> StateId {
949 let mut bytes = [0u8; 32];
950 bytes[0] = seed;
951 for (i, b) in bytes.iter_mut().enumerate().skip(1) {
952 *b = seed.wrapping_add(i as u8 * 7);
953 }
954 StateId::from_bytes(bytes)
955 }
956
957 fn fresh_builder(
962 tmp: &tempfile::TempDir,
963 ) -> (StreamingPackBuilder<Cursor<Vec<u8>>>, PathBuf, PathBuf) {
964 let bucket_dir = tmp.path().join("buckets");
965 let index_path = tmp.path().join("test.idx");
966 let cursor = Cursor::new(Vec::<u8>::new());
967 let b = StreamingPackBuilder::new(
968 cursor,
969 index_path.clone(),
970 CompressionConfig::default(),
971 bucket_dir.clone(),
972 )
973 .unwrap();
974 (b, bucket_dir, index_path)
975 }
976
977 fn finalize_cursor(
982 b: StreamingPackBuilder<Cursor<Vec<u8>>>,
983 index_path: &std::path::Path,
984 ) -> (Vec<u8>, Vec<u8>, PackStats) {
985 let (cursor, stats) = b.finalize().unwrap();
986 let index_bytes = std::fs::read(index_path).unwrap();
987 (cursor.into_inner(), index_bytes, stats)
988 }
989
990 #[test]
991 fn empty_pack_finalizes_to_valid_zero_count_pack() {
992 let tmp = tempfile::TempDir::new().unwrap();
993 let (b, bucket_dir, idx_path) = fresh_builder(&tmp);
994 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
995
996 assert_eq!(stats.object_count, 0);
997 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
999 assert!(reader.list_ids().unwrap().is_empty());
1000 assert!(
1002 !bucket_dir.exists(),
1003 "bucket dir should be cleaned on successful finalize"
1004 );
1005 }
1006
1007 #[test]
1008 fn single_blob_with_hash_id_round_trips() {
1009 let tmp = tempfile::TempDir::new().unwrap();
1010 let (mut b, _, idx_path) = fresh_builder(&tmp);
1011 let hash = deterministic_hash(0x42);
1012 let payload = b"hello, streaming pack".to_vec();
1013 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1014 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1015
1016 assert_eq!(stats.object_count, 1);
1017 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1018 let id = PackObjectId::Hash(hash);
1019 assert!(reader.has_object(&id).unwrap());
1020 let (got_type, got_data) = reader.get_object(&id).unwrap().unwrap();
1021 assert_eq!(got_type, ObjectType::Blob);
1022 assert_eq!(got_data, payload);
1023 }
1024
1025 #[test]
1026 fn single_state_with_change_id_round_trips() {
1027 let tmp = tempfile::TempDir::new().unwrap();
1028 let (mut b, _, idx_path) = fresh_builder(&tmp);
1029 let cid = deterministic_state_id(0xa5);
1030 let payload = b"serialized-state-bytes".to_vec();
1031 b.add_id(
1032 PackObjectId::StateId(cid),
1033 ObjectType::State,
1034 payload.clone(),
1035 )
1036 .unwrap();
1037 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1038
1039 assert_eq!(stats.object_count, 1);
1040 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1041 let id = PackObjectId::StateId(cid);
1042 let (ty, data) = reader.get_object(&id).unwrap().unwrap();
1043 assert_eq!(ty, ObjectType::State);
1044 assert_eq!(data, payload);
1045 }
1046
1047 #[test]
1048 fn shared_compact_tree_frame_reconstructs_each_indexed_object() {
1049 use crate::object::{Tree, TreeEntry};
1050
1051 let tmp = tempfile::TempDir::new().unwrap();
1052 let (mut builder, _, index_path) = fresh_builder(&tmp);
1053 let blob = deterministic_hash(0x33);
1054 let trees = vec![
1055 Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1056 Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1057 ];
1058 let ids = trees
1059 .iter()
1060 .map(|tree| PackObjectId::Hash(tree.hash()))
1061 .collect::<Vec<_>>();
1062 let frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1063 builder
1064 .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1065 .unwrap();
1066 let (pack, index, stats) = finalize_cursor(builder, &index_path);
1067 let reader = PackReader::from_bytes(pack, index).unwrap();
1068
1069 assert_eq!(stats.object_count, 2);
1070 assert_eq!(
1071 reader.encoded_payload_bytes(ObjectType::Tree).unwrap(),
1072 frame.len() as u64
1073 );
1074 let hosted = ids
1075 .iter()
1076 .zip(&trees)
1077 .map(|(id, tree)| {
1078 (
1079 *id,
1080 ObjectType::Tree,
1081 tree.encode_canonical().unwrap().len() as u64,
1082 )
1083 })
1084 .collect::<Vec<_>>();
1085 assert!(
1086 reader
1087 .copy_hosted_encoded_subset(&hosted)
1088 .unwrap()
1089 .is_none(),
1090 "repository-local compact frames must use the hosted fallback"
1091 );
1092 for (id, tree) in ids.iter().zip(&trees) {
1093 let (object_type, bytes) = reader.get_object(id).unwrap().unwrap();
1094 assert_eq!(object_type, ObjectType::Tree);
1095 assert_eq!(bytes, tree.encode_canonical().unwrap());
1096 }
1097 }
1098
1099 #[test]
1100 fn compact_tree_extraction_rejects_an_index_alias_with_the_wrong_typed_hash() {
1101 use crate::object::{Tree, TreeEntry};
1102
1103 let tmp = tempfile::TempDir::new().unwrap();
1104 let (mut builder, _, index_path) = fresh_builder(&tmp);
1105 let blob = deterministic_hash(0x34);
1106 let trees = vec![
1107 Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1108 Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1109 ];
1110 let wrong_hash = ContentHash::compute_typed("tree", b"not the second tree");
1111 assert_ne!(wrong_hash, trees[1].hash());
1112 let ids = vec![
1113 PackObjectId::Hash(trees[0].hash()),
1114 PackObjectId::Hash(wrong_hash),
1115 ];
1116 let frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1117 builder
1118 .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1119 .unwrap();
1120 let (pack, index, _) = finalize_cursor(builder, &index_path);
1121 let reader = PackReader::from_bytes(pack, index).unwrap();
1122
1123 let error = reader.get_object(&ids[1]).unwrap_err();
1124 assert!(
1125 error
1126 .to_string()
1127 .contains("does not contain indexed object"),
1128 "extraction must derive and verify the tree's typed hash: {error}"
1129 );
1130 }
1131
1132 #[test]
1133 fn shared_lineage_blob_frame_reconstructs_each_indexed_object() {
1134 let tmp = tempfile::TempDir::new().unwrap();
1135 let (mut builder, _, index_path) = fresh_builder(&tmp);
1136 let bodies = [b"newest version".as_slice(), b"older version".as_slice()];
1137 let ids = bodies
1138 .iter()
1139 .map(|body| PackObjectId::Hash(ContentHash::compute_typed("blob", body)))
1140 .collect::<Vec<_>>();
1141 let frame = heddle_object_model::compact::encode_blob_frame(&bodies).unwrap();
1142 builder
1143 .add_shared_frame(&ids, ObjectType::Blob, frame.len(), &frame)
1144 .unwrap();
1145 let (pack, index, stats) = finalize_cursor(builder, &index_path);
1146 let reader = PackReader::from_bytes(pack, index).unwrap();
1147
1148 assert_eq!(stats.object_count, bodies.len() as u64);
1149 assert_eq!(
1150 reader.encoded_payload_bytes(ObjectType::Blob).unwrap(),
1151 frame.len() as u64
1152 );
1153 for (id, expected) in ids.iter().zip(bodies) {
1154 let (object_type, actual) = reader.get_object(id).unwrap().unwrap();
1155 assert_eq!(object_type, ObjectType::Blob);
1156 assert_eq!(actual, expected);
1157 let PackObjectId::Hash(hash) = id else {
1158 unreachable!("blob ids are hashes")
1159 };
1160 assert_eq!(
1161 reader.get_hashed_object_type(hash).unwrap(),
1162 Some(ObjectType::Blob)
1163 );
1164 assert_eq!(
1165 reader.get_hashed_object_size(hash).unwrap(),
1166 Some(expected.len() as u64)
1167 );
1168 }
1169 }
1170
1171 #[test]
1172 fn ordinary_blob_starting_with_frame_magic_remains_ordinary() {
1173 let tmp = tempfile::TempDir::new().unwrap();
1174 let (mut builder, _, index_path) = fresh_builder(&tmp);
1175 let body = b"HCB2 arbitrary user content".to_vec();
1176 let hash = ContentHash::compute_typed("blob", &body);
1177 builder.add(hash, ObjectType::Blob, body.clone()).unwrap();
1178 let (pack, index, _) = finalize_cursor(builder, &index_path);
1179 let reader = PackReader::from_bytes(pack, index).unwrap();
1180
1181 assert_eq!(
1182 reader.get_object(&PackObjectId::Hash(hash)).unwrap(),
1183 Some((ObjectType::Blob, body))
1184 );
1185 }
1186
1187 #[test]
1188 fn corrupt_compact_frame_byte_invalidates_every_contained_object() {
1189 use crate::object::{Tree, TreeEntry};
1190
1191 let tmp = tempfile::TempDir::new().unwrap();
1192 let (mut builder, _, index_path) = fresh_builder(&tmp);
1193 let blob = deterministic_hash(0x44);
1194 let trees = vec![
1195 Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1196 Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1197 ];
1198 let ids = trees
1199 .iter()
1200 .map(|tree| PackObjectId::Hash(tree.hash()))
1201 .collect::<Vec<_>>();
1202 let mut frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1203 let corrupt_at = frame.len() / 2;
1204 frame[corrupt_at] ^= 0x01;
1205 builder
1206 .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1207 .unwrap();
1208 let (pack, index, _) = finalize_cursor(builder, &index_path);
1209 let reader = PackReader::from_bytes(pack, index).unwrap();
1210
1211 for id in ids {
1212 let error = reader.get_object(&id).unwrap_err();
1213 assert!(
1214 error
1215 .to_string()
1216 .contains("compact frame checksum mismatch"),
1217 "unexpected error for {id:?}: {error}"
1218 );
1219 }
1220 }
1221
1222 #[test]
1223 fn corrupt_blob_frame_byte_invalidates_every_contained_object() {
1224 let tmp = tempfile::TempDir::new().unwrap();
1225 let (mut builder, _, index_path) = fresh_builder(&tmp);
1226 let bodies = [b"newest version".as_slice(), b"older version".as_slice()];
1227 let ids = bodies
1228 .iter()
1229 .map(|body| PackObjectId::Hash(ContentHash::compute_typed("blob", body)))
1230 .collect::<Vec<_>>();
1231 let mut frame = heddle_object_model::compact::encode_blob_frame(&bodies).unwrap();
1232 let corrupt_at = frame.len() / 2;
1233 frame[corrupt_at] ^= 0x01;
1234 builder
1235 .add_shared_frame(&ids, ObjectType::Blob, frame.len(), &frame)
1236 .unwrap();
1237 let (pack, index, _) = finalize_cursor(builder, &index_path);
1238 let reader = PackReader::from_bytes(pack, index).unwrap();
1239
1240 for id in ids {
1241 let error = reader.get_object(&id).unwrap_err();
1242 assert!(
1243 error
1244 .to_string()
1245 .contains("compact frame checksum mismatch"),
1246 "unexpected error for {id:?}: {error}"
1247 );
1248 }
1249 }
1250
1251 #[test]
1252 fn mixed_hash_and_changeid_ids_all_retrievable() {
1253 let tmp = tempfile::TempDir::new().unwrap();
1254 let (mut b, _, idx_path) = fresh_builder(&tmp);
1255 let blob_hash = deterministic_hash(0x10);
1256 let tree_hash = deterministic_hash(0x20);
1257 let state_cid = deterministic_state_id(0x80);
1258
1259 b.add(blob_hash, ObjectType::Blob, b"blob-bytes".to_vec())
1260 .unwrap();
1261 b.add(tree_hash, ObjectType::Tree, b"serialized-tree".to_vec())
1262 .unwrap();
1263 b.add_id(
1264 PackObjectId::StateId(state_cid),
1265 ObjectType::State,
1266 b"serialized-state".to_vec(),
1267 )
1268 .unwrap();
1269
1270 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1271 assert_eq!(stats.object_count, 3);
1272 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1273 assert_eq!(
1274 reader
1275 .get_object(&PackObjectId::Hash(blob_hash))
1276 .unwrap()
1277 .unwrap()
1278 .1,
1279 b"blob-bytes".to_vec()
1280 );
1281 assert_eq!(
1282 reader
1283 .get_object(&PackObjectId::Hash(tree_hash))
1284 .unwrap()
1285 .unwrap()
1286 .1,
1287 b"serialized-tree".to_vec()
1288 );
1289 assert_eq!(
1290 reader
1291 .get_object(&PackObjectId::StateId(state_cid))
1292 .unwrap()
1293 .unwrap()
1294 .1,
1295 b"serialized-state".to_vec()
1296 );
1297 }
1298
1299 #[test]
1300 fn ten_thousand_objects_round_trip_correctly() {
1301 let tmp = tempfile::TempDir::new().unwrap();
1305 let (mut b, _, idx_path) = fresh_builder(&tmp);
1306 let mut hashes = Vec::with_capacity(10_000);
1307 for i in 0..10_000u32 {
1308 let h = blake3::hash(&i.to_le_bytes());
1311 let hash = ContentHash::from_bytes(*h.as_bytes());
1312 hashes.push(hash);
1313 b.add(hash, ObjectType::Blob, format!("payload-{i}").into_bytes())
1314 .unwrap();
1315 }
1316 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1317 assert_eq!(stats.object_count, 10_000);
1318
1319 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1320 assert_eq!(reader.list_ids().unwrap().len(), 10_000);
1321 for i in [0, 1, 99, 1234, 5_000, 9_999] {
1323 let id = PackObjectId::Hash(hashes[i]);
1324 let (_ty, data) = reader.get_object(&id).unwrap().unwrap();
1325 assert_eq!(data, format!("payload-{i}").into_bytes());
1326 }
1327 }
1328
1329 #[test]
1330 fn bucket_writers_are_lru_capped_below_fd_limit() {
1331 let tmp = tempfile::TempDir::new().unwrap();
1332 let (mut b, _bucket_dir, idx_path) = fresh_builder(&tmp);
1333 let mut ids = Vec::new();
1334
1335 for i in 0..BUCKETS_PER_VARIANT {
1336 let hash = deterministic_hash(i as u8);
1337 ids.push(PackObjectId::Hash(hash));
1338 b.add(hash, ObjectType::Blob, format!("hash-{i}").into_bytes())
1339 .unwrap();
1340 assert!(
1341 b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1342 "open bucket writers should stay capped"
1343 );
1344 }
1345
1346 for i in 0..BUCKETS_PER_VARIANT {
1347 let cid = deterministic_state_id(i as u8);
1348 ids.push(PackObjectId::StateId(cid));
1349 b.add_id(
1350 PackObjectId::StateId(cid),
1351 ObjectType::State,
1352 format!("state-{i}").into_bytes(),
1353 )
1354 .unwrap();
1355 assert!(
1356 b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1357 "open bucket writers should stay capped"
1358 );
1359 }
1360
1361 for i in 0..BUCKETS_PER_VARIANT {
1362 let hash = deterministic_hash(i as u8);
1363 let id = PackObjectId::AnnotatedTag(hash);
1364 ids.push(id);
1365 b.add_id(
1366 id,
1367 ObjectType::AnnotatedTag,
1368 format!("tag-{i}").into_bytes(),
1369 )
1370 .unwrap();
1371 assert!(
1372 b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1373 "open bucket writers should stay capped"
1374 );
1375 }
1376
1377 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1378 assert_eq!(stats.object_count, TOTAL_BUCKETS as u64);
1379 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1380 for id in ids {
1381 assert!(reader.has_object(&id).unwrap(), "missing id {id:?}");
1382 }
1383 }
1384
1385 #[test]
1386 fn index_id_sort_order_matches_packbuilder_output() {
1387 use crate::store::pack::PackBuilder;
1395 let payloads: Vec<(PackObjectId, ObjectType, Vec<u8>)> = (0..200u32)
1396 .map(|i| {
1397 let h = blake3::hash(&i.to_le_bytes());
1398 (
1399 PackObjectId::Hash(ContentHash::from_bytes(*h.as_bytes())),
1400 if i % 3 == 0 {
1401 ObjectType::Tree
1402 } else {
1403 ObjectType::Blob
1404 },
1405 format!("body-{i}").into_bytes(),
1406 )
1407 })
1408 .collect();
1409
1410 let compression = CompressionConfig {
1413 max_delta_size: 0,
1414 ..CompressionConfig::default()
1415 };
1416 let mut classic = PackBuilder::new(compression);
1417 for (id, ty, data) in payloads.iter() {
1418 classic.add_id(*id, *ty, data.clone());
1419 }
1420 let (classic_pack, classic_index, _) = classic.build().unwrap();
1421 let classic_reader = PackReader::from_bytes(classic_pack, classic_index).unwrap();
1422
1423 let tmp = tempfile::TempDir::new().unwrap();
1424 let bucket_dir = tmp.path().join("buckets");
1425 let idx_path = tmp.path().join("test.idx");
1426 let cursor = Cursor::new(Vec::<u8>::new());
1427 let mut streaming =
1428 StreamingPackBuilder::new(cursor, idx_path.clone(), compression, bucket_dir).unwrap();
1429 for (id, ty, data) in payloads.iter() {
1430 streaming.add_id(*id, *ty, data.clone()).unwrap();
1431 }
1432 let (streaming_pack, streaming_index, _) = finalize_cursor(streaming, &idx_path);
1433 let streaming_reader = PackReader::from_bytes(streaming_pack, streaming_index).unwrap();
1434
1435 assert_eq!(
1438 streaming_reader.list_ids().unwrap(),
1439 classic_reader.list_ids().unwrap(),
1440 "streaming and classic indices should report the same id sequence"
1441 );
1442 for (id, _ty, want) in payloads.iter().take(10).chain(payloads.iter().skip(190)) {
1445 let (_, got) = streaming_reader.get_object(id).unwrap().unwrap();
1446 assert_eq!(&got, want);
1447 let (_, classic_got) = classic_reader.get_object(id).unwrap().unwrap();
1448 assert_eq!(got, classic_got);
1449 }
1450 }
1451
1452 #[test]
1453 fn corrupted_pack_fails_checksum_verification() {
1454 let tmp = tempfile::TempDir::new().unwrap();
1455 let (mut b, _, idx_path) = fresh_builder(&tmp);
1456 b.add(
1457 deterministic_hash(0x01),
1458 ObjectType::Blob,
1459 b"some bytes".to_vec(),
1460 )
1461 .unwrap();
1462 let (mut pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1463 let body_byte = 18; pack_data[body_byte] ^= 0xff;
1466 let result = PackReader::from_bytes(pack_data, index_data);
1467 assert!(
1468 result.is_err(),
1469 "PackReader should reject pack with mutated body"
1470 );
1471 }
1472
1473 #[test]
1474 fn pack_count_in_header_matches_index_entry_count() {
1475 let tmp = tempfile::TempDir::new().unwrap();
1476 let (mut b, _, idx_path) = fresh_builder(&tmp);
1477 for i in 0..7u8 {
1478 b.add(
1479 deterministic_hash(i),
1480 ObjectType::Blob,
1481 format!("p{i}").into_bytes(),
1482 )
1483 .unwrap();
1484 }
1485 let (pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1486 let count = u64::from_be_bytes(pack_data[8..16].try_into().unwrap());
1488 assert_eq!(count, 7);
1489 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1490 assert_eq!(reader.list_ids().unwrap().len(), 7);
1491 }
1492
1493 #[test]
1494 fn declared_pack_count_is_written_before_finalize() {
1495 let tmp = tempfile::TempDir::new().unwrap();
1496 let bucket_dir = tmp.path().join("buckets");
1497 let idx_path = tmp.path().join("test.idx");
1498 let cursor = Cursor::new(Vec::<u8>::new());
1499 let mut b = StreamingPackBuilder::new_with_object_count(
1500 cursor,
1501 idx_path.clone(),
1502 CompressionConfig::default(),
1503 bucket_dir,
1504 2,
1505 )
1506 .unwrap();
1507
1508 b.flush_pack().unwrap();
1509 let initial = b.pack_writer.as_ref().unwrap().get_ref().get_ref().clone();
1510 assert_eq!(u64::from_be_bytes(initial[8..16].try_into().unwrap()), 2);
1511
1512 let hash = deterministic_hash(0x40);
1513 b.add(hash, ObjectType::Blob, b"known-count-entry".to_vec())
1514 .unwrap();
1515 b.flush_pack().unwrap();
1516 let after_add = b.pack_writer.as_ref().unwrap().get_ref().get_ref().clone();
1517 assert_eq!(u64::from_be_bytes(after_add[8..16].try_into().unwrap()), 2);
1518
1519 let second_hash = deterministic_hash(0x41);
1520 b.add(second_hash, ObjectType::Blob, b"second-entry".to_vec())
1521 .unwrap();
1522 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1523
1524 assert_eq!(stats.object_count, 2);
1525 assert_eq!(u64::from_be_bytes(pack_data[8..16].try_into().unwrap()), 2);
1526 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1527 assert!(reader.has_object(&PackObjectId::Hash(hash)).unwrap());
1528 assert!(reader.has_object(&PackObjectId::Hash(second_hash)).unwrap());
1529 }
1530
1531 #[test]
1532 fn declared_pack_count_mismatch_fails_finalize() {
1533 let tmp = tempfile::TempDir::new().unwrap();
1534 let bucket_dir = tmp.path().join("buckets");
1535 let idx_path = tmp.path().join("test.idx");
1536 let cursor = Cursor::new(Vec::<u8>::new());
1537 let mut b = StreamingPackBuilder::new_with_object_count(
1538 cursor,
1539 idx_path,
1540 CompressionConfig::default(),
1541 bucket_dir,
1542 2,
1543 )
1544 .unwrap();
1545
1546 b.add(
1547 deterministic_hash(0x50),
1548 ObjectType::Blob,
1549 b"only-entry".to_vec(),
1550 )
1551 .unwrap();
1552 let error = b.finalize().unwrap_err();
1553
1554 assert!(
1555 error
1556 .to_string()
1557 .contains("streaming pack declared 2 record(s) but added 1")
1558 );
1559 }
1560
1561 #[test]
1562 fn bucket_files_are_cleaned_on_successful_finalize() {
1563 let tmp = tempfile::TempDir::new().unwrap();
1564 let bucket_dir = tmp.path().join("buckets");
1565 let idx_path = tmp.path().join("test.idx");
1566 let cursor = Cursor::new(Vec::<u8>::new());
1567 let mut b = StreamingPackBuilder::new(
1568 cursor,
1569 idx_path.clone(),
1570 CompressionConfig::default(),
1571 bucket_dir.clone(),
1572 )
1573 .unwrap();
1574 for i in 0..50u8 {
1575 b.add(deterministic_hash(i), ObjectType::Blob, vec![i; 32])
1576 .unwrap();
1577 }
1578 assert!(bucket_dir.exists());
1580 let bucket_count = std::fs::read_dir(&bucket_dir).unwrap().count();
1581 assert!(bucket_count > 0, "bucket dir should hold some files");
1582 let _ = finalize_cursor(b, &idx_path);
1583 assert!(
1584 !bucket_dir.exists(),
1585 "bucket dir should be removed on finalize"
1586 );
1587 }
1588
1589 #[test]
1590 fn bucket_files_are_cleaned_on_drop_without_finalize() {
1591 let tmp = tempfile::TempDir::new().unwrap();
1592 let bucket_dir = tmp.path().join("buckets");
1593 let idx_path = tmp.path().join("test.idx");
1594 {
1595 let cursor = Cursor::new(Vec::<u8>::new());
1596 let mut b = StreamingPackBuilder::new(
1597 cursor,
1598 idx_path.clone(),
1599 CompressionConfig::default(),
1600 bucket_dir.clone(),
1601 )
1602 .unwrap();
1603 for i in 0..10u8 {
1604 b.add(deterministic_hash(i), ObjectType::Blob, vec![0; 32])
1605 .unwrap();
1606 }
1607 assert!(bucket_dir.exists());
1608 }
1610 assert!(
1611 !idx_path.exists(),
1612 "no index file should have been created without finalize"
1613 );
1614 assert!(
1615 !bucket_dir.exists(),
1616 "bucket dir should be removed on Drop when finalize never ran"
1617 );
1618 }
1619
1620 #[test]
1621 fn large_blob_streams_to_disk_without_double_buffering() {
1622 let tmp = tempfile::TempDir::new().unwrap();
1627 let bucket_dir = tmp.path().join("buckets");
1628 let pack_path = tmp.path().join("pack.dat");
1629 let idx_path = tmp.path().join("pack.idx");
1630 let file = std::fs::OpenOptions::new()
1631 .read(true)
1632 .write(true)
1633 .create(true)
1634 .truncate(true)
1635 .open(&pack_path)
1636 .unwrap();
1637 let mut b = StreamingPackBuilder::new(
1638 file,
1639 idx_path.clone(),
1640 CompressionConfig::default(),
1641 bucket_dir,
1642 )
1643 .unwrap();
1644 let payload: Vec<u8> = (0..4 * 1024 * 1024u32).map(|i| (i & 0xff) as u8).collect();
1645 let hash = deterministic_hash(0xff);
1646 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1647 let (_, stats) = b.finalize().unwrap();
1648 let index_data = std::fs::read(&idx_path).unwrap();
1649 assert_eq!(stats.object_count, 1);
1650 let pack_bytes = std::fs::read(&pack_path).unwrap();
1651 let reader = PackReader::from_bytes(pack_bytes, index_data).unwrap();
1654 let (_ty, got) = reader
1655 .get_object(&PackObjectId::Hash(hash))
1656 .unwrap()
1657 .unwrap();
1658 assert_eq!(got, payload);
1659 }
1660
1661 #[test]
1662 fn bucket_distribution_for_random_hashes_is_roughly_uniform() {
1663 let tmp = tempfile::TempDir::new().unwrap();
1670 let bucket_dir = tmp.path().join("buckets");
1671 let idx_path = tmp.path().join("test.idx");
1672 let cursor = Cursor::new(Vec::<u8>::new());
1673 let mut b = StreamingPackBuilder::new(
1674 cursor,
1675 idx_path.clone(),
1676 CompressionConfig::default(),
1677 bucket_dir.clone(),
1678 )
1679 .unwrap();
1680 for i in 0..1024u32 {
1681 let h = blake3::hash(&i.to_le_bytes());
1682 let hash = ContentHash::from_bytes(*h.as_bytes());
1683 b.add(hash, ObjectType::Blob, b"x".to_vec()).unwrap();
1684 }
1685 b.pack_writer.as_mut().unwrap().flush().unwrap();
1687 let mut max_entries = 0usize;
1688 let entry_size = 33 + 8;
1691 for path in b.bucket_paths.iter() {
1692 if path.exists() {
1693 let size = std::fs::metadata(path).unwrap().len() as usize;
1694 let entries = size / entry_size;
1695 if entries > max_entries {
1696 max_entries = entries;
1697 }
1698 }
1699 }
1700 assert!(
1703 max_entries <= 16,
1704 "max bucket has {max_entries} entries; uniform expected ~4"
1705 );
1706 let _ = finalize_cursor(b, &idx_path);
1707 }
1708
1709 #[test]
1710 fn finalize_returns_correct_stats() {
1711 let tmp = tempfile::TempDir::new().unwrap();
1712 let (mut b, _, idx_path) = fresh_builder(&tmp);
1713 let payload = vec![0xabu8; 1024];
1714 for i in 0..5u8 {
1715 b.add(deterministic_hash(i), ObjectType::Blob, payload.clone())
1716 .unwrap();
1717 }
1718 let (_, _, stats) = finalize_cursor(b, &idx_path);
1719 assert_eq!(stats.object_count, 5);
1720 assert_eq!(stats.total_uncompressed, 5 * 1024);
1721 assert!(stats.total_compressed > 0);
1722 assert!(stats.compression_ratio > 0.0);
1723 assert_eq!(stats.delta_count, 0, "streaming builder never deltas");
1724 }
1725
1726 #[cfg(feature = "zstd")]
1727 #[test]
1728 fn streaming_compression_roundtrips_through_zstd_frame() {
1729 let tmp = tempfile::TempDir::new().unwrap();
1737 let (mut b, _, idx_path) = fresh_builder(&tmp);
1738 let payload = vec![0u8; 64 * 1024];
1741 let hash = deterministic_hash(0x77);
1742 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1743 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1744 assert!(
1745 stats.total_compressed < stats.total_uncompressed,
1746 "expected compression ratio < 1.0, got {}/{}",
1747 stats.total_compressed,
1748 stats.total_uncompressed
1749 );
1750 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1751 let (_ty, got) = reader
1752 .get_object(&PackObjectId::Hash(hash))
1753 .unwrap()
1754 .unwrap();
1755 assert_eq!(got, payload);
1756 }
1757
1758 #[cfg(feature = "zstd")]
1759 #[test]
1760 fn equal_length_zstd_frame_is_stored_as_raw_payload() {
1761 fn encode_like_streaming_writer(data: &[u8]) -> Vec<u8> {
1765 let mut compressed = Vec::new();
1766 let mut encoder = zstd::stream::write::Encoder::new(&mut compressed, 3).unwrap();
1767 encoder
1768 .set_pledged_src_size(Some(data.len() as u64))
1769 .unwrap();
1770 std::io::Write::write_all(&mut encoder, data).unwrap();
1771 encoder.finish().unwrap();
1772 compressed
1773 }
1774
1775 let mut random = Vec::with_capacity(1024);
1776 for seed in 0u64..32 {
1777 random.extend_from_slice(blake3::hash(&seed.to_le_bytes()).as_bytes());
1778 }
1779 let payload = (256..=random.len())
1780 .find_map(|logical_len| {
1781 (0..=logical_len).find_map(|zero_prefix| {
1782 let mut candidate = random[..logical_len].to_vec();
1783 candidate[..zero_prefix].fill(0);
1784 let compressed = encode_like_streaming_writer(&candidate);
1785 (compressed.len() == candidate.len()).then_some(candidate)
1786 })
1787 })
1788 .expect("fixture search must find an equal-length zstd frame");
1789 let compressed = encode_like_streaming_writer(&payload);
1790 assert_eq!(compressed.len(), payload.len());
1791 assert_eq!(compressed.first(), Some(&0x28), "fixture must be zstd");
1792
1793 let tmp = tempfile::TempDir::new().unwrap();
1794 let (mut builder, _, index_path) = fresh_builder(&tmp);
1795 let id = PackObjectId::Hash(deterministic_hash(0x78));
1796 builder
1797 .add_id(id, ObjectType::StateAttachment, payload.clone())
1798 .unwrap();
1799 let (pack, index, stats) = finalize_cursor(builder, &index_path);
1800
1801 assert_eq!(stats.total_compressed, stats.total_uncompressed);
1802 let reader = PackReader::from_bytes(pack, index).unwrap();
1803 assert_eq!(
1804 reader.get_object(&id).unwrap(),
1805 Some((ObjectType::StateAttachment, payload))
1806 );
1807 }
1808
1809 #[cfg(feature = "zstd")]
1810 #[test]
1811 fn padded_varint_decodes_to_original_value_for_canonical_decoder() {
1812 let cases: &[u64] = &[0, 1, 127, 128, 4096, 1_000_000, 1_000_000_000_000, u64::MAX];
1818 for &value in cases {
1819 let mut buf = [0u8; 10];
1820 super::encode_varint_padded_to_10(value, &mut buf);
1821 let (decoded, consumed) = super::super::varint::decode_varint(&buf)
1822 .expect("padded varint should always decode");
1823 assert_eq!(decoded, value, "varint roundtrip failed for {value}");
1824 assert_eq!(
1825 consumed, 10,
1826 "padded encoding should consume all 10 bytes for {value}"
1827 );
1828 }
1829 }
1830
1831 #[cfg(feature = "zstd")]
1832 #[test]
1833 fn streaming_path_does_not_buffer_compressed_payload_in_memory() {
1834 let tmp = tempfile::TempDir::new().unwrap();
1847 let bucket_dir = tmp.path().join("buckets");
1848 let pack_path = tmp.path().join("pack.dat");
1849 let idx_path = tmp.path().join("pack.idx");
1850 let file = std::fs::OpenOptions::new()
1851 .read(true)
1852 .write(true)
1853 .create(true)
1854 .truncate(true)
1855 .open(&pack_path)
1856 .unwrap();
1857 let mut b = StreamingPackBuilder::new(
1858 file,
1859 idx_path.clone(),
1860 CompressionConfig::default(),
1861 bucket_dir,
1862 )
1863 .unwrap();
1864 let payload = vec![0xa5u8; 8 * 1024 * 1024];
1865 let hash = deterministic_hash(0x66);
1866 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1867 let mid_size = std::fs::metadata(&pack_path).unwrap().len();
1871 assert!(
1872 mid_size > 16 + 40,
1873 "pack file should hold real entry data after add; size={mid_size}"
1874 );
1875 let (_, _) = b.finalize().unwrap();
1876 let pack_bytes = std::fs::read(&pack_path).unwrap();
1877 let index_bytes = std::fs::read(&idx_path).unwrap();
1878 let reader = PackReader::from_bytes(pack_bytes, index_bytes).unwrap();
1879 let (_ty, got) = reader
1880 .get_object(&PackObjectId::Hash(hash))
1881 .unwrap()
1882 .unwrap();
1883 assert_eq!(got, payload);
1884 }
1885
1886 #[test]
1887 fn list_ids_returns_all_added_ids_sorted() {
1888 let tmp = tempfile::TempDir::new().unwrap();
1889 let (mut b, _, idx_path) = fresh_builder(&tmp);
1890 let mut added: Vec<PackObjectId> = Vec::new();
1891 for seed in [0x05u8, 0xa0, 0x12, 0x9f, 0x33] {
1893 let id = PackObjectId::Hash(deterministic_hash(seed));
1894 b.add_id(id, ObjectType::Blob, vec![seed; 4]).unwrap();
1895 added.push(id);
1896 }
1897 for seed in [0x80u8, 0x10, 0xff] {
1898 let id = PackObjectId::StateId(deterministic_state_id(seed));
1899 b.add_id(id, ObjectType::State, vec![seed; 4]).unwrap();
1900 added.push(id);
1901 }
1902 let (pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1903 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1904 let mut got = reader.list_ids().unwrap();
1905 let mut sorted = got.clone();
1908 sorted.sort();
1909 assert_eq!(got, sorted, "list_ids must come back sorted");
1910 added.sort();
1912 got.sort();
1913 assert_eq!(got, added);
1914 }
1915}