fix(deletion): remove and recover PR git refs

This commit is contained in:
DanConwayDev
2026-06-18 20:25:26 +00:00
parent 3afcd4b661
commit 3945a192b7
4 changed files with 494 additions and 15 deletions
+91 -14
View File
@@ -79,6 +79,48 @@ pub fn event_names_relays_prs_endpoint(event: &Event, domain: &str) -> bool {
false
}
/// Return all distinct `<d>` identifiers named by this event's `clone` tags
/// that target this relay's `/prs/<signer-npub>/<d>.git` endpoint and also
/// appear in the event's `a` tags.
///
/// This is used by deletion/recovery code paths to map a PR/PR-update event to
/// its scoped `/prs/` bare repository locations.
pub fn prs_identifiers_named_by_event_clone_tags(event: &Event, domain: &str) -> Vec<String> {
if !matches!(
event.kind,
Kind::GitPullRequest | Kind::GitPullRequestUpdate
) {
return Vec::new();
}
let d_tags: std::collections::HashSet<String> =
collect_a_tag_d_values(event).into_iter().collect();
if d_tags.is_empty() {
return Vec::new();
}
let mut out = std::collections::BTreeSet::new();
for tag in event.tags.iter() {
let parts = tag.clone().to_vec();
if parts.first().map(String::as_str) != Some("clone") {
continue;
}
for url in parts.iter().skip(1) {
let Some(identifier) = clone_url_prs_identifier_for_relay(url, domain, &event.pubkey)
else {
continue;
};
if d_tags.contains(&identifier) {
out.insert(identifier);
}
}
}
out.into_iter().collect()
}
/// Extract `<d>` from every well-formed `a` tag of the form
/// `30617:<64-hex-pubkey>:<d>` on `event`. Malformed tags are silently
/// skipped — a malformed `a` tag must not crash, it just means the event
@@ -129,68 +171,80 @@ pub fn clone_url_names_relays_prs_endpoint(
signer: &PublicKey,
d_tags: &[String],
) -> bool {
let Some(d_decoded) = clone_url_prs_identifier_for_relay(url, domain, signer) else {
return false;
};
d_tags.contains(&d_decoded)
}
/// Parse `url` as this relay's `/prs/<signer-npub>/<d>.git` endpoint and
/// return the decoded `<d>` when it matches.
fn clone_url_prs_identifier_for_relay(
url: &str,
domain: &str,
signer: &PublicKey,
) -> Option<String> {
// Scheme: http or https only, case-insensitive.
let rest = if let Some(r) = strip_prefix_ignore_ascii_case(url, "http://") {
r
} else if let Some(r) = strip_prefix_ignore_ascii_case(url, "https://") {
r
} else {
return false;
return None;
};
// Reject query strings and fragments outright.
if rest.contains('?') || rest.contains('#') {
return false;
return None;
}
// Split authority and path on the first `/`.
let slash_idx = match rest.find('/') {
Some(i) => i,
None => return false,
None => return None,
};
let authority = &rest[..slash_idx];
let path = &rest[slash_idx..]; // includes leading `/`.
if !authority.eq_ignore_ascii_case(domain) {
return false;
return None;
}
let path = path.trim_end_matches('/');
let inner = match path.strip_prefix(&format!("/{}/", PRS_URL_PREFIX)) {
Some(s) => s,
None => return false,
None => return None,
};
// Exactly two segments: `<npub>` and `<repo>.git`.
let segments: Vec<&str> = inner.split('/').collect();
if segments.len() != 2 {
return false;
return None;
}
let npub_segment = segments[0];
let repo_segment = segments[1];
if !npub_segment.starts_with("npub1") {
return false;
return None;
}
let url_pubkey = match PublicKey::from_bech32(npub_segment) {
Ok(pk) => pk,
Err(_) => return false,
Err(_) => return None,
};
if url_pubkey != *signer {
return false;
return None;
}
let d_encoded = match repo_segment.strip_suffix(".git") {
Some(s) => s,
None => return false,
None => return None,
};
if d_encoded.is_empty() {
return false;
return None;
}
let d_decoded = percent_decode(d_encoded);
d_tags.contains(&d_decoded)
Some(percent_decode(d_encoded))
}
/// Case-insensitive equivalent of `str::strip_prefix` for ASCII prefixes.
@@ -290,6 +344,29 @@ mod tests {
assert!(event_qualifies_for_pr_relaxation(&event, &cfg));
}
#[test]
fn extracts_prs_identifiers_from_clone_tags() {
let signer = Keys::generate();
let target_hex = Keys::generate().public_key().to_hex();
let npub = signer.public_key().to_bech32().unwrap();
let event = pr_event(
&signer,
&[(&target_hex, "my-repo"), (&target_hex, "other-repo")],
&[
&format!("http://relay.example/prs/{}/my-repo.git", npub),
&format!("https://relay.example/prs/{}/other-repo.git", npub),
&format!("https://other.example/prs/{}/my-repo.git", npub),
],
Kind::GitPullRequest,
);
let identifiers = prs_identifiers_named_by_event_clone_tags(&event, "relay.example");
assert_eq!(
identifiers,
vec!["my-repo".to_string(), "other-repo".to_string()]
);
}
#[test]
fn rejects_other_kinds() {
let signer = Keys::generate();
+139
View File
@@ -1759,6 +1759,123 @@ impl Nip34WritePolicy {
Ok(restored_any)
}
fn pr_event_commit(event: &Event) -> Option<String> {
event.tags.iter().find_map(|tag| {
let parts = tag.as_slice();
if parts.len() >= 2 && parts[0] == "c" {
Some(parts[1].clone())
} else {
None
}
})
}
fn pr_event_identifiers(event: &Event) -> BTreeSet<String> {
event
.tags
.iter()
.filter_map(|tag| {
let parts = tag.as_slice();
if parts.len() < 2 || parts[0] != "a" {
return None;
}
let coord: Vec<&str> = parts[1].splitn(3, ':').collect();
if coord.len() != 3 || coord[0] != "30617" {
return None;
}
Some(coord[2].to_string())
})
.collect()
}
async fn pr_target_repo_paths_for_recovery(&self, event: &Event) -> BTreeSet<PathBuf> {
let mut paths = BTreeSet::new();
for identifier in Self::pr_event_identifiers(event) {
let Ok(repo_data) =
crate::git::authorization::fetch_repository_data_excluding_purgatory(
&self.ctx.database,
&identifier,
)
.await
else {
continue;
};
for announcement in &repo_data.announcements {
paths.insert(self.ctx.git_data_path.join(announcement.repo_path()));
}
}
for identifier in crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags(
event,
&self.ctx.config.domain,
) {
paths.insert(crate::grasp06::paths::prs_repo_path(
&self.ctx.git_data_path,
&event.pubkey.to_hex(),
&identifier,
));
}
paths
}
async fn restore_pr_git_refs_for_recovered_event(&self, event: &Event) -> anyhow::Result<bool> {
if !matches!(
event.kind,
Kind::GitPullRequest | Kind::GitPullRequestUpdate
) {
return Ok(true);
}
let Some(commit) = Self::pr_event_commit(event) else {
tracing::warn!(
event_id = %event.id.to_hex(),
"Recovery could not restore PR git ref: event missing c-tag"
);
return Ok(false);
};
let ref_name = format!("refs/nostr/{}", event.id.to_hex());
let mut restored_any = false;
let repo_paths = self.pr_target_repo_paths_for_recovery(event).await;
for repo_path in repo_paths {
if !repo_path.is_dir() {
continue;
}
if !crate::git::commit_exists(&repo_path, &commit) {
continue;
}
crate::git::update_ref(&repo_path, &ref_name, &commit).map_err(|e| {
anyhow::anyhow!(
"failed to restore PR ref {} -> {} in {}: {}",
ref_name,
commit,
repo_path.display(),
e
)
})?;
restored_any = true;
}
if !restored_any {
tracing::warn!(
event_id = %event.id.to_hex(),
ref_name = %ref_name,
commit = %commit,
"Recovery did not find a repository with commit needed to restore PR ref"
);
}
Ok(restored_any)
}
async fn restore_events_from_holding(
&self,
records: &[RecoveryMetadataRecord],
@@ -1814,6 +1931,28 @@ impl Nip34WritePolicy {
}
}
if matches!(
event.kind,
Kind::GitPullRequest | Kind::GitPullRequestUpdate
) {
match self.restore_pr_git_refs_for_recovered_event(&event).await {
Ok(true) => {}
Ok(false) => {
blocked.push(record.clone());
continue;
}
Err(e) => {
tracing::error!(
error = %e,
event_id = %event_id.to_hex(),
"Recovery failed restoring PR git refs"
);
blocked.push(record.clone());
continue;
}
}
}
restorable.push(record.clone());
}
Ok(None) => {
+227 -1
View File
@@ -29,7 +29,7 @@
///
/// Author ownership is enforced before any main-DB deletion or tombstone
/// recording: only the original author may delete their event.
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs::File;
use std::path::PathBuf;
@@ -1227,6 +1227,25 @@ impl DeletionPolicy {
for event in matches {
let mut event_metadata = metadata.clone();
if matches!(
event.kind,
Kind::GitPullRequest | Kind::GitPullRequestUpdate
) {
if let Some((coord, owner_hex, identifier)) =
Self::pr_event_primary_announcement_scope(&event)
{
if event_metadata.coordinate.is_none() {
event_metadata.coordinate = Some(coord);
}
if event_metadata.identifier.is_none() {
event_metadata.identifier = Some(identifier);
}
if event_metadata.owner_pubkey.is_none() {
event_metadata.owner_pubkey = Some(owner_hex);
}
}
}
if event.kind == Kind::GitRepoAnnouncement {
let owner_hex = event.pubkey.to_hex();
let owner_dir = owner_directory_component(&event.pubkey);
@@ -1272,6 +1291,13 @@ impl DeletionPolicy {
);
continue;
}
if matches!(
event.kind,
Kind::GitPullRequest | Kind::GitPullRequestUpdate
) {
self.delete_pr_event_git_refs(&event).await;
}
}
deletable.push(event.id);
}
@@ -1286,6 +1312,150 @@ impl DeletionPolicy {
}
}
fn pr_event_primary_announcement_scope(event: &Event) -> Option<(String, String, String)> {
for coordinate in Self::a_tag_coordinates(event) {
let Some((kind, owner, identifier)) = Self::parse_coordinate(&coordinate) else {
continue;
};
if kind != Kind::GitRepoAnnouncement {
continue;
}
return Some((
format!("30617:{}:{}", owner.to_hex(), identifier),
owner.to_hex(),
identifier,
));
}
None
}
fn pr_identifiers_from_a_tags(event: &Event) -> BTreeSet<String> {
let mut identifiers = BTreeSet::new();
for coordinate in Self::a_tag_coordinates(event) {
let Some((kind, _, identifier)) = Self::parse_coordinate(&coordinate) else {
continue;
};
if kind == Kind::GitRepoAnnouncement {
identifiers.insert(identifier);
}
}
identifiers
}
async fn collect_normal_repo_paths_for_pr_event(&self, event: &Event) -> BTreeSet<PathBuf> {
let mut paths = BTreeSet::new();
for coordinate in Self::a_tag_coordinates(event) {
let Some((kind, owner, identifier)) = Self::parse_coordinate(&coordinate) else {
continue;
};
if kind != Kind::GitRepoAnnouncement {
continue;
}
paths.insert(self.repo_path_for_owner_and_identifier(
&owner_directory_component(&owner),
&identifier,
));
}
for identifier in Self::pr_identifiers_from_a_tags(event) {
let Ok(repo_data) =
fetch_repository_data_excluding_purgatory(&self.ctx.database, &identifier).await
else {
continue;
};
for announcement in &repo_data.announcements {
paths.insert(self.ctx.git_data_path.join(announcement.repo_path()));
}
}
paths
}
fn collect_grasp06_repo_paths_for_pr_event(&self, event: &Event) -> BTreeSet<PathBuf> {
crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags(
event,
&self.ctx.config.domain,
)
.into_iter()
.map(|identifier| {
crate::grasp06::paths::prs_repo_path(
&self.ctx.git_data_path,
&event.pubkey.to_hex(),
&identifier,
)
})
.collect()
}
fn cleanup_zero_ref_grasp06_repo_if_idle(&self, repo_path: &PathBuf) {
if !crate::grasp06::paths::is_prs_repo_path(repo_path, &self.ctx.git_data_path) {
return;
}
let state = crate::grasp06::receive::path_state(&self.ctx.repo_init_locks, repo_path);
let _guard = state.mu.lock().expect("prs path mutex poisoned");
if state.in_flight.load(std::sync::atomic::Ordering::Relaxed) != 0 {
return;
}
let is_zero_ref = matches!(git::list_refs(repo_path), Ok(refs) if refs.is_empty());
if !is_zero_ref {
return;
}
if let Err(e) = std::fs::remove_dir_all(repo_path) {
tracing::warn!(
repo = %repo_path.display(),
error = %e,
"Failed to remove zero-ref /prs/ repo while processing deletion request"
);
return;
}
if let Some(parent) = repo_path.parent() {
let _ = std::fs::remove_dir(parent);
}
}
async fn delete_pr_event_git_refs(&self, event: &Event) {
let ref_name = format!("refs/nostr/{}", event.id.to_hex());
let mut repo_paths = self.collect_normal_repo_paths_for_pr_event(event).await;
repo_paths.extend(self.collect_grasp06_repo_paths_for_pr_event(event));
for repo_path in repo_paths {
if !repo_path.is_dir() {
continue;
}
match git::delete_ref(&repo_path, &ref_name) {
Ok(()) => {
tracing::info!(
event_id = %event.id.to_hex(),
repo = %repo_path.display(),
ref_name = %ref_name,
"Deleted PR git ref for deletion-requested event"
);
self.cleanup_zero_ref_grasp06_repo_if_idle(&repo_path);
}
Err(e) => {
tracing::debug!(
event_id = %event.id.to_hex(),
repo = %repo_path.display(),
ref_name = %ref_name,
error = %e,
"PR git ref not deleted for this repository"
);
}
}
}
}
fn archive_output_path(
&self,
owner_path_component: &str,
@@ -1980,4 +2150,60 @@ mod tests {
"Disrespector mode must NOT delete the target from the main DB"
);
}
#[test]
fn test_grasp06_pr_clone_tags_map_to_prs_repo_paths() {
let db = Arc::new(nostr_memory::MemoryDatabase::unbounded());
let purgatory = Arc::new(Purgatory::new(PathBuf::new()));
let git_data_path = PathBuf::from("/tmp/ngit-grasp-test-git");
let config = crate::config::Config {
domain: "test.example.com".to_string(),
..crate::config::Config::for_testing()
};
let ctx = PolicyContext::new_for_test(
"test.example.com",
db,
git_data_path.clone(),
purgatory,
config,
);
let signer = Keys::generate();
let target = Keys::generate();
let npub = signer.public_key().to_bech32().expect("pubkey to bech32");
let event = EventBuilder::new(Kind::GitPullRequest, "prs clone test")
.tags(vec![
Tag::custom(
"a",
vec![format!(
"30617:{}:{}",
target.public_key().to_hex(),
"my-repo"
)],
),
Tag::custom(
"clone",
vec![format!(
"http://test.example.com/prs/{}/{}.git",
npub, "my-repo"
)],
),
Tag::custom("c", vec!["0".repeat(40)]),
])
.finalize(&signer)
.expect("build pr event");
let policy = DeletionPolicy::new(ctx.clone());
let paths = policy.collect_grasp06_repo_paths_for_pr_event(&event);
let expected = crate::grasp06::paths::prs_repo_path(
&git_data_path,
&signer.public_key().to_hex(),
"my-repo",
);
assert!(
paths.contains(&expected),
"expected /prs/ repo path derived from clone tag"
);
}
}
+37
View File
@@ -14,8 +14,24 @@ use common::{
use grasp_audit::{clone_repo, AuditClient, AuditConfig, DETERMINISTIC_COMMIT_HASH};
use nostr_sdk::prelude::*;
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
fn repo_has_ref(repo_path: &Path, ref_name: &str) -> bool {
let output = Command::new("git")
.args([
"--git-dir",
repo_path.to_str().expect("repo path utf8"),
"show-ref",
"--verify",
ref_name,
])
.output()
.expect("run git show-ref");
output.status.success()
}
#[tokio::test]
async fn test_announcement_cascade_deletes_supported_dependent_kinds() {
let relay = TestRelay::start().await;
@@ -176,6 +192,19 @@ async fn test_announcement_cascade_deletes_served_pr_chain_kinds() {
.expect("push PR update ref should succeed");
fs::remove_dir_all(&clone_path).expect("remove cloned repo");
let repo_path = relay
.git_data_path()
.join(client.public_key().to_bech32().expect("pubkey to bech32"))
.join(format!("{}.git", repo_id));
assert!(
repo_has_ref(&repo_path, &pr_ref),
"PR ref should exist before deletion"
);
assert!(
repo_has_ref(&repo_path, &pr_update_ref),
"PR update ref should exist before deletion"
);
let pr_status = client
.event_builder(Kind::from(1631), "pr status")
.tag(Tag::custom("e", vec![pr.id.to_hex()]))
@@ -239,6 +268,14 @@ async fn test_announcement_cascade_deletes_served_pr_chain_kinds() {
survivors.is_empty(),
"announcement deletion should cascade across PR-chain dependent kinds; survivors: {survivors:?}"
);
assert!(
!repo_has_ref(&repo_path, &pr_ref),
"announcement deletion should remove PR git ref"
);
assert!(
!repo_has_ref(&repo_path, &pr_update_ref),
"announcement deletion should remove PR update git ref"
);
}
#[tokio::test]