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/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 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1091 insertions(+) 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/nostr/policy') 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