diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:42:00 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:42:00 +0000 |
| commit | 819866330c7e2f535a155d1d7efaf2e12dc15dc2 (patch) | |
| tree | d84c8361811544aad9cad089c0358b9028c8fb80 /src/nostr/policy | |
| parent | fd0c87c787d0626b3546fa571541c9c809711821 (diff) | |
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
Diffstat (limited to 'src/nostr/policy')
| -rw-r--r-- | src/nostr/policy/announcement.rs | 157 | ||||
| -rw-r--r-- | src/nostr/policy/mod.rs | 41 | ||||
| -rw-r--r-- | src/nostr/policy/pr_event.rs | 198 | ||||
| -rw-r--r-- | src/nostr/policy/related.rs | 276 | ||||
| -rw-r--r-- | src/nostr/policy/state.rs | 419 |
5 files changed, 1091 insertions, 0 deletions
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 @@ | |||
| 1 | /// Announcement Policy - Repository announcement validation | ||
| 2 | /// | ||
| 3 | /// Handles validation of NIP-34 repository announcements (kind 30617) | ||
| 4 | /// according to GRASP-01 specification. | ||
| 5 | use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; | ||
| 6 | |||
| 7 | use super::PolicyContext; | ||
| 8 | use crate::nostr::events::{ | ||
| 9 | validate_announcement, RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT, | ||
| 10 | }; | ||
| 11 | |||
| 12 | /// Result of announcement policy evaluation | ||
| 13 | #[derive(Debug)] | ||
| 14 | pub enum AnnouncementResult { | ||
| 15 | /// Accept: Event passes validation | ||
| 16 | Accept, | ||
| 17 | /// Accept as maintainer: Event accepted via maintainer exception | ||
| 18 | AcceptMaintainer, | ||
| 19 | /// Reject: Event fails validation with reason | ||
| 20 | Reject(String), | ||
| 21 | } | ||
| 22 | |||
| 23 | /// Policy for validating repository announcements | ||
| 24 | #[derive(Clone)] | ||
| 25 | pub struct AnnouncementPolicy { | ||
| 26 | ctx: PolicyContext, | ||
| 27 | } | ||
| 28 | |||
| 29 | impl AnnouncementPolicy { | ||
| 30 | pub fn new(ctx: PolicyContext) -> Self { | ||
| 31 | Self { ctx } | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Validate a repository announcement event | ||
| 35 | /// | ||
| 36 | /// Returns `Accept` if the announcement lists the service properly, | ||
| 37 | /// `AcceptMaintainer` if accepted via maintainer exception, | ||
| 38 | /// or `Reject` with reason. | ||
| 39 | pub async fn validate(&self, event: &Event) -> AnnouncementResult { | ||
| 40 | // First, try normal validation (announcement lists service) | ||
| 41 | match validate_announcement(event, &self.ctx.domain) { | ||
| 42 | Ok(_) => AnnouncementResult::Accept, | ||
| 43 | Err(validation_err) => { | ||
| 44 | // Validation failed - check if this is a recursive maintainer announcement | ||
| 45 | // GRASP-01 Exception: Accept announcements from recursive maintainers | ||
| 46 | // even without listing the service, for chain discovery and GRASP-02 sync | ||
| 47 | |||
| 48 | // Try to parse the announcement to get identifier | ||
| 49 | match RepositoryAnnouncement::from_event(event.clone()) { | ||
| 50 | Ok(announcement) => { | ||
| 51 | // Check if author is listed as maintainer in any existing announcement | ||
| 52 | match self | ||
| 53 | .is_maintainer_in_any_announcement( | ||
| 54 | &announcement.identifier, | ||
| 55 | &event.pubkey, | ||
| 56 | ) | ||
| 57 | .await | ||
| 58 | { | ||
| 59 | Ok(true) => AnnouncementResult::AcceptMaintainer, | ||
| 60 | Ok(false) => AnnouncementResult::Reject(validation_err.to_string()), | ||
| 61 | Err(_) => { | ||
| 62 | // Fail-secure: reject on database errors | ||
| 63 | AnnouncementResult::Reject(validation_err.to_string()) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | } | ||
| 67 | Err(_) => AnnouncementResult::Reject(validation_err.to_string()), | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Create a bare git repository if it doesn't exist | ||
| 74 | /// Path format: <git_data_path>/<npub>/<identifier>.git | ||
| 75 | pub fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { | ||
| 76 | let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); | ||
| 77 | |||
| 78 | // Check if repository already exists | ||
| 79 | if repo_path.exists() { | ||
| 80 | tracing::debug!("Repository already exists at {}", repo_path.display()); | ||
| 81 | return Ok(()); | ||
| 82 | } | ||
| 83 | |||
| 84 | // Create parent directory (npub directory) | ||
| 85 | let parent = repo_path | ||
| 86 | .parent() | ||
| 87 | .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?; | ||
| 88 | |||
| 89 | std::fs::create_dir_all(parent) | ||
| 90 | .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; | ||
| 91 | |||
| 92 | // Initialize bare repository using git command | ||
| 93 | let output = std::process::Command::new("git") | ||
| 94 | .args(["init", "--bare", repo_path.to_str().unwrap()]) | ||
| 95 | .output() | ||
| 96 | .map_err(|e| format!("Failed to execute git init: {}", e))?; | ||
| 97 | |||
| 98 | if !output.status.success() { | ||
| 99 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 100 | return Err(format!("git init failed: {}", stderr)); | ||
| 101 | } | ||
| 102 | |||
| 103 | tracing::info!("Created bare repository at {}", repo_path.display()); | ||
| 104 | Ok(()) | ||
| 105 | } | ||
| 106 | |||
| 107 | /// Check if a pubkey is listed as a maintainer in any announcement for this identifier | ||
| 108 | /// | ||
| 109 | /// A pubkey is considered a maintainer if: | ||
| 110 | /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR | ||
| 111 | /// 2. They are listed in the maintainers tag of ANY announcement with this identifier | ||
| 112 | /// | ||
| 113 | /// This enables accepting announcements from maintainers even when they don't list | ||
| 114 | /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. | ||
| 115 | async fn is_maintainer_in_any_announcement( | ||
| 116 | &self, | ||
| 117 | identifier: &str, | ||
| 118 | author: &PublicKey, | ||
| 119 | ) -> Result<bool, String> { | ||
| 120 | // Query all announcements with this identifier that are already in the database | ||
| 121 | let filter = Filter::new() | ||
| 122 | .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) | ||
| 123 | .custom_tag( | ||
| 124 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 125 | identifier.to_string(), | ||
| 126 | ); | ||
| 127 | |||
| 128 | let announcements: Vec<Event> = match self.ctx.database.query(filter).await { | ||
| 129 | Ok(events) => events.into_iter().collect(), | ||
| 130 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 131 | }; | ||
| 132 | |||
| 133 | if announcements.is_empty() { | ||
| 134 | // No existing announcements for this identifier - author cannot be a maintainer | ||
| 135 | return Ok(false); | ||
| 136 | } | ||
| 137 | |||
| 138 | let author_hex = author.to_hex(); | ||
| 139 | |||
| 140 | // Check each announcement to see if author is listed as a maintainer | ||
| 141 | for event in &announcements { | ||
| 142 | // Check if author is the owner of this announcement | ||
| 143 | if event.pubkey == *author { | ||
| 144 | return Ok(true); | ||
| 145 | } | ||
| 146 | |||
| 147 | // Check if author is listed in the maintainers tag | ||
| 148 | if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { | ||
| 149 | if announcement.maintainers.contains(&author_hex) { | ||
| 150 | return Ok(true); | ||
| 151 | } | ||
| 152 | } | ||
| 153 | } | ||
| 154 | |||
| 155 | Ok(false) | ||
| 156 | } | ||
| 157 | } \ 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 @@ | |||
| 1 | /// Policy module for NIP-34 write policies | ||
| 2 | /// | ||
| 3 | /// This module splits the large Nip34WritePolicy into focused sub-policies: | ||
| 4 | /// - `AnnouncementPolicy` - Repository announcement validation | ||
| 5 | /// - `StatePolicy` - State event validation + ref alignment | ||
| 6 | /// - `PrEventPolicy` - PR/PR Update validation | ||
| 7 | /// - `RelatedEventPolicy` - Forward/backward reference checking | ||
| 8 | |||
| 9 | mod announcement; | ||
| 10 | mod pr_event; | ||
| 11 | mod related; | ||
| 12 | mod state; | ||
| 13 | |||
| 14 | pub use announcement::{AnnouncementPolicy, AnnouncementResult}; | ||
| 15 | pub use pr_event::PrEventPolicy; | ||
| 16 | pub use related::{ReferenceResult, RelatedEventPolicy}; | ||
| 17 | pub use state::{AlignmentResult, StatePolicy, StateResult}; | ||
| 18 | |||
| 19 | use super::SharedDatabase; | ||
| 20 | |||
| 21 | /// Shared context for all sub-policies | ||
| 22 | #[derive(Clone)] | ||
| 23 | pub struct PolicyContext { | ||
| 24 | pub domain: String, | ||
| 25 | pub database: SharedDatabase, | ||
| 26 | pub git_data_path: std::path::PathBuf, | ||
| 27 | } | ||
| 28 | |||
| 29 | impl PolicyContext { | ||
| 30 | pub fn new( | ||
| 31 | domain: impl Into<String>, | ||
| 32 | database: SharedDatabase, | ||
| 33 | git_data_path: impl Into<std::path::PathBuf>, | ||
| 34 | ) -> Self { | ||
| 35 | Self { | ||
| 36 | domain: domain.into(), | ||
| 37 | database, | ||
| 38 | git_data_path: git_data_path.into(), | ||
| 39 | } | ||
| 40 | } | ||
| 41 | } \ 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 @@ | |||
| 1 | /// PR Event Policy - PR/PR Update validation | ||
| 2 | /// | ||
| 3 | /// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619) | ||
| 4 | /// according to GRASP-01 specification. | ||
| 5 | use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; | ||
| 6 | |||
| 7 | use super::PolicyContext; | ||
| 8 | use crate::git; | ||
| 9 | use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT}; | ||
| 10 | |||
| 11 | /// Policy for validating PR and PR Update events | ||
| 12 | #[derive(Clone)] | ||
| 13 | pub struct PrEventPolicy { | ||
| 14 | ctx: PolicyContext, | ||
| 15 | } | ||
| 16 | |||
| 17 | impl PrEventPolicy { | ||
| 18 | pub fn new(ctx: PolicyContext) -> Self { | ||
| 19 | Self { ctx } | ||
| 20 | } | ||
| 21 | |||
| 22 | /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag | ||
| 23 | /// | ||
| 24 | /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, | ||
| 25 | /// this checks if a corresponding refs/nostr/<event-id> ref exists in the | ||
| 26 | /// repository and validates that it points to the correct commit (from the | ||
| 27 | /// `c` tag). If the ref exists but points to a different commit, the ref is | ||
| 28 | /// deleted. | ||
| 29 | /// | ||
| 30 | /// PR and PR Update events can have multiple `a` tags to update multiple | ||
| 31 | /// repositories simultaneously. | ||
| 32 | /// | ||
| 33 | /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent | ||
| 34 | /// with their corresponding events. | ||
| 35 | /// | ||
| 36 | /// # Returns | ||
| 37 | /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure | ||
| 38 | pub async fn validate_nostr_ref(&self, event: &Event) -> Result<Option<usize>, String> { | ||
| 39 | let event_id = event.id.to_hex(); | ||
| 40 | |||
| 41 | // Extract the `c` tag (commit hash) from the PR event | ||
| 42 | let expected_commit = event.tags.iter().find_map(|tag| { | ||
| 43 | let tag_vec = tag.clone().to_vec(); | ||
| 44 | if tag_vec.len() >= 2 && tag_vec[0] == "c" { | ||
| 45 | Some(tag_vec[1].clone()) | ||
| 46 | } else { | ||
| 47 | None | ||
| 48 | } | ||
| 49 | }); | ||
| 50 | |||
| 51 | let expected_commit = match expected_commit { | ||
| 52 | Some(c) => c, | ||
| 53 | None => { | ||
| 54 | tracing::debug!( | ||
| 55 | "PR event {} has no 'c' tag, skipping ref validation", | ||
| 56 | event_id | ||
| 57 | ); | ||
| 58 | return Ok(None); | ||
| 59 | } | ||
| 60 | }; | ||
| 61 | |||
| 62 | // Extract ALL `a` tags (repository references) from the PR event | ||
| 63 | // PR events can reference multiple repositories | ||
| 64 | // Format: 30617:<pubkey>:<identifier> | ||
| 65 | let repo_refs: Vec<String> = event | ||
| 66 | .tags | ||
| 67 | .iter() | ||
| 68 | .filter_map(|tag| { | ||
| 69 | let tag_vec = tag.clone().to_vec(); | ||
| 70 | if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") { | ||
| 71 | Some(tag_vec[1].clone()) | ||
| 72 | } else { | ||
| 73 | None | ||
| 74 | } | ||
| 75 | }) | ||
| 76 | .collect(); | ||
| 77 | |||
| 78 | if repo_refs.is_empty() { | ||
| 79 | tracing::debug!( | ||
| 80 | "PR event {} has no repo 'a' tags, skipping ref validation", | ||
| 81 | event_id | ||
| 82 | ); | ||
| 83 | return Ok(None); | ||
| 84 | } | ||
| 85 | |||
| 86 | let mut deleted_count = 0; | ||
| 87 | |||
| 88 | // Process each repository reference | ||
| 89 | for repo_ref in repo_refs { | ||
| 90 | // Parse the repo reference: 30617:<pubkey>:<identifier> | ||
| 91 | let parts: Vec<&str> = repo_ref.split(':').collect(); | ||
| 92 | if parts.len() < 3 { | ||
| 93 | tracing::debug!( | ||
| 94 | "PR event {} has invalid 'a' tag format: {}", | ||
| 95 | event_id, | ||
| 96 | repo_ref | ||
| 97 | ); | ||
| 98 | continue; | ||
| 99 | } | ||
| 100 | |||
| 101 | let repo_pubkey = match PublicKey::from_hex(parts[1]) { | ||
| 102 | Ok(pk) => pk, | ||
| 103 | Err(_) => { | ||
| 104 | tracing::debug!( | ||
| 105 | "PR event {} has invalid pubkey in 'a' tag: {}", | ||
| 106 | event_id, | ||
| 107 | parts[1] | ||
| 108 | ); | ||
| 109 | continue; | ||
| 110 | } | ||
| 111 | }; | ||
| 112 | let identifier = parts[2]; | ||
| 113 | |||
| 114 | // Look up repository announcement to get the npub for path | ||
| 115 | let filter = Filter::new() | ||
| 116 | .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) | ||
| 117 | .author(repo_pubkey) | ||
| 118 | .custom_tag( | ||
| 119 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 120 | identifier.to_string(), | ||
| 121 | ); | ||
| 122 | |||
| 123 | let announcements: Vec<Event> = match self.ctx.database.query(filter).await { | ||
| 124 | Ok(events) => events.into_iter().collect(), | ||
| 125 | Err(e) => { | ||
| 126 | tracing::warn!( | ||
| 127 | "Failed to query for repository announcement for PR {}: {}", | ||
| 128 | event_id, | ||
| 129 | e | ||
| 130 | ); | ||
| 131 | continue; | ||
| 132 | } | ||
| 133 | }; | ||
| 134 | |||
| 135 | if announcements.is_empty() { | ||
| 136 | tracing::debug!( | ||
| 137 | "No repository announcement found for PR event {} (repo {}:{})", | ||
| 138 | event_id, | ||
| 139 | repo_pubkey.to_hex(), | ||
| 140 | identifier | ||
| 141 | ); | ||
| 142 | continue; | ||
| 143 | } | ||
| 144 | |||
| 145 | // Process each matching announcement (there could be multiple) | ||
| 146 | for announcement_event in announcements { | ||
| 147 | let announcement = match RepositoryAnnouncement::from_event(announcement_event) { | ||
| 148 | Ok(a) => a, | ||
| 149 | Err(e) => { | ||
| 150 | tracing::warn!( | ||
| 151 | "Failed to parse announcement for PR {} validation: {}", | ||
| 152 | event_id, | ||
| 153 | e | ||
| 154 | ); | ||
| 155 | continue; | ||
| 156 | } | ||
| 157 | }; | ||
| 158 | |||
| 159 | // Build repository path | ||
| 160 | let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); | ||
| 161 | |||
| 162 | // Validate the ref | ||
| 163 | match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { | ||
| 164 | Ok(true) => { | ||
| 165 | tracing::info!( | ||
| 166 | "Deleted mismatched refs/nostr/{} in {} (expected commit {})", | ||
| 167 | event_id, | ||
| 168 | repo_path.display(), | ||
| 169 | expected_commit | ||
| 170 | ); | ||
| 171 | deleted_count += 1; | ||
| 172 | } | ||
| 173 | Ok(false) => { | ||
| 174 | tracing::debug!( | ||
| 175 | "refs/nostr/{} in {} is valid or doesn't exist", | ||
| 176 | event_id, | ||
| 177 | repo_path.display() | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | Err(e) => { | ||
| 181 | tracing::warn!( | ||
| 182 | "Failed to validate refs/nostr/{} in {}: {}", | ||
| 183 | event_id, | ||
| 184 | repo_path.display(), | ||
| 185 | e | ||
| 186 | ); | ||
| 187 | } | ||
| 188 | } | ||
| 189 | } | ||
| 190 | } | ||
| 191 | |||
| 192 | if deleted_count > 0 { | ||
| 193 | Ok(Some(deleted_count)) | ||
| 194 | } else { | ||
| 195 | Ok(None) | ||
| 196 | } | ||
| 197 | } | ||
| 198 | } \ 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 @@ | |||
| 1 | /// Related Event Policy - Forward/backward reference checking | ||
| 2 | /// | ||
| 3 | /// Handles validation of events that reference accepted repositories or events | ||
| 4 | /// (backward references) and events that are referenced by accepted events | ||
| 5 | /// (forward references). | ||
| 6 | use nostr_relay_builder::prelude::{ | ||
| 7 | Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag, | ||
| 8 | }; | ||
| 9 | |||
| 10 | use super::PolicyContext; | ||
| 11 | |||
| 12 | /// Result of reference checking | ||
| 13 | #[derive(Debug)] | ||
| 14 | pub enum ReferenceResult { | ||
| 15 | /// Event references an accepted repository (addressable ref found) | ||
| 16 | ReferencesRepository(String), | ||
| 17 | /// Event references an accepted event (event ID found) | ||
| 18 | ReferencesEvent(EventId), | ||
| 19 | /// Event is referenced by an accepted event (forward reference) | ||
| 20 | ReferencedByAccepted, | ||
| 21 | /// No valid references found - event is an orphan | ||
| 22 | Orphan, | ||
| 23 | } | ||
| 24 | |||
| 25 | /// Policy for checking event references (backward and forward) | ||
| 26 | #[derive(Clone)] | ||
| 27 | pub struct RelatedEventPolicy { | ||
| 28 | ctx: PolicyContext, | ||
| 29 | } | ||
| 30 | |||
| 31 | impl RelatedEventPolicy { | ||
| 32 | pub fn new(ctx: PolicyContext) -> Self { | ||
| 33 | Self { ctx } | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Check all reference types for an event | ||
| 37 | /// | ||
| 38 | /// Returns the first valid reference found, or `Orphan` if none found. | ||
| 39 | pub async fn check_references(&self, event: &Event) -> Result<ReferenceResult, String> { | ||
| 40 | // Extract all reference tags from event | ||
| 41 | let (addressable_refs, event_refs) = Self::extract_reference_tags(event); | ||
| 42 | |||
| 43 | // Check 1: Does this event reference an accepted repository? | ||
| 44 | if let Some(addr_ref) = self.find_accepted_repository(&addressable_refs).await? { | ||
| 45 | return Ok(ReferenceResult::ReferencesRepository(addr_ref)); | ||
| 46 | } | ||
| 47 | |||
| 48 | // Check 2: Does this event reference an accepted event? | ||
| 49 | if let Some(event_ref) = self.find_accepted_event(&event_refs).await? { | ||
| 50 | return Ok(ReferenceResult::ReferencesEvent(event_ref)); | ||
| 51 | } | ||
| 52 | |||
| 53 | // Check 3: Is this event referenced by an accepted event? | ||
| 54 | if self.is_referenced_by_accepted(event).await? { | ||
| 55 | return Ok(ReferenceResult::ReferencedByAccepted); | ||
| 56 | } | ||
| 57 | |||
| 58 | // No valid references found | ||
| 59 | Ok(ReferenceResult::Orphan) | ||
| 60 | } | ||
| 61 | |||
| 62 | /// Extract all reference tags from an event (a, A, q, e, E) | ||
| 63 | /// Returns (addressable_refs, event_refs) | ||
| 64 | pub fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) { | ||
| 65 | let mut addressable_refs = Vec::new(); | ||
| 66 | let mut event_refs = Vec::new(); | ||
| 67 | |||
| 68 | for tag in event.tags.iter() { | ||
| 69 | let tag_vec = tag.clone().to_vec(); | ||
| 70 | if tag_vec.is_empty() { | ||
| 71 | continue; | ||
| 72 | } | ||
| 73 | |||
| 74 | match tag_vec[0].as_str() { | ||
| 75 | // Addressable event references (a, A, q with kind:pubkey:identifier format) | ||
| 76 | "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => { | ||
| 77 | addressable_refs.push(tag_vec[1].clone()); | ||
| 78 | } | ||
| 79 | // Event ID references (e, E, q with event ID format) | ||
| 80 | "e" | "E" if tag_vec.len() > 1 => { | ||
| 81 | if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { | ||
| 82 | event_refs.push(event_id); | ||
| 83 | } | ||
| 84 | } | ||
| 85 | "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => { | ||
| 86 | if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { | ||
| 87 | event_refs.push(event_id); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | _ => {} | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | (addressable_refs, event_refs) | ||
| 95 | } | ||
| 96 | |||
| 97 | /// Check if any addressable events (repositories) exist in database | ||
| 98 | /// Returns the first matching addressable reference found, or None if none match | ||
| 99 | async fn find_accepted_repository( | ||
| 100 | &self, | ||
| 101 | addressables: &[String], | ||
| 102 | ) -> Result<Option<String>, String> { | ||
| 103 | if addressables.is_empty() { | ||
| 104 | return Ok(None); | ||
| 105 | } | ||
| 106 | |||
| 107 | // Parse all addressable references | ||
| 108 | let mut parsed_refs = Vec::new(); | ||
| 109 | for addr in addressables { | ||
| 110 | let parts: Vec<&str> = addr.split(':').collect(); | ||
| 111 | if parts.len() < 3 { | ||
| 112 | continue; // Skip invalid format | ||
| 113 | } | ||
| 114 | |||
| 115 | let kind = match parts[0].parse::<u16>() { | ||
| 116 | Ok(k) => k, | ||
| 117 | Err(_) => continue, // Skip invalid kind | ||
| 118 | }; | ||
| 119 | let pubkey = match PublicKey::from_hex(parts[1]) { | ||
| 120 | Ok(pk) => pk, | ||
| 121 | Err(_) => continue, // Skip invalid pubkey | ||
| 122 | }; | ||
| 123 | let identifier = parts[2].to_string(); | ||
| 124 | |||
| 125 | parsed_refs.push((addr.clone(), kind, pubkey, identifier)); | ||
| 126 | } | ||
| 127 | |||
| 128 | if parsed_refs.is_empty() { | ||
| 129 | return Ok(None); | ||
| 130 | } | ||
| 131 | |||
| 132 | // Group by kind to reduce queries | ||
| 133 | use std::collections::HashMap; | ||
| 134 | let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new(); | ||
| 135 | for (addr, kind, pubkey, identifier) in parsed_refs { | ||
| 136 | by_kind | ||
| 137 | .entry(kind) | ||
| 138 | .or_default() | ||
| 139 | .push((addr, pubkey, identifier)); | ||
| 140 | } | ||
| 141 | |||
| 142 | // Query each kind group | ||
| 143 | for (kind, refs) in by_kind { | ||
| 144 | let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect(); | ||
| 145 | |||
| 146 | let filter = Filter::new().kind(Kind::from(kind)).authors(authors); | ||
| 147 | |||
| 148 | match self.ctx.database.query(filter).await { | ||
| 149 | Ok(events) => { | ||
| 150 | // Check if any event matches our identifier requirements | ||
| 151 | for event in events { | ||
| 152 | for (addr, _pubkey, identifier) in &refs { | ||
| 153 | // Match identifier tag | ||
| 154 | if event.tags.iter().any(|tag| { | ||
| 155 | let tag_vec = tag.clone().to_vec(); | ||
| 156 | tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier | ||
| 157 | }) { | ||
| 158 | return Ok(Some(addr.clone())); | ||
| 159 | } | ||
| 160 | } | ||
| 161 | } | ||
| 162 | } | ||
| 163 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | Ok(None) | ||
| 168 | } | ||
| 169 | |||
| 170 | /// Check if any events exist in database | ||
| 171 | /// Returns the first matching event ID found, or None if none match | ||
| 172 | async fn find_accepted_event( | ||
| 173 | &self, | ||
| 174 | event_ids: &[EventId], | ||
| 175 | ) -> Result<Option<EventId>, String> { | ||
| 176 | if event_ids.is_empty() { | ||
| 177 | return Ok(None); | ||
| 178 | } | ||
| 179 | |||
| 180 | // Single query for all event IDs | ||
| 181 | let filter = Filter::new().ids(event_ids.iter().copied()); | ||
| 182 | |||
| 183 | match self.ctx.database.query(filter).await { | ||
| 184 | Ok(events) => { | ||
| 185 | // Get first event from the iterator | ||
| 186 | Ok(events.into_iter().next().map(|e| e.id)) | ||
| 187 | } | ||
| 188 | Err(e) => Err(format!("Database query failed: {}", e)), | ||
| 189 | } | ||
| 190 | } | ||
| 191 | |||
| 192 | /// Check if any accepted event references this event (forward reference) | ||
| 193 | /// | ||
| 194 | /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format | ||
| 195 | /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format | ||
| 196 | /// For regular events: Only checks event ID reference tags (e, E, q) | ||
| 197 | async fn is_referenced_by_accepted(&self, event: &Event) -> Result<bool, String> { | ||
| 198 | let kind_u16 = event.kind.as_u16(); | ||
| 199 | |||
| 200 | // Check if this is any kind of replaceable event | ||
| 201 | let is_regular_replaceable = (10000..20000).contains(&kind_u16); | ||
| 202 | let is_parameterized_replaceable = (30000..40000).contains(&kind_u16); | ||
| 203 | |||
| 204 | if is_regular_replaceable || is_parameterized_replaceable { | ||
| 205 | // Build the appropriate address format based on event type | ||
| 206 | let address = if is_parameterized_replaceable { | ||
| 207 | // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) | ||
| 208 | let identifier = event | ||
| 209 | .tags | ||
| 210 | .iter() | ||
| 211 | .find_map(|tag| { | ||
| 212 | let tag_vec = tag.clone().to_vec(); | ||
| 213 | if tag_vec.len() >= 2 && tag_vec[0] == "d" { | ||
| 214 | Some(tag_vec[1].clone()) | ||
| 215 | } else { | ||
| 216 | None | ||
| 217 | } | ||
| 218 | }) | ||
| 219 | .unwrap_or_default(); // Empty string if no 'd' tag | ||
| 220 | format!( | ||
| 221 | "{}:{}:{}", | ||
| 222 | event.kind.as_u16(), | ||
| 223 | event.pubkey.to_hex(), | ||
| 224 | identifier | ||
| 225 | ) | ||
| 226 | } else { | ||
| 227 | // For regular replaceable: kind:pubkey format (1 colon) | ||
| 228 | format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) | ||
| 229 | }; | ||
| 230 | |||
| 231 | // Check addressable reference tags: a, A, q (with address format) | ||
| 232 | let addressable_tags = [ | ||
| 233 | SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference | ||
| 234 | SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference | ||
| 235 | SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID) | ||
| 236 | ]; | ||
| 237 | |||
| 238 | for tag_type in &addressable_tags { | ||
| 239 | let filter = Filter::new().custom_tag(*tag_type, address.clone()); | ||
| 240 | |||
| 241 | match self.ctx.database.query(filter).await { | ||
| 242 | Ok(events) => { | ||
| 243 | if !events.is_empty() { | ||
| 244 | return Ok(true); | ||
| 245 | } | ||
| 246 | } | ||
| 247 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 248 | } | ||
| 249 | } | ||
| 250 | } else { | ||
| 251 | // For regular events, check event ID reference tags: e, E, q (with hex ID) | ||
| 252 | let event_id_hex = event.id.to_hex(); | ||
| 253 | |||
| 254 | let event_id_tags = [ | ||
| 255 | SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference | ||
| 256 | SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference | ||
| 257 | SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference | ||
| 258 | ]; | ||
| 259 | |||
| 260 | for tag_type in &event_id_tags { | ||
| 261 | let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone()); | ||
| 262 | |||
| 263 | match self.ctx.database.query(filter).await { | ||
| 264 | Ok(events) => { | ||
| 265 | if !events.is_empty() { | ||
| 266 | return Ok(true); | ||
| 267 | } | ||
| 268 | } | ||
| 269 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 270 | } | ||
| 271 | } | ||
| 272 | } | ||
| 273 | |||
| 274 | Ok(false) | ||
| 275 | } | ||
| 276 | } \ 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 @@ | |||
| 1 | /// State Policy - State event validation + ref alignment | ||
| 2 | /// | ||
| 3 | /// Handles validation of NIP-34 repository state events (kind 30618) | ||
| 4 | /// and aligns git refs with authorized state according to GRASP-01. | ||
| 5 | use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; | ||
| 6 | |||
| 7 | use super::PolicyContext; | ||
| 8 | use crate::git; | ||
| 9 | use crate::nostr::events::{ | ||
| 10 | validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, | ||
| 11 | KIND_REPOSITORY_STATE, | ||
| 12 | }; | ||
| 13 | |||
| 14 | /// Result of aligning a repository with authorized state | ||
| 15 | #[derive(Debug, Default)] | ||
| 16 | pub struct AlignmentResult { | ||
| 17 | /// Number of refs created | ||
| 18 | pub refs_created: usize, | ||
| 19 | /// Number of refs updated | ||
| 20 | pub refs_updated: usize, | ||
| 21 | /// Number of refs deleted | ||
| 22 | pub refs_deleted: usize, | ||
| 23 | /// Whether HEAD was set | ||
| 24 | pub head_set: bool, | ||
| 25 | } | ||
| 26 | |||
| 27 | impl AlignmentResult { | ||
| 28 | pub fn has_changes(&self) -> bool { | ||
| 29 | self.refs_created > 0 || self.refs_updated > 0 || self.refs_deleted > 0 || self.head_set | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | /// Result of state policy evaluation | ||
| 34 | #[derive(Debug)] | ||
| 35 | pub enum StateResult { | ||
| 36 | /// Accept: Event passes validation | ||
| 37 | Accept, | ||
| 38 | /// Reject: Event fails validation with reason | ||
| 39 | Reject(String), | ||
| 40 | } | ||
| 41 | |||
| 42 | /// Policy for validating repository state events and aligning refs | ||
| 43 | #[derive(Clone)] | ||
| 44 | pub struct StatePolicy { | ||
| 45 | ctx: PolicyContext, | ||
| 46 | } | ||
| 47 | |||
| 48 | impl StatePolicy { | ||
| 49 | pub fn new(ctx: PolicyContext) -> Self { | ||
| 50 | Self { ctx } | ||
| 51 | } | ||
| 52 | |||
| 53 | /// Validate a repository state event | ||
| 54 | pub fn validate(&self, event: &Event) -> StateResult { | ||
| 55 | match validate_state(event) { | ||
| 56 | Ok(_) => StateResult::Accept, | ||
| 57 | Err(e) => StateResult::Reject(e.to_string()), | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | /// Process a state event: validate and align owner repositories | ||
| 62 | /// | ||
| 63 | /// Returns the number of repositories aligned if successful. | ||
| 64 | pub async fn process_state_event(&self, event: &Event) -> Result<usize, String> { | ||
| 65 | // Parse state to get HEAD and branch info | ||
| 66 | let state = RepositoryState::from_event(event.clone()) | ||
| 67 | .map_err(|e| format!("Failed to parse state: {}", e))?; | ||
| 68 | |||
| 69 | // Identify owner repositories for which this is the latest authorized state | ||
| 70 | let owner_repos = self.identify_owner_repositories(&state).await?; | ||
| 71 | let repo_count = owner_repos.len(); | ||
| 72 | let mut total_aligned = 0; | ||
| 73 | |||
| 74 | // Align each owner repository with the authorized state | ||
| 75 | for (_announcement, repo_path) in owner_repos { | ||
| 76 | let result = self.align_repository_with_state(&repo_path, &state); | ||
| 77 | |||
| 78 | if result.has_changes() { | ||
| 79 | tracing::info!( | ||
| 80 | "Aligned {} with state: created={}, updated={}, deleted={}, head_set={}", | ||
| 81 | repo_path.display(), | ||
| 82 | result.refs_created, | ||
| 83 | result.refs_updated, | ||
| 84 | result.refs_deleted, | ||
| 85 | result.head_set | ||
| 86 | ); | ||
| 87 | total_aligned += 1; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | if repo_count > 0 { | ||
| 92 | tracing::info!( | ||
| 93 | "Processed state event for {} repo(s) ({} aligned) with identifier {}", | ||
| 94 | repo_count, | ||
| 95 | total_aligned, | ||
| 96 | state.identifier | ||
| 97 | ); | ||
| 98 | } else { | ||
| 99 | tracing::debug!( | ||
| 100 | "No owner repos to align for state - git data not available yet or not latest" | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | |||
| 104 | Ok(total_aligned) | ||
| 105 | } | ||
| 106 | |||
| 107 | /// Check if this state event is the latest for its identifier among authorized authors | ||
| 108 | /// | ||
| 109 | /// A state is considered "latest" if no other state event in the database | ||
| 110 | /// from an authorized author has a newer timestamp. | ||
| 111 | async fn is_latest_state_for_identifier( | ||
| 112 | &self, | ||
| 113 | state: &RepositoryState, | ||
| 114 | authorized_pubkeys: &[PublicKey], | ||
| 115 | ) -> Result<bool, String> { | ||
| 116 | let filter = Filter::new() | ||
| 117 | .kind(Kind::from(KIND_REPOSITORY_STATE)) | ||
| 118 | .custom_tag( | ||
| 119 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 120 | state.identifier.clone(), | ||
| 121 | ); | ||
| 122 | |||
| 123 | match self.ctx.database.query(filter).await { | ||
| 124 | Ok(events) => { | ||
| 125 | for event in events { | ||
| 126 | // Skip comparing to self (same event ID) | ||
| 127 | if event.id == state.event.id { | ||
| 128 | continue; | ||
| 129 | } | ||
| 130 | // Only consider events from authorized authors for this announcement | ||
| 131 | if !authorized_pubkeys.contains(&event.pubkey) { | ||
| 132 | continue; | ||
| 133 | } | ||
| 134 | // If any existing event from an authorized author is newer, this is not the latest | ||
| 135 | if event.created_at > state.event.created_at { | ||
| 136 | tracing::debug!( | ||
| 137 | "State {} is not latest: found newer state {} from {} (ts {} > {})", | ||
| 138 | state.event.id.to_hex(), | ||
| 139 | event.id.to_hex(), | ||
| 140 | event.pubkey.to_hex(), | ||
| 141 | event.created_at.as_secs(), | ||
| 142 | state.event.created_at.as_secs() | ||
| 143 | ); | ||
| 144 | return Ok(false); | ||
| 145 | } | ||
| 146 | } | ||
| 147 | Ok(true) | ||
| 148 | } | ||
| 149 | Err(e) => Err(format!("Database query failed: {}", e)), | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | /// Find all repository announcements where the given pubkey is authorized | ||
| 154 | async fn find_authorized_announcements( | ||
| 155 | &self, | ||
| 156 | identifier: &str, | ||
| 157 | state_author: &PublicKey, | ||
| 158 | ) -> Result<Vec<RepositoryAnnouncement>, String> { | ||
| 159 | let filter = Filter::new() | ||
| 160 | .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) | ||
| 161 | .custom_tag( | ||
| 162 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 163 | identifier.to_string(), | ||
| 164 | ); | ||
| 165 | |||
| 166 | match self.ctx.database.query(filter).await { | ||
| 167 | Ok(events) => { | ||
| 168 | let mut authorized = Vec::new(); | ||
| 169 | let state_author_hex = state_author.to_hex(); | ||
| 170 | |||
| 171 | for event in events { | ||
| 172 | if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { | ||
| 173 | // Check if state author is authorized for this announcement | ||
| 174 | let is_owner = event.pubkey == *state_author; | ||
| 175 | let is_maintainer = announcement.maintainers.contains(&state_author_hex); | ||
| 176 | |||
| 177 | if is_owner || is_maintainer { | ||
| 178 | tracing::debug!( | ||
| 179 | "Found authorized announcement for {}: owner={}, maintainer={}", | ||
| 180 | identifier, | ||
| 181 | if is_owner { | ||
| 182 | event.pubkey.to_hex() | ||
| 183 | } else { | ||
| 184 | "n/a".to_string() | ||
| 185 | }, | ||
| 186 | is_maintainer | ||
| 187 | ); | ||
| 188 | authorized.push(announcement); | ||
| 189 | } | ||
| 190 | } | ||
| 191 | } | ||
| 192 | Ok(authorized) | ||
| 193 | } | ||
| 194 | Err(e) => Err(format!("Database query failed: {}", e)), | ||
| 195 | } | ||
| 196 | } | ||
| 197 | |||
| 198 | /// Identify all owner repositories for which this state event is the latest authorized state | ||
| 199 | async fn identify_owner_repositories( | ||
| 200 | &self, | ||
| 201 | state: &RepositoryState, | ||
| 202 | ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> { | ||
| 203 | // Find all announcements where state author is authorized | ||
| 204 | let announcements = self | ||
| 205 | .find_authorized_announcements(&state.identifier, &state.event.pubkey) | ||
| 206 | .await?; | ||
| 207 | |||
| 208 | if announcements.is_empty() { | ||
| 209 | tracing::debug!( | ||
| 210 | "No authorized announcements found for state {} by {}", | ||
| 211 | state.identifier, | ||
| 212 | state.event.pubkey.to_hex() | ||
| 213 | ); | ||
| 214 | return Ok(Vec::new()); | ||
| 215 | } | ||
| 216 | |||
| 217 | let mut owner_repos = Vec::new(); | ||
| 218 | |||
| 219 | for announcement in announcements { | ||
| 220 | // Build the list of authorized pubkeys for this specific announcement | ||
| 221 | let mut authorized_pubkeys = vec![announcement.event.pubkey]; | ||
| 222 | for maintainer_hex in &announcement.maintainers { | ||
| 223 | if let Ok(pk) = PublicKey::from_hex(maintainer_hex) { | ||
| 224 | authorized_pubkeys.push(pk); | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | // Check if this is the latest state event for THIS announcement's context | ||
| 229 | if !self | ||
| 230 | .is_latest_state_for_identifier(state, &authorized_pubkeys) | ||
| 231 | .await? | ||
| 232 | { | ||
| 233 | tracing::debug!( | ||
| 234 | "Skipping {} in {}'s repo - not the latest state event for this context", | ||
| 235 | state.identifier, | ||
| 236 | announcement.event.pubkey.to_hex() | ||
| 237 | ); | ||
| 238 | continue; | ||
| 239 | } | ||
| 240 | |||
| 241 | // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git | ||
| 242 | let repo_path = self.ctx.git_data_path.join(announcement.repo_path().clone()); | ||
| 243 | owner_repos.push((announcement, repo_path)); | ||
| 244 | } | ||
| 245 | |||
| 246 | Ok(owner_repos) | ||
| 247 | } | ||
| 248 | |||
| 249 | /// Align a repository's refs with the authorized state | ||
| 250 | /// | ||
| 251 | /// This function: | ||
| 252 | /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/) | ||
| 253 | /// 2. Updates refs that exist in state if we have the commit | ||
| 254 | /// 3. Sets HEAD if the HEAD branch's commit is available | ||
| 255 | pub fn align_repository_with_state( | ||
| 256 | &self, | ||
| 257 | repo_path: &std::path::Path, | ||
| 258 | state: &RepositoryState, | ||
| 259 | ) -> AlignmentResult { | ||
| 260 | let mut result = AlignmentResult::default(); | ||
| 261 | |||
| 262 | // Check if repository exists | ||
| 263 | if !repo_path.exists() { | ||
| 264 | tracing::debug!( | ||
| 265 | "Repository not found at {}, cannot align with state", | ||
| 266 | repo_path.display() | ||
| 267 | ); | ||
| 268 | return result; | ||
| 269 | } | ||
| 270 | |||
| 271 | // Get current refs from the repository | ||
| 272 | let current_refs = match git::list_refs(repo_path) { | ||
| 273 | Ok(refs) => refs, | ||
| 274 | Err(e) => { | ||
| 275 | tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e); | ||
| 276 | return result; | ||
| 277 | } | ||
| 278 | }; | ||
| 279 | |||
| 280 | // Build expected refs from state | ||
| 281 | let mut expected_refs: std::collections::HashMap<String, String> = | ||
| 282 | std::collections::HashMap::new(); | ||
| 283 | |||
| 284 | for branch in &state.branches { | ||
| 285 | let ref_name = format!("refs/heads/{}", branch.name); | ||
| 286 | expected_refs.insert(ref_name, branch.commit.clone()); | ||
| 287 | } | ||
| 288 | |||
| 289 | for tag in &state.tags { | ||
| 290 | let ref_name = format!("refs/tags/{}", tag.name); | ||
| 291 | expected_refs.insert(ref_name, tag.commit.clone()); | ||
| 292 | } | ||
| 293 | |||
| 294 | // Process current refs: update or delete as needed | ||
| 295 | for (ref_name, current_commit) in ¤t_refs { | ||
| 296 | // Only process refs/heads/ and refs/tags/ | ||
| 297 | if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") { | ||
| 298 | continue; | ||
| 299 | } | ||
| 300 | |||
| 301 | match expected_refs.get(ref_name) { | ||
| 302 | Some(expected_commit) => { | ||
| 303 | // Ref should exist - check if commit matches | ||
| 304 | if current_commit != expected_commit { | ||
| 305 | // Check if we have the expected commit | ||
| 306 | if git::commit_exists(repo_path, expected_commit) { | ||
| 307 | // Update the ref | ||
| 308 | match git::update_ref(repo_path, ref_name, expected_commit) { | ||
| 309 | Ok(()) => { | ||
| 310 | tracing::info!( | ||
| 311 | "Updated {} from {} to {} in {}", | ||
| 312 | ref_name, | ||
| 313 | current_commit, | ||
| 314 | expected_commit, | ||
| 315 | repo_path.display() | ||
| 316 | ); | ||
| 317 | result.refs_updated += 1; | ||
| 318 | } | ||
| 319 | Err(e) => { | ||
| 320 | tracing::warn!( | ||
| 321 | "Failed to update {} in {}: {}", | ||
| 322 | ref_name, | ||
| 323 | repo_path.display(), | ||
| 324 | e | ||
| 325 | ); | ||
| 326 | } | ||
| 327 | } | ||
| 328 | } else { | ||
| 329 | tracing::debug!( | ||
| 330 | "Commit {} not available for {} in {}", | ||
| 331 | expected_commit, | ||
| 332 | ref_name, | ||
| 333 | repo_path.display() | ||
| 334 | ); | ||
| 335 | } | ||
| 336 | } | ||
| 337 | } | ||
| 338 | None => { | ||
| 339 | // Ref should not exist - delete it | ||
| 340 | match git::delete_ref(repo_path, ref_name) { | ||
| 341 | Ok(()) => { | ||
| 342 | tracing::info!( | ||
| 343 | "Deleted {} (not in state) from {}", | ||
| 344 | ref_name, | ||
| 345 | repo_path.display() | ||
| 346 | ); | ||
| 347 | result.refs_deleted += 1; | ||
| 348 | } | ||
| 349 | Err(e) => { | ||
| 350 | tracing::warn!( | ||
| 351 | "Failed to delete {} from {}: {}", | ||
| 352 | ref_name, | ||
| 353 | repo_path.display(), | ||
| 354 | e | ||
| 355 | ); | ||
| 356 | } | ||
| 357 | } | ||
| 358 | } | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | // Add refs that exist in state but not in repo (if we have the commit) | ||
| 363 | for (ref_name, expected_commit) in &expected_refs { | ||
| 364 | let exists = current_refs.iter().any(|(r, _)| r == ref_name); | ||
| 365 | if !exists && git::commit_exists(repo_path, expected_commit) { | ||
| 366 | match git::update_ref(repo_path, ref_name, expected_commit) { | ||
| 367 | Ok(()) => { | ||
| 368 | tracing::info!( | ||
| 369 | "Created {} at {} in {}", | ||
| 370 | ref_name, | ||
| 371 | expected_commit, | ||
| 372 | repo_path.display() | ||
| 373 | ); | ||
| 374 | result.refs_created += 1; | ||
| 375 | } | ||
| 376 | Err(e) => { | ||
| 377 | tracing::warn!( | ||
| 378 | "Failed to create {} in {}: {}", | ||
| 379 | ref_name, | ||
| 380 | repo_path.display(), | ||
| 381 | e | ||
| 382 | ); | ||
| 383 | } | ||
| 384 | } | ||
| 385 | } | ||
| 386 | } | ||
| 387 | |||
| 388 | // Set HEAD if specified in state | ||
| 389 | if let Some(head_ref) = &state.head { | ||
| 390 | if let Some(branch_name) = state.get_head_branch() { | ||
| 391 | if let Some(head_commit) = state.get_branch_commit(branch_name) { | ||
| 392 | match git::try_set_head_if_available(repo_path, head_ref, head_commit) { | ||
| 393 | Ok(true) => { | ||
| 394 | tracing::info!( | ||
| 395 | "Set HEAD to {} in {} (from state by {})", | ||
| 396 | head_ref, | ||
| 397 | repo_path.display(), | ||
| 398 | state.event.pubkey.to_hex() | ||
| 399 | ); | ||
| 400 | result.head_set = true; | ||
| 401 | } | ||
| 402 | Ok(false) => { | ||
| 403 | tracing::debug!( | ||
| 404 | "HEAD commit {} not available yet in {}", | ||
| 405 | head_commit, | ||
| 406 | repo_path.display() | ||
| 407 | ); | ||
| 408 | } | ||
| 409 | Err(e) => { | ||
| 410 | tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e); | ||
| 411 | } | ||
| 412 | } | ||
| 413 | } | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | result | ||
| 418 | } | ||
| 419 | } \ No newline at end of file | ||