mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
The completed-push shortcut required every target to be satisfied and treated verification as all-or-nothing even without atomic negotiation. A completed deletion mixed with new work still failed, and post-push promotion could apply refs that receive-pack had just rejected despite exiting successfully. Replace the shortcut with an unsigned receive-pack plan. Verify satisfied refs without writes when completed deletions are present, forward remaining commands and original pack/options to Git, and merge per-ref statuses. Non-atomic checks complete independently; atomic checks retain prepared verification locks until Git exits, and abort before execution on verification failure. Native statuses remain authoritative for actual updates. Zero-to-zero deletes never reach Git. Keep whole-request signed-state authorization and independent PR validation. Accept branch/tag deletion only when absent from the authorizing state. Capture native per-ref failures on ordinary unsigned pushes too, retain successful tips, and defer immediate state promotion after rejection. Background reconciliation of authorized state is unchanged; signed/malformed requests retain their path. Stream sideband progress while retaining the status report for finalization; preserve status-v2 options and honor clients that did not request a report. Own subprocess cleanup and verification locks together across upload awaits, and stop Git on client cancellation before releasing those locks. Validation: the 956-test library suite passed, followed by all ten final plan regressions including cancellation and lock cleanup. All 208 selected Git, authorization, PR-hosting and streaming integrations passed after the final changes. Real Git tests cover mixed success, update-hook partial rejection and atomic rejection with and without completed deletions. Streaming fixtures now emit valid Git packets and retain bounded readiness/finalization checks. Strict all-target Clippy passed. Assisted-by: GPT-6
393 lines
12 KiB
Rust
393 lines
12 KiB
Rust
use http_body_util::BodyExt;
|
|
use hyper::body::Bytes;
|
|
use ngit_grasp::{
|
|
git::{
|
|
handlers::handle_receive_pack,
|
|
storage::{FamilyKey, LocalGitStorage},
|
|
},
|
|
nostr::SharedDatabase,
|
|
purgatory::Purgatory,
|
|
};
|
|
use nostr_sdk::prelude::*;
|
|
use std::{path::Path, process::Command, sync::Arc, time::Duration};
|
|
|
|
fn git(path: &Path, args: &[&str]) -> Vec<u8> {
|
|
let result = Command::new("git")
|
|
.current_dir(path)
|
|
.args(["-c", "user.name=Test", "-c", "user.email=test@example.com"])
|
|
.args(args)
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
result.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&result.stderr)
|
|
);
|
|
result.stdout
|
|
}
|
|
|
|
fn push(repo: &Path, old: &str, new: &str) -> Bytes {
|
|
let command = format!("{old} {new} refs/heads/main\0report-status\n");
|
|
let mut bytes = format!("{:04x}{command}0000", command.len() + 4).into_bytes();
|
|
bytes.extend(git(repo, &["pack-objects", "--stdout"]));
|
|
bytes.into()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn state_promoted_after_advertisement_does_not_reject_an_already_applied_push() {
|
|
promoted_push(false, None).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn already_applied_branch_does_not_require_state_for_a_pr_ref() {
|
|
promoted_push(true, None).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn already_applied_deletion_is_a_verified_noop() {
|
|
promoted_push(false, Some("report-status")).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn already_applied_deletion_supports_sideband_status_v2() {
|
|
promoted_push(false, Some("report-status-v2 side-band-64k")).await;
|
|
}
|
|
|
|
async fn promoted_push(with_pr: bool, deletion_caps: Option<&str>) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let owner = Keys::generate();
|
|
let identifier = "promotion-race";
|
|
let repo = dir
|
|
.path()
|
|
.join(owner.public_key().to_bech32().unwrap())
|
|
.join("promotion-race.git");
|
|
let storage = LocalGitStorage::new(dir.path());
|
|
storage
|
|
.create_thin_view(&FamilyKey::sha1(identifier).unwrap(), &repo)
|
|
.unwrap();
|
|
let family = storage
|
|
.ensure_family(&FamilyKey::sha1(identifier).unwrap())
|
|
.unwrap();
|
|
let tree = String::from_utf8(git(&family, &["mktree"]))
|
|
.unwrap()
|
|
.trim()
|
|
.to_owned();
|
|
let commit = String::from_utf8(git(
|
|
&family,
|
|
&["commit-tree", &tree, "-m", "already promoted"],
|
|
))
|
|
.unwrap()
|
|
.trim()
|
|
.to_owned();
|
|
git(&repo, &["update-ref", "refs/heads/main", &commit]);
|
|
let db: SharedDatabase = Arc::new(nostr_memory::MemoryDatabase::unbounded());
|
|
let ann = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
|
.tags([
|
|
Tag::identifier(identifier),
|
|
Tag::custom("clone", ["https://service.example/promotion-race.git"]),
|
|
])
|
|
.finalize(&owner)
|
|
.unwrap();
|
|
let state = EventBuilder::new(Kind::RepoState, "")
|
|
.tags([
|
|
Tag::identifier(identifier),
|
|
Tag::custom("refs/heads/main", [&commit]),
|
|
])
|
|
.finalize(&owner)
|
|
.unwrap();
|
|
db.save_event(&ann).await.unwrap();
|
|
db.save_event(&state).await.unwrap();
|
|
let purgatory = Arc::new(Purgatory::new(dir.path().to_path_buf()));
|
|
let relay = LocalRelayBuilder::default().database(db.clone()).build();
|
|
|
|
// The client saw an absent branch; background promotion installed it
|
|
// before its upload arrived and removed the state from purgatory.
|
|
let request = if let Some(caps) = deletion_caps {
|
|
let branch = format!("{} {commit} refs/heads/main\0{caps}\n", "0".repeat(40));
|
|
let deletion = format!("{commit} {} refs/heads/gone\n", "0".repeat(40));
|
|
Bytes::from(format!(
|
|
"{:04x}{branch}{:04x}{deletion}0000",
|
|
branch.len() + 4,
|
|
deletion.len() + 4
|
|
))
|
|
} else if with_pr {
|
|
let command = format!(
|
|
"{} {commit} refs/heads/main\0report-status\n",
|
|
"0".repeat(40)
|
|
);
|
|
let pr = format!(
|
|
"{} {commit} refs/nostr/{}\n",
|
|
"0".repeat(40),
|
|
"a".repeat(64)
|
|
);
|
|
let mut raw = format!(
|
|
"{:04x}{command}{:04x}{pr}0000",
|
|
command.len() + 4,
|
|
pr.len() + 4
|
|
)
|
|
.into_bytes();
|
|
raw.extend(git(&repo, &["pack-objects", "--stdout"]));
|
|
Bytes::from(raw)
|
|
} else {
|
|
push(&repo, &"0".repeat(40), &commit)
|
|
};
|
|
let response = tokio::time::timeout(
|
|
Duration::from_secs(10),
|
|
handle_receive_pack(
|
|
repo.clone(),
|
|
request,
|
|
db.clone(),
|
|
relay.clone(),
|
|
identifier,
|
|
&owner.public_key().to_hex(),
|
|
purgatory.clone(),
|
|
dir.path().to_str().unwrap(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let body = tokio::time::timeout(Duration::from_secs(10), response.into_body().collect())
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.to_bytes();
|
|
let result = String::from_utf8_lossy(&body);
|
|
assert!(
|
|
result.contains("unpack ok") && result.contains("ok refs/heads/main"),
|
|
"{result}"
|
|
);
|
|
|
|
if deletion_caps.is_some() {
|
|
assert!(result.contains("ok refs/heads/gone"), "{result}");
|
|
assert!(
|
|
!String::from_utf8(git(&repo, &["for-each-ref", "refs/heads/gone"]))
|
|
.unwrap()
|
|
.contains("refs/heads/gone")
|
|
);
|
|
}
|
|
if with_pr {
|
|
assert!(
|
|
result.contains(&format!("ok refs/nostr/{}", "a".repeat(64))),
|
|
"{result}"
|
|
);
|
|
assert_eq!(
|
|
String::from_utf8(git(
|
|
&repo,
|
|
&["rev-parse", &format!("refs/nostr/{}", "a".repeat(64))]
|
|
))
|
|
.unwrap()
|
|
.trim(),
|
|
commit
|
|
);
|
|
}
|
|
|
|
// Existing objects are not authority to change a ref: a different target
|
|
// still needs an authorizing state in purgatory.
|
|
let other = String::from_utf8(git(
|
|
&family,
|
|
&["commit-tree", &tree, "-m", "not authorized"],
|
|
))
|
|
.unwrap()
|
|
.trim()
|
|
.to_owned();
|
|
let response = handle_receive_pack(
|
|
repo.clone(),
|
|
push(&repo, &commit, &other),
|
|
db,
|
|
relay.clone(),
|
|
identifier,
|
|
&owner.public_key().to_hex(),
|
|
purgatory,
|
|
dir.path().to_str().unwrap(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let body = tokio::time::timeout(Duration::from_secs(10), response.into_body().collect())
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.to_bytes();
|
|
assert!(String::from_utf8_lossy(&body).contains("authorisation failed"));
|
|
assert_eq!(
|
|
String::from_utf8(git(&repo, &["rev-parse", "refs/heads/main"]))
|
|
.unwrap()
|
|
.trim(),
|
|
commit
|
|
);
|
|
relay.shutdown();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn completed_deletion_and_new_branch_succeed_together() {
|
|
mixed_deletion_push(false, false, true).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn atomic_completed_deletion_and_new_branch_succeed_together() {
|
|
mixed_deletion_push(true, false, true).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_atomic_push_reports_success_and_rejection_per_ref() {
|
|
mixed_deletion_push(false, true, true).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn atomic_push_rejects_every_ref_when_one_update_fails() {
|
|
mixed_deletion_push(true, true, true).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ordinary_non_atomic_push_preserves_partial_result() {
|
|
mixed_deletion_push(false, true, false).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ordinary_atomic_push_preserves_rejection() {
|
|
mixed_deletion_push(true, true, false).await;
|
|
}
|
|
|
|
async fn mixed_deletion_push(atomic: bool, reject_branch: bool, include_deletion: bool) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let owner = Keys::generate();
|
|
let identifier = "mixed-push";
|
|
let repo = dir
|
|
.path()
|
|
.join(owner.public_key().to_bech32().unwrap())
|
|
.join("mixed-push.git");
|
|
let storage = LocalGitStorage::new(dir.path());
|
|
let key = FamilyKey::sha1(identifier).unwrap();
|
|
storage.create_thin_view(&key, &repo).unwrap();
|
|
let family = storage.ensure_family(&key).unwrap();
|
|
let tree = String::from_utf8(git(&family, &["mktree"])).unwrap();
|
|
let oid = String::from_utf8(git(
|
|
&family,
|
|
&["commit-tree", tree.trim(), "-m", "available"],
|
|
))
|
|
.unwrap()
|
|
.trim()
|
|
.to_owned();
|
|
let bad = oid.clone();
|
|
if reject_branch {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let hook = repo.join("hooks/update");
|
|
std::fs::write(&hook, b"#!/bin/sh\n[ \"$1\" != refs/heads/unavailable ]\n").unwrap();
|
|
std::fs::set_permissions(hook, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
}
|
|
let db: SharedDatabase = Arc::new(nostr_memory::MemoryDatabase::unbounded());
|
|
db.save_event(
|
|
&EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
|
.tags([
|
|
Tag::identifier(identifier),
|
|
Tag::custom("clone", ["https://service.example/mixed-push.git"]),
|
|
])
|
|
.finalize(&owner)
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut tags = vec![
|
|
Tag::identifier(identifier),
|
|
Tag::custom("refs/heads/feature", [&oid]),
|
|
];
|
|
if reject_branch {
|
|
tags.push(Tag::custom("refs/heads/unavailable", [&bad]));
|
|
}
|
|
let state = EventBuilder::new(Kind::RepoState, "")
|
|
.tags(tags)
|
|
.finalize(&owner)
|
|
.unwrap();
|
|
let purgatory = Arc::new(Purgatory::new(dir.path().to_path_buf()));
|
|
purgatory.add_state(state, identifier.into(), owner.public_key(), false);
|
|
let relay = LocalRelayBuilder::default().database(db.clone()).build();
|
|
let caps = if atomic {
|
|
"report-status-v2 side-band-64k atomic"
|
|
} else {
|
|
"report-status"
|
|
};
|
|
// Capabilities begin on the omitted deletion and must move to the first
|
|
// actual Git update. Its pack must remain intact.
|
|
let mut commands = if include_deletion {
|
|
vec![
|
|
format!("{oid} {} refs/heads/gone\0{caps}\n", "0".repeat(40)),
|
|
format!("{} {oid} refs/heads/feature\n", "0".repeat(40)),
|
|
]
|
|
} else {
|
|
vec![format!(
|
|
"{} {oid} refs/heads/feature\0{caps}\n",
|
|
"0".repeat(40)
|
|
)]
|
|
};
|
|
if reject_branch {
|
|
commands.push(format!("{} {bad} refs/heads/unavailable\n", "0".repeat(40)));
|
|
}
|
|
let mut raw = Vec::new();
|
|
for command in commands {
|
|
raw.extend(format!("{:04x}{command}", command.len() + 4).as_bytes());
|
|
}
|
|
raw.extend(b"0000");
|
|
raw.extend(git(&repo, &["pack-objects", "--stdout"]));
|
|
let response = tokio::time::timeout(
|
|
Duration::from_secs(10),
|
|
handle_receive_pack(
|
|
repo.clone(),
|
|
raw.into(),
|
|
db,
|
|
relay.clone(),
|
|
identifier,
|
|
&owner.public_key().to_hex(),
|
|
purgatory,
|
|
dir.path().to_str().unwrap(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let body = tokio::time::timeout(Duration::from_secs(10), response.into_body().collect())
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.to_bytes();
|
|
let report = String::from_utf8_lossy(&body);
|
|
let rejected_all = atomic && reject_branch;
|
|
let names = if include_deletion {
|
|
vec!["gone", "feature"]
|
|
} else {
|
|
vec!["feature"]
|
|
};
|
|
for name in names {
|
|
assert!(
|
|
report.contains(&format!(
|
|
"{} refs/heads/{name}",
|
|
if rejected_all { "ng" } else { "ok" }
|
|
)),
|
|
"{report}"
|
|
);
|
|
}
|
|
if reject_branch {
|
|
assert!(report.contains("ng refs/heads/unavailable"), "{report}");
|
|
}
|
|
let refs = String::from_utf8(git(
|
|
&repo,
|
|
&["for-each-ref", "--format=%(refname) %(objectname)"],
|
|
))
|
|
.unwrap();
|
|
assert_eq!(refs.contains("refs/heads/feature"), !rejected_all, "{refs}");
|
|
assert!(!refs.contains("refs/heads/gone"), "{refs}");
|
|
assert!(!refs.contains("refs/heads/unavailable"), "{refs}");
|
|
relay.shutdown();
|
|
}
|