1use std::collections::{HashMap, HashSet, VecDeque};
92use std::io;
93use std::sync::atomic::AtomicBool;
94use std::sync::Arc;
95
96use alkblobs::{Algorithm, Key, Pin};
97use gix_hash::ObjectId;
98use gix_object::Kind;
99use gix_pack::data::output::bytes::FromEntriesIter;
100use gix_pack::data::output::count;
101use gix_pack::data::output::entry::iter_from_counts;
102use gix_pack::data::output::entry::iter_from_counts::{Mode, Options, Outcome};
103use gix_traverse::commit::{Parents, Simple};
104use sha2::Digest as _;
105
106use alkgit::{GitPackGen, Limits, RepoRecord, StorageError};
107use async_trait::async_trait;
108
109use crate::odb::{store_leg_error, ObjectFront, SessionFind};
110
111pub(crate) const PACK_ALGORITHM: Algorithm = Algorithm::GitBlobSha256;
117
118const PACK_PIPE_CAPACITY: usize = 256 * 1024;
123
124const SERVE_WINDOW: u64 = 1024 * 1024;
128
129pub(crate) const DEFAULT_CACHE_ENTRIES: usize = 4;
132
133fn request_digest(wants: &[ObjectId], boundary_haves: &[ObjectId]) -> [u8; 32] {
139 let mut digest = sha2::Sha256::new();
140 let mut wants = wants.to_vec();
141 wants.sort_unstable();
142 for want in wants {
143 digest.update(b"W");
144 digest.update(want.as_bytes());
145 }
146 let mut haves = boundary_haves.to_vec();
147 haves.sort_unstable();
148 for have in haves {
149 digest.update(b"H");
150 digest.update(have.as_bytes());
151 }
152 digest.finalize().into()
153}
154
155struct PeeledTip {
158 chain: Vec<ObjectId>,
159 terminal: ObjectId,
160 terminal_is_commit: bool,
161}
162
163fn peel_tip(find: &SessionFind, tip: ObjectId) -> Result<PeeledTip, StorageError> {
169 let mut chain = Vec::new();
170 let mut current = tip;
171 loop {
172 let mut buf = Vec::new();
173 let data = match gix_object::FindExt::find(find, ¤t, &mut buf) {
174 Ok(data) => data,
175 Err(error) => return Err(from_pipeline_error(find, error, tip)),
176 };
177 let decoded = data.decode();
178 let next = match decoded {
179 Ok(gix_object::ObjectRef::Tag(tag)) => {
180 chain.push(current);
181 tag.target()
182 }
183 Ok(gix_object::ObjectRef::Commit(_)) => {
184 return Ok(PeeledTip {
185 chain,
186 terminal: current,
187 terminal_is_commit: true,
188 });
189 }
190 Ok(_) => {
191 return Ok(PeeledTip {
192 chain,
193 terminal: current,
194 terminal_is_commit: false,
195 });
196 }
197 Err(error) => return Err(StorageError::Io(error.to_string())),
198 };
199 current = next;
200 }
201}
202
203fn pipeline_missing(find: &SessionFind, fallback: ObjectId) -> StorageError {
206 StorageError::ObjectMissing {
207 oid: find.missed_object().unwrap_or(fallback),
208 }
209}
210
211fn from_pipeline_error(
215 find: &SessionFind,
216 error: gix_error::Error,
217 fallback: ObjectId,
218) -> StorageError {
219 if error.is_not_found() {
220 pipeline_missing(find, fallback)
221 } else {
222 StorageError::Io(error.to_string())
223 }
224}
225
226fn run_pipeline<W: io::Write>(
233 find: &SessionFind,
234 wants: Vec<ObjectId>,
235 boundary_haves: Vec<ObjectId>,
236 sink: &mut W,
237) -> Result<(), StorageError> {
238 let no_want = ObjectId::null(gix_hash::Kind::Sha1);
239 let first_want = wants.first().copied().unwrap_or(no_want);
240
241 let mut boundary_commits = HashSet::new();
242 let mut boundary_objects = HashSet::new();
243 for have in boundary_haves {
244 let peeled = peel_tip(find, have)?;
245 boundary_objects.extend(peeled.chain.iter().copied());
246 boundary_objects.insert(peeled.terminal);
247 if peeled.terminal_is_commit {
248 boundary_commits.insert(peeled.terminal);
249 }
250 }
251
252 let mut commit_tips = Vec::new();
253 let mut non_commit_tips = Vec::new();
254 let mut tag_chains = Vec::new();
255 for want in wants {
256 let peeled = peel_tip(find, want)?;
257 for chain_oid in peeled.chain {
258 if !boundary_objects.contains(&chain_oid) {
259 tag_chains.push(chain_oid);
260 }
261 }
262 if peeled.terminal_is_commit {
263 commit_tips.push(peeled.terminal);
264 } else if !boundary_objects.contains(&peeled.terminal) {
265 non_commit_tips.push(peeled.terminal);
266 }
267 }
268
269 let mut walk = Simple::filtered(commit_tips, find.clone(), |tip: &gix_hash::oid| {
270 !boundary_commits.contains(tip)
271 })
272 .parents(Parents::All);
273 let mut survivors = Vec::new();
274 for info in walk.by_ref() {
275 let info = info.map_err(|error| from_pipeline_error(find, error, first_want))?;
276 survivors.push(info.id);
277 }
278
279 let counter = gix_utils::progress::Discard;
280 let interrupt = AtomicBool::new(false);
281 let mut closure_inputs = survivors;
282 closure_inputs.extend(non_commit_tips);
283 let (mut counts, _count_outcome) = count::objects_unthreaded(
284 find,
285 &mut closure_inputs
286 .into_iter()
287 .map(Ok::<ObjectId, gix_error::Error>),
288 &counter,
289 &interrupt,
290 count::objects::ObjectExpansion::TreeContents,
291 )
292 .map_err(|error| from_pipeline_error(find, error, first_want))?;
293 let (tag_counts, _tag_outcome) = count::objects_unthreaded(
294 find,
295 &mut tag_chains.into_iter().map(Ok::<ObjectId, gix_error::Error>),
296 &counter,
297 &interrupt,
298 count::objects::ObjectExpansion::AsIs,
299 )
300 .map_err(|error| from_pipeline_error(find, error, first_want))?;
301 let mut counted: HashSet<ObjectId> = counts.iter().map(|count| count.id).collect();
302 for tag_count in tag_counts {
303 if counted.contains(&tag_count.id) {
304 continue;
305 }
306 counted.insert(tag_count.id);
307 counts.push(tag_count);
308 }
309
310 for count in &counts {
311 if !gix_pack::Find::contains(find, &count.id) {
312 return Err(StorageError::ObjectMissing { oid: count.id });
313 }
314 }
315
316 let counts_len = counts.len();
317 let mut entries = gix_parallel::InOrderIter::from(
318 iter_from_counts(
319 counts,
320 find.clone(),
321 Box::new(gix_utils::progress::Discard),
322 Options {
323 thread_limit: Some(1),
324 mode: Mode::PackCopyAndBaseObjects,
325 allow_thin_pack: false,
326 chunk_size: 10,
327 version: gix_pack::data::Version::V2,
328 compression: gix_zlib::Compression::DEFAULT,
329 },
330 )
331 .map_err(|error| from_pipeline_error(find, error, first_want))?,
332 );
333 let mut from_entries = FromEntriesIter::new(
334 entries.by_ref(),
335 sink,
336 u32::try_from(counts_len)
337 .map_err(|_| StorageError::Invalid("the pack's object count exceeds u32".to_owned()))?,
338 gix_pack::data::Version::V2,
339 gix_hash::Kind::Sha1,
340 );
341 for step in from_entries.by_ref() {
342 step.map_err(|error| from_pipeline_error(find, error, first_want))?;
343 }
344 if from_entries.digest().is_none() {
345 return Err(StorageError::Io(
346 "the pack trailer was never written".to_string(),
347 ));
348 }
349 let entry_statistics: Outcome = {
350 use gix_parallel::Finalize as _;
351 entries
352 .inner
353 .finalize()
354 .map_err(|error| from_pipeline_error(find, error, first_want))?
355 };
356 if entry_statistics.missing_objects > 0 {
357 let missing = find
358 .missed_object()
359 .unwrap_or_else(|| ObjectId::null(gix_hash::Kind::Sha1));
360 return Err(StorageError::ObjectMissing { oid: missing });
361 }
362 Ok(())
363}
364
365struct PipeSink {
371 stream: tokio::io::DuplexStream,
372 front: ObjectFront,
373}
374
375impl io::Write for PipeSink {
376 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
377 use tokio::io::AsyncWriteExt;
378 self.front
379 .block_on(AsyncWriteExt::write(&mut self.stream, buf))
380 }
381
382 fn flush(&mut self) -> io::Result<()> {
383 use tokio::io::AsyncWriteExt;
384 self.front.block_on(AsyncWriteExt::flush(&mut self.stream))
385 }
386}
387
388struct CachedPack {
392 key: Key,
393 _pin: Pin,
394 len: u64,
395}
396
397pub struct PackCache {
405 max_entries: usize,
406 entries: parking_lot::Mutex<HashMap<[u8; 32], CachedPack>>,
407 order: parking_lot::Mutex<VecDeque<[u8; 32]>>,
408}
409
410impl PackCache {
411 pub fn new(max_entries: usize) -> Self {
416 PackCache {
417 max_entries: max_entries.max(1),
418 entries: parking_lot::Mutex::new(HashMap::new()),
419 order: parking_lot::Mutex::new(VecDeque::new()),
420 }
421 }
422
423 fn lookup(&self, digest: [u8; 32]) -> Option<(Key, u64)> {
426 self.entries
427 .lock()
428 .get(&digest)
429 .map(|cached| (cached.key.clone(), cached.len))
430 }
431
432 fn insert(&self, digest: [u8; 32], key: Key, len: u64, pin: Pin) {
436 let mut entries = self.entries.lock();
437 let mut order = self.order.lock();
438 while entries.len() >= self.max_entries {
439 match order.pop_front() {
440 Some(evict) => {
441 entries.remove(&evict);
442 }
443 None => break,
444 }
445 }
446 entries.insert(
447 digest,
448 CachedPack {
449 key,
450 _pin: pin,
451 len,
452 },
453 );
454 order.push_back(digest);
455 }
456
457 pub fn release_all(&self) -> usize {
462 let mut entries = self.entries.lock();
463 let mut order = self.order.lock();
464 order.clear();
465 let released = entries.len();
466 entries.clear();
467 released
468 }
469}
470
471enum Serving {
473 Direct,
476 ThroughStore(Arc<PackCache>),
481}
482
483pub struct PackGen {
494 front: ObjectFront,
495 serving: Serving,
496}
497
498impl PackGen {
499 pub fn direct(front: ObjectFront) -> Self {
502 PackGen {
503 front,
504 serving: Serving::Direct,
505 }
506 }
507
508 pub fn through_store(front: ObjectFront) -> Self {
522 PackGen::through_store_with(front, Arc::new(PackCache::new(DEFAULT_CACHE_ENTRIES)))
523 }
524
525 pub fn through_store_with(front: ObjectFront, cache: Arc<PackCache>) -> Self {
530 PackGen {
531 front,
532 serving: Serving::ThroughStore(cache),
533 }
534 }
535
536 async fn store_pack(
542 &self,
543 find: SessionFind,
544 wants: Vec<ObjectId>,
545 boundary_haves: Vec<ObjectId>,
546 ) -> Result<(Key, Pin, u64), StorageError> {
547 let store = Arc::clone(self.front.store());
548 let (pipe_write, pipe_read) = tokio::io::duplex(PACK_PIPE_CAPACITY);
549 let sink = PipeSink {
550 stream: pipe_write,
551 front: self.front.clone(),
552 };
553 let pipeline = tokio::task::spawn_blocking(move || {
554 let mut sink = sink;
555 run_pipeline(&find, wants, boundary_haves, &mut sink)
556 });
557 let put = store.put_as(PACK_ALGORITHM, None, pipe_read);
558 let put = put.await;
559 let piped = pipeline
560 .await
561 .map_err(|join| StorageError::Io(format!("the pack generation task failed: {join}")))?;
562 match (put, piped) {
563 (Ok((key, pin)), Ok(())) => {
564 let meta = store
565 .stat(&key.as_bytes())
566 .await
567 .map_err(|e| store_leg_error("alkblobs stat", e))?
568 .ok_or_else(|| {
569 StorageError::Invalid(
570 "the pack put returned a key the store no longer stats".to_owned(),
571 )
572 })?;
573 Ok((key, pin, meta.len))
574 }
575 (Err(put_error), _) => Err(store_leg_error("the pack put", put_error)),
576 (Ok((_key, pin)), Err(pipeline_error)) => {
577 drop(pin);
578 Err(pipeline_error)
579 }
580 }
581 }
582
583 async fn serve_stored(
588 &self,
589 key: &Key,
590 len: u64,
591 sink: Box<dyn io::Write + Send>,
592 ) -> Result<(), StorageError> {
593 let front = self.front.clone();
594 let key_bytes = key.as_bytes().to_vec();
595 let store = Arc::clone(self.front.store());
596 let serving = tokio::task::spawn_blocking(move || -> Result<(), StorageError> {
597 let mut sink = sink;
598 let mut offset = 0u64;
599 while offset < len {
600 let window = SERVE_WINDOW.min(len - offset);
601 let (slice, digest) = front
602 .block_on(store.read_range(&key_bytes, offset, window))
603 .map_err(|e| store_leg_error("alkblobs read_range", e))?;
604 let derived: [u8; 32] = sha2::Sha256::digest(&slice).into();
605 if derived != digest {
606 return Err(StorageError::Invalid(format!(
607 "the stored pack's window at {offset} fails its slice digest (mid-flight corruption)"
608 )));
609 }
610 sink.write_all(&slice)
611 .map_err(|e| StorageError::Io(format!("the pack sink failed: {e}")))?;
612 offset += window;
613 }
614 sink.flush()
615 .map_err(|e| StorageError::Io(format!("the pack sink failed: {e}")))
616 });
617 serving
618 .await
619 .map_err(|join| StorageError::Io(format!("the pack serving task failed: {join}")))?
620 }
621}
622
623#[async_trait]
624impl GitPackGen for PackGen {
625 async fn generate(
630 &self,
631 _repo: &RepoRecord,
632 wants: Vec<ObjectId>,
633 boundary_haves: Vec<ObjectId>,
634 _limits: Limits,
635 sink: Box<dyn io::Write + Send>,
636 ) -> Result<(), StorageError> {
637 match &self.serving {
638 Serving::Direct => {
639 let front = self.front.clone();
640 let direct = tokio::task::spawn_blocking(move || {
641 let find = front.session();
642 let mut sink = sink;
643 run_pipeline(&find, wants, boundary_haves, &mut sink)?;
644 sink.flush()
645 .map_err(|e| StorageError::Io(format!("the pack sink failed: {e}")))
646 });
647 direct.await.map_err(|join| {
648 StorageError::Io(format!("the pack generation task failed: {join}"))
649 })?
650 }
651 Serving::ThroughStore(cache) => {
652 let digest = request_digest(&wants, &boundary_haves);
653 if let Some((key, len)) = cache.lookup(digest) {
654 return self.serve_stored(&key, len, sink).await;
655 }
656 let find = self.front.session();
657 let (key, pin, len) = self.store_pack(find, wants, boundary_haves).await?;
658 cache.insert(digest, key.clone(), len, pin);
659 self.serve_stored(&key, len, sink).await
660 }
661 }
662 }
663
664 async fn common_haves(
676 &self,
677 _repo: &RepoRecord,
678 haves: Vec<ObjectId>,
679 ) -> Result<Vec<ObjectId>, StorageError> {
680 let mut recognized = Vec::new();
681 for oid in haves {
682 if self.front.kind(&oid).await? == Some(Kind::Commit) {
683 recognized.push(oid);
684 }
685 }
686 Ok(recognized)
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use alkblobs::Store;
693 use alkgit::{GitPackGen as _, Limits, RepoRecord, StorageError, Visibility};
694 use gix_hash::ObjectId;
695 use gix_object::Kind;
696 use std::io::{self, Write};
697 use std::sync::Arc;
698
699 use super::{request_digest, run_pipeline, PackCache, PackGen};
700 use crate::odb::{key_to_oid, ObjectFront};
701 use crate::testutil::scratch_dir as scratch_root;
702
703 fn repo() -> RepoRecord {
704 RepoRecord {
705 repo_id: "substrate/packgen-tests".to_owned(),
706 visibility: Visibility::Public,
707 grants: Default::default(),
708 }
709 }
710
711 async fn put_object(front: &ObjectFront, kind: Kind, content: &[u8]) -> ObjectId {
712 let (key, _pin) = front.put(kind, content).await.unwrap();
713 key_to_oid(&key).expect("the put's key is the family key")
714 }
715
716 fn tree_bytes(entries: &[(&str, &ObjectId)]) -> Vec<u8> {
717 let mut tree = Vec::new();
718 for (name, oid) in entries {
719 tree.extend_from_slice(format!("100644 {name}\0").as_bytes());
720 tree.extend_from_slice(oid.as_bytes());
721 }
722 tree
723 }
724
725 fn commit_bytes(tree: &ObjectId, parents: &[&ObjectId]) -> Vec<u8> {
726 let mut body = format!("tree {tree}\n");
727 for parent in parents {
728 body.push_str(&format!("parent {parent}\n"));
729 }
730 body.push_str(
731 "author A <a@example.invalid> 1750000000 +0000\n\
732 committer A <a@example.invalid> 1750000000 +0000\n\nthe message\n",
733 );
734 body.into_bytes()
735 }
736
737 fn tag_bytes(target: &ObjectId) -> Vec<u8> {
738 format!(
739 "object {target}\ntype commit\ntag v1.0.0\n\
740 tagger A <a@example.invalid> 1750000000 +0000\n\nannotated\n"
741 )
742 .into_bytes()
743 }
744
745 struct Fixture {
750 base: ObjectId,
751 tip: ObjectId,
752 tag: ObjectId,
753 stray: ObjectId,
754 }
755
756 async fn build_fixture(front: &ObjectFront) -> Fixture {
757 let blob = put_object(front, Kind::Blob, b"hello\n").await;
758 let stray = put_object(front, Kind::Blob, b"stray\n").await;
759 let other = put_object(front, Kind::Blob, b"second file\n").await;
760 let base_tree = put_object(front, Kind::Tree, &tree_bytes(&[("hello", &blob)])).await;
761 let tip_tree = put_object(
762 front,
763 Kind::Tree,
764 &tree_bytes(&[("hello", &blob), ("second", &other)]),
765 )
766 .await;
767 let base = put_object(front, Kind::Commit, &commit_bytes(&base_tree, &[])).await;
768 let tip = put_object(front, Kind::Commit, &commit_bytes(&tip_tree, &[&base])).await;
769 let tag = put_object(front, Kind::Tag, &tag_bytes(&base)).await;
770 Fixture {
771 base,
772 tip,
773 tag,
774 stray,
775 }
776 }
777
778 #[derive(Clone, Default)]
781 struct BytesSink(Arc<parking_lot::Mutex<Vec<u8>>>);
782
783 impl BytesSink {
784 fn bytes(&self) -> Vec<u8> {
785 self.0.lock().clone()
786 }
787 }
788
789 impl Write for BytesSink {
790 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
791 self.0.lock().extend_from_slice(buf);
792 Ok(buf.len())
793 }
794
795 fn flush(&mut self) -> io::Result<()> {
796 Ok(())
797 }
798 }
799
800 async fn generate(
801 packgen: &PackGen,
802 wants: Vec<ObjectId>,
803 boundary: Vec<ObjectId>,
804 ) -> Result<Vec<u8>, StorageError> {
805 let sink = BytesSink::default();
806 packgen
807 .generate(
808 &repo(),
809 wants,
810 boundary,
811 Limits::default(),
812 Box::new(sink.clone()),
813 )
814 .await?;
815 Ok(sink.bytes())
816 }
817
818 fn cached_count(packgen: &PackGen) -> usize {
819 match &packgen.serving {
820 super::Serving::ThroughStore(cache) => cache.cached(),
821 super::Serving::Direct => panic!("the packgen is not through-store"),
822 }
823 }
824
825 #[tokio::test]
826 async fn generate_packs_the_fixture_over_the_real_find() {
827 let front = ObjectFront::new(Arc::new(Store::mem_only()));
828 let fx = build_fixture(&front).await;
829 let packgen = PackGen::direct(front.clone());
830 let pack = generate(&packgen, vec![fx.tip], vec![]).await.unwrap();
831
832 assert!(pack.starts_with(b"PACK"), "the pack header wrote");
833 let object_count = u32::from_be_bytes([pack[8], pack[9], pack[10], pack[11]]);
834 assert_eq!(
835 object_count, 6,
836 "closure(tip) − closure(nothing) = tip, base, two trees, two blobs"
837 );
838 let trailer = &pack[pack.len() - 20..];
839 assert!(
840 trailer.iter().any(|byte| *byte != 0),
841 "the trailer carries the sha1 digest"
842 );
843 }
844
845 #[tokio::test]
846 async fn direct_output_is_byte_identical_to_the_doubles_pipeline() {
847 let front = ObjectFront::new(Arc::new(Store::mem_only()));
848 let fx = build_fixture(&front).await;
849 let wants = vec![fx.tip, fx.tag];
850 let boundary = vec![fx.base];
851 let packgen = PackGen::direct(front.clone());
852 let pack = generate(&packgen, wants.clone(), boundary.clone())
853 .await
854 .unwrap();
855
856 let find = front.session();
857 let reference = tokio::task::spawn_blocking(move || {
858 let mut reference = Vec::new();
859 run_pipeline(&find, wants, boundary, &mut reference)?;
860 Ok::<_, StorageError>(reference)
861 })
862 .await
863 .unwrap()
864 .unwrap();
865 assert_eq!(
866 pack, reference,
867 "the direct shape is the doubles' ADR-004 pipeline, byte for byte \
868 (the boundary have trims the commit walk; the tag want rides the AsIs stage)"
869 );
870 }
871
872 #[tokio::test]
873 async fn a_missing_want_aborts_naming_the_missing_object() {
874 let front = ObjectFront::new(Arc::new(Store::mem_only()));
875 let missing = ObjectId::from([0xA5u8; 20]);
876 let packgen = PackGen::direct(front.clone());
877 let error = generate(&packgen, vec![missing], vec![]).await.unwrap_err();
878 match error {
879 StorageError::ObjectMissing { oid } => {
880 assert_eq!(oid, missing, "the abort names the missing object");
881 }
882 other => panic!("a missing want is the ObjectMissing abort, not {other}"),
883 }
884 }
885
886 #[tokio::test]
890 async fn a_missing_closure_member_aborts_naming_it() {
891 let front = ObjectFront::new(Arc::new(Store::mem_only()));
892 let ghost = ObjectId::from([0xEEu8; 20]);
893 let tree = put_object(&front, Kind::Tree, &tree_bytes(&[("ghost", &ghost)])).await;
894 let commit = put_object(&front, Kind::Commit, &commit_bytes(&tree, &[])).await;
895 let packgen = PackGen::direct(front);
896 let error = generate(&packgen, vec![commit], vec![]).await.unwrap_err();
897 match error {
898 StorageError::ObjectMissing { oid } => {
899 assert_eq!(oid, ghost, "the abort names the missing member");
900 }
901 other => panic!("a missing member is the ObjectMissing abort, not {other}"),
902 }
903 }
904
905 #[tokio::test]
906 async fn the_ack_predicate_recognizes_only_existing_commits() {
907 let front = ObjectFront::new(Arc::new(Store::mem_only()));
908 let fx = build_fixture(&front).await;
909 let missing = ObjectId::from([0x3Cu8; 20]);
910 let packgen = PackGen::direct(front.clone());
911 let recognized = packgen
912 .common_haves(&repo(), vec![fx.tip, fx.stray, fx.tag, missing, fx.base])
913 .await
914 .unwrap();
915 assert_eq!(
916 recognized,
917 vec![fx.tip, fx.base],
918 "exists + is-commit: the blob and the tag are unrecognized, a \
919 missing have subtracts nothing, the caller's order is verbatim"
920 );
921 }
922
923 #[tokio::test]
924 async fn the_through_store_shape_serves_the_stored_pack() {
925 let front = ObjectFront::new(Arc::new(Store::mem_only()));
926 let fx = build_fixture(&front).await;
927 let wants = vec![fx.tip];
928 let boundary: Vec<ObjectId> = vec![];
929 let direct_packgen = PackGen::direct(front.clone());
930 let direct = generate(&direct_packgen, wants.clone(), boundary.clone())
931 .await
932 .unwrap();
933
934 let packgen = PackGen::through_store(front.clone());
935 let first = generate(&packgen, wants.clone(), boundary.clone())
936 .await
937 .unwrap();
938 assert_eq!(first, direct, "the stored pack is the generated pack");
939 assert_eq!(cached_count(&packgen), 1, "one request, one cached pack");
940
941 let digest = request_digest(&wants, &boundary);
942 let (key, len) = match &packgen.serving {
943 super::Serving::ThroughStore(cache) => cache.lookup(digest).unwrap(),
944 super::Serving::Direct => unreachable!(),
945 };
946 let stored = front.store().stat(&key.as_bytes()).await.unwrap();
947 assert_eq!(stored.map(|meta| meta.len), Some(len), "put-once stat");
948
949 let second = generate(&packgen, wants, boundary).await.unwrap();
950 assert_eq!(second, direct, "the repeat fetch rides the stored pack");
951 assert_eq!(cached_count(&packgen), 1, "no regeneration on the hit");
952 }
953
954 #[tokio::test]
955 async fn cache_eviction_and_release_are_the_pin_paths() {
956 let front = ObjectFront::new(Arc::new(Store::mem_only()));
957 let fx = build_fixture(&front).await;
958 let packgen = PackGen::through_store_with(front.clone(), Arc::new(PackCache::new(1)));
959 let first = generate(&packgen, vec![fx.tip], vec![]).await.unwrap();
960 assert_eq!(cached_count(&packgen), 1);
961
962 let second = generate(&packgen, vec![fx.tag], vec![]).await.unwrap();
963 assert_ne!(&first, &second, "the two requests differ");
964 assert_eq!(
965 cached_count(&packgen),
966 1,
967 "capacity held; the first evicted"
968 );
969
970 let cache = match &packgen.serving {
971 super::Serving::ThroughStore(cache) => cache,
972 super::Serving::Direct => unreachable!(),
973 };
974 assert!(
975 cache
976 .lookup(request_digest(std::slice::from_ref(&fx.tip), &[]))
977 .is_none(),
978 "the evicted digest is gone (its pin dropped — sweep-eligible)"
979 );
980 assert!(
981 cache
982 .lookup(request_digest(std::slice::from_ref(&fx.tag), &[]))
983 .is_some(),
984 "the newest entry is the cache's"
985 );
986 assert_eq!(cache.release_all(), 1, "the housekeeping release path");
987 assert_eq!(cache.cached(), 0, "the release path drains the cache");
988 }
989
990 #[tokio::test]
991 async fn a_kv_only_store_rejects_an_over_threshold_pack() {
992 let front = ObjectFront::new(Arc::new(Store::kv_only_mem(256)));
993 let fx = build_fixture(&front).await;
994 assert!(front.stat(&fx.tip).await.unwrap().is_some());
995 let packgen = PackGen::through_store(front.clone());
996 let error = generate(&packgen, vec![fx.tip], vec![]).await.unwrap_err();
997 match &error {
998 StorageError::Io(reason) => {
999 assert!(
1000 reason.contains("kv-only"),
1001 "the kv-only put-time rejection is what surfaces: {reason}"
1002 );
1003 }
1004 other => panic!("the over-threshold pack is the put's rejection, not {other}"),
1005 }
1006 }
1007
1008 #[tokio::test]
1009 async fn the_direct_shape_never_rounds_through_the_store() {
1010 let front = ObjectFront::new(Arc::new(Store::kv_only_mem(256)));
1011 let fx = build_fixture(&front).await;
1012 let packgen = PackGen::direct(front.clone());
1013 let pack = generate(&packgen, vec![fx.tip], vec![]).await.unwrap();
1014 assert!(
1015 pack.starts_with(b"PACK"),
1016 "a pack the through-store shape would have rejected streams direct"
1017 );
1018 }
1019
1020 #[tokio::test]
1025 async fn the_generated_pack_indexes_clean_under_git() {
1026 if std::process::Command::new("git")
1027 .arg("--version")
1028 .output()
1029 .is_err()
1030 {
1031 eprintln!("skipping (no git binary installed)");
1032 return;
1033 }
1034 let dir = scratch_root("packgen", "git-index");
1035 let init = std::process::Command::new("git")
1036 .args(["init", "-q"])
1037 .current_dir(&dir)
1038 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1039 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1040 .output()
1041 .unwrap();
1042 assert!(init.status.success(), "git init failed: {init:?}");
1043
1044 let front = ObjectFront::new(Arc::new(Store::mem_only()));
1045 let fx = build_fixture(&front).await;
1046 let packgen = PackGen::direct(front.clone());
1047 let pack = generate(&packgen, vec![fx.tip], vec![]).await.unwrap();
1048 let pack_path = dir.join("incoming.pack");
1049 std::fs::write(&pack_path, &pack).unwrap();
1050
1051 let index = std::process::Command::new("git")
1052 .args(["index-pack", "--stdin"])
1053 .current_dir(&dir)
1054 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1055 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1056 .stdin(std::process::Stdio::from(
1057 std::fs::File::open(&pack_path).unwrap(),
1058 ))
1059 .output()
1060 .unwrap();
1061 assert!(
1062 index.status.success(),
1063 "git index-pack failed: {}",
1064 String::from_utf8_lossy(&index.stderr)
1065 );
1066 let fsck = std::process::Command::new("git")
1067 .args(["fsck", "--strict"])
1068 .current_dir(&dir)
1069 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1070 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1071 .output()
1072 .unwrap();
1073 assert!(
1074 fsck.status.success(),
1075 "git fsck --strict failed: {}",
1076 String::from_utf8_lossy(&fsck.stderr)
1077 );
1078 std::fs::remove_dir_all(&dir).unwrap_or_default();
1079 }
1080
1081 impl PackCache {
1084 fn cached(&self) -> usize {
1085 self.entries.lock().len()
1086 }
1087 }
1088}