diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-24 08:02:12 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-24 11:54:18 +0000 |
| commit | 70d0197e85ae4ef85202781f6d2dc9e76bd508b3 (patch) | |
| tree | 45efb6565e81ba755acc5955e68d5b7119d1e122 /src/nostr/policy | |
| parent | f8c3e3920ed2a1bdaab30be912276993449a5476 (diff) | |
feat(purgatory): add broken purgatory implementation
Diffstat (limited to 'src/nostr/policy')
| -rw-r--r-- | src/nostr/policy/mod.rs | 5 | ||||
| -rw-r--r-- | src/nostr/policy/pr_event.rs | 149 | ||||
| -rw-r--r-- | src/nostr/policy/state.rs | 55 |
3 files changed, 208 insertions, 1 deletions
diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs index 19db5f6..2a446fe 100644 --- a/src/nostr/policy/mod.rs +++ b/src/nostr/policy/mod.rs | |||
| @@ -16,6 +16,8 @@ pub use related::{ReferenceResult, RelatedEventPolicy}; | |||
| 16 | pub use state::{AlignmentResult, StatePolicy, StateResult}; | 16 | pub use state::{AlignmentResult, StatePolicy, StateResult}; |
| 17 | 17 | ||
| 18 | use super::SharedDatabase; | 18 | use super::SharedDatabase; |
| 19 | use crate::purgatory::Purgatory; | ||
| 20 | use std::sync::Arc; | ||
| 19 | 21 | ||
| 20 | /// Shared context for all sub-policies | 22 | /// Shared context for all sub-policies |
| 21 | #[derive(Clone)] | 23 | #[derive(Clone)] |
| @@ -23,6 +25,7 @@ pub struct PolicyContext { | |||
| 23 | pub domain: String, | 25 | pub domain: String, |
| 24 | pub database: SharedDatabase, | 26 | pub database: SharedDatabase, |
| 25 | pub git_data_path: std::path::PathBuf, | 27 | pub git_data_path: std::path::PathBuf, |
| 28 | pub purgatory: Arc<Purgatory>, | ||
| 26 | } | 29 | } |
| 27 | 30 | ||
| 28 | impl PolicyContext { | 31 | impl PolicyContext { |
| @@ -30,11 +33,13 @@ impl PolicyContext { | |||
| 30 | domain: impl Into<String>, | 33 | domain: impl Into<String>, |
| 31 | database: SharedDatabase, | 34 | database: SharedDatabase, |
| 32 | git_data_path: impl Into<std::path::PathBuf>, | 35 | git_data_path: impl Into<std::path::PathBuf>, |
| 36 | purgatory: Arc<Purgatory>, | ||
| 33 | ) -> Self { | 37 | ) -> Self { |
| 34 | Self { | 38 | Self { |
| 35 | domain: domain.into(), | 39 | domain: domain.into(), |
| 36 | database, | 40 | database, |
| 37 | git_data_path: git_data_path.into(), | 41 | git_data_path: git_data_path.into(), |
| 42 | purgatory, | ||
| 38 | } | 43 | } |
| 39 | } | 44 | } |
| 40 | } | 45 | } |
diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs index 53da369..c7602b0 100644 --- a/src/nostr/policy/pr_event.rs +++ b/src/nostr/policy/pr_event.rs | |||
| @@ -19,6 +19,155 @@ impl PrEventPolicy { | |||
| 19 | Self { ctx } | 19 | Self { ctx } |
| 20 | } | 20 | } |
| 21 | 21 | ||
| 22 | /// Check if git data exists for a PR event | ||
| 23 | /// | ||
| 24 | /// This checks: | ||
| 25 | /// 1. If a placeholder exists (git-data-first scenario) | ||
| 26 | /// 2. If the commit exists in any relevant repository | ||
| 27 | /// | ||
| 28 | /// # Returns | ||
| 29 | /// - `Ok(true)` if git data ready (either placeholder found or commit exists) | ||
| 30 | /// - `Ok(false)` if git data missing (should add to purgatory) | ||
| 31 | /// - `Err(msg)` on errors | ||
| 32 | pub async fn check_git_data_exists(&self, event: &Event) -> Result<bool, String> { | ||
| 33 | let event_id = event.id.to_hex(); | ||
| 34 | |||
| 35 | // Extract the `c` tag (commit hash) from the PR event | ||
| 36 | let commit = event.tags.iter().find_map(|tag| { | ||
| 37 | let tag_vec = tag.clone().to_vec(); | ||
| 38 | if tag_vec.len() >= 2 && tag_vec[0] == "c" { | ||
| 39 | Some(tag_vec[1].clone()) | ||
| 40 | } else { | ||
| 41 | None | ||
| 42 | } | ||
| 43 | }); | ||
| 44 | |||
| 45 | let commit = match commit { | ||
| 46 | Some(c) => c, | ||
| 47 | None => { | ||
| 48 | return Err(format!("PR event {} has no 'c' tag", event_id)); | ||
| 49 | } | ||
| 50 | }; | ||
| 51 | |||
| 52 | // Check for placeholder first (git-data-first scenario) | ||
| 53 | if let Some(placeholder_commit) = self.ctx.purgatory.find_pr_placeholder(&event_id) { | ||
| 54 | if placeholder_commit == commit { | ||
| 55 | // Perfect match - git data arrived first with matching commit | ||
| 56 | tracing::debug!( | ||
| 57 | "Found matching placeholder for PR event {} with commit {}", | ||
| 58 | event_id, | ||
| 59 | commit | ||
| 60 | ); | ||
| 61 | // Remove placeholder - event processing will continue normally | ||
| 62 | self.ctx.purgatory.remove_pr(&event_id); | ||
| 63 | return Ok(true); | ||
| 64 | } else { | ||
| 65 | // Placeholder has different commit - incoming event supersedes | ||
| 66 | tracing::info!( | ||
| 67 | "PR event {} supersedes placeholder: event expects commit {}, placeholder has {}", | ||
| 68 | event_id, | ||
| 69 | commit, | ||
| 70 | placeholder_commit | ||
| 71 | ); | ||
| 72 | // Remove placeholder with old commit data | ||
| 73 | self.ctx.purgatory.remove_pr(&event_id); | ||
| 74 | // TODO: Also remove git data (refs/nostr/<event-id>) - Phase 5 | ||
| 75 | // Fall through to check if new commit exists | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | // Check if commit exists in any repository referenced by this PR | ||
| 80 | // Extract ALL `a` tags (repository references) from the PR event | ||
| 81 | let repo_refs: Vec<String> = event | ||
| 82 | .tags | ||
| 83 | .iter() | ||
| 84 | .filter_map(|tag| { | ||
| 85 | let tag_vec = tag.clone().to_vec(); | ||
| 86 | if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") { | ||
| 87 | Some(tag_vec[1].clone()) | ||
| 88 | } else { | ||
| 89 | None | ||
| 90 | } | ||
| 91 | }) | ||
| 92 | .collect(); | ||
| 93 | |||
| 94 | if repo_refs.is_empty() { | ||
| 95 | // No repo references - cannot check git data | ||
| 96 | // This is unusual but let it through (other validation will catch issues) | ||
| 97 | return Ok(true); | ||
| 98 | } | ||
| 99 | |||
| 100 | // Check each repository to see if commit exists | ||
| 101 | for repo_ref in repo_refs { | ||
| 102 | // Parse the repo reference: 30617:<pubkey>:<identifier> | ||
| 103 | let parts: Vec<&str> = repo_ref.split(':').collect(); | ||
| 104 | if parts.len() < 3 { | ||
| 105 | continue; | ||
| 106 | } | ||
| 107 | |||
| 108 | let repo_pubkey = match PublicKey::from_hex(parts[1]) { | ||
| 109 | Ok(pk) => pk, | ||
| 110 | Err(_) => continue, | ||
| 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 | continue; | ||
| 137 | } | ||
| 138 | |||
| 139 | // Check each matching announcement | ||
| 140 | for announcement_event in announcements { | ||
| 141 | let announcement = match RepositoryAnnouncement::from_event(announcement_event) { | ||
| 142 | Ok(a) => a, | ||
| 143 | Err(_) => continue, | ||
| 144 | }; | ||
| 145 | |||
| 146 | // Build repository path | ||
| 147 | let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); | ||
| 148 | |||
| 149 | // Check if commit exists | ||
| 150 | if git::commit_exists(&repo_path, &commit) { | ||
| 151 | tracing::debug!( | ||
| 152 | "Found commit {} for PR event {} in repository {}", | ||
| 153 | commit, | ||
| 154 | event_id, | ||
| 155 | repo_path.display() | ||
| 156 | ); | ||
| 157 | return Ok(true); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | // No git data found - should add to purgatory | ||
| 163 | tracing::debug!( | ||
| 164 | "No git data found for PR event {} with commit {}", | ||
| 165 | event_id, | ||
| 166 | commit | ||
| 167 | ); | ||
| 168 | Ok(false) | ||
| 169 | } | ||
| 170 | |||
| 22 | /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag | 171 | /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag |
| 23 | /// | 172 | /// |
| 24 | /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, | 173 | /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, |
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index 43349e2..5e749ed 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs | |||
| @@ -66,6 +66,24 @@ impl StatePolicy { | |||
| 66 | let state = RepositoryState::from_event(event.clone()) | 66 | let state = RepositoryState::from_event(event.clone()) |
| 67 | .map_err(|e| format!("Failed to parse state: {}", e))?; | 67 | .map_err(|e| format!("Failed to parse state: {}", e))?; |
| 68 | 68 | ||
| 69 | // Check if ANY git repositories exist for this identifier (regardless of authorization) | ||
| 70 | // This helps us distinguish "no git data yet" from "not authorized" or "not latest" | ||
| 71 | let has_any_git_data = self.has_git_data_for_identifier(&state.identifier); | ||
| 72 | |||
| 73 | if !has_any_git_data { | ||
| 74 | // No git data exists yet - add to purgatory | ||
| 75 | tracing::debug!( | ||
| 76 | "No git data found for identifier {}, adding state event {} to purgatory", | ||
| 77 | state.identifier, | ||
| 78 | event.id.to_hex() | ||
| 79 | ); | ||
| 80 | self.ctx | ||
| 81 | .purgatory | ||
| 82 | .add_state(event.clone(), state.identifier.clone(), event.pubkey); | ||
| 83 | // Return 0 repos aligned, but this is not an error | ||
| 84 | return Ok(0); | ||
| 85 | } | ||
| 86 | |||
| 69 | // Identify owner repositories for which this is the latest authorized state | 87 | // Identify owner repositories for which this is the latest authorized state |
| 70 | let owner_repos = self.identify_owner_repositories(&state).await?; | 88 | let owner_repos = self.identify_owner_repositories(&state).await?; |
| 71 | let repo_count = owner_repos.len(); | 89 | let repo_count = owner_repos.len(); |
| @@ -97,13 +115,48 @@ impl StatePolicy { | |||
| 97 | ); | 115 | ); |
| 98 | } else { | 116 | } else { |
| 99 | tracing::debug!( | 117 | tracing::debug!( |
| 100 | "No owner repos to align for state - git data not available yet or not latest" | 118 | "No owner repos to align for state - git data exists but author not authorized or not latest" |
| 101 | ); | 119 | ); |
| 102 | } | 120 | } |
| 103 | 121 | ||
| 104 | Ok(total_aligned) | 122 | Ok(total_aligned) |
| 105 | } | 123 | } |
| 106 | 124 | ||
| 125 | /// Check if any git repositories exist for the given identifier | ||
| 126 | /// | ||
| 127 | /// Scans the git_data_path for any directories matching the pattern: | ||
| 128 | /// `<any-npub>/<identifier>.git` | ||
| 129 | /// | ||
| 130 | /// This is used to distinguish "no git data yet" from "not authorized". | ||
| 131 | fn has_git_data_for_identifier(&self, identifier: &str) -> bool { | ||
| 132 | let git_data_path = &self.ctx.git_data_path; | ||
| 133 | |||
| 134 | // Check if git_data_path exists | ||
| 135 | if !git_data_path.exists() { | ||
| 136 | return false; | ||
| 137 | } | ||
| 138 | |||
| 139 | // Scan for any npub directories | ||
| 140 | let read_dir = match std::fs::read_dir(git_data_path) { | ||
| 141 | Ok(dir) => dir, | ||
| 142 | Err(_) => return false, | ||
| 143 | }; | ||
| 144 | |||
| 145 | for entry in read_dir.flatten() { | ||
| 146 | if let Ok(file_type) = entry.file_type() { | ||
| 147 | if file_type.is_dir() { | ||
| 148 | // Check if <npub>/<identifier>.git exists | ||
| 149 | let repo_path = entry.path().join(format!("{}.git", identifier)); | ||
| 150 | if repo_path.exists() { | ||
| 151 | return true; | ||
| 152 | } | ||
| 153 | } | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | false | ||
| 158 | } | ||
| 159 | |||
| 107 | /// Check if this state event is the latest for its identifier among authorized authors | 160 | /// Check if this state event is the latest for its identifier among authorized authors |
| 108 | /// | 161 | /// |
| 109 | /// A state is considered "latest" if no other state event in the database | 162 | /// A state is considered "latest" if no other state event in the database |