diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:42:00 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:42:00 +0000 |
| commit | 819866330c7e2f535a155d1d7efaf2e12dc15dc2 (patch) | |
| tree | d84c8361811544aad9cad089c0358b9028c8fb80 /src/nostr/policy/state.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/policy/state.rs')
| -rw-r--r-- | src/nostr/policy/state.rs | 419 |
1 files changed, 419 insertions, 0 deletions
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 | ||