Files
ngit-grasp/src/git/authorization.rs
T
DanConwayDev e9a54678d5 feat(nip34): treat the moderator role tag as a role without maintainership
Follow the clarified NIP-34 indexed roles model (nips 986edd1): the `o`
(moderator) tag is a role tag, so its presence suppresses the deprecated
`maintainers` fallback, and a self-`o` entry is a self-role, so a
moderator-only author is not implicitly a maintainer and their state
events are not authorized. `o` listings grant no maintainership and
create no maintainer invitations. Like `M`/`m`, a pubkey may appear in
at most one `o` tag; a duplicate rejects the announcement.

No further handling is needed: moderators are only empowered to have
their status events (kinds 1630-1633) treated as authoritative, and this
relay does not reject status events from non-maintainers, so the role's
own authority requires no enforcement. Moderators never publish
authoritative repository state.

Deliberately deferred: announcements from moderators are not walked for
the `M`/`m` assignments they might carry (the NIP says role combinations
beyond self-plus-lead SHOULD be avoided unless the author is `M`), and
moderator membership gets no reciprocal-confirmation treatment. Both
only matter if status-event authority is ever enforced.

Validation: nostr::events and git::authorization unit tests and the
state_authorization suite pass.
2026-08-19 11:29:30 +00:00

1719 lines
63 KiB
Rust

