1use std::collections::{HashMap, HashSet};
100use std::io::{self, Read as _};
101use std::path::PathBuf;
102use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
103use std::sync::Arc;
104use std::time::{SystemTime, UNIX_EPOCH};
105
106use alkblobs::Batch;
107use gix_hash::ObjectId;
108use gix_object::Kind;
109use gix_pack::bundle::write::Options as BundleOptions;
110use gix_pack::data::entry::Header as PackHeader;
111
112use alkgit::{
113 GitPackIngest, Limits, PreparedPush, PushOptions, RefUpdate, RepoRecord, StorageError,
114};
115use async_trait::async_trait;
116
117use crate::odb::{key_to_oid, loose_form, store_leg_error, ObjectFront, SessionFind};
118use crate::pins::{PendingPinRegistry, PinCustody};
119
120use crate::odb::GIT_OBJECT_ALGORITHM;
123
124const INFLATE_CHUNK_BYTES: usize = 65536;
127
128static WORK_AREA_COUNTER: AtomicU64 = AtomicU64::new(0);
132
133pub struct PackIngest {
142 front: ObjectFront,
143 pins: Arc<PendingPinRegistry>,
144 root: PathBuf,
145}
146
147impl PackIngest {
148 pub(crate) fn new(front: ObjectFront, pins: Arc<PendingPinRegistry>, root: PathBuf) -> Self {
153 PackIngest { front, pins, root }
154 }
155}
156
157enum LegFailure {
161 Pack(String),
162 Backing(StorageError),
163}
164
165impl From<StorageError> for LegFailure {
166 fn from(error: StorageError) -> Self {
167 LegFailure::Backing(error)
168 }
169}
170
171impl From<String> for LegFailure {
172 fn from(reason: String) -> Self {
173 LegFailure::Pack(reason)
174 }
175}
176
177struct WorkArea(PathBuf);
181
182impl Drop for WorkArea {
183 fn drop(&mut self) {
184 let _ = std::fs::remove_dir_all(&self.0);
185 }
186}
187
188struct BudgetedReader<R> {
195 inner: R,
196 bytes_seen: u64,
197 limit: u64,
198}
199
200impl<R> BudgetedReader<R> {
201 fn new(inner: R, limit: u64) -> Self {
202 BudgetedReader {
203 inner,
204 bytes_seen: 0,
205 limit,
206 }
207 }
208}
209
210impl<R: io::Read> io::Read for BudgetedReader<R> {
211 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
212 if self.bytes_seen >= self.limit {
213 return Err(io::Error::other(format!(
214 "the pack stream exceeded the maximum pack size budget of {} bytes",
215 self.limit
216 )));
217 }
218 let read = self.inner.read(buf)?;
219 self.bytes_seen += read as u64;
220 Ok(read)
221 }
222}
223
224fn store_read_failure(oid: &ObjectId, error: StorageError) -> LegFailure {
229 match error {
230 StorageError::Invalid(message) => {
231 LegFailure::Pack(format!("corrupt object {oid}: {message}"))
232 }
233 other => LegFailure::Backing(other),
234 }
235}
236
237fn index_leg(
245 pack: Box<dyn io::Read + Send>,
246 dir: PathBuf,
247 thin: SessionFind,
248 budget: u64,
249 cap: u64,
250) -> Result<Option<(PathBuf, PathBuf)>, String> {
251 let mut counted = BudgetedReader::new(pack, budget);
252 let mut header = [0u8; gix_pack::data::header::SIZE];
253 counted
254 .read_exact(&mut header)
255 .map_err(|_| "the pack stream ended in the header".to_string())?;
256 let (_version, object_count) = gix_pack::data::header::decode(&header)
257 .map_err(|error| format!("the pack header does not decode: {error}"))?;
258
259 if object_count == 0 {
260 let mut trailer = [0u8; gix_hash::Kind::Sha1.len_in_bytes()];
261 counted
262 .read_exact(&mut trailer)
263 .map_err(|_| "the zero-object pack stream ends before the trailer".to_string())?;
264 let mut hasher = gix_hash::hasher(gix_hash::Kind::Sha1);
265 hasher.update(&header);
266 let digest = hasher
267 .try_finalize()
268 .map_err(|error| format!("the pack trailer does not hash: {error}"))?;
269 if digest != ObjectId::from(trailer) {
270 return Err(
271 "the zero-object pack's trailer does not verify against the pack bytes".to_owned(),
272 );
273 }
274 return Ok(None);
275 }
276
277 let interrupt = AtomicBool::new(false);
278 let mut progress = gix_utils::progress::Discard;
279 let mut pack_stream = io::BufReader::new(io::Cursor::new(header.to_vec()).chain(counted));
280 let outcome = gix_pack::Bundle::write_to_directory(
281 &mut pack_stream,
282 Some(&dir),
283 &mut progress,
284 &interrupt,
285 Some(thin),
286 gix_hash::Kind::Sha1,
287 BundleOptions {
288 thread_limit: Some(1),
289 iteration_mode: gix_pack::data::input::Mode::Verify,
290 index_version: Default::default(),
291 alloc_limit_bytes: Some(usize::try_from(cap).unwrap_or(usize::MAX)),
292 compression: gix_zlib::Compression::DEFAULT,
293 },
294 )
295 .map_err(|error| format!("the pack does not index: {error}"))?;
296 match (outcome.index_path, outcome.data_path) {
297 (Some(index_path), Some(pack_path)) => Ok(Some((pack_path, index_path))),
298 _ => Ok(None),
299 }
300}
301
302fn rederive_object(kind: Kind, body: &[u8]) -> Result<ObjectId, String> {
305 gix_object::compute_hash(gix_hash::Kind::Sha1, kind, body)
306 .map_err(|error| format!("the pack entry object does not hash: {error}"))
307}
308
309fn put_in_batch(
316 front: &ObjectFront,
317 batch: &mut Batch,
318 kind: Kind,
319 body: &[u8],
320 expected: ObjectId,
321) -> Result<(), StorageError> {
322 let form = loose_form(kind, body);
323 let store = front.store().clone();
324 let future = async {
325 store
326 .put_in_batch(
327 batch,
328 GIT_OBJECT_ALGORITHM,
329 Some(form.len() as u64),
330 io::Cursor::new(&form),
331 )
332 .await
333 };
334 let key = front
335 .block_on(future)
336 .map_err(|error| store_leg_error("alkblobs put_in_batch", error))?;
337 if key_to_oid(&key) != Some(expected) {
338 return Err(StorageError::Invalid(format!(
339 "the put returned a key that does not re-derive to {expected}"
340 )));
341 }
342 Ok(())
343}
344
345fn base_object(
349 front: &ObjectFront,
350 base_oid: ObjectId,
351 base_kind: Kind,
352) -> Result<(Kind, Vec<u8>), LegFailure> {
353 let found = front.block_on(front.get(&base_oid));
354 match found {
355 Ok(None) => Err(LegFailure::Pack(format!("missing object {base_oid}"))),
356 Err(error) => Err(store_read_failure(&base_oid, error)),
357 Ok(Some((delivered_kind, body))) => {
358 if delivered_kind != base_kind {
359 return Err(LegFailure::Pack(format!(
360 "the base object {base_oid} delivered kind {delivered_kind:?} against \
361 the delta chain's kind {base_kind:?}"
362 )));
363 }
364 Ok((base_kind, body))
365 }
366 }
367}
368
369fn delta_size_at(delta: &[u8], offset: usize) -> Result<(u64, usize), String> {
372 let mut size = 0u64;
373 let mut shift = 0u32;
374 let mut cursor = offset;
375 loop {
376 let Some(&byte) = delta.get(cursor) else {
377 return Err("the delta header is truncated".to_string());
378 };
379 cursor += 1;
380 size |= u64::from(byte & 0x7f) << shift;
381 shift += 7;
382 if byte & 0x80 == 0 {
383 return Ok((size, cursor - offset));
384 }
385 if shift >= 64 {
386 return Err("the delta header size overflow".to_string());
387 }
388 }
389}
390
391fn apply_delta(base: &[u8], delta: &[u8], cap: u64) -> Result<Vec<u8>, String> {
399 let (base_size, base_header_len) = delta_size_at(delta, 0)?;
400 let (result_size, result_header_len) = delta_size_at(delta, base_header_len)?;
401 if base_size != u64::try_from(base.len()).unwrap_or(u64::MAX) {
402 return Err(format!(
403 "the delta declares a base size of {base_size} but the base object is {} bytes",
404 base.len()
405 ));
406 }
407 if result_size > u64::from(u32::MAX) {
408 return Err("the delta declares an out-of-bounds result size".to_string());
409 }
410 if result_size > cap {
411 return Err(format!(
412 "the delta declares a materialized result of {result_size} bytes beyond \
413 the ingestion decompressed-size cap of {cap} bytes"
414 ));
415 }
416 let mut out = Vec::new();
417 let mut cursor = base_header_len + result_header_len;
418 while let Some(&cmd) = delta.get(cursor) {
419 cursor += 1;
420 if cmd & 0x80 != 0 {
421 let mut ofs = 0u64;
422 let mut size = 0u64;
423 for (bit, shift) in [(0x01, 0), (0x02, 8), (0x04, 16), (0x08, 24)] {
424 if cmd & bit != 0 {
425 let Some(&byte) = delta.get(cursor) else {
426 return Err("the delta copy instruction is truncated".to_string());
427 };
428 cursor += 1;
429 ofs |= u64::from(byte) << shift;
430 }
431 }
432 for (bit, shift) in [(0x10, 0), (0x20, 8), (0x40, 16)] {
433 if cmd & bit != 0 {
434 let Some(&byte) = delta.get(cursor) else {
435 return Err("the delta copy instruction is truncated".to_string());
436 };
437 cursor += 1;
438 size |= u64::from(byte) << shift;
439 }
440 }
441 if size == 0 {
442 size = 0x10000;
443 }
444 let (ofs, size) = (
445 usize::try_from(ofs).map_err(|_| "the delta copy offset overflows".to_string())?,
446 usize::try_from(size).map_err(|_| "the delta copy size overflows".to_string())?,
447 );
448 let Some(end) = ofs.checked_add(size) else {
449 return Err("the delta copy range overflows".to_string());
450 };
451 let Some(bytes) = base.get(ofs..end) else {
452 return Err("the delta copy range exceeds the base object size".to_string());
453 };
454 out.extend_from_slice(bytes);
455 } else if cmd == 0 {
456 return Err("the delta command 0 is reserved and invalid".to_string());
457 } else {
458 let end = cursor + usize::from(cmd);
459 let Some(bytes) = delta.get(cursor..end) else {
460 return Err("the delta insert data is truncated".to_string());
461 };
462 cursor = end;
463 out.extend_from_slice(bytes);
464 }
465 if u64::try_from(out.len()).unwrap_or(u64::MAX) > result_size {
466 return Err(format!(
467 "the delta instructions produce more than the declared {result_size} bytes"
468 ));
469 }
470 }
471 if out.len() as u64 != result_size {
472 return Err(format!(
473 "the delta instructions produce {} bytes but the delta declares {result_size}",
474 out.len()
475 ));
476 }
477 Ok(out)
478}
479
480fn inflate_slice(compressed: &[u8], declared: u64, cap: u64) -> Result<Vec<u8>, String> {
485 let mut out: Vec<u8> = Vec::new();
486 let mut decompressor = gix_zlib::Decompress::new();
487 let mut reader = io::Cursor::new(compressed);
488 let mut chunk = vec![0u8; INFLATE_CHUNK_BYTES];
489 loop {
490 let written = gix_zlib::stream::inflate::read(&mut reader, &mut decompressor, &mut chunk)
491 .map_err(|error| format!("the pack entry does not inflate: {error}"))?;
492 out.extend_from_slice(&chunk[..written]);
493 if u64::try_from(out.len()).unwrap_or(u64::MAX) > cap {
494 return Err(format!(
495 "the pack entry inflates past the ingestion decompressed-size cap of {cap} bytes"
496 ));
497 }
498 if written < chunk.len() {
499 break;
500 }
501 }
502 if out.len() as u64 != declared {
503 return Err(format!(
504 "the pack entry inflates to {} bytes but {declared} were declared",
505 out.len()
506 ));
507 }
508 Ok(out)
509}
510
511fn ingest_leg(
520 front: ObjectFront,
521 index_path: PathBuf,
522 tips: Vec<ObjectId>,
523 cap: u64,
524) -> Result<Batch, LegFailure> {
525 let bundle = gix_pack::Bundle::at(&index_path, gix_hash::Kind::Sha1)
526 .map_err(|error| LegFailure::Pack(format!("the indexed pack does not open: {error}")))?;
527 let mut batch = front.store().batch();
528 let indexed_by_offset: HashMap<u64, ObjectId> = bundle
529 .index
530 .iter()
531 .map(|entry| (entry.pack_offset, entry.oid))
532 .collect();
533 let mut staged: HashMap<u64, (ObjectId, Kind)> = HashMap::new();
534 let mut parked: Vec<(u64, ObjectId)> = Vec::new();
535
536 for offset in bundle.index.sorted_offsets() {
537 stage_entry(
538 &bundle,
539 &front,
540 &mut batch,
541 &mut staged,
542 &mut parked,
543 offset,
544 cap,
545 )?;
546 }
547 while !parked.is_empty() {
548 let drained = std::mem::take(&mut parked);
549 let before = staged.len();
550 for (offset, _base_id) in drained {
551 stage_entry(
552 &bundle,
553 &front,
554 &mut batch,
555 &mut staged,
556 &mut parked,
557 offset,
558 cap,
559 )?;
560 }
561 if staged.len() == before {
562 let (_, base_id) = parked[0];
563 return Err(LegFailure::Pack(format!(
564 "the delta base {base_id} does not resolve within the pack"
565 )));
566 }
567 }
568
569 for (offset, indexed) in &indexed_by_offset {
570 match staged.get(offset) {
571 Some((materialized, _)) if materialized == indexed => {}
572 Some((materialized, _)) => {
573 return Err(LegFailure::Pack(format!(
574 "the pack entry at offset {offset} materialized to {materialized}, \
575 not its indexed object {indexed}"
576 )));
577 }
578 None => {
579 let base = *indexed;
580 return Err(LegFailure::Pack(format!(
581 "the delta base {base} does not resolve within the pack"
582 )));
583 }
584 }
585 }
586
587 connectivity_leg(&front, &tips)?;
588 Ok(batch)
589}
590
591fn stage_entry(
601 bundle: &gix_pack::Bundle,
602 front: &ObjectFront,
603 batch: &mut Batch,
604 staged: &mut HashMap<u64, (ObjectId, Kind)>,
605 parked: &mut Vec<(u64, ObjectId)>,
606 offset: u64,
607 cap: u64,
608) -> Result<(), LegFailure> {
609 let entry = bundle.pack.entry(offset).map_err(|error| {
610 LegFailure::Pack(format!(
611 "the pack entry at {offset} does not parse: {error}"
612 ))
613 })?;
614 let declared = entry.decompressed_size;
615 if declared > cap {
616 return Err(LegFailure::Pack(format!(
617 "the pack entry at {offset} declares {declared} decompressed bytes beyond \
618 the ingestion decompressed-size cap of {cap} bytes"
619 )));
620 }
621 let compressed = bundle
622 .pack
623 .entry_slice(entry.data_offset..bundle.pack.pack_end() as u64)
624 .ok_or_else(|| {
625 LegFailure::Pack(format!(
626 "the pack entry at {offset} reads outside the pack data file"
627 ))
628 })?;
629
630 let staged_object = match entry.header {
631 PackHeader::Blob | PackHeader::Tree | PackHeader::Commit | PackHeader::Tag => {
632 let kind = entry.header.as_kind().ok_or_else(|| {
633 LegFailure::Pack(format!("the pack entry at {offset} carries no object kind"))
634 })?;
635 let body = inflate_slice(compressed, declared, cap)?;
636 let oid = rederive_object(kind, &body)?;
637 put_in_batch(front, batch, kind, &body, oid)?;
638 Some((oid, kind))
639 }
640 PackHeader::OfsDelta { base_distance } => {
641 let base_offset = entry
642 .checked_base_pack_offset(base_distance)
643 .ok_or_else(|| {
644 LegFailure::Pack(format!(
645 "the ofs delta at pack offset {offset} names an impossible base \
646 distance {base_distance}"
647 ))
648 })?;
649 let Some((base_oid, base_kind)) = staged.get(&base_offset).copied() else {
650 return Err(LegFailure::Pack(format!(
651 "the ofs delta's base at distance {base_distance} does not resolve \
652 within the pack"
653 )));
654 };
655 let (_, base_body) = base_object(front, base_oid, base_kind)?;
656 let payload = inflate_slice(compressed, declared, cap)?;
657 let body = apply_delta(&base_body, &payload, cap)?;
658 let oid = rederive_object(base_kind, &body)?;
659 put_in_batch(front, batch, base_kind, &body, oid)?;
660 Some((oid, base_kind))
661 }
662 PackHeader::RefDelta { base_id } => {
663 let base_offset = match bundle.index.lookup(base_id) {
664 Some(base_index) => bundle.index.pack_offset_at_index(base_index),
665 None => {
666 return Err(LegFailure::Pack(format!(
667 "the delta base {base_id} is neither in the pack nor in the repository"
668 )));
669 }
670 };
671 match staged.get(&base_offset).copied() {
672 Some((base_oid, base_kind)) => {
673 let (_, base_body) = base_object(front, base_oid, base_kind)?;
674 let payload = inflate_slice(compressed, declared, cap)?;
675 let body = apply_delta(&base_body, &payload, cap)?;
676 let oid = rederive_object(base_kind, &body)?;
677 put_in_batch(front, batch, base_kind, &body, oid)?;
678 Some((oid, base_kind))
679 }
680 None => {
681 parked.push((offset, base_id));
682 None
683 }
684 }
685 }
686 };
687 if let Some((oid, kind)) = staged_object {
688 staged.insert(offset, (oid, kind));
689 }
690 Ok(())
691}
692
693fn connectivity_leg(front: &ObjectFront, tips: &[ObjectId]) -> Result<(), LegFailure> {
700 let mut seen: HashSet<ObjectId> = HashSet::new();
701 let mut stack: Vec<ObjectId> = tips.to_vec();
702 while let Some(oid) = stack.pop() {
703 if !seen.insert(oid) {
704 continue;
705 }
706 let (kind, body) = {
707 let found = front.block_on(front.get(&oid));
708 match found {
709 Ok(None) => return Err(LegFailure::Pack(format!("missing object {oid}"))),
710 Err(error) => return Err(store_read_failure(&oid, error)),
711 Ok(Some((kind, body))) => (kind, body),
712 }
713 };
714 match kind {
715 Kind::Commit => {
716 let commit = gix_object::CommitRef::from_bytes(&body, gix_hash::Kind::Sha1)
717 .map_err(|error| LegFailure::Pack(format!("corrupt commit {oid}: {error}")))?;
718 stack.push(commit.tree());
719 for parent in commit.parents() {
720 stack.push(parent);
721 }
722 }
723 Kind::Tree => {
724 let tree = gix_object::TreeRef::from_bytes(&body, gix_hash::Kind::Sha1)
725 .map_err(|error| LegFailure::Pack(format!("corrupt tree {oid}: {error}")))?;
726 for entry in &tree.entries {
727 stack.push(entry.oid.into());
728 }
729 }
730 Kind::Tag => {
731 let tag = gix_object::TagRef::from_bytes(&body, gix_hash::Kind::Sha1)
732 .map_err(|error| LegFailure::Pack(format!("corrupt tag {oid}: {error}")))?;
733 stack.push(tag.target());
734 }
735 Kind::Blob => {}
736 }
737 }
738 Ok(())
739}
740
741#[async_trait]
742impl GitPackIngest for PackIngest {
743 async fn prepare(
755 &self,
756 repo: &RepoRecord,
757 updates: Vec<RefUpdate>,
758 pack: Box<dyn io::Read + Send>,
759 _push_options: Option<PushOptions>,
760 limits: Limits,
761 ) -> Result<PreparedPush, StorageError> {
762 let nanos = SystemTime::now()
763 .duration_since(UNIX_EPOCH)
764 .map(|duration| duration.as_nanos())
765 .unwrap_or_default();
766 let dir = self.root.join(format!(
767 "ingest-{}-{}-{}",
768 std::process::id(),
769 nanos,
770 WORK_AREA_COUNTER.fetch_add(1, Ordering::Relaxed)
771 ));
772 tokio::fs::create_dir_all(&dir).await.map_err(|error| {
773 StorageError::Io(format!("the working area failed to create: {error}"))
774 })?;
775 let _area = WorkArea(dir.clone());
776
777 let indexed = {
778 let front = self.front.clone();
779 let dir = dir.clone();
780 let thin = front.session();
781 let budget = limits.max_pack_size;
782 let cap = limits.max_object_size;
783 tokio::task::spawn_blocking(move || index_leg(pack, dir, thin, budget, cap))
784 .await
785 .map_err(|join| {
786 StorageError::Io(format!("the pack indexing task failed: {join}"))
787 })?
788 };
789
790 match indexed {
791 Ok(None) => {
792 let front = self.front.clone();
793 let tips: Vec<ObjectId> = updates.iter().filter_map(|update| update.new).collect();
794 let verdict = tokio::task::spawn_blocking(move || connectivity_leg(&front, &tips))
795 .await
796 .map_err(|join| {
797 StorageError::Io(format!("the pack connectivity task failed: {join}"))
798 })?;
799 match verdict {
800 Ok(()) => {
801 self.pins.retain(
802 &repo.repo_id,
803 updates.clone(),
804 PinCustody::Pins(Vec::new()),
805 );
806 Ok(PreparedPush {
807 unpack: Ok(()),
808 updates,
809 })
810 }
811 Err(LegFailure::Pack(reason)) => Ok(PreparedPush {
812 unpack: Err(reason),
813 updates,
814 }),
815 Err(LegFailure::Backing(error)) => Err(error),
816 }
817 }
818 Ok(Some(pack_and_index)) => {
819 let front = self.front.clone();
820 let tips: Vec<ObjectId> = updates.iter().filter_map(|update| update.new).collect();
821 let cap = limits.max_object_size;
822 let leg = move || {
823 let (_pack_path, index_path) = pack_and_index;
824 ingest_leg(front, index_path, tips, cap)
825 };
826 let verdict = tokio::task::spawn_blocking(leg).await.map_err(|join| {
827 StorageError::Io(format!("the pack ingestion task failed: {join}"))
828 })?;
829 match verdict {
830 Ok(batch) => {
831 self.pins
832 .retain(&repo.repo_id, updates.clone(), PinCustody::Batch(batch));
833 Ok(PreparedPush {
834 unpack: Ok(()),
835 updates,
836 })
837 }
838 Err(LegFailure::Pack(reason)) => Ok(PreparedPush {
839 unpack: Err(reason),
840 updates,
841 }),
842 Err(LegFailure::Backing(error)) => Err(error),
843 }
844 }
845 Err(reason) => Ok(PreparedPush {
846 unpack: Err(reason),
847 updates,
848 }),
849 }
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use std::collections::BTreeMap;
856
857 use crate::testutil::record as repo;
858 use crate::testutil::scratch_dir as scratch_root;
859 use std::path::Path;
860 use std::process::Stdio;
861 use std::sync::atomic::AtomicU64;
862 use std::sync::Arc;
863
864 use alkcall::core::Identity;
865 use gix_hash::Kind as HashKind;
866 use gix_hash::ObjectId;
867 use gix_object::Kind;
868 use gix_pack::data::entry::Header as PackHeader;
869 use gix_pack::data::input::Entry as PackEntry;
870 use tokio::net::TcpListener;
871 use tokio::sync::Semaphore;
872
873 use alkgit::{
874 serve_duplex_resolved, AccessAction, GitPackGen, GitPackIngest, GitRefs, GitRegistry,
875 GitRegistryStore, Limits, Service, Visibility,
876 };
877
878 use super::*;
879 use crate::liveness::RefClosure;
880 use crate::pins::{CommittedRefs, MemMutationLegs};
881 use crate::reactor::Reactor;
882 use crate::refs::InMemoryRefTable;
883 use crate::registry::{
884 CachedRegistryProvider, InMemoryRegistry, RegistryBackend, RegistryWrite,
885 };
886
887 fn up(name: &str, expected: Option<u8>, new: Option<u8>) -> RefUpdate {
888 RefUpdate {
889 name: name.to_owned(),
890 expected: expected.map(|byte| ObjectId::from([byte; 20])),
891 new: new.map(|byte| ObjectId::from([byte; 20])),
892 }
893 }
894
895 fn up_oid(name: &str, expected: Option<ObjectId>, new: Option<ObjectId>) -> RefUpdate {
896 RefUpdate {
897 name: name.to_owned(),
898 expected,
899 new,
900 }
901 }
902
903 fn mem_front() -> ObjectFront {
904 ObjectFront::new(Arc::new(alkblobs::Store::mem_only()))
905 }
906
907 async fn put_object(front: &ObjectFront, kind: Kind, content: &[u8]) -> ObjectId {
908 let (key, _guard) = front.put(kind, content).await.unwrap();
909 key_to_oid(&key).expect("the put's key is the family key")
910 }
911
912 fn tree_bytes(entries: &[(&str, &ObjectId)]) -> Vec<u8> {
913 let mut tree = Vec::new();
914 for (name, oid) in entries {
915 tree.extend_from_slice(format!("100644 {name}\0").as_bytes());
916 tree.extend_from_slice(oid.as_bytes());
917 }
918 tree
919 }
920
921 fn commit_bytes(tree: &ObjectId, parents: &[&ObjectId]) -> Vec<u8> {
922 let mut body = format!("tree {tree}\n");
923 for parent in parents {
924 body.push_str(&format!("parent {parent}\n"));
925 }
926 body.push_str(
927 "author A <a@example.invalid> 1750000000 +0000\n\
928 committer A <a@example.invalid> 1750000000 +0000\n\nthe message\n",
929 );
930 body.into_bytes()
931 }
932
933 async fn push(
934 ingest: &PackIngest,
935 repo: &RepoRecord,
936 updates: Vec<RefUpdate>,
937 pack: Vec<u8>,
938 ) -> PreparedPush {
939 ingest
940 .prepare(
941 repo,
942 updates,
943 Box::new(std::io::Cursor::new(pack)),
944 None,
945 Limits::default(),
946 )
947 .await
948 .expect("the pack-level session never errors here")
949 }
950
951 #[derive(Clone, Default)]
953 struct BytesSink(Arc<parking_lot::Mutex<Vec<u8>>>);
954
955 impl BytesSink {
956 fn bytes(&self) -> Vec<u8> {
957 self.0.lock().clone()
958 }
959 }
960
961 impl std::io::Write for BytesSink {
962 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
963 self.0.lock().extend_from_slice(buf);
964 Ok(buf.len())
965 }
966
967 fn flush(&mut self) -> std::io::Result<()> {
968 Ok(())
969 }
970 }
971
972 async fn generate_pack(front: &ObjectFront, wants: Vec<ObjectId>) -> Vec<u8> {
973 let packgen = crate::packgen::PackGen::direct(front.clone());
974 let sink = BytesSink::default();
975 packgen
976 .generate(
977 &repo("origin"),
978 wants,
979 Vec::new(),
980 Limits::default(),
981 Box::new(sink.clone()),
982 )
983 .await
984 .expect("the fixture pack generates");
985 sink.bytes()
986 }
987
988 struct Fixture {
989 blob: ObjectId,
990 tree_base: ObjectId,
991 tree_tip: ObjectId,
992 base: ObjectId,
993 tip: ObjectId,
994 tag: ObjectId,
995 }
996
997 async fn build_fixture(front: &ObjectFront) -> Fixture {
998 let blob = put_object(front, Kind::Blob, b"hello\n").await;
999 let second = put_object(front, Kind::Blob, b"second\n").await;
1000 let tree_base = put_object(front, Kind::Tree, &tree_bytes(&[("hello", &blob)])).await;
1001 let tree_tip = put_object(
1002 front,
1003 Kind::Tree,
1004 &tree_bytes(&[("hello", &blob), ("second", &second)]),
1005 )
1006 .await;
1007 let base = put_object(front, Kind::Commit, &commit_bytes(&tree_base, &[])).await;
1008 let tip = put_object(front, Kind::Commit, &commit_bytes(&tree_tip, &[&base])).await;
1009 let tag = put_object(front, Kind::Tag, &tag_bytes(&base)).await;
1010 Fixture {
1011 blob,
1012 tree_base,
1013 tree_tip,
1014 base,
1015 tip,
1016 tag,
1017 }
1018 }
1019
1020 fn tag_bytes(target: &ObjectId) -> Vec<u8> {
1021 format!(
1022 "object {target}\ntype commit\ntag v1.0.0\n\
1023 tagger A <a@example.invalid> 1750000000 +0000\n\nannotated\n"
1024 )
1025 .into_bytes()
1026 }
1027
1028 fn deflate_bytes(bytes: &[u8]) -> Vec<u8> {
1029 let mut deflate =
1030 gix_zlib::stream::deflate::Write::new(Vec::new(), gix_zlib::Compression::DEFAULT);
1031 std::io::Write::write_all(&mut deflate, bytes).expect("fixture bytes deflate");
1032 std::io::Write::flush(&mut deflate).expect("the fixture deflate stream finishes");
1033 deflate.into_inner()
1034 }
1035
1036 fn pack_entry_base(kind: Kind, body: &[u8], pack_offset: u64) -> PackEntry {
1037 let header = match kind {
1038 Kind::Blob => PackHeader::Blob,
1039 Kind::Tree => PackHeader::Tree,
1040 Kind::Commit => PackHeader::Commit,
1041 Kind::Tag => PackHeader::Tag,
1042 };
1043 let compressed = deflate_bytes(body);
1044 PackEntry {
1045 header,
1046 header_size: header.size(body.len() as u64) as u16,
1047 pack_offset,
1048 compressed: Some(compressed.clone()),
1049 compressed_size: compressed.len() as u64,
1050 crc32: None,
1051 decompressed_size: body.len() as u64,
1052 trailer: None,
1053 }
1054 }
1055
1056 fn pack_entry_delta(header: PackHeader, payload: &[u8], pack_offset: u64) -> PackEntry {
1057 let compressed = deflate_bytes(payload);
1058 PackEntry {
1059 header,
1060 header_size: header.size(payload.len() as u64) as u16,
1061 pack_offset,
1062 compressed: Some(compressed.clone()),
1063 compressed_size: compressed.len() as u64,
1064 crc32: None,
1065 decompressed_size: payload.len() as u64,
1066 trailer: None,
1067 }
1068 }
1069
1070 fn pack_bytes(entries: &[PackEntry]) -> Vec<u8> {
1071 let mut out = Vec::new();
1072 out.extend_from_slice(&gix_pack::data::header::encode(
1073 gix_pack::data::Version::V2,
1074 entries.len() as u32,
1075 ));
1076 for entry in entries {
1077 entry
1078 .header
1079 .write_to(entry.decompressed_size, &mut out)
1080 .expect("fixture entry headers write");
1081 out.extend_from_slice(
1082 entry
1083 .compressed
1084 .as_ref()
1085 .expect("fixture entries keep bytes"),
1086 );
1087 }
1088 let mut hasher = gix_hash::hasher(HashKind::Sha1);
1089 hasher.update(&out);
1090 let digest = hasher
1091 .try_finalize()
1092 .expect("fixture pack trailers hash over the pack bytes");
1093 out.extend_from_slice(digest.as_slice());
1094 out
1095 }
1096
1097 fn delta_size_varint(size: u64) -> Vec<u8> {
1098 let mut out = Vec::new();
1099 let mut rest = size;
1100 loop {
1101 let mut byte = rest as u8 & 0x7f;
1102 rest >>= 7;
1103 if rest != 0 {
1104 byte |= 0x80;
1105 }
1106 out.push(byte);
1107 if rest == 0 {
1108 return out;
1109 }
1110 }
1111 }
1112
1113 fn delta_stream(base_size: u64, result: &[u8]) -> Vec<u8> {
1114 let mut out = delta_size_varint(base_size);
1115 out.extend(delta_size_varint(base_size + result.len() as u64));
1116 if base_size > 0 {
1117 let mut cmd = 0x80u8;
1118 let mut size_bytes = Vec::new();
1119 let mut rest = base_size;
1120 for bit in [0x10u8, 0x20, 0x40] {
1121 if rest & 0xff != 0 {
1122 cmd |= bit;
1123 size_bytes.push(rest as u8);
1124 }
1125 rest >>= 8;
1126 }
1127 out.push(cmd);
1128 out.extend_from_slice(&size_bytes);
1129 }
1130 for chunk in result.chunks(127) {
1131 out.push(chunk.len() as u8);
1132 out.extend_from_slice(chunk);
1133 }
1134 out
1135 }
1136
1137 fn hostile_delta_stream(base_size: u64, declared_result: u64) -> Vec<u8> {
1138 let mut out = delta_size_varint(base_size);
1139 out.extend(delta_size_varint(declared_result));
1140 out
1141 }
1142
1143 fn closure_of(front: &ObjectFront, rows: &Arc<InMemoryRefTable>) -> Arc<RefClosure> {
1146 RefClosure::new(
1147 front.clone(),
1148 rows.clone() as Arc<dyn crate::liveness::RefRows>,
1149 Arc::new(AtomicU64::new(0)),
1150 )
1151 }
1152
1153 #[tokio::test(flavor = "multi_thread")]
1160 async fn an_ordinary_pack_ingests_pins_and_discards_its_area() {
1161 let temp = scratch_root("ingest", "ordinary");
1162 let origin = mem_front();
1163 let fx = build_fixture(&origin).await;
1164 let pack = generate_pack(&origin, vec![fx.tip, fx.tag]).await;
1165 assert!(pack.starts_with(b"PACK"));
1166
1167 let front = mem_front();
1168 let pins = Arc::new(PendingPinRegistry::new());
1169 let ingest = PackIngest::new(front.clone(), pins.clone(), temp.join("work"));
1170 let updates = vec![
1171 up_oid("refs/heads/main", None, Some(fx.tip)),
1172 up_oid("refs/tags/v1", None, Some(fx.tag)),
1173 ];
1174 let prepared = push(&ingest, &repo("r"), updates.clone(), pack).await;
1175 assert!(prepared.unpack.is_ok(), "{:?}", prepared.unpack);
1176 assert_eq!(prepared.updates, updates, "verbatim on the ok path");
1177 assert_eq!(pins.pending_count("r"), 1, "the custody retained");
1178
1179 for oid in [fx.tip, fx.base, fx.tag, fx.blob, fx.tree_base, fx.tree_tip] {
1180 assert!(
1181 front.stat(&oid).await.unwrap().is_some(),
1182 "object {oid} landed and stats"
1183 );
1184 }
1185
1186 let store = front.store().clone();
1187 let rows = Arc::new(InMemoryRefTable::new());
1188 let source = closure_of(&front, &rows);
1189 source.register(&store).expect("the closure registers");
1190 let report = store.sweep().await.expect("the sweep runs");
1191 assert_eq!(
1192 report.deleted, 0,
1193 "the retained custody holds the push's objects across the sweep window"
1194 );
1195
1196 let durable = Arc::new(InMemoryRegistry::new());
1197 durable.put(&repo("r")).await.expect("the repo registers");
1198 let provider = Arc::new(CachedRegistryProvider::new());
1199 let mem_store = Arc::new(alkstore_mem::MemStore::new());
1200 let reactor = Arc::new(Reactor::new(
1201 mem_store.clone(),
1202 durable.clone(),
1203 provider.clone(),
1204 ));
1205 let legs = Arc::new(MemMutationLegs::new(durable.clone(), reactor));
1206 let inner: Arc<dyn GitRefs> = rows.clone();
1207 let wrapped = CommittedRefs::new(inner, pins.clone(), legs);
1208 let outcomes = wrapped
1209 .apply_updates(&repo("r"), updates.clone(), false)
1210 .await
1211 .expect("the CAS commits");
1212 assert!(outcomes.iter().all(|outcome| outcome.result.is_ok()));
1213 assert_eq!(pins.pending_count("r"), 0, "the entry drained post-CAS");
1214 let watermark = durable.watermark().await.expect("the watermark reads");
1215 assert_eq!(
1216 watermark, 2,
1217 "the marker write anchored the watermark (1 = the put)"
1218 );
1219
1220 let work = temp.join("work");
1221 assert!(
1222 dir_is_empty(&work),
1223 "the working area is discarded (its root may remain, empty)"
1224 );
1225 let _ = tokio::fs::remove_dir_all(&temp).await;
1226 }
1227
1228 #[tokio::test]
1232 async fn a_thin_pack_resolves_its_bases_through_the_find() {
1233 let temp = scratch_root("ingest", "thin");
1234 let front = mem_front();
1235 let base_body = b"base\n".as_slice();
1236 let base_oid = put_object(&front, Kind::Blob, base_body).await;
1237 let delta = delta_stream(base_body.len() as u64, b"-delta");
1238 let result_body = {
1239 let mut body = base_body.to_vec();
1240 body.extend_from_slice(b"-delta");
1241 body
1242 };
1243 let result_oid = gix_object::compute_hash(HashKind::Sha1, Kind::Blob, &result_body)
1244 .expect("the fixture result hashes");
1245 let entry = pack_entry_delta(PackHeader::RefDelta { base_id: base_oid }, &delta, 0);
1246 let pack = pack_bytes(&[entry]);
1247
1248 let pins = Arc::new(PendingPinRegistry::new());
1249 let ingest = PackIngest::new(front.clone(), pins.clone(), temp.join("work"));
1250 let updates = vec![up_oid("refs/heads/main", None, Some(result_oid))];
1251 let prepared = push(&ingest, &repo("r"), updates.clone(), pack).await;
1252 assert!(
1253 prepared.unpack.is_ok(),
1254 "the thin base resolves through the Find: {:?}",
1255 prepared.unpack
1256 );
1257 assert_eq!(prepared.updates, updates);
1258 assert_eq!(pins.pending_count("r"), 1, "the custody retained");
1259 assert!(
1260 front.stat(&result_oid).await.unwrap().is_some(),
1261 "the pack's own object landed"
1262 );
1263 assert!(
1264 front.stat(&base_oid).await.unwrap().is_some(),
1265 "the thin base's normalized entry landed through the store's dedup"
1266 );
1267 let _ = tokio::fs::remove_dir_all(&temp).await;
1268 }
1269
1270 fn dir_is_empty(path: &Path) -> bool {
1271 match std::fs::read_dir(path) {
1272 Ok(mut entries) => entries.next().is_none(),
1273 Err(_) => true,
1274 }
1275 }
1276
1277 #[tokio::test]
1282 async fn the_empty_pack_is_valid_and_reports_as_such() {
1283 let temp = scratch_root("ingest", "empty-pack");
1284 let front = mem_front();
1285 let tip = put_object(&front, Kind::Blob, b"already here\n").await;
1286 let pack = pack_bytes(&[]);
1287
1288 let pins = Arc::new(PendingPinRegistry::new());
1289 let ingest = PackIngest::new(front.clone(), pins.clone(), temp.join("work"));
1290 let updates = vec![up_oid("refs/heads/main", None, Some(tip))];
1291 let prepared = push(&ingest, &repo("r"), updates.clone(), pack).await;
1292 assert!(prepared.unpack.is_ok(), "{:?}", prepared.unpack);
1293 assert_eq!(prepared.updates, updates, "verbatim on the empty path");
1294 assert_eq!(pins.pending_count("r"), 1, "the empty custody retained");
1295 assert!(dir_is_empty(&temp.join("work")), "nothing was written");
1296 let _ = tokio::fs::remove_dir_all(&temp).await;
1297 }
1298
1299 #[tokio::test]
1303 async fn a_zero_pack_with_a_bad_trailer_fails_into_unpack_ng() {
1304 let temp = scratch_root("ingest", "empty-bad");
1305 let front = mem_front();
1306 let mut pack = Vec::new();
1307 pack.extend_from_slice(&gix_pack::data::header::encode(
1308 gix_pack::data::Version::V2,
1309 0,
1310 ));
1311 pack.extend_from_slice(&[7u8; 20]);
1312
1313 let pins = Arc::new(PendingPinRegistry::new());
1314 let ingest = PackIngest::new(front.clone(), pins.clone(), temp.join("work"));
1315 let updates = vec![up("refs/heads/main", None, None)];
1316 let prepared = push(&ingest, &repo("r"), updates.clone(), pack).await;
1317 let reason = prepared.unpack.expect_err("the bad trailer fails the pack");
1318 assert!(!reason.is_empty(), "the ng reason is server-chosen");
1319 assert_eq!(prepared.updates, updates, "verbatim on the ng path");
1320 assert_eq!(pins.pending_count("r"), 0, "nothing retained on ng");
1321 let _ = tokio::fs::remove_dir_all(&temp).await;
1322 }
1323
1324 #[tokio::test]
1331 async fn a_hostile_declared_decompressed_size_fails_into_unpack_ng() {
1332 let temp = scratch_root("ingest", "hostile-declared");
1333 let front = mem_front();
1334 let mut hostile = pack_entry_base(Kind::Blob, b"", 0);
1335 hostile.decompressed_size = 1u64 << 40;
1336 let pack = pack_bytes(&[hostile]);
1337
1338 let pins = Arc::new(PendingPinRegistry::new());
1339 let ingest = PackIngest::new(front.clone(), pins.clone(), temp.join("work"));
1340 let updates = vec![up_oid(
1341 "refs/heads/main",
1342 None,
1343 Some(ObjectId::from([6u8; 20])),
1344 )];
1345 let prepared = push(&ingest, &repo("r"), updates.clone(), pack).await;
1346 let reason = prepared
1347 .unpack
1348 .expect_err("the hostile declaration fails into the ng leg");
1349 assert!(
1350 !reason.is_empty(),
1351 "the ng reason is server-chosen: {reason}"
1352 );
1353 assert_eq!(prepared.updates, updates, "verbatim on the ng path");
1354 assert_eq!(pins.pending_count("r"), 0, "nothing retained on ng");
1355 let _ = tokio::fs::remove_dir_all(&temp).await;
1356 }
1357
1358 #[tokio::test]
1363 async fn a_delta_declaring_a_result_beyond_the_cap_fails_into_unpack_ng() {
1364 let temp = scratch_root("ingest", "delta-cap");
1365 let front = mem_front();
1366 let base_body = b"delta-base".to_vec();
1367 let base_entry = pack_entry_base(Kind::Blob, &base_body, 0);
1368 let delta_entry = pack_entry_delta(
1369 PackHeader::OfsDelta {
1370 base_distance: base_entry.bytes_in_pack(),
1371 },
1372 &hostile_delta_stream(base_body.len() as u64, 200_000),
1373 base_entry.bytes_in_pack(),
1374 );
1375 let pack = pack_bytes(&[base_entry, delta_entry]);
1376 let pins = Arc::new(PendingPinRegistry::new());
1377 let ingest = PackIngest::new(front.clone(), pins.clone(), temp.join("work"));
1378 let limits = Limits {
1379 max_object_size: 65536,
1380 ..Limits::default()
1381 };
1382 let updates = vec![up_oid(
1383 "refs/heads/main",
1384 None,
1385 Some(ObjectId::from([7u8; 20])),
1386 )];
1387 let prepared = ingest
1388 .prepare(
1389 &repo("r"),
1390 updates.clone(),
1391 Box::new(std::io::Cursor::new(pack)),
1392 None,
1393 limits,
1394 )
1395 .await
1396 .unwrap();
1397 let reason = prepared
1398 .unpack
1399 .expect_err("the hostile delta result fails into the ng leg");
1400 assert!(
1401 reason.contains("fit in memory") || reason.contains("cap"),
1402 "the machinery's vet (or the applier's cap check) carries the reason: {reason}"
1403 );
1404 assert_eq!(prepared.updates, updates, "verbatim on the ng path");
1405 assert_eq!(pins.pending_count("r"), 0, "nothing retained on ng");
1406 let _ = tokio::fs::remove_dir_all(&temp).await;
1407 }
1408
1409 #[tokio::test]
1415 async fn the_incremental_inflate_aborts_at_the_cap() {
1416 let big = vec![0u8; 200_000];
1417 let compressed = deflate_bytes(&big);
1418 let error = inflate_slice(&compressed, big.len() as u64, 65536)
1419 .expect_err("the stream's production exceeds the cap");
1420 assert!(error.contains("cap"), "the reason names the cap: {error}");
1421 }
1422
1423 #[tokio::test]
1429 async fn the_delta_applier_vets_the_result_incrementally() {
1430 let base = b"0123456789".to_vec();
1431 let mut delta = delta_size_varint(base.len() as u64);
1432 delta.extend(delta_size_varint(20));
1433 delta.push(0x90);
1434 delta.push(10);
1435 let mut instructions = vec![15u8];
1436 instructions.extend_from_slice(&[b'X'; 15]);
1437 delta.extend_from_slice(&instructions);
1438 let error = apply_delta(&base, &delta, 1024).expect_err("overproducing fails");
1439 assert!(
1440 error.contains("declared"),
1441 "the reason names the declaration: {error}"
1442 );
1443 let error = apply_delta(
1444 &base,
1445 &hostile_delta_stream(base.len() as u64, 1 << 20),
1446 1024,
1447 )
1448 .expect_err("the hostile declaration fails on the cap");
1449 assert!(
1450 error.contains("cap"),
1451 "the reason names the decompressed-size cap: {error}"
1452 );
1453 }
1454
1455 #[tokio::test]
1458 async fn an_in_pack_forward_ref_delta_resolves() {
1459 let temp = scratch_root("ingest", "forward-ref");
1460 let front = mem_front();
1461 let base_body = b"the later base\n".to_vec();
1462 let delta_result = {
1463 let mut body = base_body.clone();
1464 body.extend_from_slice(b"-delta");
1465 body
1466 };
1467 let delta_oid = gix_object::compute_hash(HashKind::Sha1, Kind::Blob, &delta_result)
1468 .expect("the fixture hashes");
1469 let base_oid = gix_object::compute_hash(HashKind::Sha1, Kind::Blob, &base_body)
1470 .expect("the fixture hashes");
1471 let delta_payload = delta_stream(base_body.len() as u64, b"-delta");
1472 let delta = pack_entry_delta(
1473 PackHeader::RefDelta { base_id: base_oid },
1474 &delta_payload,
1475 0,
1476 );
1477 let base = pack_entry_base(Kind::Blob, &base_body, delta.bytes_in_pack());
1478 let pack = pack_bytes(&[delta, base]);
1479
1480 let pins = Arc::new(PendingPinRegistry::new());
1481 let ingest = PackIngest::new(front.clone(), pins.clone(), temp.join("work"));
1482 let updates = vec![up_oid("refs/heads/main", None, Some(delta_oid))];
1483 let prepared = push(&ingest, &repo("r"), updates.clone(), pack).await;
1484 assert!(
1485 prepared.unpack.is_ok(),
1486 "the forward ref-delta parks and resolves: {:?}",
1487 prepared.unpack
1488 );
1489 assert!(front.stat(&delta_oid).await.unwrap().is_some());
1490 assert!(front.stat(&base_oid).await.unwrap().is_some());
1491 let _ = tokio::fs::remove_dir_all(&temp).await;
1492 }
1493
1494 #[tokio::test]
1497 async fn an_ofs_delta_pack_resolves_backward() {
1498 let temp = scratch_root("ingest", "ofs");
1499 let front = mem_front();
1500 let base_body = b"the base body\n".to_vec();
1501 let result_body = {
1502 let mut body = base_body.clone();
1503 body.extend_from_slice(b"-delta");
1504 body
1505 };
1506 let result_oid = gix_object::compute_hash(HashKind::Sha1, Kind::Blob, &result_body)
1507 .expect("the fixture hashes");
1508 let base_entry = pack_entry_base(Kind::Blob, &base_body, 0);
1509 let delta = pack_entry_delta(
1510 PackHeader::OfsDelta {
1511 base_distance: base_entry.bytes_in_pack(),
1512 },
1513 &delta_stream(base_body.len() as u64, b"-delta"),
1514 base_entry.bytes_in_pack(),
1515 );
1516 let pack = pack_bytes(&[base_entry, delta]);
1517
1518 let pins = Arc::new(PendingPinRegistry::new());
1519 let ingest = PackIngest::new(front.clone(), pins.clone(), temp.join("work"));
1520 let updates = vec![up_oid("refs/heads/main", None, Some(result_oid))];
1521 let prepared = push(&ingest, &repo("r"), updates.clone(), pack).await;
1522 assert!(
1523 prepared.unpack.is_ok(),
1524 "the ofsdelta resolves: {:?}",
1525 prepared.unpack
1526 );
1527 assert!(front.stat(&result_oid).await.unwrap().is_some());
1528 let _ = tokio::fs::remove_dir_all(&temp).await;
1529 }
1530
1531 #[tokio::test(flavor = "multi_thread")]
1537 async fn a_missing_closure_member_fails_the_connectivity_ng() {
1538 let temp = scratch_root("ingest", "connectivity");
1539 let front = mem_front();
1540 let ghost = ObjectId::from([0xE9u8; 20]);
1541 let commit = put_object(&front, Kind::Commit, &commit_bytes(&ghost, &[])).await;
1542 let pack = pack_bytes(&[pack_entry_base(Kind::Commit, &commit_bytes(&ghost, &[]), 0)]);
1543 let pins = Arc::new(PendingPinRegistry::new());
1544 let ingest = PackIngest::new(front.clone(), pins.clone(), temp.join("work"));
1545 let updates = vec![up_oid("refs/heads/main", None, Some(commit))];
1546 let prepared = push(&ingest, &repo("r"), updates.clone(), pack).await;
1547 let reason = prepared
1548 .unpack
1549 .expect_err("the missing tree fails the walk");
1550 assert!(
1551 reason.contains("missing object"),
1552 "the reason names the missing member: {reason}"
1553 );
1554 assert_eq!(prepared.updates, updates, "verbatim on the ng path");
1555 assert_eq!(pins.pending_count("r"), 0, "nothing retained on ng");
1556
1557 let store = front.store().clone();
1558 let rows = Arc::new(InMemoryRefTable::new());
1559 let source = closure_of(&front, &rows);
1560 source.register(&store).expect("the closure registers");
1561 let report = store.sweep().await.expect("the sweep runs");
1562 assert_eq!(report.deleted, 1, "the abandoned batch's objects recover");
1563 assert!(front.stat(&commit).await.unwrap().is_none());
1564 let _ = tokio::fs::remove_dir_all(&temp).await;
1565 }
1566
1567 #[tokio::test]
1572 async fn malformed_packs_fail_into_unpack_ng() {
1573 let front = mem_front();
1574
1575 let pins = Arc::new(PendingPinRegistry::new());
1576 let ingest = PackIngest::new(
1577 front.clone(),
1578 pins.clone(),
1579 scratch_root("ingest", "malformed").join("work"),
1580 );
1581 let updates = vec![up("refs/heads/main", None, Some(1))];
1582 let prepared = push(
1583 &ingest,
1584 &repo("r"),
1585 updates.clone(),
1586 b"garbage garbage ga".to_vec(),
1587 )
1588 .await;
1589 let reason = prepared.unpack.expect_err("garbage fails into the ng leg");
1590 assert!(!reason.is_empty(), "the ng reason is server-chosen");
1591 assert_eq!(prepared.updates, updates, "verbatim on the ng path");
1592 assert_eq!(pins.pending_count("r"), 0, "nothing retained on ng");
1593
1594 let pins = Arc::new(PendingPinRegistry::new());
1595 let ingest = PackIngest::new(
1596 front.clone(),
1597 pins.clone(),
1598 scratch_root("ingest", "truncated").join("work"),
1599 );
1600 let legit = pack_bytes(&[pack_entry_base(Kind::Blob, b"truncated away\n", 0)]);
1601 let truncated = legit[..legit.len() - 24].to_vec();
1602 let prepared = push(&ingest, &repo("r"), updates.clone(), truncated).await;
1603 assert!(prepared.unpack.is_err(), "a truncated stream cannot pass");
1604
1605 let pins = Arc::new(PendingPinRegistry::new());
1606 let ingest = PackIngest::new(
1607 front,
1608 pins.clone(),
1609 scratch_root("ingest", "digest").join("work"),
1610 );
1611 let mut digest_broken = pack_bytes(&[pack_entry_base(Kind::Blob, b"payload\n", 0)]);
1612 let last = digest_broken.len() - 1;
1613 digest_broken[last] ^= 0xff;
1614 let prepared = push(&ingest, &repo("r"), updates.clone(), digest_broken).await;
1615 assert!(prepared.unpack.is_err(), "a bad digest cannot pass");
1616 assert_eq!(pins.pending_count("r"), 0, "nothing retained on ng");
1617 }
1618
1619 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1627 async fn a_real_git_push_round_trips_through_the_implement() {
1628 if std::process::Command::new("git")
1629 .arg("--version")
1630 .output()
1631 .is_err()
1632 {
1633 eprintln!("skipping row: no `git` binary on PATH");
1634 return;
1635 }
1636 let temp = scratch_root("ingest", "git-push");
1637 std::fs::create_dir_all(&temp).expect("the scratch root creates");
1638 let front = mem_front();
1639 let pins = Arc::new(PendingPinRegistry::new());
1640 let ingest: Arc<dyn GitPackIngest> = Arc::new(PackIngest::new(
1641 front.clone(),
1642 pins.clone(),
1643 temp.join("ingest-root"),
1644 ));
1645 let durable = Arc::new(InMemoryRegistry::new());
1646 let provider = Arc::new(CachedRegistryProvider::new());
1647 let backend = Arc::new(RegistryBackend::with_cached_provider(
1648 durable.clone(),
1649 provider.clone(),
1650 ));
1651 let registry: Arc<dyn GitRegistry> = backend.clone();
1652 let registry_store: Arc<dyn GitRegistryStore> = backend;
1653 let identity = Identity {
1654 id: "u-push".to_owned(),
1655 scopes: Vec::new(),
1656 resources: Default::default(),
1657 };
1658 let record = RepoRecord {
1659 repo_id: "repo".to_owned(),
1660 visibility: Visibility::Private,
1661 grants: BTreeMap::from([(
1662 identity.id.clone(),
1663 vec![AccessAction::Read, AccessAction::Write],
1664 )]),
1665 };
1666 registry_store.put_repo(&record).await.unwrap();
1667
1668 let rows = Arc::new(InMemoryRefTable::new());
1669 let mem_store = Arc::new(alkstore_mem::MemStore::new());
1670 let reactor = Arc::new(Reactor::new(
1671 mem_store.clone(),
1672 durable.clone(),
1673 provider.clone(),
1674 ));
1675 let legs = Arc::new(MemMutationLegs::new(durable.clone(), reactor));
1676 let refs: Arc<dyn GitRefs> = Arc::new(CommittedRefs::new(rows.clone(), pins.clone(), legs));
1677
1678 let packgen: Arc<dyn GitPackGen> = Arc::new(crate::packgen::PackGen::direct(front.clone()));
1679 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1680 let port = listener.local_addr().unwrap().port();
1681 let limits = Limits::default();
1682 let pipeline = Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks));
1683 let push_ref = refs.clone();
1684 let push_registry = registry.clone();
1685 let push_gen = packgen.clone();
1686 let push_ingest = ingest.clone();
1687 let ident = identity.clone();
1688 let door = tokio::spawn(async move {
1689 loop {
1690 let Ok((mut conn, _peer)) = listener.accept().await else {
1691 break;
1692 };
1693 let refs = push_ref.clone();
1694 let registry = push_registry.clone();
1695 let gen = push_gen.clone();
1696 let ingest = push_ingest.clone();
1697 let identity = ident.clone();
1698 let pipeline = pipeline.clone();
1699 tokio::spawn(async move {
1700 let Some(frame) = read_daemon_frame(&mut conn).await else {
1701 return;
1702 };
1703 let Some(repo_id) = parse_daemon_frame(&frame) else {
1704 return;
1705 };
1706 let _ = serve_duplex_resolved(
1707 &repo_id,
1708 Some(&identity),
1709 Service::ReceivePack,
1710 conn,
1711 limits,
1712 registry,
1713 refs,
1714 gen,
1715 ingest,
1716 false,
1717 pipeline,
1718 )
1719 .await;
1720 });
1721 }
1722 });
1723
1724 let work = temp.join("work");
1725 run_git(
1726 &temp,
1727 &["init", "-q", "-b", "main", work.to_string_lossy().as_ref()],
1728 )
1729 .await;
1730 std::fs::write(work.join("a.txt"), "alpha\n").unwrap();
1731 run_git(&work, &["add", "."]).await;
1732 run_git(
1733 &work,
1734 &[
1735 "-c",
1736 "user.name=A",
1737 "-c",
1738 "user.email=a@example.invalid",
1739 "commit",
1740 "-q",
1741 "-m",
1742 "one",
1743 ],
1744 )
1745 .await;
1746 let url = format!("git://127.0.0.1:{port}/repo");
1747 run_git(&work, &["push", "-q", &url, "main"]).await;
1748
1749 let tip1 = run_git(&work, &["rev-parse", "HEAD"]).await;
1750 let tree1 = run_git(&work, &["rev-parse", "HEAD^{tree}"]).await;
1751 let blob1 = run_git(&work, &["rev-parse", "HEAD:a.txt"]).await;
1752 let parse = |hex_text: &String| {
1753 ObjectId::from_hex(hex_text.trim().as_bytes()).expect("the git output is hex")
1754 };
1755 let tip1 = parse(&tip1);
1756 let tree1 = parse(&tree1);
1757 let blob1 = parse(&blob1);
1758 let main = rows
1759 .list_refs(&record)
1760 .await
1761 .unwrap()
1762 .into_iter()
1763 .find(|line| line.name == "refs/heads/main")
1764 .expect("the ref row landed");
1765 assert_eq!(main.oid, Some(tip1), "the push's tip is the ref row");
1766 for (oid, kind) in [
1767 (tip1, Kind::Commit),
1768 (tree1, Kind::Tree),
1769 (blob1, Kind::Blob),
1770 ] {
1771 assert_eq!(
1772 front.kind(&oid).await.unwrap(),
1773 Some(kind),
1774 "the pushed object {oid} verified into the store"
1775 );
1776 }
1777 assert_eq!(
1778 pins.pending_count("repo"),
1779 0,
1780 "the custody drained through the committed-ref side's CAS"
1781 );
1782 let watermark_one = durable.watermark().await.unwrap();
1783
1784 std::fs::write(work.join("b.txt"), "beta\n").unwrap();
1785 run_git(&work, &["add", "."]).await;
1786 run_git(
1787 &work,
1788 &[
1789 "-c",
1790 "user.name=A",
1791 "-c",
1792 "user.email=a@example.invalid",
1793 "commit",
1794 "-q",
1795 "-m",
1796 "two",
1797 ],
1798 )
1799 .await;
1800 run_git(&work, &["push", "-q", &url, "main"]).await;
1801 let tip2 = run_git(&work, &["rev-parse", "HEAD"]).await;
1802 let tip2 = parse(&tip2);
1803 let main = rows
1804 .list_refs(&record)
1805 .await
1806 .unwrap()
1807 .into_iter()
1808 .find(|line| line.name == "refs/heads/main")
1809 .expect("the ref row");
1810 assert_eq!(
1811 main.oid,
1812 Some(tip2),
1813 "the incremental push advanced the row"
1814 );
1815 assert_eq!(front.kind(&tip2).await.unwrap(), Some(Kind::Commit));
1816 assert_eq!(
1817 pins.pending_count("repo"),
1818 0,
1819 "the second push's custody drained"
1820 );
1821 let watermark_two = durable.watermark().await.unwrap();
1822 assert_eq!(
1823 watermark_two,
1824 watermark_one + 1,
1825 "each committed push rode its marker leg"
1826 );
1827
1828 door.abort();
1829 let _ = tokio::fs::remove_dir_all(&temp).await;
1830 }
1831
1832 async fn read_daemon_frame(conn: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
1834 let mut header = [0u8; 4];
1835 tokio::time::timeout(std::time::Duration::from_secs(10), async {
1836 tokio::io::AsyncReadExt::read_exact(conn, &mut header).await
1837 })
1838 .await
1839 .ok()?
1840 .ok()?;
1841 let len = usize::from_str_radix(std::str::from_utf8(&header).expect("hex header"), 16)
1842 .expect("hex length");
1843 if !(4..=(1 << 16)).contains(&len) {
1844 return None;
1845 }
1846 let mut payload = vec![0u8; len - 4];
1847 tokio::io::AsyncReadExt::read_exact(conn, &mut payload)
1848 .await
1849 .ok()?;
1850 Some(payload)
1851 }
1852
1853 fn parse_daemon_frame(frame: &[u8]) -> Option<String> {
1857 let text = String::from_utf8(frame.to_vec()).ok()?;
1858 let mut segments = text.split('\0');
1859 let (service, repo_path) = segments.next()?.split_once(' ')?;
1860 if service != "git-receive-pack" {
1861 return None;
1862 }
1863 let repo_id = repo_path.strip_prefix('/').unwrap_or(repo_path);
1864 if repo_id.is_empty() {
1865 return None;
1866 }
1867 Some(repo_id.to_owned())
1868 }
1869
1870 async fn run_git(dir: &Path, args: &[&str]) -> String {
1873 let mut command = tokio::process::Command::new("git");
1874 command
1875 .arg("-C")
1876 .arg(dir)
1877 .args(args)
1878 .env_clear()
1879 .env("PATH", std::env::var("PATH").unwrap_or_default())
1880 .env("GIT_CONFIG_NOSYSTEM", "1")
1881 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1882 .env("GIT_TERMINAL_PROMPT", "0")
1883 .stdin(Stdio::null())
1884 .stdout(Stdio::piped())
1885 .stderr(Stdio::piped());
1886 let output = command.output().await.expect("the git command spawns");
1887 assert!(
1888 output.status.success(),
1889 "git {args:?} failed: {}{}",
1890 String::from_utf8_lossy(&output.stdout),
1891 String::from_utf8_lossy(&output.stderr)
1892 );
1893 String::from_utf8_lossy(&output.stdout).into_owned()
1894 }
1895}