diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:42:00 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:42:00 +0000 |
| commit | 819866330c7e2f535a155d1d7efaf2e12dc15dc2 (patch) | |
| tree | d84c8361811544aad9cad089c0358b9028c8fb80 /src/nostr/builder.rs | |
| parent | fd0c87c787d0626b3546fa571541c9c809711821 (diff) | |
refactor: split Nip34WritePolicy into focused sub-policies
Split the ~900 line Nip34WritePolicy into focused sub-policies for improved
testability and maintainability:
- AnnouncementPolicy - Repository announcement validation
- StatePolicy - State event validation + ref alignment
- PrEventPolicy - PR/PR Update validation
- RelatedEventPolicy - Forward/backward reference checking
The main Nip34WritePolicy now delegates to these sub-policies via a shared
PolicyContext that provides domain, database, and git_data_path.
Also updates:
- README.md: Accurate project structure reflecting actual implementation
- docs/learnings: Marks this technical debt item as complete
Diffstat (limited to 'src/nostr/builder.rs')
| -rw-r--r-- | src/nostr/builder.rs | 1334 |
1 files changed, 165 insertions, 1169 deletions
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 |