anvilsign in

collin/anvil · d6e72c47

fix(gitserver-core): annotated-tag packs, unpeeled receive ads, delete-refs

Collin Richards · 2026-06-10 10:18 UTC · d6e72c479256762fc2951c395718b26d7848e05c · parent 84b3fd31 · browse files

modifiedvendor/gitserver-core/src/pack.rs+159 −2
⋯ 424 unchanged lines
425425 Ok(())
426426 }
427427
428+/// Peel `wants` for a pack walk: tag wants contribute the tag object itself
429+/// (plus the chain down to a non-tag), and the peeled target joins the walk —
430+/// commits as walk tips, trees/blobs (rare tag targets) collected directly.
431+/// `git fetch`/`push` send tag *object* ids whenever annotated tags are
432+/// involved, so every pack path needs this.
433+fn peel_wants(
434+ repo: &gix::Repository,
435+ wants: &[gix::ObjectId],
436+) -> std::result::Result<
437+ (Vec<gix::ObjectId>, Vec<gix::ObjectId>),
438+ Box<dyn std::error::Error + Send + Sync>,
439+> {
440+ let mut commit_wants = Vec::new();
441+ let mut extras = Vec::new();
442+ let mut seen = HashSet::new();
443+ for want in wants {
444+ let mut oid = *want;
445+ loop {
446+ let obj = repo.find_object(oid)?;
447+ match obj.kind {
448+ gix::object::Kind::Tag => {
449+ if seen.insert(oid) {
450+ extras.push(oid);
451+ }
452+ oid = gix::objs::TagRefIter::from_bytes(&obj.data, gix::hash::Kind::Sha1)
453+ .target_id()?;
454+ }
455+ gix::object::Kind::Commit => {
456+ commit_wants.push(oid);
457+ break;
458+ }
459+ gix::object::Kind::Tree => {
460+ collect_tree_oids(repo, oid, &mut seen, &mut extras)?;
461+ break;
462+ }
463+ gix::object::Kind::Blob => {
464+ if seen.insert(oid) {
465+ extras.push(oid);
466+ }
467+ break;
468+ }
469+ }
470+ }
471+ }
472+ Ok((commit_wants, extras))
473+}
474+
428475 /// Walk commits from `wants` (excluding `haves`) and collect all
429-/// reachable ObjectIds (commits, trees, blobs).
476+/// reachable ObjectIds (commits, trees, blobs). Tag wants are peeled, with
477+/// the tag objects themselves included in the result.
430478 ///
431479 /// Pass 1 of the two-pass streaming approach: only OIDs are stored,
432480 /// not object data.
⋯ 2 unchanged lines
435483 wants: &[gix::ObjectId],
436484 haves: &[gix::ObjectId],
437485 ) -> std::result::Result<Vec<gix::ObjectId>, Box<dyn std::error::Error + Send + Sync>> {
486+ let (commit_wants, extras) = peel_wants(repo, wants)?;
438487 let have_set: HashSet<gix::ObjectId> = haves.iter().copied().collect();
439488 let mut seen = HashSet::new();
440489 let mut oids = Vec::new();
⋯ 4 unchanged lines
445494 }
446495
447496 let walk = repo
448- .rev_walk(wants.iter().copied())
497+ .rev_walk(commit_wants.iter().copied())
449498 .with_hidden(haves.iter().copied())
450499 .all()?;
451500
⋯ 16 unchanged lines
468517 collect_tree_oids(repo, tree_oid, &mut seen, &mut oids)?;
469518 }
470519
520+ // Tag objects (and direct tree/blob tag targets) ride along after the
521+ // walk; anything already collected or known to the remote is skipped.
522+ oids.extend(
523+ extras
524+ .into_iter()
525+ .filter(|oid| !seen.contains(oid) && !have_set.contains(oid)),
526+ );
527+
471528 Ok(oids)
472529 }
473530
⋯ 12 unchanged lines
486543 .collect())
487544 }
488545
546+/// Build a complete, self-contained pack (header + entries + SHA-1 trailer)
547+/// of everything reachable from `wants` but not from `haves`, returned as one
548+/// buffer. Entries are undeltified base objects — used by the *push*
549+/// (send-pack) client, where mirror pushes are infrequent enough that pack
550+/// size doesn't justify delta search.
551+///
552+/// `haves` must exist in the local object database (callers filter the remote
553+/// advertisement accordingly).
554+pub fn build_raw_pack(
555+ repo_path: &Path,
556+ wants: &[gix::ObjectId],
557+ haves: &[gix::ObjectId],
558+) -> Result<Vec<u8>> {
559+ let repo = gix::open(repo_path)?;
560+ // Tag wants (annotated tags) are peeled inside `collect_all_oids`, with
561+ // the tag objects themselves included in the result.
562+ let oids = collect_all_oids(&repo, wants, haves)
563+ .map_err(|e| Error::Protocol(format!("collecting pack objects: {e}")))?;
564+
565+ let mut out = Vec::new();
566+ out.extend_from_slice(b"PACK");
567+ out.extend_from_slice(&2u32.to_be_bytes());
568+ out.extend_from_slice(&(oids.len() as u32).to_be_bytes());
569+ for oid in &oids {
570+ let obj = repo
571+ .find_object(*oid)
572+ .map_err(|e| Error::Protocol(format!("reading {oid}: {e}")))?;
573+ out.extend_from_slice(&build_base_entry(obj.kind, &obj.data));
574+ }
575+ let checksum = Sha1::digest(&out);
576+ out.extend_from_slice(&checksum);
577+ Ok(out)
578+}
579+
489580 /// Generate the complete pack response for a Git upload-pack request.
490581 ///
491582 /// Returns an `AsyncRead` producing the side-band-64k framed response that
⋯ 359 unchanged lines
851942 let pack_found = buf.windows(4).any(|window| window == b"PACK");
852943 assert!(pack_found, "response should contain PACK signature");
853944 }
945+
946+ /// Regression: a want may be an annotated tag *object* (protocol-v2
947+ /// `ls-refs` advertises unpeeled ids). The walk used to choke on it
948+ /// mid-stream, killing `git clone` of any repo with an annotated tag;
949+ /// now the tag is peeled and the tag object itself joins the pack.
950+ #[tokio::test]
951+ async fn tag_object_wants_are_peeled_and_packed() {
952+ let dir = TempDir::new().unwrap();
953+ let repo_path = create_repo_with_commit(dir.path());
954+
955+ let clone_path = dir.path().join("workdir");
956+ let out = Command::new("git")
957+ .args(["tag", "-a", "-m", "annotated", "v1"])
958+ .current_dir(&clone_path)
959+ .env("GIT_AUTHOR_NAME", "t")
960+ .env("GIT_AUTHOR_EMAIL", "t@t")
961+ .env("GIT_COMMITTER_NAME", "t")
962+ .env("GIT_COMMITTER_EMAIL", "t@t")
963+ .output()
964+ .unwrap();
965+ assert!(out.status.success());
966+ let out = Command::new("git")
967+ .args(["push", "-q", "origin", "v1"])
968+ .current_dir(&clone_path)
969+ .output()
970+ .unwrap();
971+ assert!(out.status.success());
972+
973+ let repo = gix::open(&repo_path).unwrap();
974+ let tag_oid = repo
975+ .find_reference("refs/tags/v1")
976+ .unwrap()
977+ .try_id()
978+ .unwrap()
979+ .detach();
980+ assert_eq!(
981+ repo.find_object(tag_oid).unwrap().kind,
982+ gix::object::Kind::Tag,
983+ "fixture must produce a real tag object"
984+ );
985+ drop(repo);
986+
987+ let pack = build_raw_pack(&repo_path, &[tag_oid], &[]).unwrap();
988+ assert!(pack.starts_with(b"PACK"));
989+ let count = u32::from_be_bytes(pack[8..12].try_into().unwrap());
990+ // tag + commit + tree + blob
991+ assert_eq!(count, 4, "tag object and full closure packed");
992+
993+ // The streaming (fetch-serving) path accepts the same want.
994+ let request = UploadPackRequest {
995+ wants: vec![tag_oid],
996+ haves: vec![],
997+ done: true,
998+ capabilities: UploadPackCapabilities::default(),
999+ shallow: ShallowRequest::default(),
1000+ object_ids: None,
1001+ };
1002+ let mut reader = generate_pack(&repo_path, &request).unwrap();
1003+ let mut buf = Vec::new();
1004+ reader.read_to_end(&mut buf).await.unwrap();
1005+ assert!(
1006+ buf.windows(4).any(|w| w == b"PACK"),
1007+ "fetch response should contain a pack, got: {:?}",
1008+ String::from_utf8_lossy(&buf[..buf.len().min(200)])
1009+ );
1010+ }
8541011 }
modifiedvendor/gitserver-core/src/receive_pack.rs+47 −8
⋯ 39 unchanged lines
4040
4141 const ZERO_ID: &str = "0000000000000000000000000000000000000000";
4242 const CAPABILITIES: &str = concat!(
43- "report-status report-status-v2 side-band-64k quiet ofs-delta object-format=sha1 agent=gitserver/",
43+ "report-status report-status-v2 delete-refs side-band-64k quiet ofs-delta object-format=sha1 agent=gitserver/",
4444 env!("CARGO_PKG_VERSION")
4545 );
4646
⋯ 1 unchanged line
4848 let repo = gix::open(repo_path)?;
4949 let mut out = Vec::new();
5050 let head_name = repo.head_name().ok().flatten();
51+ // Advertise each ref's *direct* target — for an annotated tag that is the
52+ // tag object, not its peeled commit. Clients diff these ids against their
53+ // own ref targets, and the update transaction later compares against the
54+ // real target too; advertising peeled ids made identical tags look
55+ // changed (and their no-op re-push then failed validation).
5156 let mut refs: Vec<(String, gix::ObjectId)> = repo
5257 .references()
5358 .map_err(|e| Error::Protocol(format!("failed to open refs: {e}")))?
5459 .all()
5560 .map_err(|e| Error::Protocol(format!("failed to iterate refs: {e}")))?
5661 .flatten()
57- .filter_map(|mut reference| {
62+ .filter_map(|reference| {
5863 reference
59- .peel_to_id()
60- .ok()
64+ .try_id()
6165 .map(|id| (reference.name().as_bstr().to_string(), id.detach()))
6266 })
6367 .collect();
⋯ 245 unchanged lines
309313 interrupt: &AtomicBool,
310314 ) -> Result<RefEdit> {
311315 if command.new_id == ZERO_ID {
312- return Err(Error::Protocol(format!(
313- "deletion prohibited for {}",
314- command.refname
315- )));
316+ return validate_ref_delete(repo, command);
316317 }
317318
318319 let is_branch = command.refname.starts_with("refs/heads/");
⋯ 52 unchanged lines
371372 })
372373 }
373374
375+/// Validate a ref deletion (`new == zero`), advertised via `delete-refs`.
376+/// The branch `HEAD` points at is protected — like a forge's default branch,
377+/// deleting it would leave the repository unborn.
378+fn validate_ref_delete(repo: &gix::Repository, command: &UpdateCommand) -> Result<RefEdit> {
379+ if command.old_id == ZERO_ID {
380+ return Err(Error::Protocol(format!(
381+ "invalid delete of {} (old and new are both zero)",
382+ command.refname
383+ )));
384+ }
385+ if repo
386+ .head_name()
387+ .ok()
388+ .flatten()
389+ .is_some_and(|head| head.as_bstr() == command.refname.as_str())
390+ {
391+ return Err(Error::Protocol(format!(
392+ "deletion of the default branch {} is not allowed",
393+ command.refname
394+ )));
395+ }
396+ let old_id = gix::ObjectId::from_hex(command.old_id.as_bytes())
397+ .map_err(|_| Error::Protocol(format!("invalid old object id: {}", command.old_id)))?;
398+ let name: gix::refs::FullName = command
399+ .refname
400+ .as_str()
401+ .try_into()
402+ .map_err(|e| Error::Protocol(format!("invalid ref name {}: {e}", command.refname)))?;
403+ Ok(RefEdit {
404+ change: Change::Delete {
405+ expected: PreviousValue::MustExistAndMatch(Target::Object(old_id)),
406+ log: RefLog::AndReference,
407+ },
408+ name,
409+ deref: false,
410+ })
411+}
412+
374413 fn ensure_fast_forward(
375414 repo: &gix::Repository,
376415 old_id: gix::ObjectId,
⋯ 307 unchanged lines
modifiedvendor/gitserver-core/src/refs.rs+11 −3
⋯ 38 unchanged lines
3939 refs.push((id.to_string(), "HEAD".to_string()));
4040 }
4141
42- // Iterate all references
42+ // Iterate all references. Each line advertises the ref's *direct* target
43+ // (for an annotated tag, the tag object); the peeled commit follows on a
44+ // conventional `<refname>^{}` line, as git's own advertisement does.
4345 if let Ok(platform) = repo.references()
4446 && let Ok(iter) = platform.all()
4547 {
4648 for mut r in iter.flatten() {
4749 let name = r.name().as_bstr().to_string();
48- if let Ok(id) = r.peel_to_id() {
49- refs.push((id.to_string(), name));
50+ let Some(direct) = r.try_id().map(|id| id.detach()) else {
51+ continue;
52+ };
53+ refs.push((direct.to_string(), name.clone()));
54+ if let Ok(peeled) = r.peel_to_id()
55+ && peeled.detach() != direct
56+ {
57+ refs.push((peeled.to_string(), format!("{name}^{{}}")));
5058 }
5159 }
5260 }
⋯ 175 unchanged lines