From c1255eac488264b6ae38d25423bb43c085575a63 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 29 Sep 2026 09:16:41 +0000 Subject: [PATCH] fix(grasp06): recover placeholder scope from a surviving ref A crash can leave a /prs/ ref installed without its purgatory checkpoint. Recover the scope from the signed event's service-local clone URL, signer, identifier, and exact event-id ref and commit. This lets git-first PR events complete after restart without relaxing URL or commit validation. Assume the signed local URL names the intended contributor repository. Standard endpoint recovery and object staging are separate changes. Validation: nix develop -c cargo test --test grasp06_pr_hosting pr_event_after_crash_recovers_prs_placeholder_scope passed. Assisted-by: GPT-6 --- CHANGELOG.md | 4 +++ src/nostr/policy/pr_event.rs | 53 +++++++++++++++++++++++++++++------- tests/common/relay.rs | 18 ++++++++++++ tests/grasp06_pr_hosting.rs | 51 ++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 10 deletions(-) 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; +}