diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-31 09:18:21 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-31 10:49:09 +0000 |
| commit | 768fe91caa676e4501aa26e14e01ca47f3ea4ca1 (patch) | |
| tree | e697becb6b2253909d399073f5c2bd2d571fcf5e | |
| parent | 3d6901831904141166d9ed8f47813c45cba109b6 (diff) | |
purgatory: fix pr event recieve code
| -rw-r--r-- | src/git/mod.rs | 4 | ||||
| -rw-r--r-- | src/nostr/builder.rs | 71 | ||||
| -rw-r--r-- | src/nostr/policy/pr_event.rs | 396 | ||||
| -rw-r--r-- | src/nostr/policy/state.rs | 187 |
4 files changed, 223 insertions, 435 deletions
diff --git a/src/git/mod.rs b/src/git/mod.rs index 1847c8c..d34f98b 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs | |||
| @@ -94,10 +94,6 @@ pub fn oid_exists(repo_path: &Path, oid: &str) -> bool { | |||
| 94 | } | 94 | } |
| 95 | } | 95 | } |
| 96 | 96 | ||
| 97 | pub fn is_valid_oid(oid: &str) -> bool { | ||
| 98 | oid.len() >= 5 && oid.len() <= 40 && oid.chars().all(|c| c.is_digit(16)) | ||
| 99 | } | ||
| 100 | |||
| 101 | /// Set the repository HEAD to point to a branch | 97 | /// Set the repository HEAD to point to a branch |
| 102 | /// | 98 | /// |
| 103 | /// This updates the HEAD symbolic ref to point to the specified branch. | 99 | /// This updates the HEAD symbolic ref to point to the specified branch. |
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 37fa025..3d7a0d8 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs | |||
| @@ -168,8 +168,54 @@ impl Nip34WritePolicy { | |||
| 168 | async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult { | 168 | async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult { |
| 169 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); | 169 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); |
| 170 | 170 | ||
| 171 | // Check if git data exists (checks placeholders and commit existence) | 171 | // duplicate check in purgatory |
| 172 | match self.pr_event_policy.check_git_data_exists(event).await { | 172 | let in_purgatory = self |
| 173 | .ctx | ||
| 174 | .purgatory | ||
| 175 | .find_pr(&event.id.to_hex()) | ||
| 176 | .is_some_and(|e| e.event.is_some()); | ||
| 177 | if in_purgatory { | ||
| 178 | tracing::debug!( | ||
| 179 | "processed PR event duplicate (already in purgatory): {}", | ||
| 180 | event.id, | ||
| 181 | ); | ||
| 182 | return WritePolicyResult::Reject { | ||
| 183 | status: true, // Client sees OK | ||
| 184 | message: "duplicate: in purgatory".into(), | ||
| 185 | }; | ||
| 186 | } | ||
| 187 | |||
| 188 | // duplicate check in db | ||
| 189 | match &self.ctx.database.check_id(&event.id).await { | ||
| 190 | Ok(DatabaseEventStatus::Saved) => { | ||
| 191 | return WritePolicyResult::Reject { | ||
| 192 | status: true, // Client sees OK | ||
| 193 | message: "duplicate".into(), | ||
| 194 | }; | ||
| 195 | } | ||
| 196 | Ok(DatabaseEventStatus::Deleted) => { | ||
| 197 | return WritePolicyResult::Reject { | ||
| 198 | status: false, | ||
| 199 | message: "invalid: accepted deletion request for this event".into(), | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | Err(e) => { | ||
| 203 | return WritePolicyResult::Reject { | ||
| 204 | status: false, | ||
| 205 | message: format!("error: internal error: {e}").into(), | ||
| 206 | }; | ||
| 207 | } | ||
| 208 | _ => {} // continue | ||
| 209 | } | ||
| 210 | |||
| 211 | // Reject PRs unrelated to stored repositories / events | ||
| 212 | match self.handle_related_event(event, "PR").await { | ||
| 213 | WritePolicyResult::Accept => {} // continue | ||
| 214 | rejected => return rejected, | ||
| 215 | } | ||
| 216 | |||
| 217 | // Check if git data exists (delete any incorrect commits at refs/nostr/<event-id>, copies correct data to relivant repositories) | ||
| 218 | match self.pr_event_policy.git_data_check(event).await { | ||
| 173 | Ok(false) => { | 219 | Ok(false) => { |
| 174 | // No git data exists - add to purgatory | 220 | // No git data exists - add to purgatory |
| 175 | let commit = event | 221 | let commit = event |
| @@ -196,18 +242,19 @@ impl Nip34WritePolicy { | |||
| 196 | .purgatory | 242 | .purgatory |
| 197 | .add_pr(event.clone(), event.id.to_hex(), commit.clone()); | 243 | .add_pr(event.clone(), event.id.to_hex(), commit.clone()); |
| 198 | 244 | ||
| 199 | return WritePolicyResult::Reject { | 245 | WritePolicyResult::Reject { |
| 200 | status: true, // Client sees OK | 246 | status: true, // Client sees OK |
| 201 | message: format!( | 247 | message: format!( |
| 202 | "purgatory: PR event stored, waiting for git push with commit {}", | 248 | "purgatory: PR event stored, waiting for git push with commit {}", |
| 203 | commit | 249 | commit |
| 204 | ) | 250 | ) |
| 205 | .into(), | 251 | .into(), |
| 206 | }; | 252 | } |
| 207 | } | 253 | } |
| 208 | Ok(true) => { | 254 | Ok(true) => { |
| 209 | // Git data exists - proceed with normal validation | 255 | // Git data exists - proceed with normal validation |
| 210 | tracing::debug!("Git data exists for PR event {}", event_id_str); | 256 | tracing::debug!("Git data exists for PR event {}", event_id_str); |
| 257 | WritePolicyResult::Accept | ||
| 211 | } | 258 | } |
| 212 | Err(e) => { | 259 | Err(e) => { |
| 213 | // Error checking git data - reject event | 260 | // Error checking git data - reject event |
| @@ -216,23 +263,9 @@ impl Nip34WritePolicy { | |||
| 216 | event_id_str, | 263 | event_id_str, |
| 217 | e | 264 | e |
| 218 | ); | 265 | ); |
| 219 | return WritePolicyResult::reject(format!("Failed to check git data: {}", e)); | 266 | WritePolicyResult::reject(format!("Failed to check git data: {}", e)) |
| 220 | } | 267 | } |
| 221 | } | 268 | } |
| 222 | |||
| 223 | // Validate refs/nostr refs for this PR event | ||
| 224 | // This deletes any refs/nostr/<event-id> that points to wrong commit | ||
| 225 | if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await { | ||
| 226 | tracing::warn!( | ||
| 227 | "Failed to validate refs/nostr for PR event {}: {}", | ||
| 228 | event_id_str, | ||
| 229 | e | ||
| 230 | ); | ||
| 231 | // Don't reject - just log the error and proceed with normal validation | ||
| 232 | } | ||
| 233 | |||
| 234 | // Continue with reference checking (same as related events) | ||
| 235 | self.handle_related_event(event, "PR").await | ||
| 236 | } | 269 | } |
| 237 | 270 | ||
| 238 | /// Handle events that must reference accepted repositories or events | 271 | /// Handle events that must reference accepted repositories or events |
diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs index c7602b0..ff3bade 100644 --- a/src/nostr/policy/pr_event.rs +++ b/src/nostr/policy/pr_event.rs | |||
| @@ -2,11 +2,12 @@ | |||
| 2 | /// | 2 | /// |
| 3 | /// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619) | 3 | /// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619) |
| 4 | /// according to GRASP-01 specification. | 4 | /// according to GRASP-01 specification. |
| 5 | use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; | 5 | use anyhow::{bail, Result}; |
| 6 | use nostr_relay_builder::prelude::Event; | ||
| 6 | 7 | ||
| 7 | use super::PolicyContext; | 8 | use super::PolicyContext; |
| 8 | use crate::git; | 9 | use crate::git; |
| 9 | use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT}; | 10 | use crate::git::authorization::{collect_authorized_maintainers, fetch_repository_data}; |
| 10 | 11 | ||
| 11 | /// Policy for validating PR and PR Update events | 12 | /// Policy for validating PR and PR Update events |
| 12 | #[derive(Clone)] | 13 | #[derive(Clone)] |
| @@ -21,15 +22,18 @@ impl PrEventPolicy { | |||
| 21 | 22 | ||
| 22 | /// Check if git data exists for a PR event | 23 | /// Check if git data exists for a PR event |
| 23 | /// | 24 | /// |
| 24 | /// This checks: | 25 | /// This unified method checks for git data existence and handles: |
| 25 | /// 1. If a placeholder exists (git-data-first scenario) | 26 | /// 1. Placeholder validation (git-data-first scenario) |
| 26 | /// 2. If the commit exists in any relevant repository | 27 | /// 2. Commit existence in referenced repositories |
| 28 | /// 3. Deletion of incorrect refs/nostr/<event-id> refs | ||
| 29 | /// 4. Deletion of incorrect placeholders | ||
| 30 | /// 5. Copying git data to all referenced repositories when found | ||
| 27 | /// | 31 | /// |
| 28 | /// # Returns | 32 | /// # Returns |
| 29 | /// - `Ok(true)` if git data ready (either placeholder found or commit exists) | 33 | /// - `Ok(true)` if git data ready (commit exists and is synced to all repos) |
| 30 | /// - `Ok(false)` if git data missing (should add to purgatory) | 34 | /// - `Ok(false)` if git data missing (should add to purgatory) |
| 31 | /// - `Err(msg)` on errors | 35 | /// - `Err(msg)` on errors |
| 32 | pub async fn check_git_data_exists(&self, event: &Event) -> Result<bool, String> { | 36 | pub async fn git_data_check(&self, event: &Event) -> Result<bool> { |
| 33 | let event_id = event.id.to_hex(); | 37 | let event_id = event.id.to_hex(); |
| 34 | 38 | ||
| 35 | // Extract the `c` tag (commit hash) from the PR event | 39 | // Extract the `c` tag (commit hash) from the PR event |
| @@ -45,7 +49,7 @@ impl PrEventPolicy { | |||
| 45 | let commit = match commit { | 49 | let commit = match commit { |
| 46 | Some(c) => c, | 50 | Some(c) => c, |
| 47 | None => { | 51 | None => { |
| 48 | return Err(format!("PR event {} has no 'c' tag", event_id)); | 52 | bail!(format!("PR event {} has no 'c' tag", event_id)); |
| 49 | } | 53 | } |
| 50 | }; | 54 | }; |
| 51 | 55 | ||
| @@ -60,7 +64,7 @@ impl PrEventPolicy { | |||
| 60 | ); | 64 | ); |
| 61 | // Remove placeholder - event processing will continue normally | 65 | // Remove placeholder - event processing will continue normally |
| 62 | self.ctx.purgatory.remove_pr(&event_id); | 66 | self.ctx.purgatory.remove_pr(&event_id); |
| 63 | return Ok(true); | 67 | // Continue to validate and sync refs across all repos |
| 64 | } else { | 68 | } else { |
| 65 | // Placeholder has different commit - incoming event supersedes | 69 | // Placeholder has different commit - incoming event supersedes |
| 66 | tracing::info!( | 70 | tracing::info!( |
| @@ -69,148 +73,124 @@ impl PrEventPolicy { | |||
| 69 | commit, | 73 | commit, |
| 70 | placeholder_commit | 74 | placeholder_commit |
| 71 | ); | 75 | ); |
| 72 | // Remove placeholder with old commit data | 76 | // Remove incorrect placeholder |
| 73 | self.ctx.purgatory.remove_pr(&event_id); | 77 | self.ctx.purgatory.remove_pr(&event_id); |
| 74 | // TODO: Also remove git data (refs/nostr/<event-id>) - Phase 5 | 78 | // Delete incorrect git data (refs/nostr/<event-id>) from all repos |
| 75 | // Fall through to check if new commit exists | 79 | // This will be handled below when we validate refs |
| 76 | } | 80 | } |
| 77 | } | 81 | } |
| 78 | 82 | ||
| 79 | // Check if commit exists in any repository referenced by this PR | 83 | let repo_paths = self.find_relevant_repo_paths(event).await?; |
| 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 | 84 | ||
| 94 | if repo_refs.is_empty() { | 85 | if repo_paths.is_empty() { |
| 95 | // No repo references - cannot check git data | 86 | tracing::debug!("No repository paths found for PR event {}", event_id); |
| 96 | // This is unusual but let it through (other validation will catch issues) | 87 | return Ok(false); |
| 97 | return Ok(true); | ||
| 98 | } | 88 | } |
| 99 | 89 | ||
| 100 | // Check each repository to see if commit exists | 90 | // delete incorrect refs/nostr/<event-id> |
| 101 | for repo_ref in repo_refs { | 91 | for repo_path in &repo_paths { |
| 102 | // Parse the repo reference: 30617:<pubkey>:<identifier> | 92 | // First, validate/delete any incorrect refs/nostr/<event-id> |
| 103 | let parts: Vec<&str> = repo_ref.split(':').collect(); | 93 | match git::validate_nostr_ref(repo_path, &event_id, &commit) { |
| 104 | if parts.len() < 3 { | 94 | Ok(true) => { |
| 105 | continue; | 95 | tracing::info!( |
| 106 | } | 96 | "Deleted mismatched refs/nostr/{} in {}", |
| 107 | 97 | event_id, | |
| 108 | let repo_pubkey = match PublicKey::from_hex(parts[1]) { | 98 | repo_path.display() |
| 109 | Ok(pk) => pk, | 99 | ); |
| 110 | Err(_) => continue, | 100 | } |
| 111 | }; | 101 | Ok(false) => {} |
| 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) => { | 102 | Err(e) => { |
| 126 | tracing::warn!( | 103 | tracing::warn!( |
| 127 | "Failed to query for repository announcement for PR {}: {}", | 104 | "Failed to validate refs/nostr/{} in {}: {}", |
| 128 | event_id, | 105 | event_id, |
| 106 | repo_path.display(), | ||
| 129 | e | 107 | e |
| 130 | ); | 108 | ); |
| 131 | continue; | ||
| 132 | } | 109 | } |
| 133 | }; | 110 | } |
| 111 | } | ||
| 134 | 112 | ||
| 135 | if announcements.is_empty() { | 113 | // find location of correct git data (if exists) |
| 136 | continue; | 114 | let mut source_repo: Option<std::path::PathBuf> = None; |
| 115 | for repo_path in &repo_paths { | ||
| 116 | // Check if commit exists in this repository | ||
| 117 | if git::commit_exists(repo_path, &commit) { | ||
| 118 | source_repo = Some(repo_path.clone()); | ||
| 119 | tracing::debug!( | ||
| 120 | "Found commit {} in repository {}", | ||
| 121 | commit, | ||
| 122 | repo_path.display() | ||
| 123 | ); | ||
| 124 | break; | ||
| 137 | } | 125 | } |
| 126 | } | ||
| 138 | 127 | ||
| 139 | // Check each matching announcement | 128 | // Copy commit to all other referenced repositories |
| 140 | for announcement_event in announcements { | 129 | if let Some(source_repo) = source_repo { |
| 141 | let announcement = match RepositoryAnnouncement::from_event(announcement_event) { | 130 | for repo_path in &repo_paths { |
| 142 | Ok(a) => a, | 131 | if repo_path == &source_repo { |
| 143 | Err(_) => continue, | 132 | // Skip source repo |
| 144 | }; | 133 | continue; |
| 134 | } | ||
| 145 | 135 | ||
| 146 | // Build repository path | 136 | // Check if repository exists |
| 147 | let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); | 137 | if !repo_path.exists() { |
| 138 | tracing::debug!( | ||
| 139 | "Repository {} does not exist, skipping sync", | ||
| 140 | repo_path.display() | ||
| 141 | ); | ||
| 142 | continue; | ||
| 143 | } | ||
| 148 | 144 | ||
| 149 | // Check if commit exists | 145 | // Check if commit already exists |
| 150 | if git::commit_exists(&repo_path, &commit) { | 146 | if git::commit_exists(repo_path, &commit) { |
| 151 | tracing::debug!( | 147 | tracing::debug!( |
| 152 | "Found commit {} for PR event {} in repository {}", | 148 | "Commit {} already exists in {}, skipping sync", |
| 153 | commit, | 149 | commit, |
| 154 | event_id, | ||
| 155 | repo_path.display() | 150 | repo_path.display() |
| 156 | ); | 151 | ); |
| 157 | return Ok(true); | 152 | continue; |
| 158 | } | 153 | } |
| 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 | |||
| 171 | /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag | ||
| 172 | /// | ||
| 173 | /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, | ||
| 174 | /// this checks if a corresponding refs/nostr/<event-id> ref exists in the | ||
| 175 | /// repository and validates that it points to the correct commit (from the | ||
| 176 | /// `c` tag). If the ref exists but points to a different commit, the ref is | ||
| 177 | /// deleted. | ||
| 178 | /// | ||
| 179 | /// PR and PR Update events can have multiple `a` tags to update multiple | ||
| 180 | /// repositories simultaneously. | ||
| 181 | /// | ||
| 182 | /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent | ||
| 183 | /// with their corresponding events. | ||
| 184 | /// | ||
| 185 | /// # Returns | ||
| 186 | /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure | ||
| 187 | pub async fn validate_nostr_ref(&self, event: &Event) -> Result<Option<usize>, String> { | ||
| 188 | let event_id = event.id.to_hex(); | ||
| 189 | 154 | ||
| 190 | // Extract the `c` tag (commit hash) from the PR event | 155 | // Fetch commit from source repo to target repo |
| 191 | let expected_commit = event.tags.iter().find_map(|tag| { | 156 | tracing::info!( |
| 192 | let tag_vec = tag.clone().to_vec(); | 157 | "Syncing commit {} from {} to {}", |
| 193 | if tag_vec.len() >= 2 && tag_vec[0] == "c" { | 158 | commit, |
| 194 | Some(tag_vec[1].clone()) | 159 | source_repo.display(), |
| 195 | } else { | 160 | repo_path.display() |
| 196 | None | ||
| 197 | } | ||
| 198 | }); | ||
| 199 | |||
| 200 | let expected_commit = match expected_commit { | ||
| 201 | Some(c) => c, | ||
| 202 | None => { | ||
| 203 | tracing::debug!( | ||
| 204 | "PR event {} has no 'c' tag, skipping ref validation", | ||
| 205 | event_id | ||
| 206 | ); | 161 | ); |
| 207 | return Ok(None); | 162 | |
| 163 | match self.copy_commit(&source_repo, repo_path, &commit).await { | ||
| 164 | Ok(()) => { | ||
| 165 | tracing::info!( | ||
| 166 | "Successfully synced commit {} to {}", | ||
| 167 | commit, | ||
| 168 | repo_path.display() | ||
| 169 | ); | ||
| 170 | } | ||
| 171 | Err(e) => { | ||
| 172 | tracing::warn!( | ||
| 173 | "Failed to sync commit {} to {}: {}", | ||
| 174 | commit, | ||
| 175 | repo_path.display(), | ||
| 176 | e | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | } | ||
| 208 | } | 180 | } |
| 209 | }; | 181 | Ok(true) |
| 182 | } else { | ||
| 183 | tracing::debug!( | ||
| 184 | "No git data found for PR event {} with commit {}", | ||
| 185 | event_id, | ||
| 186 | commit | ||
| 187 | ); | ||
| 188 | Ok(false) | ||
| 189 | } | ||
| 190 | } | ||
| 210 | 191 | ||
| 192 | async fn find_relevant_repo_paths(&self, event: &Event) -> Result<Vec<std::path::PathBuf>> { | ||
| 211 | // Extract ALL `a` tags (repository references) from the PR event | 193 | // Extract ALL `a` tags (repository references) from the PR event |
| 212 | // PR events can reference multiple repositories | ||
| 213 | // Format: 30617:<pubkey>:<identifier> | ||
| 214 | let repo_refs: Vec<String> = event | 194 | let repo_refs: Vec<String> = event |
| 215 | .tags | 195 | .tags |
| 216 | .iter() | 196 | .iter() |
| @@ -225,123 +205,85 @@ impl PrEventPolicy { | |||
| 225 | .collect(); | 205 | .collect(); |
| 226 | 206 | ||
| 227 | if repo_refs.is_empty() { | 207 | if repo_refs.is_empty() { |
| 228 | tracing::debug!( | 208 | return Ok(Vec::new()); |
| 229 | "PR event {} has no repo 'a' tags, skipping ref validation", | ||
| 230 | event_id | ||
| 231 | ); | ||
| 232 | return Ok(None); | ||
| 233 | } | 209 | } |
| 234 | 210 | ||
| 235 | let mut deleted_count = 0; | 211 | // 1. Find identifier from first a tag starting with "30617:" |
| 212 | let parts: Vec<&str> = repo_refs[0].split(':').collect(); | ||
| 213 | if parts.len() < 3 { | ||
| 214 | return Err(anyhow::anyhow!("Invalid repository reference format")); | ||
| 215 | } | ||
| 216 | let identifier = parts[2]; | ||
| 217 | |||
| 218 | // 2. Fetch repo data | ||
| 219 | let db_repo_data = fetch_repository_data(&self.ctx.database, identifier).await?; | ||
| 236 | 220 | ||
| 237 | // Process each repository reference | 221 | // 3. Extract list of maintainers from "a 30617:<maintainer>:<identifier>" tags |
| 238 | for repo_ref in repo_refs { | 222 | let mut maintainer_pubkeys = std::collections::HashSet::new(); |
| 239 | // Parse the repo reference: 30617:<pubkey>:<identifier> | 223 | for repo_ref in &repo_refs { |
| 240 | let parts: Vec<&str> = repo_ref.split(':').collect(); | 224 | let parts: Vec<&str> = repo_ref.split(':').collect(); |
| 241 | if parts.len() < 3 { | 225 | if parts.len() >= 2 { |
| 242 | tracing::debug!( | 226 | maintainer_pubkeys.insert(parts[1].to_string()); |
| 243 | "PR event {} has invalid 'a' tag format: {}", | ||
| 244 | event_id, | ||
| 245 | repo_ref | ||
| 246 | ); | ||
| 247 | continue; | ||
| 248 | } | 227 | } |
| 228 | } | ||
| 249 | 229 | ||
| 250 | let repo_pubkey = match PublicKey::from_hex(parts[1]) { | 230 | // 4. Identify owner repos that list any of the maintainers using this function |
| 251 | Ok(pk) => pk, | 231 | let by_owner = collect_authorized_maintainers(&db_repo_data.announcements); |
| 252 | Err(_) => { | ||
| 253 | tracing::debug!( | ||
| 254 | "PR event {} has invalid pubkey in 'a' tag: {}", | ||
| 255 | event_id, | ||
| 256 | parts[1] | ||
| 257 | ); | ||
| 258 | continue; | ||
| 259 | } | ||
| 260 | }; | ||
| 261 | let identifier = parts[2]; | ||
| 262 | |||
| 263 | // Look up repository announcement to get the npub for path | ||
| 264 | let filter = Filter::new() | ||
| 265 | .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) | ||
| 266 | .author(repo_pubkey) | ||
| 267 | .custom_tag( | ||
| 268 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 269 | identifier.to_string(), | ||
| 270 | ); | ||
| 271 | |||
| 272 | let announcements: Vec<Event> = match self.ctx.database.query(filter).await { | ||
| 273 | Ok(events) => events.into_iter().collect(), | ||
| 274 | Err(e) => { | ||
| 275 | tracing::warn!( | ||
| 276 | "Failed to query for repository announcement for PR {}: {}", | ||
| 277 | event_id, | ||
| 278 | e | ||
| 279 | ); | ||
| 280 | continue; | ||
| 281 | } | ||
| 282 | }; | ||
| 283 | |||
| 284 | if announcements.is_empty() { | ||
| 285 | tracing::debug!( | ||
| 286 | "No repository announcement found for PR event {} (repo {}:{})", | ||
| 287 | event_id, | ||
| 288 | repo_pubkey.to_hex(), | ||
| 289 | identifier | ||
| 290 | ); | ||
| 291 | continue; | ||
| 292 | } | ||
| 293 | 232 | ||
| 294 | // Process each matching announcement (there could be multiple) | 233 | // 5. Return the repo_path for each owner whose authorized maintainers include any of our maintainers |
| 295 | for announcement_event in announcements { | 234 | let mut repo_paths = Vec::new(); |
| 296 | let announcement = match RepositoryAnnouncement::from_event(announcement_event) { | 235 | for announcement in &db_repo_data.announcements { |
| 297 | Ok(a) => a, | 236 | let owner_pubkey = announcement.event.pubkey.to_hex(); |
| 298 | Err(e) => { | ||
| 299 | tracing::warn!( | ||
| 300 | "Failed to parse announcement for PR {} validation: {}", | ||
| 301 | event_id, | ||
| 302 | e | ||
| 303 | ); | ||
| 304 | continue; | ||
| 305 | } | ||
| 306 | }; | ||
| 307 | 237 | ||
| 308 | // Build repository path | 238 | // Check if this owner's authorized maintainers overlap with our maintainer list |
| 309 | let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); | 239 | if let Some(authorized_maintainers) = by_owner.get(&owner_pubkey) { |
| 240 | let has_overlap = authorized_maintainers | ||
| 241 | .iter() | ||
| 242 | .any(|m| maintainer_pubkeys.contains(m)); | ||
| 310 | 243 | ||
| 311 | // Validate the ref | 244 | if has_overlap { |
| 312 | match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { | 245 | let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); |
| 313 | Ok(true) => { | 246 | repo_paths.push(repo_path); |
| 314 | tracing::info!( | ||
| 315 | "Deleted mismatched refs/nostr/{} in {} (expected commit {})", | ||
| 316 | event_id, | ||
| 317 | repo_path.display(), | ||
| 318 | expected_commit | ||
| 319 | ); | ||
| 320 | deleted_count += 1; | ||
| 321 | } | ||
| 322 | Ok(false) => { | ||
| 323 | tracing::debug!( | ||
| 324 | "refs/nostr/{} in {} is valid or doesn't exist", | ||
| 325 | event_id, | ||
| 326 | repo_path.display() | ||
| 327 | ); | ||
| 328 | } | ||
| 329 | Err(e) => { | ||
| 330 | tracing::warn!( | ||
| 331 | "Failed to validate refs/nostr/{} in {}: {}", | ||
| 332 | event_id, | ||
| 333 | repo_path.display(), | ||
| 334 | e | ||
| 335 | ); | ||
| 336 | } | ||
| 337 | } | 247 | } |
| 338 | } | 248 | } |
| 339 | } | 249 | } |
| 340 | 250 | ||
| 341 | if deleted_count > 0 { | 251 | Ok(repo_paths) |
| 342 | Ok(Some(deleted_count)) | 252 | } |
| 343 | } else { | 253 | /// Copy a commit from source repository to target repository |
| 344 | Ok(None) | 254 | /// |
| 255 | /// Uses `git fetch` to copy a specific commit between local repositories. | ||
| 256 | /// | ||
| 257 | /// # Arguments | ||
| 258 | /// * `source_repo` - Path to repository containing the commit | ||
| 259 | /// * `target_repo` - Path to repository to receive the commit | ||
| 260 | /// * `commit` - Commit hash to copy | ||
| 261 | /// | ||
| 262 | /// # Returns | ||
| 263 | /// Ok(()) on success, Err with error message on failure | ||
| 264 | async fn copy_commit( | ||
| 265 | &self, | ||
| 266 | source_repo: &std::path::Path, | ||
| 267 | target_repo: &std::path::Path, | ||
| 268 | commit: &str, | ||
| 269 | ) -> Result<(), String> { | ||
| 270 | use std::process::Command; | ||
| 271 | |||
| 272 | let output = Command::new("git") | ||
| 273 | .args([ | ||
| 274 | "fetch", | ||
| 275 | source_repo.to_str().ok_or("Invalid source path")?, | ||
| 276 | commit, | ||
| 277 | ]) | ||
| 278 | .current_dir(target_repo) | ||
| 279 | .output() | ||
| 280 | .map_err(|e| format!("Failed to execute git fetch: {}", e))?; | ||
| 281 | |||
| 282 | if !output.status.success() { | ||
| 283 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 284 | return Err(format!("git fetch failed: {}", stderr)); | ||
| 345 | } | 285 | } |
| 286 | |||
| 287 | Ok(()) | ||
| 346 | } | 288 | } |
| 347 | } | 289 | } |
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index 13f2549..1203890 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs | |||
| @@ -6,15 +6,12 @@ use nostr_relay_builder::builder::WritePolicyResult; | |||
| 6 | /// | 6 | /// |
| 7 | /// Handles validation of NIP-34 repository state events (kind 30618) | 7 | /// Handles validation of NIP-34 repository state events (kind 30618) |
| 8 | /// and aligns git refs with authorized state according to GRASP-01. | 8 | /// and aligns git refs with authorized state according to GRASP-01. |
| 9 | use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; | 9 | use nostr_relay_builder::prelude::Event; |
| 10 | 10 | ||
| 11 | use super::PolicyContext; | 11 | use super::PolicyContext; |
| 12 | use crate::git::authorization::{collect_authorized_maintainers, fetch_repository_data}; | 12 | use crate::git::authorization::{collect_authorized_maintainers, fetch_repository_data}; |
| 13 | use crate::git::{self}; | 13 | use crate::git::{self}; |
| 14 | use crate::nostr::events::{ | 14 | use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState}; |
| 15 | validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, | ||
| 16 | KIND_REPOSITORY_STATE, | ||
| 17 | }; | ||
| 18 | 15 | ||
| 19 | /// Result of aligning a repository with authorized state | 16 | /// Result of aligning a repository with authorized state |
| 20 | #[derive(Debug, Default)] | 17 | #[derive(Debug, Default)] |
| @@ -168,186 +165,6 @@ impl StatePolicy { | |||
| 168 | } | 165 | } |
| 169 | } | 166 | } |
| 170 | 167 | ||
| 171 | /// Check if any git repositories exist for the given identifier | ||
| 172 | /// | ||
| 173 | /// Scans the git_data_path for any directories matching the pattern: | ||
| 174 | /// `<any-npub>/<identifier>.git` | ||
| 175 | /// | ||
| 176 | /// This is used to distinguish "no git data yet" from "not authorized". | ||
| 177 | fn has_git_data_for_identifier(&self, identifier: &str) -> bool { | ||
| 178 | let git_data_path = &self.ctx.git_data_path; | ||
| 179 | |||
| 180 | // Check if git_data_path exists | ||
| 181 | if !git_data_path.exists() { | ||
| 182 | return false; | ||
| 183 | } | ||
| 184 | |||
| 185 | // Scan for any npub directories | ||
| 186 | let read_dir = match std::fs::read_dir(git_data_path) { | ||
| 187 | Ok(dir) => dir, | ||
| 188 | Err(_) => return false, | ||
| 189 | }; | ||
| 190 | |||
| 191 | for entry in read_dir.flatten() { | ||
| 192 | if let Ok(file_type) = entry.file_type() { | ||
| 193 | if file_type.is_dir() { | ||
| 194 | // Check if <npub>/<identifier>.git exists | ||
| 195 | let repo_path = entry.path().join(format!("{}.git", identifier)); | ||
| 196 | if repo_path.exists() { | ||
| 197 | return true; | ||
| 198 | } | ||
| 199 | } | ||
| 200 | } | ||
| 201 | } | ||
| 202 | |||
| 203 | false | ||
| 204 | } | ||
| 205 | |||
| 206 | /// Check if this state event is the latest for its identifier among authorized authors | ||
| 207 | /// | ||
| 208 | /// A state is considered "latest" if no other state event in the database | ||
| 209 | /// from an authorized author has a newer timestamp. | ||
| 210 | async fn is_latest_state_for_identifier( | ||
| 211 | &self, | ||
| 212 | state: &RepositoryState, | ||
| 213 | authorized_pubkeys: &[PublicKey], | ||
| 214 | ) -> Result<bool, String> { | ||
| 215 | let filter = Filter::new() | ||
| 216 | .kind(Kind::from(KIND_REPOSITORY_STATE)) | ||
| 217 | .custom_tag( | ||
| 218 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 219 | state.identifier.clone(), | ||
| 220 | ); | ||
| 221 | |||
| 222 | match self.ctx.database.query(filter).await { | ||
| 223 | Ok(events) => { | ||
| 224 | for event in events { | ||
| 225 | // Skip comparing to self (same event ID) | ||
| 226 | if event.id == state.event.id { | ||
| 227 | continue; | ||
| 228 | } | ||
| 229 | // Only consider events from authorized authors for this announcement | ||
| 230 | if !authorized_pubkeys.contains(&event.pubkey) { | ||
| 231 | continue; | ||
| 232 | } | ||
| 233 | // If any existing event from an authorized author is newer, this is not the latest | ||
| 234 | if event.created_at > state.event.created_at { | ||
| 235 | tracing::debug!( | ||
| 236 | "State {} is not latest: found newer state {} from {} (ts {} > {})", | ||
| 237 | state.event.id.to_hex(), | ||
| 238 | event.id.to_hex(), | ||
| 239 | event.pubkey.to_hex(), | ||
| 240 | event.created_at.as_secs(), | ||
| 241 | state.event.created_at.as_secs() | ||
| 242 | ); | ||
| 243 | return Ok(false); | ||
| 244 | } | ||
| 245 | } | ||
| 246 | Ok(true) | ||
| 247 | } | ||
| 248 | Err(e) => Err(format!("Database query failed: {}", e)), | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | /// Find all repository announcements where the given pubkey is authorized | ||
| 253 | async fn find_authorized_announcements( | ||
| 254 | &self, | ||
| 255 | identifier: &str, | ||
| 256 | state_author: &PublicKey, | ||
| 257 | ) -> Result<Vec<RepositoryAnnouncement>, String> { | ||
| 258 | let filter = Filter::new() | ||
| 259 | .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) | ||
| 260 | .custom_tag( | ||
| 261 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 262 | identifier.to_string(), | ||
| 263 | ); | ||
| 264 | |||
| 265 | match self.ctx.database.query(filter).await { | ||
| 266 | Ok(events) => { | ||
| 267 | let mut authorized = Vec::new(); | ||
| 268 | let state_author_hex = state_author.to_hex(); | ||
| 269 | |||
| 270 | for event in events { | ||
| 271 | if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { | ||
| 272 | // Check if state author is authorized for this announcement | ||
| 273 | let is_owner = event.pubkey == *state_author; | ||
| 274 | let is_maintainer = announcement.maintainers.contains(&state_author_hex); | ||
| 275 | |||
| 276 | if is_owner || is_maintainer { | ||
| 277 | tracing::debug!( | ||
| 278 | "Found authorized announcement for {}: owner={}, maintainer={}", | ||
| 279 | identifier, | ||
| 280 | if is_owner { | ||
| 281 | event.pubkey.to_hex() | ||
| 282 | } else { | ||
| 283 | "n/a".to_string() | ||
| 284 | }, | ||
| 285 | is_maintainer | ||
| 286 | ); | ||
| 287 | authorized.push(announcement); | ||
| 288 | } | ||
| 289 | } | ||
| 290 | } | ||
| 291 | Ok(authorized) | ||
| 292 | } | ||
| 293 | Err(e) => Err(format!("Database query failed: {}", e)), | ||
| 294 | } | ||
| 295 | } | ||
| 296 | |||
| 297 | /// Identify all owner repositories for which this state event is the latest authorized state | ||
| 298 | async fn identify_owner_repositories( | ||
| 299 | &self, | ||
| 300 | state: &RepositoryState, | ||
| 301 | ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> { | ||
| 302 | // Find all announcements where state author is authorized | ||
| 303 | let announcements = self | ||
| 304 | .find_authorized_announcements(&state.identifier, &state.event.pubkey) | ||
| 305 | .await?; | ||
| 306 | |||
| 307 | if announcements.is_empty() { | ||
| 308 | tracing::debug!( | ||
| 309 | "No authorized announcements found for state {} by {}", | ||
| 310 | state.identifier, | ||
| 311 | state.event.pubkey.to_hex() | ||
| 312 | ); | ||
| 313 | return Ok(Vec::new()); | ||
| 314 | } | ||
| 315 | |||
| 316 | let mut owner_repos = Vec::new(); | ||
| 317 | |||
| 318 | for announcement in announcements { | ||
| 319 | // Build the list of authorized pubkeys for this specific announcement | ||
| 320 | let mut authorized_pubkeys = vec![announcement.event.pubkey]; | ||
| 321 | for maintainer_hex in &announcement.maintainers { | ||
| 322 | if let Ok(pk) = PublicKey::from_hex(maintainer_hex) { | ||
| 323 | authorized_pubkeys.push(pk); | ||
| 324 | } | ||
| 325 | } | ||
| 326 | |||
| 327 | // Check if this is the latest state event for THIS announcement's context | ||
| 328 | if !self | ||
| 329 | .is_latest_state_for_identifier(state, &authorized_pubkeys) | ||
| 330 | .await? | ||
| 331 | { | ||
| 332 | tracing::debug!( | ||
| 333 | "Skipping {} in {}'s repo - not the latest state event for this context", | ||
| 334 | state.identifier, | ||
| 335 | announcement.event.pubkey.to_hex() | ||
| 336 | ); | ||
| 337 | continue; | ||
| 338 | } | ||
| 339 | |||
| 340 | // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git | ||
| 341 | let repo_path = self | ||
| 342 | .ctx | ||
| 343 | .git_data_path | ||
| 344 | .join(announcement.repo_path().clone()); | ||
| 345 | owner_repos.push((announcement, repo_path)); | ||
| 346 | } | ||
| 347 | |||
| 348 | Ok(owner_repos) | ||
| 349 | } | ||
| 350 | |||
| 351 | /// Align a repository's refs with the authorized state | 168 | /// Align a repository's refs with the authorized state |
| 352 | /// | 169 | /// |
| 353 | /// This function: | 170 | /// This function: |