1use std::{
3 fs::{self, File, OpenOptions},
4 io::{Read, Write},
5 path::{Path, PathBuf},
6 time::{SystemTime, UNIX_EPOCH},
7};
8
9use objects::store::{
10 CompressionConfig, ObjectStore,
11 pack::{PackBuilder, PackObjectId, PackReader, StreamingPackBuilder},
12};
13
14use crate::{
15 ObjectData, ObjectId, ObjectInfo, ObjectType, ProtocolError, Result, load_object_data,
16};
17
18pub const MAX_RECEIVED_PACK_SIZE: u64 = 2 * 1024 * 1024 * 1024;
29
30pub const MAX_RECEIVED_PACK_INDEX_SIZE: u64 = 256 * 1024 * 1024;
36
37pub const MAX_RECEIVED_GIT_PACK_SIZE: u64 = 2 * 1024 * 1024 * 1024;
44
45#[derive(Debug, Clone)]
46pub struct NativePackBundle {
47 pub pack_data: Vec<u8>,
48 pub index_data: Vec<u8>,
49}
50
51#[derive(Debug)]
52pub struct NativePackFileBundle {
53 dir: PathBuf,
54 pub pack_path: PathBuf,
55 pub index_path: PathBuf,
56 pub pack_len: u64,
57 pub index_len: u64,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct ReusedNativePackStats {
62 pub object_count: usize,
63 pub encoded_bytes_copied: u64,
64}
65
66pub fn reuse_native_pack_encoded_subset_in(
70 root: &Path,
71 source_pack_path: &Path,
72 objects: &[ObjectInfo],
73) -> Result<Option<(NativePackFileBundle, ReusedNativePackStats)>> {
74 if objects.is_empty()
75 || objects
76 .iter()
77 .any(|object| !object.obj_type.packable_for_push())
78 {
79 return Ok(None);
80 }
81 let source_index_path = source_pack_path.with_extension("idx");
82 if !source_pack_path.is_file() || !source_index_path.is_file() {
83 return Ok(None);
84 }
85 let reader = PackReader::open(source_pack_path, &source_index_path)?;
86 let expected = objects
87 .iter()
88 .map(|object| {
89 Ok((
90 to_pack_object_id(&object.id),
91 object.obj_type.pack_object_type()?,
92 object.size,
93 ))
94 })
95 .collect::<Result<Vec<_>>>()?;
96 let Some(reused) = reader.copy_hosted_encoded_subset(&expected)? else {
97 return Ok(None);
98 };
99
100 let base = root.join("transfer-spool");
101 fs::create_dir_all(&base)?;
102 let dir = unique_spool_dir(&base)?;
103 let pack_path = dir.join("pack");
104 let index_path = dir.join("idx");
105 let write_result = (|| -> Result<(u64, u64)> {
106 fs::write(&pack_path, &reused.pack_data)?;
107 fs::write(&index_path, &reused.index_data)?;
108 Ok((
109 u64::try_from(reused.pack_data.len()).map_err(|_| {
110 ProtocolError::InvalidState("reused pack length exceeds u64".to_string())
111 })?,
112 u64::try_from(reused.index_data.len()).map_err(|_| {
113 ProtocolError::InvalidState("reused pack index length exceeds u64".to_string())
114 })?,
115 ))
116 })();
117 let (pack_len, index_len) = match write_result {
118 Ok(lengths) => lengths,
119 Err(error) => {
120 let _ = fs::remove_dir_all(&dir);
121 return Err(error);
122 }
123 };
124 Ok(Some((
125 NativePackFileBundle {
126 dir,
127 pack_path,
128 index_path,
129 pack_len,
130 index_len,
131 },
132 ReusedNativePackStats {
133 object_count: objects.len(),
134 encoded_bytes_copied: reused.encoded_bytes_copied,
135 },
136 )))
137}
138
139impl Drop for NativePackFileBundle {
140 fn drop(&mut self) {
141 let _ = fs::remove_dir_all(&self.dir);
142 }
143}
144
145#[derive(Debug)]
146pub struct PackFileChunkReader {
147 file: File,
148 total_len: u64,
149 chunk_size: usize,
150 offset: u64,
151 chunk_index: u32,
152}
153
154pub type NativePackFileChunk = (u64, u32, Vec<u8>, bool);
155
156impl PackFileChunkReader {
157 pub fn open(path: &Path, chunk_size: usize) -> Result<Self> {
158 let file = File::open(path)?;
159 let total_len = file.metadata()?.len();
160 Ok(Self {
161 file,
162 total_len,
163 chunk_size: chunk_size.max(1),
164 offset: 0,
165 chunk_index: 0,
166 })
167 }
168
169 pub fn next_chunk(&mut self) -> Result<Option<NativePackFileChunk>> {
170 if self.offset >= self.total_len {
171 return Ok(None);
172 }
173 let remaining = self.total_len - self.offset;
174 let len = remaining.min(self.chunk_size as u64);
175 let len = usize::try_from(len).map_err(|_| {
176 ProtocolError::InvalidState("native pack file chunk length exceeds usize".to_string())
177 })?;
178 let mut data = vec![0u8; len];
179 self.file.read_exact(&mut data)?;
180
181 let offset = self.offset;
182 let chunk_index = self.chunk_index;
183 self.offset = self.offset.checked_add(len as u64).ok_or_else(|| {
184 ProtocolError::InvalidState("native pack file chunk offset overflow".to_string())
185 })?;
186 self.chunk_index = self.chunk_index.checked_add(1).ok_or_else(|| {
187 ProtocolError::InvalidState("native pack file chunk index overflow".to_string())
188 })?;
189 Ok(Some((
190 offset,
191 chunk_index,
192 data,
193 self.offset == self.total_len,
194 )))
195 }
196}
197
198#[derive(Debug)]
199pub struct GrowingPackChunkReader {
200 file: File,
201 chunk_size: usize,
202 offset: u64,
203 chunk_index: u32,
204}
205
206impl GrowingPackChunkReader {
207 pub fn open(path: &Path, chunk_size: usize) -> Result<Self> {
208 Ok(Self {
209 file: File::open(path)?,
210 chunk_size: chunk_size.max(1),
211 offset: 0,
212 chunk_index: 0,
213 })
214 }
215
216 pub fn next_available_chunk(
217 &mut self,
218 final_stream: bool,
219 ) -> Result<Option<NativePackFileChunk>> {
220 let total_len = self.file.metadata()?.len();
221 if self.offset >= total_len {
222 return Ok(None);
223 }
224 let available = total_len - self.offset;
225 if !final_stream && available < self.chunk_size as u64 {
226 return Ok(None);
227 }
228
229 let len = available.min(self.chunk_size as u64);
230 let len = usize::try_from(len).map_err(|_| {
231 ProtocolError::InvalidState(
232 "growing native pack chunk length exceeds usize".to_string(),
233 )
234 })?;
235 let mut data = vec![0u8; len];
236 self.file.read_exact(&mut data)?;
237
238 let offset = self.offset;
239 let chunk_index = self.chunk_index;
240 self.offset = self.offset.checked_add(len as u64).ok_or_else(|| {
241 ProtocolError::InvalidState("growing native pack chunk offset overflow".to_string())
242 })?;
243 self.chunk_index = self.chunk_index.checked_add(1).ok_or_else(|| {
244 ProtocolError::InvalidState("growing native pack chunk index overflow".to_string())
245 })?;
246 Ok(Some((
247 offset,
248 chunk_index,
249 data,
250 final_stream && self.offset == total_len,
251 )))
252 }
253}
254
255pub struct NativePackStreamingWriter {
256 dir: Option<PathBuf>,
257 pack_path: PathBuf,
258 index_path: PathBuf,
259 builder: Option<StreamingPackBuilder<File>>,
260}
261
262impl NativePackStreamingWriter {
263 pub fn new_in(root: &Path, object_count: u64) -> Result<Self> {
264 let base = root.join("transfer-spool");
265 fs::create_dir_all(&base)?;
266 let dir = unique_spool_dir(&base)?;
267 let pack_path = dir.join("pack");
268 let index_path = dir.join("idx");
269 let bucket_dir = dir.join("buckets");
270 let pack_file = OpenOptions::new()
271 .read(true)
272 .write(true)
273 .create_new(true)
274 .open(&pack_path)?;
275 let builder = StreamingPackBuilder::new_with_object_count_ephemeral(
276 pack_file,
277 index_path.clone(),
278 sync_pack_compression(),
279 bucket_dir,
280 object_count,
281 )
282 .map_err(ProtocolError::from)?;
283
284 Ok(Self {
285 dir: Some(dir),
286 pack_path,
287 index_path,
288 builder: Some(builder),
289 })
290 }
291
292 pub fn pack_path(&self) -> &Path {
293 &self.pack_path
294 }
295
296 pub fn index_path(&self) -> &Path {
297 &self.index_path
298 }
299
300 pub fn add_object_data(&mut self, object: ObjectData) -> Result<()> {
301 if !is_native_packable_object_type(object.obj_type) {
302 return Err(ProtocolError::InvalidState(format!(
303 "{:?} sidecar records cannot be packed into the content-addressed object pack",
304 object.obj_type
305 )));
306 }
307 let builder = self.builder.as_mut().ok_or_else(|| {
308 ProtocolError::InvalidState("native pack streaming writer is finalized".to_string())
309 })?;
310 let pack_id = to_pack_object_id(&object.id);
311 builder
312 .add_id(pack_id, object.obj_type.pack_object_type()?, object.data)
313 .map_err(ProtocolError::from)
314 }
315
316 pub fn flush_pack(&mut self) -> Result<()> {
317 let builder = self.builder.as_mut().ok_or_else(|| {
318 ProtocolError::InvalidState("native pack streaming writer is finalized".to_string())
319 })?;
320 builder.flush_pack().map_err(ProtocolError::from)
321 }
322
323 pub fn finish(mut self) -> Result<NativePackFileBundle> {
324 let builder = self.builder.take().ok_or_else(|| {
325 ProtocolError::InvalidState("native pack streaming writer is finalized".to_string())
326 })?;
327 let (mut file, _) = builder.finalize().map_err(ProtocolError::from)?;
328 file.flush()?;
329 drop(file);
330 let pack_len = fs::metadata(&self.pack_path)?.len();
331 let index_len = fs::metadata(&self.index_path)?.len();
332 let dir = self.dir.take().ok_or_else(|| {
333 ProtocolError::InvalidState("native pack streaming writer lost spool dir".to_string())
334 })?;
335 Ok(NativePackFileBundle {
336 dir,
337 pack_path: self.pack_path.clone(),
338 index_path: self.index_path.clone(),
339 pack_len,
340 index_len,
341 })
342 }
343}
344
345impl Drop for NativePackStreamingWriter {
346 fn drop(&mut self) {
347 if let Some(dir) = self.dir.take() {
348 let _ = fs::remove_dir_all(dir);
349 }
350 }
351}
352
353#[derive(Debug, Default, Clone)]
354pub struct PackChunkState {
355 pub pack_data: Vec<u8>,
356 pub index_data: Vec<u8>,
357 pack_progress: (u64, u32),
358 index_progress: (u64, u32),
359 pack_complete: bool,
360 index_complete: bool,
361}
362
363impl PackChunkState {
364 pub fn is_complete(&self) -> bool {
365 self.pack_complete && self.index_complete
366 }
367}
368
369#[derive(Debug, Default, Clone)]
370pub struct GitPackChunkState {
371 transfer_id: Option<String>,
372 pack_size: Option<u64>,
373 next_offset: u64,
374 next_chunk_index: u32,
375 pack_data: Vec<u8>,
376}
377
378impl GitPackChunkState {
379 pub fn is_idle(&self) -> bool {
380 self.transfer_id.is_none()
381 && self.pack_size.is_none()
382 && self.next_offset == 0
383 && self.next_chunk_index == 0
384 && self.pack_data.is_empty()
385 }
386
387 pub fn ensure_idle(&self) -> Result<()> {
388 if self.is_idle() {
389 Ok(())
390 } else {
391 Err(ProtocolError::InvalidState(
392 "Git pack transfer ended before final chunk".to_string(),
393 ))
394 }
395 }
396
397 pub fn receive_chunk(
398 &mut self,
399 transfer_id: &str,
400 offset: u64,
401 chunk_index: u32,
402 is_final_chunk: bool,
403 pack_size: u64,
404 data: &[u8],
405 ) -> Result<Option<Vec<u8>>> {
406 if transfer_id.is_empty() {
407 return Err(ProtocolError::InvalidState(
408 "Git pack transfer_id is required".to_string(),
409 ));
410 }
411 if pack_size > MAX_RECEIVED_GIT_PACK_SIZE {
412 return Err(ProtocolError::InvalidState(format!(
413 "Git pack exceeds maximum transfer size of {MAX_RECEIVED_GIT_PACK_SIZE} bytes"
414 )));
415 }
416 if data.is_empty() {
417 return Err(ProtocolError::InvalidState(
418 "Git pack chunk must not be empty".to_string(),
419 ));
420 }
421 match self.transfer_id.as_ref() {
422 Some(current) if current != transfer_id => {
423 return Err(ProtocolError::InvalidState(format!(
424 "Git pack transfer id changed from {current:?} to {transfer_id:?}"
425 )));
426 }
427 Some(_) => {}
428 None => {
429 self.transfer_id = Some(transfer_id.to_string());
430 self.pack_size = Some(pack_size);
431 }
432 }
433 if self.pack_size != Some(pack_size) {
434 return Err(ProtocolError::InvalidState(
435 "Git pack size changed during transfer".to_string(),
436 ));
437 }
438 if offset != self.next_offset {
439 return Err(ProtocolError::InvalidState(format!(
440 "Git pack offset mismatch: expected {}, got {}",
441 self.next_offset, offset
442 )));
443 }
444 if chunk_index != self.next_chunk_index {
445 return Err(ProtocolError::InvalidState(format!(
446 "Git pack chunk index mismatch: expected {}, got {}",
447 self.next_chunk_index, chunk_index
448 )));
449 }
450 let chunk_len = u64::try_from(data.len()).map_err(|_| {
451 ProtocolError::InvalidState("Git pack chunk length exceeds u64".to_string())
452 })?;
453 let next_offset = self
454 .next_offset
455 .checked_add(chunk_len)
456 .ok_or_else(|| ProtocolError::InvalidState("Git pack offset overflow".to_string()))?;
457 if next_offset > pack_size {
458 return Err(ProtocolError::InvalidState(
459 "Git pack chunk exceeds declared pack size".to_string(),
460 ));
461 }
462 self.pack_data.extend_from_slice(data);
463 self.next_offset = next_offset;
464 self.next_chunk_index = self.next_chunk_index.checked_add(1).ok_or_else(|| {
465 ProtocolError::InvalidState("Git pack chunk index overflow".to_string())
466 })?;
467 if is_final_chunk {
468 if self.next_offset != pack_size {
469 return Err(ProtocolError::InvalidState(format!(
470 "Git pack final size mismatch: declared {}, received {}",
471 pack_size, self.next_offset
472 )));
473 }
474 let pack_data = std::mem::take(&mut self.pack_data);
475 self.transfer_id = None;
476 self.pack_size = None;
477 self.next_offset = 0;
478 self.next_chunk_index = 0;
479 return Ok(Some(pack_data));
480 }
481 if self.next_offset == pack_size {
482 return Err(ProtocolError::InvalidState(
483 "Git pack reached declared size without final chunk marker".to_string(),
484 ));
485 }
486 Ok(None)
487 }
488}
489
490#[derive(Debug)]
491pub struct PackChunkSpool {
492 dir: PathBuf,
493 pack: PackStreamSpool,
494 index: PackStreamSpool,
495}
496
497impl PackChunkSpool {
498 pub fn new_in(root: &Path) -> Result<Self> {
499 let base = root.join("transfer-spool");
500 fs::create_dir_all(&base)?;
501 let dir = unique_spool_dir(&base)?;
502 let pack = PackStreamSpool::new(dir.join("pack"))?;
503 let index = PackStreamSpool::new(dir.join("idx"))?;
504 Ok(Self { dir, pack, index })
505 }
506
507 pub fn is_complete(&self) -> bool {
508 self.pack.complete && self.index.complete
509 }
510
511 #[allow(clippy::too_many_arguments)]
512 pub fn receive_chunk(
513 &mut self,
514 is_index: bool,
515 resume_offset: u64,
516 chunk_index: u32,
517 is_complete: bool,
518 data: &[u8],
519 is_final_chunk: bool,
520 ) -> Result<()> {
521 let max_bytes = if is_index {
522 MAX_RECEIVED_PACK_INDEX_SIZE
523 } else {
524 MAX_RECEIVED_PACK_SIZE
525 };
526 let stream = if is_index {
527 &mut self.index
528 } else {
529 &mut self.pack
530 };
531 receive_pack_chunk_to_spool(
532 stream,
533 is_index,
534 resume_offset,
535 chunk_index,
536 is_complete,
537 data,
538 is_final_chunk,
539 max_bytes,
540 )
541 }
542
543 pub fn install_into(&mut self, store: &impl ObjectStore) -> Result<Vec<PackObjectId>> {
544 if !self.is_complete() {
545 return Err(ProtocolError::InvalidState(
546 "native pack spool is incomplete".to_string(),
547 ));
548 }
549 self.pack.close()?;
550 self.index.close()?;
551 store
552 .install_pack_streaming(&self.pack.path, &self.index.path)
553 .map_err(ProtocolError::from)
554 }
555}
556
557impl Drop for PackChunkSpool {
558 fn drop(&mut self) {
559 let _ = fs::remove_dir_all(&self.dir);
560 }
561}
562
563#[derive(Debug)]
564struct PackStreamSpool {
565 path: PathBuf,
566 file: Option<File>,
567 progress: (u64, u32),
568 complete: bool,
569}
570
571impl PackStreamSpool {
572 fn new(path: PathBuf) -> Result<Self> {
573 let file = File::create(&path)?;
574 Ok(Self {
575 path,
576 file: Some(file),
577 progress: (0, 0),
578 complete: false,
579 })
580 }
581
582 fn write_all(&mut self, data: &[u8]) -> Result<()> {
583 let Some(file) = self.file.as_mut() else {
584 return Err(ProtocolError::InvalidState(
585 "native pack spool stream is already closed".to_string(),
586 ));
587 };
588 file.write_all(data)?;
589 Ok(())
590 }
591
592 fn close(&mut self) -> Result<()> {
593 if let Some(mut file) = self.file.take() {
594 file.flush()?;
595 file.sync_all()?;
596 }
597 Ok(())
598 }
599}
600
601pub fn native_pack_excluded_object_types() -> &'static [ObjectType] {
602 &[ObjectType::Redaction, ObjectType::StateVisibility]
603}
604
605pub fn is_native_packable_object_type(obj_type: ObjectType) -> bool {
606 obj_type.packable()
607}
608
609pub fn build_native_pack(
610 store: &impl ObjectStore,
611 objects: &[ObjectInfo],
612) -> Result<NativePackBundle> {
613 let mut builder = PackBuilder::new(sync_pack_compression());
614
615 for info in objects {
616 if !is_native_packable_object_type(info.obj_type) {
622 continue;
623 }
624 let object = load_object_data(store, &info.id, info.obj_type)?;
625 let pack_id = to_pack_object_id(&object.id);
626 builder.add_id(pack_id, object.obj_type.pack_object_type()?, object.data);
627 }
628
629 let (pack_data, index_data, _) = builder.build()?;
630 Ok(NativePackBundle {
631 pack_data,
632 index_data,
633 })
634}
635
636fn sync_pack_compression() -> CompressionConfig {
637 CompressionConfig {
638 level: 1,
639 min_size: 1024,
640 max_delta_size: 0,
641 ..CompressionConfig::default()
642 }
643}
644
645pub fn install_received_pack(
646 store: &impl ObjectStore,
647 pack_data: &[u8],
648 index_data: &[u8],
649) -> Result<Vec<PackObjectId>> {
650 store
651 .install_pack(pack_data, index_data)
652 .map_err(ProtocolError::from)
653}
654
655pub fn next_pack_chunk(
656 data: &[u8],
657 chunk_size: usize,
658 chunk_index: usize,
659) -> Option<(usize, Vec<u8>, bool)> {
660 let (start, len) = crate::chunk_bounds(data.len(), chunk_size.max(1), chunk_index)?;
661 let is_final = start + len == data.len();
662 Some((start, data[start..start + len].to_vec(), is_final))
663}
664
665pub fn receive_pack_chunk(
666 state: &mut PackChunkState,
667 is_index: bool,
668 resume_offset: u64,
669 chunk_index: u32,
670 is_complete: bool,
671 data: &[u8],
672 is_final_chunk: bool,
673) -> Result<()> {
674 let max_bytes = if is_index {
675 MAX_RECEIVED_PACK_INDEX_SIZE
676 } else {
677 MAX_RECEIVED_PACK_SIZE
678 };
679 receive_pack_chunk_with_limit(
680 state,
681 is_index,
682 resume_offset,
683 chunk_index,
684 is_complete,
685 data,
686 is_final_chunk,
687 max_bytes,
688 )
689}
690
691#[allow(clippy::too_many_arguments)]
692fn receive_pack_chunk_with_limit(
693 state: &mut PackChunkState,
694 is_index: bool,
695 resume_offset: u64,
696 chunk_index: u32,
697 is_complete: bool,
698 data: &[u8],
699 is_final_chunk: bool,
700 max_bytes: u64,
701) -> Result<()> {
702 let (buffer, progress, complete) = if is_index {
703 (
704 &mut state.index_data,
705 &mut state.index_progress,
706 &mut state.index_complete,
707 )
708 } else {
709 (
710 &mut state.pack_data,
711 &mut state.pack_progress,
712 &mut state.pack_complete,
713 )
714 };
715
716 let next_progress = validate_pack_chunk(
717 *progress,
718 is_index,
719 resume_offset,
720 chunk_index,
721 data,
722 max_bytes,
723 )?;
724
725 buffer.extend_from_slice(data);
726 *progress = next_progress;
727 if is_final_chunk || is_complete {
728 *complete = true;
729 }
730 Ok(())
731}
732
733#[allow(clippy::too_many_arguments)]
734fn receive_pack_chunk_to_spool(
735 stream: &mut PackStreamSpool,
736 is_index: bool,
737 resume_offset: u64,
738 chunk_index: u32,
739 is_complete: bool,
740 data: &[u8],
741 is_final_chunk: bool,
742 max_bytes: u64,
743) -> Result<()> {
744 let next_progress = validate_pack_chunk(
745 stream.progress,
746 is_index,
747 resume_offset,
748 chunk_index,
749 data,
750 max_bytes,
751 )?;
752 stream.write_all(data)?;
753 stream.progress = next_progress;
754 if is_final_chunk || is_complete {
755 stream.complete = true;
756 }
757 Ok(())
758}
759
760fn validate_pack_chunk(
761 progress: (u64, u32),
762 is_index: bool,
763 resume_offset: u64,
764 chunk_index: u32,
765 data: &[u8],
766 max_bytes: u64,
767) -> Result<(u64, u32)> {
768 if resume_offset != progress.0 {
769 return Err(ProtocolError::InvalidState(format!(
770 "native pack chunk resume offset mismatch: expected {}, got {}",
771 progress.0, resume_offset
772 )));
773 }
774 if chunk_index != progress.1 {
775 return Err(ProtocolError::InvalidState(format!(
776 "native pack chunk index mismatch: expected {}, got {}",
777 progress.1, chunk_index
778 )));
779 }
780
781 let data_len = u64::try_from(data.len()).map_err(|_| {
782 ProtocolError::InvalidState("native pack chunk length does not fit in u64".to_string())
783 })?;
784 let next_offset = progress.0.checked_add(data_len).ok_or_else(|| {
785 ProtocolError::InvalidState("native pack chunk offset overflow".to_string())
786 })?;
787 if next_offset > max_bytes {
788 let stream_name = if is_index { "index" } else { "body" };
789 return Err(ProtocolError::InvalidState(format!(
790 "native pack {stream_name} exceeds receive size limit: {next_offset} bytes (max {max_bytes})"
791 )));
792 }
793 let next_chunk = progress.1.checked_add(1).ok_or_else(|| {
794 ProtocolError::InvalidState("native pack chunk index overflow".to_string())
795 })?;
796
797 Ok((next_offset, next_chunk))
798}
799
800pub(crate) fn unique_spool_dir(base: &Path) -> Result<PathBuf> {
801 let stamp = SystemTime::now()
802 .duration_since(UNIX_EPOCH)
803 .map_err(|err| {
804 ProtocolError::InvalidState(format!("system clock before UNIX epoch: {err}"))
805 })?
806 .as_nanos();
807 for attempt in 0..100u32 {
808 let dir = base.join(format!("pack-{}-{stamp}-{attempt}", std::process::id()));
809 match fs::create_dir(&dir) {
810 Ok(()) => return Ok(dir),
811 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
812 Err(err) => return Err(ProtocolError::Io(err)),
813 }
814 }
815 Err(ProtocolError::InvalidState(
816 "failed to allocate native pack spool directory".to_string(),
817 ))
818}
819
820fn to_pack_object_id(id: &ObjectId) -> PackObjectId {
821 match id {
822 ObjectId::Hash(hash) => PackObjectId::Hash(*hash),
823 ObjectId::StateId(state_id) => PackObjectId::StateId(*state_id),
824 ObjectId::StateAttachment { id, .. } => PackObjectId::Hash(*id.as_hash()),
825 }
826}
827
828#[cfg(test)]
829mod tests {
830 use objects::{
831 object::{Blob, ContentHash, StateId},
832 store::{
833 CompressionConfig, FsStore, ObjectStore,
834 pack::{ObjectType as PackObjectType, PackBuilder, PackObjectId, PackReader},
835 },
836 };
837 use tempfile::TempDir;
838
839 use super::{
840 GitPackChunkState, GrowingPackChunkReader, MAX_RECEIVED_PACK_SIZE,
841 NativePackStreamingWriter, ObjectData, ObjectId, ObjectInfo, ObjectType, PackChunkSpool,
842 PackChunkState, PackFileChunkReader, build_native_pack, install_received_pack,
843 next_pack_chunk, receive_pack_chunk, receive_pack_chunk_with_limit,
844 reuse_native_pack_encoded_subset_in,
845 };
846
847 fn create_test_store() -> (TempDir, FsStore) {
848 let temp = TempDir::new().unwrap();
849 let store = FsStore::new(temp.path().join(".heddle"));
850 store.init().unwrap();
851 (temp, store)
852 }
853
854 fn hash(byte: u8) -> ContentHash {
855 ContentHash::from_bytes([byte; 32])
856 }
857
858 #[test]
859 fn encoded_snapshot_subset_is_wire_equivalent_without_local_artifacts_or_attachments() {
860 let source = TempDir::new().unwrap();
861 let spool = TempDir::new().unwrap();
862 let source_pack = source.path().join("snapshot.pack");
863 let source_index = source.path().join("snapshot.idx");
864 let blob = (
865 PackObjectId::Hash(hash(1)),
866 PackObjectType::Blob,
867 b"blob body".to_vec(),
868 );
869 let tree = (
870 PackObjectId::Hash(hash(2)),
871 PackObjectType::Tree,
872 b"tree body".to_vec(),
873 );
874 let state_id = StateId::from_bytes([3; 32]);
875 let state = (
876 PackObjectId::StateId(state_id),
877 PackObjectType::State,
878 b"state body".to_vec(),
879 );
880 let attachment_id = PackObjectId::Hash(hash(4));
881 let artifact_id = PackObjectId::Hash(hash(5));
882 let mut builder = PackBuilder::new(CompressionConfig {
883 max_delta_size: 0,
884 ..CompressionConfig::default()
885 });
886 for (id, kind, body) in [
887 blob.clone(),
888 tree.clone(),
889 state.clone(),
890 (
891 attachment_id,
892 PackObjectType::StateAttachment,
893 b"local attachment".to_vec(),
894 ),
895 (
896 artifact_id,
897 PackObjectType::SnapshotCommit,
898 b"local commit artifact".to_vec(),
899 ),
900 ] {
901 builder.add_id(id, kind, body);
902 }
903 let (pack, index, _) = builder.build().unwrap();
904 std::fs::write(&source_pack, pack).unwrap();
905 std::fs::write(&source_index, index).unwrap();
906
907 let wanted = vec![
908 ObjectInfo {
909 id: ObjectId::Hash(hash(1)),
910 obj_type: ObjectType::Blob,
911 size: blob.2.len() as u64,
912 delta_base: None,
913 },
914 ObjectInfo {
915 id: ObjectId::Hash(hash(2)),
916 obj_type: ObjectType::Tree,
917 size: tree.2.len() as u64,
918 delta_base: None,
919 },
920 ObjectInfo {
921 id: ObjectId::StateId(state_id),
922 obj_type: ObjectType::State,
923 size: state.2.len() as u64,
924 delta_base: None,
925 },
926 ];
927 let (bundle, stats) =
928 reuse_native_pack_encoded_subset_in(spool.path(), &source_pack, &wanted)
929 .unwrap()
930 .expect("authoritative non-delta subset must be reusable");
931
932 assert_eq!(stats.object_count, wanted.len());
933 assert!(stats.encoded_bytes_copied > 0);
934 let reused = PackReader::open(&bundle.pack_path, &bundle.index_path).unwrap();
935 let mut reused_ids = reused.list_ids();
936 reused_ids.sort();
937 let mut wanted_ids = vec![blob.0, tree.0, state.0];
938 wanted_ids.sort();
939 assert_eq!(reused_ids, wanted_ids);
940 assert!(!reused.has_object(&attachment_id));
941 assert!(!reused.has_object(&artifact_id));
942
943 for path in [&bundle.pack_path, &bundle.index_path] {
944 let expected_wire_bytes = std::fs::read(path).unwrap();
945 let mut chunk_reader = PackFileChunkReader::open(path, 7).unwrap();
946 let mut wire_bytes = Vec::new();
947 while let Some((offset, chunk_index, data, is_final)) =
948 chunk_reader.next_chunk().unwrap()
949 {
950 assert_eq!(offset as usize, wire_bytes.len());
951 assert_eq!(chunk_index as usize, wire_bytes.len() / 7);
952 wire_bytes.extend_from_slice(&data);
953 assert_eq!(is_final, wire_bytes.len() == expected_wire_bytes.len());
954 }
955 assert_eq!(wire_bytes, expected_wire_bytes);
956 }
957 for (id, _, expected) in [blob, tree, state] {
958 assert_eq!(reused.get_object(&id).unwrap().unwrap().1, expected);
959 }
960 }
961
962 #[test]
963 fn encoded_snapshot_subset_falls_back_for_mismatch_delta_or_attachment_request() {
964 let source = TempDir::new().unwrap();
965 let spool = TempDir::new().unwrap();
966 let source_pack = source.path().join("snapshot.pack");
967 let source_index = source.path().join("snapshot.idx");
968 let first = b"This is the base content. ".repeat(100);
969 let second = b"This is modified content. ".repeat(100);
970 let mut builder = PackBuilder::new(CompressionConfig::default());
971 builder.add(hash(10), PackObjectType::Blob, first.clone());
972 builder.add(hash(11), PackObjectType::Blob, second.clone());
973 let (pack, index, stats) = builder.build().unwrap();
974 assert!(stats.delta_count > 0, "fixture must contain a delta");
975 std::fs::write(&source_pack, pack).unwrap();
976 std::fs::write(&source_index, index).unwrap();
977 let delta_wants = [ObjectInfo {
978 id: ObjectId::Hash(hash(11)),
979 obj_type: ObjectType::Blob,
980 size: second.len() as u64,
981 delta_base: None,
982 }];
983 assert!(
984 reuse_native_pack_encoded_subset_in(spool.path(), &source_pack, &delta_wants)
985 .unwrap()
986 .is_none()
987 );
988
989 let missing_wants = [ObjectInfo {
990 id: ObjectId::Hash(hash(12)),
991 obj_type: ObjectType::Blob,
992 size: 1,
993 delta_base: None,
994 }];
995 assert!(
996 reuse_native_pack_encoded_subset_in(spool.path(), &source_pack, &missing_wants)
997 .unwrap()
998 .is_none()
999 );
1000
1001 let attachment_wants = [ObjectInfo {
1002 id: ObjectId::StateAttachment {
1003 state: StateId::from_bytes([13; 32]),
1004 id: objects::object::StateAttachmentId::from_hash(hash(14)),
1005 kind: objects::object::StateAttachmentKind::SemanticIndex,
1006 },
1007 obj_type: ObjectType::StateAttachment,
1008 size: 1,
1009 delta_base: None,
1010 }];
1011 assert!(
1012 reuse_native_pack_encoded_subset_in(spool.path(), &source_pack, &attachment_wants)
1013 .unwrap()
1014 .is_none()
1015 );
1016 }
1017
1018 #[test]
1019 fn receive_pack_chunk_rejects_cumulative_size_over_limit_before_buffering() {
1020 let mut state = PackChunkState::default();
1021
1022 receive_pack_chunk_with_limit(&mut state, false, 0, 0, false, b"abcd", false, 8).unwrap();
1023 receive_pack_chunk_with_limit(&mut state, false, 4, 1, false, b"efgh", false, 8).unwrap();
1024
1025 let error = receive_pack_chunk_with_limit(&mut state, false, 8, 2, false, b"i", false, 8)
1026 .unwrap_err();
1027
1028 assert_eq!(state.pack_data, b"abcdefgh");
1029 assert!(
1030 error
1031 .to_string()
1032 .contains("native pack body exceeds receive size limit")
1033 );
1034 assert!(error.to_string().contains("9 bytes (max 8)"));
1035 }
1036
1037 #[test]
1038 fn receive_pack_chunk_checks_production_limit_before_extending_buffer() {
1039 let mut state = PackChunkState {
1040 pack_progress: (MAX_RECEIVED_PACK_SIZE - 1, 0),
1041 ..PackChunkState::default()
1042 };
1043
1044 let error = receive_pack_chunk(
1045 &mut state,
1046 false,
1047 MAX_RECEIVED_PACK_SIZE - 1,
1048 0,
1049 false,
1050 b"xx",
1051 false,
1052 )
1053 .unwrap_err();
1054
1055 assert!(state.pack_data.is_empty());
1056 assert!(
1057 error
1058 .to_string()
1059 .contains("native pack body exceeds receive size limit")
1060 );
1061 }
1062
1063 #[test]
1064 fn receive_pack_chunk_rejects_resume_offset_mismatch_before_buffering() {
1065 let mut state = PackChunkState::default();
1066
1067 let error =
1068 receive_pack_chunk(&mut state, false, 1, 0, false, b"late chunk", false).unwrap_err();
1069
1070 assert!(state.pack_data.is_empty());
1071 assert!(
1072 error
1073 .to_string()
1074 .contains("native pack chunk resume offset mismatch: expected 0, got 1")
1075 );
1076 }
1077
1078 #[test]
1079 fn receive_pack_chunk_rejects_chunk_index_mismatch_before_buffering() {
1080 let mut state = PackChunkState::default();
1081
1082 receive_pack_chunk(&mut state, false, 0, 0, false, b"abc", false).unwrap();
1083 let error = receive_pack_chunk(&mut state, false, 3, 2, false, b"def", false).unwrap_err();
1084
1085 assert_eq!(state.pack_data, b"abc");
1086 assert!(
1087 error
1088 .to_string()
1089 .contains("native pack chunk index mismatch: expected 1, got 2")
1090 );
1091 }
1092
1093 #[test]
1094 fn git_pack_chunk_state_requires_ordered_chunks_and_final_size() {
1095 let mut state = GitPackChunkState::default();
1096
1097 assert!(
1098 state
1099 .receive_chunk("git-pack:test", 0, 0, false, 8, b"abcd")
1100 .unwrap()
1101 .is_none()
1102 );
1103 let error = state
1104 .receive_chunk("git-pack:test", 4, 2, true, 8, b"efgh")
1105 .unwrap_err();
1106
1107 assert!(
1108 error
1109 .to_string()
1110 .contains("Git pack chunk index mismatch: expected 1, got 2")
1111 );
1112 assert!(state.ensure_idle().is_err());
1113
1114 let mut state = GitPackChunkState::default();
1115 state
1116 .receive_chunk("git-pack:test", 0, 0, false, 8, b"abcd")
1117 .unwrap();
1118 let complete = state
1119 .receive_chunk("git-pack:test", 4, 1, true, 8, b"efgh")
1120 .unwrap()
1121 .unwrap();
1122
1123 assert_eq!(complete, b"abcdefgh");
1124 assert!(state.ensure_idle().is_ok());
1125 }
1126
1127 #[test]
1128 fn receive_pack_chunk_accepts_completion_flags_for_pack_and_index() {
1129 let mut state = PackChunkState::default();
1130
1131 receive_pack_chunk(&mut state, false, 0, 0, true, b"pack-body", false).unwrap();
1132 assert!(!state.is_complete());
1133 receive_pack_chunk(&mut state, true, 0, 0, false, b"pack-index", true).unwrap();
1134
1135 assert!(state.is_complete());
1136 assert_eq!(state.pack_data, b"pack-body");
1137 assert_eq!(state.index_data, b"pack-index");
1138 }
1139
1140 #[test]
1141 fn normal_size_native_pack_receives_and_installs() {
1142 let (_source_temp, source_store) = create_test_store();
1143 let (_dest_temp, dest_store) = create_test_store();
1144 let blob = Blob::from("native pack receive regression");
1145 let hash = source_store.put_blob(&blob).unwrap();
1146 let bundle = build_native_pack(
1147 &source_store,
1148 &[ObjectInfo {
1149 id: ObjectId::Hash(hash),
1150 obj_type: ObjectType::Blob,
1151 size: blob.size() as u64,
1152 delta_base: None,
1153 }],
1154 )
1155 .unwrap();
1156
1157 let mut state = PackChunkState::default();
1158 let mut chunk_index = 0usize;
1159 while let Some((start, data, is_final)) = next_pack_chunk(&bundle.pack_data, 7, chunk_index)
1160 {
1161 receive_pack_chunk(
1162 &mut state,
1163 false,
1164 start as u64,
1165 chunk_index as u32,
1166 is_final,
1167 &data,
1168 is_final,
1169 )
1170 .unwrap();
1171 chunk_index += 1;
1172 }
1173
1174 let mut index_chunk = 0usize;
1175 while let Some((start, data, is_final)) =
1176 next_pack_chunk(&bundle.index_data, 5, index_chunk)
1177 {
1178 receive_pack_chunk(
1179 &mut state,
1180 true,
1181 start as u64,
1182 index_chunk as u32,
1183 is_final,
1184 &data,
1185 is_final,
1186 )
1187 .unwrap();
1188 index_chunk += 1;
1189 }
1190
1191 assert!(state.is_complete());
1192 assert_eq!(state.pack_data, bundle.pack_data);
1193 assert_eq!(state.index_data, bundle.index_data);
1194
1195 let installed_ids =
1196 install_received_pack(&dest_store, &state.pack_data, &state.index_data).unwrap();
1197
1198 assert_eq!(installed_ids, vec![PackObjectId::Hash(hash)]);
1199 let installed_blob = dest_store.get_blob(&hash).unwrap().unwrap();
1200 assert_eq!(installed_blob.content(), blob.content());
1201 }
1202
1203 #[test]
1204 fn normal_size_native_pack_spools_and_installs() {
1205 let (_source_temp, source_store) = create_test_store();
1206 let (dest_temp, dest_store) = create_test_store();
1207 let blob = Blob::from("native pack spooled receive regression");
1208 let hash = source_store.put_blob(&blob).unwrap();
1209 let bundle = build_native_pack(
1210 &source_store,
1211 &[ObjectInfo {
1212 id: ObjectId::Hash(hash),
1213 obj_type: ObjectType::Blob,
1214 size: blob.size() as u64,
1215 delta_base: None,
1216 }],
1217 )
1218 .unwrap();
1219
1220 let mut spool = PackChunkSpool::new_in(dest_temp.path()).unwrap();
1221 let mut chunk_index = 0usize;
1222 while let Some((start, data, is_final)) = next_pack_chunk(&bundle.pack_data, 7, chunk_index)
1223 {
1224 spool
1225 .receive_chunk(
1226 false,
1227 start as u64,
1228 chunk_index as u32,
1229 is_final,
1230 &data,
1231 is_final,
1232 )
1233 .unwrap();
1234 chunk_index += 1;
1235 }
1236
1237 let mut index_chunk = 0usize;
1238 while let Some((start, data, is_final)) =
1239 next_pack_chunk(&bundle.index_data, 5, index_chunk)
1240 {
1241 spool
1242 .receive_chunk(
1243 true,
1244 start as u64,
1245 index_chunk as u32,
1246 is_final,
1247 &data,
1248 is_final,
1249 )
1250 .unwrap();
1251 index_chunk += 1;
1252 }
1253
1254 assert!(spool.is_complete());
1255 let installed_ids = spool.install_into(&dest_store).unwrap();
1256
1257 assert_eq!(installed_ids, vec![PackObjectId::Hash(hash)]);
1258 let installed_blob = dest_store.get_blob(&hash).unwrap().unwrap();
1259 assert_eq!(installed_blob.content(), blob.content());
1260 }
1261
1262 #[test]
1263 fn native_pack_streaming_writer_drains_growing_pack_and_installs() {
1264 let (source_temp, source_store) = create_test_store();
1265 let (dest_temp, dest_store) = create_test_store();
1266 let blob = Blob::from("native pack growing stream regression");
1267 let hash = source_store.put_blob(&blob).unwrap();
1268 let large_blob = Blob::from_slice(&vec![b'z'; 4096]);
1269 let large_hash = source_store.put_blob(&large_blob).unwrap();
1270
1271 let mut writer = NativePackStreamingWriter::new_in(source_temp.path(), 2).unwrap();
1272 let mut pack_reader = GrowingPackChunkReader::open(writer.pack_path(), 31).unwrap();
1273 let mut spool = PackChunkSpool::new_in(dest_temp.path()).unwrap();
1274 let mut saw_interleaved_pack_chunk = false;
1275
1276 for (id, obj_type, data) in [
1277 (
1278 ObjectId::Hash(hash),
1279 ObjectType::Blob,
1280 blob.content().to_vec(),
1281 ),
1282 (
1283 ObjectId::Hash(large_hash),
1284 ObjectType::Blob,
1285 large_blob.content().to_vec(),
1286 ),
1287 ] {
1288 writer
1289 .add_object_data(ObjectData {
1290 id,
1291 obj_type,
1292 data,
1293 is_delta: false,
1294 })
1295 .unwrap();
1296 writer.flush_pack().unwrap();
1297 while let Some((offset, chunk_index, data, is_final)) =
1298 pack_reader.next_available_chunk(false).unwrap()
1299 {
1300 assert!(
1301 !is_final,
1302 "pre-final growing pack drain must not mark chunks final"
1303 );
1304 saw_interleaved_pack_chunk = true;
1305 spool
1306 .receive_chunk(false, offset, chunk_index, false, &data, false)
1307 .unwrap();
1308 }
1309 }
1310
1311 let bundle = writer.finish().unwrap();
1312 let mut saw_final_pack_chunk = false;
1313 while let Some((offset, chunk_index, data, is_final)) =
1314 pack_reader.next_available_chunk(true).unwrap()
1315 {
1316 saw_final_pack_chunk |= is_final;
1317 spool
1318 .receive_chunk(false, offset, chunk_index, is_final, &data, is_final)
1319 .unwrap();
1320 }
1321
1322 let mut index_reader = PackFileChunkReader::open(&bundle.index_path, 17).unwrap();
1323 while let Some((offset, chunk_index, data, is_final)) = index_reader.next_chunk().unwrap() {
1324 spool
1325 .receive_chunk(true, offset, chunk_index, is_final, &data, is_final)
1326 .unwrap();
1327 }
1328
1329 assert!(
1330 saw_interleaved_pack_chunk,
1331 "expected at least one pack chunk before finalize"
1332 );
1333 assert!(
1334 saw_final_pack_chunk,
1335 "expected final pack chunk after finish"
1336 );
1337 assert!(spool.is_complete());
1338 let mut installed_ids = spool.install_into(&dest_store).unwrap();
1339 let mut expected_ids = vec![PackObjectId::Hash(hash), PackObjectId::Hash(large_hash)];
1340 installed_ids.sort();
1341 expected_ids.sort();
1342
1343 assert_eq!(installed_ids, expected_ids);
1344 let installed_blob = dest_store.get_blob(&hash).unwrap().unwrap();
1345 assert_eq!(installed_blob.content(), blob.content());
1346 let installed_large_blob = dest_store.get_blob(&large_hash).unwrap().unwrap();
1347 assert_eq!(installed_large_blob.content(), large_blob.content());
1348 }
1349}