From 744094c61d6e65892bcdb5a29b90b845ce87559f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 28 Nov 2025 10:31:46 +0000 Subject: fix maintainer recursion --- src/git/authorization.rs | 367 ++++++++++++++++++++++++++++++++++++++++++++++- src/git/handlers.rs | 104 +++++--------- 2 files changed, 403 insertions(+), 68 deletions(-) (limited to 'src/git') diff --git a/src/git/authorization.rs b/src/git/authorization.rs index 16498c1..1be3de9 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs @@ -9,7 +9,7 @@ //! //! ## Authorization Flow (Efficient Single-Query Approach) //! -//! 1. Fetch announcement and state events for the repository from the relay +//! 1. Fetch announcement and state events for the repository from the relay database //! 2. Collect all authorized publishers: announcement authors + listed maintainers //! 3. Find the latest state event authored by any authorized publisher //! 4. Validate that the pushed refs match the state event @@ -20,16 +20,377 @@ //! same identifier: //! - They are the author of that announcement, OR //! - They are listed in the "maintainers" tag of that announcement +//! +//! ## 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 nostr_sdk::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32}; -use std::collections::HashSet; +use nostr_relay_builder::prelude::*; +use nostr_sdk::ToBech32; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use tracing::debug; use crate::nostr::events::{ RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, }; +/// 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, + /// All repository state events with this identifier + pub states: Vec, +} + +/// 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( + database: &Arc, + identifier: &str, +) -> Result { + let filter = Filter::new() + .kinds([ + Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), + Kind::from(KIND_REPOSITORY_STATE), + ]) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + identifier.to_string(), + ); + + let events: Vec = 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::from(KIND_REPOSITORY_ANNOUNCEMENT) { + if let Ok(announcement) = RepositoryAnnouncement::from_event(event) { + announcements.push(announcement); + } + } else if event.kind == Kind::from(KIND_REPOSITORY_STATE) { + if let Ok(state) = RepositoryState::from_event(event) { + states.push(state); + } + } + } + + debug!( + "Parsed {} announcements and {} states for identifier {}", + announcements.len(), + states.len(), + identifier + ); + + Ok(RepositoryData { + announcements, + states, + }) +} + +/// Collect authorized maintainers grouped by owner from a set of announcements +/// +/// For each announcement, returns a map from owner pubkey to authorized maintainers: +/// - The owner is always included in their own list +/// - All pubkeys listed in the "maintainers" tag are also included +/// - **Recursively**: if a maintainer also has an announcement for the same identifier, +/// their maintainers are included too (transitive closure) +/// +/// This allows looking up who can publish state events for a specific owner's +/// version of the repository. +/// +/// ## Example +/// +/// If Alice's announcement lists Bob as maintainer, and Bob's announcement (for the +/// same identifier) lists Charlie as maintainer, then Alice's authorized set will +/// be {Alice, Bob, Charlie}. +pub fn collect_authorized_maintainers( + announcements: &[RepositoryAnnouncement], +) -> HashMap> { + let mut by_owner: HashMap> = HashMap::new(); + + for announcement in announcements { + let owner = announcement.event.pubkey.to_hex(); + let identifier = &announcement.identifier; + + // Use recursive helper to get all maintainers + let mut checked: HashSet = HashSet::new(); + get_maintainers_recursive(announcements, &owner, identifier, &mut checked); + + by_owner.insert(owner, checked.into_iter().collect()); + } + + debug!( + "Collected maintainers for {} owners from {} announcements (with recursive expansion)", + by_owner.len(), + announcements.len() + ); + + by_owner +} + +/// Recursively find all maintainers starting from a pubkey +/// +/// This follows the pattern from ngit-relay's GetMaintainers function: +/// - If pubkey already checked, return early (cycle prevention) +/// - Mark pubkey as checked +/// - Find the announcement for this pubkey+identifier +/// - Recursively call for each maintainer listed in that announcement +/// - The `checked` set accumulates all visited pubkeys +fn get_maintainers_recursive( + announcements: &[RepositoryAnnouncement], + pubkey: &str, + identifier: &str, + checked: &mut HashSet, +) { + // Check if this pubkey has already been processed + if checked.contains(pubkey) { + return; // Already checked - avoid cycles + } + checked.insert(pubkey.to_string()); // Mark as checked + + // Find the announcement event for this pubkey+identifier + let announcement = announcements.iter().find(|a| { + a.event.pubkey.to_hex() == pubkey && a.identifier == identifier + }); + + let Some(announcement) = announcement else { + return; // No announcement found for this pubkey + }; + + // Recursively find maintainers for each listed maintainer + for maintainer_pubkey in &announcement.maintainers { + get_maintainers_recursive(announcements, maintainer_pubkey, identifier, checked); + } +} + +/// Collect all authorized maintainers as a flat set from all announcements +/// +/// This is a convenience function that flattens the per-owner maintainer lists +/// into a single set. Use this when you don't need owner-specific authorization. +pub fn collect_all_authorized_maintainers( + announcements: &[RepositoryAnnouncement], +) -> HashSet { + let by_owner = collect_authorized_maintainers(announcements); + let mut all_authorized = HashSet::new(); + + for maintainers in by_owner.values() { + for maintainer in maintainers { + all_authorized.insert(maintainer.clone()); + } + } + + debug!( + "Collected {} total authorized maintainers from {} owners", + all_authorized.len(), + by_owner.len() + ); + + all_authorized +} + +/// Find the latest state event authored by an authorized maintainer +/// +/// Returns the state with the highest created_at timestamp among those +/// authored by pubkeys in the authorized set. +pub fn find_latest_authorized_state<'a>( + states: &'a [RepositoryState], + authorized_pubkeys: &HashSet, +) -> Option<&'a RepositoryState> { + states + .iter() + .filter(|s| { + let pubkey_hex = s.event.pubkey.to_hex(); + authorized_pubkeys.contains(&pubkey_hex) + }) + .max_by_key(|s| s.event.created_at) +} + +/// Find the latest authorized state for a specific announcement context +/// +/// This is similar to `find_latest_authorized_state` but considers only +/// the maintainers authorized for a specific announcement (owner + maintainers), +/// not the global set across all announcements. +pub fn find_latest_state_for_announcement<'a>( + states: &'a [RepositoryState], + announcement: &RepositoryAnnouncement, +) -> Option<&'a RepositoryState> { + // Build the authorized set for this specific announcement + let mut authorized = HashSet::new(); + authorized.insert(announcement.event.pubkey.to_hex()); + for maintainer in &announcement.maintainers { + authorized.insert(maintainer.clone()); + } + + find_latest_authorized_state(states, &authorized) +} + +/// Check if a state event is the latest for its identifier among given authorized authors +/// +/// A state is considered "latest" if no other state in the provided list +/// from an authorized author has a newer timestamp. +pub fn is_latest_state( + state: &RepositoryState, + all_states: &[RepositoryState], + authorized_pubkeys: &HashSet, +) -> bool { + for other in all_states { + // Skip self + if other.event.id == state.event.id { + continue; + } + // Only compare against authorized authors + if !authorized_pubkeys.contains(&other.event.pubkey.to_hex()) { + continue; + } + // If any authorized state is newer, this is not the latest + if other.event.created_at > state.event.created_at { + return false; + } + } + true +} + +/// Get the authorization result for a repository from the database +/// +/// This is the main entry point for authorization that queries the database directly. +/// It: +/// 1. Fetches all announcements and states for the identifier with a single query +/// 2. Collects all authorized maintainers from announcements +/// 3. Finds the latest state event from an authorized maintainer +/// +/// Returns an `AuthorizationResult` that indicates whether a push is authorized. +pub async fn get_authorization_from_db( + database: &Arc, + identifier: &str, +) -> Result { + // Fetch all repository data with a single query + let repo_data = fetch_repository_data(database, identifier).await?; + + if repo_data.announcements.is_empty() { + return Ok(AuthorizationResult::denied( + "No repository announcement found", + )); + } + + // Collect all authorized maintainers (flattened across all owners) + let authorized = collect_all_authorized_maintainers(&repo_data.announcements); + + if authorized.is_empty() { + return Ok(AuthorizationResult::denied( + "No authorized maintainers found", + )); + } + + debug!( + "Found {} authorized maintainers for repository {}", + authorized.len(), + identifier + ); + + // Find the latest authorized state + match find_latest_authorized_state(&repo_data.states, &authorized) { + Some(state) => Ok(AuthorizationResult::authorized( + state.clone(), + authorized.into_iter().collect(), + )), + None => Ok(AuthorizationResult::denied( + "No state event found from authorized publishers", + )), + } +} + +/// Get the authorization result for a repository scoped to a specific owner +/// +/// Unlike `get_authorization_from_db`, this function scopes the authorization +/// to a specific owner's announcement. This is the correct approach for Git push +/// authorization where the URL path specifies the owner. +/// +/// 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 all announcements and states for the identifier +/// 2. Collects authorized maintainers from all announcements (grouped by owner) +/// 3. Looks up the authorized set for the specific owner +/// 4. Finds the latest state event from an authorized maintainer +/// +/// Returns an `AuthorizationResult` that indicates whether a push is authorized. +pub async fn get_authorization_for_owner( + database: &Arc, + identifier: &str, + owner_pubkey: &str, +) -> Result { + // Fetch all repository data with a single query + let repo_data = fetch_repository_data(database, 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 = 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 + ); + + // Find the latest authorized state from owner's maintainer set + match find_latest_authorized_state(&repo_data.states, &authorized) { + Some(state) => Ok(AuthorizationResult::authorized( + state.clone(), + authorized.into_iter().collect(), + )), + None => Ok(AuthorizationResult::denied( + "No state event found from authorized publishers", + )), + } +} + /// Result of authorization check #[derive(Debug)] pub struct AuthorizationResult { diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 73f72f3..7974d8a 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs @@ -3,17 +3,19 @@ //! This module implements the HTTP handlers for Git Smart HTTP protocol. use std::path::PathBuf; +use std::sync::Arc; use hyper::{body::Bytes, Response, StatusCode}; use http_body_util::Full; +use nostr_relay_builder::prelude::MemoryDatabase; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tracing::{debug, error, info, warn}; use super::authorization::{ - AuthorizationContext, AuthorizationResult, parse_pushed_refs, validate_push_refs, + get_authorization_for_owner, parse_pushed_refs, validate_push_refs, AuthorizationResult, }; use super::protocol::{GitService, PktLine}; use super::subprocess::GitSubprocess; -use super::{try_set_head_if_available}; +use super::try_set_head_if_available; use crate::nostr::events::RepositoryState; @@ -150,17 +152,6 @@ pub async fn handle_upload_pack( .unwrap()) } -/// Authorization parameters for push operations -#[derive(Debug, Clone)] -pub struct PushAuthParams { - /// The relay URL for fetching events (e.g., "ws://localhost:8080") - pub relay_url: String, - /// The npub of the repository owner - pub owner_npub: String, - /// The repository identifier (d tag) - pub identifier: String, -} - /// Handle POST /git-receive-pack (push) /// /// This includes GRASP authorization validation according to GRASP-01: @@ -169,10 +160,19 @@ pub struct PushAuthParams { /// /// Also per GRASP-01: "MUST set repository HEAD per repository state announcement /// as soon as the git data related to that branch has been received." +/// +/// # Arguments +/// * `repo_path` - Path to the bare git repository +/// * `request_body` - The git pack data from the client +/// * `database` - Optional database reference for authorization queries +/// * `identifier` - The repository identifier (d tag) for authorization lookup +/// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization pub async fn handle_receive_pack( repo_path: PathBuf, request_body: Bytes, - auth_params: Option, + database: Option>, + identifier: &str, + owner_pubkey: &str, ) -> Result>, GitError> { debug!("Handling receive-pack for {:?}", repo_path); @@ -183,26 +183,25 @@ pub async fn handle_receive_pack( // Keep track of state for HEAD setting after push let mut authorized_state: Option = None; - // GRASP Authorization Check - if let Some(ref params) = auth_params { + // GRASP Authorization Check (if database is provided) + if let Some(ref db) = database { info!( - "Authorizing push for {}/{} via {}", - params.owner_npub, params.identifier, params.relay_url + "Authorizing push for {} owned by {} via database query", + identifier, owner_pubkey ); - match authorize_push(params, &request_body).await { + match authorize_push(db, identifier, owner_pubkey, &request_body).await { Ok(auth_result) => { if !auth_result.authorized { warn!( - "Push rejected for {}/{}: {}", - params.owner_npub, params.identifier, auth_result.reason + "Push rejected for {}: {}", + identifier, auth_result.reason ); return Err(GitError::Unauthorized); } info!( - "Push authorized for {}/{} - {} maintainers", - params.owner_npub, - params.identifier, + "Push authorized for {} - {} maintainers", + identifier, auth_result.maintainers.len() ); // Save the state for HEAD setting after push @@ -210,14 +209,14 @@ pub async fn handle_receive_pack( } Err(e) => { warn!( - "Authorization check failed for {}/{}: {}", - params.owner_npub, params.identifier, e + "Authorization check failed for {}: {}", + identifier, e ); return Err(GitError::Unauthorized); } } } else { - debug!("No authorization parameters provided - accepting push"); + debug!("No database provided - accepting push without authorization"); } // Spawn git receive-pack @@ -299,50 +298,25 @@ pub async fn handle_receive_pack( /// Perform GRASP authorization for a push operation /// -/// This function: -/// 1. Fetches announcement and state events from the relay -/// 2. Collects all authorized publishers from announcements -/// 3. Gets the latest authorized state -/// 4. Validates that pushed refs match the state +/// This function queries the database directly (not via WebSocket): +/// 1. Fetches announcement and state events for the identifier +/// 2. Filters to the specific owner's announcement +/// 3. Collects authorized publishers from that announcement (owner + maintainers) +/// 4. Gets the latest authorized state from those publishers +/// 5. Validates that pushed refs match the state async fn authorize_push( - params: &PushAuthParams, + database: &Arc, + identifier: &str, + owner_pubkey: &str, request_body: &Bytes, ) -> anyhow::Result { - use nostr_sdk::ClientBuilder; - use std::time::Duration; - debug!( - "Fetching events for identifier {} from relay {}", - params.identifier, params.relay_url + "Authorizing push for {} owned by {} via database query", + identifier, owner_pubkey ); - // Create a Nostr client to fetch events - let client = ClientBuilder::default().build(); - client.add_relay(¶ms.relay_url).await?; - client.connect().await; - - // Create filter for repository events - let filter = AuthorizationContext::create_filter(¶ms.identifier); - - // Fetch events with timeout - let events = client.fetch_events(filter, Duration::from_secs(5)) - .await - .map_err(|e| anyhow::anyhow!("Failed to fetch events: {}", e))?; - - let events: Vec<_> = events.into_iter().collect(); - debug!("Fetched {} events from relay", events.len()); - - if events.is_empty() { - return Ok(AuthorizationResult::denied( - "No repository announcement or state events found on relay", - )); - } - - // Create authorization context - let ctx = AuthorizationContext::new(events); - - // Get the authorized state (no owner_pubkey needed - self-contained check) - let auth_result = ctx.get_authorized_state(¶ms.identifier)?; + // Get authorization result from database, scoped to specific owner + let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; if !auth_result.authorized { return Ok(auth_result); -- cgit v1.2.3