upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/git/handlers.rs
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/handlers.rs
parentf8c3e3920ed2a1bdaab30be912276993449a5476 (diff)
feat(purgatory): add broken purgatory implementation
Diffstat (limited to 'src/git/handlers.rs')
-rw-r--r--src/git/handlers.rs321
1 files changed, 218 insertions, 103 deletions
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