From fba89ba702c429ebe60d7f9c5380fd5c2b2a3338 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 19 Jun 2026 09:26:25 +0100 Subject: [PATCH] fix(recovery): prune tombstoned PR refs during restore --- src/nostr/builder.rs | 46 ++++++++++++++++++++++++++++- tests/nip09_recovery.rs | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index c6b4f11..420481d 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -1771,6 +1771,13 @@ impl Nip34WritePolicy { } fn pr_event_identifiers(event: &Event) -> BTreeSet { + Self::pr_event_announcement_scopes(event) + .into_iter() + .map(|(_, identifier)| identifier) + .collect() + } + + fn pr_event_announcement_scopes(event: &Event) -> BTreeSet<(PublicKey, String)> { event .tags .iter() @@ -1785,7 +1792,8 @@ impl Nip34WritePolicy { return None; } - Some(coord[2].to_string()) + let owner = PublicKey::from_hex(coord[1]).ok()?; + Some((owner, coord[2].to_string())) }) .collect() } @@ -1793,6 +1801,13 @@ impl Nip34WritePolicy { async fn pr_target_repo_paths_for_recovery(&self, event: &Event) -> BTreeSet { let mut paths = BTreeSet::new(); + for (owner, identifier) in Self::pr_event_announcement_scopes(event) { + paths.insert(self.repo_path_for_owner_and_identifier( + &Self::owner_directory_component(&owner), + &identifier, + )); + } + for identifier in Self::pr_event_identifiers(event) { let Ok(repo_data) = crate::git::authorization::fetch_repository_data_excluding_purgatory( @@ -1876,6 +1891,34 @@ impl Nip34WritePolicy { Ok(restored_any) } + async fn cleanup_pr_git_refs_for_tombstoned_event(&self, event: &Event) { + if !matches!( + event.kind, + Kind::GitPullRequest | Kind::GitPullRequestUpdate + ) { + return; + } + + let ref_name = format!("refs/nostr/{}", event.id.to_hex()); + 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 let Err(e) = crate::git::delete_ref(&repo_path, &ref_name) { + tracing::debug!( + event_id = %event.id.to_hex(), + repo = %repo_path.display(), + ref_name = %ref_name, + error = %e, + "Recovery tombstone cleanup: PR git ref not deleted for this repository" + ); + } + } + } + async fn restore_events_from_holding( &self, records: &[RecoveryMetadataRecord], @@ -1893,6 +1936,7 @@ impl Nip34WritePolicy { match self.ctx.holding.archived_event(&event_id).await { Ok(Some(event)) => { if self.recovery_blocked_by_tombstone(&event).await { + self.cleanup_pr_git_refs_for_tombstoned_event(&event).await; tracing::info!( event_id = %event.id.to_hex(), kind = event.kind.as_u16(), diff --git a/tests/nip09_recovery.rs b/tests/nip09_recovery.rs index 760d153..0c012b3 100644 --- a/tests/nip09_recovery.rs +++ b/tests/nip09_recovery.rs @@ -536,6 +536,70 @@ async fn purgatory_promotion_recovery_respects_state_tombstones() { fixture.relay.stop().await; } +#[tokio::test] +async fn purgatory_promotion_tombstoned_pr_events_do_not_restore_pr_refs() { + let fixture = + setup_parked_announcement_with_failed_ingest_recovery("promotion-recovery-pr-tombstones", false) + .await; + + fixture + .client + .send_event(build_deletion( + &fixture.client, + &[fixture.pr.id, fixture.pr_update.id], + &[], + )) + .await + .expect("submit PR tombstone deletion request before promotion"); + tokio::time::sleep(Duration::from_millis(300)).await; + + std::fs::write(&fixture.archive_path, &fixture.archive_backup) + .expect("restore archive bytes before PR tombstone promotion"); + + let promoted_state = + submit_new_state_and_push_for_promotion(&fixture, "promotion-pr-tombstone").await; + + assert!(fixture + .client + .is_event_on_relay(fixture.reannouncement.id) + .await + .expect("query promoted re-announcement")); + assert!(fixture + .client + .is_event_on_relay(promoted_state.id) + .await + .expect("query promoted new state")); + assert!( + !fixture + .client + .is_event_on_relay(fixture.pr.id) + .await + .expect("query tombstoned pr"), + "tombstoned PR must not resurrect during recovery" + ); + assert!( + !fixture + .client + .is_event_on_relay(fixture.pr_update.id) + .await + .expect("query tombstoned pr update"), + "tombstoned PR update must not resurrect during recovery" + ); + + let pr_ref = format!("refs/nostr/{}", fixture.pr.id.to_hex()); + let pr_update_ref = format!("refs/nostr/{}", fixture.pr_update.id.to_hex()); + assert!( + bare_ref_value(&fixture.repo_path, &pr_ref).is_none(), + "tombstoned PR should have refs/nostr entry cleaned during recovery" + ); + assert!( + bare_ref_value(&fixture.repo_path, &pr_update_ref).is_none(), + "tombstoned PR update should have refs/nostr entry cleaned during recovery" + ); + + fixture.relay.stop().await; +} + #[tokio::test] async fn repeated_purgatory_promotion_attempts_remain_idempotent() { let fixture = setup_parked_announcement_with_failed_ingest_recovery(