diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e0fa4..c2b1bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Accept a GRASP-06 `/prs/` PR event whose pushed ref survived a crash that + lost its purgatory placeholder, matching it by the event's service-local + clone URL and exact `refs/nostr/` ref. + - Remove abandoned normal-endpoint PR refs when their pending event expires, retaining exact repository scopes across restarts and retrying failed cleanup. diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs index 449b85d..ec9e07d 100644 --- a/src/nostr/policy/pr_event.rs +++ b/src/nostr/policy/pr_event.rs @@ -65,16 +65,49 @@ impl PrEventPolicy { } }; - // Check for placeholder first (git-data-first scenario) - if let Some(placeholder_commit) = self.ctx.purgatory.find_pr_placeholder(&event_id) { - // Read the full entry so we can inspect any GRASP-06 scope - // recorded when the placeholder was created from a /prs/ push. - let prs_scope = self - .ctx - .purgatory - .find_pr(&event_id) - .and_then(|entry| entry.prs_scope); - + // A crash may happen after Git installs the ref but before the + // purgatory checkpoint. Recover the /prs/ scope only from a signed, + // service-local clone URL for this signer and an exact event-id/OID ref. + let mut placeholder = self + .ctx + .purgatory + .find_pr_placeholder(&event_id) + .map(|commit| { + let scope = self + .ctx + .purgatory + .find_pr(&event_id) + .and_then(|entry| entry.prs_scope); + (commit, scope) + }); + if placeholder.is_none() && self.ctx.config.grasp06_enable { + for identifier in crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags( + event, + &self.ctx.config.service_address(), + ) { + if !git::validate_repository_identifier(&identifier) { + continue; + } + let path = crate::grasp06::paths::prs_repo_path( + &self.ctx.git_data_path, + &event.pubkey.to_hex(), + &identifier, + ); + if git::get_ref_commit(&path, &format!("refs/nostr/{event_id}")).as_deref() + == Some(commit.as_str()) + { + placeholder = Some(( + commit.clone(), + Some(crate::purgatory::PrsPlaceholderScope { + submitter: event.pubkey, + identifier, + }), + )); + break; + } + } + } + if let Some((placeholder_commit, prs_scope)) = placeholder { if let Some(scope) = prs_scope { // The placeholder was created by a /prs//.git // push (06.md line 12). The arriving event MUST be signed by diff --git a/tests/common/relay.rs b/tests/common/relay.rs index 0bc517a..3a24c4c 100644 --- a/tests/common/relay.rs +++ b/tests/common/relay.rs @@ -451,6 +451,24 @@ impl TestRelay { .await } + /// Persistent GRASP-06 storage for crash/restart tests. + pub async fn start_with_grasp_06_paths( + git_data_path: PathBuf, + relay_data_path: PathBuf, + ) -> Self { + Self::start_internal( + port::reserve_port(), + RelayOptions { + grasp06_enable: true, + lmdb_backend: true, + git_data_path: Some(git_data_path), + relay_data_path: Some(relay_data_path), + ..RelayOptions::default() + }, + ) + .await + } + /// Start a relay on a port that the caller has already reserved. /// /// Use this when the test needs the port number *before* the relay diff --git a/tests/grasp06_pr_hosting.rs b/tests/grasp06_pr_hosting.rs index 4f71fb0..d9c758b 100644 --- a/tests/grasp06_pr_hosting.rs +++ b/tests/grasp06_pr_hosting.rs @@ -369,3 +369,54 @@ isolated_test_with_grasp_06!( test_commit_mismatch_deletes_ref_and_blocks_promotion_with_grasp_06, PushValidationTests::test_commit_mismatch_deletes_ref_and_blocks_promotion ); + +/// A crash can lose the purgatory checkpoint after a `/prs/` push installed +/// its ref. The arriving PR event must still be matched to that push, using +/// only its signed, service-local clone URL and the exact event-id ref. +#[tokio::test] +async fn pr_event_after_crash_recovers_prs_placeholder_scope() { + use nostr_sdk::prelude::*; + let persistent = tempfile::tempdir().unwrap(); + let relay = TestRelay::start_with_grasp_06_paths( + persistent.path().join("git"), + persistent.path().join("relay"), + ) + .await; + let client = AuditClient::new(relay.url(), AuditConfig::isolated()) + .await + .unwrap(); + let keys = client.keys().clone(); + let npub = keys.public_key().to_bech32().unwrap(); + let identifier = "crash-recovered-pr"; + let local = tempfile::tempdir().unwrap(); + let commit = + common::create_test_repo_with_commit(local.path(), common::CommitVariant::PrTest).unwrap(); + let clone_url = format!("http://{}/prs/{npub}/{identifier}.git", relay.domain()); + let event = common::create_pr_event_with_clone( + &keys, + &format!("30617:{}:{identifier}", keys.public_key().to_hex()), + &commit, + "Crash-recovered PR", + &[&clone_url], + ) + .unwrap(); + common::push_ref_to_relay( + local.path(), + &relay.domain(), + &format!("prs/{npub}"), + identifier, + &commit, + &format!("refs/nostr/{}", event.id), + ) + .unwrap(); + // Killing the relay loses any placeholder not yet checkpointed. + let relay = relay.restart().await; + let client = AuditClient::new(relay.url(), AuditConfig::isolated()) + .await + .unwrap(); + client.send_event(event.clone()).await.unwrap(); + common::wait_for_event_served(relay.url(), &event.id, std::time::Duration::from_secs(10)) + .await + .unwrap(); + relay.stop().await; +}