From 819866330c7e2f535a155d1d7efaf2e12dc15dc2 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 4 Dec 2025 15:42:00 +0000 Subject: refactor: split Nip34WritePolicy into focused sub-policies Split the ~900 line Nip34WritePolicy into focused sub-policies for improved testability and maintainability: - AnnouncementPolicy - Repository announcement validation - StatePolicy - State event validation + ref alignment - PrEventPolicy - PR/PR Update validation - RelatedEventPolicy - Forward/backward reference checking The main Nip34WritePolicy now delegates to these sub-policies via a shared PolicyContext that provides domain, database, and git_data_path. Also updates: - README.md: Accurate project structure reflecting actual implementation - docs/learnings: Marks this technical debt item as complete --- src/nostr/builder.rs | 1334 +++++--------------------------------- src/nostr/mod.rs | 4 + src/nostr/policy/announcement.rs | 157 +++++ src/nostr/policy/mod.rs | 41 ++ src/nostr/policy/pr_event.rs | 198 ++++++ src/nostr/policy/related.rs | 276 ++++++++ src/nostr/policy/state.rs | 419 ++++++++++++ 7 files changed, 1260 insertions(+), 1169 deletions(-) create mode 100644 src/nostr/policy/announcement.rs create mode 100644 src/nostr/policy/mod.rs create mode 100644 src/nostr/policy/pr_event.rs create mode 100644 src/nostr/policy/related.rs create mode 100644 src/nostr/policy/state.rs (limited to 'src') diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 00e5969..15ff083 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -1,64 +1,51 @@ /// Nostr Relay Builder Configuration /// /// This module integrates nostr-relay-builder with NIP-34 validation logic -/// preserved from the original implementation. +/// using modular sub-policies for each event type. use std::net::SocketAddr; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use nostr::nips::nip19::ToBech32; -use nostr::prelude::{Alphabet, SingleLetterTag}; -use nostr::{EventId, Filter, Kind, PublicKey}; use nostr_lmdb::NostrLMDB; use nostr_relay_builder::prelude::*; use crate::config::{Config, DatabaseBackend}; -use crate::git; use crate::nostr::events::{ - validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, KIND_PR, - KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, + RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, + KIND_REPOSITORY_STATE, +}; +use crate::nostr::policy::{ + AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, RelatedEventPolicy, + ReferenceResult, StatePolicy, StateResult, }; /// Type alias for the shared database used by the relay pub type SharedDatabase = Arc; -/// Result of aligning a repository with authorized state -#[derive(Debug, Default)] -struct AlignmentResult { - /// Number of refs created - refs_created: usize, - /// Number of refs updated - refs_updated: usize, - /// Number of refs deleted - refs_deleted: usize, - /// Whether HEAD was set - head_set: bool, -} - /// NIP-34 Write Policy with Full GRASP-01 Event Validation /// -/// Validates all events according to GRASP-01 specification: -/// - Repository announcements must list service in clone and relays tags -/// EXCEPTION: Recursive maintainer announcements are accepted even without -/// listing the service, to enable maintainer chain discovery and GRASP-02 sync -/// - Repository state announcements must have valid structure -/// - Other events must reference accepted repositories or events -/// - Forward references are supported (events referenced by accepted events) -/// - Orphan events with no valid references are rejected +/// Validates all events according to GRASP-01 specification using modular sub-policies: +/// - `AnnouncementPolicy` - Repository announcement validation +/// - `StatePolicy` - State event validation + ref alignment +/// - `PrEventPolicy` - PR/PR Update validation +/// - `RelatedEventPolicy` - Forward/backward reference checking /// /// Uses stateful database queries to check event relationships. #[derive(Clone)] pub struct Nip34WritePolicy { - domain: String, - database: SharedDatabase, - git_data_path: PathBuf, + ctx: PolicyContext, + announcement_policy: AnnouncementPolicy, + state_policy: StatePolicy, + pr_event_policy: PrEventPolicy, + related_event_policy: RelatedEventPolicy, } impl std::fmt::Debug for Nip34WritePolicy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Nip34WritePolicy") - .field("domain", &self.domain) - .field("git_data_path", &self.git_data_path) + .field("domain", &self.ctx.domain) + .field("git_data_path", &self.ctx.git_data_path) .field("database", &"") .finish() } @@ -68,837 +55,200 @@ impl Nip34WritePolicy { pub fn new( domain: impl Into, database: SharedDatabase, - git_data_path: impl Into, + git_data_path: impl Into, ) -> Self { + let ctx = PolicyContext::new(domain, database, git_data_path); Self { - domain: domain.into(), - database, - git_data_path: git_data_path.into(), + announcement_policy: AnnouncementPolicy::new(ctx.clone()), + state_policy: StatePolicy::new(ctx.clone()), + pr_event_policy: PrEventPolicy::new(ctx.clone()), + related_event_policy: RelatedEventPolicy::new(ctx.clone()), + ctx, } } - /// Create a bare git repository if it doesn't exist - /// Path format: //.git - fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { - let repo_path = self.git_data_path.join(announcement.repo_path()); - - // Check if repository already exists - if repo_path.exists() { - tracing::debug!("Repository already exists at {}", repo_path.display()); - return Ok(()); - } - - // Create parent directory (npub directory) - let parent = repo_path - .parent() - .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?; - - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; - - // Initialize bare repository using git command - let output = std::process::Command::new("git") - .args(["init", "--bare", repo_path.to_str().unwrap()]) - .output() - .map_err(|e| format!("Failed to execute git init: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git init failed: {}", stderr)); - } - - tracing::info!("Created bare repository at {}", repo_path.display()); - Ok(()) - } - - /// Check if this state event is the latest for its identifier among authorized authors - /// - /// A state is considered "latest" if no other state event in the database - /// from an authorized author has a newer timestamp. This handles out-of-order - /// delivery where an older event arrives after a newer one. - /// - /// The authorized_pubkeys should be the owner and maintainers of a specific - /// announcement, so different owners with the same identifier don't interfere. - async fn is_latest_state_for_identifier( - database: &SharedDatabase, - state: &RepositoryState, - authorized_pubkeys: &[PublicKey], - ) -> Result { - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_STATE)) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - state.identifier.clone(), - ); + /// Handle repository announcement event + async fn handle_announcement(&self, event: &Event) -> PolicyResult { + let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); + + match self.announcement_policy.validate(event).await { + AnnouncementResult::Accept => { + // Parse announcement to get repository details + match RepositoryAnnouncement::from_event(event.clone()) { + Ok(announcement) => { + // Try to create bare repository if it doesn't exist + if let Err(e) = self.announcement_policy.ensure_bare_repository(&announcement) + { + tracing::warn!( + "Failed to create bare repository for {}: {}", + event_id_str, + e + ); + // Note: We still accept the event even if repo creation fails + } - match database.query(filter).await { - Ok(events) => { - for event in events { - // Skip comparing to self (same event ID) - if event.id == state.event.id { - continue; + tracing::debug!("Accepted repository announcement: {}", event_id_str); + PolicyResult::Accept } - // Only consider events from authorized authors for this announcement - if !authorized_pubkeys.contains(&event.pubkey) { - continue; - } - // If any existing event from an authorized author is newer, this is not the latest - if event.created_at > state.event.created_at { - tracing::debug!( - "State {} is not latest: found newer state {} from {} (ts {} > {})", - state.event.id.to_hex(), - event.id.to_hex(), - event.pubkey.to_hex(), - event.created_at.as_secs(), - state.event.created_at.as_secs() + Err(e) => { + tracing::warn!( + "Failed to parse repository announcement {}: {}", + event_id_str, + e ); - return Ok(false); + PolicyResult::Reject(format!("Failed to parse announcement: {}", e)) } } - Ok(true) } - Err(e) => Err(format!("Database query failed: {}", e)), - } - } - - /// Find all repository announcements where the given pubkey is authorized - /// - /// A pubkey is authorized for an announcement if: - /// - They are the owner (pubkey of the announcement event), OR - /// - They are listed in the "maintainers" tag - /// - /// This is needed because a maintainer can publish a state event that - /// should update HEAD in the repository of the announcement owner, - /// not in the maintainer's own (possibly non-existent) repository. - async fn find_authorized_announcements( - database: &SharedDatabase, - identifier: &str, - state_author: &PublicKey, - ) -> Result, String> { - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - identifier.to_string(), - ); - - match database.query(filter).await { - Ok(events) => { - let mut authorized = Vec::new(); - let state_author_hex = state_author.to_hex(); - - for event in events { - if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { - // Check if state author is authorized for this announcement - let is_owner = event.pubkey == *state_author; - let is_maintainer = announcement.maintainers.contains(&state_author_hex); - - if is_owner || is_maintainer { - tracing::debug!( - "Found authorized announcement for {}: owner={}, maintainer={}", - identifier, - if is_owner { - event.pubkey.to_hex() - } else { - "n/a".to_string() - }, - is_maintainer - ); - authorized.push(announcement); - } + AnnouncementResult::AcceptMaintainer => { + // Parse announcement to get details for logging + match RepositoryAnnouncement::from_event(event.clone()) { + Ok(announcement) => { + tracing::info!( + "Accepted maintainer announcement {} (author {} is listed as maintainer for {})", + event_id_str, + event.pubkey.to_hex(), + announcement.identifier + ); + // Don't create bare repository for external announcements + PolicyResult::Accept + } + Err(e) => { + tracing::warn!( + "Failed to parse maintainer announcement {}: {}", + event_id_str, + e + ); + PolicyResult::Reject(format!("Failed to parse announcement: {}", e)) } - } - Ok(authorized) - } - Err(e) => Err(format!("Database query failed: {}", e)), - } - } - - /// Identify all owner repositories for which this state event is the latest authorized state - /// - /// Returns a list of (announcement, repo_path) pairs where: - /// - The state author is authorized (owner or maintainer) - /// - This state event is the latest for the identifier in that context - async fn identify_owner_repositories( - &self, - database: &SharedDatabase, - state: &RepositoryState, - ) -> Result, String> { - // Find all announcements where state author is authorized - let announcements = - Self::find_authorized_announcements(database, &state.identifier, &state.event.pubkey) - .await?; - - if announcements.is_empty() { - tracing::debug!( - "No authorized announcements found for state {} by {}", - state.identifier, - state.event.pubkey.to_hex() - ); - return Ok(Vec::new()); - } - - let mut owner_repos = Vec::new(); - - for announcement in announcements { - // Build the list of authorized pubkeys for this specific announcement - // (owner + maintainers) - let mut authorized_pubkeys = vec![announcement.event.pubkey]; - for maintainer_hex in &announcement.maintainers { - if let Ok(pk) = PublicKey::from_hex(maintainer_hex) { - authorized_pubkeys.push(pk); } } - - // Check if this is the latest state event for THIS announcement's context - if !Self::is_latest_state_for_identifier(database, state, &authorized_pubkeys).await? { - tracing::debug!( - "Skipping {} in {}'s repo - not the latest state event for this context", - state.identifier, - announcement.event.pubkey.to_hex() + AnnouncementResult::Reject(reason) => { + tracing::warn!( + "Rejected repository announcement {}: {}", + event_id_str, + reason ); - continue; + PolicyResult::Reject(reason) } - - // Build repository path: //.git - let repo_path = self.git_data_path.join(announcement.repo_path().clone()); - owner_repos.push((announcement, repo_path)); } - - Ok(owner_repos) } - /// Align an owner repository's refs with the authorized state - /// - /// This function: - /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/) - /// 2. Updates refs that exist in state if we have the commit (for refs/heads/ and refs/tags/) - /// 3. Sets HEAD if the HEAD branch's commit is available - /// - /// Per GRASP-01: "MUST set repository HEAD per repository state announcement - /// as soon as the git data related to that branch has been received." - /// - /// Returns a summary of actions taken. - fn align_owner_repository_with_state( - &self, - repo_path: &std::path::Path, - state: &RepositoryState, - ) -> AlignmentResult { - let mut result = AlignmentResult::default(); - - // Check if repository exists - if !repo_path.exists() { - tracing::debug!( - "Repository not found at {}, cannot align with state", - repo_path.display() - ); - return result; - } - - // Get current refs from the repository - let current_refs = match git::list_refs(repo_path) { - Ok(refs) => refs, - Err(e) => { - tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e); - return result; - } - }; - - // Build expected refs from state - let mut expected_refs: std::collections::HashMap = - std::collections::HashMap::new(); - - for branch in &state.branches { - let ref_name = format!("refs/heads/{}", branch.name); - expected_refs.insert(ref_name, branch.commit.clone()); - } - - for tag in &state.tags { - let ref_name = format!("refs/tags/{}", tag.name); - expected_refs.insert(ref_name, tag.commit.clone()); - } - - // Process current refs: update or delete as needed - for (ref_name, current_commit) in ¤t_refs { - // Only process refs/heads/ and refs/tags/ - if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") { - continue; - } - - match expected_refs.get(ref_name) { - Some(expected_commit) => { - // Ref should exist - check if commit matches - if current_commit != expected_commit { - // Check if we have the expected commit - if git::commit_exists(repo_path, expected_commit) { - // Update the ref - match git::update_ref(repo_path, ref_name, expected_commit) { - Ok(()) => { - tracing::info!( - "Updated {} from {} to {} in {}", - ref_name, - current_commit, - expected_commit, - repo_path.display() - ); - result.refs_updated += 1; - } - Err(e) => { - tracing::warn!( - "Failed to update {} in {}: {}", - ref_name, - repo_path.display(), - e - ); - } - } - } else { - tracing::debug!( - "Commit {} not available for {} in {}", - expected_commit, - ref_name, - repo_path.display() - ); - } - } - } - None => { - // Ref should not exist - delete it - match git::delete_ref(repo_path, ref_name) { - Ok(()) => { - tracing::info!( - "Deleted {} (not in state) from {}", - ref_name, - repo_path.display() - ); - result.refs_deleted += 1; - } - Err(e) => { + /// Handle repository state event + async fn handle_state(&self, event: &Event) -> PolicyResult { + let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); + + match self.state_policy.validate(event) { + StateResult::Accept => { + // Parse state to get HEAD and branch info + match RepositoryState::from_event(event.clone()) { + Ok(_state) => { + // Process state alignment asynchronously + if let Err(e) = self.state_policy.process_state_event(event).await { tracing::warn!( - "Failed to delete {} from {}: {}", - ref_name, - repo_path.display(), + "Failed to process state event {}: {}", + event_id_str, e ); } - } - } - } - } - // Add refs that exist in state but not in repo (if we have the commit) - for (ref_name, expected_commit) in &expected_refs { - let exists = current_refs.iter().any(|(r, _)| r == ref_name); - if !exists && git::commit_exists(repo_path, expected_commit) { - match git::update_ref(repo_path, ref_name, expected_commit) { - Ok(()) => { - tracing::info!( - "Created {} at {} in {}", - ref_name, - expected_commit, - repo_path.display() - ); - result.refs_created += 1; + tracing::debug!("Accepted repository state: {}", event_id_str); + PolicyResult::Accept } Err(e) => { tracing::warn!( - "Failed to create {} in {}: {}", - ref_name, - repo_path.display(), + "Failed to parse repository state {}: {}", + event_id_str, e ); + // Still accept the event even if we can't parse it + // The validation passed, so it's structurally valid + PolicyResult::Accept } } } - } - - // Set HEAD if specified in state - if let Some(head_ref) = &state.head { - if let Some(branch_name) = state.get_head_branch() { - if let Some(head_commit) = state.get_branch_commit(branch_name) { - match git::try_set_head_if_available(repo_path, head_ref, head_commit) { - Ok(true) => { - tracing::info!( - "Set HEAD to {} in {} (from state by {})", - head_ref, - repo_path.display(), - state.event.pubkey.to_hex() - ); - result.head_set = true; - } - Ok(false) => { - tracing::debug!( - "HEAD commit {} not available yet in {}", - head_commit, - repo_path.display() - ); - } - Err(e) => { - tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e); - } - } - } + StateResult::Reject(reason) => { + tracing::warn!("Rejected repository state {}: {}", event_id_str, reason); + PolicyResult::Reject(reason) } } - - result } - /// Check if a pubkey is listed as a maintainer in any announcement for this identifier - /// - /// A pubkey is considered a maintainer if: - /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR - /// 2. They are listed in the maintainers tag of ANY announcement with this identifier - /// - /// This enables accepting announcements from maintainers even when they don't list - /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. - async fn is_maintainer_in_any_announcement( - database: &SharedDatabase, - identifier: &str, - author: &PublicKey, - ) -> Result { - // Query all announcements with this identifier that are already in the database - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - identifier.to_string(), + /// Handle PR or PR Update event + async fn handle_pr_event(&self, event: &Event) -> PolicyResult { + let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); + + // Validate refs/nostr refs for this PR event + // This deletes any refs/nostr/ that points to wrong commit + if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await { + tracing::warn!( + "Failed to validate refs/nostr for PR event {}: {}", + event_id_str, + e ); - - let announcements: Vec = match database.query(filter).await { - Ok(events) => events.into_iter().collect(), - Err(e) => return Err(format!("Database query failed: {}", e)), - }; - - if announcements.is_empty() { - // No existing announcements for this identifier - author cannot be a maintainer - return Ok(false); - } - - let author_hex = author.to_hex(); - - // Check each announcement to see if author is listed as a maintainer - for event in &announcements { - // Check if author is the owner of this announcement - if event.pubkey == *author { - return Ok(true); - } - - // Check if author is listed in the maintainers tag - if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { - if announcement.maintainers.contains(&author_hex) { - return Ok(true); - } - } - } - - Ok(false) - } - - /// Extract all reference tags from an event (a, A, q, e, E) - /// Returns (addressable_refs, event_refs) - fn extract_reference_tags(event: &Event) -> (Vec, Vec) { - let mut addressable_refs = Vec::new(); - let mut event_refs = Vec::new(); - - for tag in event.tags.iter() { - let tag_vec = tag.clone().to_vec(); - if tag_vec.is_empty() { - continue; - } - - match tag_vec[0].as_str() { - // Addressable event references (a, A, q with kind:pubkey:identifier format) - "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => { - addressable_refs.push(tag_vec[1].clone()); - } - // Event ID references (e, E, q with event ID format) - "e" | "E" if tag_vec.len() > 1 => { - if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { - event_refs.push(event_id); - } - } - "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => { - if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { - event_refs.push(event_id); - } - } - _ => {} - } + // Don't reject - just log the error and proceed with normal validation } - (addressable_refs, event_refs) + // Continue with reference checking (same as related events) + self.handle_related_event(event, "PR").await } - /// Validate refs/nostr/ ref against a PR or PR Update event's `c` tag - /// - /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, - /// this checks if a corresponding refs/nostr/ ref exists in the - /// repository and validates that it points to the correct commit (from the - /// `c` tag). If the ref exists but points to a different commit, the ref is - /// deleted. - /// - /// PR and PR Update events can have multiple `a` tags to update multiple - /// repositories simultaneously. - /// - /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent - /// with their corresponding events. - /// - /// # Arguments - /// * `database` - Database for looking up repository announcements - /// * `event` - The PR event (kind 1618) or PR Update event (kind 1619) - /// - /// # Returns - /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure - async fn validate_pr_nostr_ref( - &self, - database: &SharedDatabase, - event: &Event, - ) -> Result, String> { - let event_id = event.id.to_hex(); - - // Extract the `c` tag (commit hash) from the PR event - let expected_commit = event.tags.iter().find_map(|tag| { - let tag_vec = tag.clone().to_vec(); - if tag_vec.len() >= 2 && tag_vec[0] == "c" { - Some(tag_vec[1].clone()) - } else { - None - } - }); + /// Handle events that must reference accepted repositories or events + async fn handle_related_event(&self, event: &Event, event_type: &str) -> PolicyResult { + let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); - let expected_commit = match expected_commit { - Some(c) => c, - None => { + match self.related_event_policy.check_references(event).await { + Ok(ReferenceResult::ReferencesRepository(addr_ref)) => { tracing::debug!( - "PR event {} has no 'c' tag, skipping ref validation", - event_id + "Accepted {} event {}: references accepted repository {}", + event_type, + event_id_str, + addr_ref ); - return Ok(None); + PolicyResult::Accept } - }; - - // Extract ALL `a` tags (repository references) from the PR event - // PR events can reference multiple repositories - // Format: 30617:: - let repo_refs: Vec = 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:") { - Some(tag_vec[1].clone()) - } else { - None - } - }) - .collect(); - - if repo_refs.is_empty() { - tracing::debug!( - "PR event {} has no repo 'a' tags, skipping ref validation", - event_id - ); - return Ok(None); - } - - let mut deleted_count = 0; - - // Process each repository reference - for repo_ref in repo_refs { - // Parse the repo reference: 30617:: - let parts: Vec<&str> = repo_ref.split(':').collect(); - if parts.len() < 3 { + Ok(ReferenceResult::ReferencesEvent(event_ref)) => { tracing::debug!( - "PR event {} has invalid 'a' tag format: {}", - event_id, - repo_ref + "Accepted {} event {}: references accepted event {}", + event_type, + event_id_str, + event_ref ); - continue; + PolicyResult::Accept } - - let repo_pubkey = match PublicKey::from_hex(parts[1]) { - Ok(pk) => pk, - Err(_) => { - tracing::debug!( - "PR event {} has invalid pubkey in 'a' tag: {}", - event_id, - parts[1] - ); - continue; - } - }; - let identifier = parts[2]; - - // Look up repository announcement to get the npub for path - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) - .author(repo_pubkey) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - identifier.to_string(), - ); - - let announcements: Vec = match database.query(filter).await { - Ok(events) => events.into_iter().collect(), - Err(e) => { - tracing::warn!( - "Failed to query for repository announcement for PR {}: {}", - event_id, - e - ); - continue; - } - }; - - if announcements.is_empty() { + Ok(ReferenceResult::ReferencedByAccepted) => { tracing::debug!( - "No repository announcement found for PR event {} (repo {}:{})", - event_id, - repo_pubkey.to_hex(), - identifier + "Accepted {} event {}: referenced by accepted event", + event_type, + event_id_str ); - continue; - } - - // Process each matching announcement (there could be multiple) - for announcement_event in announcements { - let announcement = match RepositoryAnnouncement::from_event(announcement_event) { - Ok(a) => a, - Err(e) => { - tracing::warn!( - "Failed to parse announcement for PR {} validation: {}", - event_id, - e - ); - continue; - } - }; - - // Build repository path - let repo_path = self.git_data_path.join(announcement.repo_path()); - - // Validate the ref - match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { - Ok(true) => { - tracing::info!( - "Deleted mismatched refs/nostr/{} in {} (expected commit {})", - event_id, - repo_path.display(), - expected_commit - ); - deleted_count += 1; - } - Ok(false) => { - tracing::debug!( - "refs/nostr/{} in {} is valid or doesn't exist", - event_id, - repo_path.display() - ); - } - Err(e) => { - tracing::warn!( - "Failed to validate refs/nostr/{} in {}: {}", - event_id, - repo_path.display(), - e - ); - } - } - } - } - - if deleted_count > 0 { - Ok(Some(deleted_count)) - } else { - Ok(None) - } - } - - /// Check if any addressable events (repositories) exist in database - /// Returns the first matching addressable reference found, or None if none match - async fn find_accepted_repository( - database: &SharedDatabase, - addressables: &[String], - ) -> Result, String> { - if addressables.is_empty() { - return Ok(None); - } - - // Parse all addressable references - let mut parsed_refs = Vec::new(); - for addr in addressables { - let parts: Vec<&str> = addr.split(':').collect(); - if parts.len() < 3 { - continue; // Skip invalid format - } - - let kind = match parts[0].parse::() { - Ok(k) => k, - Err(_) => continue, // Skip invalid kind - }; - let pubkey = match PublicKey::from_hex(parts[1]) { - Ok(pk) => pk, - Err(_) => continue, // Skip invalid pubkey - }; - let identifier = parts[2].to_string(); - - parsed_refs.push((addr.clone(), kind, pubkey, identifier)); - } - - if parsed_refs.is_empty() { - return Ok(None); - } - - // Group by kind to reduce queries - use std::collections::HashMap; - let mut by_kind: HashMap> = HashMap::new(); - for (addr, kind, pubkey, identifier) in parsed_refs { - by_kind - .entry(kind) - .or_default() - .push((addr, pubkey, identifier)); - } - - // Query each kind group - for (kind, refs) in by_kind { - let authors: Vec = refs.iter().map(|(_, pk, _)| *pk).collect(); - - let filter = Filter::new().kind(Kind::from(kind)).authors(authors); - - match database.query(filter).await { - Ok(events) => { - // Check if any event matches our identifier requirements - for event in events { - for (addr, _pubkey, identifier) in &refs { - // Match identifier tag - if event.tags.iter().any(|tag| { - let tag_vec = tag.clone().to_vec(); - tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier - }) { - return Ok(Some(addr.clone())); - } - } - } - } - Err(e) => return Err(format!("Database query failed: {}", e)), + PolicyResult::Accept } - } - - Ok(None) - } - - /// Check if any events exist in database - /// Returns the first matching event ID found, or None if none match - async fn find_accepted_event( - database: &SharedDatabase, - event_ids: &[EventId], - ) -> Result, String> { - if event_ids.is_empty() { - return Ok(None); - } - - // Single query for all event IDs - let filter = Filter::new().ids(event_ids.iter().copied()); - - match database.query(filter).await { - Ok(events) => { - // Get first event from the iterator - Ok(events.into_iter().next().map(|e| e.id)) - } - Err(e) => Err(format!("Database query failed: {}", e)), - } - } - - /// Check if any accepted event references this event (forward reference) - /// - /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format - /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format - /// For regular events: Only checks event ID reference tags (e, E, q) - /// - /// This optimization recognizes that replaceable events are referenced by coordinate address, - /// while regular events are referenced by event ID. - async fn is_referenced_by_accepted( - database: &SharedDatabase, - event: &Event, - ) -> Result { - let kind_u16 = event.kind.as_u16(); - - // Check if this is any kind of replaceable event - let is_regular_replaceable = (10000..20000).contains(&kind_u16); - let is_parameterized_replaceable = (30000..40000).contains(&kind_u16); - - if is_regular_replaceable || is_parameterized_replaceable { - // Build the appropriate address format based on event type - let address = if is_parameterized_replaceable { - // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) - let identifier = event - .tags - .iter() - .find_map(|tag| { - let tag_vec = tag.clone().to_vec(); - if tag_vec.len() >= 2 && tag_vec[0] == "d" { - Some(tag_vec[1].clone()) - } else { - None - } - }) - .unwrap_or_default(); // Empty string if no 'd' tag - format!( - "{}:{}:{}", - event.kind.as_u16(), - event.pubkey.to_hex(), - identifier - ) - } else { - // For regular replaceable: kind:pubkey format (1 colon) - format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) - }; - - // Check addressable reference tags: a, A, q (with address format) - let addressable_tags = [ - SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference - SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference - SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID) - ]; - - for tag_type in &addressable_tags { - let filter = Filter::new().custom_tag(*tag_type, address.clone()); - - match database.query(filter).await { - Ok(events) => { - if !events.is_empty() { - return Ok(true); - } - } - Err(e) => return Err(format!("Database query failed: {}", e)), - } + Ok(ReferenceResult::Orphan) => { + let (addressable_refs, event_refs) = + RelatedEventPolicy::extract_reference_tags(event); + tracing::info!( + "Rejected orphan {} event {}: no references to accepted repos or events (checked {} addressable, {} event refs)", + event_type, + event_id_str, + addressable_refs.len(), + event_refs.len() + ); + PolicyResult::Reject(format!( + "{} event must reference an accepted repository or accepted event", + event_type + )) } - } else { - // For regular events, check event ID reference tags: e, E, q (with hex ID) - let event_id_hex = event.id.to_hex(); - - let event_id_tags = [ - SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference - SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference - SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference - ]; - - for tag_type in &event_id_tags { - let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone()); - - match database.query(filter).await { - Ok(events) => { - if !events.is_empty() { - return Ok(true); - } - } - Err(e) => return Err(format!("Database query failed: {}", e)), - } + Err(e) => { + tracing::warn!( + "Database query failed for {} {}, rejecting (fail-secure): {}", + event_type, + event_id_str, + e + ); + PolicyResult::Reject(format!("Database query failed: {}", e)) } } - - Ok(false) } } @@ -908,366 +258,12 @@ impl WritePolicy for Nip34WritePolicy { event: &'a nostr_relay_builder::prelude::Event, _addr: &'a SocketAddr, ) -> BoxedFuture<'a, PolicyResult> { - let database = self.database.clone(); - let domain = self.domain.clone(); - Box::pin(async move { - let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); - match event.kind.as_u16() { - KIND_REPOSITORY_ANNOUNCEMENT => { - // First, try normal validation (announcement lists service) - match validate_announcement(event, &domain) { - Ok(_) => { - // Parse announcement to get repository details - match RepositoryAnnouncement::from_event(event.clone()) { - Ok(announcement) => { - // Try to create bare repository if it doesn't exist - if let Err(e) = self.ensure_bare_repository(&announcement) { - tracing::warn!( - "Failed to create bare repository for {}: {}", - event_id_str, - e - ); - // Note: We still accept the event even if repo creation fails - // The git operation failure shouldn't prevent event acceptance - } - - tracing::debug!( - "Accepted repository announcement: {}", - event_id_str - ); - PolicyResult::Accept - } - Err(e) => { - tracing::warn!( - "Failed to parse repository announcement {}: {}", - event_id_str, - e - ); - PolicyResult::Reject(format!( - "Failed to parse announcement: {}", - e - )) - } - } - } - Err(validation_err) => { - // Validation failed - check if this is a recursive maintainer announcement - // GRASP-01 Exception: Accept announcements from recursive maintainers - // even without listing the service, for chain discovery and GRASP-02 sync - - // Try to parse the announcement to get identifier - match RepositoryAnnouncement::from_event(event.clone()) { - Ok(announcement) => { - // Check if author is listed as maintainer in any existing announcement - match Self::is_maintainer_in_any_announcement( - &database, - &announcement.identifier, - &event.pubkey, - ) - .await - { - Ok(true) => { - tracing::info!( - "Accepted maintainer announcement {} (author {} is listed as maintainer for {})", - event_id_str, - event.pubkey.to_hex(), - announcement.identifier - ); - // Don't create bare repository for external announcements - // (they point to other servers) - PolicyResult::Accept - } - Ok(false) => { - tracing::warn!( - "Rejected repository announcement {}: {} (not a maintainer)", - event_id_str, - validation_err - ); - PolicyResult::Reject(validation_err.to_string()) - } - Err(e) => { - tracing::warn!( - "Failed to check maintainer status for {}: {}", - event_id_str, - e - ); - // Fail-secure: reject on database errors - PolicyResult::Reject(validation_err.to_string()) - } - } - } - Err(parse_err) => { - tracing::warn!( - "Rejected repository announcement {}: {} (parse error: {})", - event_id_str, - validation_err, - parse_err - ); - PolicyResult::Reject(validation_err.to_string()) - } - } - } - } - } - KIND_REPOSITORY_STATE => match validate_state(event) { - Ok(_) => { - // Parse state to get HEAD and branch info - match RepositoryState::from_event(event.clone()) { - Ok(state) => { - // Identify owner repositories for which this is the latest authorized state - match self.identify_owner_repositories(&database, &state).await { - Ok(owner_repos) => { - let repo_count = owner_repos.len(); - let mut total_aligned = 0; - - // Align each owner repository with the authorized state - for (_announcement, repo_path) in owner_repos { - let result = self.align_owner_repository_with_state( - &repo_path, &state, - ); - - if result.refs_created > 0 - || result.refs_updated > 0 - || result.refs_deleted > 0 - || result.head_set - { - tracing::info!( - "Aligned {} with state {}: created={}, updated={}, deleted={}, head_set={}", - repo_path.display(), - event_id_str, - result.refs_created, - result.refs_updated, - result.refs_deleted, - result.head_set - ); - total_aligned += 1; - } - } - - if repo_count > 0 { - tracing::info!( - "Processed state event {} for {} repo(s) ({} aligned) with identifier {}", - event_id_str, - repo_count, - total_aligned, - state.identifier - ); - } else { - tracing::debug!( - "No owner repos to align for state {} - git data not available yet or not latest", - event_id_str - ); - } - } - Err(e) => { - tracing::warn!( - "Failed to identify owner repositories for state {}: {}", - event_id_str, - e - ); - } - } - - tracing::debug!("Accepted repository state: {}", event_id_str); - PolicyResult::Accept - } - Err(e) => { - tracing::warn!( - "Failed to parse repository state {}: {}", - event_id_str, - e - ); - // Still accept the event even if we can't parse it - // The validation passed, so it's structurally valid - PolicyResult::Accept - } - } - } - Err(e) => { - tracing::warn!("Rejected repository state {}: {}", event_id_str, e); - PolicyResult::Reject(e.to_string()) - } - }, - // KIND_PR (1618) and KIND_PR_UPDATE (1619): Validate refs/nostr/ refs before acceptance - KIND_PR | KIND_PR_UPDATE => { - // Validate refs/nostr refs for this PR event - // This deletes any refs/nostr/ that points to wrong commit - if let Err(e) = self.validate_pr_nostr_ref(&database, event).await { - tracing::warn!( - "Failed to validate refs/nostr for PR event {}: {}", - event_id_str, - e - ); - // Don't reject - just log the error and proceed with normal validation - } - - // Continue with standard reference checking (same as default case) - let (addressable_refs, event_refs) = Self::extract_reference_tags(event); - - // Check 1: Does this event reference an accepted repository? - match Self::find_accepted_repository(&database, &addressable_refs).await { - Ok(Some(addr_ref)) => { - tracing::debug!( - "Accepted PR event {}: references accepted repository {}", - event_id_str, - addr_ref - ); - return PolicyResult::Accept; - } - Ok(None) => { - // No matching repositories, continue to next check - } - Err(e) => { - tracing::warn!( - "Database query failed for PR {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // Check 2: Does this event reference an accepted event? - match Self::find_accepted_event(&database, &event_refs).await { - Ok(Some(event_ref)) => { - tracing::debug!( - "Accepted PR event {}: references accepted event {}", - event_id_str, - event_ref - ); - return PolicyResult::Accept; - } - Ok(None) => { - // No matching events, continue to next check - } - Err(e) => { - tracing::warn!( - "Database query failed for PR {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // Check 3: Is this event referenced by an accepted event? - match Self::is_referenced_by_accepted(&database, event).await { - Ok(true) => { - tracing::debug!( - "Accepted PR event {}: referenced by accepted event", - event_id_str - ); - return PolicyResult::Accept; - } - Ok(false) => { - // No forward references found, continue to rejection - } - Err(e) => { - tracing::warn!( - "Database query failed for PR {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // No valid references found - reject as orphan event - tracing::info!( - "Rejected orphan PR event {}: no references to accepted repos or events", - event_id_str - ); - PolicyResult::Reject( - "PR event must reference an accepted repository or accepted event" - .to_string(), - ) - } - // GRASP-01: Check if event references accepted repositories or events - _ => { - // Extract all reference tags from event - let (addressable_refs, event_refs) = Self::extract_reference_tags(event); - - // Check 1: Does this event reference an accepted repository? (batched) - match Self::find_accepted_repository(&database, &addressable_refs).await { - Ok(Some(addr_ref)) => { - tracing::debug!( - "Accepted event {}: references accepted repository {}", - event_id_str, - addr_ref - ); - return PolicyResult::Accept; - } - Ok(None) => { - // No matching repositories, continue to next check - } - Err(e) => { - tracing::warn!( - "Database query failed for event {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // Check 2: Does this event reference an accepted event? (batched, transitive) - match Self::find_accepted_event(&database, &event_refs).await { - Ok(Some(event_ref)) => { - tracing::debug!( - "Accepted event {}: references accepted event {}", - event_id_str, - event_ref - ); - return PolicyResult::Accept; - } - Ok(None) => { - // No matching events, continue to next check - } - Err(e) => { - tracing::warn!( - "Database query failed for event {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // Check 3: Is this event referenced by an accepted event? (forward reference) - match Self::is_referenced_by_accepted(&database, event).await { - Ok(true) => { - tracing::debug!( - "Accepted event {}: referenced by accepted event", - event_id_str - ); - return PolicyResult::Accept; - } - Ok(false) => { - // No forward references found, continue to rejection - } - Err(e) => { - tracing::warn!( - "Database query failed for event {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // No valid references found - reject as orphan event - tracing::info!( - "Rejected orphan event {}: no references to accepted repos or events (checked {} addressable, {} event refs)", - event_id_str, - addressable_refs.len(), - event_refs.len() - ); - PolicyResult::Reject( - "Event must reference an accepted repository or accepted event".to_string(), - ) - } + KIND_REPOSITORY_ANNOUNCEMENT => self.handle_announcement(event).await, + KIND_REPOSITORY_STATE => self.handle_state(event).await, + KIND_PR | KIND_PR_UPDATE => self.handle_pr_event(event).await, + _ => self.handle_related_event(event, "Event").await, } }) } @@ -1351,4 +347,4 @@ pub fn create_relay(config: &Config) -> Result { relay: LocalRelay::new(builder), database, }) -} +} \ No newline at end of file diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 2bf0346..a2820fc 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -1,2 +1,6 @@ pub mod builder; pub mod events; +pub mod policy; + +/// Re-export SharedDatabase for use by policy modules +pub use builder::SharedDatabase; diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs new file mode 100644 index 0000000..8d30baf --- /dev/null +++ b/src/nostr/policy/announcement.rs @@ -0,0 +1,157 @@ +/// Announcement Policy - Repository announcement validation +/// +/// Handles validation of NIP-34 repository announcements (kind 30617) +/// according to GRASP-01 specification. +use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; + +use super::PolicyContext; +use crate::nostr::events::{ + validate_announcement, RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT, +}; + +/// Result of announcement policy evaluation +#[derive(Debug)] +pub enum AnnouncementResult { + /// Accept: Event passes validation + Accept, + /// Accept as maintainer: Event accepted via maintainer exception + AcceptMaintainer, + /// Reject: Event fails validation with reason + Reject(String), +} + +/// Policy for validating repository announcements +#[derive(Clone)] +pub struct AnnouncementPolicy { + ctx: PolicyContext, +} + +impl AnnouncementPolicy { + pub fn new(ctx: PolicyContext) -> Self { + Self { ctx } + } + + /// Validate a repository announcement event + /// + /// Returns `Accept` if the announcement lists the service properly, + /// `AcceptMaintainer` if accepted via maintainer exception, + /// or `Reject` with reason. + pub async fn validate(&self, event: &Event) -> AnnouncementResult { + // First, try normal validation (announcement lists service) + match validate_announcement(event, &self.ctx.domain) { + Ok(_) => AnnouncementResult::Accept, + Err(validation_err) => { + // Validation failed - check if this is a recursive maintainer announcement + // GRASP-01 Exception: Accept announcements from recursive maintainers + // even without listing the service, for chain discovery and GRASP-02 sync + + // Try to parse the announcement to get identifier + match RepositoryAnnouncement::from_event(event.clone()) { + Ok(announcement) => { + // Check if author is listed as maintainer in any existing announcement + match self + .is_maintainer_in_any_announcement( + &announcement.identifier, + &event.pubkey, + ) + .await + { + Ok(true) => AnnouncementResult::AcceptMaintainer, + Ok(false) => AnnouncementResult::Reject(validation_err.to_string()), + Err(_) => { + // Fail-secure: reject on database errors + AnnouncementResult::Reject(validation_err.to_string()) + } + } + } + Err(_) => AnnouncementResult::Reject(validation_err.to_string()), + } + } + } + } + + /// Create a bare git repository if it doesn't exist + /// Path format: //.git + pub fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { + let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); + + // Check if repository already exists + if repo_path.exists() { + tracing::debug!("Repository already exists at {}", repo_path.display()); + return Ok(()); + } + + // Create parent directory (npub directory) + let parent = repo_path + .parent() + .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?; + + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; + + // Initialize bare repository using git command + let output = std::process::Command::new("git") + .args(["init", "--bare", repo_path.to_str().unwrap()]) + .output() + .map_err(|e| format!("Failed to execute git init: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git init failed: {}", stderr)); + } + + tracing::info!("Created bare repository at {}", repo_path.display()); + Ok(()) + } + + /// Check if a pubkey is listed as a maintainer in any announcement for this identifier + /// + /// A pubkey is considered a maintainer if: + /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR + /// 2. They are listed in the maintainers tag of ANY announcement with this identifier + /// + /// This enables accepting announcements from maintainers even when they don't list + /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. + async fn is_maintainer_in_any_announcement( + &self, + identifier: &str, + author: &PublicKey, + ) -> Result { + // Query all announcements with this identifier that are already in the database + let filter = Filter::new() + .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + identifier.to_string(), + ); + + let announcements: Vec = match self.ctx.database.query(filter).await { + Ok(events) => events.into_iter().collect(), + Err(e) => return Err(format!("Database query failed: {}", e)), + }; + + if announcements.is_empty() { + // No existing announcements for this identifier - author cannot be a maintainer + return Ok(false); + } + + let author_hex = author.to_hex(); + + // Check each announcement to see if author is listed as a maintainer + for event in &announcements { + // Check if author is the owner of this announcement + if event.pubkey == *author { + return Ok(true); + } + + // Check if author is listed in the maintainers tag + if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { + if announcement.maintainers.contains(&author_hex) { + return Ok(true); + } + } + } + + Ok(false) + } +} \ No newline at end of file diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs new file mode 100644 index 0000000..6d67394 --- /dev/null +++ b/src/nostr/policy/mod.rs @@ -0,0 +1,41 @@ +/// Policy module for NIP-34 write policies +/// +/// This module splits the large Nip34WritePolicy into focused sub-policies: +/// - `AnnouncementPolicy` - Repository announcement validation +/// - `StatePolicy` - State event validation + ref alignment +/// - `PrEventPolicy` - PR/PR Update validation +/// - `RelatedEventPolicy` - Forward/backward reference checking + +mod announcement; +mod pr_event; +mod related; +mod state; + +pub use announcement::{AnnouncementPolicy, AnnouncementResult}; +pub use pr_event::PrEventPolicy; +pub use related::{ReferenceResult, RelatedEventPolicy}; +pub use state::{AlignmentResult, StatePolicy, StateResult}; + +use super::SharedDatabase; + +/// Shared context for all sub-policies +#[derive(Clone)] +pub struct PolicyContext { + pub domain: String, + pub database: SharedDatabase, + pub git_data_path: std::path::PathBuf, +} + +impl PolicyContext { + pub fn new( + domain: impl Into, + database: SharedDatabase, + git_data_path: impl Into, + ) -> Self { + Self { + domain: domain.into(), + database, + git_data_path: git_data_path.into(), + } + } +} \ No newline at end of file diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs new file mode 100644 index 0000000..fee9a2a --- /dev/null +++ b/src/nostr/policy/pr_event.rs @@ -0,0 +1,198 @@ +/// PR Event Policy - PR/PR Update validation +/// +/// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619) +/// according to GRASP-01 specification. +use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; + +use super::PolicyContext; +use crate::git; +use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT}; + +/// Policy for validating PR and PR Update events +#[derive(Clone)] +pub struct PrEventPolicy { + ctx: PolicyContext, +} + +impl PrEventPolicy { + pub fn new(ctx: PolicyContext) -> Self { + Self { ctx } + } + + /// Validate refs/nostr/ ref against a PR or PR Update event's `c` tag + /// + /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, + /// this checks if a corresponding refs/nostr/ ref exists in the + /// repository and validates that it points to the correct commit (from the + /// `c` tag). If the ref exists but points to a different commit, the ref is + /// deleted. + /// + /// PR and PR Update events can have multiple `a` tags to update multiple + /// repositories simultaneously. + /// + /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent + /// with their corresponding events. + /// + /// # Returns + /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure + pub async fn validate_nostr_ref(&self, event: &Event) -> Result, String> { + let event_id = event.id.to_hex(); + + // Extract the `c` tag (commit hash) from the PR event + let expected_commit = event.tags.iter().find_map(|tag| { + let tag_vec = tag.clone().to_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "c" { + Some(tag_vec[1].clone()) + } else { + None + } + }); + + let expected_commit = match expected_commit { + Some(c) => c, + None => { + tracing::debug!( + "PR event {} has no 'c' tag, skipping ref validation", + event_id + ); + return Ok(None); + } + }; + + // Extract ALL `a` tags (repository references) from the PR event + // PR events can reference multiple repositories + // Format: 30617:: + let repo_refs: Vec = 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:") { + Some(tag_vec[1].clone()) + } else { + None + } + }) + .collect(); + + if repo_refs.is_empty() { + tracing::debug!( + "PR event {} has no repo 'a' tags, skipping ref validation", + event_id + ); + return Ok(None); + } + + let mut deleted_count = 0; + + // Process each repository reference + for repo_ref in repo_refs { + // Parse the repo reference: 30617:: + let parts: Vec<&str> = repo_ref.split(':').collect(); + if parts.len() < 3 { + tracing::debug!( + "PR event {} has invalid 'a' tag format: {}", + event_id, + repo_ref + ); + continue; + } + + let repo_pubkey = match PublicKey::from_hex(parts[1]) { + Ok(pk) => pk, + Err(_) => { + tracing::debug!( + "PR event {} has invalid pubkey in 'a' tag: {}", + event_id, + parts[1] + ); + continue; + } + }; + let identifier = parts[2]; + + // Look up repository announcement to get the npub for path + let filter = Filter::new() + .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) + .author(repo_pubkey) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + identifier.to_string(), + ); + + let announcements: Vec = match self.ctx.database.query(filter).await { + Ok(events) => events.into_iter().collect(), + Err(e) => { + tracing::warn!( + "Failed to query for repository announcement for PR {}: {}", + event_id, + e + ); + continue; + } + }; + + if announcements.is_empty() { + tracing::debug!( + "No repository announcement found for PR event {} (repo {}:{})", + event_id, + repo_pubkey.to_hex(), + identifier + ); + continue; + } + + // Process each matching announcement (there could be multiple) + for announcement_event in announcements { + let announcement = match RepositoryAnnouncement::from_event(announcement_event) { + Ok(a) => a, + Err(e) => { + tracing::warn!( + "Failed to parse announcement for PR {} validation: {}", + event_id, + e + ); + continue; + } + }; + + // Build repository path + let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); + + // Validate the ref + match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { + Ok(true) => { + tracing::info!( + "Deleted mismatched refs/nostr/{} in {} (expected commit {})", + event_id, + repo_path.display(), + expected_commit + ); + deleted_count += 1; + } + Ok(false) => { + tracing::debug!( + "refs/nostr/{} in {} is valid or doesn't exist", + event_id, + repo_path.display() + ); + } + Err(e) => { + tracing::warn!( + "Failed to validate refs/nostr/{} in {}: {}", + event_id, + repo_path.display(), + e + ); + } + } + } + } + + if deleted_count > 0 { + Ok(Some(deleted_count)) + } else { + Ok(None) + } + } +} \ No newline at end of file diff --git a/src/nostr/policy/related.rs b/src/nostr/policy/related.rs new file mode 100644 index 0000000..1937ca7 --- /dev/null +++ b/src/nostr/policy/related.rs @@ -0,0 +1,276 @@ +/// Related Event Policy - Forward/backward reference checking +/// +/// Handles validation of events that reference accepted repositories or events +/// (backward references) and events that are referenced by accepted events +/// (forward references). +use nostr_relay_builder::prelude::{ + Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag, +}; + +use super::PolicyContext; + +/// Result of reference checking +#[derive(Debug)] +pub enum ReferenceResult { + /// Event references an accepted repository (addressable ref found) + ReferencesRepository(String), + /// Event references an accepted event (event ID found) + ReferencesEvent(EventId), + /// Event is referenced by an accepted event (forward reference) + ReferencedByAccepted, + /// No valid references found - event is an orphan + Orphan, +} + +/// Policy for checking event references (backward and forward) +#[derive(Clone)] +pub struct RelatedEventPolicy { + ctx: PolicyContext, +} + +impl RelatedEventPolicy { + pub fn new(ctx: PolicyContext) -> Self { + Self { ctx } + } + + /// Check all reference types for an event + /// + /// Returns the first valid reference found, or `Orphan` if none found. + pub async fn check_references(&self, event: &Event) -> Result { + // Extract all reference tags from event + let (addressable_refs, event_refs) = Self::extract_reference_tags(event); + + // Check 1: Does this event reference an accepted repository? + if let Some(addr_ref) = self.find_accepted_repository(&addressable_refs).await? { + return Ok(ReferenceResult::ReferencesRepository(addr_ref)); + } + + // Check 2: Does this event reference an accepted event? + if let Some(event_ref) = self.find_accepted_event(&event_refs).await? { + return Ok(ReferenceResult::ReferencesEvent(event_ref)); + } + + // Check 3: Is this event referenced by an accepted event? + if self.is_referenced_by_accepted(event).await? { + return Ok(ReferenceResult::ReferencedByAccepted); + } + + // No valid references found + Ok(ReferenceResult::Orphan) + } + + /// Extract all reference tags from an event (a, A, q, e, E) + /// Returns (addressable_refs, event_refs) + pub fn extract_reference_tags(event: &Event) -> (Vec, Vec) { + let mut addressable_refs = Vec::new(); + let mut event_refs = Vec::new(); + + for tag in event.tags.iter() { + let tag_vec = tag.clone().to_vec(); + if tag_vec.is_empty() { + continue; + } + + match tag_vec[0].as_str() { + // Addressable event references (a, A, q with kind:pubkey:identifier format) + "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => { + addressable_refs.push(tag_vec[1].clone()); + } + // Event ID references (e, E, q with event ID format) + "e" | "E" if tag_vec.len() > 1 => { + if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { + event_refs.push(event_id); + } + } + "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => { + if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { + event_refs.push(event_id); + } + } + _ => {} + } + } + + (addressable_refs, event_refs) + } + + /// Check if any addressable events (repositories) exist in database + /// Returns the first matching addressable reference found, or None if none match + async fn find_accepted_repository( + &self, + addressables: &[String], + ) -> Result, String> { + if addressables.is_empty() { + return Ok(None); + } + + // Parse all addressable references + let mut parsed_refs = Vec::new(); + for addr in addressables { + let parts: Vec<&str> = addr.split(':').collect(); + if parts.len() < 3 { + continue; // Skip invalid format + } + + let kind = match parts[0].parse::() { + Ok(k) => k, + Err(_) => continue, // Skip invalid kind + }; + let pubkey = match PublicKey::from_hex(parts[1]) { + Ok(pk) => pk, + Err(_) => continue, // Skip invalid pubkey + }; + let identifier = parts[2].to_string(); + + parsed_refs.push((addr.clone(), kind, pubkey, identifier)); + } + + if parsed_refs.is_empty() { + return Ok(None); + } + + // Group by kind to reduce queries + use std::collections::HashMap; + let mut by_kind: HashMap> = HashMap::new(); + for (addr, kind, pubkey, identifier) in parsed_refs { + by_kind + .entry(kind) + .or_default() + .push((addr, pubkey, identifier)); + } + + // Query each kind group + for (kind, refs) in by_kind { + let authors: Vec = refs.iter().map(|(_, pk, _)| *pk).collect(); + + let filter = Filter::new().kind(Kind::from(kind)).authors(authors); + + match self.ctx.database.query(filter).await { + Ok(events) => { + // Check if any event matches our identifier requirements + for event in events { + for (addr, _pubkey, identifier) in &refs { + // Match identifier tag + if event.tags.iter().any(|tag| { + let tag_vec = tag.clone().to_vec(); + tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier + }) { + return Ok(Some(addr.clone())); + } + } + } + } + Err(e) => return Err(format!("Database query failed: {}", e)), + } + } + + Ok(None) + } + + /// Check if any events exist in database + /// Returns the first matching event ID found, or None if none match + async fn find_accepted_event( + &self, + event_ids: &[EventId], + ) -> Result, String> { + if event_ids.is_empty() { + return Ok(None); + } + + // Single query for all event IDs + let filter = Filter::new().ids(event_ids.iter().copied()); + + match self.ctx.database.query(filter).await { + Ok(events) => { + // Get first event from the iterator + Ok(events.into_iter().next().map(|e| e.id)) + } + Err(e) => Err(format!("Database query failed: {}", e)), + } + } + + /// Check if any accepted event references this event (forward reference) + /// + /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format + /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format + /// For regular events: Only checks event ID reference tags (e, E, q) + async fn is_referenced_by_accepted(&self, event: &Event) -> Result { + let kind_u16 = event.kind.as_u16(); + + // Check if this is any kind of replaceable event + let is_regular_replaceable = (10000..20000).contains(&kind_u16); + let is_parameterized_replaceable = (30000..40000).contains(&kind_u16); + + if is_regular_replaceable || is_parameterized_replaceable { + // Build the appropriate address format based on event type + let address = if is_parameterized_replaceable { + // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) + let identifier = event + .tags + .iter() + .find_map(|tag| { + let tag_vec = tag.clone().to_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "d" { + Some(tag_vec[1].clone()) + } else { + None + } + }) + .unwrap_or_default(); // Empty string if no 'd' tag + format!( + "{}:{}:{}", + event.kind.as_u16(), + event.pubkey.to_hex(), + identifier + ) + } else { + // For regular replaceable: kind:pubkey format (1 colon) + format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) + }; + + // Check addressable reference tags: a, A, q (with address format) + let addressable_tags = [ + SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference + SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference + SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID) + ]; + + for tag_type in &addressable_tags { + let filter = Filter::new().custom_tag(*tag_type, address.clone()); + + match self.ctx.database.query(filter).await { + Ok(events) => { + if !events.is_empty() { + return Ok(true); + } + } + Err(e) => return Err(format!("Database query failed: {}", e)), + } + } + } else { + // For regular events, check event ID reference tags: e, E, q (with hex ID) + let event_id_hex = event.id.to_hex(); + + let event_id_tags = [ + SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference + SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference + SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference + ]; + + for tag_type in &event_id_tags { + let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone()); + + match self.ctx.database.query(filter).await { + Ok(events) => { + if !events.is_empty() { + return Ok(true); + } + } + Err(e) => return Err(format!("Database query failed: {}", e)), + } + } + } + + Ok(false) + } +} \ No newline at end of file diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs new file mode 100644 index 0000000..5692bd8 --- /dev/null +++ b/src/nostr/policy/state.rs @@ -0,0 +1,419 @@ +/// State Policy - State event validation + ref alignment +/// +/// Handles validation of NIP-34 repository state events (kind 30618) +/// and aligns git refs with authorized state according to GRASP-01. +use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; + +use super::PolicyContext; +use crate::git; +use crate::nostr::events::{ + validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, + KIND_REPOSITORY_STATE, +}; + +/// Result of aligning a repository with authorized state +#[derive(Debug, Default)] +pub struct AlignmentResult { + /// Number of refs created + pub refs_created: usize, + /// Number of refs updated + pub refs_updated: usize, + /// Number of refs deleted + pub refs_deleted: usize, + /// Whether HEAD was set + pub head_set: bool, +} + +impl AlignmentResult { + pub fn has_changes(&self) -> bool { + self.refs_created > 0 || self.refs_updated > 0 || self.refs_deleted > 0 || self.head_set + } +} + +/// Result of state policy evaluation +#[derive(Debug)] +pub enum StateResult { + /// Accept: Event passes validation + Accept, + /// Reject: Event fails validation with reason + Reject(String), +} + +/// Policy for validating repository state events and aligning refs +#[derive(Clone)] +pub struct StatePolicy { + ctx: PolicyContext, +} + +impl StatePolicy { + pub fn new(ctx: PolicyContext) -> Self { + Self { ctx } + } + + /// Validate a repository state event + pub fn validate(&self, event: &Event) -> StateResult { + match validate_state(event) { + Ok(_) => StateResult::Accept, + Err(e) => StateResult::Reject(e.to_string()), + } + } + + /// Process a state event: validate and align owner repositories + /// + /// Returns the number of repositories aligned if successful. + pub async fn process_state_event(&self, event: &Event) -> Result { + // Parse state to get HEAD and branch info + let state = RepositoryState::from_event(event.clone()) + .map_err(|e| format!("Failed to parse state: {}", e))?; + + // Identify owner repositories for which this is the latest authorized state + let owner_repos = self.identify_owner_repositories(&state).await?; + let repo_count = owner_repos.len(); + let mut total_aligned = 0; + + // Align each owner repository with the authorized state + for (_announcement, repo_path) in owner_repos { + let result = self.align_repository_with_state(&repo_path, &state); + + if result.has_changes() { + tracing::info!( + "Aligned {} with state: created={}, updated={}, deleted={}, head_set={}", + repo_path.display(), + result.refs_created, + result.refs_updated, + result.refs_deleted, + result.head_set + ); + total_aligned += 1; + } + } + + if repo_count > 0 { + tracing::info!( + "Processed state event for {} repo(s) ({} aligned) with identifier {}", + repo_count, + total_aligned, + state.identifier + ); + } else { + tracing::debug!( + "No owner repos to align for state - git data not available yet or not latest" + ); + } + + Ok(total_aligned) + } + + /// Check if this state event is the latest for its identifier among authorized authors + /// + /// A state is considered "latest" if no other state event in the database + /// from an authorized author has a newer timestamp. + async fn is_latest_state_for_identifier( + &self, + state: &RepositoryState, + authorized_pubkeys: &[PublicKey], + ) -> Result { + let filter = Filter::new() + .kind(Kind::from(KIND_REPOSITORY_STATE)) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + state.identifier.clone(), + ); + + match self.ctx.database.query(filter).await { + Ok(events) => { + for event in events { + // Skip comparing to self (same event ID) + if event.id == state.event.id { + continue; + } + // Only consider events from authorized authors for this announcement + if !authorized_pubkeys.contains(&event.pubkey) { + continue; + } + // If any existing event from an authorized author is newer, this is not the latest + if event.created_at > state.event.created_at { + tracing::debug!( + "State {} is not latest: found newer state {} from {} (ts {} > {})", + state.event.id.to_hex(), + event.id.to_hex(), + event.pubkey.to_hex(), + event.created_at.as_secs(), + state.event.created_at.as_secs() + ); + return Ok(false); + } + } + Ok(true) + } + Err(e) => Err(format!("Database query failed: {}", e)), + } + } + + /// Find all repository announcements where the given pubkey is authorized + async fn find_authorized_announcements( + &self, + identifier: &str, + state_author: &PublicKey, + ) -> Result, String> { + let filter = Filter::new() + .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + identifier.to_string(), + ); + + match self.ctx.database.query(filter).await { + Ok(events) => { + let mut authorized = Vec::new(); + let state_author_hex = state_author.to_hex(); + + for event in events { + if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { + // Check if state author is authorized for this announcement + let is_owner = event.pubkey == *state_author; + let is_maintainer = announcement.maintainers.contains(&state_author_hex); + + if is_owner || is_maintainer { + tracing::debug!( + "Found authorized announcement for {}: owner={}, maintainer={}", + identifier, + if is_owner { + event.pubkey.to_hex() + } else { + "n/a".to_string() + }, + is_maintainer + ); + authorized.push(announcement); + } + } + } + Ok(authorized) + } + Err(e) => Err(format!("Database query failed: {}", e)), + } + } + + /// Identify all owner repositories for which this state event is the latest authorized state + async fn identify_owner_repositories( + &self, + state: &RepositoryState, + ) -> Result, String> { + // Find all announcements where state author is authorized + let announcements = self + .find_authorized_announcements(&state.identifier, &state.event.pubkey) + .await?; + + if announcements.is_empty() { + tracing::debug!( + "No authorized announcements found for state {} by {}", + state.identifier, + state.event.pubkey.to_hex() + ); + return Ok(Vec::new()); + } + + let mut owner_repos = Vec::new(); + + for announcement in announcements { + // Build the list of authorized pubkeys for this specific announcement + let mut authorized_pubkeys = vec![announcement.event.pubkey]; + for maintainer_hex in &announcement.maintainers { + if let Ok(pk) = PublicKey::from_hex(maintainer_hex) { + authorized_pubkeys.push(pk); + } + } + + // Check if this is the latest state event for THIS announcement's context + if !self + .is_latest_state_for_identifier(state, &authorized_pubkeys) + .await? + { + tracing::debug!( + "Skipping {} in {}'s repo - not the latest state event for this context", + state.identifier, + announcement.event.pubkey.to_hex() + ); + continue; + } + + // Build repository path: //.git + let repo_path = self.ctx.git_data_path.join(announcement.repo_path().clone()); + owner_repos.push((announcement, repo_path)); + } + + Ok(owner_repos) + } + + /// Align a repository's refs with the authorized state + /// + /// This function: + /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/) + /// 2. Updates refs that exist in state if we have the commit + /// 3. Sets HEAD if the HEAD branch's commit is available + pub fn align_repository_with_state( + &self, + repo_path: &std::path::Path, + state: &RepositoryState, + ) -> AlignmentResult { + let mut result = AlignmentResult::default(); + + // Check if repository exists + if !repo_path.exists() { + tracing::debug!( + "Repository not found at {}, cannot align with state", + repo_path.display() + ); + return result; + } + + // Get current refs from the repository + let current_refs = match git::list_refs(repo_path) { + Ok(refs) => refs, + Err(e) => { + tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e); + return result; + } + }; + + // Build expected refs from state + let mut expected_refs: std::collections::HashMap = + std::collections::HashMap::new(); + + for branch in &state.branches { + let ref_name = format!("refs/heads/{}", branch.name); + expected_refs.insert(ref_name, branch.commit.clone()); + } + + for tag in &state.tags { + let ref_name = format!("refs/tags/{}", tag.name); + expected_refs.insert(ref_name, tag.commit.clone()); + } + + // Process current refs: update or delete as needed + for (ref_name, current_commit) in ¤t_refs { + // Only process refs/heads/ and refs/tags/ + if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") { + continue; + } + + match expected_refs.get(ref_name) { + Some(expected_commit) => { + // Ref should exist - check if commit matches + if current_commit != expected_commit { + // Check if we have the expected commit + if git::commit_exists(repo_path, expected_commit) { + // Update the ref + match git::update_ref(repo_path, ref_name, expected_commit) { + Ok(()) => { + tracing::info!( + "Updated {} from {} to {} in {}", + ref_name, + current_commit, + expected_commit, + repo_path.display() + ); + result.refs_updated += 1; + } + Err(e) => { + tracing::warn!( + "Failed to update {} in {}: {}", + ref_name, + repo_path.display(), + e + ); + } + } + } else { + tracing::debug!( + "Commit {} not available for {} in {}", + expected_commit, + ref_name, + repo_path.display() + ); + } + } + } + None => { + // Ref should not exist - delete it + match git::delete_ref(repo_path, ref_name) { + Ok(()) => { + tracing::info!( + "Deleted {} (not in state) from {}", + ref_name, + repo_path.display() + ); + result.refs_deleted += 1; + } + Err(e) => { + tracing::warn!( + "Failed to delete {} from {}: {}", + ref_name, + repo_path.display(), + e + ); + } + } + } + } + } + + // Add refs that exist in state but not in repo (if we have the commit) + for (ref_name, expected_commit) in &expected_refs { + let exists = current_refs.iter().any(|(r, _)| r == ref_name); + if !exists && git::commit_exists(repo_path, expected_commit) { + match git::update_ref(repo_path, ref_name, expected_commit) { + Ok(()) => { + tracing::info!( + "Created {} at {} in {}", + ref_name, + expected_commit, + repo_path.display() + ); + result.refs_created += 1; + } + Err(e) => { + tracing::warn!( + "Failed to create {} in {}: {}", + ref_name, + repo_path.display(), + e + ); + } + } + } + } + + // Set HEAD if specified in state + if let Some(head_ref) = &state.head { + if let Some(branch_name) = state.get_head_branch() { + if let Some(head_commit) = state.get_branch_commit(branch_name) { + match git::try_set_head_if_available(repo_path, head_ref, head_commit) { + Ok(true) => { + tracing::info!( + "Set HEAD to {} in {} (from state by {})", + head_ref, + repo_path.display(), + state.event.pubkey.to_hex() + ); + result.head_set = true; + } + Ok(false) => { + tracing::debug!( + "HEAD commit {} not available yet in {}", + head_commit, + repo_path.display() + ); + } + Err(e) => { + tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e); + } + } + } + } + } + + result + } +} \ No newline at end of file -- cgit v1.2.3