From 3945a192b78e29bec2e93baf42fe2b4d81c655b3 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 18 Jun 2026 20:25:26 +0000 Subject: [PATCH] fix(deletion): remove and recover PR git refs --- src/grasp06/policy.rs | 105 +++++++++++-- src/nostr/builder.rs | 139 ++++++++++++++++++ src/nostr/policy/deletion.rs | 228 ++++++++++++++++++++++++++++- tests/nip09_cascade_event_types.rs | 37 +++++ 4 files changed, 494 insertions(+), 15 deletions(-) diff --git a/src/grasp06/policy.rs b/src/grasp06/policy.rs index 996a747..5382cb1 100644 --- a/src/grasp06/policy.rs +++ b/src/grasp06/policy.rs @@ -79,6 +79,48 @@ pub fn event_names_relays_prs_endpoint(event: &Event, domain: &str) -> bool { false } +/// Return all distinct `` identifiers named by this event's `clone` tags +/// that target this relay's `/prs//.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 { + if !matches!( + event.kind, + Kind::GitPullRequest | Kind::GitPullRequestUpdate + ) { + return Vec::new(); + } + + let d_tags: std::collections::HashSet = + 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 `` from every well-formed `a` tag of the form /// `30617:<64-hex-pubkey>:` 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//.git` endpoint and +/// return the decoded `` when it matches. +fn clone_url_prs_identifier_for_relay( + url: &str, + domain: &str, + signer: &PublicKey, +) -> Option { // 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: `` and `.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(); diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 96b63f8..c6b4f11 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -1759,6 +1759,123 @@ impl Nip34WritePolicy { Ok(restored_any) } + fn pr_event_commit(event: &Event) -> Option { + 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 { + 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 { + 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 { + 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) => { diff --git a/src/nostr/policy/deletion.rs b/src/nostr/policy/deletion.rs index e9b3d0c..27f3b0f 100644 --- a/src/nostr/policy/deletion.rs +++ b/src/nostr/policy/deletion.rs @@ -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 { + 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 { + 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 { + 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" + ); + } } diff --git a/tests/nip09_cascade_event_types.rs b/tests/nip09_cascade_event_types.rs index 42a74db..7c6cbc8 100644 --- a/tests/nip09_cascade_event_types.rs +++ b/tests/nip09_cascade_event_types.rs @@ -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]