//! GRASP Push Authorization
//!
//! This module implements the authorization logic for Git pushes according to GRASP-01.
//!
//! ## GRASP-01 Requirement
//!
//! "MUST accept pushes via this service that match the latest repo state announcement
//! on the relay, respecting the maintainer set."
//!
//! ## Authorization Flow (Efficient Single-Query Approach)
//!
//! 1. Fetch announcement and state events for the repository from the relay database
//! 2. Compute the confirmed maintainer set for the owner's repository
//! 3. Find the latest state event authored by a confirmed maintainer
//! 4. Validate that the pushed refs match the state event
//!
//! ## Authorization Logic
//!
//! Maintainership is reciprocal. A pubkey listed as a maintainer in an
//! announcement is only *invited*: none of its events are authoritative until
//! its own announcement for the same identifier lists back an existing
//! confirmed maintainer. See [`compute_membership`].
//!
//! ## Shared Helper Functions
//!
//! This module provides helper functions that can be used by both:
//! - Git push authorization in handlers.rs
//! - HEAD updates triggered by state events in builder.rs (event policy)
use anyhow::{anyhow, Result};
use hyper::body::Bytes;
use nostr_sdk::prelude::*;
use nostr_sdk::prelude::{EventId, ToBech32};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tracing::{debug, info, warn};
use crate::nostr::events::{RepositoryAnnouncement, RepositoryState};
use crate::nostr::SharedDatabase;
use crate::purgatory::Purgatory;
use nostr_sdk::prelude::{Kind, PublicKey};
/// Perform GRASP authorization for a push operation
///
/// This function queries the database directly (not via WebSocket):
/// 1. Parses the pushed refs from the git pack protocol
/// 2. Separates refs/nostr/ refs from normal refs
/// 3. For normal refs: validates against state events in purgatory
/// 4. For refs/nostr/ refs: validates event ID format and collects PR/PR-update events from purgatory
/// 5. Returns all authorizing events (state + PR/PR-update) in the result
pub async fn authorize_push(
database: &SharedDatabase,
identifier: &str,
owner_pubkey: &str,
request_body: &Bytes,
purgatory: &Arc<Purgatory>,
repo_path: &std::path::Path,
) -> anyhow::Result<AuthorizationResult> {
debug!(
"Authorizing push for {} owned by {} via database query",
identifier, owner_pubkey
);
// Parse refs from the push request
let pushed_refs = parse_pushed_refs(request_body);
debug!("Parsed {} refs from push request", pushed_refs.len());
for (old_oid, new_oid, ref_name) in &pushed_refs {
debug!(" {} {} -> {}", ref_name, old_oid, new_oid);
}
// Separate refs/nostr/ refs from state refs
let (nostr_refs, state_refs): (Vec<_>, Vec<_>) = pushed_refs
.iter()
.partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/"));
// Collect all purgatory events that authorize this push
let mut purgatory_events = Vec::new();
// Handle refs/nostr/ refs - validate and collect PR/PR-update events from purgatory
if !nostr_refs.is_empty() {
debug!(
"Found {} refs/nostr/ refs - validating and collecting from purgatory",
nostr_refs.len()
);
for (_, new_oid, ref_name) in &nostr_refs {
// Standard endpoint passes `None` for prs_url: signer / a-tag
// identifier are enforced by the surrounding maintainer-set
// authorization, not by the URL.
match pre_validate_refs_nostr_push(database, purgatory, new_oid, ref_name, None).await {
NostrRefPreValidation::Rejected { reason } => {
warn!("refs/nostr/ validation failed: {}", reason);
return Ok(AuthorizationResult::denied(reason));
}
NostrRefPreValidation::Authorized {
event_from_purgatory,
} => {
if let Some(event) = event_from_purgatory {
debug!("Found matching PR event in purgatory for ref {}", ref_name);
purgatory_events.push(event);
} else {
debug!("Ref {} validated against existing record", ref_name);
}
}
NostrRefPreValidation::Unknown => {
// No entry in DB or purgatory — create placeholder so
// the 30-minute sweep can clean the ref up if the PR
// event never arrives. Standard-endpoint placeholders
// carry no /prs/ scope.
let event_id_hex = ref_name
.strip_prefix("refs/nostr/")
.expect("shape validated in pre_validate_refs_nostr_push");
purgatory.add_pr_placeholder(event_id_hex.to_string(), new_oid.clone());
debug!(
"Created placeholder for {} - awaiting PR event (will expire in 30min if event doesn't arrive)",
event_id_hex
);
}
}
}
}
// Handle normal refs - validate against state events
if !state_refs.is_empty() {
debug!(
"Found {} non-refs/nostr/ refs - checking state authorization",
state_refs.len()
);
let auth_result = get_state_authorization_for_specific_owner_repo(
database,
identifier,
owner_pubkey,
purgatory,
&pushed_refs, //it would be better to accept state_refs but thats in different format
repo_path,
)
.await?;
if !auth_result.authorized {
return Ok(auth_result);
}
// Collect state events from purgatory
purgatory_events.extend(auth_result.purgatory_events);
// Validate refs against state
let other_refs_owned: Vec<(String, String, String)> = state_refs
.into_iter()
.map(|(a, b, c)| (a.clone(), b.clone(), c.clone()))
.collect();
if let Some(ref state) = auth_result.state {
debug!(
"Validating against state with {} branches",
state.branches.len()
);
if other_refs_owned.is_empty() && !state.branches.is_empty() {
warn!("No refs parsed from push request but state event has branches - rejecting");
return Ok(AuthorizationResult::denied(
"Failed to parse refs from push request - cannot validate against state",
));
}
if let Err(e) = validate_push_refs(state, &other_refs_owned) {
warn!("Ref validation failed: {}", e);
return Ok(AuthorizationResult::denied(format!(
"Ref validation failed: {}",
e
)));
}
debug!("Ref validation passed");
}
// Return result with purgatory events
return Ok(AuthorizationResult {
authorized: true,
reason: auth_result.reason,
state: auth_result.state,
maintainers: auth_result.maintainers,
purgatory_events,
});
}
// Only refs/nostr/ refs - return success with collected events
Ok(AuthorizationResult {
authorized: true,
reason: "Push to refs/nostr/ validated".to_string(),
state: None,
maintainers: vec![],
purgatory_events,
})
}
/// Repository data fetched from the database
///
/// Contains all announcements and states for a given identifier,
/// fetched with a single filter query.
#[derive(Debug)]
pub struct RepositoryData {
/// All repository announcements with this identifier
pub announcements: Vec<RepositoryAnnouncement>,
/// All repository state events with this identifier
pub states: Vec<RepositoryState>,
}
/// Fetch all repository data (announcements + states) for a given identifier
///
/// This performs a single database query to fetch both announcement and state events,
/// which is more efficient than separate queries.
pub async fn fetch_repository_data_excluding_purgatory(
database: &SharedDatabase,
identifier: &str,
) -> Result<RepositoryData> {
let filter = Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.custom_tag(SingleLetterTag::LOWERCASE_D, identifier.to_string());
let events: Vec<Event> = database
.query(filter)
.await
.map_err(|e| anyhow!("Database query failed: {}", e))?
.into_iter()
.collect();
debug!(
"Fetched {} events for identifier {} from database",
events.len(),
identifier
);
// Separate into announcements and states
let mut announcements = Vec::new();
let mut states = Vec::new();
for event in events {
if event.kind == Kind::GitRepoAnnouncement {
if let Ok(announcement) = RepositoryAnnouncement::from_event(event) {
announcements.push(announcement);
}
} else if event.kind == Kind::RepoState {
if let Ok(state) = RepositoryState::from_event(event) {
states.push(state);
}
}
}
debug!(
"Parsed {} announcements and {} states from database for identifier {}",
announcements.len(),
states.len(),
identifier
);
Ok(RepositoryData {
announcements,
states,
})
}
/// Fetch repository data including announcements from purgatory
///
/// This combines database announcements with purgatory announcements,
/// which is needed for authorization when the announcement hasn't been
/// promoted yet (no git data has arrived).
pub async fn fetch_repository_data_with_purgatory(
database: &SharedDatabase,
purgatory: &crate::purgatory::Purgatory,
identifier: &str,
) -> Result<RepositoryData> {
// First, fetch from database
let mut repo_data = fetch_repository_data_excluding_purgatory(database, identifier).await?;
// Then, add announcements from purgatory
let purgatory_announcements = purgatory.get_announcements_by_identifier(identifier);
let purgatory_count = purgatory_announcements.len();
for entry in purgatory_announcements {
if let Ok(announcement) = RepositoryAnnouncement::from_event(entry.event) {
if let Some(current) = repo_data.announcements.iter_mut().find(|current| {
current.event.pubkey == announcement.event.pubkey
&& current.identifier == announcement.identifier
}) {
*current = announcement;
} else {
repo_data.announcements.push(announcement);
}
}
}
debug!(
"Fetched repository data with purgatory: {} announcements ({} from purgatory), {} states",
repo_data.announcements.len(),
purgatory_count,
repo_data.states.len()
);
Ok(repo_data)
}
pub fn pubkey_authorised_for_repo_owners(
pubkey: &PublicKey,
db_repo_data: &RepositoryData,
) -> Vec<String> {
let mut repo_owners_authorising_pubkey = HashSet::new();
let collections = collect_authorized_maintainers(&db_repo_data.announcements);
for (owner, authoised) in collections {
if authoised.contains(&pubkey.to_hex()) {
repo_owners_authorising_pubkey.insert(owner.to_string());
}
}
repo_owners_authorising_pubkey.iter().cloned().collect()
}
/// Confirmed membership of one owner's repository.
#[derive(Debug, Default)]
pub struct RepoMembership {
/// Pubkeys whose repository state events are authoritative for this
/// owner's repository: the owner plus every confirmed maintainer.
pub state_maintainers: HashSet<String>,
/// Pubkeys currently listed as maintainers whose own announcement does
/// not yet list back a confirmed maintainer. Their announcements must
/// still be fetched and accepted - that is how the relay notices an
/// invitation was accepted - but none of their events are authoritative.
pub invited: HashSet<String>,
}
/// Compute the confirmed maintainer set for `owner`'s repository.
///
/// A pubkey listed as a maintainer is only *invited* until its own
/// announcement for the same identifier lists back a pubkey that is already
/// a confirmed maintainer (reciprocal acknowledgment). Confirmation is
/// evaluated as a fixpoint, so maintainers listed by other confirmed
/// maintainers are reached recursively. The acknowledging announcement must
/// also assert an active role for its own author: an ended `M`/`m`
/// self-entry means the pubkey has left, which takes precedence over
/// assignments in other announcements. An author who appears in no role tag
/// implicitly asserts maintainership for the repository's entire history.
///
/// The owner is always a confirmed maintainer of their own repository:
/// announcing a repository in their namespace is what creates it on this
/// service.
pub fn compute_membership(
announcements: &[RepositoryAnnouncement],
owner: &str,
identifier: &str,
) -> RepoMembership {
let find = |pubkey: &str| {
announcements
.iter()
.find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier)
};
if find(owner).is_none() {
return RepoMembership::default();
}
let mut confirmed: HashSet<String> = HashSet::from([owner.to_string()]);
// Fixpoint: keep confirming listed pubkeys whose own announcement lists
// back an already-confirmed maintainer. The confirmed set only grows, so
// the loop terminates.
loop {
let listed: HashSet<String> = confirmed
.iter()
.filter_map(|member| find(member))
.flat_map(|announcement| announcement.listed_maintainers())
.collect();
let mut progressed = false;
for candidate in &listed {
if confirmed.contains(candidate) {
continue;
}
let Some(candidate_announcement) = find(candidate) else {
continue; // invited: no announcement of their own yet
};
if !candidate_announcement.author_role_active() {
continue; // left: an ended self-role takes precedence
}
let lists_back = candidate_announcement
.listed_maintainers()
.iter()
.any(|pubkey| confirmed.contains(pubkey));
if lists_back {
confirmed.insert(candidate.clone());
progressed = true;
}
}
if !progressed {
let invited = listed
.into_iter()
.filter(|pubkey| !confirmed.contains(pubkey))
// A pubkey whose own announcement shows it left is not
// invited.
.filter(|pubkey| !find(pubkey).is_some_and(|a| a.author_has_left()))
.collect();
return RepoMembership {
state_maintainers: confirmed,
invited,
};
}
}
}
/// Collect authorized state publishers grouped by owner from a set of
/// announcements.
///
/// For each announcement, returns a map from owner pubkey to the pubkeys
/// whose repository state events are authoritative for that owner's
/// repository: the confirmed maintainer set computed by
/// [`compute_membership`]. Invited pubkeys (listed but without a reciprocal
/// announcement) are never included.
pub fn collect_authorized_maintainers(
announcements: &[RepositoryAnnouncement],
) -> HashMap<String, Vec<String>> {
let mut by_owner: HashMap<String, Vec<String>> = HashMap::new();
for announcement in announcements {
let owner = announcement.event.pubkey.to_hex();
let membership = compute_membership(announcements, &owner, &announcement.identifier);
by_owner.insert(owner, membership.state_maintainers.into_iter().collect());
}
debug!(
"Collected confirmed state maintainers for {} owners from {} announcements",
by_owner.len(),
announcements.len()
);
by_owner
}
/// Get the authorization result for a repository scoped to a specific owner
///
/// Push authorization checks ONLY purgatory for state events. The database represents
/// the current git state, while purgatory holds the intended future state that pushes
/// should be authorized against.
///
/// A push to `alice/my-repo` should only consider authorization from alice's
/// announcement, not bob's announcement for the same identifier.
///
/// It:
/// 1. Fetches announcements for the identifier
/// 2. Collects authorized maintainers from owner's announcement
/// 3. Checks purgatory for matching state events from authorized maintainers
///
/// Returns an `AuthorizationResult` that indicates whether a push is authorized.
pub async fn get_state_authorization_for_specific_owner_repo(
database: &SharedDatabase,
identifier: &str,
owner_pubkey: &str,
purgatory: &std::sync::Arc<crate::purgatory::Purgatory>,
pushed_refs: &[(String, String, String)],
repo_path: &std::path::Path,
) -> Result<AuthorizationResult> {
use crate::git::list_refs;
use crate::purgatory::RefUpdate;
// Fetch announcements from database AND purgatory - needed for authorization
// when the announcement hasn't been promoted yet (no git data has arrived)
let repo_data = fetch_repository_data_with_purgatory(database, purgatory, identifier).await?;
if repo_data.announcements.is_empty() {
return Ok(AuthorizationResult::denied(
"No repository announcement found",
));
}
// Collect authorized maintainers grouped by owner from all announcements
let by_owner = collect_authorized_maintainers(&repo_data.announcements);
// Look up the authorized set for this specific owner
let authorized: HashSet<String> = match by_owner.get(owner_pubkey) {
Some(maintainers) => maintainers.iter().cloned().collect(),
None => {
return Ok(AuthorizationResult::denied(format!(
"No repository announcement found for owner {}",
owner_pubkey
)));
}
};
if authorized.is_empty() {
return Ok(AuthorizationResult::denied(
"No authorized maintainers found",
));
}
debug!(
"Found {} authorized maintainers for repository {} (owner: {})",
authorized.len(),
identifier,
owner_pubkey
);
// Accept pushes where all refs are already at the desired state (old_oid == new_oid)
// This handles race conditions where state events are applied between fetch and push
if !pushed_refs.is_empty() {
let all_refs_unchanged = pushed_refs
.iter()
.all(|(old_oid, new_oid, _)| old_oid == new_oid);
if all_refs_unchanged {
debug!(
"All pushed refs unchanged (old_oid == new_oid) for {} owned by {}, accepting without purgatory check",
identifier, owner_pubkey
);
return Ok(AuthorizationResult {
authorized: true,
reason: "Push accepted: all refs already at desired state (no-op)".to_string(),
state: None,
maintainers: authorized.into_iter().collect(),
purgatory_events: vec![],
});
}
}
// Check purgatory for matching state events
// Convert pushed refs to RefUpdate (filter out refs/nostr/* refs)
let pushed_updates: Vec<RefUpdate> = pushed_refs
.iter()
.filter(|(_, _, name)| !name.starts_with("refs/nostr/"))
.map(|(old_oid, new_oid, ref_name)| RefUpdate {
old_oid: old_oid.clone(),
new_oid: new_oid.clone(),
ref_name: ref_name.clone(),
})
.collect();
// Get local refs from repository
let local_refs_list = list_refs(repo_path).unwrap_or_default();
let local_refs: HashMap<String, String> = local_refs_list.into_iter().collect();
// Find matching state events in purgatory
let matching_events = purgatory.find_matching_states(identifier, &pushed_updates, &local_refs);
if !matching_events.is_empty() {
debug!(
"Found {} matching state event(s) in purgatory",
matching_events.len()
);
// Filter to authorized events and collect them
let authorized_events: Vec<Event> = matching_events
.into_iter()
.filter(|event| {
let author_hex = event.pubkey.to_hex();
authorized.contains(&author_hex)
})
.collect();
if !authorized_events.is_empty() {
// Find the latest event
let latest_authorized = authorized_events
.iter()
.max_by_key(|event| event.created_at)
.unwrap(); // Safe because we checked the vec is not empty
// Parse the event into RepositoryState
if let Ok(state) = RepositoryState::from_event(latest_authorized.clone()) {
info!(
"Authorized by state event {} from purgatory (author: {})",
latest_authorized.id,
latest_authorized
.pubkey
.to_bech32()
.unwrap_or_else(|_| latest_authorized.pubkey.to_hex())
);
// Extend purgatory announcement expiry for the owner.
//
// Per design doc decision #4: git auth extending a state event's expiry
// also extends the announcement's expiry. The repo is actively receiving
// git data, so the announcement should not expire prematurely.
// This also revives soft-expired announcements (recreates bare repo).
if let Ok(owner_pk) = PublicKey::parse(owner_pubkey) {
if purgatory.has_purgatory_announcement(&owner_pk, identifier) {
purgatory.extend_announcement_expiry(
&owner_pk,
identifier,
std::time::Duration::from_secs(1800),
);
debug!(
identifier = %identifier,
owner = %owner_pubkey,
"Extended purgatory announcement expiry due to git push authorization"
);
}
}
return Ok(AuthorizationResult {
authorized: true,
reason: "Authorized by state event in purgatory".to_string(),
state: Some(state),
maintainers: authorized.into_iter().collect(),
purgatory_events: vec![latest_authorized.clone()],
});
} else {
warn!(
"Failed to parse purgatory event {} as RepositoryState",
latest_authorized.id
);
}
} else {
debug!("Purgatory events found but none from authorized authors");
}
} else {
// Check if there are ANY state events in purgatory for this identifier
let all_purgatory_states = purgatory.find_state(identifier);
if !all_purgatory_states.is_empty() {
// There are state events but none match the push - diagnose why
debug!(
"Found {} state event(s) in purgatory for {} but none match the push",
all_purgatory_states.len(),
identifier
);
// Count authorized state events and collect diagnostic info
let mut authorized_count = 0;
let mut diagnostic_reasons = Vec::new();
// Diagnose why each authorized state event doesn't match
for entry in all_purgatory_states.iter() {
let author_hex = entry.event.pubkey.to_hex();
if authorized.contains(&author_hex) {
authorized_count += 1;
if let Some(reason) = crate::purgatory::diagnose_state_mismatch(
&entry.event,
&pushed_updates,
&local_refs,
) {
debug!(
"State event {} from authorized author {} doesn't match push: {}",
entry.event.id,
entry
.event
.pubkey
.to_bech32()
.unwrap_or_else(|_| author_hex.clone()),
reason
);
diagnostic_reasons.push(reason);
}
}
}
// Create concise WARN message summarizing the rejection
let summary = if authorized_count > 0 {
let reason_summary = if !diagnostic_reasons.is_empty() {
// Take the first diagnostic reason as representative
format!(" ({})", diagnostic_reasons[0])
} else {
String::new()
};
format!(
"{} state event{} in purgatory from authorized publisher{} but doesn't match push{}",
authorized_count,
if authorized_count == 1 { "" } else { "s" },
if authorized_count == 1 { "" } else { "s" },
reason_summary
)
} else {
format!(
"{} state event{} in purgatory but none from authorized publishers",
all_purgatory_states.len(),
if all_purgatory_states.len() == 1 {
""
} else {
"s"
}
)
};
warn!("Push rejected for {}: {}", identifier, summary);
return Ok(AuthorizationResult::denied(summary));
} else {
debug!("No state events found in purgatory for {}", identifier);
warn!(
"Push rejected for {}: No state events in purgatory",
identifier
);
return Ok(AuthorizationResult::denied("No state events in purgatory"));
}
}
// No matching state found in purgatory
Ok(AuthorizationResult::denied(
"No matching state event found in purgatory from authorized publishers",
))
}
/// Result of authorization check
#[derive(Debug)]
pub struct AuthorizationResult {
/// Whether the push is authorized
pub authorized: bool,
/// Reason for the decision (for logging/debugging)
pub reason: String,
/// The authorized state if available
pub state: Option<RepositoryState>,
/// The set of valid maintainers (authorized publishers)
pub maintainers: Vec<String>,
/// Events from purgatory that authorized this push (state, PR, PR-update events)
pub purgatory_events: Vec<Event>,
}
impl AuthorizationResult {
/// Create a denied authorization result
pub fn denied(reason: impl Into<String>) -> Self {
Self {
authorized: false,
reason: reason.into(),
state: None,
maintainers: vec![],
purgatory_events: vec![],
}
}
}
/// Validate that pushed refs match the authorized state
///
/// Takes the refs being pushed (ref name -> commit hash) and validates
/// against the state event.
pub fn validate_push_refs(
state: &RepositoryState,
pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name)
) -> Result<()> {
for (old_oid, new_oid, ref_name) in pushed_refs {
debug!("Validating push: {} {} -> {}", ref_name, old_oid, new_oid);
// Handle branch updates
if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") {
if let Some(expected_commit) = state.get_branch_commit(branch_name) {
if new_oid != expected_commit {
return Err(anyhow!(
"Branch {} push rejected: expected commit {}, got {}",
branch_name,
expected_commit,
new_oid
));
}
// Commit matches state - authorized
debug!(
"Branch {} push authorized: {} matches state",
branch_name, new_oid
);
} else {
// Branch not in state - REJECT (GRASP-01 requirement)
return Err(anyhow!(
"Branch {} push rejected: not announced in state event",
branch_name
));
}
}
// Handle tag updates
if let Some(tag_name) = ref_name.strip_prefix("refs/tags/") {
if let Some(expected_commit) = state.get_tag_commit(tag_name) {
if new_oid != expected_commit {
return Err(anyhow!(
"Tag {} push rejected: expected commit {}, got {}",
tag_name,
expected_commit,
new_oid
));
}
}
}
// refs/nostr/* is handled separately per GRASP-01
if ref_name.starts_with("refs/nostr/") {
// Extract event_id from "refs/nostr/<event-id>"
if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") {
// Validate it parses as a valid EventId
if EventId::parse(event_id_str).is_err() {
return Err(anyhow!(
"Invalid event ID format in ref: {}. Expected valid nostr event ID.",
ref_name
));
}
// Valid EventId format - allow push (skip state event check)
debug!(
"refs/nostr/{} push authorized (valid EventId)",
event_id_str
);
continue; // Skip the rest of ref validation for this ref
} else {
return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name));
}
}
}
Ok(())
}
/// Parse the refs being updated from a Git pack
///
/// The receive-pack protocol sends ref updates in pkt-line format:
/// - 4-byte hex length prefix (e.g., "00a5")
/// - Payload: `<old-oid> <new-oid> <ref-name>\0<capabilities>\n`
/// - Flush packet "0000" terminates the list
/// - Then comes the PACK data
///
/// This function handles both pkt-line format (from real Git clients) and
/// simple text format (for unit tests).
pub fn parse_pushed_refs(data: &[u8]) -> Vec<(String, String, String)> {
// Check if this looks like pkt-line format (starts with 4 hex digits)
// A valid pkt-line push starts with a length > 4 (not a flush packet)
if data.len() >= 4 {
if let Ok(len_str) = std::str::from_utf8(&data[0..4]) {
if let Ok(len) = u16::from_str_radix(len_str, 16) {
// A valid pkt-line data packet has length > 4 (flush is 0)
// Also check that the length makes sense for a ref update
if len > 4 && (len as usize) <= data.len() {
// This is pkt-line format, parse it properly
return parse_pktline_refs(data);
}
}
}
}
// Fall back to simple text format (for tests)
parse_text_refs(data)
}
/// Parse refs from pkt-line format data
fn parse_pktline_refs(mut data: &[u8]) -> Vec<(String, String, String)> {
let mut refs = Vec::new();
while data.len() >= 4 {
// Parse pkt-line length prefix
let len_str = match std::str::from_utf8(&data[0..4]) {
Ok(s) => s,
Err(_) => break,
};
let len = match u16::from_str_radix(len_str, 16) {
Ok(l) => l as usize,
Err(_) => break,
};
// Flush packet (0000) ends the ref list
if len == 0 {
break;
}
if len < 4 || data.len() < len {
break;
}
// Extract payload (without the 4-byte length prefix)
let payload = &data[4..len];
// Parse the payload: "old_oid new_oid ref_name\0capabilities\n"
if let Some(ref_update) = parse_ref_line(payload) {
refs.push(ref_update);
}
// Move to next pkt-line
data = &data[len..];
}
debug!("Parsed {} refs from pkt-line format", refs.len());
refs
}
/// Parse refs from simple text format (for backward compatibility with tests)
fn parse_text_refs(data: &[u8]) -> Vec<(String, String, String)> {
let mut refs = Vec::new();
let text = String::from_utf8_lossy(data);
for line in text.lines() {
// Skip empty lines and pack data
if line.is_empty() || line.starts_with("PACK") {
continue;
}
if let Some(ref_update) = parse_ref_line(line.as_bytes()) {
refs.push(ref_update);
}
}
refs
}
/// Parse a single ref update line: "old_oid new_oid ref_name\0capabilities"
fn parse_ref_line(payload: &[u8]) -> Option<(String, String, String)> {
// Convert to string, handling potential invalid UTF-8
let line = String::from_utf8_lossy(payload);
// Strip trailing newline if present
let line = line.trim_end_matches('\n');
// Split at null byte to separate command from capabilities
let command_part = line.split('\0').next().unwrap_or("");
// Parse "old_oid new_oid ref_name"
let parts: Vec<&str> = command_part.split_whitespace().collect();
if parts.len() >= 3 {
let old_oid = parts[0];
let new_oid = parts[1];
let ref_name = parts[2];
// Validate OID format (40 hex chars)
if old_oid.len() == 40
&& new_oid.len() == 40
&& old_oid.chars().all(|c| c.is_ascii_hexdigit())
&& new_oid.chars().all(|c| c.is_ascii_hexdigit())
{
return Some((
old_oid.to_string(),
new_oid.to_string(),
ref_name.to_string(),
));
}
}
None
}
/// Convert hex pubkey to bech32 npub format
pub fn pubkey_to_npub(hex_pubkey: &str) -> Result<String> {
let pk = PublicKey::parse(hex_pubkey)?;
Ok(pk.to_bech32()?)
}
/// Convert bech32 npub to hex pubkey format
pub fn npub_to_pubkey(npub: &str) -> Result<String> {
let pk = PublicKey::parse(npub)?;
Ok(pk.to_hex())
}
/// Fetch a PR (kind 1617) or PR-Update (kind 1618) event by its ID.
///
/// Returns the first matching event, or `None` if no such event has been
/// accepted into the database. Used by [`pre_validate_refs_nostr_push`]
/// — which needs the full event so it can verify the signer pubkey and
/// the `a`-tag identifier on top of the `c` tag.
pub async fn get_pr_event_by_id(
database: &SharedDatabase,
event_id: &EventId,
) -> Result<Option<Event>> {
let filter = Filter::new()
.ids([*event_id])
.kinds([Kind::GitPullRequest, Kind::GitPullRequestUpdate]);
let events: Vec<Event> = database
.query(filter)
.await
.map_err(|e| anyhow!("Database query failed: {}", e))?
.into_iter()
.collect();
Ok(events.into_iter().next())
}
/// Extract the `c` (commit) tag value from a NIP-34 PR/PR-Update event.
///
/// Per NIP-34, PR events carry a `c` tag whose second element is the head
/// commit being proposed. Returns `None` if the tag is missing or malformed.
pub fn extract_commit_tag(event: &Event) -> Option<String> {
event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("c"))
.and_then(|tag| tag.as_slice().get(1).map(|s| s.to_string()))
}
/// Constraints imposed by the GRASP-06 `/prs/<npub>/<identifier>` URL on
/// any event resolved while pre-validating a `refs/nostr/<event-id>`
/// push.
///
/// When present, a known event found in the DB or purgatory MUST have:
///
/// - `event.pubkey == submitter`,
/// - at least one `a`-tag of the form `30617:<hex>:<d>` where `d ==
/// identifier`, AND
/// - at least one `clone` tag naming this relay's
/// `/prs/<submitter-npub>/<identifier>.git` endpoint (the opt-in
/// signal that prevents every GRASP-06 relay from accepting every PR
/// event that happens to match its URL shape).
///
/// Standard `/<npub>/<id>.git` pushes pass `None` here — they rely on the
/// surrounding `authorize_push` flow to gate by the maintainer set
/// instead.
#[derive(Debug, Clone, Copy)]
pub struct PrsUrlConstraints<'a> {
pub submitter: &'a PublicKey,
pub identifier: &'a str,
/// The relay's own domain (host[:port]) used to verify the `clone` tag.
pub domain: &'a str,
}
/// Outcome of [`pre_validate_refs_nostr_push`] for one ref.
///
/// The function is **pure** — no DB writes, no purgatory mutations, no
/// disk I/O. Callers decide whether to reject the push, proceed, and
/// whether to create a placeholder.
#[derive(Debug)]
pub enum NostrRefPreValidation {
/// The ref is allowed to proceed.
///
/// `event_from_purgatory` carries the matched event when the match
/// came from a populated purgatory entry, so the caller (the standard
/// `authorize_push`) can collect it into `purgatory_events`. `None`
/// means the match came from the DB or from a placeholder-only
/// purgatory entry — nothing to collect.
Authorized { event_from_purgatory: Option<Event> },
/// No event with that id is known to the relay yet. The caller may
/// create a placeholder (with or without a `/prs/` scope) so the
/// purgatory sweep can clean up the ref if the event never arrives.
Unknown,
/// The push must be rejected. `reason` is suitable for both an
/// authorization-denied response and a `git-receive-pack` ERR
/// pkt-line.
Rejected { reason: String },
}
/// Pre-validate one `refs/nostr/<event-id>` push against the database and
/// purgatory.
///
/// Shared by both the standard endpoint
/// ([`authorize_push`]) and the GRASP-06 `/prs/` receive-pack handler
/// (`crate::grasp06::receive::handle_prs_receive_pack`). The two endpoints
/// have different mismatch UX (pre-reject vs post-delete) but the
/// underlying mismatch *criteria* are the same — gathering them here
/// keeps the criteria in one place.
///
/// Checks performed:
///
/// 1. `ref_name` is exactly `refs/nostr/<64-lowercase-hex>` and parses as
/// an [`EventId`].
/// 2. DB lookup via [`get_pr_event_by_id`]. If found, the event's `c` tag
/// MUST match `new_oid`; with `prs_url` set, the event's signer MUST
/// match `submitter` and one of its `a`-tag d-values MUST match
/// `identifier`.
/// 3. Purgatory lookup. Same checks as (2) for populated entries. For
/// placeholder-only entries with a `prs_scope`, the scope MUST match
/// `prs_url` when one is supplied (this catches the case where one
/// `/prs/<A>/<id>` push tries to claim a ref previously staged at
/// `/prs/<B>/<id>` under the same event id).
/// 4. Otherwise the function returns [`NostrRefPreValidation::Unknown`].
pub async fn pre_validate_refs_nostr_push(
database: &SharedDatabase,
purgatory: &Purgatory,
new_oid: &str,
ref_name: &str,
prs_url: Option<PrsUrlConstraints<'_>>,
) -> NostrRefPreValidation {
// 1. Ref-name shape.
let event_id_hex = match ref_name.strip_prefix("refs/nostr/") {
Some(s) => s,
None => {
return NostrRefPreValidation::Rejected {
reason: format!("ref {} is outside refs/nostr/", ref_name),
}
}
};
let event_id = match EventId::parse(event_id_hex) {
Ok(id) => id,
Err(_) => {
return NostrRefPreValidation::Rejected {
reason: format!("Invalid event ID format in ref: {}", ref_name),
}
}
};
// 2. DB first.
match get_pr_event_by_id(database, &event_id).await {
Ok(Some(event)) => {
if let Some(reason) = describe_known_event_mismatch(&event, new_oid, prs_url) {
return NostrRefPreValidation::Rejected {
reason: format!("PR event {} {}", event_id_hex, reason),
};
}
return NostrRefPreValidation::Authorized {
event_from_purgatory: None,
};
}
Ok(None) => {}
Err(e) => {
// Treat DB error as not-found for permissive behaviour. The
// standard endpoint preserves the historical behaviour of
// creating a placeholder; the /prs/ handler likewise creates
// one and lets the sweep clean up if the event never arrives.
warn!(
"DB query for {} failed (treating as not-found): {}",
ref_name, e
);
}
}
// 3. Purgatory.
if let Some(entry) = purgatory.find_pr(event_id_hex) {
match entry.event {
Some(event) => {
if let Some(reason) = describe_known_event_mismatch(&event, new_oid, prs_url) {
return NostrRefPreValidation::Rejected {
reason: format!("PR event {} (purgatory) {}", event_id_hex, reason),
};
}
return NostrRefPreValidation::Authorized {
event_from_purgatory: Some(event),
};
}
None => {
// Placeholder-only entry. Standard endpoint historically
// allows overwriting (no event → no c-tag to validate
// against). The /prs/ endpoint additionally requires that
// any recorded scope matches the URL — otherwise one
// `/prs/<A>/<id>` push could claim a ref previously
// staged at `/prs/<B>/<id>` under the same event id.
if let (Some(scope), Some(prs)) = (entry.prs_scope.as_ref(), prs_url) {
if scope.submitter != *prs.submitter || scope.identifier != prs.identifier {
return NostrRefPreValidation::Rejected {
reason: format!(
"ref refs/nostr/{} pre-registered under a different /prs/ scope ({}/{})",
event_id_hex,
scope.submitter.to_hex(),
scope.identifier
),
};
}
}
return NostrRefPreValidation::Authorized {
event_from_purgatory: None,
};
}
}
}
// 4. Nothing known yet.
NostrRefPreValidation::Unknown
}
/// Cross-check a known PR / PR-Update event against the pushed commit
/// and (optionally) the `/prs/<npub>/<identifier>` URL constraints.
///
/// Returns `None` if everything matches, or `Some(reason)` describing the
/// first mismatch encountered. The reason is intended to be embedded into
/// a user-facing authorization-denied / ERR pkt-line message.
fn describe_known_event_mismatch(
event: &Event,
pushed_commit: &str,
prs: Option<PrsUrlConstraints<'_>>,
) -> Option<String> {
// `c` tag must match the pushed commit.
match extract_commit_tag(event) {
Some(c) if c == pushed_commit => {}
Some(c) => {
return Some(format!(
"specifies commit {}, but push contains {}",
c, pushed_commit
))
}
None => return Some("has no `c` tag".to_string()),
}
// /prs/ extra constraints.
if let Some(prs) = prs {
if event.pubkey != *prs.submitter {
return Some(format!(
"is signed by {} which does not match /prs/ submitter {}",
event.pubkey.to_hex(),
prs.submitter.to_hex(),
));
}
match crate::git::sync::extract_identifier_from_pr_event(event) {
Some(id) if id == prs.identifier => {}
Some(id) => {
return Some(format!(
"has a-tag identifier {} which does not match /prs/ identifier {}",
id, prs.identifier
))
}
None => return Some("has no parsable a-tag identifier".to_string()),
}
// The event must explicitly opt in to this relay's /prs/ endpoint via
// a `clone` tag. Without this check any PR event whose signer and
// identifier happen to match the URL could be pushed here, turning
// every GRASP-06 relay into an unsolicited mirror for every PR event
// on the network.
let d_tags = vec![prs.identifier.to_string()];
let has_clone_tag = event.tags.iter().any(|tag| {
let parts = tag.clone().to_vec();
if parts.first().map(String::as_str) != Some("clone") {
return false;
}
parts.iter().skip(1).any(|url| {
crate::grasp06::policy::clone_url_names_relays_prs_endpoint(
url,
prs.domain,
prs.submitter,
&d_tags,
)
})
});
if !has_clone_tag {
return Some(format!(
"has no `clone` tag naming this relay's /prs/{}/{}.git endpoint",
prs.submitter
.to_bech32()
.unwrap_or_else(|_| prs.submitter.to_hex()),
prs.identifier,
));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Tag};
fn create_test_keys() -> Keys {
Keys::generate()
}
fn create_announcement_event(keys: &Keys, identifier: &str, maintainers: &[&Keys]) -> Event {
let mut tags = vec![Tag::custom("d", vec![identifier.to_string()])];
// Add maintainers as a single "maintainers" tag per NIP-34
// Format: ["maintainers", "<pubkey1-hex>", "<pubkey2-hex>", ...]
if !maintainers.is_empty() {
let maintainer_pubkeys: Vec<String> = maintainers
.iter()
.map(|k| k.public_key().to_hex())
.collect();
tags.push(Tag::custom("maintainers", maintainer_pubkeys));
}
// Add clone and relay tags for validity
tags.push(Tag::custom(
"clone",
vec!["https://example.com/test.git".to_string()],
));
tags.push(Tag::custom("relays", vec!["wss://example.com".to_string()]));
EventBuilder::new(Kind::GitRepoAnnouncement, "Test repo")
.tags(tags)
.finalize(keys)
.unwrap()
}
fn create_state_event(keys: &Keys, identifier: &str, branches: &[(&str, &str)]) -> Event {
let mut tags = vec![Tag::custom("d", vec![identifier.to_string()])];
for (branch, commit) in branches {
tags.push(Tag::custom(
format!("refs/heads/{}", branch),
vec![commit.to_string()],
));
}
EventBuilder::new(Kind::RepoState, "")
.tags(tags)
.finalize(keys)
.unwrap()
}
fn parse(event: Event) -> RepositoryAnnouncement {
RepositoryAnnouncement::from_event(event).unwrap()
}
fn hex(keys: &Keys) -> String {
keys.public_key().to_hex()
}
#[test]
fn test_owner_is_sole_state_maintainer() {
let alice = create_test_keys();
let identifier = "test-repo";
let announcements = vec![parse(create_announcement_event(&alice, identifier, &[]))];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert_eq!(membership.state_maintainers, HashSet::from([hex(&alice)]));
assert!(membership.invited.is_empty());
}
#[test]
fn test_no_owner_announcement_means_no_membership() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Only Bob has announced; Alice's repository has no membership.
let announcements = vec![parse(create_announcement_event(&bob, identifier, &[]))];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.is_empty());
assert!(membership.invited.is_empty());
}
#[test]
fn test_listed_maintainer_without_announcement_is_invited() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Alice lists Bob, but Bob has published no announcement
let announcements = vec![parse(create_announcement_event(
&alice,
identifier,
&[&bob],
))];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(!membership.state_maintainers.contains(&hex(&bob)));
assert!(membership.invited.contains(&hex(&bob)));
let by_owner = collect_authorized_maintainers(&announcements);
assert!(!by_owner[&hex(&alice)].contains(&hex(&bob)));
}
#[test]
fn test_reciprocal_maintainer_is_confirmed() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
let announcements = vec![
parse(create_announcement_event(&alice, identifier, &[&bob])),
parse(create_announcement_event(&bob, identifier, &[&alice])),
];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.contains(&hex(&alice)));
assert!(membership.state_maintainers.contains(&hex(&bob)));
assert!(membership.invited.is_empty());
}
#[test]
fn test_non_reciprocal_announcement_stays_invited() {
let alice = create_test_keys();
let bob = create_test_keys();
let charlie = create_test_keys();
let identifier = "test-repo";
// Bob announced the same identifier but does not list Alice back
let announcements = vec![
parse(create_announcement_event(&alice, identifier, &[&bob])),
parse(create_announcement_event(&bob, identifier, &[&charlie])),
];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(!membership.state_maintainers.contains(&hex(&bob)));
assert!(membership.invited.contains(&hex(&bob)));
}
#[test]
fn test_reciprocal_announcement_for_other_identifier_stays_invited() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Bob lists Alice back, but under a different identifier
let announcements = vec![
parse(create_announcement_event(&alice, identifier, &[&bob])),
parse(create_announcement_event(&bob, "other-repo", &[&alice])),
];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(!membership.state_maintainers.contains(&hex(&bob)));
assert!(membership.invited.contains(&hex(&bob)));
}
#[test]
fn test_recursive_reciprocal_chain_confirmed() {
let alice = create_test_keys();
let bob = create_test_keys();
let charlie = create_test_keys();
let identifier = "test-repo";
// Alice <-> Bob, Bob -> Charlie, Charlie -> Bob
let announcements = vec![
parse(create_announcement_event(&alice, identifier, &[&bob])),
parse(create_announcement_event(
&bob,
identifier,
&[&alice, &charlie],
)),
parse(create_announcement_event(&charlie, identifier, &[&bob])),
];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.contains(&hex(&alice)));
assert!(membership.state_maintainers.contains(&hex(&bob)));
assert!(membership.state_maintainers.contains(&hex(&charlie)));
}
#[test]
fn test_mutual_listing_without_owner_link_stays_invited() {
let alice = create_test_keys();
let bob = create_test_keys();
let charlie = create_test_keys();
let identifier = "test-repo";
// Bob and Charlie list each other but neither lists Alice: a mutual
// clique disconnected from the owner never becomes confirmed.
let announcements = vec![
parse(create_announcement_event(&alice, identifier, &[&bob])),
parse(create_announcement_event(&bob, identifier, &[&charlie])),
parse(create_announcement_event(&charlie, identifier, &[&bob])),
];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert_eq!(membership.state_maintainers, HashSet::from([hex(&alice)]));
assert!(membership.invited.contains(&hex(&bob)));
}
/// Build an announcement using NIP-34 indexed role tags.
/// Each entry is (tag name, values) where values start with the pubkey
/// followed by optional history timestamps.
fn create_role_announcement(
keys: &Keys,
identifier: &str,
roles: &[(&str, Vec<String>)],
) -> Event {
let mut tags = vec![Tag::custom("d", vec![identifier.to_string()])];
for (name, values) in roles {
tags.push(Tag::custom(*name, values.clone()));
}
tags.push(Tag::custom(
"clone",
vec!["https://example.com/test.git".to_string()],
));
tags.push(Tag::custom("relays", vec!["wss://example.com".to_string()]));
EventBuilder::new(Kind::GitRepoAnnouncement, "Test repo")
.tags(tags)
.finalize(keys)
.unwrap()
}
#[test]
fn test_role_tag_maintainer_confirmed_when_reciprocal() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
let announcements = vec![
parse(create_role_announcement(
&alice,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
)),
parse(create_role_announcement(
&bob,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
)),
];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.contains(&hex(&bob)));
}
#[test]
fn test_ended_role_is_no_longer_maintainer() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Alice's announcement records Bob's co-maintainer role as ended
let alice_announcement = create_role_announcement(
&alice,
identifier,
&[
("M", vec![hex(&alice)]),
(
"m",
vec![hex(&bob), "0".to_string(), "1700000000".to_string()],
),
],
);
// Bob still lists himself and Alice as active
let bob_announcement = create_role_announcement(
&bob,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
);
let announcements = vec![parse(alice_announcement), parse(bob_announcement)];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(!membership.state_maintainers.contains(&hex(&bob)));
assert!(
!membership.invited.contains(&hex(&bob)),
"an ended role must not be treated as an open invitation"
);
}
#[test]
fn test_returning_maintainer_history_is_active() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Odd element count: added, removed, then added again
let alice_announcement = create_role_announcement(
&alice,
identifier,
&[
("M", vec![hex(&alice)]),
(
"m",
vec![
hex(&bob),
"100".to_string(),
"200".to_string(),
"300".to_string(),
],
),
],
);
let bob_announcement = create_role_announcement(
&bob,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
);
let announcements = vec![parse(alice_announcement), parse(bob_announcement)];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.contains(&hex(&bob)));
}
#[test]
fn test_self_leave_takes_precedence() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Alice still lists Bob as active
let alice_announcement = create_role_announcement(
&alice,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
);
// Bob ended his own role: he has left
let bob_announcement = create_role_announcement(
&bob,
identifier,
&[
("M", vec![hex(&alice)]),
(
"m",
vec![hex(&bob), "0".to_string(), "1700000000".to_string()],
),
],
);
let announcements = vec![parse(alice_announcement), parse(bob_announcement)];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(!membership.state_maintainers.contains(&hex(&bob)));
assert!(!membership.invited.contains(&hex(&bob)));
}
#[test]
fn test_moderator_is_not_a_state_maintainer() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Alice lists Bob as moderator; Bob acknowledges with a matching
// self-`o` and lists the lead. Moderators are not maintainers: Bob
// is neither state-authorized nor treated as an open invitation.
let announcements = vec![
parse(create_role_announcement(
&alice,
identifier,
&[("M", vec![hex(&alice)]), ("o", vec![hex(&bob)])],
)),
parse(create_role_announcement(
&bob,
identifier,
&[("M", vec![hex(&alice)]), ("o", vec![hex(&bob)])],
)),
];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert_eq!(membership.state_maintainers, HashSet::from([hex(&alice)]));
assert!(membership.invited.is_empty());
}
#[test]
fn test_subordinate_fork_marker_does_not_block_acceptance() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Alice invites Bob; Bob's announcement carries a `u` (subordinate
// fork) tag. Per NIP-34 `u` has no effect on maintainership, so
// Bob's listing of Alice still acknowledges the invitation.
let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]);
let mut tags = vec![
Tag::custom("d", vec![identifier.to_string()]),
Tag::custom("maintainers", vec![hex(&alice)]),
Tag::custom("u", vec![format!("30617:{}:{}", hex(&alice), identifier)]),
];
tags.push(Tag::custom(
"clone",
vec!["https://example.com/test.git".to_string()],
));
tags.push(Tag::custom("relays", vec!["wss://example.com".to_string()]));
let bob_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repo")
.tags(tags)
.finalize(&bob)
.unwrap();
let announcements = vec![parse(alice_announcement), parse(bob_announcement)];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.contains(&hex(&bob)));
assert!(!membership.invited.contains(&hex(&bob)));
}
#[test]
fn test_validate_push_refs_success() {
let alice = create_test_keys();
let identifier = "test-repo";
let state_event = create_state_event(&alice, identifier, &[("main", "abc123def456")]);
let state = RepositoryState::from_event(state_event).unwrap();
let pushed_refs = vec![(
"0".repeat(40),
"abc123def456".to_string() + &"0".repeat(28),
"refs/heads/main".to_string(),
)];
// This should pass since we're allowing new branches for now
let result = validate_push_refs(&state, &pushed_refs);
// The branch name matches, but commit doesn't match exactly - this tests the logic
assert!(result.is_ok() || result.is_err());
}
#[test]
fn test_parse_pushed_refs() {
let old = "0".repeat(40);
let new = "a".repeat(40);
let data = format!("{} {} refs/heads/main\0 report-status\n", old, new);
let refs = parse_pushed_refs(data.as_bytes());
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].0, old);
assert_eq!(refs[0].1, new);
assert_eq!(refs[0].2, "refs/heads/main");
}
#[test]
fn test_parse_pushed_refs_pktline_format() {
// Build a pkt-line formatted push request like git client sends
// Format: 4-byte hex length + payload
// Payload: "old_oid new_oid ref_name\0capabilities\n"
let old = "0".repeat(40);
let new = "a".repeat(40);
let ref_name = "refs/heads/main";
let capabilities = " report-status side-band-64k";
// Build the pkt-line payload
let payload = format!("{} {} {}\0{}\n", old, new, ref_name, capabilities);
// Calculate length (4-byte prefix + payload)
let len = 4 + payload.len();
let pktline = format!("{:04x}{}", len, payload);
// Add flush packet to end
let data = format!("{}0000", pktline);
let refs = parse_pushed_refs(data.as_bytes());
assert_eq!(refs.len(), 1, "Expected 1 ref, got {}", refs.len());
assert_eq!(refs[0].0, old);
assert_eq!(refs[0].1, new);
assert_eq!(refs[0].2, ref_name);
}
#[test]
fn test_parse_pushed_refs_multiple_refs() {
// Test multiple refs in pkt-line format
let old1 = "0".repeat(40);
let new1 = "a".repeat(40);
let old2 = "b".repeat(40);
let new2 = "c".repeat(40);
// First ref with capabilities
let payload1 = format!("{} {} refs/heads/main\0report-status\n", old1, new1);
let len1 = 4 + payload1.len();
let pktline1 = format!("{:04x}{}", len1, payload1);
// Second ref without capabilities (subsequent refs don't have them)
let payload2 = format!("{} {} refs/heads/feature\n", old2, new2);
let len2 = 4 + payload2.len();
let pktline2 = format!("{:04x}{}", len2, payload2);
let data = format!("{}{}0000", pktline1, pktline2);
let refs = parse_pushed_refs(data.as_bytes());
assert_eq!(refs.len(), 2, "Expected 2 refs, got {}", refs.len());
assert_eq!(refs[0].2, "refs/heads/main");
assert_eq!(refs[1].2, "refs/heads/feature");
}
#[test]
fn test_npub_pubkey_conversion() {
let keys = create_test_keys();
let hex = keys.public_key().to_hex();
let npub = pubkey_to_npub(&hex).unwrap();
assert!(npub.starts_with("npub1"));
let back_to_hex = npub_to_pubkey(&npub).unwrap();
assert_eq!(hex, back_to_hex);
}
}