From 768fe91caa676e4501aa26e14e01ca47f3ea4ca1 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 31 Dec 2025 09:18:21 +0000 Subject: purgatory: fix pr event recieve code --- src/git/mod.rs | 4 - src/nostr/builder.rs | 71 +++++--- src/nostr/policy/pr_event.rs | 396 ++++++++++++++++++------------------------- src/nostr/policy/state.rs | 187 +------------------- 4 files changed, 223 insertions(+), 435 deletions(-) (limited to 'src') 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 { } } -pub fn is_valid_oid(oid: &str) -> bool { - oid.len() >= 5 && oid.len() <= 40 && oid.chars().all(|c| c.is_digit(16)) -} - /// Set the repository HEAD to point to a branch /// /// 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 { async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult { let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); - // Check if git data exists (checks placeholders and commit existence) - match self.pr_event_policy.check_git_data_exists(event).await { + // duplicate check in purgatory + let in_purgatory = self + .ctx + .purgatory + .find_pr(&event.id.to_hex()) + .is_some_and(|e| e.event.is_some()); + if in_purgatory { + tracing::debug!( + "processed PR event duplicate (already in purgatory): {}", + event.id, + ); + return WritePolicyResult::Reject { + status: true, // Client sees OK + message: "duplicate: in purgatory".into(), + }; + } + + // duplicate check in db + match &self.ctx.database.check_id(&event.id).await { + Ok(DatabaseEventStatus::Saved) => { + return WritePolicyResult::Reject { + status: true, // Client sees OK + message: "duplicate".into(), + }; + } + Ok(DatabaseEventStatus::Deleted) => { + return WritePolicyResult::Reject { + status: false, + message: "invalid: accepted deletion request for this event".into(), + }; + } + Err(e) => { + return WritePolicyResult::Reject { + status: false, + message: format!("error: internal error: {e}").into(), + }; + } + _ => {} // continue + } + + // Reject PRs unrelated to stored repositories / events + match self.handle_related_event(event, "PR").await { + WritePolicyResult::Accept => {} // continue + rejected => return rejected, + } + + // Check if git data exists (delete any incorrect commits at refs/nostr/, copies correct data to relivant repositories) + match self.pr_event_policy.git_data_check(event).await { Ok(false) => { // No git data exists - add to purgatory let commit = event @@ -196,18 +242,19 @@ impl Nip34WritePolicy { .purgatory .add_pr(event.clone(), event.id.to_hex(), commit.clone()); - return WritePolicyResult::Reject { + WritePolicyResult::Reject { status: true, // Client sees OK message: format!( "purgatory: PR event stored, waiting for git push with commit {}", commit ) .into(), - }; + } } Ok(true) => { // Git data exists - proceed with normal validation tracing::debug!("Git data exists for PR event {}", event_id_str); + WritePolicyResult::Accept } Err(e) => { // Error checking git data - reject event @@ -216,23 +263,9 @@ impl Nip34WritePolicy { event_id_str, e ); - return WritePolicyResult::reject(format!("Failed to check git data: {}", e)); + WritePolicyResult::reject(format!("Failed to check git data: {}", e)) } } - - // Validate refs/nostr refs for this PR event - // This deletes any refs/nostr/ that points to wrong commit - if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await { - tracing::warn!( - "Failed to validate refs/nostr for PR event {}: {}", - event_id_str, - e - ); - // Don't reject - just log the error and proceed with normal validation - } - - // Continue with reference checking (same as related events) - self.handle_related_event(event, "PR").await } /// 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 @@ /// /// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619) /// according to GRASP-01 specification. -use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; +use anyhow::{bail, Result}; +use nostr_relay_builder::prelude::Event; use super::PolicyContext; use crate::git; -use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT}; +use crate::git::authorization::{collect_authorized_maintainers, fetch_repository_data}; /// Policy for validating PR and PR Update events #[derive(Clone)] @@ -21,15 +22,18 @@ impl PrEventPolicy { /// Check if git data exists for a PR event /// - /// This checks: - /// 1. If a placeholder exists (git-data-first scenario) - /// 2. If the commit exists in any relevant repository + /// This unified method checks for git data existence and handles: + /// 1. Placeholder validation (git-data-first scenario) + /// 2. Commit existence in referenced repositories + /// 3. Deletion of incorrect refs/nostr/ refs + /// 4. Deletion of incorrect placeholders + /// 5. Copying git data to all referenced repositories when found /// /// # Returns - /// - `Ok(true)` if git data ready (either placeholder found or commit exists) + /// - `Ok(true)` if git data ready (commit exists and is synced to all repos) /// - `Ok(false)` if git data missing (should add to purgatory) /// - `Err(msg)` on errors - pub async fn check_git_data_exists(&self, event: &Event) -> Result { + pub async fn git_data_check(&self, event: &Event) -> Result { let event_id = event.id.to_hex(); // Extract the `c` tag (commit hash) from the PR event @@ -45,7 +49,7 @@ impl PrEventPolicy { let commit = match commit { Some(c) => c, None => { - return Err(format!("PR event {} has no 'c' tag", event_id)); + bail!(format!("PR event {} has no 'c' tag", event_id)); } }; @@ -60,7 +64,7 @@ impl PrEventPolicy { ); // Remove placeholder - event processing will continue normally self.ctx.purgatory.remove_pr(&event_id); - return Ok(true); + // Continue to validate and sync refs across all repos } else { // Placeholder has different commit - incoming event supersedes tracing::info!( @@ -69,148 +73,124 @@ impl PrEventPolicy { commit, placeholder_commit ); - // Remove placeholder with old commit data + // Remove incorrect placeholder self.ctx.purgatory.remove_pr(&event_id); - // TODO: Also remove git data (refs/nostr/) - Phase 5 - // Fall through to check if new commit exists + // Delete incorrect git data (refs/nostr/) from all repos + // This will be handled below when we validate refs } } - // Check if commit exists in any repository referenced by this PR - // Extract ALL `a` tags (repository references) from the PR event - let repo_refs: Vec = event - .tags - .iter() - .filter_map(|tag| { - let tag_vec = tag.clone().to_vec(); - if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") { - Some(tag_vec[1].clone()) - } else { - None - } - }) - .collect(); + let repo_paths = self.find_relevant_repo_paths(event).await?; - if repo_refs.is_empty() { - // No repo references - cannot check git data - // This is unusual but let it through (other validation will catch issues) - return Ok(true); + if repo_paths.is_empty() { + tracing::debug!("No repository paths found for PR event {}", event_id); + return Ok(false); } - // Check each repository to see if commit exists - for repo_ref in repo_refs { - // Parse the repo reference: 30617:: - let parts: Vec<&str> = repo_ref.split(':').collect(); - if parts.len() < 3 { - continue; - } - - let repo_pubkey = match PublicKey::from_hex(parts[1]) { - Ok(pk) => pk, - Err(_) => continue, - }; - let identifier = parts[2]; - - // Look up repository announcement to get the npub for path - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) - .author(repo_pubkey) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - identifier.to_string(), - ); - - let announcements: Vec = match self.ctx.database.query(filter).await { - Ok(events) => events.into_iter().collect(), + // delete incorrect refs/nostr/ + for repo_path in &repo_paths { + // First, validate/delete any incorrect refs/nostr/ + match git::validate_nostr_ref(repo_path, &event_id, &commit) { + Ok(true) => { + tracing::info!( + "Deleted mismatched refs/nostr/{} in {}", + event_id, + repo_path.display() + ); + } + Ok(false) => {} Err(e) => { tracing::warn!( - "Failed to query for repository announcement for PR {}: {}", + "Failed to validate refs/nostr/{} in {}: {}", event_id, + repo_path.display(), e ); - continue; } - }; + } + } - if announcements.is_empty() { - continue; + // find location of correct git data (if exists) + let mut source_repo: Option = None; + for repo_path in &repo_paths { + // Check if commit exists in this repository + if git::commit_exists(repo_path, &commit) { + source_repo = Some(repo_path.clone()); + tracing::debug!( + "Found commit {} in repository {}", + commit, + repo_path.display() + ); + break; } + } - // Check each matching announcement - for announcement_event in announcements { - let announcement = match RepositoryAnnouncement::from_event(announcement_event) { - Ok(a) => a, - Err(_) => continue, - }; + // Copy commit to all other referenced repositories + if let Some(source_repo) = source_repo { + for repo_path in &repo_paths { + if repo_path == &source_repo { + // Skip source repo + continue; + } - // Build repository path - let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); + // Check if repository exists + if !repo_path.exists() { + tracing::debug!( + "Repository {} does not exist, skipping sync", + repo_path.display() + ); + continue; + } - // Check if commit exists - if git::commit_exists(&repo_path, &commit) { + // Check if commit already exists + if git::commit_exists(repo_path, &commit) { tracing::debug!( - "Found commit {} for PR event {} in repository {}", + "Commit {} already exists in {}, skipping sync", commit, - event_id, repo_path.display() ); - return Ok(true); + continue; } - } - } - - // No git data found - should add to purgatory - tracing::debug!( - "No git data found for PR event {} with commit {}", - event_id, - commit - ); - Ok(false) - } - - /// Validate refs/nostr/ ref against a PR or PR Update event's `c` tag - /// - /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, - /// this checks if a corresponding refs/nostr/ ref exists in the - /// repository and validates that it points to the correct commit (from the - /// `c` tag). If the ref exists but points to a different commit, the ref is - /// deleted. - /// - /// PR and PR Update events can have multiple `a` tags to update multiple - /// repositories simultaneously. - /// - /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent - /// with their corresponding events. - /// - /// # Returns - /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure - pub async fn validate_nostr_ref(&self, event: &Event) -> Result, String> { - let event_id = event.id.to_hex(); - // Extract the `c` tag (commit hash) from the PR event - let expected_commit = event.tags.iter().find_map(|tag| { - let tag_vec = tag.clone().to_vec(); - if tag_vec.len() >= 2 && tag_vec[0] == "c" { - Some(tag_vec[1].clone()) - } else { - None - } - }); - - let expected_commit = match expected_commit { - Some(c) => c, - None => { - tracing::debug!( - "PR event {} has no 'c' tag, skipping ref validation", - event_id + // Fetch commit from source repo to target repo + tracing::info!( + "Syncing commit {} from {} to {}", + commit, + source_repo.display(), + repo_path.display() ); - return Ok(None); + + match self.copy_commit(&source_repo, repo_path, &commit).await { + Ok(()) => { + tracing::info!( + "Successfully synced commit {} to {}", + commit, + repo_path.display() + ); + } + Err(e) => { + tracing::warn!( + "Failed to sync commit {} to {}: {}", + commit, + repo_path.display(), + e + ); + } + } } - }; + Ok(true) + } else { + tracing::debug!( + "No git data found for PR event {} with commit {}", + event_id, + commit + ); + Ok(false) + } + } + async fn find_relevant_repo_paths(&self, event: &Event) -> Result> { // Extract ALL `a` tags (repository references) from the PR event - // PR events can reference multiple repositories - // Format: 30617:: let repo_refs: Vec = event .tags .iter() @@ -225,123 +205,85 @@ impl PrEventPolicy { .collect(); if repo_refs.is_empty() { - tracing::debug!( - "PR event {} has no repo 'a' tags, skipping ref validation", - event_id - ); - return Ok(None); + return Ok(Vec::new()); } - let mut deleted_count = 0; + // 1. Find identifier from first a tag starting with "30617:" + let parts: Vec<&str> = repo_refs[0].split(':').collect(); + if parts.len() < 3 { + return Err(anyhow::anyhow!("Invalid repository reference format")); + } + let identifier = parts[2]; + + // 2. Fetch repo data + let db_repo_data = fetch_repository_data(&self.ctx.database, identifier).await?; - // Process each repository reference - for repo_ref in repo_refs { - // Parse the repo reference: 30617:: + // 3. Extract list of maintainers from "a 30617::" tags + let mut maintainer_pubkeys = std::collections::HashSet::new(); + for repo_ref in &repo_refs { let parts: Vec<&str> = repo_ref.split(':').collect(); - if parts.len() < 3 { - tracing::debug!( - "PR event {} has invalid 'a' tag format: {}", - event_id, - repo_ref - ); - continue; + if parts.len() >= 2 { + maintainer_pubkeys.insert(parts[1].to_string()); } + } - let repo_pubkey = match PublicKey::from_hex(parts[1]) { - Ok(pk) => pk, - Err(_) => { - tracing::debug!( - "PR event {} has invalid pubkey in 'a' tag: {}", - event_id, - parts[1] - ); - continue; - } - }; - let identifier = parts[2]; - - // Look up repository announcement to get the npub for path - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) - .author(repo_pubkey) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - identifier.to_string(), - ); - - let announcements: Vec = match self.ctx.database.query(filter).await { - Ok(events) => events.into_iter().collect(), - Err(e) => { - tracing::warn!( - "Failed to query for repository announcement for PR {}: {}", - event_id, - e - ); - continue; - } - }; - - if announcements.is_empty() { - tracing::debug!( - "No repository announcement found for PR event {} (repo {}:{})", - event_id, - repo_pubkey.to_hex(), - identifier - ); - continue; - } + // 4. Identify owner repos that list any of the maintainers using this function + let by_owner = collect_authorized_maintainers(&db_repo_data.announcements); - // Process each matching announcement (there could be multiple) - for announcement_event in announcements { - let announcement = match RepositoryAnnouncement::from_event(announcement_event) { - Ok(a) => a, - Err(e) => { - tracing::warn!( - "Failed to parse announcement for PR {} validation: {}", - event_id, - e - ); - continue; - } - }; + // 5. Return the repo_path for each owner whose authorized maintainers include any of our maintainers + let mut repo_paths = Vec::new(); + for announcement in &db_repo_data.announcements { + let owner_pubkey = announcement.event.pubkey.to_hex(); - // Build repository path - let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); + // Check if this owner's authorized maintainers overlap with our maintainer list + if let Some(authorized_maintainers) = by_owner.get(&owner_pubkey) { + let has_overlap = authorized_maintainers + .iter() + .any(|m| maintainer_pubkeys.contains(m)); - // Validate the ref - match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { - Ok(true) => { - tracing::info!( - "Deleted mismatched refs/nostr/{} in {} (expected commit {})", - event_id, - repo_path.display(), - expected_commit - ); - deleted_count += 1; - } - Ok(false) => { - tracing::debug!( - "refs/nostr/{} in {} is valid or doesn't exist", - event_id, - repo_path.display() - ); - } - Err(e) => { - tracing::warn!( - "Failed to validate refs/nostr/{} in {}: {}", - event_id, - repo_path.display(), - e - ); - } + if has_overlap { + let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); + repo_paths.push(repo_path); } } } - if deleted_count > 0 { - Ok(Some(deleted_count)) - } else { - Ok(None) + Ok(repo_paths) + } + /// Copy a commit from source repository to target repository + /// + /// Uses `git fetch` to copy a specific commit between local repositories. + /// + /// # Arguments + /// * `source_repo` - Path to repository containing the commit + /// * `target_repo` - Path to repository to receive the commit + /// * `commit` - Commit hash to copy + /// + /// # Returns + /// Ok(()) on success, Err with error message on failure + async fn copy_commit( + &self, + source_repo: &std::path::Path, + target_repo: &std::path::Path, + commit: &str, + ) -> Result<(), String> { + use std::process::Command; + + let output = Command::new("git") + .args([ + "fetch", + source_repo.to_str().ok_or("Invalid source path")?, + commit, + ]) + .current_dir(target_repo) + .output() + .map_err(|e| format!("Failed to execute git fetch: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git fetch failed: {}", stderr)); } + + Ok(()) } } 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; /// /// Handles validation of NIP-34 repository state events (kind 30618) /// and aligns git refs with authorized state according to GRASP-01. -use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; +use nostr_relay_builder::prelude::Event; use super::PolicyContext; use crate::git::authorization::{collect_authorized_maintainers, fetch_repository_data}; use crate::git::{self}; -use crate::nostr::events::{ - validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, - KIND_REPOSITORY_STATE, -}; +use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState}; /// Result of aligning a repository with authorized state #[derive(Debug, Default)] @@ -168,186 +165,6 @@ impl StatePolicy { } } - /// Check if any git repositories exist for the given identifier - /// - /// Scans the git_data_path for any directories matching the pattern: - /// `/.git` - /// - /// This is used to distinguish "no git data yet" from "not authorized". - fn has_git_data_for_identifier(&self, identifier: &str) -> bool { - let git_data_path = &self.ctx.git_data_path; - - // Check if git_data_path exists - if !git_data_path.exists() { - return false; - } - - // Scan for any npub directories - let read_dir = match std::fs::read_dir(git_data_path) { - Ok(dir) => dir, - Err(_) => return false, - }; - - for entry in read_dir.flatten() { - if let Ok(file_type) = entry.file_type() { - if file_type.is_dir() { - // Check if /.git exists - let repo_path = entry.path().join(format!("{}.git", identifier)); - if repo_path.exists() { - return true; - } - } - } - } - - false - } - - /// Check if this state event is the latest for its identifier among authorized authors - /// - /// A state is considered "latest" if no other state event in the database - /// from an authorized author has a newer timestamp. - async fn is_latest_state_for_identifier( - &self, - state: &RepositoryState, - authorized_pubkeys: &[PublicKey], - ) -> Result { - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_STATE)) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - state.identifier.clone(), - ); - - match self.ctx.database.query(filter).await { - Ok(events) => { - for event in events { - // Skip comparing to self (same event ID) - if event.id == state.event.id { - continue; - } - // Only consider events from authorized authors for this announcement - if !authorized_pubkeys.contains(&event.pubkey) { - continue; - } - // If any existing event from an authorized author is newer, this is not the latest - if event.created_at > state.event.created_at { - tracing::debug!( - "State {} is not latest: found newer state {} from {} (ts {} > {})", - state.event.id.to_hex(), - event.id.to_hex(), - event.pubkey.to_hex(), - event.created_at.as_secs(), - state.event.created_at.as_secs() - ); - return Ok(false); - } - } - Ok(true) - } - Err(e) => Err(format!("Database query failed: {}", e)), - } - } - - /// Find all repository announcements where the given pubkey is authorized - async fn find_authorized_announcements( - &self, - identifier: &str, - state_author: &PublicKey, - ) -> Result, String> { - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - identifier.to_string(), - ); - - match self.ctx.database.query(filter).await { - Ok(events) => { - let mut authorized = Vec::new(); - let state_author_hex = state_author.to_hex(); - - for event in events { - if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { - // Check if state author is authorized for this announcement - let is_owner = event.pubkey == *state_author; - let is_maintainer = announcement.maintainers.contains(&state_author_hex); - - if is_owner || is_maintainer { - tracing::debug!( - "Found authorized announcement for {}: owner={}, maintainer={}", - identifier, - if is_owner { - event.pubkey.to_hex() - } else { - "n/a".to_string() - }, - is_maintainer - ); - authorized.push(announcement); - } - } - } - Ok(authorized) - } - Err(e) => Err(format!("Database query failed: {}", e)), - } - } - - /// Identify all owner repositories for which this state event is the latest authorized state - async fn identify_owner_repositories( - &self, - state: &RepositoryState, - ) -> Result, String> { - // Find all announcements where state author is authorized - let announcements = self - .find_authorized_announcements(&state.identifier, &state.event.pubkey) - .await?; - - if announcements.is_empty() { - tracing::debug!( - "No authorized announcements found for state {} by {}", - state.identifier, - state.event.pubkey.to_hex() - ); - return Ok(Vec::new()); - } - - let mut owner_repos = Vec::new(); - - for announcement in announcements { - // Build the list of authorized pubkeys for this specific announcement - let mut authorized_pubkeys = vec![announcement.event.pubkey]; - for maintainer_hex in &announcement.maintainers { - if let Ok(pk) = PublicKey::from_hex(maintainer_hex) { - authorized_pubkeys.push(pk); - } - } - - // Check if this is the latest state event for THIS announcement's context - if !self - .is_latest_state_for_identifier(state, &authorized_pubkeys) - .await? - { - tracing::debug!( - "Skipping {} in {}'s repo - not the latest state event for this context", - state.identifier, - announcement.event.pubkey.to_hex() - ); - continue; - } - - // Build repository path: //.git - let repo_path = self - .ctx - .git_data_path - .join(announcement.repo_path().clone()); - owner_repos.push((announcement, repo_path)); - } - - Ok(owner_repos) - } - /// Align a repository's refs with the authorized state /// /// This function: -- cgit v1.2.3