feat(grasp06): scope /prs/ placeholders and mirror released PRs into announced repos

Two related changes that together turn the cross-service mirror test
green.

Placeholder scoping (security pre-req):

A push to /prs/<submitter>/<id>.git previously created an unscoped PR
placeholder keyed only on the ref name's event-id. That let an
attacker push refs/nostr/<event-id> under their own /prs/ namespace
and have it later 'claimed' by an unrelated event of the same id
published elsewhere on the relay.

PrPurgatoryEntry now carries an optional PrsPlaceholderScope
{ submitter, identifier } recording the URL the push landed on
(#[serde(default)] keeps on-disk state from older binaries
deserialisable). The /prs/ receive-pack handler uses a new
Purgatory::add_prs_pr_placeholder so scope-on-create is the only
way to make a /prs/ placeholder; add_pr_placeholder (standard
endpoint) is untouched. When the matching event arrives,
git_data_check in the PR policy reads the full entry via find_pr,
and if prs_scope is Some it cross-checks event.pubkey == submitter,
one of the event's a-tag d-tags == identifier, and commit == placeholder
commit. Any mismatch deletes the /prs/ ref + placeholder and lets the
event continue through the normal acceptance flow (which may still
legitimately accept it via the relaxation or the standard path).

Cross-service mirror:

extract_identifier_from_repo_path now recognises both the standard
<npub>/<id>.git layout and the /prs/<submitter-hex>/<id>.git layout
(06.md line 14). Without this, process_newly_available_git_data
early-returned for /prs/ pushes and never released the matching PR
event from purgatory — the root cause the failing test surfaced.
extract_owner_from_repo_path now returns None for /prs/ paths
instead of the literal 'prs'; call sites already cope with None
(unwrap_or_default or match-bail).

