upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/git/authorization.rs113
-rw-r--r--src/git/handlers.rs321
-rw-r--r--src/git/mod.rs68
-rw-r--r--src/http/mod.rs11
-rw-r--r--src/lib.rs1
-rw-r--r--src/main.rs61
-rw-r--r--src/nostr/builder.rs116
-rw-r--r--src/nostr/policy/mod.rs5
-rw-r--r--src/nostr/policy/pr_event.rs149
-rw-r--r--src/nostr/policy/state.rs55
-rw-r--r--src/purgatory/helpers.rs435
-rw-r--r--src/purgatory/mod.rs593
-rw-r--r--src/purgatory/types.rs99
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};
31use nostr_relay_builder::prelude::*; 31use nostr_relay_builder::prelude::*;
32use nostr_sdk::{EventId, ToBech32}; 32use nostr_sdk::{EventId, ToBech32};
33use std::collections::{HashMap, HashSet}; 33use std::collections::{HashMap, HashSet};
34use tracing::debug; 34use tracing::{debug, info, warn};
35 35
36use crate::nostr::builder::SharedDatabase; 36use crate::nostr::builder::SharedDatabase;
37use crate::nostr::events::{ 37use 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.
342pub async fn get_authorization_for_owner( 341pub 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
408impl AuthorizationResult { 481impl 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
5use http_body_util::Full; 5use http_body_util::Full;
6use hyper::{body::Bytes, Response, StatusCode}; 6use hyper::{body::Bytes, Response, StatusCode};
7use nostr_sdk::prelude::*;
7use std::path::PathBuf; 8use std::path::PathBuf;
9use std::sync::Arc;
8use tokio::io::{AsyncReadExt, AsyncWriteExt}; 10use tokio::io::{AsyncReadExt, AsyncWriteExt};
9use tracing::{debug, error, info, warn}; 11use tracing::{debug, error, info, warn};
10 12
11use super::authorization::{ 13use 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};
15use super::protocol::{GitService, PktLine}; 17use super::protocol::{GitService, PktLine};
16use super::subprocess::GitSubprocess; 18use super::subprocess::GitSubprocess;
17use super::try_set_head_if_available; 19use super::try_set_head_if_available;
18 20
19use crate::nostr::builder::SharedDatabase; 21use crate::nostr::builder::SharedDatabase;
20use crate::nostr::events::RepositoryState; 22use crate::nostr::events::{RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_STATE};
23use 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
177pub async fn handle_receive_pack( 185pub 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
311async fn authorize_push( 365async 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
356pub 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;
27use crate::git; 27use crate::git;
28use crate::metrics::Metrics; 28use crate::metrics::Metrics;
29use crate::nostr::builder::SharedDatabase; 29use crate::nostr::builder::SharedDatabase;
30use 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)
32const CORS_ALLOW_ORIGIN: &str = "*"; 33const 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
99impl HttpService { 102impl 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 {
diff --git a/src/lib.rs b/src/lib.rs
index a1306c4..8befd6f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,4 +3,5 @@ pub mod git;
3pub mod http; 3pub mod http;
4pub mod metrics; 4pub mod metrics;
5pub mod nostr; 5pub mod nostr;
6pub mod purgatory;
6pub mod sync; 7pub 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 @@
1use std::sync::Arc; 1use std::sync::Arc;
2use std::time::Duration;
2 3
3use anyhow::Result; 4use anyhow::Result;
5use tokio::signal;
4use tracing::{info, Level}; 6use tracing::{info, Level};
5use tracing_subscriber::FmtSubscriber; 7use tracing_subscriber::FmtSubscriber;
6 8
7use ngit_grasp::{ 9use 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
14use crate::config::{Config, DatabaseBackend}; 14use crate::config::{Config, DatabaseBackend};
15use crate::nostr::events::{ 15use 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};
19use crate::nostr::policy::{ 19use 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)
292pub async fn create_relay(config: &Config) -> Result<RelayWithDatabase> { 374pub 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};
16pub use state::{AlignmentResult, StatePolicy, StateResult}; 16pub use state::{AlignmentResult, StatePolicy, StateResult};
17 17
18use super::SharedDatabase; 18use super::SharedDatabase;
19use crate::purgatory::Purgatory;
20use 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
28impl PolicyContext { 31impl 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
7use super::{RefPair, RefUpdate};
8use nostr_sdk::prelude::*;
9use 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/// ```
38pub 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/// ```
90pub 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/// ```
152pub 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)]
168mod 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
14mod helpers;
15mod types;
16
17pub use helpers::{can_satisfy_state, extract_refs_from_state, get_unpushed_refs};
18pub use types::{PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry};
19
20use dashmap::DashMap;
21use nostr_sdk::prelude::*;
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24
25/// Default expiry duration for purgatory entries (30 minutes)
26const 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)]
34pub 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
44impl 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
340impl Default for Purgatory {
341 fn default() -> Self {
342 Self::new()
343 }
344}
345
346#[cfg(test)]
347mod 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]
378fn 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]
408fn 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]
427fn 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]
482fn 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]
511fn 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]
565fn 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
7use nostr_sdk::prelude::*;
8use 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)]
16pub 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)]
31pub 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
40impl 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)]
63pub 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)]
86pub 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}