upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/git
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-24 08:02:12 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-24 11:54:18 +0000
commit70d0197e85ae4ef85202781f6d2dc9e76bd508b3 (patch)
tree45efb6565e81ba755acc5955e68d5b7119d1e122 /src/git
parentf8c3e3920ed2a1bdaab30be912276993449a5476 (diff)
feat(purgatory): add broken purgatory implementation
Diffstat (limited to 'src/git')
-rw-r--r--src/git/authorization.rs113
-rw-r--r--src/git/handlers.rs321
-rw-r--r--src/git/mod.rs68
3 files changed, 380 insertions, 122 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