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
This commit is contained in:
DanConwayDev
2026-09-29 09:16:41 +00:00
parent dcf36a2210
commit c1255eac48
4 changed files with 116 additions and 10 deletions
+4
View File
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### 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/<event-id>` ref.
- Remove abandoned normal-endpoint PR refs when their pending event expires, - Remove abandoned normal-endpoint PR refs when their pending event expires,
retaining exact repository scopes across restarts and retrying failed cleanup. retaining exact repository scopes across restarts and retrying failed cleanup.
+43 -10
View File
@@ -65,16 +65,49 @@ impl PrEventPolicy {
} }
}; };
// Check for placeholder first (git-data-first scenario) // A crash may happen after Git installs the ref but before the
if let Some(placeholder_commit) = self.ctx.purgatory.find_pr_placeholder(&event_id) { // purgatory checkpoint. Recover the /prs/ scope only from a signed,
// Read the full entry so we can inspect any GRASP-06 scope // service-local clone URL for this signer and an exact event-id/OID ref.
// recorded when the placeholder was created from a /prs/ push. let mut placeholder = self
let prs_scope = self .ctx
.ctx .purgatory
.purgatory .find_pr_placeholder(&event_id)
.find_pr(&event_id) .map(|commit| {
.and_then(|entry| entry.prs_scope); 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 { if let Some(scope) = prs_scope {
// The placeholder was created by a /prs/<submitter>/<id>.git // The placeholder was created by a /prs/<submitter>/<id>.git
// push (06.md line 12). The arriving event MUST be signed by // push (06.md line 12). The arriving event MUST be signed by
+18
View File
@@ -451,6 +451,24 @@ impl TestRelay {
.await .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. /// Start a relay on a port that the caller has already reserved.
/// ///
/// Use this when the test needs the port number *before* the relay /// Use this when the test needs the port number *before* the relay
+51
View File
@@ -369,3 +369,54 @@ isolated_test_with_grasp_06!(
test_commit_mismatch_deletes_ref_and_blocks_promotion_with_grasp_06, test_commit_mismatch_deletes_ref_and_blocks_promotion_with_grasp_06,
PushValidationTests::test_commit_mismatch_deletes_ref_and_blocks_promotion 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;
}