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(
380 &mut self,
381 id: PackObjectId,
382 obj_type: ObjectType,
383 data: impl AsRef<[u8]>,
384 ) -> Result<()> {
385 let data = data.as_ref();
386 let pw = self
391 .pack_writer
392 .as_mut()
393 .ok_or_else(|| StoreError::InvalidObject("pack builder is finalized".into()))?;
394 let entry_start = self.pack_position;
395 let offset = entry_start
396 .checked_sub(self.header_offset)
397 .ok_or_else(|| StoreError::InvalidObject("pack position precedes its header".into()))?;
398
399 self.total_uncompressed = self
400 .total_uncompressed
401 .checked_add(data.len() as u64)
402 .ok_or_else(|| StoreError::InvalidObject("pack decoded size overflow".into()))?;
403
404 let mut header_buf = Vec::with_capacity(40);
407 id.encode_tagged(&mut header_buf);
408 let encoded_type = if obj_type == ObjectType::AnnotatedTag {
409 ObjectType::Blob
410 } else {
411 obj_type
412 };
413 super::varint::encode_type_and_size(encoded_type, data.len() as u64, &mut header_buf);
414 pw.write_all(&header_buf).map_err(StoreError::from)?;
415 self.pack_position = self
416 .pack_position
417 .checked_add(header_buf.len() as u64)
418 .ok_or_else(|| {
419 StoreError::InvalidObject("streaming pack position overflow".to_string())
420 })?;
421 #[cfg(feature = "zstd")]
425 let csize_pos = entry_start + header_buf.len() as u64;
426
427 let want_compress: bool;
440 #[cfg(feature = "zstd")]
441 {
442 want_compress = self.compression.enabled && data.len() >= self.compression.min_size;
443 }
444 #[cfg(not(feature = "zstd"))]
445 {
446 want_compress = false;
447 }
448 if !want_compress {
449 let mut csize_buf = Vec::with_capacity(10);
452 super::varint::encode_varint(data.len() as u64, &mut csize_buf);
453 pw.write_all(&csize_buf).map_err(StoreError::from)?;
454 self.pack_position = self
455 .pack_position
456 .checked_add(csize_buf.len() as u64)
457 .ok_or_else(|| {
458 StoreError::InvalidObject("streaming pack position overflow".to_string())
459 })?;
460 pw.write_all(data).map_err(StoreError::from)?;
461 self.pack_position = self
462 .pack_position
463 .checked_add(data.len() as u64)
464 .ok_or_else(|| {
465 StoreError::InvalidObject("streaming pack position overflow".to_string())
466 })?;
467 self.total_compressed += data.len() as u64;
468 } else {
469 #[cfg(feature = "zstd")]
470 {
471 pw.write_all(&[0u8; CSIZE_PLACEHOLDER_LEN])
474 .map_err(StoreError::from)?;
475 self.pack_position = self
476 .pack_position
477 .checked_add(CSIZE_PLACEHOLDER_LEN as u64)
478 .ok_or_else(|| {
479 StoreError::InvalidObject("streaming pack position overflow".to_string())
480 })?;
481 let body_start = self.pack_position;
482 let compressed_size;
483 {
484 let mut counting = CountingWriter::new(&mut *pw);
485 let mut enc =
486 zstd::stream::write::Encoder::new(&mut counting, self.compression.level)
487 .map_err(StoreError::from)?;
488 enc.set_pledged_src_size(Some(data.len() as u64))
493 .map_err(StoreError::from)?;
494 enc.write_all(data).map_err(StoreError::from)?;
495 enc.finish().map_err(StoreError::from)?;
496 compressed_size = counting.written;
497 }
498 self.pack_position =
499 self.pack_position
500 .checked_add(compressed_size)
501 .ok_or_else(|| {
502 StoreError::InvalidObject(
503 "streaming pack position overflow".to_string(),
504 )
505 })?;
506 let body_end = body_start.checked_add(compressed_size).ok_or_else(|| {
507 StoreError::InvalidObject("streaming pack position overflow".to_string())
508 })?;
509 self.total_compressed += compressed_size;
510
511 let mut csize_bytes = [0u8; CSIZE_PLACEHOLDER_LEN];
516 encode_varint_padded_to_10(compressed_size, &mut csize_bytes);
517 pw.flush().map_err(StoreError::from)?;
518 let inner = pw.get_mut();
519 inner
520 .seek(SeekFrom::Start(csize_pos))
521 .map_err(StoreError::from)?;
522 inner.write_all(&csize_bytes).map_err(StoreError::from)?;
523 if compressed_size == data.len() as u64 {
528 inner
529 .seek(SeekFrom::Start(body_start))
530 .map_err(StoreError::from)?;
531 inner.write_all(data).map_err(StoreError::from)?;
532 }
533 inner
534 .seek(SeekFrom::Start(body_end))
535 .map_err(StoreError::from)?;
536 }
537 #[cfg(not(feature = "zstd"))]
538 {
539 unreachable!("compression branch reached without `zstd` feature");
542 }
543 }
544
545 self.add_index_entry(id, offset)?;
546 self.record_count += 1;
547 self.object_count += 1;
548 Ok(())
549 }
550
551 pub fn add_shared_frame(
558 &mut self,
559 ids: &[PackObjectId],
560 obj_type: ObjectType,
561 uncompressed_size: usize,
562 stored_data: &[u8],
563 ) -> Result<()> {
564 if ids.is_empty() {
565 return Err(StoreError::InvalidObject(
566 "compact frame must contain at least one object".to_string(),
567 ));
568 }
569 if !matches!(
570 obj_type,
571 ObjectType::Blob | ObjectType::Tree | ObjectType::State
572 ) {
573 return Err(StoreError::InvalidObject(
574 "shared compact frames may contain only blobs, trees, or states".to_string(),
575 ));
576 }
577 let unique = ids.iter().copied().collect::<HashSet<_>>();
578 if unique.len() != ids.len() {
579 return Err(StoreError::InvalidObject(
580 "compact frame contains duplicate object ids".to_string(),
581 ));
582 }
583 if ids.iter().any(|id| {
584 !matches!(
585 (obj_type, id),
586 (ObjectType::Blob | ObjectType::Tree, PackObjectId::Hash(_))
587 | (ObjectType::State, PackObjectId::StateId(_))
588 )
589 }) {
590 return Err(StoreError::InvalidObject(
591 "compact frame id kind does not match its object type".to_string(),
592 ));
593 }
594
595 let entry_start = self.pack_position;
596 let offset = entry_start
597 .checked_sub(self.header_offset)
598 .expect("header offset should precede compact frame");
599 let mut header = Vec::with_capacity(48);
600 ids[0].encode_tagged(&mut header);
601 super::varint::encode_type_and_size(obj_type, uncompressed_size as u64, &mut header);
602 super::varint::encode_varint(stored_data.len() as u64, &mut header);
603 let writer = self
604 .pack_writer
605 .as_mut()
606 .expect("add_shared_frame called after finalize");
607 writer.write_all(&header).map_err(StoreError::from)?;
608 writer.write_all(stored_data).map_err(StoreError::from)?;
609 self.pack_position = self
610 .pack_position
611 .checked_add((header.len() + stored_data.len()) as u64)
612 .ok_or_else(|| {
613 StoreError::InvalidObject("streaming pack position overflow".to_string())
614 })?;
615 self.total_uncompressed = self
616 .total_uncompressed
617 .saturating_add(uncompressed_size as u64);
618 self.total_compressed = self
619 .total_compressed
620 .saturating_add(stored_data.len() as u64);
621 self.record_count = self
622 .record_count
623 .checked_add(1)
624 .ok_or_else(|| StoreError::InvalidObject("pack record count overflow".to_string()))?;
625 for id in ids {
626 self.add_index_entry(*id, offset)?;
627 }
628 self.object_count = self
629 .object_count
630 .checked_add(ids.len() as u64)
631 .ok_or_else(|| StoreError::InvalidObject("pack object count overflow".to_string()))?;
632 Ok(())
633 }
634
635 fn add_index_entry(&mut self, id: PackObjectId, offset: u64) -> Result<()> {
636 let bucket_idx = bucket_index_for(&id);
637 let bucket = self.get_or_open_bucket(bucket_idx)?;
638 let mut entry = Vec::with_capacity(33 + 8);
639 id.encode_tagged(&mut entry);
640 entry.extend_from_slice(&offset.to_be_bytes());
641 bucket.write_all(&entry).map_err(StoreError::from)
642 }
643
644 fn get_or_open_bucket(&mut self, idx: usize) -> Result<&mut BufWriter<File>> {
645 self.bucket_access_tick = self.bucket_access_tick.wrapping_add(1);
646 let last_used = self.bucket_access_tick;
647 if self.bucket_writers[idx].is_none() {
648 if self.open_bucket_writers >= MAX_OPEN_BUCKET_WRITERS {
649 self.evict_lru_bucket()?;
650 }
651 let path = &self.bucket_paths[idx];
652 let f = OpenOptions::new()
653 .create(true)
654 .append(true)
655 .open(path)
656 .map_err(StoreError::from)?;
657 self.bucket_writers[idx] = Some(BucketWriter {
658 writer: BufWriter::new(f),
659 last_used,
660 });
661 self.open_bucket_writers += 1;
662 } else if let Some(bucket) = self.bucket_writers[idx].as_mut() {
663 bucket.last_used = last_used;
664 }
665 Ok(&mut self.bucket_writers[idx]
666 .as_mut()
667 .expect("just inserted above")
668 .writer)
669 }
670
671 fn evict_lru_bucket(&mut self) -> Result<()> {
672 let Some((idx, _)) = self
673 .bucket_writers
674 .iter()
675 .enumerate()
676 .filter_map(|(idx, bucket)| bucket.as_ref().map(|bucket| (idx, bucket.last_used)))
677 .min_by_key(|(_, last_used)| *last_used)
678 else {
679 return Ok(());
680 };
681
682 if let Some(mut bucket) = self.bucket_writers[idx].take() {
683 bucket.writer.flush().map_err(StoreError::from)?;
684 self.open_bucket_writers -= 1;
685 }
686 Ok(())
687 }
688
689 pub fn finalize(mut self) -> Result<(W, PackStats)> {
700 for bucket in self.bucket_writers.iter_mut().flatten() {
703 bucket.writer.flush().map_err(StoreError::from)?;
704 }
705 for slot in self.bucket_writers.iter_mut() {
708 *slot = None;
709 }
710 self.open_bucket_writers = 0;
711
712 let bw = self
716 .pack_writer
717 .take()
718 .expect("finalize called twice — pack_writer already consumed");
719 let mut writer = bw
720 .into_inner()
721 .map_err(|e| StoreError::from(std::io::Error::other(e.to_string())))?;
722 if let Some(expected) = self.declared_object_count {
723 if expected != self.record_count {
724 return Err(StoreError::InvalidObject(format!(
725 "streaming pack declared {expected} record(s) but added {}",
726 self.record_count
727 )));
728 }
729 } else {
730 writer
731 .seek(SeekFrom::Start(self.header_offset))
732 .map_err(StoreError::from)?;
733 let mut header_bytes = Vec::with_capacity(16);
734 write_container_header(&mut header_bytes, pack_container_spec(), self.record_count);
735 writer.write_all(&header_bytes).map_err(StoreError::from)?;
736 }
737
738 writer
743 .seek(SeekFrom::Start(self.header_offset))
744 .map_err(StoreError::from)?;
745 let mut hasher = blake3::Hasher::new();
746 let mut buf = vec![0u8; 64 * 1024];
747 loop {
748 let n = writer.read(&mut buf).map_err(StoreError::from)?;
749 if n == 0 {
750 break;
751 }
752 hasher.update(&buf[..n]);
753 }
754 let checksum = hasher.finalize();
755
756 writer.seek(SeekFrom::End(0)).map_err(StoreError::from)?;
758 writer
759 .write_all(checksum.as_bytes())
760 .map_err(StoreError::from)?;
761 writer.flush().map_err(StoreError::from)?;
762 if self.durable {
764 writer
765 .sync_data_for_durability()
766 .map_err(StoreError::from)?;
767 }
768
769 let idx_file = File::create(&self.index_path).map_err(StoreError::from)?;
782 let mut idx_writer = BufWriter::new(idx_file);
783 write_index_header(&mut idx_writer, self.object_count)?;
784 let mut entries_written: u64 = 0;
785 for path in self.bucket_paths.iter() {
786 if !path.exists() {
787 continue;
788 }
789 let bucket_bytes = std::fs::read(path).map_err(StoreError::from)?;
790 let mut entries = decode_bucket_file(&bucket_bytes)?;
791 entries.sort_by_key(|(id, _)| *id);
796 for (id, offset) in entries {
797 write_index_entry(&mut idx_writer, id, offset)?;
798 entries_written += 1;
799 }
800 }
801 idx_writer.flush().map_err(StoreError::from)?;
802 let idx_file = idx_writer
804 .into_inner()
805 .map_err(|e| StoreError::from(std::io::Error::other(e.to_string())))?;
806 if self.durable {
807 idx_file.sync_all().map_err(StoreError::from)?;
808 if let Some(parent) = self.index_path.parent() {
809 heddle_fs_prims::fs_atomic::sync_directory(parent).map_err(StoreError::from)?;
810 }
811 }
812 debug_assert_eq!(
813 entries_written, self.object_count,
814 "streaming index entry count drifted from add() count"
815 );
816
817 for path in self.bucket_paths.iter() {
822 let _ = std::fs::remove_file(path);
823 }
824 let _ = std::fs::remove_dir(&self.bucket_dir);
825 self.finalized = true;
826
827 let stats = PackStats {
828 object_count: self.object_count,
829 total_uncompressed: self.total_uncompressed,
830 total_compressed: self.total_compressed,
831 delta_count: 0,
832 compression_ratio: if self.total_uncompressed == 0 {
833 0.0
834 } else {
835 self.total_compressed as f64 / self.total_uncompressed as f64
836 },
837 };
838
839 Ok((writer, stats))
840 }
841}
842
843fn write_index_header<W: Write>(out: &mut W, count: u64) -> Result<()> {
848 super::pack_index::index_header().write_to(out, count)
849}
850
851fn write_index_entry<W: Write>(out: &mut W, id: PackObjectId, offset: u64) -> Result<()> {
854 let buf = super::pack_index::encode_index_entry(id, offset);
855 out.write_all(&buf).map_err(StoreError::from)
856}
857
858#[cfg(feature = "zstd")]
871fn encode_varint_padded_to_10(value: u64, out: &mut [u8; 10]) {
872 let mut v = value;
873 for slot in out.iter_mut().take(9) {
874 *slot = 0x80 | ((v & 0x7F) as u8);
875 v >>= 7;
876 }
877 out[9] = (v & 0x7F) as u8;
878}
879
880impl<W: Write + Read + Seek> Drop for StreamingPackBuilder<W> {
881 fn drop(&mut self) {
882 if self.finalized {
883 return;
884 }
885 for path in self.bucket_paths.iter() {
888 let _ = std::fs::remove_file(path);
889 }
890 let _ = std::fs::remove_dir(&self.bucket_dir);
891 }
892}
893
894fn bucket_index_for(id: &PackObjectId) -> usize {
898 match id {
899 PackObjectId::Hash(h) => HASH_VARIANT * BUCKETS_PER_VARIANT + h.as_bytes()[0] as usize,
900 PackObjectId::StateId(c) => {
901 CHANGEID_VARIANT * BUCKETS_PER_VARIANT + c.as_bytes()[0] as usize
902 }
903 PackObjectId::AnnotatedTag(hash) => {
904 ANNOTATED_TAG_VARIANT * BUCKETS_PER_VARIANT + hash.as_bytes()[0] as usize
905 }
906 }
907}
908
909fn decode_bucket_file(bytes: &[u8]) -> Result<Vec<(PackObjectId, u64)>> {
914 let mut out = Vec::new();
915 let mut pos = 0;
916 while pos < bytes.len() {
917 let (id, id_len) = PackObjectId::decode_tagged(&bytes[pos..])?;
918 pos += id_len;
919 if pos + 8 > bytes.len() {
920 return Err(StoreError::InvalidObject(
921 "streaming bucket entry truncated at offset".to_string(),
922 ));
923 }
924 let offset = u64::from_be_bytes(bytes[pos..pos + 8].try_into().map_err(|_| {
925 StoreError::InvalidObject("streaming bucket bad offset slice".to_string())
926 })?);
927 pos += 8;
928 out.push((id, offset));
929 }
930 Ok(out)
931}
932
933#[cfg(test)]
936mod tests {
937 use std::io::Cursor;
938
939 use super::*;
940 use crate::{
941 object::StateId,
942 store::pack::{PackReader, PackStats},
943 };
944
945 fn deterministic_hash(seed: u8) -> ContentHash {
946 let mut bytes = [0u8; 32];
950 bytes[0] = seed;
951 for (i, b) in bytes.iter_mut().enumerate().skip(1) {
952 *b = seed.wrapping_mul(31).wrapping_add(i as u8);
953 }
954 ContentHash::from_bytes(bytes)
955 }
956
957 fn deterministic_state_id(seed: u8) -> StateId {
958 let mut bytes = [0u8; 32];
959 bytes[0] = seed;
960 for (i, b) in bytes.iter_mut().enumerate().skip(1) {
961 *b = seed.wrapping_add(i as u8 * 7);
962 }
963 StateId::from_bytes(bytes)
964 }
965
966 fn fresh_builder(
971 tmp: &tempfile::TempDir,
972 ) -> (StreamingPackBuilder<Cursor<Vec<u8>>>, PathBuf, PathBuf) {
973 let bucket_dir = tmp.path().join("buckets");
974 let index_path = tmp.path().join("test.idx");
975 let cursor = Cursor::new(Vec::<u8>::new());
976 let b = StreamingPackBuilder::new(
977 cursor,
978 index_path.clone(),
979 CompressionConfig::default(),
980 bucket_dir.clone(),
981 )
982 .unwrap();
983 (b, bucket_dir, index_path)
984 }
985
986 fn finalize_cursor(
991 b: StreamingPackBuilder<Cursor<Vec<u8>>>,
992 index_path: &std::path::Path,
993 ) -> (Vec<u8>, Vec<u8>, PackStats) {
994 let (cursor, stats) = b.finalize().unwrap();
995 let index_bytes = std::fs::read(index_path).unwrap();
996 (cursor.into_inner(), index_bytes, stats)
997 }
998
999 #[test]
1000 fn empty_pack_finalizes_to_valid_zero_count_pack() {
1001 let tmp = tempfile::TempDir::new().unwrap();
1002 let (b, bucket_dir, idx_path) = fresh_builder(&tmp);
1003 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1004
1005 assert_eq!(stats.object_count, 0);
1006 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1008 assert!(reader.list_ids().unwrap().is_empty());
1009 assert!(
1011 !bucket_dir.exists(),
1012 "bucket dir should be cleaned on successful finalize"
1013 );
1014 }
1015
1016 #[test]
1017 fn single_blob_with_hash_id_round_trips() {
1018 let tmp = tempfile::TempDir::new().unwrap();
1019 let (mut b, _, idx_path) = fresh_builder(&tmp);
1020 let hash = deterministic_hash(0x42);
1021 let payload = b"hello, streaming pack".to_vec();
1022 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1023 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1024
1025 assert_eq!(stats.object_count, 1);
1026 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1027 let id = PackObjectId::Hash(hash);
1028 assert!(reader.has_object(&id).unwrap());
1029 let (got_type, got_data) = reader.get_object(&id).unwrap().unwrap();
1030 assert_eq!(got_type, ObjectType::Blob);
1031 assert_eq!(got_data, payload);
1032 }
1033
1034 #[test]
1035 fn single_state_with_change_id_round_trips() {
1036 let tmp = tempfile::TempDir::new().unwrap();
1037 let (mut b, _, idx_path) = fresh_builder(&tmp);
1038 let cid = deterministic_state_id(0xa5);
1039 let payload = b"serialized-state-bytes".to_vec();
1040 b.add_id(
1041 PackObjectId::StateId(cid),
1042 ObjectType::State,
1043 payload.clone(),
1044 )
1045 .unwrap();
1046 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1047
1048 assert_eq!(stats.object_count, 1);
1049 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1050 let id = PackObjectId::StateId(cid);
1051 let (ty, data) = reader.get_object(&id).unwrap().unwrap();
1052 assert_eq!(ty, ObjectType::State);
1053 assert_eq!(data, payload);
1054 }
1055
1056 #[test]
1057 fn shared_compact_tree_frame_reconstructs_each_indexed_object() {
1058 use crate::object::{Tree, TreeEntry};
1059
1060 let tmp = tempfile::TempDir::new().unwrap();
1061 let (mut builder, _, index_path) = fresh_builder(&tmp);
1062 let blob = deterministic_hash(0x33);
1063 let trees = vec![
1064 Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1065 Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1066 ];
1067 let ids = trees
1068 .iter()
1069 .map(|tree| PackObjectId::Hash(tree.hash()))
1070 .collect::<Vec<_>>();
1071 let frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1072 builder
1073 .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1074 .unwrap();
1075 let (pack, index, stats) = finalize_cursor(builder, &index_path);
1076 let reader = PackReader::from_bytes(pack, index).unwrap();
1077
1078 assert_eq!(stats.object_count, 2);
1079 assert_eq!(
1080 reader.encoded_payload_bytes(ObjectType::Tree).unwrap(),
1081 frame.len() as u64
1082 );
1083 let hosted = ids
1084 .iter()
1085 .zip(&trees)
1086 .map(|(id, tree)| {
1087 (
1088 *id,
1089 ObjectType::Tree,
1090 tree.encode_canonical().unwrap().len() as u64,
1091 )
1092 })
1093 .collect::<Vec<_>>();
1094 assert!(
1095 reader
1096 .copy_hosted_encoded_subset(&hosted)
1097 .unwrap()
1098 .is_none(),
1099 "repository-local compact frames must use the hosted fallback"
1100 );
1101 for (id, tree) in ids.iter().zip(&trees) {
1102 let (object_type, bytes) = reader.get_object(id).unwrap().unwrap();
1103 assert_eq!(object_type, ObjectType::Tree);
1104 assert_eq!(bytes, tree.encode_canonical().unwrap());
1105 }
1106 }
1107
1108 #[test]
1109 fn compact_tree_extraction_rejects_an_index_alias_with_the_wrong_typed_hash() {
1110 use crate::object::{Tree, TreeEntry};
1111
1112 let tmp = tempfile::TempDir::new().unwrap();
1113 let (mut builder, _, index_path) = fresh_builder(&tmp);
1114 let blob = deterministic_hash(0x34);
1115 let trees = vec![
1116 Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1117 Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1118 ];
1119 let wrong_hash = ContentHash::compute_typed("tree", b"not the second tree");
1120 assert_ne!(wrong_hash, trees[1].hash());
1121 let ids = vec![
1122 PackObjectId::Hash(trees[0].hash()),
1123 PackObjectId::Hash(wrong_hash),
1124 ];
1125 let frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1126 builder
1127 .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1128 .unwrap();
1129 let (pack, index, _) = finalize_cursor(builder, &index_path);
1130 let reader = PackReader::from_bytes(pack, index).unwrap();
1131
1132 let error = reader.get_object(&ids[1]).unwrap_err();
1133 assert!(
1134 error
1135 .to_string()
1136 .contains("does not contain indexed object"),
1137 "extraction must derive and verify the tree's typed hash: {error}"
1138 );
1139 }
1140
1141 #[test]
1142 fn shared_lineage_blob_frame_reconstructs_each_indexed_object() {
1143 let tmp = tempfile::TempDir::new().unwrap();
1144 let (mut builder, _, index_path) = fresh_builder(&tmp);
1145 let bodies = [b"newest version".as_slice(), b"older version".as_slice()];
1146 let ids = bodies
1147 .iter()
1148 .map(|body| PackObjectId::Hash(ContentHash::compute_typed("blob", body)))
1149 .collect::<Vec<_>>();
1150 let frame = heddle_object_model::compact::encode_blob_frame(&bodies).unwrap();
1151 builder
1152 .add_shared_frame(&ids, ObjectType::Blob, frame.len(), &frame)
1153 .unwrap();
1154 let (pack, index, stats) = finalize_cursor(builder, &index_path);
1155 let reader = PackReader::from_bytes(pack, index).unwrap();
1156
1157 assert_eq!(stats.object_count, bodies.len() as u64);
1158 assert_eq!(
1159 reader.encoded_payload_bytes(ObjectType::Blob).unwrap(),
1160 frame.len() as u64
1161 );
1162 for (id, expected) in ids.iter().zip(bodies) {
1163 let (object_type, actual) = reader.get_object(id).unwrap().unwrap();
1164 assert_eq!(object_type, ObjectType::Blob);
1165 assert_eq!(actual, expected);
1166 let PackObjectId::Hash(hash) = id else {
1167 unreachable!("blob ids are hashes")
1168 };
1169 assert_eq!(
1170 reader.get_hashed_object_type(hash).unwrap(),
1171 Some(ObjectType::Blob)
1172 );
1173 assert_eq!(
1174 reader.get_hashed_object_size(hash).unwrap(),
1175 Some(expected.len() as u64)
1176 );
1177 }
1178 }
1179
1180 #[test]
1181 fn ordinary_blob_starting_with_frame_magic_remains_ordinary() {
1182 let tmp = tempfile::TempDir::new().unwrap();
1183 let (mut builder, _, index_path) = fresh_builder(&tmp);
1184 let body = b"HCB2 arbitrary user content".to_vec();
1185 let hash = ContentHash::compute_typed("blob", &body);
1186 builder.add(hash, ObjectType::Blob, body.clone()).unwrap();
1187 let (pack, index, _) = finalize_cursor(builder, &index_path);
1188 let reader = PackReader::from_bytes(pack, index).unwrap();
1189
1190 assert_eq!(
1191 reader.get_object(&PackObjectId::Hash(hash)).unwrap(),
1192 Some((ObjectType::Blob, body))
1193 );
1194 }
1195
1196 #[test]
1197 fn corrupt_compact_frame_byte_invalidates_every_contained_object() {
1198 use crate::object::{Tree, TreeEntry};
1199
1200 let tmp = tempfile::TempDir::new().unwrap();
1201 let (mut builder, _, index_path) = fresh_builder(&tmp);
1202 let blob = deterministic_hash(0x44);
1203 let trees = vec![
1204 Tree::from_entries(vec![TreeEntry::file("a", blob, false).unwrap()]),
1205 Tree::from_entries(vec![TreeEntry::file("b", blob, true).unwrap()]),
1206 ];
1207 let ids = trees
1208 .iter()
1209 .map(|tree| PackObjectId::Hash(tree.hash()))
1210 .collect::<Vec<_>>();
1211 let mut frame = heddle_object_model::compact::encode_tree_frame(&trees).unwrap();
1212 let corrupt_at = frame.len() / 2;
1213 frame[corrupt_at] ^= 0x01;
1214 builder
1215 .add_shared_frame(&ids, ObjectType::Tree, frame.len(), &frame)
1216 .unwrap();
1217 let (pack, index, _) = finalize_cursor(builder, &index_path);
1218 let reader = PackReader::from_bytes(pack, index).unwrap();
1219
1220 for id in ids {
1221 let error = reader.get_object(&id).unwrap_err();
1222 assert!(
1223 error
1224 .to_string()
1225 .contains("compact frame checksum mismatch"),
1226 "unexpected error for {id:?}: {error}"
1227 );
1228 }
1229 }
1230
1231 #[test]
1232 fn corrupt_blob_frame_byte_invalidates_every_contained_object() {
1233 let tmp = tempfile::TempDir::new().unwrap();
1234 let (mut builder, _, index_path) = fresh_builder(&tmp);
1235 let bodies = [b"newest version".as_slice(), b"older version".as_slice()];
1236 let ids = bodies
1237 .iter()
1238 .map(|body| PackObjectId::Hash(ContentHash::compute_typed("blob", body)))
1239 .collect::<Vec<_>>();
1240 let mut frame = heddle_object_model::compact::encode_blob_frame(&bodies).unwrap();
1241 let corrupt_at = frame.len() / 2;
1242 frame[corrupt_at] ^= 0x01;
1243 builder
1244 .add_shared_frame(&ids, ObjectType::Blob, frame.len(), &frame)
1245 .unwrap();
1246 let (pack, index, _) = finalize_cursor(builder, &index_path);
1247 let reader = PackReader::from_bytes(pack, index).unwrap();
1248
1249 for id in ids {
1250 let error = reader.get_object(&id).unwrap_err();
1251 assert!(
1252 error
1253 .to_string()
1254 .contains("compact frame checksum mismatch"),
1255 "unexpected error for {id:?}: {error}"
1256 );
1257 }
1258 }
1259
1260 #[test]
1261 fn mixed_hash_and_changeid_ids_all_retrievable() {
1262 let tmp = tempfile::TempDir::new().unwrap();
1263 let (mut b, _, idx_path) = fresh_builder(&tmp);
1264 let blob_hash = deterministic_hash(0x10);
1265 let tree_hash = deterministic_hash(0x20);
1266 let state_cid = deterministic_state_id(0x80);
1267
1268 b.add(blob_hash, ObjectType::Blob, b"blob-bytes".to_vec())
1269 .unwrap();
1270 b.add(tree_hash, ObjectType::Tree, b"serialized-tree".to_vec())
1271 .unwrap();
1272 b.add_id(
1273 PackObjectId::StateId(state_cid),
1274 ObjectType::State,
1275 b"serialized-state",
1276 )
1277 .unwrap();
1278
1279 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1280 assert_eq!(stats.object_count, 3);
1281 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1282 assert_eq!(
1283 reader
1284 .get_object(&PackObjectId::Hash(blob_hash))
1285 .unwrap()
1286 .unwrap()
1287 .1,
1288 b"blob-bytes".to_vec()
1289 );
1290 assert_eq!(
1291 reader
1292 .get_object(&PackObjectId::Hash(tree_hash))
1293 .unwrap()
1294 .unwrap()
1295 .1,
1296 b"serialized-tree".to_vec()
1297 );
1298 assert_eq!(
1299 reader
1300 .get_object(&PackObjectId::StateId(state_cid))
1301 .unwrap()
1302 .unwrap()
1303 .1,
1304 b"serialized-state".to_vec()
1305 );
1306 }
1307
1308 #[test]
1309 fn ten_thousand_objects_round_trip_correctly() {
1310 let tmp = tempfile::TempDir::new().unwrap();
1314 let (mut b, _, idx_path) = fresh_builder(&tmp);
1315 let mut hashes = Vec::with_capacity(10_000);
1316 for i in 0..10_000u32 {
1317 let h = blake3::hash(&i.to_le_bytes());
1320 let hash = ContentHash::from_bytes(*h.as_bytes());
1321 hashes.push(hash);
1322 b.add(hash, ObjectType::Blob, format!("payload-{i}").into_bytes())
1323 .unwrap();
1324 }
1325 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1326 assert_eq!(stats.object_count, 10_000);
1327
1328 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1329 assert_eq!(reader.list_ids().unwrap().len(), 10_000);
1330 for i in [0, 1, 99, 1234, 5_000, 9_999] {
1332 let id = PackObjectId::Hash(hashes[i]);
1333 let (_ty, data) = reader.get_object(&id).unwrap().unwrap();
1334 assert_eq!(data, format!("payload-{i}").into_bytes());
1335 }
1336 }
1337
1338 #[test]
1339 fn bucket_writers_are_lru_capped_below_fd_limit() {
1340 let tmp = tempfile::TempDir::new().unwrap();
1341 let (mut b, _bucket_dir, idx_path) = fresh_builder(&tmp);
1342 let mut ids = Vec::new();
1343
1344 for i in 0..BUCKETS_PER_VARIANT {
1345 let hash = deterministic_hash(i as u8);
1346 ids.push(PackObjectId::Hash(hash));
1347 b.add(hash, ObjectType::Blob, format!("hash-{i}").into_bytes())
1348 .unwrap();
1349 assert!(
1350 b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1351 "open bucket writers should stay capped"
1352 );
1353 }
1354
1355 for i in 0..BUCKETS_PER_VARIANT {
1356 let cid = deterministic_state_id(i as u8);
1357 ids.push(PackObjectId::StateId(cid));
1358 b.add_id(
1359 PackObjectId::StateId(cid),
1360 ObjectType::State,
1361 format!("state-{i}").into_bytes(),
1362 )
1363 .unwrap();
1364 assert!(
1365 b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1366 "open bucket writers should stay capped"
1367 );
1368 }
1369
1370 for i in 0..BUCKETS_PER_VARIANT {
1371 let hash = deterministic_hash(i as u8);
1372 let id = PackObjectId::AnnotatedTag(hash);
1373 ids.push(id);
1374 b.add_id(
1375 id,
1376 ObjectType::AnnotatedTag,
1377 format!("tag-{i}").into_bytes(),
1378 )
1379 .unwrap();
1380 assert!(
1381 b.open_bucket_writers <= MAX_OPEN_BUCKET_WRITERS,
1382 "open bucket writers should stay capped"
1383 );
1384 }
1385
1386 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1387 assert_eq!(stats.object_count, TOTAL_BUCKETS as u64);
1388 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1389 for id in ids {
1390 assert!(reader.has_object(&id).unwrap(), "missing id {id:?}");
1391 }
1392 }
1393
1394 #[test]
1395 fn index_id_sort_order_matches_packbuilder_output() {
1396 use crate::store::pack::PackBuilder;
1404 let payloads: Vec<(PackObjectId, ObjectType, Vec<u8>)> = (0..200u32)
1405 .map(|i| {
1406 let h = blake3::hash(&i.to_le_bytes());
1407 (
1408 PackObjectId::Hash(ContentHash::from_bytes(*h.as_bytes())),
1409 if i % 3 == 0 {
1410 ObjectType::Tree
1411 } else {
1412 ObjectType::Blob
1413 },
1414 format!("body-{i}").into_bytes(),
1415 )
1416 })
1417 .collect();
1418
1419 let compression = CompressionConfig {
1422 max_delta_size: 0,
1423 ..CompressionConfig::default()
1424 };
1425 let mut classic = PackBuilder::new(compression);
1426 for (id, ty, data) in payloads.iter() {
1427 classic.add_id(*id, *ty, data.clone());
1428 }
1429 let (classic_pack, classic_index, _) = classic.build().unwrap();
1430 let classic_reader = PackReader::from_bytes(classic_pack, classic_index).unwrap();
1431
1432 let tmp = tempfile::TempDir::new().unwrap();
1433 let bucket_dir = tmp.path().join("buckets");
1434 let idx_path = tmp.path().join("test.idx");
1435 let cursor = Cursor::new(Vec::<u8>::new());
1436 let mut streaming =
1437 StreamingPackBuilder::new(cursor, idx_path.clone(), compression, bucket_dir).unwrap();
1438 for (id, ty, data) in payloads.iter() {
1439 streaming.add_id(*id, *ty, data.clone()).unwrap();
1440 }
1441 let (streaming_pack, streaming_index, _) = finalize_cursor(streaming, &idx_path);
1442 let streaming_reader = PackReader::from_bytes(streaming_pack, streaming_index).unwrap();
1443
1444 assert_eq!(
1447 streaming_reader.list_ids().unwrap(),
1448 classic_reader.list_ids().unwrap(),
1449 "streaming and classic indices should report the same id sequence"
1450 );
1451 for (id, _ty, want) in payloads.iter().take(10).chain(payloads.iter().skip(190)) {
1454 let (_, got) = streaming_reader.get_object(id).unwrap().unwrap();
1455 assert_eq!(&got, want);
1456 let (_, classic_got) = classic_reader.get_object(id).unwrap().unwrap();
1457 assert_eq!(got, classic_got);
1458 }
1459 }
1460
1461 #[test]
1462 fn corrupted_pack_fails_checksum_verification() {
1463 let tmp = tempfile::TempDir::new().unwrap();
1464 let (mut b, _, idx_path) = fresh_builder(&tmp);
1465 b.add(
1466 deterministic_hash(0x01),
1467 ObjectType::Blob,
1468 b"some bytes".to_vec(),
1469 )
1470 .unwrap();
1471 let (mut pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1472 let body_byte = 18; pack_data[body_byte] ^= 0xff;
1475 let result = PackReader::from_bytes(pack_data, index_data);
1476 assert!(
1477 result.is_err(),
1478 "PackReader should reject pack with mutated body"
1479 );
1480 }
1481
1482 #[test]
1483 fn pack_count_in_header_matches_index_entry_count() {
1484 let tmp = tempfile::TempDir::new().unwrap();
1485 let (mut b, _, idx_path) = fresh_builder(&tmp);
1486 for i in 0..7u8 {
1487 b.add(
1488 deterministic_hash(i),
1489 ObjectType::Blob,
1490 format!("p{i}").into_bytes(),
1491 )
1492 .unwrap();
1493 }
1494 let (pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1495 let count = u64::from_be_bytes(pack_data[8..16].try_into().unwrap());
1497 assert_eq!(count, 7);
1498 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1499 assert_eq!(reader.list_ids().unwrap().len(), 7);
1500 }
1501
1502 #[test]
1503 fn declared_pack_count_is_written_before_finalize() {
1504 let tmp = tempfile::TempDir::new().unwrap();
1505 let bucket_dir = tmp.path().join("buckets");
1506 let idx_path = tmp.path().join("test.idx");
1507 let cursor = Cursor::new(Vec::<u8>::new());
1508 let mut b = StreamingPackBuilder::new_with_object_count(
1509 cursor,
1510 idx_path.clone(),
1511 CompressionConfig::default(),
1512 bucket_dir,
1513 2,
1514 )
1515 .unwrap();
1516
1517 b.flush_pack().unwrap();
1518 let initial = b.pack_writer.as_ref().unwrap().get_ref().get_ref().clone();
1519 assert_eq!(u64::from_be_bytes(initial[8..16].try_into().unwrap()), 2);
1520
1521 let hash = deterministic_hash(0x40);
1522 b.add(hash, ObjectType::Blob, b"known-count-entry".to_vec())
1523 .unwrap();
1524 b.flush_pack().unwrap();
1525 let after_add = b.pack_writer.as_ref().unwrap().get_ref().get_ref().clone();
1526 assert_eq!(u64::from_be_bytes(after_add[8..16].try_into().unwrap()), 2);
1527
1528 let second_hash = deterministic_hash(0x41);
1529 b.add(second_hash, ObjectType::Blob, b"second-entry".to_vec())
1530 .unwrap();
1531 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1532
1533 assert_eq!(stats.object_count, 2);
1534 assert_eq!(u64::from_be_bytes(pack_data[8..16].try_into().unwrap()), 2);
1535 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1536 assert!(reader.has_object(&PackObjectId::Hash(hash)).unwrap());
1537 assert!(reader.has_object(&PackObjectId::Hash(second_hash)).unwrap());
1538 }
1539
1540 #[test]
1541 fn declared_pack_count_mismatch_fails_finalize() {
1542 let tmp = tempfile::TempDir::new().unwrap();
1543 let bucket_dir = tmp.path().join("buckets");
1544 let idx_path = tmp.path().join("test.idx");
1545 let cursor = Cursor::new(Vec::<u8>::new());
1546 let mut b = StreamingPackBuilder::new_with_object_count(
1547 cursor,
1548 idx_path,
1549 CompressionConfig::default(),
1550 bucket_dir,
1551 2,
1552 )
1553 .unwrap();
1554
1555 b.add(
1556 deterministic_hash(0x50),
1557 ObjectType::Blob,
1558 b"only-entry".to_vec(),
1559 )
1560 .unwrap();
1561 let error = b.finalize().unwrap_err();
1562
1563 assert!(
1564 error
1565 .to_string()
1566 .contains("streaming pack declared 2 record(s) but added 1")
1567 );
1568 }
1569
1570 #[test]
1571 fn bucket_files_are_cleaned_on_successful_finalize() {
1572 let tmp = tempfile::TempDir::new().unwrap();
1573 let bucket_dir = tmp.path().join("buckets");
1574 let idx_path = tmp.path().join("test.idx");
1575 let cursor = Cursor::new(Vec::<u8>::new());
1576 let mut b = StreamingPackBuilder::new(
1577 cursor,
1578 idx_path.clone(),
1579 CompressionConfig::default(),
1580 bucket_dir.clone(),
1581 )
1582 .unwrap();
1583 for i in 0..50u8 {
1584 b.add(deterministic_hash(i), ObjectType::Blob, vec![i; 32])
1585 .unwrap();
1586 }
1587 assert!(bucket_dir.exists());
1589 let bucket_count = std::fs::read_dir(&bucket_dir).unwrap().count();
1590 assert!(bucket_count > 0, "bucket dir should hold some files");
1591 let _ = finalize_cursor(b, &idx_path);
1592 assert!(
1593 !bucket_dir.exists(),
1594 "bucket dir should be removed on finalize"
1595 );
1596 }
1597
1598 #[test]
1599 fn bucket_files_are_cleaned_on_drop_without_finalize() {
1600 let tmp = tempfile::TempDir::new().unwrap();
1601 let bucket_dir = tmp.path().join("buckets");
1602 let idx_path = tmp.path().join("test.idx");
1603 {
1604 let cursor = Cursor::new(Vec::<u8>::new());
1605 let mut b = StreamingPackBuilder::new(
1606 cursor,
1607 idx_path.clone(),
1608 CompressionConfig::default(),
1609 bucket_dir.clone(),
1610 )
1611 .unwrap();
1612 for i in 0..10u8 {
1613 b.add(deterministic_hash(i), ObjectType::Blob, vec![0; 32])
1614 .unwrap();
1615 }
1616 assert!(bucket_dir.exists());
1617 }
1619 assert!(
1620 !idx_path.exists(),
1621 "no index file should have been created without finalize"
1622 );
1623 assert!(
1624 !bucket_dir.exists(),
1625 "bucket dir should be removed on Drop when finalize never ran"
1626 );
1627 }
1628
1629 #[test]
1630 fn large_blob_streams_to_disk_without_double_buffering() {
1631 let tmp = tempfile::TempDir::new().unwrap();
1636 let bucket_dir = tmp.path().join("buckets");
1637 let pack_path = tmp.path().join("pack.dat");
1638 let idx_path = tmp.path().join("pack.idx");
1639 let file = std::fs::OpenOptions::new()
1640 .read(true)
1641 .write(true)
1642 .create(true)
1643 .truncate(true)
1644 .open(&pack_path)
1645 .unwrap();
1646 let mut b = StreamingPackBuilder::new(
1647 file,
1648 idx_path.clone(),
1649 CompressionConfig::default(),
1650 bucket_dir,
1651 )
1652 .unwrap();
1653 let payload: Vec<u8> = (0..4 * 1024 * 1024u32).map(|i| (i & 0xff) as u8).collect();
1654 let hash = deterministic_hash(0xff);
1655 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1656 let (_, stats) = b.finalize().unwrap();
1657 let index_data = std::fs::read(&idx_path).unwrap();
1658 assert_eq!(stats.object_count, 1);
1659 let pack_bytes = std::fs::read(&pack_path).unwrap();
1660 let reader = PackReader::from_bytes(pack_bytes, index_data).unwrap();
1663 let (_ty, got) = reader
1664 .get_object(&PackObjectId::Hash(hash))
1665 .unwrap()
1666 .unwrap();
1667 assert_eq!(got, payload);
1668 }
1669
1670 #[test]
1671 fn bucket_distribution_for_random_hashes_is_roughly_uniform() {
1672 let tmp = tempfile::TempDir::new().unwrap();
1679 let bucket_dir = tmp.path().join("buckets");
1680 let idx_path = tmp.path().join("test.idx");
1681 let cursor = Cursor::new(Vec::<u8>::new());
1682 let mut b = StreamingPackBuilder::new(
1683 cursor,
1684 idx_path.clone(),
1685 CompressionConfig::default(),
1686 bucket_dir.clone(),
1687 )
1688 .unwrap();
1689 for i in 0..1024u32 {
1690 let h = blake3::hash(&i.to_le_bytes());
1691 let hash = ContentHash::from_bytes(*h.as_bytes());
1692 b.add(hash, ObjectType::Blob, b"x".to_vec()).unwrap();
1693 }
1694 b.pack_writer.as_mut().unwrap().flush().unwrap();
1696 let mut max_entries = 0usize;
1697 let entry_size = 33 + 8;
1700 for path in b.bucket_paths.iter() {
1701 if path.exists() {
1702 let size = std::fs::metadata(path).unwrap().len() as usize;
1703 let entries = size / entry_size;
1704 if entries > max_entries {
1705 max_entries = entries;
1706 }
1707 }
1708 }
1709 assert!(
1712 max_entries <= 16,
1713 "max bucket has {max_entries} entries; uniform expected ~4"
1714 );
1715 let _ = finalize_cursor(b, &idx_path);
1716 }
1717
1718 #[test]
1719 fn finalize_returns_correct_stats() {
1720 let tmp = tempfile::TempDir::new().unwrap();
1721 let (mut b, _, idx_path) = fresh_builder(&tmp);
1722 let payload = vec![0xabu8; 1024];
1723 for i in 0..5u8 {
1724 b.add(deterministic_hash(i), ObjectType::Blob, payload.clone())
1725 .unwrap();
1726 }
1727 let (_, _, stats) = finalize_cursor(b, &idx_path);
1728 assert_eq!(stats.object_count, 5);
1729 assert_eq!(stats.total_uncompressed, 5 * 1024);
1730 assert!(stats.total_compressed > 0);
1731 assert!(stats.compression_ratio > 0.0);
1732 assert_eq!(stats.delta_count, 0, "streaming builder never deltas");
1733 }
1734
1735 #[cfg(feature = "zstd")]
1736 #[test]
1737 fn streaming_compression_roundtrips_through_zstd_frame() {
1738 let tmp = tempfile::TempDir::new().unwrap();
1746 let (mut b, _, idx_path) = fresh_builder(&tmp);
1747 let payload = vec![0u8; 64 * 1024];
1750 let hash = deterministic_hash(0x77);
1751 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1752 let (pack_data, index_data, stats) = finalize_cursor(b, &idx_path);
1753 assert!(
1754 stats.total_compressed < stats.total_uncompressed,
1755 "expected compression ratio < 1.0, got {}/{}",
1756 stats.total_compressed,
1757 stats.total_uncompressed
1758 );
1759 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1760 let (_ty, got) = reader
1761 .get_object(&PackObjectId::Hash(hash))
1762 .unwrap()
1763 .unwrap();
1764 assert_eq!(got, payload);
1765 }
1766
1767 #[cfg(feature = "zstd")]
1768 #[test]
1769 fn equal_length_zstd_frame_is_stored_as_raw_payload() {
1770 fn encode_like_streaming_writer(data: &[u8]) -> Vec<u8> {
1774 let mut compressed = Vec::new();
1775 let mut encoder = zstd::stream::write::Encoder::new(&mut compressed, 3).unwrap();
1776 encoder
1777 .set_pledged_src_size(Some(data.len() as u64))
1778 .unwrap();
1779 std::io::Write::write_all(&mut encoder, data).unwrap();
1780 encoder.finish().unwrap();
1781 compressed
1782 }
1783
1784 let mut random = Vec::with_capacity(1024);
1785 for seed in 0u64..32 {
1786 random.extend_from_slice(blake3::hash(&seed.to_le_bytes()).as_bytes());
1787 }
1788 let payload = (256..=random.len())
1789 .find_map(|logical_len| {
1790 (0..=logical_len).find_map(|zero_prefix| {
1791 let mut candidate = random[..logical_len].to_vec();
1792 candidate[..zero_prefix].fill(0);
1793 let compressed = encode_like_streaming_writer(&candidate);
1794 (compressed.len() == candidate.len()).then_some(candidate)
1795 })
1796 })
1797 .expect("fixture search must find an equal-length zstd frame");
1798 let compressed = encode_like_streaming_writer(&payload);
1799 assert_eq!(compressed.len(), payload.len());
1800 assert_eq!(compressed.first(), Some(&0x28), "fixture must be zstd");
1801
1802 let tmp = tempfile::TempDir::new().unwrap();
1803 let (mut builder, _, index_path) = fresh_builder(&tmp);
1804 let id = PackObjectId::Hash(deterministic_hash(0x78));
1805 builder
1806 .add_id(id, ObjectType::StateAttachment, payload.clone())
1807 .unwrap();
1808 let (pack, index, stats) = finalize_cursor(builder, &index_path);
1809
1810 assert_eq!(stats.total_compressed, stats.total_uncompressed);
1811 let reader = PackReader::from_bytes(pack, index).unwrap();
1812 assert_eq!(
1813 reader.get_object(&id).unwrap(),
1814 Some((ObjectType::StateAttachment, payload))
1815 );
1816 }
1817
1818 #[cfg(feature = "zstd")]
1819 #[test]
1820 fn padded_varint_decodes_to_original_value_for_canonical_decoder() {
1821 let cases: &[u64] = &[0, 1, 127, 128, 4096, 1_000_000, 1_000_000_000_000, u64::MAX];
1827 for &value in cases {
1828 let mut buf = [0u8; 10];
1829 super::encode_varint_padded_to_10(value, &mut buf);
1830 let (decoded, consumed) = super::super::varint::decode_varint(&buf)
1831 .expect("padded varint should always decode");
1832 assert_eq!(decoded, value, "varint roundtrip failed for {value}");
1833 assert_eq!(
1834 consumed, 10,
1835 "padded encoding should consume all 10 bytes for {value}"
1836 );
1837 }
1838 }
1839
1840 #[cfg(feature = "zstd")]
1841 #[test]
1842 fn streaming_path_does_not_buffer_compressed_payload_in_memory() {
1843 let tmp = tempfile::TempDir::new().unwrap();
1856 let bucket_dir = tmp.path().join("buckets");
1857 let pack_path = tmp.path().join("pack.dat");
1858 let idx_path = tmp.path().join("pack.idx");
1859 let file = std::fs::OpenOptions::new()
1860 .read(true)
1861 .write(true)
1862 .create(true)
1863 .truncate(true)
1864 .open(&pack_path)
1865 .unwrap();
1866 let mut b = StreamingPackBuilder::new(
1867 file,
1868 idx_path.clone(),
1869 CompressionConfig::default(),
1870 bucket_dir,
1871 )
1872 .unwrap();
1873 let payload = vec![0xa5u8; 8 * 1024 * 1024];
1874 let hash = deterministic_hash(0x66);
1875 b.add(hash, ObjectType::Blob, payload.clone()).unwrap();
1876 let mid_size = std::fs::metadata(&pack_path).unwrap().len();
1880 assert!(
1881 mid_size > 16 + 40,
1882 "pack file should hold real entry data after add; size={mid_size}"
1883 );
1884 let (_, _) = b.finalize().unwrap();
1885 let pack_bytes = std::fs::read(&pack_path).unwrap();
1886 let index_bytes = std::fs::read(&idx_path).unwrap();
1887 let reader = PackReader::from_bytes(pack_bytes, index_bytes).unwrap();
1888 let (_ty, got) = reader
1889 .get_object(&PackObjectId::Hash(hash))
1890 .unwrap()
1891 .unwrap();
1892 assert_eq!(got, payload);
1893 }
1894
1895 #[test]
1896 fn list_ids_returns_all_added_ids_sorted() {
1897 let tmp = tempfile::TempDir::new().unwrap();
1898 let (mut b, _, idx_path) = fresh_builder(&tmp);
1899 let mut added: Vec<PackObjectId> = Vec::new();
1900 for seed in [0x05u8, 0xa0, 0x12, 0x9f, 0x33] {
1902 let id = PackObjectId::Hash(deterministic_hash(seed));
1903 b.add_id(id, ObjectType::Blob, vec![seed; 4]).unwrap();
1904 added.push(id);
1905 }
1906 for seed in [0x80u8, 0x10, 0xff] {
1907 let id = PackObjectId::StateId(deterministic_state_id(seed));
1908 b.add_id(id, ObjectType::State, vec![seed; 4]).unwrap();
1909 added.push(id);
1910 }
1911 let (pack_data, index_data, _) = finalize_cursor(b, &idx_path);
1912 let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
1913 let mut got = reader.list_ids().unwrap();
1914 let mut sorted = got.clone();
1917 sorted.sort();
1918 assert_eq!(got, sorted, "list_ids must come back sorted");
1919 added.sort();
1921 got.sort();
1922 assert_eq!(got, added);
1923 }
1924}