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 | |
| parent | f8c3e3920ed2a1bdaab30be912276993449a5476 (diff) | |
feat(purgatory): add broken purgatory implementation
Diffstat (limited to 'src')
| -rw-r--r-- | src/git/authorization.rs | 113 | ||||
| -rw-r--r-- | src/git/handlers.rs | 321 | ||||
| -rw-r--r-- | src/git/mod.rs | 68 | ||||
| -rw-r--r-- | src/http/mod.rs | 11 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/main.rs | 61 | ||||
| -rw-r--r-- | src/nostr/builder.rs | 116 | ||||
| -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 | ||||
| -rw-r--r-- | src/purgatory/helpers.rs | 435 | ||||
| -rw-r--r-- | src/purgatory/mod.rs | 593 | ||||
| -rw-r--r-- | src/purgatory/types.rs | 99 |
13 files changed, 1886 insertions, 141 deletions
diff --git a/src/git/authorization.rs b/src/git/authorization.rs index 4896fc0..fbeaf9e 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs | |||
| @@ -31,7 +31,7 @@ use anyhow::{anyhow, Result}; | |||
| 31 | use nostr_relay_builder::prelude::*; | 31 | use nostr_relay_builder::prelude::*; |
| 32 | use nostr_sdk::{EventId, ToBech32}; | 32 | use nostr_sdk::{EventId, ToBech32}; |
| 33 | use std::collections::{HashMap, HashSet}; | 33 | use std::collections::{HashMap, HashSet}; |
| 34 | use tracing::debug; | 34 | use tracing::{debug, info, warn}; |
| 35 | 35 | ||
| 36 | use crate::nostr::builder::SharedDatabase; | 36 | use crate::nostr::builder::SharedDatabase; |
| 37 | use crate::nostr::events::{ | 37 | use crate::nostr::events::{ |
| @@ -325,26 +325,31 @@ pub async fn get_authorization_from_db( | |||
| 325 | 325 | ||
| 326 | /// Get the authorization result for a repository scoped to a specific owner | 326 | /// Get the authorization result for a repository scoped to a specific owner |
| 327 | /// | 327 | /// |
| 328 | /// Unlike `get_authorization_from_db`, this function scopes the authorization | 328 | /// Push authorization checks ONLY purgatory for state events. The database represents |
| 329 | /// to a specific owner's announcement. This is the correct approach for Git push | 329 | /// the current git state, while purgatory holds the intended future state that pushes |
| 330 | /// authorization where the URL path specifies the owner. | 330 | /// should be authorized against. |
| 331 | /// | 331 | /// |
| 332 | /// A push to `alice/my-repo` should only consider authorization from alice's | 332 | /// A push to `alice/my-repo` should only consider authorization from alice's |
| 333 | /// announcement, not bob's announcement for the same identifier. | 333 | /// announcement, not bob's announcement for the same identifier. |
| 334 | /// | 334 | /// |
| 335 | /// It: | 335 | /// It: |
| 336 | /// 1. Fetches all announcements and states for the identifier | 336 | /// 1. Fetches announcements for the identifier |
| 337 | /// 2. Collects authorized maintainers from all announcements (grouped by owner) | 337 | /// 2. Collects authorized maintainers from owner's announcement |
| 338 | /// 3. Looks up the authorized set for the specific owner | 338 | /// 3. Checks purgatory for matching state events from authorized maintainers |
| 339 | /// 4. Finds the latest state event from an authorized maintainer | ||
| 340 | /// | 339 | /// |
| 341 | /// Returns an `AuthorizationResult` that indicates whether a push is authorized. | 340 | /// Returns an `AuthorizationResult` that indicates whether a push is authorized. |
| 342 | pub async fn get_authorization_for_owner( | 341 | pub async fn get_state_authorization_for_specific_owner_repo( |
| 343 | database: &SharedDatabase, | 342 | database: &SharedDatabase, |
| 344 | identifier: &str, | 343 | identifier: &str, |
| 345 | owner_pubkey: &str, | 344 | owner_pubkey: &str, |
| 345 | purgatory: &std::sync::Arc<crate::purgatory::Purgatory>, | ||
| 346 | pushed_refs: &[(String, String, String)], | ||
| 347 | repo_path: &std::path::Path, | ||
| 346 | ) -> Result<AuthorizationResult> { | 348 | ) -> Result<AuthorizationResult> { |
| 347 | // Fetch all repository data with a single query | 349 | use crate::git::list_refs; |
| 350 | use crate::purgatory::RefUpdate; | ||
| 351 | |||
| 352 | // Fetch announcements only - we don't need database states | ||
| 348 | let repo_data = fetch_repository_data(database, identifier).await?; | 353 | let repo_data = fetch_repository_data(database, identifier).await?; |
| 349 | 354 | ||
| 350 | if repo_data.announcements.is_empty() { | 355 | if repo_data.announcements.is_empty() { |
| @@ -380,16 +385,82 @@ pub async fn get_authorization_for_owner( | |||
| 380 | owner_pubkey | 385 | owner_pubkey |
| 381 | ); | 386 | ); |
| 382 | 387 | ||
| 383 | // Find the latest authorized state from owner's maintainer set | 388 | // Check purgatory for matching state events |
| 384 | match find_latest_authorized_state(&repo_data.states, &authorized) { | 389 | // Convert pushed refs to RefUpdate (filter out refs/nostr/* refs) |
| 385 | Some(state) => Ok(AuthorizationResult::authorized( | 390 | let pushed_updates: Vec<RefUpdate> = pushed_refs |
| 386 | state.clone(), | 391 | .iter() |
| 387 | authorized.into_iter().collect(), | 392 | .filter(|(_, _, name)| !name.starts_with("refs/nostr/")) |
| 388 | )), | 393 | .map(|(old_oid, new_oid, ref_name)| RefUpdate { |
| 389 | None => Ok(AuthorizationResult::denied( | 394 | old_oid: old_oid.clone(), |
| 390 | "No state event found from authorized publishers", | 395 | new_oid: new_oid.clone(), |
| 391 | )), | 396 | ref_name: ref_name.clone(), |
| 397 | }) | ||
| 398 | .collect(); | ||
| 399 | |||
| 400 | // Get local refs from repository | ||
| 401 | let local_refs_list = list_refs(repo_path).unwrap_or_default(); | ||
| 402 | let local_refs: HashMap<String, String> = local_refs_list.into_iter().collect(); | ||
| 403 | |||
| 404 | // Find matching state events in purgatory | ||
| 405 | let matching_events = purgatory.find_matching_states(identifier, &pushed_updates, &local_refs); | ||
| 406 | |||
| 407 | if !matching_events.is_empty() { | ||
| 408 | debug!( | ||
| 409 | "Found {} matching state event(s) in purgatory", | ||
| 410 | matching_events.len() | ||
| 411 | ); | ||
| 412 | |||
| 413 | // Filter to authorized events and collect them | ||
| 414 | let authorized_events: Vec<Event> = matching_events | ||
| 415 | .into_iter() | ||
| 416 | .filter(|event| { | ||
| 417 | let author_hex = event.pubkey.to_hex(); | ||
| 418 | authorized.contains(&author_hex) | ||
| 419 | }) | ||
| 420 | .collect(); | ||
| 421 | |||
| 422 | if !authorized_events.is_empty() { | ||
| 423 | // Find the latest event | ||
| 424 | let latest_authorized = authorized_events | ||
| 425 | .iter() | ||
| 426 | .max_by_key(|event| event.created_at) | ||
| 427 | .unwrap(); // Safe because we checked the vec is not empty | ||
| 428 | |||
| 429 | // Parse the event into RepositoryState | ||
| 430 | if let Ok(state) = RepositoryState::from_event(latest_authorized.clone()) { | ||
| 431 | info!( | ||
| 432 | "Authorized by state event {} from purgatory (author: {})", | ||
| 433 | latest_authorized.id, | ||
| 434 | latest_authorized | ||
| 435 | .pubkey | ||
| 436 | .to_bech32() | ||
| 437 | .unwrap_or_else(|_| latest_authorized.pubkey.to_hex()) | ||
| 438 | ); | ||
| 439 | |||
| 440 | return Ok(AuthorizationResult { | ||
| 441 | authorized: true, | ||
| 442 | reason: "Authorized by state event in purgatory".to_string(), | ||
| 443 | state: Some(state), | ||
| 444 | maintainers: authorized.into_iter().collect(), | ||
| 445 | purgatory_events: vec![latest_authorized.clone()], | ||
| 446 | }); | ||
| 447 | } else { | ||
| 448 | warn!( | ||
| 449 | "Failed to parse purgatory event {} as RepositoryState", | ||
| 450 | latest_authorized.id | ||
| 451 | ); | ||
| 452 | } | ||
| 453 | } else { | ||
| 454 | debug!("Purgatory events found but none from authorized authors"); | ||
| 455 | } | ||
| 456 | } else { | ||
| 457 | debug!("No matching state events found in purgatory"); | ||
| 392 | } | 458 | } |
| 459 | |||
| 460 | // No matching state found in purgatory | ||
| 461 | Ok(AuthorizationResult::denied( | ||
| 462 | "No state event found in purgatory from authorized publishers", | ||
| 463 | )) | ||
| 393 | } | 464 | } |
| 394 | 465 | ||
| 395 | /// Result of authorization check | 466 | /// Result of authorization check |
| @@ -403,6 +474,8 @@ pub struct AuthorizationResult { | |||
| 403 | pub state: Option<RepositoryState>, | 474 | pub state: Option<RepositoryState>, |
| 404 | /// The set of valid maintainers (authorized publishers) | 475 | /// The set of valid maintainers (authorized publishers) |
| 405 | pub maintainers: Vec<String>, | 476 | pub maintainers: Vec<String>, |
| 477 | /// Events from purgatory that authorized this push (state, PR, PR-update events) | ||
| 478 | pub purgatory_events: Vec<Event>, | ||
| 406 | } | 479 | } |
| 407 | 480 | ||
| 408 | impl AuthorizationResult { | 481 | impl AuthorizationResult { |
| @@ -413,6 +486,7 @@ impl AuthorizationResult { | |||
| 413 | reason: "Push matches latest authorized state".to_string(), | 486 | reason: "Push matches latest authorized state".to_string(), |
| 414 | state: Some(state), | 487 | state: Some(state), |
| 415 | maintainers, | 488 | maintainers, |
| 489 | purgatory_events: vec![], | ||
| 416 | } | 490 | } |
| 417 | } | 491 | } |
| 418 | 492 | ||
| @@ -423,6 +497,7 @@ impl AuthorizationResult { | |||
| 423 | reason: reason.into(), | 497 | reason: reason.into(), |
| 424 | state: None, | 498 | state: None, |
| 425 | maintainers: vec![], | 499 | maintainers: vec![], |
| 500 | purgatory_events: vec![], | ||
| 426 | } | 501 | } |
| 427 | } | 502 | } |
| 428 | } | 503 | } |
diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 8e5f5e1..df6f0e9 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs | |||
| @@ -4,20 +4,23 @@ | |||
| 4 | 4 | ||
| 5 | use http_body_util::Full; | 5 | use http_body_util::Full; |
| 6 | use hyper::{body::Bytes, Response, StatusCode}; | 6 | use hyper::{body::Bytes, Response, StatusCode}; |
| 7 | use nostr_sdk::prelude::*; | ||
| 7 | use std::path::PathBuf; | 8 | use std::path::PathBuf; |
| 9 | use std::sync::Arc; | ||
| 8 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | 10 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 9 | use tracing::{debug, error, info, warn}; | 11 | use tracing::{debug, error, info, warn}; |
| 10 | 12 | ||
| 11 | use super::authorization::{ | 13 | use super::authorization::{ |
| 12 | get_authorization_for_owner, parse_pushed_refs, validate_nostr_ref_pushes, validate_push_refs, | 14 | get_state_authorization_for_specific_owner_repo, parse_pushed_refs, validate_nostr_ref_pushes, |
| 13 | AuthorizationResult, | 15 | validate_push_refs, AuthorizationResult, |
| 14 | }; | 16 | }; |
| 15 | use super::protocol::{GitService, PktLine}; | 17 | use super::protocol::{GitService, PktLine}; |
| 16 | use super::subprocess::GitSubprocess; | 18 | use super::subprocess::GitSubprocess; |
| 17 | use super::try_set_head_if_available; | 19 | use super::try_set_head_if_available; |
| 18 | 20 | ||
| 19 | use crate::nostr::builder::SharedDatabase; | 21 | use crate::nostr::builder::SharedDatabase; |
| 20 | use crate::nostr::events::RepositoryState; | 22 | use crate::nostr::events::{RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_STATE}; |
| 23 | use crate::purgatory::Purgatory; | ||
| 21 | 24 | ||
| 22 | /// Handle GET /info/refs?service=git-{upload,receive}-pack | 25 | /// Handle GET /info/refs?service=git-{upload,receive}-pack |
| 23 | /// | 26 | /// |
| @@ -168,18 +171,24 @@ pub async fn handle_upload_pack( | |||
| 168 | /// Also per GRASP-01: "MUST set repository HEAD per repository state announcement | 171 | /// Also per GRASP-01: "MUST set repository HEAD per repository state announcement |
| 169 | /// as soon as the git data related to that branch has been received." | 172 | /// as soon as the git data related to that branch has been received." |
| 170 | /// | 173 | /// |
| 174 | /// Also purgatory GRASP-01: "Accepted repo state announcements, PRs and PR Updates | ||
| 175 | /// SHOULD be accepted with message "purgatory: won't be served until git data arrives" | ||
| 176 | /// and kepted in purgatory (not served) until the related git data arrives and | ||
| 177 | /// otherwise discarded after 30 minutes." | ||
| 178 | /// | ||
| 171 | /// # Arguments | 179 | /// # Arguments |
| 172 | /// * `repo_path` - Path to the bare git repository | 180 | /// * `repo_path` - Path to the bare git repository |
| 173 | /// * `request_body` - The git pack data from the client | 181 | /// * `request_body` - The git pack data from the client |
| 174 | /// * `database` - Optional database reference for authorization queries | 182 | /// * `database` - Database reference for authorization queries |
| 175 | /// * `identifier` - The repository identifier (d tag) for authorization lookup | 183 | /// * `identifier` - The repository identifier (d tag) for authorization lookup |
| 176 | /// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization | 184 | /// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization |
| 177 | pub async fn handle_receive_pack( | 185 | pub async fn handle_receive_pack( |
| 178 | repo_path: PathBuf, | 186 | repo_path: PathBuf, |
| 179 | request_body: Bytes, | 187 | request_body: Bytes, |
| 180 | database: Option<SharedDatabase>, | 188 | database: SharedDatabase, |
| 181 | identifier: &str, | 189 | identifier: &str, |
| 182 | owner_pubkey: &str, | 190 | owner_pubkey: &str, |
| 191 | purgatory: Arc<Purgatory>, | ||
| 183 | ) -> Result<Response<Full<Bytes>>, GitError> { | 192 | ) -> Result<Response<Full<Bytes>>, GitError> { |
| 184 | debug!("Handling receive-pack for {:?}", repo_path); | 193 | debug!("Handling receive-pack for {:?}", repo_path); |
| 185 | 194 | ||
| @@ -187,37 +196,46 @@ pub async fn handle_receive_pack( | |||
| 187 | return Err(GitError::RepositoryNotFound); | 196 | return Err(GitError::RepositoryNotFound); |
| 188 | } | 197 | } |
| 189 | 198 | ||
| 190 | // Keep track of state for HEAD setting after push | 199 | // Keep track of state and events for processing after push |
| 191 | let mut authorized_state: Option<RepositoryState> = None; | 200 | let mut authorized_state: Option<RepositoryState> = None; |
| 201 | let mut authorized_events: Vec<Event> = Vec::new(); | ||
| 192 | 202 | ||
| 193 | // GRASP Authorization Check (if database is provided) | 203 | // GRASP Authorization Check |
| 194 | if let Some(ref db) = database { | 204 | info!( |
| 195 | info!( | 205 | "Authorizing push for {} owned by {} via database query", |
| 196 | "Authorizing push for {} owned by {} via database query", | 206 | identifier, owner_pubkey |
| 197 | identifier, owner_pubkey | 207 | ); |
| 198 | ); | ||
| 199 | 208 | ||
| 200 | match authorize_push(db, identifier, owner_pubkey, &request_body).await { | 209 | match authorize_push( |
| 201 | Ok(auth_result) => { | 210 | &database, |
| 202 | if !auth_result.authorized { | 211 | identifier, |
| 203 | warn!("Push rejected for {}: {}", identifier, auth_result.reason); | 212 | owner_pubkey, |
| 204 | return Err(GitError::Unauthorized); | 213 | &request_body, |
| 205 | } | 214 | &purgatory, |
| 206 | info!( | 215 | &repo_path, |
| 207 | "Push authorized for {} - {} maintainers", | 216 | ) |
| 208 | identifier, | 217 | .await |
| 209 | auth_result.maintainers.len() | 218 | { |
| 210 | ); | 219 | Ok(auth_result) => { |
| 211 | // Save the state for HEAD setting after push | 220 | if !auth_result.authorized { |
| 212 | authorized_state = auth_result.state; | 221 | warn!("Push rejected for {}: {}", identifier, auth_result.reason); |
| 213 | } | ||
| 214 | Err(e) => { | ||
| 215 | warn!("Authorization check failed for {}: {}", identifier, e); | ||
| 216 | return Err(GitError::Unauthorized); | 222 | return Err(GitError::Unauthorized); |
| 217 | } | 223 | } |
| 224 | info!( | ||
| 225 | "Push authorized for {} - {} maintainers, {} purgatory events", | ||
| 226 | identifier, | ||
| 227 | auth_result.maintainers.len(), | ||
| 228 | auth_result.purgatory_events.len() | ||
| 229 | ); | ||
| 230 | // Save the state for HEAD setting after push | ||
| 231 | authorized_state = auth_result.state.clone(); | ||
| 232 | // Save the purgatory events for database saving after push | ||
| 233 | authorized_events = auth_result.purgatory_events; | ||
| 234 | } | ||
| 235 | Err(e) => { | ||
| 236 | warn!("Authorization check failed for {}: {}", identifier, e); | ||
| 237 | return Err(GitError::Unauthorized); | ||
| 218 | } | 238 | } |
| 219 | } else { | ||
| 220 | debug!("No database provided - accepting push without authorization"); | ||
| 221 | } | 239 | } |
| 222 | 240 | ||
| 223 | // Spawn git receive-pack | 241 | // Spawn git receive-pack |
| @@ -265,7 +283,7 @@ pub async fn handle_receive_pack( | |||
| 265 | // GRASP-01: Set HEAD after git data is received | 283 | // GRASP-01: Set HEAD after git data is received |
| 266 | // "MUST set repository HEAD per repository state announcement | 284 | // "MUST set repository HEAD per repository state announcement |
| 267 | // as soon as the git data related to that branch has been received." | 285 | // as soon as the git data related to that branch has been received." |
| 268 | if let Some(state) = authorized_state { | 286 | if let Some(ref state) = authorized_state { |
| 269 | if let Some(head_ref) = &state.head { | 287 | if let Some(head_ref) = &state.head { |
| 270 | if let Some(branch_name) = state.get_head_branch() { | 288 | if let Some(branch_name) = state.get_head_branch() { |
| 271 | if let Some(commit) = state.get_branch_commit(branch_name) { | 289 | if let Some(commit) = state.get_branch_commit(branch_name) { |
| @@ -288,6 +306,43 @@ pub async fn handle_receive_pack( | |||
| 288 | } | 306 | } |
| 289 | } | 307 | } |
| 290 | 308 | ||
| 309 | // Save all events from purgatory that authorized this push and remove them from purgatory | ||
| 310 | // This includes state events, PR events, and PR-update events | ||
| 311 | if !authorized_events.is_empty() { | ||
| 312 | info!( | ||
| 313 | "Saving {} purgatory event(s) to database after successful push", | ||
| 314 | authorized_events.len() | ||
| 315 | ); | ||
| 316 | |||
| 317 | for event in &authorized_events { | ||
| 318 | match database.save_event(event).await { | ||
| 319 | Ok(_) => { | ||
| 320 | info!("Saved purgatory event {} to database", event.id); | ||
| 321 | // TODO let broadcast_success = local_relay.notify_event(event.clone()); | ||
| 322 | warn!("TODO Here we need to broadcast on open websockets for live listeners. eventid; {}", event.id); | ||
| 323 | // Remove from purgatory based on event kind | ||
| 324 | if event.kind == Kind::from(KIND_REPOSITORY_STATE) { | ||
| 325 | purgatory.remove_state_event(identifier, &event.id); | ||
| 326 | info!("Removed state event {} from purgatory", event.id); | ||
| 327 | } else if event.kind == Kind::from(KIND_PR) | ||
| 328 | || event.kind == Kind::from(KIND_PR_UPDATE) | ||
| 329 | { | ||
| 330 | // Extract event ID from the event itself (it's the event.id) | ||
| 331 | let event_id_hex = event.id.to_hex(); | ||
| 332 | purgatory.remove_pr(&event_id_hex); | ||
| 333 | info!("Removed PR event {} from purgatory", event.id); | ||
| 334 | } | ||
| 335 | } | ||
| 336 | Err(e) => { | ||
| 337 | warn!( | ||
| 338 | "Failed to save purgatory event {} to database: {}", | ||
| 339 | event.id, e | ||
| 340 | ); | ||
| 341 | } | ||
| 342 | } | ||
| 343 | } | ||
| 344 | } | ||
| 345 | |||
| 291 | Ok(Response::builder() | 346 | Ok(Response::builder() |
| 292 | .status(StatusCode::OK) | 347 | .status(StatusCode::OK) |
| 293 | .header( | 348 | .header( |
| @@ -302,115 +357,175 @@ pub async fn handle_receive_pack( | |||
| 302 | /// Perform GRASP authorization for a push operation | 357 | /// Perform GRASP authorization for a push operation |
| 303 | /// | 358 | /// |
| 304 | /// This function queries the database directly (not via WebSocket): | 359 | /// This function queries the database directly (not via WebSocket): |
| 305 | /// 1. Fetches announcement and state events for the identifier | 360 | /// 1. Parses the pushed refs from the git pack protocol |
| 306 | /// 2. Filters to the specific owner's announcement | 361 | /// 2. Separates refs/nostr/ refs from normal refs |
| 307 | /// 3. Collects authorized publishers from that announcement (owner + maintainers) | 362 | /// 3. For normal refs: validates against state events in purgatory |
| 308 | /// 4. Gets the latest authorized state from those publishers | 363 | /// 4. For refs/nostr/ refs: validates event ID format and collects PR/PR-update events from purgatory |
| 309 | /// 5. Validates that pushed refs match the state | 364 | /// 5. Returns all authorizing events (state + PR/PR-update) in the result |
| 310 | /// 6. Validates refs/nostr/<event-id> has valid event id and if event exists, `c` tag matches ref | ||
| 311 | async fn authorize_push( | 365 | async fn authorize_push( |
| 312 | database: &SharedDatabase, | 366 | database: &SharedDatabase, |
| 313 | identifier: &str, | 367 | identifier: &str, |
| 314 | owner_pubkey: &str, | 368 | owner_pubkey: &str, |
| 315 | request_body: &Bytes, | 369 | request_body: &Bytes, |
| 370 | purgatory: &Arc<Purgatory>, | ||
| 371 | repo_path: &std::path::Path, | ||
| 316 | ) -> anyhow::Result<AuthorizationResult> { | 372 | ) -> anyhow::Result<AuthorizationResult> { |
| 317 | debug!( | 373 | debug!( |
| 318 | "Authorizing push for {} owned by {} via database query", | 374 | "Authorizing push for {} owned by {} via database query", |
| 319 | identifier, owner_pubkey | 375 | identifier, owner_pubkey |
| 320 | ); | 376 | ); |
| 321 | 377 | ||
| 322 | // Parse refs from the push request FIRST to check if this is a refs/nostr/ push | 378 | // Parse refs from the push request |
| 323 | let pushed_refs = parse_pushed_refs(request_body); | 379 | let pushed_refs = parse_pushed_refs(request_body); |
| 324 | debug!("Parsed {} refs from push request", pushed_refs.len()); | 380 | debug!("Parsed {} refs from push request", pushed_refs.len()); |
| 325 | for (old_oid, new_oid, ref_name) in &pushed_refs { | 381 | for (old_oid, new_oid, ref_name) in &pushed_refs { |
| 326 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); | 382 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); |
| 327 | } | 383 | } |
| 328 | 384 | ||
| 329 | // Separate refs/nostr/ refs from other refs | 385 | // Separate refs/nostr/ refs from state refs |
| 330 | // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`" | 386 | let (nostr_refs, state_refs): (Vec<_>, Vec<_>) = pushed_refs |
| 331 | let (nostr_refs, other_refs): (Vec<_>, Vec<_>) = pushed_refs | ||
| 332 | .iter() | 387 | .iter() |
| 333 | .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/")); | 388 | .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/")); |
| 334 | 389 | ||
| 335 | // Validate refs/nostr/ refs if any exist | 390 | // Collect all purgatory events that authorize this push |
| 391 | let mut purgatory_events = Vec::new(); | ||
| 392 | |||
| 393 | // Handle refs/nostr/ refs - validate and collect PR/PR-update events from purgatory | ||
| 336 | if !nostr_refs.is_empty() { | 394 | if !nostr_refs.is_empty() { |
| 337 | debug!( | 395 | debug!( |
| 338 | "Found {} refs/nostr/ refs - validating against events", | 396 | "Found {} refs/nostr/ refs - validating and collecting from purgatory", |
| 339 | nostr_refs.len() | 397 | nostr_refs.len() |
| 340 | ); | 398 | ); |
| 341 | 399 | ||
| 342 | // Validate refs/nostr/ pushes: checks event ID format and commit matching | 400 | for (_, new_oid, ref_name) in &nostr_refs { |
| 343 | let nostr_refs_owned: Vec<(String, String, String)> = nostr_refs | 401 | // Extract event ID from ref name |
| 344 | .into_iter() | 402 | if let Some(event_id_hex) = ref_name.strip_prefix("refs/nostr/") { |
| 345 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) | 403 | // Validate event ID format |
| 346 | .collect(); | 404 | if EventId::parse(event_id_hex).is_err() { |
| 347 | if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await { | 405 | warn!("Invalid event ID format in ref: {}", ref_name); |
| 348 | warn!("refs/nostr/ validation failed: {}", e); | 406 | return Ok(AuthorizationResult::denied(format!( |
| 349 | return Ok(AuthorizationResult::denied(format!( | 407 | "Invalid event ID format in ref: {}", |
| 350 | "refs/nostr/ validation failed: {}", | 408 | ref_name |
| 351 | e | 409 | ))); |
| 352 | ))); | 410 | } |
| 411 | |||
| 412 | // Check purgatory for PR event | ||
| 413 | if let Some(entry) = purgatory.find_pr(event_id_hex) { | ||
| 414 | if let Some(event) = entry.event { | ||
| 415 | // Verify commit matches | ||
| 416 | if entry.commit == *new_oid { | ||
| 417 | debug!( | ||
| 418 | "Found matching PR event {} in purgatory for ref {}", | ||
| 419 | event_id_hex, ref_name | ||
| 420 | ); | ||
| 421 | purgatory_events.push(event); | ||
| 422 | } else { | ||
| 423 | warn!( | ||
| 424 | "PR event {} in purgatory has commit mismatch: expected {}, got {}", | ||
| 425 | event_id_hex, entry.commit, new_oid | ||
| 426 | ); | ||
| 427 | return Ok(AuthorizationResult::denied(format!( | ||
| 428 | "PR event {} commit mismatch: expected {}, got {}", | ||
| 429 | event_id_hex, entry.commit, new_oid | ||
| 430 | ))); | ||
| 431 | } | ||
| 432 | } else { | ||
| 433 | // Placeholder exists - allow push (git-data-first scenario) | ||
| 434 | debug!( | ||
| 435 | "Found placeholder already for PR event {} in purgatory - as we dont have the event and therefore dont know the required commit_id we allow overwriting with a different commit_id", | ||
| 436 | event_id_hex | ||
| 437 | ); | ||
| 438 | } | ||
| 439 | } else { | ||
| 440 | // No entry in purgatory - check database for existing event | ||
| 441 | let nostr_refs_owned = vec![(String::new(), new_oid.clone(), ref_name.clone())]; | ||
| 442 | if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await { | ||
| 443 | warn!("refs/nostr/ validation failed: {}", e); | ||
| 444 | return Ok(AuthorizationResult::denied(format!( | ||
| 445 | "refs/nostr/ validation failed: {}", | ||
| 446 | e | ||
| 447 | ))); | ||
| 448 | } | ||
| 449 | debug!( | ||
| 450 | "No purgatory entry for {} - validated against database", | ||
| 451 | event_id_hex | ||
| 452 | ); | ||
| 453 | } | ||
| 454 | } | ||
| 353 | } | 455 | } |
| 354 | debug!("refs/nostr/ push validated successfully"); | ||
| 355 | } | 456 | } |
| 356 | 457 | ||
| 357 | // If only refs/nostr/ refs, we're done - return success | 458 | // Handle normal refs - validate against state events |
| 358 | if other_refs.is_empty() { | 459 | if !state_refs.is_empty() { |
| 359 | debug!("Only refs/nostr/ refs in push - authorization complete"); | 460 | debug!( |
| 360 | return Ok(AuthorizationResult { | 461 | "Found {} non-refs/nostr/ refs - checking state authorization", |
| 361 | authorized: true, | 462 | state_refs.len() |
| 362 | reason: "Push to refs/nostr/ validated against events".to_string(), | 463 | ); |
| 363 | state: None, | ||
| 364 | maintainers: vec![], | ||
| 365 | }); | ||
| 366 | } | ||
| 367 | 464 | ||
| 368 | // For non-refs/nostr/ refs, require state validation | 465 | let auth_result = get_state_authorization_for_specific_owner_repo( |
| 369 | debug!( | 466 | database, |
| 370 | "Found {} non-refs/nostr/ refs - checking state authorization", | 467 | identifier, |
| 371 | other_refs.len() | 468 | owner_pubkey, |
| 372 | ); | 469 | purgatory, |
| 373 | let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; | 470 | &pushed_refs, //it would be better to accept state_refs but thats in different format |
| 471 | repo_path, | ||
| 472 | ) | ||
| 473 | .await?; | ||
| 374 | 474 | ||
| 375 | if !auth_result.authorized { | 475 | if !auth_result.authorized { |
| 376 | return Ok(auth_result); | 476 | return Ok(auth_result); |
| 377 | } | 477 | } |
| 378 | 478 | ||
| 379 | // Convert other_refs for validation | 479 | // Collect state events from purgatory |
| 380 | let other_refs_owned: Vec<(String, String, String)> = other_refs | 480 | purgatory_events.extend(auth_result.purgatory_events); |
| 381 | .into_iter() | ||
| 382 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) | ||
| 383 | .collect(); | ||
| 384 | 481 | ||
| 385 | // Validate non-refs/nostr/ refs against state | 482 | // Validate refs against state |
| 386 | if let Some(ref state) = auth_result.state { | 483 | let other_refs_owned: Vec<(String, String, String)> = state_refs |
| 387 | debug!( | 484 | .into_iter() |
| 388 | "Validating against state with {} branches", | 485 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) |
| 389 | state.branches.len() | 486 | .collect(); |
| 390 | ); | ||
| 391 | 487 | ||
| 392 | // If we have a state event but couldn't parse any refs, reject the push. | 488 | if let Some(ref state) = auth_result.state { |
| 393 | // This protects against parsing failures allowing unauthorized pushes. | 489 | debug!( |
| 394 | if other_refs_owned.is_empty() && !state.branches.is_empty() { | 490 | "Validating against state with {} branches", |
| 395 | warn!("No refs parsed from push request but state event has branches - rejecting"); | 491 | state.branches.len() |
| 396 | return Ok(AuthorizationResult::denied( | 492 | ); |
| 397 | "Failed to parse refs from push request - cannot validate against state", | 493 | |
| 398 | )); | 494 | if other_refs_owned.is_empty() && !state.branches.is_empty() { |
| 399 | } | 495 | warn!("No refs parsed from push request but state event has branches - rejecting"); |
| 496 | return Ok(AuthorizationResult::denied( | ||
| 497 | "Failed to parse refs from push request - cannot validate against state", | ||
| 498 | )); | ||
| 499 | } | ||
| 400 | 500 | ||
| 401 | if let Err(e) = validate_push_refs(state, &other_refs_owned) { | 501 | if let Err(e) = validate_push_refs(state, &other_refs_owned) { |
| 402 | warn!("Ref validation failed: {}", e); | 502 | warn!("Ref validation failed: {}", e); |
| 403 | return Ok(AuthorizationResult::denied(format!( | 503 | return Ok(AuthorizationResult::denied(format!( |
| 404 | "Ref validation failed: {}", | 504 | "Ref validation failed: {}", |
| 405 | e | 505 | e |
| 406 | ))); | 506 | ))); |
| 507 | } | ||
| 508 | debug!("Ref validation passed"); | ||
| 407 | } | 509 | } |
| 408 | debug!("Ref validation passed"); | 510 | |
| 409 | } else { | 511 | // Return result with purgatory events |
| 410 | warn!("No state in auth_result - cannot validate refs"); | 512 | return Ok(AuthorizationResult { |
| 513 | authorized: true, | ||
| 514 | reason: auth_result.reason, | ||
| 515 | state: auth_result.state, | ||
| 516 | maintainers: auth_result.maintainers, | ||
| 517 | purgatory_events, | ||
| 518 | }); | ||
| 411 | } | 519 | } |
| 412 | 520 | ||
| 413 | Ok(auth_result) | 521 | // Only refs/nostr/ refs - return success with collected events |
| 522 | Ok(AuthorizationResult { | ||
| 523 | authorized: true, | ||
| 524 | reason: "Push to refs/nostr/ validated".to_string(), | ||
| 525 | state: None, | ||
| 526 | maintainers: vec![], | ||
| 527 | purgatory_events, | ||
| 528 | }) | ||
| 414 | } | 529 | } |
| 415 | 530 | ||
| 416 | /// Errors that can occur in Git handlers | 531 | /// Errors that can occur in Git handlers |
diff --git a/src/git/mod.rs b/src/git/mod.rs index 599a94b..5c99b3e 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs | |||
| @@ -340,6 +340,74 @@ pub fn validate_nostr_ref( | |||
| 340 | Ok(true) | 340 | Ok(true) |
| 341 | } | 341 | } |
| 342 | 342 | ||
| 343 | /// Clean up placeholder refs from all repositories on shutdown. | ||
| 344 | /// | ||
| 345 | /// Walks through all git repositories in the git_data_path and deletes | ||
| 346 | /// `refs/nostr/<event-id>` refs for the given event IDs. This is called | ||
| 347 | /// on shutdown to clean up placeholders created when git data arrived | ||
| 348 | /// before the corresponding PR event. | ||
| 349 | /// | ||
| 350 | /// # Arguments | ||
| 351 | /// * `git_data_path` - Base directory containing git repositories | ||
| 352 | /// * `event_ids` - Event IDs whose refs/nostr/ refs should be deleted | ||
| 353 | /// | ||
| 354 | /// # Returns | ||
| 355 | /// Number of refs successfully deleted | ||
| 356 | pub fn cleanup_placeholder_refs(git_data_path: &str, event_ids: &[String]) -> usize { | ||
| 357 | if event_ids.is_empty() { | ||
| 358 | return 0; | ||
| 359 | } | ||
| 360 | |||
| 361 | let git_path = PathBuf::from(git_data_path); | ||
| 362 | if !git_path.exists() { | ||
| 363 | debug!("Git data path does not exist: {}", git_data_path); | ||
| 364 | return 0; | ||
| 365 | } | ||
| 366 | |||
| 367 | let mut deleted_count = 0; | ||
| 368 | |||
| 369 | // Walk through all repositories (npub/repo.git structure) | ||
| 370 | if let Ok(npub_entries) = std::fs::read_dir(&git_path) { | ||
| 371 | for npub_entry in npub_entries.flatten() { | ||
| 372 | if !npub_entry.path().is_dir() { | ||
| 373 | continue; | ||
| 374 | } | ||
| 375 | |||
| 376 | // For each npub directory, check repos | ||
| 377 | if let Ok(repo_entries) = std::fs::read_dir(npub_entry.path()) { | ||
| 378 | for repo_entry in repo_entries.flatten() { | ||
| 379 | let repo_path = repo_entry.path(); | ||
| 380 | if !repo_path.is_dir() || !repo_path.to_string_lossy().ends_with(".git") { | ||
| 381 | continue; | ||
| 382 | } | ||
| 383 | |||
| 384 | // Try to delete refs/nostr/<event-id> for each placeholder event | ||
| 385 | for event_id in event_ids { | ||
| 386 | let ref_name = format!("refs/nostr/{}", event_id); | ||
| 387 | if delete_ref(&repo_path, &ref_name).is_ok() { | ||
| 388 | deleted_count += 1; | ||
| 389 | info!( | ||
| 390 | "Cleaned up placeholder ref {} from {}", | ||
| 391 | ref_name, | ||
| 392 | repo_path.display() | ||
| 393 | ); | ||
| 394 | } | ||
| 395 | } | ||
| 396 | } | ||
| 397 | } | ||
| 398 | } | ||
| 399 | } | ||
| 400 | |||
| 401 | if deleted_count > 0 { | ||
| 402 | info!( | ||
| 403 | "Shutdown cleanup: removed {} placeholder refs from git repositories", | ||
| 404 | deleted_count | ||
| 405 | ); | ||
| 406 | } | ||
| 407 | |||
| 408 | deleted_count | ||
| 409 | } | ||
| 410 | |||
| 343 | /// Get the current HEAD ref from a repository | 411 | /// Get the current HEAD ref from a repository |
| 344 | /// | 412 | /// |
| 345 | /// # Arguments | 413 | /// # Arguments |
diff --git a/src/http/mod.rs b/src/http/mod.rs index 91a6067..d62cc4a 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs | |||
| @@ -27,6 +27,7 @@ use crate::config::Config; | |||
| 27 | use crate::git; | 27 | use crate::git; |
| 28 | use crate::metrics::Metrics; | 28 | use crate::metrics::Metrics; |
| 29 | use crate::nostr::builder::SharedDatabase; | 29 | use crate::nostr::builder::SharedDatabase; |
| 30 | use crate::purgatory::Purgatory; | ||
| 30 | 31 | ||
| 31 | /// CORS headers required by GRASP-01 specification (lines 40-47) | 32 | /// CORS headers required by GRASP-01 specification (lines 40-47) |
| 32 | const CORS_ALLOW_ORIGIN: &str = "*"; | 33 | const CORS_ALLOW_ORIGIN: &str = "*"; |
| @@ -94,6 +95,8 @@ struct HttpService { | |||
| 94 | database: SharedDatabase, | 95 | database: SharedDatabase, |
| 95 | /// Optional metrics for Prometheus endpoint | 96 | /// Optional metrics for Prometheus endpoint |
| 96 | metrics: Option<Arc<Metrics>>, | 97 | metrics: Option<Arc<Metrics>>, |
| 98 | /// Purgatory for event/git coordination | ||
| 99 | purgatory: Arc<Purgatory>, | ||
| 97 | } | 100 | } |
| 98 | 101 | ||
| 99 | impl HttpService { | 102 | impl HttpService { |
| @@ -103,6 +106,7 @@ impl HttpService { | |||
| 103 | remote: SocketAddr, | 106 | remote: SocketAddr, |
| 104 | database: SharedDatabase, | 107 | database: SharedDatabase, |
| 105 | metrics: Option<Arc<Metrics>>, | 108 | metrics: Option<Arc<Metrics>>, |
| 109 | purgatory: Arc<Purgatory>, | ||
| 106 | ) -> Self { | 110 | ) -> Self { |
| 107 | Self { | 111 | Self { |
| 108 | relay, | 112 | relay, |
| @@ -110,6 +114,7 @@ impl HttpService { | |||
| 110 | remote, | 114 | remote, |
| 111 | database, | 115 | database, |
| 112 | metrics, | 116 | metrics, |
| 117 | purgatory, | ||
| 113 | } | 118 | } |
| 114 | } | 119 | } |
| 115 | } | 120 | } |
| @@ -126,6 +131,7 @@ impl Service<Request<Incoming>> for HttpService { | |||
| 126 | let method = req.method().clone(); | 131 | let method = req.method().clone(); |
| 127 | let git_data_path = self.config.effective_git_data_path(); | 132 | let git_data_path = self.config.effective_git_data_path(); |
| 128 | let database = self.database.clone(); | 133 | let database = self.database.clone(); |
| 134 | let purgatory = self.purgatory.clone(); | ||
| 129 | 135 | ||
| 130 | // Handle OPTIONS preflight requests (CORS) | 136 | // Handle OPTIONS preflight requests (CORS) |
| 131 | // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content | 137 | // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content |
| @@ -225,9 +231,10 @@ impl Service<Request<Incoming>> for HttpService { | |||
| 225 | let result = git::handlers::handle_receive_pack( | 231 | let result = git::handlers::handle_receive_pack( |
| 226 | repo_path, | 232 | repo_path, |
| 227 | body_bytes.clone(), | 233 | body_bytes.clone(), |
| 228 | Some(database.clone()), | 234 | database.clone(), |
| 229 | &identifier, | 235 | &identifier, |
| 230 | &owner_pubkey_hex, | 236 | &owner_pubkey_hex, |
| 237 | purgatory.clone(), | ||
| 231 | ) | 238 | ) |
| 232 | .await; | 239 | .await; |
| 233 | 240 | ||
| @@ -497,6 +504,7 @@ pub async fn run_server( | |||
| 497 | relay: LocalRelay, | 504 | relay: LocalRelay, |
| 498 | database: SharedDatabase, | 505 | database: SharedDatabase, |
| 499 | metrics: Option<Arc<Metrics>>, | 506 | metrics: Option<Arc<Metrics>>, |
| 507 | purgatory: Arc<Purgatory>, | ||
| 500 | ) -> anyhow::Result<()> { | 508 | ) -> anyhow::Result<()> { |
| 501 | let bind_addr: SocketAddr = config.bind_address.parse()?; | 509 | let bind_addr: SocketAddr = config.bind_address.parse()?; |
| 502 | 510 | ||
| @@ -515,6 +523,7 @@ pub async fn run_server( | |||
| 515 | addr, | 523 | addr, |
| 516 | database.clone(), | 524 | database.clone(), |
| 517 | metrics.clone(), | 525 | metrics.clone(), |
| 526 | purgatory.clone(), | ||
| 518 | ); | 527 | ); |
| 519 | 528 | ||
| 520 | tokio::spawn(async move { | 529 | tokio::spawn(async move { |
| @@ -3,4 +3,5 @@ pub mod git; | |||
| 3 | pub mod http; | 3 | pub mod http; |
| 4 | pub mod metrics; | 4 | pub mod metrics; |
| 5 | pub mod nostr; | 5 | pub mod nostr; |
| 6 | pub mod purgatory; | ||
| 6 | pub mod sync; | 7 | pub mod sync; |
diff --git a/src/main.rs b/src/main.rs index ddb198e..e39c1ab 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -1,14 +1,17 @@ | |||
| 1 | use std::sync::Arc; | 1 | use std::sync::Arc; |
| 2 | use std::time::Duration; | ||
| 2 | 3 | ||
| 3 | use anyhow::Result; | 4 | use anyhow::Result; |
| 5 | use tokio::signal; | ||
| 4 | use tracing::{info, Level}; | 6 | use tracing::{info, Level}; |
| 5 | use tracing_subscriber::FmtSubscriber; | 7 | use tracing_subscriber::FmtSubscriber; |
| 6 | 8 | ||
| 7 | use ngit_grasp::{ | 9 | use ngit_grasp::{ |
| 8 | config::{Config, DatabaseBackend}, | 10 | config::{Config, DatabaseBackend}, |
| 9 | http, | 11 | git, http, |
| 10 | metrics::Metrics, | 12 | metrics::Metrics, |
| 11 | nostr, | 13 | nostr, |
| 14 | purgatory::Purgatory, | ||
| 12 | sync::SyncManager, | 15 | sync::SyncManager, |
| 13 | }; | 16 | }; |
| 14 | 17 | ||
| @@ -45,9 +48,13 @@ async fn main() -> Result<()> { | |||
| 45 | None | 48 | None |
| 46 | }; | 49 | }; |
| 47 | 50 | ||
| 51 | // Create purgatory for event/git coordination | ||
| 52 | let purgatory = Arc::new(Purgatory::new()); | ||
| 53 | info!("Purgatory initialized for event coordination"); | ||
| 54 | |||
| 48 | // Create Nostr relay with NIP-34 validation | 55 | // Create Nostr relay with NIP-34 validation |
| 49 | // Returns both the relay and database for direct queries in handlers | 56 | // Returns both the relay and database for direct queries in handlers |
| 50 | if let Ok(relay_with_db) = nostr::builder::create_relay(&config).await { | 57 | if let Ok(relay_with_db) = nostr::builder::create_relay(&config, purgatory.clone()).await { |
| 51 | info!( | 58 | info!( |
| 52 | "Relay created with NIP-34 validation for domain: {}", | 59 | "Relay created with NIP-34 validation for domain: {}", |
| 53 | config.domain | 60 | config.domain |
| @@ -79,9 +86,57 @@ async fn main() -> Result<()> { | |||
| 79 | sync_manager.run().await; | 86 | sync_manager.run().await; |
| 80 | }); | 87 | }); |
| 81 | 88 | ||
| 89 | // Spawn background cleanup task | ||
| 90 | let cleanup_purgatory = purgatory.clone(); | ||
| 91 | tokio::spawn(async move { | ||
| 92 | let mut interval = tokio::time::interval(Duration::from_secs(60)); | ||
| 93 | loop { | ||
| 94 | interval.tick().await; | ||
| 95 | let (state_removed, pr_removed) = cleanup_purgatory.cleanup(); | ||
| 96 | if state_removed > 0 || pr_removed > 0 { | ||
| 97 | info!( | ||
| 98 | "Purgatory cleanup: removed {} state events, {} PR events", | ||
| 99 | state_removed, pr_removed | ||
| 100 | ); | ||
| 101 | } | ||
| 102 | } | ||
| 103 | }); | ||
| 104 | info!("Purgatory cleanup task started (60s interval)"); | ||
| 105 | |||
| 106 | // Setup shutdown handler for purgatory cleanup | ||
| 107 | let shutdown_purgatory = purgatory.clone(); | ||
| 108 | let git_data_path = config.effective_git_data_path(); | ||
| 109 | |||
| 82 | // Start HTTP server with integrated relay and database | 110 | // Start HTTP server with integrated relay and database |
| 83 | info!("Starting HTTP server on {}", config.bind_address); | 111 | info!("Starting HTTP server on {}", config.bind_address); |
| 84 | http::run_server(config, relay_with_db.relay, relay_with_db.database, metrics).await?; | 112 | |
| 113 | // Run server until shutdown signal, then cleanup | ||
| 114 | tokio::select! { | ||
| 115 | result = http::run_server( | ||
| 116 | config, | ||
| 117 | relay_with_db.relay, | ||
| 118 | relay_with_db.database, | ||
| 119 | metrics, | ||
| 120 | purgatory, | ||
| 121 | ) => { | ||
| 122 | if let Err(e) = result { | ||
| 123 | return Err(e); | ||
| 124 | } | ||
| 125 | } | ||
| 126 | _ = signal::ctrl_c() => { | ||
| 127 | info!("Received shutdown signal, cleaning up..."); | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | // Cleanup placeholder refs on shutdown | ||
| 132 | let placeholder_ids = shutdown_purgatory.get_placeholder_event_ids(); | ||
| 133 | if !placeholder_ids.is_empty() { | ||
| 134 | info!( | ||
| 135 | "Cleaning up {} placeholder refs/nostr/ refs on shutdown", | ||
| 136 | placeholder_ids.len() | ||
| 137 | ); | ||
| 138 | git::cleanup_placeholder_refs(&git_data_path, &placeholder_ids); | ||
| 139 | } | ||
| 85 | } | 140 | } |
| 86 | 141 | ||
| 87 | Ok(()) | 142 | Ok(()) |
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 8dd6291..2b4d524 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs | |||
| @@ -13,7 +13,7 @@ use nostr_relay_builder::prelude::*; | |||
| 13 | 13 | ||
| 14 | use crate::config::{Config, DatabaseBackend}; | 14 | use crate::config::{Config, DatabaseBackend}; |
| 15 | use crate::nostr::events::{ | 15 | use crate::nostr::events::{ |
| 16 | RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, | 16 | RepositoryAnnouncement, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, |
| 17 | KIND_REPOSITORY_STATE, KIND_USER_GRASP_LIST, | 17 | KIND_REPOSITORY_STATE, KIND_USER_GRASP_LIST, |
| 18 | }; | 18 | }; |
| 19 | use crate::nostr::policy::{ | 19 | use crate::nostr::policy::{ |
| @@ -57,8 +57,9 @@ impl Nip34WritePolicy { | |||
| 57 | domain: impl Into<String>, | 57 | domain: impl Into<String>, |
| 58 | database: SharedDatabase, | 58 | database: SharedDatabase, |
| 59 | git_data_path: impl Into<std::path::PathBuf>, | 59 | git_data_path: impl Into<std::path::PathBuf>, |
| 60 | purgatory: std::sync::Arc<crate::purgatory::Purgatory>, | ||
| 60 | ) -> Self { | 61 | ) -> Self { |
| 61 | let ctx = PolicyContext::new(domain, database, git_data_path); | 62 | let ctx = PolicyContext::new(domain, database, git_data_path, purgatory); |
| 62 | Self { | 63 | Self { |
| 63 | announcement_policy: AnnouncementPolicy::new(ctx.clone()), | 64 | announcement_policy: AnnouncementPolicy::new(ctx.clone()), |
| 64 | state_policy: StatePolicy::new(ctx.clone()), | 65 | state_policy: StatePolicy::new(ctx.clone()), |
| @@ -143,21 +144,50 @@ impl Nip34WritePolicy { | |||
| 143 | 144 | ||
| 144 | match self.state_policy.validate(event) { | 145 | match self.state_policy.validate(event) { |
| 145 | StateResult::Accept => { | 146 | StateResult::Accept => { |
| 146 | // Parse state to get HEAD and branch info | 147 | // Parse state to get identifier for purgatory message |
| 147 | match RepositoryState::from_event(event.clone()) { | 148 | let identifier = event |
| 148 | Ok(_state) => { | 149 | .tags |
| 149 | // Process state alignment asynchronously | 150 | .iter() |
| 150 | if let Err(e) = self.state_policy.process_state_event(event).await { | 151 | .find_map(|tag| { |
| 151 | tracing::warn!("Failed to process state event {}: {}", event_id_str, e); | 152 | let tag_vec = tag.clone().to_vec(); |
| 153 | if tag_vec.len() >= 2 && tag_vec[0] == "d" { | ||
| 154 | Some(tag_vec[1].clone()) | ||
| 155 | } else { | ||
| 156 | None | ||
| 152 | } | 157 | } |
| 158 | }) | ||
| 159 | .unwrap_or_else(|| "unknown".to_string()); | ||
| 153 | 160 | ||
| 154 | tracing::debug!("Accepted repository state: {}", event_id_str); | 161 | // Process state alignment asynchronously |
| 162 | match self.state_policy.process_state_event(event).await { | ||
| 163 | Ok(0) => { | ||
| 164 | // No repos aligned - event was added to purgatory | ||
| 165 | tracing::info!( | ||
| 166 | "State event {} added to purgatory: waiting for git data for identifier {}", | ||
| 167 | event_id_str, | ||
| 168 | identifier | ||
| 169 | ); | ||
| 170 | WritePolicyResult::Reject { | ||
| 171 | status: true, // Client sees OK | ||
| 172 | message: format!( | ||
| 173 | "purgatory: state event stored, waiting for git push for {}", | ||
| 174 | identifier | ||
| 175 | ) | ||
| 176 | .into(), | ||
| 177 | } | ||
| 178 | } | ||
| 179 | Ok(count) => { | ||
| 180 | // Successfully aligned repos | ||
| 181 | tracing::debug!( | ||
| 182 | "Accepted repository state {}: aligned {} repo(s)", | ||
| 183 | event_id_str, | ||
| 184 | count | ||
| 185 | ); | ||
| 155 | WritePolicyResult::Accept | 186 | WritePolicyResult::Accept |
| 156 | } | 187 | } |
| 157 | Err(e) => { | 188 | Err(e) => { |
| 158 | tracing::warn!("Failed to parse repository state {}: {}", event_id_str, e); | 189 | tracing::warn!("Failed to process state event {}: {}", event_id_str, e); |
| 159 | // Still accept the event even if we can't parse it | 190 | // Still accept the event even if processing failed |
| 160 | // The validation passed, so it's structurally valid | ||
| 161 | WritePolicyResult::Accept | 191 | WritePolicyResult::Accept |
| 162 | } | 192 | } |
| 163 | } | 193 | } |
| @@ -173,6 +203,58 @@ impl Nip34WritePolicy { | |||
| 173 | async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult { | 203 | async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult { |
| 174 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); | 204 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); |
| 175 | 205 | ||
| 206 | // Check if git data exists (checks placeholders and commit existence) | ||
| 207 | match self.pr_event_policy.check_git_data_exists(event).await { | ||
| 208 | Ok(false) => { | ||
| 209 | // No git data exists - add to purgatory | ||
| 210 | let commit = event | ||
| 211 | .tags | ||
| 212 | .iter() | ||
| 213 | .find_map(|tag| { | ||
| 214 | let tag_vec = tag.clone().to_vec(); | ||
| 215 | if tag_vec.len() >= 2 && tag_vec[0] == "c" { | ||
| 216 | Some(tag_vec[1].clone()) | ||
| 217 | } else { | ||
| 218 | None | ||
| 219 | } | ||
| 220 | }) | ||
| 221 | .unwrap_or_else(|| "unknown".to_string()); | ||
| 222 | |||
| 223 | tracing::info!( | ||
| 224 | "PR event {} added to purgatory: waiting for git push with commit {}", | ||
| 225 | event_id_str, | ||
| 226 | commit | ||
| 227 | ); | ||
| 228 | |||
| 229 | // Add to purgatory | ||
| 230 | self.ctx | ||
| 231 | .purgatory | ||
| 232 | .add_pr(event.clone(), event.id.to_hex(), commit.clone()); | ||
| 233 | |||
| 234 | return WritePolicyResult::Reject { | ||
| 235 | status: true, // Client sees OK | ||
| 236 | message: format!( | ||
| 237 | "purgatory: PR event stored, waiting for git push with commit {}", | ||
| 238 | commit | ||
| 239 | ) | ||
| 240 | .into(), | ||
| 241 | }; | ||
| 242 | } | ||
| 243 | Ok(true) => { | ||
| 244 | // Git data exists - proceed with normal validation | ||
| 245 | tracing::debug!("Git data exists for PR event {}", event_id_str); | ||
| 246 | } | ||
| 247 | Err(e) => { | ||
| 248 | // Error checking git data - reject event | ||
| 249 | tracing::warn!( | ||
| 250 | "Failed to check git data for PR event {}: {}", | ||
| 251 | event_id_str, | ||
| 252 | e | ||
| 253 | ); | ||
| 254 | return WritePolicyResult::reject(format!("Failed to check git data: {}", e)); | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 176 | // Validate refs/nostr refs for this PR event | 258 | // Validate refs/nostr refs for this PR event |
| 177 | // This deletes any refs/nostr/<event-id> that points to wrong commit | 259 | // This deletes any refs/nostr/<event-id> that points to wrong commit |
| 178 | if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await { | 260 | if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await { |
| @@ -289,7 +371,10 @@ pub struct RelayWithDatabase { | |||
| 289 | /// Returns a `RelayWithDatabase` struct containing: | 371 | /// Returns a `RelayWithDatabase` struct containing: |
| 290 | /// - The `LocalRelay` for handling WebSocket connections | 372 | /// - The `LocalRelay` for handling WebSocket connections |
| 291 | /// - The `SharedDatabase` for direct database queries (e.g., push authorization) | 373 | /// - The `SharedDatabase` for direct database queries (e.g., push authorization) |
| 292 | pub async fn create_relay(config: &Config) -> Result<RelayWithDatabase> { | 374 | pub async fn create_relay( |
| 375 | config: &Config, | ||
| 376 | purgatory: Arc<crate::purgatory::Purgatory>, | ||
| 377 | ) -> Result<RelayWithDatabase> { | ||
| 293 | tracing::info!("Configuring nostr relay with GRASP-01 validation..."); | 378 | tracing::info!("Configuring nostr relay with GRASP-01 validation..."); |
| 294 | 379 | ||
| 295 | // Determine database path | 380 | // Determine database path |
| @@ -337,7 +422,10 @@ pub async fn create_relay(config: &Config) -> Result<RelayWithDatabase> { | |||
| 337 | // Build relay with GRASP-01 validation | 422 | // Build relay with GRASP-01 validation |
| 338 | // Clone Arc for the write policy so both relay and policy can access the database | 423 | // Clone Arc for the write policy so both relay and policy can access the database |
| 339 | let git_data_path = config.effective_git_data_path(); | 424 | let git_data_path = config.effective_git_data_path(); |
| 340 | let write_policy = Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path); | 425 | |
| 426 | // Create write policy with purgatory integration | ||
| 427 | let write_policy = | ||
| 428 | Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path, purgatory); | ||
| 341 | 429 | ||
| 342 | let relay = LocalRelayBuilder::default() | 430 | let relay = LocalRelayBuilder::default() |
| 343 | .database(database.clone()) | 431 | .database(database.clone()) |
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 |
diff --git a/src/purgatory/helpers.rs b/src/purgatory/helpers.rs new file mode 100644 index 0000000..5df6cc8 --- /dev/null +++ b/src/purgatory/helpers.rs | |||
| @@ -0,0 +1,435 @@ | |||
| 1 | //! Helper functions for purgatory state event processing. | ||
| 2 | //! | ||
| 3 | //! These functions handle the late-binding extraction and matching of git refs | ||
| 4 | //! from state events. Refs are extracted at git push time rather than event | ||
| 5 | //! arrival time to enable flexible matching logic. | ||
| 6 | |||
| 7 | use super::{RefPair, RefUpdate}; | ||
| 8 | use nostr_sdk::prelude::*; | ||
| 9 | use std::collections::HashMap; | ||
| 10 | |||
| 11 | /// Extract ref pairs from a state event (kind 30618). | ||
| 12 | /// | ||
| 13 | /// Parses all `refs/heads/*` and `refs/tags/*` tags from the event, | ||
| 14 | /// creating RefPair instances with the full ref name and target object SHA. | ||
| 15 | /// | ||
| 16 | /// # Arguments | ||
| 17 | /// * `event` - The state event to extract refs from | ||
| 18 | /// | ||
| 19 | /// # Returns | ||
| 20 | /// Vector of RefPair instances, one for each ref tag found | ||
| 21 | /// | ||
| 22 | /// # Tag Format | ||
| 23 | /// State events use custom tags where the tag kind is the ref name: | ||
| 24 | /// - Tag kind: "refs/heads/main" or "refs/tags/v1.0" | ||
| 25 | /// - First value: commit SHA or annotated tag SHA | ||
| 26 | /// | ||
| 27 | /// # Example | ||
| 28 | /// ```ignore | ||
| 29 | /// // Event with tags: | ||
| 30 | /// // ["refs/heads/main", "abc123..."] | ||
| 31 | /// // ["refs/tags/v1.0", "def456..."] | ||
| 32 | /// let refs = extract_refs_from_state(&event); | ||
| 33 | /// // Returns: [ | ||
| 34 | /// // RefPair { ref_name: "refs/heads/main", object_sha: "abc123..." }, | ||
| 35 | /// // RefPair { ref_name: "refs/tags/v1.0", object_sha: "def456..." } | ||
| 36 | /// // ] | ||
| 37 | /// ``` | ||
| 38 | pub fn extract_refs_from_state(event: &Event) -> Vec<RefPair> { | ||
| 39 | event | ||
| 40 | .tags | ||
| 41 | .iter() | ||
| 42 | .filter_map(|tag| { | ||
| 43 | // Check if this is a custom tag with a ref name | ||
| 44 | if let TagKind::Custom(ref_name) = tag.kind() { | ||
| 45 | let ref_str = ref_name.as_ref(); | ||
| 46 | |||
| 47 | // Only process refs/heads/* and refs/tags/* | ||
| 48 | if ref_str.starts_with("refs/heads/") || ref_str.starts_with("refs/tags/") { | ||
| 49 | // Get the object SHA (first value in tag) | ||
| 50 | let parts = tag.clone().to_vec(); | ||
| 51 | if parts.len() >= 2 { | ||
| 52 | return Some(RefPair { | ||
| 53 | ref_name: ref_str.to_string(), | ||
| 54 | object_sha: parts[1].clone(), | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | None | ||
| 60 | }) | ||
| 61 | .collect() | ||
| 62 | } | ||
| 63 | |||
| 64 | /// Check if a state event can be satisfied by ref updates plus local refs. | ||
| 65 | /// | ||
| 66 | /// Returns true if applying the ref updates to local state results in exactly | ||
| 67 | /// the state declared in the event. This means: | ||
| 68 | /// 1. Filter local_refs to only branches (refs/heads/*) and tags (refs/tags/*) | ||
| 69 | /// 2. Apply pushed_updates to create a "would-be" state | ||
| 70 | /// 3. Compare would-be state with event's declared state - must match exactly | ||
| 71 | /// | ||
| 72 | /// This implements correct authorization: the push must transform local state | ||
| 73 | /// into the declared state, accounting for additions, deletions, and modifications. | ||
| 74 | /// | ||
| 75 | /// # Arguments | ||
| 76 | /// * `event` - The state event to check | ||
| 77 | /// * `pushed_updates` - Ref updates in the current push operation | ||
| 78 | /// * `local_refs` - Refs already existing locally (ref_name -> SHA) | ||
| 79 | /// | ||
| 80 | /// # Returns | ||
| 81 | /// true if push transforms local state into declared state, false otherwise | ||
| 82 | /// | ||
| 83 | /// # Example | ||
| 84 | /// ```ignore | ||
| 85 | /// // State event declares: refs/heads/main@abc123 | ||
| 86 | /// // Local: refs/heads/main@old123, refs/heads/dev@def456 | ||
| 87 | /// // Push updates: main old123->abc123, dev def456->0000 (delete) | ||
| 88 | /// // Result: false (event doesn't declare dev deletion) | ||
| 89 | /// ``` | ||
| 90 | pub fn can_satisfy_state( | ||
| 91 | event: &Event, | ||
| 92 | pushed_updates: &[RefUpdate], | ||
| 93 | local_refs: &HashMap<String, String>, | ||
| 94 | ) -> bool { | ||
| 95 | let state_refs = extract_refs_from_state(event); | ||
| 96 | |||
| 97 | // Filter local_refs to only branches and tags | ||
| 98 | let mut would_be_state: HashMap<String, String> = local_refs | ||
| 99 | .iter() | ||
| 100 | .filter(|(ref_name, _)| { | ||
| 101 | ref_name.starts_with("refs/heads/") || ref_name.starts_with("refs/tags/") | ||
| 102 | }) | ||
| 103 | .map(|(k, v)| (k.clone(), v.clone())) | ||
| 104 | .collect(); | ||
| 105 | |||
| 106 | // Apply all pushed updates to create the would-be state | ||
| 107 | for update in pushed_updates { | ||
| 108 | // Only process branches and tags | ||
| 109 | if !update.ref_name.starts_with("refs/heads/") && !update.ref_name.starts_with("refs/tags/") | ||
| 110 | { | ||
| 111 | continue; | ||
| 112 | } | ||
| 113 | |||
| 114 | if update.is_deletion() { | ||
| 115 | // Remove from would-be state | ||
| 116 | would_be_state.remove(&update.ref_name); | ||
| 117 | } else { | ||
| 118 | // Create or modify in would-be state | ||
| 119 | would_be_state.insert(update.ref_name.clone(), update.new_oid.clone()); | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | // Convert event's state refs to a HashMap for comparison | ||
| 124 | let declared_state: HashMap<String, String> = state_refs | ||
| 125 | .into_iter() | ||
| 126 | .map(|r| (r.ref_name, r.object_sha)) | ||
| 127 | .collect(); | ||
| 128 | |||
| 129 | // would_be_state must exactly match declared_state | ||
| 130 | would_be_state == declared_state | ||
| 131 | } | ||
| 132 | |||
| 133 | /// Get refs from state event that aren't in pushed_refs. | ||
| 134 | /// | ||
| 135 | /// Returns refs that need to be present but aren't being pushed. | ||
| 136 | /// These refs should exist in local_refs for the state to be satisfiable. | ||
| 137 | /// Useful for error messages showing what's missing. | ||
| 138 | /// | ||
| 139 | /// # Arguments | ||
| 140 | /// * `event` - The state event to check | ||
| 141 | /// * `pushed_refs` - Refs being pushed in the current operation | ||
| 142 | /// | ||
| 143 | /// # Returns | ||
| 144 | /// Vector of RefPair instances for refs not in pushed_refs | ||
| 145 | /// | ||
| 146 | /// # Example | ||
| 147 | /// ```ignore | ||
| 148 | /// // State event declares: refs/heads/main@abc123, refs/heads/dev@def456 | ||
| 149 | /// // Pushed: refs/heads/main@abc123 | ||
| 150 | /// // Result: [RefPair { ref_name: "refs/heads/dev", object_sha: "def456" }] | ||
| 151 | /// ``` | ||
| 152 | pub fn get_unpushed_refs(event: &Event, pushed_refs: &[RefPair]) -> Vec<RefPair> { | ||
| 153 | let state_refs = extract_refs_from_state(event); | ||
| 154 | |||
| 155 | state_refs | ||
| 156 | .into_iter() | ||
| 157 | .filter(|state_ref| { | ||
| 158 | // Include if NOT in pushed_refs (by name and SHA) | ||
| 159 | !pushed_refs.iter().any(|pushed_ref| { | ||
| 160 | pushed_ref.ref_name == state_ref.ref_name | ||
| 161 | && pushed_ref.object_sha == state_ref.object_sha | ||
| 162 | }) | ||
| 163 | }) | ||
| 164 | .collect() | ||
| 165 | } | ||
| 166 | |||
| 167 | #[cfg(test)] | ||
| 168 | mod tests { | ||
| 169 | use super::*; | ||
| 170 | use nostr_sdk::{EventBuilder, Keys, Tag}; | ||
| 171 | |||
| 172 | fn create_test_state_event(identifier: &str, refs: Vec<(&str, &str)>) -> Event { | ||
| 173 | let keys = Keys::generate(); | ||
| 174 | let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])]; | ||
| 175 | |||
| 176 | for (ref_name, sha) in refs { | ||
| 177 | tags.push(Tag::custom( | ||
| 178 | TagKind::custom(ref_name), | ||
| 179 | vec![sha.to_string()], | ||
| 180 | )); | ||
| 181 | } | ||
| 182 | |||
| 183 | EventBuilder::new(Kind::from(30618), "") | ||
| 184 | .tags(tags) | ||
| 185 | .sign_with_keys(&keys) | ||
| 186 | .unwrap() | ||
| 187 | } | ||
| 188 | |||
| 189 | #[test] | ||
| 190 | fn test_extract_refs_from_state() { | ||
| 191 | let event = create_test_state_event( | ||
| 192 | "test-repo", | ||
| 193 | vec![ | ||
| 194 | ("refs/heads/main", "abc123"), | ||
| 195 | ("refs/heads/dev", "def456"), | ||
| 196 | ("refs/tags/v1.0", "789xyz"), | ||
| 197 | ], | ||
| 198 | ); | ||
| 199 | |||
| 200 | let refs = extract_refs_from_state(&event); | ||
| 201 | |||
| 202 | assert_eq!(refs.len(), 3); | ||
| 203 | assert!(refs | ||
| 204 | .iter() | ||
| 205 | .any(|r| r.ref_name == "refs/heads/main" && r.object_sha == "abc123")); | ||
| 206 | assert!(refs | ||
| 207 | .iter() | ||
| 208 | .any(|r| r.ref_name == "refs/heads/dev" && r.object_sha == "def456")); | ||
| 209 | assert!(refs | ||
| 210 | .iter() | ||
| 211 | .any(|r| r.ref_name == "refs/tags/v1.0" && r.object_sha == "789xyz")); | ||
| 212 | } | ||
| 213 | |||
| 214 | #[test] | ||
| 215 | fn test_extract_refs_ignores_non_ref_tags() { | ||
| 216 | let keys = Keys::generate(); | ||
| 217 | let tags = vec![ | ||
| 218 | Tag::custom(TagKind::d(), vec!["test-repo".to_string()]), | ||
| 219 | Tag::custom( | ||
| 220 | TagKind::custom("refs/heads/main"), | ||
| 221 | vec!["abc123".to_string()], | ||
| 222 | ), | ||
| 223 | Tag::custom(TagKind::custom("some-other-tag"), vec!["value".to_string()]), | ||
| 224 | ]; | ||
| 225 | |||
| 226 | let event = EventBuilder::new(Kind::from(30618), "") | ||
| 227 | .tags(tags) | ||
| 228 | .sign_with_keys(&keys) | ||
| 229 | .unwrap(); | ||
| 230 | |||
| 231 | let refs = extract_refs_from_state(&event); | ||
| 232 | |||
| 233 | // Should only extract the refs/heads/main tag | ||
| 234 | assert_eq!(refs.len(), 1); | ||
| 235 | assert_eq!(refs[0].ref_name, "refs/heads/main"); | ||
| 236 | } | ||
| 237 | |||
| 238 | #[test] | ||
| 239 | fn test_can_satisfy_state_all_in_pushed() { | ||
| 240 | let event = create_test_state_event( | ||
| 241 | "test-repo", | ||
| 242 | vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")], | ||
| 243 | ); | ||
| 244 | |||
| 245 | let pushed_updates = vec![ | ||
| 246 | RefUpdate { | ||
| 247 | old_oid: "0000000000000000000000000000000000000000".to_string(), | ||
| 248 | new_oid: "abc123".to_string(), | ||
| 249 | ref_name: "refs/heads/main".to_string(), | ||
| 250 | }, | ||
| 251 | RefUpdate { | ||
| 252 | old_oid: "0000000000000000000000000000000000000000".to_string(), | ||
| 253 | new_oid: "def456".to_string(), | ||
| 254 | ref_name: "refs/heads/dev".to_string(), | ||
| 255 | }, | ||
| 256 | ]; | ||
| 257 | |||
| 258 | let local_refs = HashMap::new(); | ||
| 259 | |||
| 260 | assert!(can_satisfy_state(&event, &pushed_updates, &local_refs)); | ||
| 261 | } | ||
| 262 | |||
| 263 | #[test] | ||
| 264 | fn test_can_satisfy_state_split_between_pushed_and_local() { | ||
| 265 | let event = create_test_state_event( | ||
| 266 | "test-repo", | ||
| 267 | vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")], | ||
| 268 | ); | ||
| 269 | |||
| 270 | let pushed_updates = vec![RefUpdate { | ||
| 271 | old_oid: "0000000000000000000000000000000000000000".to_string(), | ||
| 272 | new_oid: "abc123".to_string(), | ||
| 273 | ref_name: "refs/heads/main".to_string(), | ||
| 274 | }]; | ||
| 275 | |||
| 276 | let mut local_refs = HashMap::new(); | ||
| 277 | local_refs.insert("refs/heads/dev".to_string(), "def456".to_string()); | ||
| 278 | |||
| 279 | assert!(can_satisfy_state(&event, &pushed_updates, &local_refs)); | ||
| 280 | } | ||
| 281 | |||
| 282 | #[test] | ||
| 283 | fn test_can_satisfy_state_missing_ref() { | ||
| 284 | let event = create_test_state_event( | ||
| 285 | "test-repo", | ||
| 286 | vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")], | ||
| 287 | ); | ||
| 288 | |||
| 289 | let pushed_updates = vec![RefUpdate { | ||
| 290 | old_oid: "0000000000000000000000000000000000000000".to_string(), | ||
| 291 | new_oid: "abc123".to_string(), | ||
| 292 | ref_name: "refs/heads/main".to_string(), | ||
| 293 | }]; | ||
| 294 | |||
| 295 | let local_refs = HashMap::new(); | ||
| 296 | |||
| 297 | // dev ref is missing | ||
| 298 | assert!(!can_satisfy_state(&event, &pushed_updates, &local_refs)); | ||
| 299 | } | ||
| 300 | |||
| 301 | #[test] | ||
| 302 | fn test_can_satisfy_state_modification() { | ||
| 303 | let event = create_test_state_event( | ||
| 304 | "test-repo", | ||
| 305 | vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")], | ||
| 306 | ); | ||
| 307 | |||
| 308 | let pushed_updates = vec![ | ||
| 309 | RefUpdate { | ||
| 310 | old_oid: "old123".to_string(), | ||
| 311 | new_oid: "abc123".to_string(), | ||
| 312 | ref_name: "refs/heads/main".to_string(), | ||
| 313 | }, | ||
| 314 | RefUpdate { | ||
| 315 | old_oid: "wrong-sha".to_string(), | ||
| 316 | new_oid: "def456".to_string(), | ||
| 317 | ref_name: "refs/heads/dev".to_string(), | ||
| 318 | }, | ||
| 319 | ]; | ||
| 320 | |||
| 321 | let mut local_refs = HashMap::new(); | ||
| 322 | local_refs.insert("refs/heads/main".to_string(), "old123".to_string()); | ||
| 323 | local_refs.insert("refs/heads/dev".to_string(), "wrong-sha".to_string()); | ||
| 324 | |||
| 325 | // Should succeed because push updates both to match event | ||
| 326 | assert!(can_satisfy_state(&event, &pushed_updates, &local_refs)); | ||
| 327 | } | ||
| 328 | |||
| 329 | #[test] | ||
| 330 | fn test_can_satisfy_state_rejects_extra_refs() { | ||
| 331 | let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]); | ||
| 332 | |||
| 333 | let pushed_updates = vec![ | ||
| 334 | RefUpdate { | ||
| 335 | old_oid: "0000000000000000000000000000000000000000".to_string(), | ||
| 336 | new_oid: "abc123".to_string(), | ||
| 337 | ref_name: "refs/heads/main".to_string(), | ||
| 338 | }, | ||
| 339 | RefUpdate { | ||
| 340 | old_oid: "old456".to_string(), | ||
| 341 | new_oid: "def456".to_string(), | ||
| 342 | ref_name: "refs/heads/dev".to_string(), | ||
| 343 | }, | ||
| 344 | ]; | ||
| 345 | |||
| 346 | let mut local_refs = HashMap::new(); | ||
| 347 | local_refs.insert("refs/heads/dev".to_string(), "old456".to_string()); | ||
| 348 | |||
| 349 | // Should fail because event doesn't declare dev | ||
| 350 | assert!(!can_satisfy_state(&event, &pushed_updates, &local_refs)); | ||
| 351 | } | ||
| 352 | |||
| 353 | #[test] | ||
| 354 | fn test_can_satisfy_state_filters_non_branch_tag_refs() { | ||
| 355 | let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]); | ||
| 356 | |||
| 357 | let pushed_updates = vec![RefUpdate { | ||
| 358 | old_oid: "0000000000000000000000000000000000000000".to_string(), | ||
| 359 | new_oid: "abc123".to_string(), | ||
| 360 | ref_name: "refs/heads/main".to_string(), | ||
| 361 | }]; | ||
| 362 | |||
| 363 | let mut local_refs = HashMap::new(); | ||
| 364 | // Add some non-branch/non-tag refs that should be filtered out | ||
| 365 | local_refs.insert("refs/pull/123/head".to_string(), "xyz789".to_string()); | ||
| 366 | local_refs.insert("refs/some/other/thing".to_string(), "aaa111".to_string()); | ||
| 367 | |||
| 368 | // Should succeed - non-branch/tag refs are filtered out | ||
| 369 | assert!(can_satisfy_state(&event, &pushed_updates, &local_refs)); | ||
| 370 | } | ||
| 371 | |||
| 372 | #[test] | ||
| 373 | fn test_can_satisfy_state_empty_event() { | ||
| 374 | let event = create_test_state_event("test-repo", vec![]); | ||
| 375 | let pushed_refs = vec![]; | ||
| 376 | let local_refs = HashMap::new(); | ||
| 377 | |||
| 378 | // Empty state event is satisfied | ||
| 379 | assert!(can_satisfy_state(&event, &pushed_refs, &local_refs)); | ||
| 380 | } | ||
| 381 | |||
| 382 | #[test] | ||
| 383 | fn test_get_unpushed_refs() { | ||
| 384 | let event = create_test_state_event( | ||
| 385 | "test-repo", | ||
| 386 | vec![ | ||
| 387 | ("refs/heads/main", "abc123"), | ||
| 388 | ("refs/heads/dev", "def456"), | ||
| 389 | ("refs/tags/v1.0", "789xyz"), | ||
| 390 | ], | ||
| 391 | ); | ||
| 392 | |||
| 393 | let pushed_refs = vec![RefPair { | ||
| 394 | ref_name: "refs/heads/main".to_string(), | ||
| 395 | object_sha: "abc123".to_string(), | ||
| 396 | }]; | ||
| 397 | |||
| 398 | let unpushed = get_unpushed_refs(&event, &pushed_refs); | ||
| 399 | |||
| 400 | assert_eq!(unpushed.len(), 2); | ||
| 401 | assert!(unpushed.iter().any(|r| r.ref_name == "refs/heads/dev")); | ||
| 402 | assert!(unpushed.iter().any(|r| r.ref_name == "refs/tags/v1.0")); | ||
| 403 | } | ||
| 404 | |||
| 405 | #[test] | ||
| 406 | fn test_get_unpushed_refs_all_pushed() { | ||
| 407 | let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]); | ||
| 408 | |||
| 409 | let pushed_refs = vec![RefPair { | ||
| 410 | ref_name: "refs/heads/main".to_string(), | ||
| 411 | object_sha: "abc123".to_string(), | ||
| 412 | }]; | ||
| 413 | |||
| 414 | let unpushed = get_unpushed_refs(&event, &pushed_refs); | ||
| 415 | |||
| 416 | assert_eq!(unpushed.len(), 0); | ||
| 417 | } | ||
| 418 | |||
| 419 | #[test] | ||
| 420 | fn test_get_unpushed_refs_sha_mismatch() { | ||
| 421 | let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]); | ||
| 422 | |||
| 423 | let pushed_refs = vec![RefPair { | ||
| 424 | ref_name: "refs/heads/main".to_string(), | ||
| 425 | object_sha: "different-sha".to_string(), // Different SHA | ||
| 426 | }]; | ||
| 427 | |||
| 428 | let unpushed = get_unpushed_refs(&event, &pushed_refs); | ||
| 429 | |||
| 430 | // Should still be unpushed because SHA doesn't match | ||
| 431 | assert_eq!(unpushed.len(), 1); | ||
| 432 | assert_eq!(unpushed[0].ref_name, "refs/heads/main"); | ||
| 433 | assert_eq!(unpushed[0].object_sha, "abc123"); | ||
| 434 | } | ||
| 435 | } | ||
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs new file mode 100644 index 0000000..18a55d5 --- /dev/null +++ b/src/purgatory/mod.rs | |||
| @@ -0,0 +1,593 @@ | |||
| 1 | //! Purgatory: In-memory holding area for events awaiting git data. | ||
| 2 | //! | ||
| 3 | //! Solves the "which arrives first?" problem where either nostr events or git pushes | ||
| 4 | //! can arrive in any order. Events and git data are held temporarily until their | ||
| 5 | //! counterpart arrives, at which point they can be processed together. | ||
| 6 | //! | ||
| 7 | //! ## Architecture | ||
| 8 | //! | ||
| 9 | //! - **In-memory only**: Data is lost on restart (acceptable per spec) | ||
| 10 | //! - **Thread-safe**: Uses DashMap for concurrent access from multiple handlers | ||
| 11 | //! - **Automatic expiry**: Entries expire after 30 minutes by default | ||
| 12 | //! - **Separate stores**: State events and PR events use different indexing strategies | ||
| 13 | |||
| 14 | mod helpers; | ||
| 15 | mod types; | ||
| 16 | |||
| 17 | pub use helpers::{can_satisfy_state, extract_refs_from_state, get_unpushed_refs}; | ||
| 18 | pub use types::{PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; | ||
| 19 | |||
| 20 | use dashmap::DashMap; | ||
| 21 | use nostr_sdk::prelude::*; | ||
| 22 | use std::sync::Arc; | ||
| 23 | use std::time::{Duration, Instant}; | ||
| 24 | |||
| 25 | /// Default expiry duration for purgatory entries (30 minutes) | ||
| 26 | const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800); | ||
| 27 | |||
| 28 | /// Main purgatory structure holding events awaiting git data. | ||
| 29 | /// | ||
| 30 | /// Provides thread-safe concurrent access to two separate stores: | ||
| 31 | /// - State events indexed by repository identifier | ||
| 32 | /// - PR events indexed by event ID | ||
| 33 | #[derive(Clone)] | ||
| 34 | pub struct Purgatory { | ||
| 35 | /// State events (kind 30618) indexed by repository identifier. | ||
| 36 | /// Multiple state events can wait for the same identifier (different maintainers). | ||
| 37 | state_events: Arc<DashMap<String, Vec<StatePurgatoryEntry>>>, | ||
| 38 | |||
| 39 | /// PR events (kind 1617/1618) or placeholders indexed by event ID (hex string). | ||
| 40 | /// Event ID is from the 'e' tag in the PR event itself. | ||
| 41 | pr_events: Arc<DashMap<String, PrPurgatoryEntry>>, | ||
| 42 | } | ||
| 43 | |||
| 44 | impl Purgatory { | ||
| 45 | /// Create a new empty purgatory. | ||
| 46 | pub fn new() -> Self { | ||
| 47 | Self { | ||
| 48 | state_events: Arc::new(DashMap::new()), | ||
| 49 | pr_events: Arc::new(DashMap::new()), | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | /// Add a state event to purgatory. | ||
| 54 | /// | ||
| 55 | /// The event will expire after the default duration unless matched with git data. | ||
| 56 | /// Multiple state events for the same identifier are allowed (from different authors). | ||
| 57 | /// | ||
| 58 | /// # Arguments | ||
| 59 | /// * `event` - The state event (kind 30618) to hold | ||
| 60 | /// * `identifier` - The repository identifier from the 'd' tag | ||
| 61 | /// * `author` - The event author's public key | ||
| 62 | pub fn add_state(&self, event: Event, identifier: String, author: PublicKey) { | ||
| 63 | let now = Instant::now(); | ||
| 64 | let entry = StatePurgatoryEntry { | ||
| 65 | event, | ||
| 66 | identifier: identifier.clone(), | ||
| 67 | author, | ||
| 68 | created_at: now, | ||
| 69 | expires_at: now + DEFAULT_EXPIRY, | ||
| 70 | }; | ||
| 71 | |||
| 72 | self.state_events.entry(identifier).or_default().push(entry); | ||
| 73 | } | ||
| 74 | |||
| 75 | /// Add a PR event to purgatory. | ||
| 76 | /// | ||
| 77 | /// The event will expire after the default duration unless matched with git data. | ||
| 78 | /// | ||
| 79 | /// # Arguments | ||
| 80 | /// * `event` - The PR event (kind 1617/1618) to hold | ||
| 81 | /// * `event_id` - The event ID (hex string) from the 'e' tag | ||
| 82 | /// * `commit` - The commit SHA from the 'c' tag | ||
| 83 | pub fn add_pr(&self, event: Event, event_id: String, commit: String) { | ||
| 84 | let now = Instant::now(); | ||
| 85 | let entry = PrPurgatoryEntry { | ||
| 86 | event: Some(event), | ||
| 87 | commit, | ||
| 88 | created_at: now, | ||
| 89 | expires_at: now + DEFAULT_EXPIRY, | ||
| 90 | }; | ||
| 91 | |||
| 92 | self.pr_events.insert(event_id, entry); | ||
| 93 | } | ||
| 94 | |||
| 95 | /// Add a PR placeholder (git data arrived before PR event). | ||
| 96 | /// | ||
| 97 | /// Creates a placeholder entry waiting for the corresponding PR event. | ||
| 98 | /// | ||
| 99 | /// # Arguments | ||
| 100 | /// * `event_id` - The expected event ID (from git ref name) | ||
| 101 | /// * `commit` - The commit SHA that was pushed | ||
| 102 | pub fn add_pr_placeholder(&self, event_id: String, commit: String) { | ||
| 103 | let now = Instant::now(); | ||
| 104 | let entry = PrPurgatoryEntry { | ||
| 105 | event: None, // Placeholder - no event yet | ||
| 106 | commit, | ||
| 107 | created_at: now, | ||
| 108 | expires_at: now + DEFAULT_EXPIRY, | ||
| 109 | }; | ||
| 110 | |||
| 111 | self.pr_events.insert(event_id, entry); | ||
| 112 | } | ||
| 113 | |||
| 114 | /// Find state events waiting for a specific repository identifier. | ||
| 115 | /// | ||
| 116 | /// Returns all state events (from all maintainers) waiting for git data | ||
| 117 | /// matching this identifier. | ||
| 118 | /// | ||
| 119 | /// # Arguments | ||
| 120 | /// * `identifier` - The repository identifier to search for | ||
| 121 | /// | ||
| 122 | /// # Returns | ||
| 123 | /// Vector of state events waiting for this identifier, or empty vec if none found | ||
| 124 | pub fn find_state(&self, identifier: &str) -> Vec<StatePurgatoryEntry> { | ||
| 125 | self.state_events | ||
| 126 | .get(identifier) | ||
| 127 | .map(|entries| entries.clone()) | ||
| 128 | .unwrap_or_default() | ||
| 129 | } | ||
| 130 | |||
| 131 | /// Find a PR event or placeholder by event ID. | ||
| 132 | /// | ||
| 133 | /// # Arguments | ||
| 134 | /// * `event_id` - The event ID to search for | ||
| 135 | /// | ||
| 136 | /// # Returns | ||
| 137 | /// The PR entry if found, None otherwise | ||
| 138 | pub fn find_pr(&self, event_id: &str) -> Option<PrPurgatoryEntry> { | ||
| 139 | self.pr_events.get(event_id).map(|entry| entry.clone()) | ||
| 140 | } | ||
| 141 | |||
| 142 | /// Find a PR placeholder specifically (git-data-first scenario). | ||
| 143 | /// | ||
| 144 | /// Returns the commit SHA only if a placeholder exists (entry with no event). | ||
| 145 | /// Used to distinguish placeholders from actual PR events. | ||
| 146 | /// | ||
| 147 | /// # Arguments | ||
| 148 | /// * `event_id` - The event ID to search for | ||
| 149 | /// | ||
| 150 | /// # Returns | ||
| 151 | /// Some(commit_sha) if a placeholder exists, None if no entry or entry has an event | ||
| 152 | pub fn find_pr_placeholder(&self, event_id: &str) -> Option<String> { | ||
| 153 | self.pr_events.get(event_id).and_then(|entry| { | ||
| 154 | if entry.event.is_none() { | ||
| 155 | Some(entry.commit.clone()) | ||
| 156 | } else { | ||
| 157 | None | ||
| 158 | } | ||
| 159 | }) | ||
| 160 | } | ||
| 161 | |||
| 162 | /// Remove a state event from purgatory. | ||
| 163 | /// | ||
| 164 | /// Removes all entries for the given identifier. | ||
| 165 | /// | ||
| 166 | /// # Arguments | ||
| 167 | /// * `identifier` - The repository identifier to remove | ||
| 168 | pub fn remove_state(&self, identifier: &str) { | ||
| 169 | self.state_events.remove(identifier); | ||
| 170 | } | ||
| 171 | |||
| 172 | /// Remove a specific state event by comparing the full event. | ||
| 173 | /// | ||
| 174 | /// This allows removing a single state event while leaving others | ||
| 175 | /// for the same identifier intact. | ||
| 176 | /// | ||
| 177 | /// # Arguments | ||
| 178 | /// * `identifier` - The repository identifier | ||
| 179 | /// * `event_id` - The specific event ID to remove | ||
| 180 | pub fn remove_state_event(&self, identifier: &str, event_id: &EventId) { | ||
| 181 | if let Some(mut entries) = self.state_events.get_mut(identifier) { | ||
| 182 | entries.retain(|entry| entry.event.id != *event_id); | ||
| 183 | if entries.is_empty() { | ||
| 184 | drop(entries); // Release lock before removal | ||
| 185 | self.state_events.remove(identifier); | ||
| 186 | } | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | /// Find state events that could be satisfied by ref updates. | ||
| 191 | /// | ||
| 192 | /// Returns state events waiting for this identifier where applying the | ||
| 193 | /// ref updates to local state results in exactly the declared state. | ||
| 194 | /// Uses late-binding ref extraction at git push time. | ||
| 195 | /// | ||
| 196 | /// # Arguments | ||
| 197 | /// * `identifier` - The repository identifier to search for | ||
| 198 | /// * `pushed_updates` - Ref updates in the current push operation | ||
| 199 | /// * `local_refs` - Refs already existing locally (ref_name -> SHA) | ||
| 200 | /// | ||
| 201 | /// # Returns | ||
| 202 | /// Vector of events that can be satisfied by the push | ||
| 203 | pub fn find_matching_states( | ||
| 204 | &self, | ||
| 205 | identifier: &str, | ||
| 206 | pushed_updates: &[RefUpdate], | ||
| 207 | local_refs: &std::collections::HashMap<String, String>, | ||
| 208 | ) -> Vec<Event> { | ||
| 209 | self.state_events | ||
| 210 | .get(identifier) | ||
| 211 | .map(|entries| { | ||
| 212 | entries | ||
| 213 | .iter() | ||
| 214 | .filter(|entry| { | ||
| 215 | helpers::can_satisfy_state(&entry.event, pushed_updates, local_refs) | ||
| 216 | }) | ||
| 217 | .map(|entry| entry.event.clone()) | ||
| 218 | .collect() | ||
| 219 | }) | ||
| 220 | .unwrap_or_default() | ||
| 221 | } | ||
| 222 | |||
| 223 | /// Extend expiry for state events about to be processed. | ||
| 224 | /// | ||
| 225 | /// Ensures entries have at least `duration` remaining on their timer. | ||
| 226 | /// Sets expiry to max(current_expiry, now + duration). | ||
| 227 | /// | ||
| 228 | /// # Arguments | ||
| 229 | /// * `identifier` - The repository identifier | ||
| 230 | /// * `event_ids` - Event IDs to extend expiry for | ||
| 231 | /// * `duration` - Minimum duration to guarantee from now | ||
| 232 | pub fn extend_expiry(&self, identifier: &str, event_ids: &[EventId], duration: Duration) { | ||
| 233 | if let Some(mut entries) = self.state_events.get_mut(identifier) { | ||
| 234 | let now = Instant::now(); | ||
| 235 | let new_expiry = now + duration; | ||
| 236 | |||
| 237 | for entry in entries.iter_mut() { | ||
| 238 | if event_ids.contains(&entry.event.id) { | ||
| 239 | // Set to max of current expiry and new expiry | ||
| 240 | if entry.expires_at < new_expiry { | ||
| 241 | entry.expires_at = new_expiry; | ||
| 242 | } | ||
| 243 | } | ||
| 244 | } | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | /// Remove a PR event or placeholder from purgatory. | ||
| 249 | /// | ||
| 250 | /// # Arguments | ||
| 251 | /// * `event_id` - The event ID to remove | ||
| 252 | pub fn remove_pr(&self, event_id: &str) { | ||
| 253 | self.pr_events.remove(event_id); | ||
| 254 | } | ||
| 255 | |||
| 256 | /// Get all PR placeholder event IDs (git-data-first entries without events). | ||
| 257 | /// | ||
| 258 | /// Returns event IDs for entries where git data arrived before the PR event. | ||
| 259 | /// These correspond to `refs/nostr/<event-id>` refs that should be cleaned up | ||
| 260 | /// on shutdown since they don't have corresponding events. | ||
| 261 | /// | ||
| 262 | /// # Returns | ||
| 263 | /// Vector of event IDs (hex strings) for placeholder entries | ||
| 264 | pub fn get_placeholder_event_ids(&self) -> Vec<String> { | ||
| 265 | self.pr_events | ||
| 266 | .iter() | ||
| 267 | .filter_map(|entry| { | ||
| 268 | if entry.value().event.is_none() { | ||
| 269 | Some(entry.key().clone()) | ||
| 270 | } else { | ||
| 271 | None | ||
| 272 | } | ||
| 273 | }) | ||
| 274 | .collect() | ||
| 275 | } | ||
| 276 | |||
| 277 | /// Remove expired entries from purgatory. | ||
| 278 | /// | ||
| 279 | /// Should be called periodically (every 60 seconds) by background task to clean up | ||
| 280 | /// entries that have exceeded their expiry deadline. | ||
| 281 | /// | ||
| 282 | /// # Returns | ||
| 283 | /// Tuple of (num_state_removed, num_pr_removed) | ||
| 284 | pub fn cleanup(&self) -> (usize, usize) { | ||
| 285 | let now = Instant::now(); | ||
| 286 | let mut state_removed = 0; | ||
| 287 | |||
| 288 | // Remove expired state events | ||
| 289 | self.state_events.retain(|_, entries| { | ||
| 290 | let original_len = entries.len(); | ||
| 291 | entries.retain(|entry| entry.expires_at > now); | ||
| 292 | state_removed += original_len - entries.len(); | ||
| 293 | !entries.is_empty() | ||
| 294 | }); | ||
| 295 | |||
| 296 | // Remove expired PR events | ||
| 297 | let expired_prs: Vec<String> = self | ||
| 298 | .pr_events | ||
| 299 | .iter() | ||
| 300 | .filter(|entry| entry.value().expires_at <= now) | ||
| 301 | .map(|entry| entry.key().clone()) | ||
| 302 | .collect(); | ||
| 303 | |||
| 304 | let pr_removed = expired_prs.len(); | ||
| 305 | for event_id in expired_prs { | ||
| 306 | self.pr_events.remove(&event_id); | ||
| 307 | } | ||
| 308 | |||
| 309 | (state_removed, pr_removed) | ||
| 310 | } | ||
| 311 | |||
| 312 | /// Remove expired entries from purgatory (legacy method). | ||
| 313 | /// | ||
| 314 | /// # Returns | ||
| 315 | /// Total number of entries removed (state + PR events) | ||
| 316 | #[deprecated(since = "0.1.0", note = "Use cleanup() instead for separate counts")] | ||
| 317 | pub fn remove_expired(&self) -> usize { | ||
| 318 | let (state, pr) = self.cleanup(); | ||
| 319 | state + pr | ||
| 320 | } | ||
| 321 | |||
| 322 | /// Get current count of entries in purgatory. | ||
| 323 | /// | ||
| 324 | /// # Returns | ||
| 325 | /// Tuple of (state_event_count, pr_event_count) | ||
| 326 | pub fn count(&self) -> (usize, usize) { | ||
| 327 | let state_count: usize = self.state_events.iter().map(|e| e.value().len()).sum(); | ||
| 328 | let pr_count = self.pr_events.len(); | ||
| 329 | (state_count, pr_count) | ||
| 330 | } | ||
| 331 | |||
| 332 | /// Clear all entries from purgatory (for testing). | ||
| 333 | #[cfg(test)] | ||
| 334 | pub fn clear(&self) { | ||
| 335 | self.state_events.clear(); | ||
| 336 | self.pr_events.clear(); | ||
| 337 | } | ||
| 338 | } | ||
| 339 | |||
| 340 | impl Default for Purgatory { | ||
| 341 | fn default() -> Self { | ||
| 342 | Self::new() | ||
| 343 | } | ||
| 344 | } | ||
| 345 | |||
| 346 | #[cfg(test)] | ||
| 347 | mod tests { | ||
| 348 | use super::*; | ||
| 349 | |||
| 350 | #[test] | ||
| 351 | fn test_purgatory_creation() { | ||
| 352 | let purgatory = Purgatory::new(); | ||
| 353 | let (state_count, pr_count) = purgatory.count(); | ||
| 354 | assert_eq!(state_count, 0); | ||
| 355 | assert_eq!(pr_count, 0); | ||
| 356 | } | ||
| 357 | |||
| 358 | #[test] | ||
| 359 | fn test_purgatory_count() { | ||
| 360 | let purgatory = Purgatory::new(); | ||
| 361 | |||
| 362 | // Add some test data | ||
| 363 | let keys = Keys::generate(); | ||
| 364 | let event = EventBuilder::text_note("test") | ||
| 365 | .sign_with_keys(&keys) | ||
| 366 | .unwrap(); | ||
| 367 | |||
| 368 | purgatory.add_state(event.clone(), "test-repo".to_string(), keys.public_key()); | ||
| 369 | purgatory.add_pr(event, "test-event-id".to_string(), "abc123".to_string()); | ||
| 370 | |||
| 371 | let (state_count, pr_count) = purgatory.count(); | ||
| 372 | assert_eq!(state_count, 1); | ||
| 373 | assert_eq!(pr_count, 1); | ||
| 374 | } | ||
| 375 | } | ||
| 376 | |||
| 377 | #[test] | ||
| 378 | fn test_pr_event_vs_placeholder() { | ||
| 379 | let purgatory = Purgatory::new(); | ||
| 380 | let keys = Keys::generate(); | ||
| 381 | let event = EventBuilder::text_note("test PR") | ||
| 382 | .sign_with_keys(&keys) | ||
| 383 | .unwrap(); | ||
| 384 | |||
| 385 | // Add a PR event with actual event | ||
| 386 | purgatory.add_pr( | ||
| 387 | event.clone(), | ||
| 388 | "event-id-1".to_string(), | ||
| 389 | "commit-abc".to_string(), | ||
| 390 | ); | ||
| 391 | |||
| 392 | // Add a placeholder (no event) | ||
| 393 | purgatory.add_pr_placeholder("event-id-2".to_string(), "commit-def".to_string()); | ||
| 394 | |||
| 395 | // find_pr should find both | ||
| 396 | assert!(purgatory.find_pr("event-id-1").is_some()); | ||
| 397 | assert!(purgatory.find_pr("event-id-2").is_some()); | ||
| 398 | |||
| 399 | // find_pr_placeholder should only find the placeholder | ||
| 400 | assert!(purgatory.find_pr_placeholder("event-id-1").is_none()); | ||
| 401 | assert_eq!( | ||
| 402 | purgatory.find_pr_placeholder("event-id-2"), | ||
| 403 | Some("commit-def".to_string()) | ||
| 404 | ); | ||
| 405 | } | ||
| 406 | |||
| 407 | #[test] | ||
| 408 | fn test_pr_placeholder_creation_and_retrieval() { | ||
| 409 | let purgatory = Purgatory::new(); | ||
| 410 | |||
| 411 | // Add a placeholder | ||
| 412 | purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-123".to_string()); | ||
| 413 | |||
| 414 | // Should be findable by find_pr | ||
| 415 | let entry = purgatory.find_pr("placeholder-id"); | ||
| 416 | assert!(entry.is_some()); | ||
| 417 | let entry = entry.unwrap(); | ||
| 418 | assert!(entry.event.is_none()); // No event yet | ||
| 419 | assert_eq!(entry.commit, "commit-123"); | ||
| 420 | |||
| 421 | // Should be findable by find_pr_placeholder | ||
| 422 | let commit = purgatory.find_pr_placeholder("placeholder-id"); | ||
| 423 | assert_eq!(commit, Some("commit-123".to_string())); | ||
| 424 | } | ||
| 425 | |||
| 426 | #[test] | ||
| 427 | fn test_cleanup_removes_expired_entries() { | ||
| 428 | use std::time::Duration; | ||
| 429 | |||
| 430 | let purgatory = Purgatory::new(); | ||
| 431 | let keys = Keys::generate(); | ||
| 432 | |||
| 433 | // Create events | ||
| 434 | let state_event = EventBuilder::text_note("state event") | ||
| 435 | .sign_with_keys(&keys) | ||
| 436 | .unwrap(); | ||
| 437 | let pr_event = EventBuilder::text_note("pr event") | ||
| 438 | .sign_with_keys(&keys) | ||
| 439 | .unwrap(); | ||
| 440 | |||
| 441 | // Add entries to purgatory | ||
| 442 | purgatory.add_state( | ||
| 443 | state_event.clone(), | ||
| 444 | "test-repo".to_string(), | ||
| 445 | keys.public_key(), | ||
| 446 | ); | ||
| 447 | purgatory.add_pr(pr_event, "pr-123".to_string(), "commit-abc".to_string()); | ||
| 448 | purgatory.add_pr_placeholder("pr-456".to_string(), "commit-def".to_string()); | ||
| 449 | |||
| 450 | // Verify entries are there | ||
| 451 | let (state_count, pr_count) = purgatory.count(); | ||
| 452 | assert_eq!(state_count, 1); | ||
| 453 | assert_eq!(pr_count, 2); | ||
| 454 | |||
| 455 | // Manually expire entries by modifying their expiry time | ||
| 456 | // (This is a bit hacky but needed for testing without waiting 30 minutes) | ||
| 457 | if let Some(mut entries) = purgatory.state_events.get_mut("test-repo") { | ||
| 458 | for entry in entries.iter_mut() { | ||
| 459 | entry.expires_at = Instant::now() - Duration::from_secs(1); | ||
| 460 | } | ||
| 461 | } | ||
| 462 | |||
| 463 | // Expire PR events | ||
| 464 | for mut entry in purgatory.pr_events.iter_mut() { | ||
| 465 | entry.value_mut().expires_at = Instant::now() - Duration::from_secs(1); | ||
| 466 | } | ||
| 467 | |||
| 468 | // Run cleanup | ||
| 469 | let (state_removed, pr_removed) = purgatory.cleanup(); | ||
| 470 | |||
| 471 | // Verify counts | ||
| 472 | assert_eq!(state_removed, 1); | ||
| 473 | assert_eq!(pr_removed, 2); | ||
| 474 | |||
| 475 | // Verify entries are gone | ||
| 476 | let (state_count, pr_count) = purgatory.count(); | ||
| 477 | assert_eq!(state_count, 0); | ||
| 478 | assert_eq!(pr_count, 0); | ||
| 479 | } | ||
| 480 | |||
| 481 | #[test] | ||
| 482 | fn test_cleanup_preserves_non_expired_entries() { | ||
| 483 | let purgatory = Purgatory::new(); | ||
| 484 | let keys = Keys::generate(); | ||
| 485 | |||
| 486 | let state_event = EventBuilder::text_note("state event") | ||
| 487 | .sign_with_keys(&keys) | ||
| 488 | .unwrap(); | ||
| 489 | let pr_event = EventBuilder::text_note("pr event") | ||
| 490 | .sign_with_keys(&keys) | ||
| 491 | .unwrap(); | ||
| 492 | |||
| 493 | // Add fresh entries | ||
| 494 | purgatory.add_state(state_event, "test-repo".to_string(), keys.public_key()); | ||
| 495 | purgatory.add_pr(pr_event, "pr-123".to_string(), "commit-abc".to_string()); | ||
| 496 | |||
| 497 | // Run cleanup | ||
| 498 | let (state_removed, pr_removed) = purgatory.cleanup(); | ||
| 499 | |||
| 500 | // Nothing should be removed | ||
| 501 | assert_eq!(state_removed, 0); | ||
| 502 | assert_eq!(pr_removed, 0); | ||
| 503 | |||
| 504 | // Verify entries are still there | ||
| 505 | let (state_count, pr_count) = purgatory.count(); | ||
| 506 | assert_eq!(state_count, 1); | ||
| 507 | assert_eq!(pr_count, 1); | ||
| 508 | } | ||
| 509 | |||
| 510 | #[test] | ||
| 511 | fn test_cleanup_mixed_expired_and_fresh() { | ||
| 512 | use std::time::Duration; | ||
| 513 | |||
| 514 | let purgatory = Purgatory::new(); | ||
| 515 | let keys = Keys::generate(); | ||
| 516 | |||
| 517 | // Add multiple state events for same repo | ||
| 518 | let event1 = EventBuilder::text_note("event1") | ||
| 519 | .sign_with_keys(&keys) | ||
| 520 | .unwrap(); | ||
| 521 | let event2 = EventBuilder::text_note("event2") | ||
| 522 | .sign_with_keys(&keys) | ||
| 523 | .unwrap(); | ||
| 524 | |||
| 525 | purgatory.add_state(event1, "test-repo".to_string(), keys.public_key()); | ||
| 526 | purgatory.add_state(event2, "test-repo".to_string(), keys.public_key()); | ||
| 527 | |||
| 528 | // Expire only the first one | ||
| 529 | if let Some(mut entries) = purgatory.state_events.get_mut("test-repo") { | ||
| 530 | if let Some(entry) = entries.get_mut(0) { | ||
| 531 | entry.expires_at = Instant::now() - Duration::from_secs(1); | ||
| 532 | } | ||
| 533 | } | ||
| 534 | |||
| 535 | // Add PR events | ||
| 536 | let pr1 = EventBuilder::text_note("pr1") | ||
| 537 | .sign_with_keys(&keys) | ||
| 538 | .unwrap(); | ||
| 539 | let pr2 = EventBuilder::text_note("pr2") | ||
| 540 | .sign_with_keys(&keys) | ||
| 541 | .unwrap(); | ||
| 542 | |||
| 543 | purgatory.add_pr(pr1, "pr-1".to_string(), "commit-1".to_string()); | ||
| 544 | purgatory.add_pr(pr2, "pr-2".to_string(), "commit-2".to_string()); | ||
| 545 | |||
| 546 | // Expire only first PR | ||
| 547 | if let Some(mut entry) = purgatory.pr_events.get_mut("pr-1") { | ||
| 548 | entry.expires_at = Instant::now() - Duration::from_secs(1); | ||
| 549 | } | ||
| 550 | |||
| 551 | // Run cleanup | ||
| 552 | let (state_removed, pr_removed) = purgatory.cleanup(); | ||
| 553 | |||
| 554 | // One of each should be removed | ||
| 555 | assert_eq!(state_removed, 1); | ||
| 556 | assert_eq!(pr_removed, 1); | ||
| 557 | |||
| 558 | // Verify remaining counts | ||
| 559 | let (state_count, pr_count) = purgatory.count(); | ||
| 560 | assert_eq!(state_count, 1); // One state event remains | ||
| 561 | assert_eq!(pr_count, 1); // One PR event remains | ||
| 562 | } | ||
| 563 | |||
| 564 | #[test] | ||
| 565 | fn test_remove_expired_legacy_method() { | ||
| 566 | use std::time::Duration; | ||
| 567 | |||
| 568 | let purgatory = Purgatory::new(); | ||
| 569 | let keys = Keys::generate(); | ||
| 570 | |||
| 571 | let state_event = EventBuilder::text_note("state") | ||
| 572 | .sign_with_keys(&keys) | ||
| 573 | .unwrap(); | ||
| 574 | let pr_event = EventBuilder::text_note("pr").sign_with_keys(&keys).unwrap(); | ||
| 575 | |||
| 576 | purgatory.add_state(state_event, "repo".to_string(), keys.public_key()); | ||
| 577 | purgatory.add_pr(pr_event, "pr-id".to_string(), "commit".to_string()); | ||
| 578 | |||
| 579 | // Expire both | ||
| 580 | if let Some(mut entries) = purgatory.state_events.get_mut("repo") { | ||
| 581 | for entry in entries.iter_mut() { | ||
| 582 | entry.expires_at = Instant::now() - Duration::from_secs(1); | ||
| 583 | } | ||
| 584 | } | ||
| 585 | for mut entry in purgatory.pr_events.iter_mut() { | ||
| 586 | entry.value_mut().expires_at = Instant::now() - Duration::from_secs(1); | ||
| 587 | } | ||
| 588 | |||
| 589 | // Test legacy method returns total | ||
| 590 | #[allow(deprecated)] | ||
| 591 | let total = purgatory.remove_expired(); | ||
| 592 | assert_eq!(total, 2); // 1 state + 1 PR | ||
| 593 | } | ||
diff --git a/src/purgatory/types.rs b/src/purgatory/types.rs new file mode 100644 index 0000000..9c47616 --- /dev/null +++ b/src/purgatory/types.rs | |||
| @@ -0,0 +1,99 @@ | |||
| 1 | //! Core data types for the purgatory system. | ||
| 2 | //! | ||
| 3 | //! Purgatory is an in-memory holding area for nostr events that depend on git data | ||
| 4 | //! that hasn't arrived yet, and vice versa. This solves the "which arrives first?" | ||
| 5 | //! problem where either the nostr event or git push can arrive first. | ||
| 6 | |||
| 7 | use nostr_sdk::prelude::*; | ||
| 8 | use std::time::Instant; | ||
| 9 | |||
| 10 | /// A reference name and its target object. | ||
| 11 | /// | ||
| 12 | /// Used to identify specific git refs (branches, tags) that a state event | ||
| 13 | /// is waiting for. The combination of ref_name and object_sha uniquely | ||
| 14 | /// identifies a git reference at a specific point in time. | ||
| 15 | #[derive(Debug, Clone, Hash, Eq, PartialEq)] | ||
| 16 | pub struct RefPair { | ||
| 17 | /// Full ref name, e.g., "refs/heads/main" or "refs/tags/v1.0" | ||
| 18 | pub ref_name: String, | ||
| 19 | /// Target object SHA (commit or annotated tag) | ||
| 20 | pub object_sha: String, | ||
| 21 | } | ||
| 22 | |||
| 23 | /// A git reference update from receive-pack protocol. | ||
| 24 | /// | ||
| 25 | /// Represents the full update information: what the ref was, what it will be, | ||
| 26 | /// and which ref is being updated. This allows detection of: | ||
| 27 | /// - Additions: old_oid is all zeros | ||
| 28 | /// - Deletions: new_oid is all zeros | ||
| 29 | /// - Modifications: both are non-zero but different | ||
| 30 | #[derive(Debug, Clone, Hash, Eq, PartialEq)] | ||
| 31 | pub struct RefUpdate { | ||
| 32 | /// Old object SHA (40 zeros = ref is being created) | ||
| 33 | pub old_oid: String, | ||
| 34 | /// New object SHA (40 zeros = ref is being deleted) | ||
| 35 | pub new_oid: String, | ||
| 36 | /// Full ref name, e.g., "refs/heads/main" or "refs/tags/v1.0" | ||
| 37 | pub ref_name: String, | ||
| 38 | } | ||
| 39 | |||
| 40 | impl RefUpdate { | ||
| 41 | /// Check if this update is creating a new ref | ||
| 42 | pub fn is_creation(&self) -> bool { | ||
| 43 | self.old_oid == "0000000000000000000000000000000000000000" | ||
| 44 | } | ||
| 45 | |||
| 46 | /// Check if this update is deleting a ref | ||
| 47 | pub fn is_deletion(&self) -> bool { | ||
| 48 | self.new_oid == "0000000000000000000000000000000000000000" | ||
| 49 | } | ||
| 50 | |||
| 51 | /// Check if this update is modifying an existing ref | ||
| 52 | pub fn is_modification(&self) -> bool { | ||
| 53 | !self.is_creation() && !self.is_deletion() | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | /// Entry for a state event (kind 30618) waiting in purgatory. | ||
| 58 | /// | ||
| 59 | /// State events declare the current state of a repository but may arrive | ||
| 60 | /// before the corresponding git data has been pushed. This entry holds | ||
| 61 | /// the event and associated metadata until the git data arrives. | ||
| 62 | #[derive(Debug, Clone)] | ||
| 63 | pub struct StatePurgatoryEntry { | ||
| 64 | /// The nostr state event (kind 30618) awaiting git data | ||
| 65 | pub event: Event, | ||
| 66 | |||
| 67 | /// The repository identifier from the event's 'd' tag | ||
| 68 | pub identifier: String, | ||
| 69 | |||
| 70 | /// Event author pubkey | ||
| 71 | pub author: PublicKey, | ||
| 72 | |||
| 73 | /// When this entry was added to purgatory | ||
| 74 | pub created_at: Instant, | ||
| 75 | |||
| 76 | /// Expiry deadline (30 min from creation, may be extended) | ||
| 77 | pub expires_at: Instant, | ||
| 78 | } | ||
| 79 | |||
| 80 | /// Entry for a PR event (kind 1617/1618) or placeholder waiting in purgatory. | ||
| 81 | /// | ||
| 82 | /// PR events reference specific commits but may arrive before the git push | ||
| 83 | /// containing those commits. Alternatively, a git push may arrive first, | ||
| 84 | /// creating a placeholder entry waiting for the corresponding PR event. | ||
| 85 | #[derive(Debug, Clone)] | ||
| 86 | pub struct PrPurgatoryEntry { | ||
| 87 | /// The nostr PR event, if received (None = git data arrived first) | ||
| 88 | pub event: Option<Event>, | ||
| 89 | |||
| 90 | /// The expected commit SHA from 'c' tag (if event exists) | ||
| 91 | /// or the actual commit pushed (if git arrived first) | ||
| 92 | pub commit: String, | ||
| 93 | |||
| 94 | /// When this entry was added to purgatory | ||
| 95 | pub created_at: Instant, | ||
| 96 | |||
| 97 | /// Expiry deadline (30 min from creation, may be extended) | ||
| 98 | pub expires_at: Instant, | ||
| 99 | } | ||