mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
+220
-24
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user