process_purgatory_pr_events now returns both the ProcessResult and
the list of (event, commit) pairs it actually released. When the
source repo is under prs_base_path, process_newly_available_git_data
iterates the released list and calls a new
mirror_prs_pr_to_announced_repos, which for each a-tag of the form
30617:<hex>:<d> looks up the announcement in the DB (purgatory
excluded), confirms the target repo exists on disk, fetches the
commit if missing, and writes refs/nostr/<event-id>. Absent
announcement or absent target repo → no mirror; the PR stays
fetchable via its clone tag at /prs/. The branch is gated on
is_prs_repo_path so the standard endpoint can never write into
/prs/* — the reverse-mirror invariant guarded by
test_standard_push_does_not_mirror_to_prs_with_grasp_06 stays intact.

copy_single_commit_between_repos promoted to pub(crate) so the new
helper can reuse it.

Back-fill on a later announcement (announcement accepted after a /prs/
push) is intentionally not handled; the design doc lists it as a
follow-up.

Test that now passes:
  - test_prs_push_mirrors_to_announced_repo_with_grasp_06

Tests that stay green (regression guards):
  - test_standard_push_does_not_mirror_to_prs_with_grasp_06
  - test_prs_push_refs_nostr_event_id_accepted_with_grasp_06
  - test_pr_event_accepted_when_clone_tag_names_prs_endpoint_with_grasp_06
  - test_pr_event_accepted_via_relaxation_is_held_in_purgatory_with_grasp_06
  - test_pr_event_rejected_when_clone_tag_does_not_name_prs_endpoint_with_grasp_06
  - the remaining grasp06_pr_hosting suite (45/45)
  - full cargo test (no regressions in sync / purgatory hot paths)

No new config options; four-place config-sync contract unaffected.
This commit is contained in:
DanConwayDev
2026-05-15 17:03:47 +00:00
parent d7799d452c
commit efdb7abf96
5 changed files with 396 additions and 35 deletions
+220 -24
View File
@@ -322,7 +322,7 @@ pub fn sync_pr_refs_to_tagged_owner_repos(
}
/// Copy a single commit from source repository to target repository
fn copy_single_commit_between_repos(
pub(crate) fn copy_single_commit_between_repos(
source_repo: &Path,
target_repo: &Path,
commit_hash: &str,
@@ -734,26 +734,38 @@ pub fn align_repository_with_state(repo_path: &Path, state: &RepositoryState) ->
/// Extract repository identifier from a repository path.
///
/// Given a path like `{git_data_path}/{npub}/{identifier}.git`, extracts the identifier.
/// Supports two on-disk layouts:
///
/// - Standard endpoint: `{git_data_path}/{npub}/{identifier}.git` — two
/// components after `git_data_path`. The identifier is the last
/// component minus the `.git` suffix.
/// - GRASP-06 `/prs/` endpoint (06.md line 14): the `/prs/`
/// contributor-submission subtree at
/// `{git_data_path}/prs/{submitter-hex}/{identifier}.git` — three
/// components, where the leading `prs` is the namespace marker (see
/// [`crate::grasp06::paths`]). The identifier is again the last
/// component minus `.git`.
///
/// Any other shape returns `None`.
///
/// # Arguments
/// * `repo_path` - Full path to the git repository
/// * `git_data_path` - Base path for git repositories
///
/// # Returns
/// The identifier if the path matches the expected pattern, None otherwise
pub fn extract_identifier_from_repo_path(repo_path: &Path, git_data_path: &Path) -> Option<String> {
// Get the relative path from git_data_path
let relative = repo_path.strip_prefix(git_data_path).ok()?;
// Expected structure: {npub}/{identifier}.git
let components: Vec<_> = relative.components().collect();
if components.len() != 2 {
return None;
}
// Get the repo directory name (e.g., "my-repo.git")
let repo_name = components[1].as_os_str().to_str()?;
// Standard layout: <npub>/<id>.git
// /prs/ layout (06.md): prs/<submitter-hex>/<id>.git
let repo_name = match components.len() {
2 => components[1].as_os_str().to_str()?,
3 if components[0].as_os_str().to_str() == Some(crate::grasp06::paths::PRS_DISK_PREFIX) => {
components[2].as_os_str().to_str()?
}
_ => return None,
};
// Strip the .git suffix
repo_name.strip_suffix(".git").map(|s| s.to_string())
@@ -872,7 +884,7 @@ pub async fn process_newly_available_git_data(
result.merge(state_result);
// Process PR events from purgatory
let pr_result = process_purgatory_pr_events(
let pr_outcome = process_purgatory_pr_events(
&identifier,
source_repo_path,
database,
@@ -881,7 +893,30 @@ pub async fn process_newly_available_git_data(
git_data_path,
)
.await;
result.merge(pr_result);
result.merge(pr_outcome.result);
// Cross-service mirror (design doc:
// docs/explanation/grasp-06-contributor-pr-submission.md, "Cross-service
// mirror" section). When the source repo lives under the `/prs/`
// subtree (06.md line 14), refs released from purgatory by this push
// must be copied into any matching announced repo on this relay so the
// PR is fetchable at the maintainer's canonical URL. The reverse
// direction is NOT supported — a push to `<owner>/<id>.git` must never
// write under `/prs/*`, so this branch is gated on the `/prs/` source
// check.
if crate::grasp06::paths::is_prs_repo_path(source_repo_path, git_data_path) {
for (event, commit) in &pr_outcome.released {
mirror_prs_pr_to_announced_repos(
source_repo_path,
event,
commit,
database,
git_data_path,
&mut result,
)
.await;
}
}
if result.released_any() {
info!(
@@ -1206,6 +1241,21 @@ pub fn is_latest_authorized_state_public(
is_latest_authorized_state(state, maintainers, db_states)
}
/// Result of `process_purgatory_pr_events` — counts plus the list of
/// (event, commit) pairs that were actually released from purgatory.
///
/// The released list is consumed by the cross-service mirror branch in
/// [`process_newly_available_git_data`] so it can copy refs from a `/prs/`
/// source into matching announced repos. Standard-endpoint callers can
/// ignore the released list.
struct PrProcessingOutcome {
result: ProcessResult,
/// Events that were saved to the DB and removed from purgatory in this
/// call, paired with their `c`-tag commit. Other entries (still
/// waiting, errored, or placeholders) are not included.
released: Vec<(Event, String)>,
}
/// Process PR events from purgatory that can now be satisfied.
async fn process_purgatory_pr_events(
identifier: &str,
@@ -1214,13 +1264,14 @@ async fn process_purgatory_pr_events(
local_relay: Option<&nostr_relay_builder::LocalRelay>,
purgatory: &Purgatory,
git_data_path: &Path,
) -> ProcessResult {
) -> PrProcessingOutcome {
let mut result = ProcessResult::default();
let mut released: Vec<(Event, String)> = Vec::new();
// Find PR events in purgatory for this identifier
let purgatory_prs = purgatory.find_prs_for_identifier(identifier);
if purgatory_prs.is_empty() {
return result;
return PrProcessingOutcome { result, released };
}
debug!(
@@ -1244,7 +1295,7 @@ async fn process_purgatory_pr_events(
result
.errors
.push(format!("Failed to fetch repo data: {}", e));
return result;
return PrProcessingOutcome { result, released };
}
};
@@ -1266,7 +1317,10 @@ async fn process_purgatory_pr_events(
continue;
}
// Extract owner pubkey
// Extract owner pubkey. For `/prs/` sources this returns None — the
// submitter in the URL is the contributor, not a maintainer of any
// announced repo. We pass an empty string so the tagged-owner sync
// loop doesn't accidentally skip a real owner.
let owner_pubkey =
extract_owner_from_repo_path(source_repo_path, git_data_path).unwrap_or_default();
@@ -1308,6 +1362,7 @@ async fn process_purgatory_pr_events(
let event_id_hex = event.id.to_hex();
purgatory.remove_pr(&event_id_hex);
result.prs_released += 1;
released.push((event.clone(), entry.commit.clone()));
info!(
identifier = %identifier,
@@ -1329,7 +1384,7 @@ async fn process_purgatory_pr_events(
}
}
result
PrProcessingOutcome { result, released }
}
/// Process announcements from purgatory that can now be promoted.
@@ -1522,17 +1577,158 @@ async fn process_purgatory_announcements(
result
}
/// Extract owner pubkey from a repository path.
/// Mirror a PR event's `refs/nostr/<event-id>` (and reachable objects)
/// from a GRASP-06 `/prs/` source repo into every announced repo on this
/// relay whose coord appears in the event's `a` tags.
///
/// Given a path like `{git_data_path}/{npub}/{identifier}.git`, extracts the npub.
/// Triggered only by [`process_newly_available_git_data`] when the source
/// repo lives under [`crate::grasp06::paths::prs_base_path`]. The
/// destination repo must (a) exist on disk and (b) have an accepted
/// announcement in the database for that coord. Absent either, nothing
/// is mirrored — the PR stays fetchable via the `clone` tag at `/prs/`.
/// See the design doc `docs/explanation/grasp-06-contributor-pr-submission.md`,
/// "Cross-service mirror" section.
///
/// Back-fill on a later announcement (i.e. an announcement accepted
/// *after* a `/prs/` push) is intentionally not handled here; the design
/// doc lists it as a follow-up.
async fn mirror_prs_pr_to_announced_repos(
source_repo_path: &Path,
event: &Event,
commit: &str,
database: &SharedDatabase,
git_data_path: &Path,
result: &mut ProcessResult,
) {
let event_id = event.id.to_hex();
let ref_name = format!("refs/nostr/{}", event_id);
for tag in event.tags.iter() {
let tag_vec = tag.clone().to_vec();
if tag_vec.len() < 2 || tag_vec[0] != "a" || !tag_vec[1].starts_with("30617:") {
continue;
}
let parts: Vec<&str> = tag_vec[1].splitn(3, ':').collect();
if parts.len() != 3 {
continue;
}
let maintainer_hex = parts[1];
let identifier = parts[2];
let maintainer = match nostr_sdk::PublicKey::parse(maintainer_hex) {
Ok(pk) => pk,
Err(e) => {
debug!(
event_id = %event_id,
a_tag = %tag_vec[1],
error = %e,
"Skipping a-tag for /prs/ mirror — maintainer pubkey unparseable"
);
continue;
}
};
// Require an accepted announcement (DB, not purgatory) at this
// coord. Without it we have no authoritative repo to mirror into.
let db_data = match fetch_repository_data_excluding_purgatory(database, identifier).await {
Ok(d) => d,
Err(e) => {
debug!(
event_id = %event_id,
identifier = %identifier,
error = %e,
"Skipping /prs/ mirror coord — failed to fetch announcement data"
);
continue;
}
};
let announcement = match db_data
.announcements
.iter()
.find(|a| a.event.pubkey == maintainer)
{
Some(a) => a,
None => {
debug!(
event_id = %event_id,
identifier = %identifier,
maintainer = %maintainer.to_hex(),
"No accepted announcement for /prs/ mirror coord — leaving PR at /prs/ only"
);
continue;
}
};
let target_repo_path = git_data_path.join(announcement.repo_path());
if !target_repo_path.exists() {
debug!(
event_id = %event_id,
target = %target_repo_path.display(),
"Announced repo path missing on disk — skipping /prs/ mirror"
);
continue;
}
if !oid_exists(&target_repo_path, commit) {
if let Err(e) =
copy_single_commit_between_repos(source_repo_path, &target_repo_path, commit)
{
warn!(
event_id = %event_id,
source = %source_repo_path.display(),
target = %target_repo_path.display(),
error = %e,
"Failed to copy PR commit from /prs/ to announced repo"
);
result.errors.push(e);
continue;
}
}
match git::update_ref(&target_repo_path, &ref_name, commit) {
Ok(()) => {
info!(
event_id = %event_id,
commit = %commit,
target = %target_repo_path.display(),
"Mirrored /prs/ PR ref into announced repo"
);
result.repos_synced += 1;
result.refs_created += 1;
}
Err(e) => {
warn!(
event_id = %event_id,
target = %target_repo_path.display(),
error = %e,
"Failed to write mirrored /prs/ PR ref into announced repo"
);
result.errors.push(e);
}
}
}
}
///
/// For the standard endpoint layout (`{git_data_path}/{npub}/{identifier}.git`)
/// this returns the npub (as a string).
///
/// For the GRASP-06 `/prs/` layout
/// (`{git_data_path}/prs/{submitter-hex}/{identifier}.git`) there is no
/// "owner" in the announcement sense — the submitter is the contributor,
/// not a maintainer of the announced repo. `None` is returned so callers
/// can branch explicitly rather than treating the literal `"prs"` as a
/// pubkey.
pub fn extract_owner_from_repo_path(repo_path: &Path, git_data_path: &Path) -> Option<String> {
let relative = repo_path.strip_prefix(git_data_path).ok()?;
let components: Vec<_> = relative.components().collect();
if !components.is_empty() {
components[0].as_os_str().to_str().map(|s| s.to_string())
} else {
None
let first = components.first()?.as_os_str().to_str()?;
if first == crate::grasp06::paths::PRS_DISK_PREFIX {
// /prs/ has no owner-in-the-announcement-sense; callers must
// handle this case explicitly.
return None;
}
Some(first.to_string())
}
#[cfg(test)]
+13 -4
View File
@@ -457,11 +457,20 @@ async fn validate_pushed_nostr_ref(
}
// 4c. Neither DB nor purgatory know about this event. Register a
// placeholder so the standard sweep can clean the ref up if the
// corresponding PR event never arrives.
purgatory.add_pr_placeholder(event_id_hex.to_string(), pushed_commit.to_string());
// placeholder scoped to the URL's submitter + identifier so the
// event-side validator can reject mismatched events (and an
// attacker can't push to their own /prs/ namespace and have an
// unrelated event of the same id later "claim" the ref). The
// standard 30-minute sweep cleans the ref up if the corresponding
// PR event never arrives.
purgatory.add_prs_pr_placeholder(
event_id_hex.to_string(),
pushed_commit.to_string(),
prs.submitter,
prs.identifier.clone(),
);
debug!(
"/prs/ post-push: added PR placeholder for {} awaiting matching event",
"/prs/ post-push: added scoped PR placeholder for {} awaiting matching event",
ref_name
);
}
+89 -5
View File
@@ -57,24 +57,108 @@ impl PrEventPolicy {
// Check for placeholder first (git-data-first scenario)
if let Some(placeholder_commit) = self.ctx.purgatory.find_pr_placeholder(&event_id) {
if placeholder_commit == commit {
// Perfect match - git data arrived first with matching commit
// 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);
if let Some(scope) = prs_scope {
// The placeholder was created by a /prs/<submitter>/<id>.git
// push (06.md line 14). The arriving event MUST be signed by
// the URL submitter AND carry an `a` tag with d-tag equal to
// the URL identifier — otherwise this event was published by
// someone else and merely shares an id with a ref the
// submitter pre-staged. Discarding the placeholder + ref
// here is the security boundary; the event itself is left
// to continue through the normal acceptance flow (which may
// still legitimately accept it via the GRASP-06 relaxation
// in `src/nostr/builder.rs` or, if announced, the standard
// GRASP-01 path).
let event_d_tags: std::collections::HashSet<String> = event
.tags
.iter()
.filter_map(|tag| {
let tag_vec = tag.clone().to_vec();
if tag_vec.len() >= 2
&& tag_vec[0] == "a"
&& tag_vec[1].starts_with("30617:")
{
let parts: Vec<&str> = tag_vec[1].splitn(3, ':').collect();
if parts.len() == 3 {
return Some(parts[2].to_string());
}
}
None
})
.collect();
let signer_matches = event.pubkey == scope.submitter;
let identifier_matches = event_d_tags.contains(&scope.identifier);
let commit_matches = placeholder_commit == commit;
if !signer_matches || !identifier_matches || !commit_matches {
tracing::warn!(
event_id = %event_id,
signer_matches,
identifier_matches,
commit_matches,
submitter = %scope.submitter.to_hex(),
identifier = %scope.identifier,
"Discarding scoped /prs/ PR placeholder — incoming event does not match URL submitter + identifier",
);
// Delete the ref the original /prs/ push wrote.
let prs_repo = crate::grasp06::paths::prs_repo_path(
&self.ctx.git_data_path,
&scope.submitter.to_hex(),
&scope.identifier,
);
let ref_name = format!("refs/nostr/{}", event_id);
if prs_repo.exists() {
if let Err(e) = crate::git::delete_ref(&prs_repo, &ref_name) {
tracing::warn!(
event_id = %event_id,
repo = %prs_repo.display(),
error = %e,
"Failed to delete /prs/ ref while discarding scoped placeholder",
);
}
}
self.ctx.purgatory.remove_pr(&event_id);
// Fall through: this arriving event was NOT served by
// the placeholder. The standard processing below treats
// it like any other PR event (find_relevant_repo_paths,
// commit lookup, etc).
} else {
tracing::debug!(
"Found matching scoped /prs/ placeholder for PR event {} with commit {}",
event_id,
commit
);
self.ctx.purgatory.remove_pr(&event_id);
}
} else if placeholder_commit == commit {
// Standard endpoint placeholder, matching commit — original
// behaviour.
tracing::debug!(
"Found matching placeholder for PR event {} with commit {}",
event_id,
commit
);
// Remove placeholder - event processing will continue normally
self.ctx.purgatory.remove_pr(&event_id);
} else {
// Placeholder has different commit - incoming event supersedes
// Standard endpoint placeholder, mismatched commit — original
// behaviour: incoming event supersedes.
tracing::info!(
"PR event {} supersedes placeholder: event expects commit {}, placeholder has {}",
event_id,
commit,
placeholder_commit
);
// Remove incorrect placeholder
self.ctx.purgatory.remove_pr(&event_id);
// Delete incorrect git data (refs/nostr/<event-id>) will be handled below
}
+51 -2
View File
@@ -21,8 +21,8 @@ pub use helpers::{
get_unpushed_refs,
};
pub use types::{
AnnouncementPurgatoryEntry, EventSource, PrPurgatoryEntry, RefPair, RefUpdate,
StatePurgatoryEntry,
AnnouncementPurgatoryEntry, EventSource, PrPurgatoryEntry, PrsPlaceholderScope, RefPair,
RefUpdate, StatePurgatoryEntry,
};
use dashmap::DashMap;
@@ -94,6 +94,11 @@ struct SerializablePrPurgatoryEntry {
/// Source of this event (direct submission vs sync)
#[serde(default)]
source: types::EventSource,
/// GRASP-06 `/prs/` placeholder scope, if any. `#[serde(default)]`
/// keeps state files written before this field existed
/// deserialisable.
#[serde(default)]
prs_scope: Option<types::PrsPlaceholderScope>,
}
/// Serializable wrapper for `AnnouncementPurgatoryEntry` with time offsets.
@@ -424,6 +429,7 @@ impl Purgatory {
created_at: now,
expires_at: now + DEFAULT_EXPIRY,
source,
prs_scope: None,
};
self.pr_events.insert(event_id, entry);
@@ -451,6 +457,47 @@ impl Purgatory {
created_at: now,
expires_at: now + DEFAULT_EXPIRY,
source: types::EventSource::Direct, // Git pushes are direct user actions
prs_scope: None,
};
self.pr_events.insert(event_id, entry);
}
/// Add a PR placeholder created by a push to the GRASP-06 `/prs/`
/// endpoint (06.md line 14).
///
/// Behaves like [`Self::add_pr_placeholder`] but additionally records
/// the URL's submitter and identifier on the entry. When the
/// corresponding PR event later arrives, the validator (see
/// [`crate::nostr::policy::pr_event`]) MUST cross-check the event's
/// signer against `submitter` and one of the event's `a`-tag d-tags
/// against `identifier`. Without this binding an attacker could push
/// any `refs/nostr/<event-id>` under their own `/prs/` namespace and
/// have it later "validated" by an unrelated event of the same id.
///
/// # Arguments
/// * `event_id` - The expected event ID (from the pushed ref name)
/// * `commit` - The commit SHA that was pushed
/// * `submitter` - Pubkey from the `/prs/<npub>/...` URL segment
/// * `identifier` - Repository identifier from the URL (percent-decoded)
pub fn add_prs_pr_placeholder(
&self,
event_id: String,
commit: String,
submitter: PublicKey,
identifier: String,
) {
let now = Instant::now();
let entry = PrPurgatoryEntry {
event: None,
commit,
created_at: now,
expires_at: now + DEFAULT_EXPIRY,
source: types::EventSource::Direct,
prs_scope: Some(types::PrsPlaceholderScope {
submitter,
identifier,
}),
};
self.pr_events.insert(event_id, entry);
@@ -1414,6 +1461,7 @@ impl Purgatory {
created_at_offset_secs: created_offset.as_secs(),
expires_at_offset_secs: expires_offset.as_secs(),
source: e.source,
prs_scope: e.prs_scope.clone(),
};
pr_events.insert(event_id, serializable);
}
@@ -1583,6 +1631,7 @@ impl Purgatory {
created_at,
expires_at,
source: e.source,
prs_scope: e.prs_scope,
};
self.pr_events.insert(event_id, entry);
+23
View File
@@ -144,6 +144,29 @@ pub struct PrPurgatoryEntry {
/// Source of this event (direct submission vs sync)
#[serde(default)]
pub source: EventSource,
/// If set, this placeholder was created by a push to the GRASP-06
/// `/prs/<submitter>/<identifier>.git` endpoint (06.md line 14). When
/// the corresponding PR event arrives, its signer MUST equal
/// `submitter` and it MUST carry an `a` tag with d-tag equal to
/// `identifier`. Mismatches cause the pushed ref + placeholder to be
/// discarded so an attacker cannot land an arbitrary
/// `refs/nostr/<event-id>` under their own `/prs/` namespace and
/// later "claim" it with an unrelated event published elsewhere.
///
/// `None` for placeholders created via the standard endpoint.
#[serde(default)]
pub prs_scope: Option<PrsPlaceholderScope>,
}
/// Binding between a GRASP-06 `/prs/` placeholder and the submitter + repo
/// identifier from the URL the push landed on. See [`PrPurgatoryEntry::prs_scope`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrsPlaceholderScope {
/// Submitter pubkey from the `/prs/<npub>/...` URL segment.
pub submitter: PublicKey,
/// Repository identifier (the URL's `<d>` value, percent-decoded).
pub identifier: String,
}
/// Entry for a repository announcement (kind 30617) waiting in purgatory.