mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
548 lines
19 KiB
Rust
548 lines
19 KiB
Rust
//! Acceptance helpers for GRASP-06 PR / PR-Update events.
|
||
//!
|
||
//! Spec: <https://github.com/DanConwayDev/grasp/blob/main/06.md> (lines 19–22).
|
||
//!
|
||
//! Under GRASP-06 a relay MUST accept a kind 1618 (PR) or 1619 (PR Update)
|
||
//! event for a repository coordinate it has no accepted announcement for,
|
||
//! provided the event:
|
||
//!
|
||
//! 1. carries at least one `a` tag of the form `30617:<hex-pubkey>:<d-tag>`, AND
|
||
//! 2. carries at least one `clone` tag whose URL resolves to **this relay's**
|
||
//! `/prs/<signer-npub>/<d-tag>.git` endpoint, where
|
||
//! - `<signer-npub>` is the bech32 of the event's signer, and
|
||
//! - `<d-tag>` matches the `d` value of one of the event's `a` tags.
|
||
//!
|
||
//! All other matching is intentionally strict: the relaxation must not fire
|
||
//! for a foreign-host clone URL, a mismatched signer, or a `<d>` not named in
|
||
//! any `a` tag. The negative audit test
|
||
//! `pr_event_rejected_when_clone_tag_does_not_name_prs_endpoint` guards this.
|
||
//!
|
||
//! This file holds *only* the relaxation predicate plus the strict clone-URL
|
||
//! comparator it needs. Wiring into the write policy lives in
|
||
//! [`crate::nostr::builder`].
|
||
|
||
use nostr_sdk::prelude::*;
|
||
|
||
use crate::config::Config;
|
||
use crate::git::percent_decode;
|
||
use crate::grasp06::paths::PRS_URL_PREFIX;
|
||
|
||
/// Returns true when `event` qualifies for the GRASP-06 PR acceptance
|
||
/// relaxation: kind is 1618 or 1619, an `a` tag of the form
|
||
/// `30617:<hex>:<d>` is present, and a `clone` tag names this relay's
|
||
/// `/prs/<signer-npub>/<d>.git` endpoint with `<d>` matching one of the
|
||
/// event's `a` tag d values.
|
||
///
|
||
/// The function is pure: it consults `event` and `config` only and performs
|
||
/// no I/O. Returns `false` if `config.grasp06_enable` is off, so callers can
|
||
/// invoke it unconditionally.
|
||
pub fn event_qualifies_for_pr_relaxation(event: &Event, config: &Config) -> bool {
|
||
if !config.grasp06_enable {
|
||
return false;
|
||
}
|
||
|
||
event_names_relays_prs_endpoint(event, &config.domain)
|
||
}
|
||
|
||
/// Returns true when `event` is a PR/PR-Update event whose `clone` tag names
|
||
/// this relay's `/prs/<signer-npub>/<d>.git` endpoint (for a `<d>` listed in one
|
||
/// of the event's `a` tags).
|
||
///
|
||
/// Unlike [`event_qualifies_for_pr_relaxation`], this helper does **not** check
|
||
/// `grasp06_enable`; callers that need pure URL-based classification can use it
|
||
/// directly.
|
||
pub fn event_names_relays_prs_endpoint(event: &Event, domain: &str) -> bool {
|
||
if !matches!(
|
||
event.kind,
|
||
Kind::GitPullRequest | Kind::GitPullRequestUpdate
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
let d_tags = collect_a_tag_d_values(event);
|
||
if d_tags.is_empty() {
|
||
return false;
|
||
}
|
||
|
||
for tag in event.tags.iter() {
|
||
let parts = tag.clone().to_vec();
|
||
if parts.first().map(String::as_str) != Some("clone") {
|
||
continue;
|
||
}
|
||
for url in parts.iter().skip(1) {
|
||
if clone_url_names_relays_prs_endpoint(url, domain, &event.pubkey, &d_tags) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// Return all distinct `<d>` identifiers named by this event's `clone` tags
|
||
/// that target this relay's `/prs/<signer-npub>/<d>.git` endpoint and also
|
||
/// appear in the event's `a` tags.
|
||
///
|
||
/// This is used by deletion/recovery code paths to map a PR/PR-update event to
|
||
/// its scoped `/prs/` bare repository locations.
|
||
pub fn prs_identifiers_named_by_event_clone_tags(event: &Event, domain: &str) -> Vec<String> {
|
||
if !matches!(
|
||
event.kind,
|
||
Kind::GitPullRequest | Kind::GitPullRequestUpdate
|
||
) {
|
||
return Vec::new();
|
||
}
|
||
|
||
let d_tags: std::collections::HashSet<String> =
|
||
collect_a_tag_d_values(event).into_iter().collect();
|
||
if d_tags.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
let mut out = std::collections::BTreeSet::new();
|
||
for tag in event.tags.iter() {
|
||
let parts = tag.clone().to_vec();
|
||
if parts.first().map(String::as_str) != Some("clone") {
|
||
continue;
|
||
}
|
||
|
||
for url in parts.iter().skip(1) {
|
||
let Some(identifier) = clone_url_prs_identifier_for_relay(url, domain, &event.pubkey)
|
||
else {
|
||
continue;
|
||
};
|
||
|
||
if d_tags.contains(&identifier) {
|
||
out.insert(identifier);
|
||
}
|
||
}
|
||
}
|
||
|
||
out.into_iter().collect()
|
||
}
|
||
|
||
/// Extract `<d>` from every well-formed `a` tag of the form
|
||
/// `30617:<64-hex-pubkey>:<d>` on `event`. Malformed tags are silently
|
||
/// skipped — a malformed `a` tag must not crash, it just means the event
|
||
/// doesn't qualify on that tag.
|
||
fn collect_a_tag_d_values(event: &Event) -> Vec<String> {
|
||
let mut out = Vec::new();
|
||
for tag in event.tags.iter() {
|
||
let parts = tag.clone().to_vec();
|
||
if parts.len() < 2 || parts[0] != "a" {
|
||
continue;
|
||
}
|
||
// `splitn(3, ':')` preserves any `:` inside the d-tag.
|
||
let coord: Vec<&str> = parts[1].splitn(3, ':').collect();
|
||
if coord.len() != 3 {
|
||
continue;
|
||
}
|
||
if coord[0] != "30617" {
|
||
continue;
|
||
}
|
||
if coord[1].len() != 64 || !coord[1].chars().all(|c| c.is_ascii_hexdigit()) {
|
||
continue;
|
||
}
|
||
if coord[2].is_empty() {
|
||
continue;
|
||
}
|
||
out.push(coord[2].to_string());
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Strictly check whether `url` is this relay's
|
||
/// `/prs/<signer-npub>/<d>.git` endpoint, for some `<d>` in `d_tags`.
|
||
///
|
||
/// Requirements (any failure → `false`):
|
||
///
|
||
/// - scheme is `http` or `https` (case-insensitive),
|
||
/// - authority (host plus optional port) equals `domain` case-insensitively,
|
||
/// - no query string and no fragment,
|
||
/// - path is exactly `/prs/<npub-segment>/<repo-segment>` after trimming
|
||
/// trailing `/`,
|
||
/// - `<repo-segment>` ends in `.git`,
|
||
/// - `<npub-segment>` decodes via [`PublicKey::from_bech32`] to `signer`,
|
||
/// - the percent-decoded part of `<repo-segment>` before `.git` matches one
|
||
/// of `d_tags`.
|
||
pub fn clone_url_names_relays_prs_endpoint(
|
||
url: &str,
|
||
domain: &str,
|
||
signer: &PublicKey,
|
||
d_tags: &[String],
|
||
) -> bool {
|
||
let Some(d_decoded) = clone_url_prs_identifier_for_relay(url, domain, signer) else {
|
||
return false;
|
||
};
|
||
|
||
d_tags.contains(&d_decoded)
|
||
}
|
||
|
||
/// Parse `url` as this relay's `/prs/<signer-npub>/<d>.git` endpoint and
|
||
/// return the decoded `<d>` when it matches.
|
||
fn clone_url_prs_identifier_for_relay(
|
||
url: &str,
|
||
domain: &str,
|
||
signer: &PublicKey,
|
||
) -> Option<String> {
|
||
// Scheme: http or https only, case-insensitive.
|
||
let rest = if let Some(r) = strip_prefix_ignore_ascii_case(url, "http://") {
|
||
r
|
||
} else {
|
||
strip_prefix_ignore_ascii_case(url, "https://")?
|
||
};
|
||
|
||
// Reject query strings and fragments outright.
|
||
if rest.contains('?') || rest.contains('#') {
|
||
return None;
|
||
}
|
||
|
||
// Split authority and path on the first `/`.
|
||
let slash_idx = rest.find('/')?;
|
||
let authority = &rest[..slash_idx];
|
||
let path = &rest[slash_idx..]; // includes leading `/`.
|
||
|
||
if !authority.eq_ignore_ascii_case(domain) {
|
||
return None;
|
||
}
|
||
|
||
let path = path.trim_end_matches('/');
|
||
|
||
let inner = path.strip_prefix(&format!("/{}/", PRS_URL_PREFIX))?;
|
||
|
||
// Exactly two segments: `<npub>` and `<repo>.git`.
|
||
let segments: Vec<&str> = inner.split('/').collect();
|
||
if segments.len() != 2 {
|
||
return None;
|
||
}
|
||
let npub_segment = segments[0];
|
||
let repo_segment = segments[1];
|
||
|
||
if !npub_segment.starts_with("npub1") {
|
||
return None;
|
||
}
|
||
let url_pubkey = match PublicKey::from_bech32(npub_segment) {
|
||
Ok(pk) => pk,
|
||
Err(_) => return None,
|
||
};
|
||
if url_pubkey != *signer {
|
||
return None;
|
||
}
|
||
|
||
let d_encoded = repo_segment.strip_suffix(".git")?;
|
||
if d_encoded.is_empty() {
|
||
return None;
|
||
}
|
||
Some(percent_decode(d_encoded))
|
||
}
|
||
|
||
/// Case-insensitive equivalent of `str::strip_prefix` for ASCII prefixes.
|
||
fn strip_prefix_ignore_ascii_case<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||
if s.len() < prefix.len() {
|
||
return None;
|
||
}
|
||
let (head, tail) = s.split_at(prefix.len());
|
||
if head.eq_ignore_ascii_case(prefix) {
|
||
Some(tail)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn config_with_domain(domain: &str, enabled: bool) -> Config {
|
||
Config {
|
||
domain: domain.to_string(),
|
||
grasp06_enable: enabled,
|
||
..Config::for_testing()
|
||
}
|
||
}
|
||
|
||
fn pr_event(
|
||
signer: &Keys,
|
||
a_tags: &[(&str, &str)], // (hex_pubkey, identifier)
|
||
clone_urls: &[&str],
|
||
kind: Kind,
|
||
) -> Event {
|
||
let mut builder = EventBuilder::new(kind, "test");
|
||
for (pk_hex, ident) in a_tags {
|
||
builder = builder.tag(Tag::custom(
|
||
"a",
|
||
vec![format!("30617:{}:{}", pk_hex, ident)],
|
||
));
|
||
}
|
||
// c tag is required by downstream policy but not by this predicate.
|
||
builder = builder.tag(Tag::custom("c", vec!["0".repeat(40)]));
|
||
if !clone_urls.is_empty() {
|
||
builder = builder.tag(Tag::custom(
|
||
"clone",
|
||
clone_urls.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||
));
|
||
}
|
||
builder.finalize(signer).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_when_feature_disabled() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("http://relay.example/prs/{}/my-repo.git", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", false);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_matching_pr_event() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("http://relay.example/prs/{}/my-repo.git", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_matching_pr_update_event() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("https://relay.example:8080/prs/{}/my-repo.git", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequestUpdate,
|
||
);
|
||
let cfg = config_with_domain("relay.example:8080", true);
|
||
assert!(event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn extracts_prs_identifiers_from_clone_tags() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo"), (&target_hex, "other-repo")],
|
||
&[
|
||
&format!("http://relay.example/prs/{}/my-repo.git", npub),
|
||
&format!("https://relay.example/prs/{}/other-repo.git", npub),
|
||
&format!("https://other.example/prs/{}/my-repo.git", npub),
|
||
],
|
||
Kind::GitPullRequest,
|
||
);
|
||
|
||
let identifiers = prs_identifiers_named_by_event_clone_tags(&event, "relay.example");
|
||
assert_eq!(
|
||
identifiers,
|
||
vec!["my-repo".to_string(), "other-repo".to_string()]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_other_kinds() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("http://relay.example/prs/{}/my-repo.git", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPatch,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_foreign_host() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("https://other-relay.example/prs/{}/my-repo.git", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_mismatched_signer_in_url() {
|
||
let signer = Keys::generate();
|
||
let other_npub = Keys::generate().public_key().to_bech32().unwrap();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let url = format!("http://relay.example/prs/{}/my-repo.git", other_npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_d_not_in_a_tags() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("http://relay.example/prs/{}/wrong-repo.git", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_url_with_query_string() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("http://relay.example/prs/{}/my-repo.git?foo=bar", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_url_with_extra_path() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("http://relay.example/prs/{}/my-repo.git/info/refs", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_when_no_clone_tag() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_when_no_a_tag() {
|
||
let signer = Keys::generate();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("http://relay.example/prs/{}/my-repo.git", npub);
|
||
let event = pr_event(&signer, &[], &[&url], Kind::GitPullRequest);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_malformed_a_tag_coord() {
|
||
let signer = Keys::generate();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("http://relay.example/prs/{}/my-repo.git", npub);
|
||
// Wrong kind in coord (30000 instead of 30617).
|
||
let other_hex = Keys::generate().public_key().to_hex();
|
||
let event = EventBuilder::new(Kind::GitPullRequest, "x")
|
||
.tag(Tag::custom(
|
||
"a",
|
||
vec![format!("30000:{}:my-repo", other_hex)],
|
||
))
|
||
.tag(Tag::custom("c", vec!["0".repeat(40)]))
|
||
.tag(Tag::custom("clone", vec![url]))
|
||
.finalize(&signer)
|
||
.unwrap();
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(!event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_with_trailing_slash_in_url() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("http://relay.example/prs/{}/my-repo.git/", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_case_insensitive_scheme_and_host() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
let url = format!("HTTP://Relay.EXAMPLE/prs/{}/my-repo.git", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my-repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_percent_encoded_identifier() {
|
||
let signer = Keys::generate();
|
||
let target_hex = Keys::generate().public_key().to_hex();
|
||
let npub = signer.public_key().to_bech32().unwrap();
|
||
// d = "my repo" → URL-encoded as "my%20repo".
|
||
let url = format!("http://relay.example/prs/{}/my%20repo.git", npub);
|
||
let event = pr_event(
|
||
&signer,
|
||
&[(&target_hex, "my repo")],
|
||
&[&url],
|
||
Kind::GitPullRequest,
|
||
);
|
||
let cfg = config_with_domain("relay.example", true);
|
||
assert!(event_qualifies_for_pr_relaxation(&event, &cfg));
|
||
}
|
||
}
|