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 | |
| 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
| -rw-r--r-- | README.md | 40 | ||||
| -rw-r--r-- | docs/learnings/grasp-01-implementation.md | 18 | ||||
| -rw-r--r-- | src/nostr/builder.rs | 1334 | ||||
| -rw-r--r-- | src/nostr/mod.rs | 4 | ||||
| -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 |
9 files changed, 1295 insertions, 1192 deletions
| @@ -326,23 +326,35 @@ nix develop -c cargo tarpaulin --out Html | |||
| 326 | ngit-grasp/ | 326 | ngit-grasp/ |
| 327 | ├── src/ | 327 | ├── src/ |
| 328 | │ ├── main.rs # Entry point, server setup | 328 | │ ├── main.rs # Entry point, server setup |
| 329 | │ ├── lib.rs # Library exports | ||
| 330 | │ ├── config.rs # Configuration | ||
| 329 | │ ├── git/ | 331 | │ ├── git/ |
| 330 | │ │ ├── mod.rs # Git module | 332 | │ │ ├── mod.rs # Git module + repository operations |
| 331 | │ │ ├── handler.rs # Git HTTP handlers | 333 | │ │ ├── handlers.rs # Git HTTP handlers |
| 332 | │ │ └── authorization.rs # Push validation logic | 334 | │ │ ├── authorization.rs # Push validation logic |
| 335 | │ │ ├── protocol.rs # Git protocol encoding | ||
| 336 | │ │ └── subprocess.rs # Git subprocess management | ||
| 333 | │ ├── nostr/ | 337 | │ ├── nostr/ |
| 334 | │ │ ├── mod.rs # Nostr module | 338 | │ │ ├── mod.rs # Nostr module |
| 335 | │ │ ├── relay.rs # Relay setup and policies | 339 | │ │ ├── builder.rs # Relay builder + Nip34WritePolicy |
| 336 | │ │ └── events.rs # Event handlers | 340 | │ │ ├── events.rs # Event parsing and validation |
| 337 | │ ├── storage/ | 341 | │ │ └── policy/ # Sub-policies (split for maintainability) |
| 338 | │ │ ├── mod.rs # Storage abstraction | 342 | │ │ ├── mod.rs # Policy module exports |
| 339 | │ │ └── repository.rs # Repository management | 343 | │ │ ├── announcement.rs # Repository announcement validation |
| 340 | │ └── config.rs # Configuration | 344 | │ │ ├── state.rs # State event validation + ref alignment |
| 341 | ├── docs/ | 345 | │ │ ├── pr_event.rs # PR/PR Update validation |
| 342 | │ └── ARCHITECTURE.md # Detailed architecture | 346 | │ │ └── related.rs # Forward/backward reference checking |
| 343 | ├── tests/ | 347 | │ ├── http/ |
| 344 | │ ├── integration/ # Integration tests | 348 | │ │ ├── mod.rs # HTTP module |
| 345 | │ └── fixtures/ # Test data | 349 | │ │ ├── landing.rs # Landing page handler |
| 350 | │ │ └── nip11.rs # NIP-11 relay info document | ||
| 351 | │ └── metrics/ | ||
| 352 | │ ├── mod.rs # Prometheus metrics | ||
| 353 | │ ├── bandwidth.rs # Bandwidth tracking | ||
| 354 | │ └── connection.rs # Connection tracking | ||
| 355 | ├── docs/ # Documentation (Diátaxis framework) | ||
| 356 | ├── tests/ # Integration tests | ||
| 357 | ├── grasp-audit/ # Compliance audit subproject | ||
| 346 | └── README.md | 358 | └── README.md |
| 347 | ``` | 359 | ``` |
| 348 | 360 | ||
diff --git a/docs/learnings/grasp-01-implementation.md b/docs/learnings/grasp-01-implementation.md index dea6389..719f751 100644 --- a/docs/learnings/grasp-01-implementation.md +++ b/docs/learnings/grasp-01-implementation.md | |||
| @@ -156,17 +156,17 @@ This enables parallel CI runs without interference. | |||
| 156 | 156 | ||
| 157 | **Better approach:** Treat architecture docs as living documents. When implementation diverges from the plan, update the doc immediately. The initial design document was valuable and should remain, but it should reflect what was built. | 157 | **Better approach:** Treat architecture docs as living documents. When implementation diverges from the plan, update the doc immediately. The initial design document was valuable and should remain, but it should reflect what was built. |
| 158 | 158 | ||
| 159 | ### 2. Smaller Nip34WritePolicy | 159 | ### 2. ~~Smaller Nip34WritePolicy~~ ✅ DONE |
| 160 | 160 | ||
| 161 | **What happened:** The [`Nip34WritePolicy`](src/nostr/builder.rs:51) grew to ~900 lines handling all event types. | 161 | **What happened:** The `Nip34WritePolicy` grew to ~900 lines handling all event types. |
| 162 | 162 | ||
| 163 | **Better approach:** Split into: | 163 | **Resolution:** Split into focused sub-policies in [`src/nostr/policy/`](src/nostr/policy/mod.rs:1): |
| 164 | - `AnnouncementPolicy` - Repository announcement validation | 164 | - [`AnnouncementPolicy`](src/nostr/policy/announcement.rs:1) - Repository announcement validation |
| 165 | - `StatePolicy` - State event validation + ref alignment | 165 | - [`StatePolicy`](src/nostr/policy/state.rs:1) - State event validation + ref alignment |
| 166 | - `RelatedEventPolicy` - Forward/backward reference checking | 166 | - [`RelatedEventPolicy`](src/nostr/policy/related.rs:1) - Forward/backward reference checking |
| 167 | - `PrEventPolicy` - PR/PR Update validation | 167 | - [`PrEventPolicy`](src/nostr/policy/pr_event.rs:1) - PR/PR Update validation |
| 168 | 168 | ||
| 169 | This would improve testability and readability. | 169 | The main [`Nip34WritePolicy`](src/nostr/builder.rs:51) now delegates to these sub-policies, improving testability and readability. |
| 170 | 170 | ||
| 171 | ### 3. Git Operations Module Organization | 171 | ### 3. Git Operations Module Organization |
| 172 | 172 | ||
| @@ -190,7 +190,7 @@ This would improve testability and readability. | |||
| 190 | 190 | ||
| 191 | ### High Priority | 191 | ### High Priority |
| 192 | 192 | ||
| 193 | 1. **Split `Nip34WritePolicy`** - Too large, hard to test/maintain | 193 | 1. ~~**Split `Nip34WritePolicy`**~~ ✅ DONE - Split into sub-policies in [`src/nostr/policy/`](src/nostr/policy/mod.rs:1) |
| 194 | 2. **Add unit tests for policy logic** - Currently relies on integration tests | 194 | 2. **Add unit tests for policy logic** - Currently relies on integration tests |
| 195 | 3. **Document actual architecture** - Docs describe plans, not implementation | 195 | 3. **Document actual architecture** - Docs describe plans, not implementation |
| 196 | 196 | ||
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 @@ | |||
| 1 | /// Nostr Relay Builder Configuration | 1 | /// Nostr Relay Builder Configuration |
| 2 | /// | 2 | /// |
| 3 | /// This module integrates nostr-relay-builder with NIP-34 validation logic | 3 | /// This module integrates nostr-relay-builder with NIP-34 validation logic |
| 4 | /// preserved from the original implementation. | 4 | /// using modular sub-policies for each event type. |
| 5 | use std::net::SocketAddr; | 5 | use std::net::SocketAddr; |
| 6 | use std::path::{Path, PathBuf}; | 6 | use std::path::Path; |
| 7 | use std::sync::Arc; | 7 | use std::sync::Arc; |
| 8 | 8 | ||
| 9 | use nostr::nips::nip19::ToBech32; | 9 | use nostr::nips::nip19::ToBech32; |
| 10 | use nostr::prelude::{Alphabet, SingleLetterTag}; | ||
| 11 | use nostr::{EventId, Filter, Kind, PublicKey}; | ||
| 12 | use nostr_lmdb::NostrLMDB; | 10 | use nostr_lmdb::NostrLMDB; |
| 13 | use nostr_relay_builder::prelude::*; | 11 | use nostr_relay_builder::prelude::*; |
| 14 | 12 | ||
| 15 | use crate::config::{Config, DatabaseBackend}; | 13 | use crate::config::{Config, DatabaseBackend}; |
| 16 | use crate::git; | ||
| 17 | use crate::nostr::events::{ | 14 | use crate::nostr::events::{ |
| 18 | validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, KIND_PR, | 15 | RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, |
| 19 | KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, | 16 | KIND_REPOSITORY_STATE, |
| 17 | }; | ||
| 18 | use crate::nostr::policy::{ | ||
| 19 | AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, RelatedEventPolicy, | ||
| 20 | ReferenceResult, StatePolicy, StateResult, | ||
| 20 | }; | 21 | }; |
| 21 | 22 | ||
| 22 | /// Type alias for the shared database used by the relay | 23 | /// Type alias for the shared database used by the relay |
| 23 | pub type SharedDatabase = Arc<dyn NostrDatabase>; | 24 | pub type SharedDatabase = Arc<dyn NostrDatabase>; |
| 24 | 25 | ||
| 25 | /// Result of aligning a repository with authorized state | ||
| 26 | #[derive(Debug, Default)] | ||
| 27 | struct AlignmentResult { | ||
| 28 | /// Number of refs created | ||
| 29 | refs_created: usize, | ||
| 30 | /// Number of refs updated | ||
| 31 | refs_updated: usize, | ||
| 32 | /// Number of refs deleted | ||
| 33 | refs_deleted: usize, | ||
| 34 | /// Whether HEAD was set | ||
| 35 | head_set: bool, | ||
| 36 | } | ||
| 37 | |||
| 38 | /// NIP-34 Write Policy with Full GRASP-01 Event Validation | 26 | /// NIP-34 Write Policy with Full GRASP-01 Event Validation |
| 39 | /// | 27 | /// |
| 40 | /// Validates all events according to GRASP-01 specification: | 28 | /// Validates all events according to GRASP-01 specification using modular sub-policies: |
| 41 | /// - Repository announcements must list service in clone and relays tags | 29 | /// - `AnnouncementPolicy` - Repository announcement validation |
| 42 | /// EXCEPTION: Recursive maintainer announcements are accepted even without | 30 | /// - `StatePolicy` - State event validation + ref alignment |
| 43 | /// listing the service, to enable maintainer chain discovery and GRASP-02 sync | 31 | /// - `PrEventPolicy` - PR/PR Update validation |
| 44 | /// - Repository state announcements must have valid structure | 32 | /// - `RelatedEventPolicy` - Forward/backward reference checking |
| 45 | /// - Other events must reference accepted repositories or events | ||
| 46 | /// - Forward references are supported (events referenced by accepted events) | ||
| 47 | /// - Orphan events with no valid references are rejected | ||
| 48 | /// | 33 | /// |
| 49 | /// Uses stateful database queries to check event relationships. | 34 | /// Uses stateful database queries to check event relationships. |
| 50 | #[derive(Clone)] | 35 | #[derive(Clone)] |
| 51 | pub struct Nip34WritePolicy { | 36 | pub struct Nip34WritePolicy { |
| 52 | domain: String, | 37 | ctx: PolicyContext, |
| 53 | database: SharedDatabase, | 38 | announcement_policy: AnnouncementPolicy, |
| 54 | git_data_path: PathBuf, | 39 | state_policy: StatePolicy, |
| 40 | pr_event_policy: PrEventPolicy, | ||
| 41 | related_event_policy: RelatedEventPolicy, | ||
| 55 | } | 42 | } |
| 56 | 43 | ||
| 57 | impl std::fmt::Debug for Nip34WritePolicy { | 44 | impl std::fmt::Debug for Nip34WritePolicy { |
| 58 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | 45 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 59 | f.debug_struct("Nip34WritePolicy") | 46 | f.debug_struct("Nip34WritePolicy") |
| 60 | .field("domain", &self.domain) | 47 | .field("domain", &self.ctx.domain) |
| 61 | .field("git_data_path", &self.git_data_path) | 48 | .field("git_data_path", &self.ctx.git_data_path) |
| 62 | .field("database", &"<database>") | 49 | .field("database", &"<database>") |
| 63 | .finish() | 50 | .finish() |
| 64 | } | 51 | } |
| @@ -68,837 +55,200 @@ impl Nip34WritePolicy { | |||
| 68 | pub fn new( | 55 | pub fn new( |
| 69 | domain: impl Into<String>, | 56 | domain: impl Into<String>, |
| 70 | database: SharedDatabase, | 57 | database: SharedDatabase, |
| 71 | git_data_path: impl Into<PathBuf>, | 58 | git_data_path: impl Into<std::path::PathBuf>, |
| 72 | ) -> Self { | 59 | ) -> Self { |
| 60 | let ctx = PolicyContext::new(domain, database, git_data_path); | ||
| 73 | Self { | 61 | Self { |
| 74 | domain: domain.into(), | 62 | announcement_policy: AnnouncementPolicy::new(ctx.clone()), |
| 75 | database, | 63 | state_policy: StatePolicy::new(ctx.clone()), |
| 76 | git_data_path: git_data_path.into(), | 64 | pr_event_policy: PrEventPolicy::new(ctx.clone()), |
| 65 | related_event_policy: RelatedEventPolicy::new(ctx.clone()), | ||
| 66 | ctx, | ||
| 77 | } | 67 | } |
| 78 | } | 68 | } |
| 79 | 69 | ||
| 80 | /// Create a bare git repository if it doesn't exist | 70 | /// Handle repository announcement event |
| 81 | /// Path format: <git_data_path>/<npub>/<identifier>.git | 71 | async fn handle_announcement(&self, event: &Event) -> PolicyResult { |
| 82 | fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { | 72 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); |
| 83 | let repo_path = self.git_data_path.join(announcement.repo_path()); | 73 | |
| 84 | 74 | match self.announcement_policy.validate(event).await { | |
| 85 | // Check if repository already exists | 75 | AnnouncementResult::Accept => { |
| 86 | if repo_path.exists() { | 76 | // Parse announcement to get repository details |
| 87 | tracing::debug!("Repository already exists at {}", repo_path.display()); | 77 | match RepositoryAnnouncement::from_event(event.clone()) { |
| 88 | return Ok(()); | 78 | Ok(announcement) => { |
| 89 | } | 79 | // Try to create bare repository if it doesn't exist |
| 90 | 80 | if let Err(e) = self.announcement_policy.ensure_bare_repository(&announcement) | |
| 91 | // Create parent directory (npub directory) | 81 | { |
| 92 | let parent = repo_path | 82 | tracing::warn!( |
| 93 | .parent() | 83 | "Failed to create bare repository for {}: {}", |
| 94 | .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?; | 84 | event_id_str, |
| 95 | 85 | e | |
| 96 | std::fs::create_dir_all(parent) | 86 | ); |
| 97 | .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; | 87 | // Note: We still accept the event even if repo creation fails |
| 98 | 88 | } | |
| 99 | // Initialize bare repository using git command | ||
| 100 | let output = std::process::Command::new("git") | ||
| 101 | .args(["init", "--bare", repo_path.to_str().unwrap()]) | ||
| 102 | .output() | ||
| 103 | .map_err(|e| format!("Failed to execute git init: {}", e))?; | ||
| 104 | |||
| 105 | if !output.status.success() { | ||
| 106 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 107 | return Err(format!("git init failed: {}", stderr)); | ||
| 108 | } | ||
| 109 | |||
| 110 | tracing::info!("Created bare repository at {}", repo_path.display()); | ||
| 111 | Ok(()) | ||
| 112 | } | ||
| 113 | |||
| 114 | /// Check if this state event is the latest for its identifier among authorized authors | ||
| 115 | /// | ||
| 116 | /// A state is considered "latest" if no other state event in the database | ||
| 117 | /// from an authorized author has a newer timestamp. This handles out-of-order | ||
| 118 | /// delivery where an older event arrives after a newer one. | ||
| 119 | /// | ||
| 120 | /// The authorized_pubkeys should be the owner and maintainers of a specific | ||
| 121 | /// announcement, so different owners with the same identifier don't interfere. | ||
| 122 | async fn is_latest_state_for_identifier( | ||
| 123 | database: &SharedDatabase, | ||
| 124 | state: &RepositoryState, | ||
| 125 | authorized_pubkeys: &[PublicKey], | ||
| 126 | ) -> Result<bool, String> { | ||
| 127 | let filter = Filter::new() | ||
| 128 | .kind(Kind::from(KIND_REPOSITORY_STATE)) | ||
| 129 | .custom_tag( | ||
| 130 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 131 | state.identifier.clone(), | ||
| 132 | ); | ||
| 133 | 89 | ||
| 134 | match database.query(filter).await { | 90 | tracing::debug!("Accepted repository announcement: {}", event_id_str); |
| 135 | Ok(events) => { | 91 | PolicyResult::Accept |
| 136 | for event in events { | ||
| 137 | // Skip comparing to self (same event ID) | ||
| 138 | if event.id == state.event.id { | ||
| 139 | continue; | ||
| 140 | } | 92 | } |
| 141 | // Only consider events from authorized authors for this announcement | 93 | Err(e) => { |
| 142 | if !authorized_pubkeys.contains(&event.pubkey) { | 94 | tracing::warn!( |
| 143 | continue; | 95 | "Failed to parse repository announcement {}: {}", |
| 144 | } | 96 | event_id_str, |
| 145 | // If any existing event from an authorized author is newer, this is not the latest | 97 | e |
| 146 | if event.created_at > state.event.created_at { | ||
| 147 | tracing::debug!( | ||
| 148 | "State {} is not latest: found newer state {} from {} (ts {} > {})", | ||
| 149 | state.event.id.to_hex(), | ||
| 150 | event.id.to_hex(), | ||
| 151 | event.pubkey.to_hex(), | ||
| 152 | event.created_at.as_secs(), | ||
| 153 | state.event.created_at.as_secs() | ||
| 154 | ); | 98 | ); |
| 155 | return Ok(false); | 99 | PolicyResult::Reject(format!("Failed to parse announcement: {}", e)) |
| 156 | } | 100 | } |
| 157 | } | 101 | } |
| 158 | Ok(true) | ||
| 159 | } | 102 | } |
| 160 | Err(e) => Err(format!("Database query failed: {}", e)), | 103 | AnnouncementResult::AcceptMaintainer => { |
| 161 | } | 104 | // Parse announcement to get details for logging |
| 162 | } | 105 | match RepositoryAnnouncement::from_event(event.clone()) { |
| 163 | 106 | Ok(announcement) => { | |
| 164 | /// Find all repository announcements where the given pubkey is authorized | 107 | tracing::info!( |
| 165 | /// | 108 | "Accepted maintainer announcement {} (author {} is listed as maintainer for {})", |
| 166 | /// A pubkey is authorized for an announcement if: | 109 | event_id_str, |
| 167 | /// - They are the owner (pubkey of the announcement event), OR | 110 | event.pubkey.to_hex(), |
| 168 | /// - They are listed in the "maintainers" tag | 111 | announcement.identifier |
| 169 | /// | 112 | ); |
| 170 | /// This is needed because a maintainer can publish a state event that | 113 | // Don't create bare repository for external announcements |
| 171 | /// should update HEAD in the repository of the announcement owner, | 114 | PolicyResult::Accept |
| 172 | /// not in the maintainer's own (possibly non-existent) repository. | 115 | } |
| 173 | async fn find_authorized_announcements( | 116 | Err(e) => { |
| 174 | database: &SharedDatabase, | 117 | tracing::warn!( |
| 175 | identifier: &str, | 118 | "Failed to parse maintainer announcement {}: {}", |
| 176 | state_author: &PublicKey, | 119 | event_id_str, |
| 177 | ) -> Result<Vec<RepositoryAnnouncement>, String> { | 120 | e |
| 178 | let filter = Filter::new() | 121 | ); |
| 179 | .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) | 122 | PolicyResult::Reject(format!("Failed to parse announcement: {}", e)) |
| 180 | .custom_tag( | ||
| 181 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 182 | identifier.to_string(), | ||
| 183 | ); | ||
| 184 | |||
| 185 | match database.query(filter).await { | ||
| 186 | Ok(events) => { | ||
| 187 | let mut authorized = Vec::new(); | ||
| 188 | let state_author_hex = state_author.to_hex(); | ||
| 189 | |||
| 190 | for event in events { | ||
| 191 | if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { | ||
| 192 | // Check if state author is authorized for this announcement | ||
| 193 | let is_owner = event.pubkey == *state_author; | ||
| 194 | let is_maintainer = announcement.maintainers.contains(&state_author_hex); | ||
| 195 | |||
| 196 | if is_owner || is_maintainer { | ||
| 197 | tracing::debug!( | ||
| 198 | "Found authorized announcement for {}: owner={}, maintainer={}", | ||
| 199 | identifier, | ||
| 200 | if is_owner { | ||
| 201 | event.pubkey.to_hex() | ||
| 202 | } else { | ||
| 203 | "n/a".to_string() | ||
| 204 | }, | ||
| 205 | is_maintainer | ||
| 206 | ); | ||
| 207 | authorized.push(announcement); | ||
| 208 | } | ||
| 209 | } | 123 | } |
| 210 | } | ||
| 211 | Ok(authorized) | ||
| 212 | } | ||
| 213 | Err(e) => Err(format!("Database query failed: {}", e)), | ||
| 214 | } | ||
| 215 | } | ||
| 216 | |||
| 217 | /// Identify all owner repositories for which this state event is the latest authorized state | ||
| 218 | /// | ||
| 219 | /// Returns a list of (announcement, repo_path) pairs where: | ||
| 220 | /// - The state author is authorized (owner or maintainer) | ||
| 221 | /// - This state event is the latest for the identifier in that context | ||
| 222 | async fn identify_owner_repositories( | ||
| 223 | &self, | ||
| 224 | database: &SharedDatabase, | ||
| 225 | state: &RepositoryState, | ||
| 226 | ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> { | ||
| 227 | // Find all announcements where state author is authorized | ||
| 228 | let announcements = | ||
| 229 | Self::find_authorized_announcements(database, &state.identifier, &state.event.pubkey) | ||
| 230 | .await?; | ||
| 231 | |||
| 232 | if announcements.is_empty() { | ||
| 233 | tracing::debug!( | ||
| 234 | "No authorized announcements found for state {} by {}", | ||
| 235 | state.identifier, | ||
| 236 | state.event.pubkey.to_hex() | ||
| 237 | ); | ||
| 238 | return Ok(Vec::new()); | ||
| 239 | } | ||
| 240 | |||
| 241 | let mut owner_repos = Vec::new(); | ||
| 242 | |||
| 243 | for announcement in announcements { | ||
| 244 | // Build the list of authorized pubkeys for this specific announcement | ||
| 245 | // (owner + maintainers) | ||
| 246 | let mut authorized_pubkeys = vec![announcement.event.pubkey]; | ||
| 247 | for maintainer_hex in &announcement.maintainers { | ||
| 248 | if let Ok(pk) = PublicKey::from_hex(maintainer_hex) { | ||
| 249 | authorized_pubkeys.push(pk); | ||
| 250 | } | 124 | } |
| 251 | } | 125 | } |
| 252 | 126 | AnnouncementResult::Reject(reason) => { | |
| 253 | // Check if this is the latest state event for THIS announcement's context | 127 | tracing::warn!( |
| 254 | if !Self::is_latest_state_for_identifier(database, state, &authorized_pubkeys).await? { | 128 | "Rejected repository announcement {}: {}", |
| 255 | tracing::debug!( | 129 | event_id_str, |
| 256 | "Skipping {} in {}'s repo - not the latest state event for this context", | 130 | reason |
| 257 | state.identifier, | ||
| 258 | announcement.event.pubkey.to_hex() | ||
| 259 | ); | 131 | ); |
| 260 | continue; | 132 | PolicyResult::Reject(reason) |
| 261 | } | 133 | } |
| 262 | |||
| 263 | // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git | ||
| 264 | let repo_path = self.git_data_path.join(announcement.repo_path().clone()); | ||
| 265 | owner_repos.push((announcement, repo_path)); | ||
| 266 | } | 134 | } |
| 267 | |||
| 268 | Ok(owner_repos) | ||
| 269 | } | 135 | } |
| 270 | 136 | ||
| 271 | /// Align an owner repository's refs with the authorized state | 137 | /// Handle repository state event |
| 272 | /// | 138 | async fn handle_state(&self, event: &Event) -> PolicyResult { |
| 273 | /// This function: | 139 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); |
| 274 | /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/) | 140 | |
| 275 | /// 2. Updates refs that exist in state if we have the commit (for refs/heads/ and refs/tags/) | 141 | match self.state_policy.validate(event) { |
| 276 | /// 3. Sets HEAD if the HEAD branch's commit is available | 142 | StateResult::Accept => { |
| 277 | /// | 143 | // Parse state to get HEAD and branch info |
| 278 | /// Per GRASP-01: "MUST set repository HEAD per repository state announcement | 144 | match RepositoryState::from_event(event.clone()) { |
| 279 | /// as soon as the git data related to that branch has been received." | 145 | Ok(_state) => { |
| 280 | /// | 146 | // Process state alignment asynchronously |
| 281 | /// Returns a summary of actions taken. | 147 | if let Err(e) = self.state_policy.process_state_event(event).await { |
| 282 | fn align_owner_repository_with_state( | ||
| 283 | &self, | ||
| 284 | repo_path: &std::path::Path, | ||
| 285 | state: &RepositoryState, | ||
| 286 | ) -> AlignmentResult { | ||
| 287 | let mut result = AlignmentResult::default(); | ||
| 288 | |||
| 289 | // Check if repository exists | ||
| 290 | if !repo_path.exists() { | ||
| 291 | tracing::debug!( | ||
| 292 | "Repository not found at {}, cannot align with state", | ||
| 293 | repo_path.display() | ||
| 294 | ); | ||
| 295 | return result; | ||
| 296 | } | ||
| 297 | |||
| 298 | // Get current refs from the repository | ||
| 299 | let current_refs = match git::list_refs(repo_path) { | ||
| 300 | Ok(refs) => refs, | ||
| 301 | Err(e) => { | ||
| 302 | tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e); | ||
| 303 | return result; | ||
| 304 | } | ||
| 305 | }; | ||
| 306 | |||
| 307 | // Build expected refs from state | ||
| 308 | let mut expected_refs: std::collections::HashMap<String, String> = | ||
| 309 | std::collections::HashMap::new(); | ||
| 310 | |||
| 311 | for branch in &state.branches { | ||
| 312 | let ref_name = format!("refs/heads/{}", branch.name); | ||
| 313 | expected_refs.insert(ref_name, branch.commit.clone()); | ||
| 314 | } | ||
| 315 | |||
| 316 | for tag in &state.tags { | ||
| 317 | let ref_name = format!("refs/tags/{}", tag.name); | ||
| 318 | expected_refs.insert(ref_name, tag.commit.clone()); | ||
| 319 | } | ||
| 320 | |||
| 321 | // Process current refs: update or delete as needed | ||
| 322 | for (ref_name, current_commit) in ¤t_refs { | ||
| 323 | // Only process refs/heads/ and refs/tags/ | ||
| 324 | if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") { | ||
| 325 | continue; | ||
| 326 | } | ||
| 327 | |||
| 328 | match expected_refs.get(ref_name) { | ||
| 329 | Some(expected_commit) => { | ||
| 330 | // Ref should exist - check if commit matches | ||
| 331 | if current_commit != expected_commit { | ||
| 332 | // Check if we have the expected commit | ||
| 333 | if git::commit_exists(repo_path, expected_commit) { | ||
| 334 | // Update the ref | ||
| 335 | match git::update_ref(repo_path, ref_name, expected_commit) { | ||
| 336 | Ok(()) => { | ||
| 337 | tracing::info!( | ||
| 338 | "Updated {} from {} to {} in {}", | ||
| 339 | ref_name, | ||
| 340 | current_commit, | ||
| 341 | expected_commit, | ||
| 342 | repo_path.display() | ||
| 343 | ); | ||
| 344 | result.refs_updated += 1; | ||
| 345 | } | ||
| 346 | Err(e) => { | ||
| 347 | tracing::warn!( | ||
| 348 | "Failed to update {} in {}: {}", | ||
| 349 | ref_name, | ||
| 350 | repo_path.display(), | ||
| 351 | e | ||
| 352 | ); | ||
| 353 | } | ||
| 354 | } | ||
| 355 | } else { | ||
| 356 | tracing::debug!( | ||
| 357 | "Commit {} not available for {} in {}", | ||
| 358 | expected_commit, | ||
| 359 | ref_name, | ||
| 360 | repo_path.display() | ||
| 361 | ); | ||
| 362 | } | ||
| 363 | } | ||
| 364 | } | ||
| 365 | None => { | ||
| 366 | // Ref should not exist - delete it | ||
| 367 | match git::delete_ref(repo_path, ref_name) { | ||
| 368 | Ok(()) => { | ||
| 369 | tracing::info!( | ||
| 370 | "Deleted {} (not in state) from {}", | ||
| 371 | ref_name, | ||
| 372 | repo_path.display() | ||
| 373 | ); | ||
| 374 | result.refs_deleted += 1; | ||
| 375 | } | ||
| 376 | Err(e) => { | ||
| 377 | tracing::warn!( | 148 | tracing::warn!( |
| 378 | "Failed to delete {} from {}: {}", | 149 | "Failed to process state event {}: {}", |
| 379 | ref_name, | 150 | event_id_str, |
| 380 | repo_path.display(), | ||
| 381 | e | 151 | e |
| 382 | ); | 152 | ); |
| 383 | } | 153 | } |
| 384 | } | ||
| 385 | } | ||
| 386 | } | ||
| 387 | } | ||
| 388 | 154 | ||
| 389 | // Add refs that exist in state but not in repo (if we have the commit) | 155 | tracing::debug!("Accepted repository state: {}", event_id_str); |
| 390 | for (ref_name, expected_commit) in &expected_refs { | 156 | PolicyResult::Accept |
| 391 | let exists = current_refs.iter().any(|(r, _)| r == ref_name); | ||
| 392 | if !exists && git::commit_exists(repo_path, expected_commit) { | ||
| 393 | match git::update_ref(repo_path, ref_name, expected_commit) { | ||
| 394 | Ok(()) => { | ||
| 395 | tracing::info!( | ||
| 396 | "Created {} at {} in {}", | ||
| 397 | ref_name, | ||
| 398 | expected_commit, | ||
| 399 | repo_path.display() | ||
| 400 | ); | ||
| 401 | result.refs_created += 1; | ||
| 402 | } | 157 | } |
| 403 | Err(e) => { | 158 | Err(e) => { |
| 404 | tracing::warn!( | 159 | tracing::warn!( |
| 405 | "Failed to create {} in {}: {}", | 160 | "Failed to parse repository state {}: {}", |
| 406 | ref_name, | 161 | event_id_str, |
| 407 | repo_path.display(), | ||
| 408 | e | 162 | e |
| 409 | ); | 163 | ); |
| 164 | // Still accept the event even if we can't parse it | ||
| 165 | // The validation passed, so it's structurally valid | ||
| 166 | PolicyResult::Accept | ||
| 410 | } | 167 | } |
| 411 | } | 168 | } |
| 412 | } | 169 | } |
| 413 | } | 170 | StateResult::Reject(reason) => { |
| 414 | 171 | tracing::warn!("Rejected repository state {}: {}", event_id_str, reason); | |
| 415 | // Set HEAD if specified in state | 172 | PolicyResult::Reject(reason) |
| 416 | if let Some(head_ref) = &state.head { | ||
| 417 | if let Some(branch_name) = state.get_head_branch() { | ||
| 418 | if let Some(head_commit) = state.get_branch_commit(branch_name) { | ||
| 419 | match git::try_set_head_if_available(repo_path, head_ref, head_commit) { | ||
| 420 | Ok(true) => { | ||
| 421 | tracing::info!( | ||
| 422 | "Set HEAD to {} in {} (from state by {})", | ||
| 423 | head_ref, | ||
| 424 | repo_path.display(), | ||
| 425 | state.event.pubkey.to_hex() | ||
| 426 | ); | ||
| 427 | result.head_set = true; | ||
| 428 | } | ||
| 429 | Ok(false) => { | ||
| 430 | tracing::debug!( | ||
| 431 | "HEAD commit {} not available yet in {}", | ||
| 432 | head_commit, | ||
| 433 | repo_path.display() | ||
| 434 | ); | ||
| 435 | } | ||
| 436 | Err(e) => { | ||
| 437 | tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e); | ||
| 438 | } | ||
| 439 | } | ||
| 440 | } | ||
| 441 | } | 173 | } |
| 442 | } | 174 | } |
| 443 | |||
| 444 | result | ||
| 445 | } | 175 | } |
| 446 | 176 | ||
| 447 | /// Check if a pubkey is listed as a maintainer in any announcement for this identifier | 177 | /// Handle PR or PR Update event |
| 448 | /// | 178 | async fn handle_pr_event(&self, event: &Event) -> PolicyResult { |
| 449 | /// A pubkey is considered a maintainer if: | 179 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); |
| 450 | /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR | 180 | |
| 451 | /// 2. They are listed in the maintainers tag of ANY announcement with this identifier | 181 | // Validate refs/nostr refs for this PR event |
| 452 | /// | 182 | // This deletes any refs/nostr/<event-id> that points to wrong commit |
| 453 | /// This enables accepting announcements from maintainers even when they don't list | 183 | if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await { |
| 454 | /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. | 184 | tracing::warn!( |
| 455 | async fn is_maintainer_in_any_announcement( | 185 | "Failed to validate refs/nostr for PR event {}: {}", |
| 456 | database: &SharedDatabase, | 186 | event_id_str, |
| 457 | identifier: &str, | 187 | e |
| 458 | author: &PublicKey, | ||
| 459 | ) -> Result<bool, String> { | ||
| 460 | // Query all announcements with this identifier that are already in the database | ||
| 461 | let filter = Filter::new() | ||
| 462 | .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) | ||
| 463 | .custom_tag( | ||
| 464 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 465 | identifier.to_string(), | ||
| 466 | ); | 188 | ); |
| 467 | 189 | // Don't reject - just log the error and proceed with normal validation | |
| 468 | let announcements: Vec<Event> = match database.query(filter).await { | ||
| 469 | Ok(events) => events.into_iter().collect(), | ||
| 470 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 471 | }; | ||
| 472 | |||
| 473 | if announcements.is_empty() { | ||
| 474 | // No existing announcements for this identifier - author cannot be a maintainer | ||
| 475 | return Ok(false); | ||
| 476 | } | ||
| 477 | |||
| 478 | let author_hex = author.to_hex(); | ||
| 479 | |||
| 480 | // Check each announcement to see if author is listed as a maintainer | ||
| 481 | for event in &announcements { | ||
| 482 | // Check if author is the owner of this announcement | ||
| 483 | if event.pubkey == *author { | ||
| 484 | return Ok(true); | ||
| 485 | } | ||
| 486 | |||
| 487 | // Check if author is listed in the maintainers tag | ||
| 488 | if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { | ||
| 489 | if announcement.maintainers.contains(&author_hex) { | ||
| 490 | return Ok(true); | ||
| 491 | } | ||
| 492 | } | ||
| 493 | } | ||
| 494 | |||
| 495 | Ok(false) | ||
| 496 | } | ||
| 497 | |||
| 498 | /// Extract all reference tags from an event (a, A, q, e, E) | ||
| 499 | /// Returns (addressable_refs, event_refs) | ||
| 500 | fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) { | ||
| 501 | let mut addressable_refs = Vec::new(); | ||
| 502 | let mut event_refs = Vec::new(); | ||
| 503 | |||
| 504 | for tag in event.tags.iter() { | ||
| 505 | let tag_vec = tag.clone().to_vec(); | ||
| 506 | if tag_vec.is_empty() { | ||
| 507 | continue; | ||
| 508 | } | ||
| 509 | |||
| 510 | match tag_vec[0].as_str() { | ||
| 511 | // Addressable event references (a, A, q with kind:pubkey:identifier format) | ||
| 512 | "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => { | ||
| 513 | addressable_refs.push(tag_vec[1].clone()); | ||
| 514 | } | ||
| 515 | // Event ID references (e, E, q with event ID format) | ||
| 516 | "e" | "E" if tag_vec.len() > 1 => { | ||
| 517 | if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { | ||
| 518 | event_refs.push(event_id); | ||
| 519 | } | ||
| 520 | } | ||
| 521 | "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => { | ||
| 522 | if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { | ||
| 523 | event_refs.push(event_id); | ||
| 524 | } | ||
| 525 | } | ||
| 526 | _ => {} | ||
| 527 | } | ||
| 528 | } | 190 | } |
| 529 | 191 | ||
| 530 | (addressable_refs, event_refs) | 192 | // Continue with reference checking (same as related events) |
| 193 | self.handle_related_event(event, "PR").await | ||
| 531 | } | 194 | } |
| 532 | 195 | ||
| 533 | /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag | 196 | /// Handle events that must reference accepted repositories or events |
| 534 | /// | 197 | async fn handle_related_event(&self, event: &Event, event_type: &str) -> PolicyResult { |
| 535 | /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, | 198 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); |
| 536 | /// this checks if a corresponding refs/nostr/<event-id> ref exists in the | ||
| 537 | /// repository and validates that it points to the correct commit (from the | ||
| 538 | /// `c` tag). If the ref exists but points to a different commit, the ref is | ||
| 539 | /// deleted. | ||
| 540 | /// | ||
| 541 | /// PR and PR Update events can have multiple `a` tags to update multiple | ||
| 542 | /// repositories simultaneously. | ||
| 543 | /// | ||
| 544 | /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent | ||
| 545 | /// with their corresponding events. | ||
| 546 | /// | ||
| 547 | /// # Arguments | ||
| 548 | /// * `database` - Database for looking up repository announcements | ||
| 549 | /// * `event` - The PR event (kind 1618) or PR Update event (kind 1619) | ||
| 550 | /// | ||
| 551 | /// # Returns | ||
| 552 | /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure | ||
| 553 | async fn validate_pr_nostr_ref( | ||
| 554 | &self, | ||
| 555 | database: &SharedDatabase, | ||
| 556 | event: &Event, | ||
| 557 | ) -> Result<Option<usize>, String> { | ||
| 558 | let event_id = event.id.to_hex(); | ||
| 559 | |||
| 560 | // Extract the `c` tag (commit hash) from the PR event | ||
| 561 | let expected_commit = event.tags.iter().find_map(|tag| { | ||
| 562 | let tag_vec = tag.clone().to_vec(); | ||
| 563 | if tag_vec.len() >= 2 && tag_vec[0] == "c" { | ||
| 564 | Some(tag_vec[1].clone()) | ||
| 565 | } else { | ||
| 566 | None | ||
| 567 | } | ||
| 568 | }); | ||
| 569 | 199 | ||
| 570 | let expected_commit = match expected_commit { | 200 | match self.related_event_policy.check_references(event).await { |
| 571 | Some(c) => c, | 201 | Ok(ReferenceResult::ReferencesRepository(addr_ref)) => { |
| 572 | None => { | ||
| 573 | tracing::debug!( | 202 | tracing::debug!( |
| 574 | "PR event {} has no 'c' tag, skipping ref validation", | 203 | "Accepted {} event {}: references accepted repository {}", |
| 575 | event_id | 204 | event_type, |
| 205 | event_id_str, | ||
| 206 | addr_ref | ||
| 576 | ); | 207 | ); |
| 577 | return Ok(None); | 208 | PolicyResult::Accept |
| 578 | } | 209 | } |
| 579 | }; | 210 | Ok(ReferenceResult::ReferencesEvent(event_ref)) => { |
| 580 | |||
| 581 | // Extract ALL `a` tags (repository references) from the PR event | ||
| 582 | // PR events can reference multiple repositories | ||
| 583 | // Format: 30617:<pubkey>:<identifier> | ||
| 584 | let repo_refs: Vec<String> = event | ||
| 585 | .tags | ||
| 586 | .iter() | ||
| 587 | .filter_map(|tag| { | ||
| 588 | let tag_vec = tag.clone().to_vec(); | ||
| 589 | if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") { | ||
| 590 | Some(tag_vec[1].clone()) | ||
| 591 | } else { | ||
| 592 | None | ||
| 593 | } | ||
| 594 | }) | ||
| 595 | .collect(); | ||
| 596 | |||
| 597 | if repo_refs.is_empty() { | ||
| 598 | tracing::debug!( | ||
| 599 | "PR event {} has no repo 'a' tags, skipping ref validation", | ||
| 600 | event_id | ||
| 601 | ); | ||
| 602 | return Ok(None); | ||
| 603 | } | ||
| 604 | |||
| 605 | let mut deleted_count = 0; | ||
| 606 | |||
| 607 | // Process each repository reference | ||
| 608 | for repo_ref in repo_refs { | ||
| 609 | // Parse the repo reference: 30617:<pubkey>:<identifier> | ||
| 610 | let parts: Vec<&str> = repo_ref.split(':').collect(); | ||
| 611 | if parts.len() < 3 { | ||
| 612 | tracing::debug!( | 211 | tracing::debug!( |
| 613 | "PR event {} has invalid 'a' tag format: {}", | 212 | "Accepted {} event {}: references accepted event {}", |
| 614 | event_id, | 213 | event_type, |
| 615 | repo_ref | 214 | event_id_str, |
| 215 | event_ref | ||
| 616 | ); | 216 | ); |
| 617 | continue; | 217 | PolicyResult::Accept |
| 618 | } | 218 | } |
| 619 | 219 | Ok(ReferenceResult::ReferencedByAccepted) => { | |
| 620 | let repo_pubkey = match PublicKey::from_hex(parts[1]) { | ||
| 621 | Ok(pk) => pk, | ||
| 622 | Err(_) => { | ||
| 623 | tracing::debug!( | ||
| 624 | "PR event {} has invalid pubkey in 'a' tag: {}", | ||
| 625 | event_id, | ||
| 626 | parts[1] | ||
| 627 | ); | ||
| 628 | continue; | ||
| 629 | } | ||
| 630 | }; | ||
| 631 | let identifier = parts[2]; | ||
| 632 | |||
| 633 | // Look up repository announcement to get the npub for path | ||
| 634 | let filter = Filter::new() | ||
| 635 | .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) | ||
| 636 | .author(repo_pubkey) | ||
| 637 | .custom_tag( | ||
| 638 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 639 | identifier.to_string(), | ||
| 640 | ); | ||
| 641 | |||
| 642 | let announcements: Vec<Event> = match database.query(filter).await { | ||
| 643 | Ok(events) => events.into_iter().collect(), | ||
| 644 | Err(e) => { | ||
| 645 | tracing::warn!( | ||
| 646 | "Failed to query for repository announcement for PR {}: {}", | ||
| 647 | event_id, | ||
| 648 | e | ||
| 649 | ); | ||
| 650 | continue; | ||
| 651 | } | ||
| 652 | }; | ||
| 653 | |||
| 654 | if announcements.is_empty() { | ||
| 655 | tracing::debug!( | 220 | tracing::debug!( |
| 656 | "No repository announcement found for PR event {} (repo {}:{})", | 221 | "Accepted {} event {}: referenced by accepted event", |
| 657 | event_id, | 222 | event_type, |
| 658 | repo_pubkey.to_hex(), | 223 | event_id_str |
| 659 | identifier | ||
| 660 | ); | 224 | ); |
| 661 | continue; | 225 | PolicyResult::Accept |
| 662 | } | ||
| 663 | |||
| 664 | // Process each matching announcement (there could be multiple) | ||
| 665 | for announcement_event in announcements { | ||
| 666 | let announcement = match RepositoryAnnouncement::from_event(announcement_event) { | ||
| 667 | Ok(a) => a, | ||
| 668 | Err(e) => { | ||
| 669 | tracing::warn!( | ||
| 670 | "Failed to parse announcement for PR {} validation: {}", | ||
| 671 | event_id, | ||
| 672 | e | ||
| 673 | ); | ||
| 674 | continue; | ||
| 675 | } | ||
| 676 | }; | ||
| 677 | |||
| 678 | // Build repository path | ||
| 679 | let repo_path = self.git_data_path.join(announcement.repo_path()); | ||
| 680 | |||
| 681 | // Validate the ref | ||
| 682 | match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { | ||
| 683 | Ok(true) => { | ||
| 684 | tracing::info!( | ||
| 685 | "Deleted mismatched refs/nostr/{} in {} (expected commit {})", | ||
| 686 | event_id, | ||
| 687 | repo_path.display(), | ||
| 688 | expected_commit | ||
| 689 | ); | ||
| 690 | deleted_count += 1; | ||
| 691 | } | ||
| 692 | Ok(false) => { | ||
| 693 | tracing::debug!( | ||
| 694 | "refs/nostr/{} in {} is valid or doesn't exist", | ||
| 695 | event_id, | ||
| 696 | repo_path.display() | ||
| 697 | ); | ||
| 698 | } | ||
| 699 | Err(e) => { | ||
| 700 | tracing::warn!( | ||
| 701 | "Failed to validate refs/nostr/{} in {}: {}", | ||
| 702 | event_id, | ||
| 703 | repo_path.display(), | ||
| 704 | e | ||
| 705 | ); | ||
| 706 | } | ||
| 707 | } | ||
| 708 | } | ||
| 709 | } | ||
| 710 | |||
| 711 | if deleted_count > 0 { | ||
| 712 | Ok(Some(deleted_count)) | ||
| 713 | } else { | ||
| 714 | Ok(None) | ||
| 715 | } | ||
| 716 | } | ||
| 717 | |||
| 718 | /// Check if any addressable events (repositories) exist in database | ||
| 719 | /// Returns the first matching addressable reference found, or None if none match | ||
| 720 | async fn find_accepted_repository( | ||
| 721 | database: &SharedDatabase, | ||
| 722 | addressables: &[String], | ||
| 723 | ) -> Result<Option<String>, String> { | ||
| 724 | if addressables.is_empty() { | ||
| 725 | return Ok(None); | ||
| 726 | } | ||
| 727 | |||
| 728 | // Parse all addressable references | ||
| 729 | let mut parsed_refs = Vec::new(); | ||
| 730 | for addr in addressables { | ||
| 731 | let parts: Vec<&str> = addr.split(':').collect(); | ||
| 732 | if parts.len() < 3 { | ||
| 733 | continue; // Skip invalid format | ||
| 734 | } | ||
| 735 | |||
| 736 | let kind = match parts[0].parse::<u16>() { | ||
| 737 | Ok(k) => k, | ||
| 738 | Err(_) => continue, // Skip invalid kind | ||
| 739 | }; | ||
| 740 | let pubkey = match PublicKey::from_hex(parts[1]) { | ||
| 741 | Ok(pk) => pk, | ||
| 742 | Err(_) => continue, // Skip invalid pubkey | ||
| 743 | }; | ||
| 744 | let identifier = parts[2].to_string(); | ||
| 745 | |||
| 746 | parsed_refs.push((addr.clone(), kind, pubkey, identifier)); | ||
| 747 | } | ||
| 748 | |||
| 749 | if parsed_refs.is_empty() { | ||
| 750 | return Ok(None); | ||
| 751 | } | ||
| 752 | |||
| 753 | // Group by kind to reduce queries | ||
| 754 | use std::collections::HashMap; | ||
| 755 | let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new(); | ||
| 756 | for (addr, kind, pubkey, identifier) in parsed_refs { | ||
| 757 | by_kind | ||
| 758 | .entry(kind) | ||
| 759 | .or_default() | ||
| 760 | .push((addr, pubkey, identifier)); | ||
| 761 | } | ||
| 762 | |||
| 763 | // Query each kind group | ||
| 764 | for (kind, refs) in by_kind { | ||
| 765 | let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect(); | ||
| 766 | |||
| 767 | let filter = Filter::new().kind(Kind::from(kind)).authors(authors); | ||
| 768 | |||
| 769 | match database.query(filter).await { | ||
| 770 | Ok(events) => { | ||
| 771 | // Check if any event matches our identifier requirements | ||
| 772 | for event in events { | ||
| 773 | for (addr, _pubkey, identifier) in &refs { | ||
| 774 | // Match identifier tag | ||
| 775 | if event.tags.iter().any(|tag| { | ||
| 776 | let tag_vec = tag.clone().to_vec(); | ||
| 777 | tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier | ||
| 778 | }) { | ||
| 779 | return Ok(Some(addr.clone())); | ||
| 780 | } | ||
| 781 | } | ||
| 782 | } | ||
| 783 | } | ||
| 784 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 785 | } | 226 | } |
| 786 | } | 227 | Ok(ReferenceResult::Orphan) => { |
| 787 | 228 | let (addressable_refs, event_refs) = | |
| 788 | Ok(None) | 229 | RelatedEventPolicy::extract_reference_tags(event); |
| 789 | } | 230 | tracing::info!( |
| 790 | 231 | "Rejected orphan {} event {}: no references to accepted repos or events (checked {} addressable, {} event refs)", | |
| 791 | /// Check if any events exist in database | 232 | event_type, |
| 792 | /// Returns the first matching event ID found, or None if none match | 233 | event_id_str, |
| 793 | async fn find_accepted_event( | 234 | addressable_refs.len(), |
| 794 | database: &SharedDatabase, | 235 | event_refs.len() |
| 795 | event_ids: &[EventId], | 236 | ); |
| 796 | ) -> Result<Option<EventId>, String> { | 237 | PolicyResult::Reject(format!( |
| 797 | if event_ids.is_empty() { | 238 | "{} event must reference an accepted repository or accepted event", |
| 798 | return Ok(None); | 239 | event_type |
| 799 | } | 240 | )) |
| 800 | |||
| 801 | // Single query for all event IDs | ||
| 802 | let filter = Filter::new().ids(event_ids.iter().copied()); | ||
| 803 | |||
| 804 | match database.query(filter).await { | ||
| 805 | Ok(events) => { | ||
| 806 | // Get first event from the iterator | ||
| 807 | Ok(events.into_iter().next().map(|e| e.id)) | ||
| 808 | } | ||
| 809 | Err(e) => Err(format!("Database query failed: {}", e)), | ||
| 810 | } | ||
| 811 | } | ||
| 812 | |||
| 813 | /// Check if any accepted event references this event (forward reference) | ||
| 814 | /// | ||
| 815 | /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format | ||
| 816 | /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format | ||
| 817 | /// For regular events: Only checks event ID reference tags (e, E, q) | ||
| 818 | /// | ||
| 819 | /// This optimization recognizes that replaceable events are referenced by coordinate address, | ||
| 820 | /// while regular events are referenced by event ID. | ||
| 821 | async fn is_referenced_by_accepted( | ||
| 822 | database: &SharedDatabase, | ||
| 823 | event: &Event, | ||
| 824 | ) -> Result<bool, String> { | ||
| 825 | let kind_u16 = event.kind.as_u16(); | ||
| 826 | |||
| 827 | // Check if this is any kind of replaceable event | ||
| 828 | let is_regular_replaceable = (10000..20000).contains(&kind_u16); | ||
| 829 | let is_parameterized_replaceable = (30000..40000).contains(&kind_u16); | ||
| 830 | |||
| 831 | if is_regular_replaceable || is_parameterized_replaceable { | ||
| 832 | // Build the appropriate address format based on event type | ||
| 833 | let address = if is_parameterized_replaceable { | ||
| 834 | // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) | ||
| 835 | let identifier = event | ||
| 836 | .tags | ||
| 837 | .iter() | ||
| 838 | .find_map(|tag| { | ||
| 839 | let tag_vec = tag.clone().to_vec(); | ||
| 840 | if tag_vec.len() >= 2 && tag_vec[0] == "d" { | ||
| 841 | Some(tag_vec[1].clone()) | ||
| 842 | } else { | ||
| 843 | None | ||
| 844 | } | ||
| 845 | }) | ||
| 846 | .unwrap_or_default(); // Empty string if no 'd' tag | ||
| 847 | format!( | ||
| 848 | "{}:{}:{}", | ||
| 849 | event.kind.as_u16(), | ||
| 850 | event.pubkey.to_hex(), | ||
| 851 | identifier | ||
| 852 | ) | ||
| 853 | } else { | ||
| 854 | // For regular replaceable: kind:pubkey format (1 colon) | ||
| 855 | format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) | ||
| 856 | }; | ||
| 857 | |||
| 858 | // Check addressable reference tags: a, A, q (with address format) | ||
| 859 | let addressable_tags = [ | ||
| 860 | SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference | ||
| 861 | SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference | ||
| 862 | SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID) | ||
| 863 | ]; | ||
| 864 | |||
| 865 | for tag_type in &addressable_tags { | ||
| 866 | let filter = Filter::new().custom_tag(*tag_type, address.clone()); | ||
| 867 | |||
| 868 | match database.query(filter).await { | ||
| 869 | Ok(events) => { | ||
| 870 | if !events.is_empty() { | ||
| 871 | return Ok(true); | ||
| 872 | } | ||
| 873 | } | ||
| 874 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 875 | } | ||
| 876 | } | 241 | } |
| 877 | } else { | 242 | Err(e) => { |
| 878 | // For regular events, check event ID reference tags: e, E, q (with hex ID) | 243 | tracing::warn!( |
| 879 | let event_id_hex = event.id.to_hex(); | 244 | "Database query failed for {} {}, rejecting (fail-secure): {}", |
| 880 | 245 | event_type, | |
| 881 | let event_id_tags = [ | 246 | event_id_str, |
| 882 | SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference | 247 | e |
| 883 | SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference | 248 | ); |
| 884 | SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference | 249 | PolicyResult::Reject(format!("Database query failed: {}", e)) |
| 885 | ]; | ||
| 886 | |||
| 887 | for tag_type in &event_id_tags { | ||
| 888 | let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone()); | ||
| 889 | |||
| 890 | match database.query(filter).await { | ||
| 891 | Ok(events) => { | ||
| 892 | if !events.is_empty() { | ||
| 893 | return Ok(true); | ||
| 894 | } | ||
| 895 | } | ||
| 896 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 897 | } | ||
| 898 | } | 250 | } |
| 899 | } | 251 | } |
| 900 | |||
| 901 | Ok(false) | ||
| 902 | } | 252 | } |
| 903 | } | 253 | } |
| 904 | 254 | ||
| @@ -908,366 +258,12 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 908 | event: &'a nostr_relay_builder::prelude::Event, | 258 | event: &'a nostr_relay_builder::prelude::Event, |
| 909 | _addr: &'a SocketAddr, | 259 | _addr: &'a SocketAddr, |
| 910 | ) -> BoxedFuture<'a, PolicyResult> { | 260 | ) -> BoxedFuture<'a, PolicyResult> { |
| 911 | let database = self.database.clone(); | ||
| 912 | let domain = self.domain.clone(); | ||
| 913 | |||
| 914 | Box::pin(async move { | 261 | Box::pin(async move { |
| 915 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); | ||
| 916 | |||
| 917 | match event.kind.as_u16() { | 262 | match event.kind.as_u16() { |
| 918 | KIND_REPOSITORY_ANNOUNCEMENT => { | 263 | KIND_REPOSITORY_ANNOUNCEMENT => self.handle_announcement(event).await, |
| 919 | // First, try normal validation (announcement lists service) | 264 | KIND_REPOSITORY_STATE => self.handle_state(event).await, |
| 920 | match validate_announcement(event, &domain) { | 265 | KIND_PR | KIND_PR_UPDATE => self.handle_pr_event(event).await, |
| 921 | Ok(_) => { | 266 | _ => self.handle_related_event(event, "Event").await, |
| 922 | // Parse announcement to get repository details | ||
| 923 | match RepositoryAnnouncement::from_event(event.clone()) { | ||
| 924 | Ok(announcement) => { | ||
| 925 | // Try to create bare repository if it doesn't exist | ||
| 926 | if let Err(e) = self.ensure_bare_repository(&announcement) { | ||
| 927 | tracing::warn!( | ||
| 928 | "Failed to create bare repository for {}: {}", | ||
| 929 | event_id_str, | ||
| 930 | e | ||
| 931 | ); | ||
| 932 | // Note: We still accept the event even if repo creation fails | ||
| 933 | // The git operation failure shouldn't prevent event acceptance | ||
| 934 | } | ||
| 935 | |||
| 936 | tracing::debug!( | ||
| 937 | "Accepted repository announcement: {}", | ||
| 938 | event_id_str | ||
| 939 | ); | ||
| 940 | PolicyResult::Accept | ||
| 941 | } | ||
| 942 | Err(e) => { | ||
| 943 | tracing::warn!( | ||
| 944 | "Failed to parse repository announcement {}: {}", | ||
| 945 | event_id_str, | ||
| 946 | e | ||
| 947 | ); | ||
| 948 | PolicyResult::Reject(format!( | ||
| 949 | "Failed to parse announcement: {}", | ||
| 950 | e | ||
| 951 | )) | ||
| 952 | } | ||
| 953 | } | ||
| 954 | } | ||
| 955 | Err(validation_err) => { | ||
| 956 | // Validation failed - check if this is a recursive maintainer announcement | ||
| 957 | // GRASP-01 Exception: Accept announcements from recursive maintainers | ||
| 958 | // even without listing the service, for chain discovery and GRASP-02 sync | ||
| 959 | |||
| 960 | // Try to parse the announcement to get identifier | ||
| 961 | match RepositoryAnnouncement::from_event(event.clone()) { | ||
| 962 | Ok(announcement) => { | ||
| 963 | // Check if author is listed as maintainer in any existing announcement | ||
| 964 | match Self::is_maintainer_in_any_announcement( | ||
| 965 | &database, | ||
| 966 | &announcement.identifier, | ||
| 967 | &event.pubkey, | ||
| 968 | ) | ||
| 969 | .await | ||
| 970 | { | ||
| 971 | Ok(true) => { | ||
| 972 | tracing::info!( | ||
| 973 | "Accepted maintainer announcement {} (author {} is listed as maintainer for {})", | ||
| 974 | event_id_str, | ||
| 975 | event.pubkey.to_hex(), | ||
| 976 | announcement.identifier | ||
| 977 | ); | ||
| 978 | // Don't create bare repository for external announcements | ||
| 979 | // (they point to other servers) | ||
| 980 | PolicyResult::Accept | ||
| 981 | } | ||
| 982 | Ok(false) => { | ||
| 983 | tracing::warn!( | ||
| 984 | "Rejected repository announcement {}: {} (not a maintainer)", | ||
| 985 | event_id_str, | ||
| 986 | validation_err | ||
| 987 | ); | ||
| 988 | PolicyResult::Reject(validation_err.to_string()) | ||
| 989 | } | ||
| 990 | Err(e) => { | ||
| 991 | tracing::warn!( | ||
| 992 | "Failed to check maintainer status for {}: {}", | ||
| 993 | event_id_str, | ||
| 994 | e | ||
| 995 | ); | ||
| 996 | // Fail-secure: reject on database errors | ||
| 997 | PolicyResult::Reject(validation_err.to_string()) | ||
| 998 | } | ||
| 999 | } | ||
| 1000 | } | ||
| 1001 | Err(parse_err) => { | ||
| 1002 | tracing::warn!( | ||
| 1003 | "Rejected repository announcement {}: {} (parse error: {})", | ||
| 1004 | event_id_str, | ||
| 1005 | validation_err, | ||
| 1006 | parse_err | ||
| 1007 | ); | ||
| 1008 | PolicyResult::Reject(validation_err.to_string()) | ||
| 1009 | } | ||
| 1010 | } | ||
| 1011 | } | ||
| 1012 | } | ||
| 1013 | } | ||
| 1014 | KIND_REPOSITORY_STATE => match validate_state(event) { | ||
| 1015 | Ok(_) => { | ||
| 1016 | // Parse state to get HEAD and branch info | ||
| 1017 | match RepositoryState::from_event(event.clone()) { | ||
| 1018 | Ok(state) => { | ||
| 1019 | // Identify owner repositories for which this is the latest authorized state | ||
| 1020 | match self.identify_owner_repositories(&database, &state).await { | ||
| 1021 | Ok(owner_repos) => { | ||
| 1022 | let repo_count = owner_repos.len(); | ||
| 1023 | let mut total_aligned = 0; | ||
| 1024 | |||
| 1025 | // Align each owner repository with the authorized state | ||
| 1026 | for (_announcement, repo_path) in owner_repos { | ||
| 1027 | let result = self.align_owner_repository_with_state( | ||
| 1028 | &repo_path, &state, | ||
| 1029 | ); | ||
| 1030 | |||
| 1031 | if result.refs_created > 0 | ||
| 1032 | || result.refs_updated > 0 | ||
| 1033 | || result.refs_deleted > 0 | ||
| 1034 | || result.head_set | ||
| 1035 | { | ||
| 1036 | tracing::info!( | ||
| 1037 | "Aligned {} with state {}: created={}, updated={}, deleted={}, head_set={}", | ||
| 1038 | repo_path.display(), | ||
| 1039 | event_id_str, | ||
| 1040 | result.refs_created, | ||
| 1041 | result.refs_updated, | ||
| 1042 | result.refs_deleted, | ||
| 1043 | result.head_set | ||
| 1044 | ); | ||
| 1045 | total_aligned += 1; | ||
| 1046 | } | ||
| 1047 | } | ||
| 1048 | |||
| 1049 | if repo_count > 0 { | ||
| 1050 | tracing::info!( | ||
| 1051 | "Processed state event {} for {} repo(s) ({} aligned) with identifier {}", | ||
| 1052 | event_id_str, | ||
| 1053 | repo_count, | ||
| 1054 | total_aligned, | ||
| 1055 | state.identifier | ||
| 1056 | ); | ||
| 1057 | } else { | ||
| 1058 | tracing::debug!( | ||
| 1059 | "No owner repos to align for state {} - git data not available yet or not latest", | ||
| 1060 | event_id_str | ||
| 1061 | ); | ||
| 1062 | } | ||
| 1063 | } | ||
| 1064 | Err(e) => { | ||
| 1065 | tracing::warn!( | ||
| 1066 | "Failed to identify owner repositories for state {}: {}", | ||
| 1067 | event_id_str, | ||
| 1068 | e | ||
| 1069 | ); | ||
| 1070 | } | ||
| 1071 | } | ||
| 1072 | |||
| 1073 | tracing::debug!("Accepted repository state: {}", event_id_str); | ||
| 1074 | PolicyResult::Accept | ||
| 1075 | } | ||
| 1076 | Err(e) => { | ||
| 1077 | tracing::warn!( | ||
| 1078 | "Failed to parse repository state {}: {}", | ||
| 1079 | event_id_str, | ||
| 1080 | e | ||
| 1081 | ); | ||
| 1082 | // Still accept the event even if we can't parse it | ||
| 1083 | // The validation passed, so it's structurally valid | ||
| 1084 | PolicyResult::Accept | ||
| 1085 | } | ||
| 1086 | } | ||
| 1087 | } | ||
| 1088 | Err(e) => { | ||
| 1089 | tracing::warn!("Rejected repository state {}: {}", event_id_str, e); | ||
| 1090 | PolicyResult::Reject(e.to_string()) | ||
| 1091 | } | ||
| 1092 | }, | ||
| 1093 | // KIND_PR (1618) and KIND_PR_UPDATE (1619): Validate refs/nostr/<event-id> refs before acceptance | ||
| 1094 | KIND_PR | KIND_PR_UPDATE => { | ||
| 1095 | // Validate refs/nostr refs for this PR event | ||
| 1096 | // This deletes any refs/nostr/<event-id> that points to wrong commit | ||
| 1097 | if let Err(e) = self.validate_pr_nostr_ref(&database, event).await { | ||
| 1098 | tracing::warn!( | ||
| 1099 | "Failed to validate refs/nostr for PR event {}: {}", | ||
| 1100 | event_id_str, | ||
| 1101 | e | ||
| 1102 | ); | ||
| 1103 | // Don't reject - just log the error and proceed with normal validation | ||
| 1104 | } | ||
| 1105 | |||
| 1106 | // Continue with standard reference checking (same as default case) | ||
| 1107 | let (addressable_refs, event_refs) = Self::extract_reference_tags(event); | ||
| 1108 | |||
| 1109 | // Check 1: Does this event reference an accepted repository? | ||
| 1110 | match Self::find_accepted_repository(&database, &addressable_refs).await { | ||
| 1111 | Ok(Some(addr_ref)) => { | ||
| 1112 | tracing::debug!( | ||
| 1113 | "Accepted PR event {}: references accepted repository {}", | ||
| 1114 | event_id_str, | ||
| 1115 | addr_ref | ||
| 1116 | ); | ||
| 1117 | return PolicyResult::Accept; | ||
| 1118 | } | ||
| 1119 | Ok(None) => { | ||
| 1120 | // No matching repositories, continue to next check | ||
| 1121 | } | ||
| 1122 | Err(e) => { | ||
| 1123 | tracing::warn!( | ||
| 1124 | "Database query failed for PR {}, rejecting (fail-secure): {}", | ||
| 1125 | event_id_str, | ||
| 1126 | e | ||
| 1127 | ); | ||
| 1128 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 1129 | } | ||
| 1130 | } | ||
| 1131 | |||
| 1132 | // Check 2: Does this event reference an accepted event? | ||
| 1133 | match Self::find_accepted_event(&database, &event_refs).await { | ||
| 1134 | Ok(Some(event_ref)) => { | ||
| 1135 | tracing::debug!( | ||
| 1136 | "Accepted PR event {}: references accepted event {}", | ||
| 1137 | event_id_str, | ||
| 1138 | event_ref | ||
| 1139 | ); | ||
| 1140 | return PolicyResult::Accept; | ||
| 1141 | } | ||
| 1142 | Ok(None) => { | ||
| 1143 | // No matching events, continue to next check | ||
| 1144 | } | ||
| 1145 | Err(e) => { | ||
| 1146 | tracing::warn!( | ||
| 1147 | "Database query failed for PR {}, rejecting (fail-secure): {}", | ||
| 1148 | event_id_str, | ||
| 1149 | e | ||
| 1150 | ); | ||
| 1151 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 1152 | } | ||
| 1153 | } | ||
| 1154 | |||
| 1155 | // Check 3: Is this event referenced by an accepted event? | ||
| 1156 | match Self::is_referenced_by_accepted(&database, event).await { | ||
| 1157 | Ok(true) => { | ||
| 1158 | tracing::debug!( | ||
| 1159 | "Accepted PR event {}: referenced by accepted event", | ||
| 1160 | event_id_str | ||
| 1161 | ); | ||
| 1162 | return PolicyResult::Accept; | ||
| 1163 | } | ||
| 1164 | Ok(false) => { | ||
| 1165 | // No forward references found, continue to rejection | ||
| 1166 | } | ||
| 1167 | Err(e) => { | ||
| 1168 | tracing::warn!( | ||
| 1169 | "Database query failed for PR {}, rejecting (fail-secure): {}", | ||
| 1170 | event_id_str, | ||
| 1171 | e | ||
| 1172 | ); | ||
| 1173 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 1174 | } | ||
| 1175 | } | ||
| 1176 | |||
| 1177 | // No valid references found - reject as orphan event | ||
| 1178 | tracing::info!( | ||
| 1179 | "Rejected orphan PR event {}: no references to accepted repos or events", | ||
| 1180 | event_id_str | ||
| 1181 | ); | ||
| 1182 | PolicyResult::Reject( | ||
| 1183 | "PR event must reference an accepted repository or accepted event" | ||
| 1184 | .to_string(), | ||
| 1185 | ) | ||
| 1186 | } | ||
| 1187 | // GRASP-01: Check if event references accepted repositories or events | ||
| 1188 | _ => { | ||
| 1189 | // Extract all reference tags from event | ||
| 1190 | let (addressable_refs, event_refs) = Self::extract_reference_tags(event); | ||
| 1191 | |||
| 1192 | // Check 1: Does this event reference an accepted repository? (batched) | ||
| 1193 | match Self::find_accepted_repository(&database, &addressable_refs).await { | ||
| 1194 | Ok(Some(addr_ref)) => { | ||
| 1195 | tracing::debug!( | ||
| 1196 | "Accepted event {}: references accepted repository {}", | ||
| 1197 | event_id_str, | ||
| 1198 | addr_ref | ||
| 1199 | ); | ||
| 1200 | return PolicyResult::Accept; | ||
| 1201 | } | ||
| 1202 | Ok(None) => { | ||
| 1203 | // No matching repositories, continue to next check | ||
| 1204 | } | ||
| 1205 | Err(e) => { | ||
| 1206 | tracing::warn!( | ||
| 1207 | "Database query failed for event {}, rejecting (fail-secure): {}", | ||
| 1208 | event_id_str, | ||
| 1209 | e | ||
| 1210 | ); | ||
| 1211 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 1212 | } | ||
| 1213 | } | ||
| 1214 | |||
| 1215 | // Check 2: Does this event reference an accepted event? (batched, transitive) | ||
| 1216 | match Self::find_accepted_event(&database, &event_refs).await { | ||
| 1217 | Ok(Some(event_ref)) => { | ||
| 1218 | tracing::debug!( | ||
| 1219 | "Accepted event {}: references accepted event {}", | ||
| 1220 | event_id_str, | ||
| 1221 | event_ref | ||
| 1222 | ); | ||
| 1223 | return PolicyResult::Accept; | ||
| 1224 | } | ||
| 1225 | Ok(None) => { | ||
| 1226 | // No matching events, continue to next check | ||
| 1227 | } | ||
| 1228 | Err(e) => { | ||
| 1229 | tracing::warn!( | ||
| 1230 | "Database query failed for event {}, rejecting (fail-secure): {}", | ||
| 1231 | event_id_str, | ||
| 1232 | e | ||
| 1233 | ); | ||
| 1234 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 1235 | } | ||
| 1236 | } | ||
| 1237 | |||
| 1238 | // Check 3: Is this event referenced by an accepted event? (forward reference) | ||
| 1239 | match Self::is_referenced_by_accepted(&database, event).await { | ||
| 1240 | Ok(true) => { | ||
| 1241 | tracing::debug!( | ||
| 1242 | "Accepted event {}: referenced by accepted event", | ||
| 1243 | event_id_str | ||
| 1244 | ); | ||
| 1245 | return PolicyResult::Accept; | ||
| 1246 | } | ||
| 1247 | Ok(false) => { | ||
| 1248 | // No forward references found, continue to rejection | ||
| 1249 | } | ||
| 1250 | Err(e) => { | ||
| 1251 | tracing::warn!( | ||
| 1252 | "Database query failed for event {}, rejecting (fail-secure): {}", | ||
| 1253 | event_id_str, | ||
| 1254 | e | ||
| 1255 | ); | ||
| 1256 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 1257 | } | ||
| 1258 | } | ||
| 1259 | |||
| 1260 | // No valid references found - reject as orphan event | ||
| 1261 | tracing::info!( | ||
| 1262 | "Rejected orphan event {}: no references to accepted repos or events (checked {} addressable, {} event refs)", | ||
| 1263 | event_id_str, | ||
| 1264 | addressable_refs.len(), | ||
| 1265 | event_refs.len() | ||
| 1266 | ); | ||
| 1267 | PolicyResult::Reject( | ||
| 1268 | "Event must reference an accepted repository or accepted event".to_string(), | ||
| 1269 | ) | ||
| 1270 | } | ||
| 1271 | } | 267 | } |
| 1272 | }) | 268 | }) |
| 1273 | } | 269 | } |
| @@ -1351,4 +347,4 @@ pub fn create_relay(config: &Config) -> Result<RelayWithDatabase> { | |||
| 1351 | relay: LocalRelay::new(builder), | 347 | relay: LocalRelay::new(builder), |
| 1352 | database, | 348 | database, |
| 1353 | }) | 349 | }) |
| 1354 | } | 350 | } \ 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 @@ | |||
| 1 | pub mod builder; | 1 | pub mod builder; |
| 2 | pub mod events; | 2 | pub mod events; |
| 3 | pub mod policy; | ||
| 4 | |||
| 5 | /// Re-export SharedDatabase for use by policy modules | ||
| 6 | 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 @@ | |||
| 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 | ||