diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-24 08:02:12 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-24 11:54:18 +0000 |
| commit | 70d0197e85ae4ef85202781f6d2dc9e76bd508b3 (patch) | |
| tree | 45efb6565e81ba755acc5955e68d5b7119d1e122 /src/git | |
| parent | f8c3e3920ed2a1bdaab30be912276993449a5476 (diff) | |
feat(purgatory): add broken purgatory implementation
Diffstat (limited to 'src/git')
| -rw-r--r-- | src/git/authorization.rs | 113 | ||||
| -rw-r--r-- | src/git/handlers.rs | 321 | ||||
| -rw-r--r-- | src/git/mod.rs | 68 |
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}; | |||
| 31 | use nostr_relay_builder::prelude::*; | 31 | use nostr_relay_builder::prelude::*; |
| 32 | use nostr_sdk::{EventId, ToBech32}; | 32 | use nostr_sdk::{EventId, ToBech32}; |
| 33 | use std::collections::{HashMap, HashSet}; | 33 | use std::collections::{HashMap, HashSet}; |
| 34 | use tracing::debug; | 34 | use tracing::{debug, info, warn}; |
| 35 | 35 | ||
| 36 | use crate::nostr::builder::SharedDatabase; | 36 | use crate::nostr::builder::SharedDatabase; |
| 37 | use crate::nostr::events::{ | 37 | use crate::nostr::events::{ |
| @@ -325,26 +325,31 @@ pub async fn get_authorization_from_db( | |||
| 325 | 325 | ||
| 326 | /// Get the authorization result for a repository scoped to a specific owner | 326 | /// Get the authorization result for a repository scoped to a specific owner |
| 327 | /// | 327 | /// |
| 328 | /// Unlike `get_authorization_from_db`, this function scopes the authorization | 328 | /// Push authorization checks ONLY purgatory for state events. The database represents |
| 329 | /// to a specific owner's announcement. This is the correct approach for Git push | 329 | /// the current git state, while purgatory holds the intended future state that pushes |
| 330 | /// authorization where the URL path specifies the owner. | 330 | /// should be authorized against. |
| 331 | /// | 331 | /// |
| 332 | /// A push to `alice/my-repo` should only consider authorization from alice's | 332 | /// A push to `alice/my-repo` should only consider authorization from alice's |
| 333 | /// announcement, not bob's announcement for the same identifier. | 333 | /// announcement, not bob's announcement for the same identifier. |
| 334 | /// | 334 | /// |
| 335 | /// It: | 335 | /// It: |
| 336 | /// 1. Fetches all announcements and states for the identifier | 336 | /// 1. Fetches announcements for the identifier |
| 337 | /// 2. Collects authorized maintainers from all announcements (grouped by owner) | 337 | /// 2. Collects authorized maintainers from owner's announcement |
| 338 | /// 3. Looks up the authorized set for the specific owner | 338 | /// 3. Checks purgatory for matching state events from authorized maintainers |
| 339 | /// 4. Finds the latest state event from an authorized maintainer | ||
| 340 | /// | 339 | /// |
| 341 | /// Returns an `AuthorizationResult` that indicates whether a push is authorized. | 340 | /// Returns an `AuthorizationResult` that indicates whether a push is authorized. |
| 342 | pub async fn get_authorization_for_owner( | 341 | pub async fn get_state_authorization_for_specific_owner_repo( |
| 343 | database: &SharedDatabase, | 342 | database: &SharedDatabase, |
| 344 | identifier: &str, | 343 | identifier: &str, |
| 345 | owner_pubkey: &str, | 344 | owner_pubkey: &str, |
| 345 | purgatory: &std::sync::Arc<crate::purgatory::Purgatory>, | ||
| 346 | pushed_refs: &[(String, String, String)], | ||
| 347 | repo_path: &std::path::Path, | ||
| 346 | ) -> Result<AuthorizationResult> { | 348 | ) -> Result<AuthorizationResult> { |
| 347 | // Fetch all repository data with a single query | 349 | use crate::git::list_refs; |
| 350 | use crate::purgatory::RefUpdate; | ||
| 351 | |||
| 352 | // Fetch announcements only - we don't need database states | ||
| 348 | let repo_data = fetch_repository_data(database, identifier).await?; | 353 | let repo_data = fetch_repository_data(database, identifier).await?; |
| 349 | 354 | ||
| 350 | if repo_data.announcements.is_empty() { | 355 | if repo_data.announcements.is_empty() { |
| @@ -380,16 +385,82 @@ pub async fn get_authorization_for_owner( | |||
| 380 | owner_pubkey | 385 | owner_pubkey |
| 381 | ); | 386 | ); |
| 382 | 387 | ||
| 383 | // Find the latest authorized state from owner's maintainer set | 388 | // Check purgatory for matching state events |
| 384 | match find_latest_authorized_state(&repo_data.states, &authorized) { | 389 | // Convert pushed refs to RefUpdate (filter out refs/nostr/* refs) |
| 385 | Some(state) => Ok(AuthorizationResult::authorized( | 390 | let pushed_updates: Vec<RefUpdate> = pushed_refs |
| 386 | state.clone(), | 391 | .iter() |
| 387 | authorized.into_iter().collect(), | 392 | .filter(|(_, _, name)| !name.starts_with("refs/nostr/")) |
| 388 | )), | 393 | .map(|(old_oid, new_oid, ref_name)| RefUpdate { |
| 389 | None => Ok(AuthorizationResult::denied( | 394 | old_oid: old_oid.clone(), |
| 390 | "No state event found from authorized publishers", | 395 | new_oid: new_oid.clone(), |
| 391 | )), | 396 | ref_name: ref_name.clone(), |
| 397 | }) | ||
| 398 | .collect(); | ||
| 399 | |||
| 400 | // Get local refs from repository | ||
| 401 | let local_refs_list = list_refs(repo_path).unwrap_or_default(); | ||
| 402 | let local_refs: HashMap<String, String> = local_refs_list.into_iter().collect(); | ||
| 403 | |||
| 404 | // Find matching state events in purgatory | ||
| 405 | let matching_events = purgatory.find_matching_states(identifier, &pushed_updates, &local_refs); | ||
| 406 | |||
| 407 | if !matching_events.is_empty() { | ||
| 408 | debug!( | ||
| 409 | "Found {} matching state event(s) in purgatory", | ||
| 410 | matching_events.len() | ||
| 411 | ); | ||
| 412 | |||
| 413 | // Filter to authorized events and collect them | ||
| 414 | let authorized_events: Vec<Event> = matching_events | ||
| 415 | .into_iter() | ||
| 416 | .filter(|event| { | ||
| 417 | let author_hex = event.pubkey.to_hex(); | ||
| 418 | authorized.contains(&author_hex) | ||
| 419 | }) | ||
| 420 | .collect(); | ||
| 421 | |||
| 422 | if !authorized_events.is_empty() { | ||
| 423 | // Find the latest event | ||
| 424 | let latest_authorized = authorized_events | ||
| 425 | .iter() | ||
| 426 | .max_by_key(|event| event.created_at) | ||
| 427 | .unwrap(); // Safe because we checked the vec is not empty | ||
| 428 | |||
| 429 | // Parse the event into RepositoryState | ||
| 430 | if let Ok(state) = RepositoryState::from_event(latest_authorized.clone()) { | ||
| 431 | info!( | ||
| 432 | "Authorized by state event {} from purgatory (author: {})", | ||
| 433 | latest_authorized.id, | ||
| 434 | latest_authorized | ||
| 435 | .pubkey | ||
| 436 | .to_bech32() | ||
| 437 | .unwrap_or_else(|_| latest_authorized.pubkey.to_hex()) | ||
| 438 | ); | ||
| 439 | |||
| 440 | return Ok(AuthorizationResult { | ||
| 441 | authorized: true, | ||
| 442 | reason: "Authorized by state event in purgatory".to_string(), | ||
| 443 | state: Some(state), | ||
| 444 | maintainers: authorized.into_iter().collect(), | ||
| 445 | purgatory_events: vec![latest_authorized.clone()], | ||
| 446 | }); | ||
| 447 | } else { | ||
| 448 | warn!( | ||
| 449 | "Failed to parse purgatory event {} as RepositoryState", | ||
| 450 | latest_authorized.id | ||
| 451 | ); | ||
| 452 | } | ||
| 453 | } else { | ||
| 454 | debug!("Purgatory events found but none from authorized authors"); | ||
| 455 | } | ||
| 456 | } else { | ||
| 457 | debug!("No matching state events found in purgatory"); | ||
| 392 | } | 458 | } |
| 459 | |||
| 460 | // No matching state found in purgatory | ||
| 461 | Ok(AuthorizationResult::denied( | ||
| 462 | "No state event found in purgatory from authorized publishers", | ||
| 463 | )) | ||
| 393 | } | 464 | } |
| 394 | 465 | ||
| 395 | /// Result of authorization check | 466 | /// Result of authorization check |
| @@ -403,6 +474,8 @@ pub struct AuthorizationResult { | |||
| 403 | pub state: Option<RepositoryState>, | 474 | pub state: Option<RepositoryState>, |
| 404 | /// The set of valid maintainers (authorized publishers) | 475 | /// The set of valid maintainers (authorized publishers) |
| 405 | pub maintainers: Vec<String>, | 476 | pub maintainers: Vec<String>, |
| 477 | /// Events from purgatory that authorized this push (state, PR, PR-update events) | ||
| 478 | pub purgatory_events: Vec<Event>, | ||
| 406 | } | 479 | } |
| 407 | 480 | ||
| 408 | impl AuthorizationResult { | 481 | impl AuthorizationResult { |
| @@ -413,6 +486,7 @@ impl AuthorizationResult { | |||
| 413 | reason: "Push matches latest authorized state".to_string(), | 486 | reason: "Push matches latest authorized state".to_string(), |
| 414 | state: Some(state), | 487 | state: Some(state), |
| 415 | maintainers, | 488 | maintainers, |
| 489 | purgatory_events: vec![], | ||
| 416 | } | 490 | } |
| 417 | } | 491 | } |
| 418 | 492 | ||
| @@ -423,6 +497,7 @@ impl AuthorizationResult { | |||
| 423 | reason: reason.into(), | 497 | reason: reason.into(), |
| 424 | state: None, | 498 | state: None, |
| 425 | maintainers: vec![], | 499 | maintainers: vec![], |
| 500 | purgatory_events: vec![], | ||
| 426 | } | 501 | } |
| 427 | } | 502 | } |
| 428 | } | 503 | } |
diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 8e5f5e1..df6f0e9 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs | |||
| @@ -4,20 +4,23 @@ | |||
| 4 | 4 | ||
| 5 | use http_body_util::Full; | 5 | use http_body_util::Full; |
| 6 | use hyper::{body::Bytes, Response, StatusCode}; | 6 | use hyper::{body::Bytes, Response, StatusCode}; |
| 7 | use nostr_sdk::prelude::*; | ||
| 7 | use std::path::PathBuf; | 8 | use std::path::PathBuf; |
| 9 | use std::sync::Arc; | ||
| 8 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | 10 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 9 | use tracing::{debug, error, info, warn}; | 11 | use tracing::{debug, error, info, warn}; |
| 10 | 12 | ||
| 11 | use super::authorization::{ | 13 | use super::authorization::{ |
| 12 | get_authorization_for_owner, parse_pushed_refs, validate_nostr_ref_pushes, validate_push_refs, | 14 | get_state_authorization_for_specific_owner_repo, parse_pushed_refs, validate_nostr_ref_pushes, |
| 13 | AuthorizationResult, | 15 | validate_push_refs, AuthorizationResult, |
| 14 | }; | 16 | }; |
| 15 | use super::protocol::{GitService, PktLine}; | 17 | use super::protocol::{GitService, PktLine}; |
| 16 | use super::subprocess::GitSubprocess; | 18 | use super::subprocess::GitSubprocess; |
| 17 | use super::try_set_head_if_available; | 19 | use super::try_set_head_if_available; |
| 18 | 20 | ||
| 19 | use crate::nostr::builder::SharedDatabase; | 21 | use crate::nostr::builder::SharedDatabase; |
| 20 | use crate::nostr::events::RepositoryState; | 22 | use crate::nostr::events::{RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_STATE}; |
| 23 | use crate::purgatory::Purgatory; | ||
| 21 | 24 | ||
| 22 | /// Handle GET /info/refs?service=git-{upload,receive}-pack | 25 | /// Handle GET /info/refs?service=git-{upload,receive}-pack |
| 23 | /// | 26 | /// |
| @@ -168,18 +171,24 @@ pub async fn handle_upload_pack( | |||
| 168 | /// Also per GRASP-01: "MUST set repository HEAD per repository state announcement | 171 | /// Also per GRASP-01: "MUST set repository HEAD per repository state announcement |
| 169 | /// as soon as the git data related to that branch has been received." | 172 | /// as soon as the git data related to that branch has been received." |
| 170 | /// | 173 | /// |
| 174 | /// Also purgatory GRASP-01: "Accepted repo state announcements, PRs and PR Updates | ||
| 175 | /// SHOULD be accepted with message "purgatory: won't be served until git data arrives" | ||
| 176 | /// and kepted in purgatory (not served) until the related git data arrives and | ||
| 177 | /// otherwise discarded after 30 minutes." | ||
| 178 | /// | ||
| 171 | /// # Arguments | 179 | /// # Arguments |
| 172 | /// * `repo_path` - Path to the bare git repository | 180 | /// * `repo_path` - Path to the bare git repository |
| 173 | /// * `request_body` - The git pack data from the client | 181 | /// * `request_body` - The git pack data from the client |
| 174 | /// * `database` - Optional database reference for authorization queries | 182 | /// * `database` - Database reference for authorization queries |
| 175 | /// * `identifier` - The repository identifier (d tag) for authorization lookup | 183 | /// * `identifier` - The repository identifier (d tag) for authorization lookup |
| 176 | /// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization | 184 | /// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization |
| 177 | pub async fn handle_receive_pack( | 185 | pub async fn handle_receive_pack( |
| 178 | repo_path: PathBuf, | 186 | repo_path: PathBuf, |
| 179 | request_body: Bytes, | 187 | request_body: Bytes, |
| 180 | database: Option<SharedDatabase>, | 188 | database: SharedDatabase, |
| 181 | identifier: &str, | 189 | identifier: &str, |
| 182 | owner_pubkey: &str, | 190 | owner_pubkey: &str, |
| 191 | purgatory: Arc<Purgatory>, | ||
| 183 | ) -> Result<Response<Full<Bytes>>, GitError> { | 192 | ) -> Result<Response<Full<Bytes>>, GitError> { |
| 184 | debug!("Handling receive-pack for {:?}", repo_path); | 193 | debug!("Handling receive-pack for {:?}", repo_path); |
| 185 | 194 | ||
| @@ -187,37 +196,46 @@ pub async fn handle_receive_pack( | |||
| 187 | return Err(GitError::RepositoryNotFound); | 196 | return Err(GitError::RepositoryNotFound); |
| 188 | } | 197 | } |
| 189 | 198 | ||
| 190 | // Keep track of state for HEAD setting after push | 199 | // Keep track of state and events for processing after push |
| 191 | let mut authorized_state: Option<RepositoryState> = None; | 200 | let mut authorized_state: Option<RepositoryState> = None; |
| 201 | let mut authorized_events: Vec<Event> = Vec::new(); | ||
| 192 | 202 | ||
| 193 | // GRASP Authorization Check (if database is provided) | 203 | // GRASP Authorization Check |
| 194 | if let Some(ref db) = database { | 204 | info!( |
| 195 | info!( | 205 | "Authorizing push for {} owned by {} via database query", |
| 196 | "Authorizing push for {} owned by {} via database query", | 206 | identifier, owner_pubkey |
| 197 | identifier, owner_pubkey | 207 | ); |
| 198 | ); | ||
| 199 | 208 | ||
| 200 | match authorize_push(db, identifier, owner_pubkey, &request_body).await { | 209 | match authorize_push( |
| 201 | Ok(auth_result) => { | 210 | &database, |
| 202 | if !auth_result.authorized { | 211 | identifier, |
| 203 | warn!("Push rejected for {}: {}", identifier, auth_result.reason); | 212 | owner_pubkey, |
| 204 | return Err(GitError::Unauthorized); | 213 | &request_body, |
| 205 | } | 214 | &purgatory, |
| 206 | info!( | 215 | &repo_path, |
| 207 | "Push authorized for {} - {} maintainers", | 216 | ) |
| 208 | identifier, | 217 | .await |
| 209 | auth_result.maintainers.len() | 218 | { |
| 210 | ); | 219 | Ok(auth_result) => { |
| 211 | // Save the state for HEAD setting after push | 220 | if !auth_result.authorized { |
| 212 | authorized_state = auth_result.state; | 221 | warn!("Push rejected for {}: {}", identifier, auth_result.reason); |
| 213 | } | ||
| 214 | Err(e) => { | ||
| 215 | warn!("Authorization check failed for {}: {}", identifier, e); | ||
| 216 | return Err(GitError::Unauthorized); | 222 | return Err(GitError::Unauthorized); |
| 217 | } | 223 | } |
| 224 | info!( | ||
| 225 | "Push authorized for {} - {} maintainers, {} purgatory events", | ||
| 226 | identifier, | ||
| 227 | auth_result.maintainers.len(), | ||
| 228 | auth_result.purgatory_events.len() | ||
| 229 | ); | ||
| 230 | // Save the state for HEAD setting after push | ||
| 231 | authorized_state = auth_result.state.clone(); | ||
| 232 | // Save the purgatory events for database saving after push | ||
| 233 | authorized_events = auth_result.purgatory_events; | ||
| 234 | } | ||
| 235 | Err(e) => { | ||
| 236 | warn!("Authorization check failed for {}: {}", identifier, e); | ||
| 237 | return Err(GitError::Unauthorized); | ||
| 218 | } | 238 | } |
| 219 | } else { | ||
| 220 | debug!("No database provided - accepting push without authorization"); | ||
| 221 | } | 239 | } |
| 222 | 240 | ||
| 223 | // Spawn git receive-pack | 241 | // Spawn git receive-pack |
| @@ -265,7 +283,7 @@ pub async fn handle_receive_pack( | |||
| 265 | // GRASP-01: Set HEAD after git data is received | 283 | // GRASP-01: Set HEAD after git data is received |
| 266 | // "MUST set repository HEAD per repository state announcement | 284 | // "MUST set repository HEAD per repository state announcement |
| 267 | // as soon as the git data related to that branch has been received." | 285 | // as soon as the git data related to that branch has been received." |
| 268 | if let Some(state) = authorized_state { | 286 | if let Some(ref state) = authorized_state { |
| 269 | if let Some(head_ref) = &state.head { | 287 | if let Some(head_ref) = &state.head { |
| 270 | if let Some(branch_name) = state.get_head_branch() { | 288 | if let Some(branch_name) = state.get_head_branch() { |
| 271 | if let Some(commit) = state.get_branch_commit(branch_name) { | 289 | if let Some(commit) = state.get_branch_commit(branch_name) { |
| @@ -288,6 +306,43 @@ pub async fn handle_receive_pack( | |||
| 288 | } | 306 | } |
| 289 | } | 307 | } |
| 290 | 308 | ||
| 309 | // Save all events from purgatory that authorized this push and remove them from purgatory | ||
| 310 | // This includes state events, PR events, and PR-update events | ||
| 311 | if !authorized_events.is_empty() { | ||
| 312 | info!( | ||
| 313 | "Saving {} purgatory event(s) to database after successful push", | ||
| 314 | authorized_events.len() | ||
| 315 | ); | ||
| 316 | |||
| 317 | for event in &authorized_events { | ||
| 318 | match database.save_event(event).await { | ||
| 319 | Ok(_) => { | ||
| 320 | info!("Saved purgatory event {} to database", event.id); | ||
| 321 | // TODO let broadcast_success = local_relay.notify_event(event.clone()); | ||
| 322 | warn!("TODO Here we need to broadcast on open websockets for live listeners. eventid; {}", event.id); | ||
| 323 | // Remove from purgatory based on event kind | ||
| 324 | if event.kind == Kind::from(KIND_REPOSITORY_STATE) { | ||
| 325 | purgatory.remove_state_event(identifier, &event.id); | ||
| 326 | info!("Removed state event {} from purgatory", event.id); | ||
| 327 | } else if event.kind == Kind::from(KIND_PR) | ||
| 328 | || event.kind == Kind::from(KIND_PR_UPDATE) | ||
| 329 | { | ||
| 330 | // Extract event ID from the event itself (it's the event.id) | ||
| 331 | let event_id_hex = event.id.to_hex(); | ||
| 332 | purgatory.remove_pr(&event_id_hex); | ||
| 333 | info!("Removed PR event {} from purgatory", event.id); | ||
| 334 | } | ||
| 335 | } | ||
| 336 | Err(e) => { | ||
| 337 | warn!( | ||
| 338 | "Failed to save purgatory event {} to database: {}", | ||
| 339 | event.id, e | ||
| 340 | ); | ||
| 341 | } | ||
| 342 | } | ||
| 343 | } | ||
| 344 | } | ||
| 345 | |||
| 291 | Ok(Response::builder() | 346 | Ok(Response::builder() |
| 292 | .status(StatusCode::OK) | 347 | .status(StatusCode::OK) |
| 293 | .header( | 348 | .header( |
| @@ -302,115 +357,175 @@ pub async fn handle_receive_pack( | |||
| 302 | /// Perform GRASP authorization for a push operation | 357 | /// Perform GRASP authorization for a push operation |
| 303 | /// | 358 | /// |
| 304 | /// This function queries the database directly (not via WebSocket): | 359 | /// This function queries the database directly (not via WebSocket): |
| 305 | /// 1. Fetches announcement and state events for the identifier | 360 | /// 1. Parses the pushed refs from the git pack protocol |
| 306 | /// 2. Filters to the specific owner's announcement | 361 | /// 2. Separates refs/nostr/ refs from normal refs |
| 307 | /// 3. Collects authorized publishers from that announcement (owner + maintainers) | 362 | /// 3. For normal refs: validates against state events in purgatory |
| 308 | /// 4. Gets the latest authorized state from those publishers | 363 | /// 4. For refs/nostr/ refs: validates event ID format and collects PR/PR-update events from purgatory |
| 309 | /// 5. Validates that pushed refs match the state | 364 | /// 5. Returns all authorizing events (state + PR/PR-update) in the result |
| 310 | /// 6. Validates refs/nostr/<event-id> has valid event id and if event exists, `c` tag matches ref | ||
| 311 | async fn authorize_push( | 365 | async fn authorize_push( |
| 312 | database: &SharedDatabase, | 366 | database: &SharedDatabase, |
| 313 | identifier: &str, | 367 | identifier: &str, |
| 314 | owner_pubkey: &str, | 368 | owner_pubkey: &str, |
| 315 | request_body: &Bytes, | 369 | request_body: &Bytes, |
| 370 | purgatory: &Arc<Purgatory>, | ||
| 371 | repo_path: &std::path::Path, | ||
| 316 | ) -> anyhow::Result<AuthorizationResult> { | 372 | ) -> anyhow::Result<AuthorizationResult> { |
| 317 | debug!( | 373 | debug!( |
| 318 | "Authorizing push for {} owned by {} via database query", | 374 | "Authorizing push for {} owned by {} via database query", |
| 319 | identifier, owner_pubkey | 375 | identifier, owner_pubkey |
| 320 | ); | 376 | ); |
| 321 | 377 | ||
| 322 | // Parse refs from the push request FIRST to check if this is a refs/nostr/ push | 378 | // Parse refs from the push request |
| 323 | let pushed_refs = parse_pushed_refs(request_body); | 379 | let pushed_refs = parse_pushed_refs(request_body); |
| 324 | debug!("Parsed {} refs from push request", pushed_refs.len()); | 380 | debug!("Parsed {} refs from push request", pushed_refs.len()); |
| 325 | for (old_oid, new_oid, ref_name) in &pushed_refs { | 381 | for (old_oid, new_oid, ref_name) in &pushed_refs { |
| 326 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); | 382 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); |
| 327 | } | 383 | } |
| 328 | 384 | ||
| 329 | // Separate refs/nostr/ refs from other refs | 385 | // Separate refs/nostr/ refs from state refs |
| 330 | // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`" | 386 | let (nostr_refs, state_refs): (Vec<_>, Vec<_>) = pushed_refs |
| 331 | let (nostr_refs, other_refs): (Vec<_>, Vec<_>) = pushed_refs | ||
| 332 | .iter() | 387 | .iter() |
| 333 | .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/")); | 388 | .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/")); |
| 334 | 389 | ||
| 335 | // Validate refs/nostr/ refs if any exist | 390 | // Collect all purgatory events that authorize this push |
| 391 | let mut purgatory_events = Vec::new(); | ||
| 392 | |||
| 393 | // Handle refs/nostr/ refs - validate and collect PR/PR-update events from purgatory | ||
| 336 | if !nostr_refs.is_empty() { | 394 | if !nostr_refs.is_empty() { |
| 337 | debug!( | 395 | debug!( |
| 338 | "Found {} refs/nostr/ refs - validating against events", | 396 | "Found {} refs/nostr/ refs - validating and collecting from purgatory", |
| 339 | nostr_refs.len() | 397 | nostr_refs.len() |
| 340 | ); | 398 | ); |
| 341 | 399 | ||
| 342 | // Validate refs/nostr/ pushes: checks event ID format and commit matching | 400 | for (_, new_oid, ref_name) in &nostr_refs { |
| 343 | let nostr_refs_owned: Vec<(String, String, String)> = nostr_refs | 401 | // Extract event ID from ref name |
| 344 | .into_iter() | 402 | if let Some(event_id_hex) = ref_name.strip_prefix("refs/nostr/") { |
| 345 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) | 403 | // Validate event ID format |
| 346 | .collect(); | 404 | if EventId::parse(event_id_hex).is_err() { |
| 347 | if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await { | 405 | warn!("Invalid event ID format in ref: {}", ref_name); |
| 348 | warn!("refs/nostr/ validation failed: {}", e); | 406 | return Ok(AuthorizationResult::denied(format!( |
| 349 | return Ok(AuthorizationResult::denied(format!( | 407 | "Invalid event ID format in ref: {}", |
| 350 | "refs/nostr/ validation failed: {}", | 408 | ref_name |
| 351 | e | 409 | ))); |
| 352 | ))); | 410 | } |
| 411 | |||
| 412 | // Check purgatory for PR event | ||
| 413 | if let Some(entry) = purgatory.find_pr(event_id_hex) { | ||
| 414 | if let Some(event) = entry.event { | ||
| 415 | // Verify commit matches | ||
| 416 | if entry.commit == *new_oid { | ||
| 417 | debug!( | ||
| 418 | "Found matching PR event {} in purgatory for ref {}", | ||
| 419 | event_id_hex, ref_name | ||
| 420 | ); | ||
| 421 | purgatory_events.push(event); | ||
| 422 | } else { | ||
| 423 | warn!( | ||
| 424 | "PR event {} in purgatory has commit mismatch: expected {}, got {}", | ||
| 425 | event_id_hex, entry.commit, new_oid | ||
| 426 | ); | ||
| 427 | return Ok(AuthorizationResult::denied(format!( | ||
| 428 | "PR event {} commit mismatch: expected {}, got {}", | ||
| 429 | event_id_hex, entry.commit, new_oid | ||
| 430 | ))); | ||
| 431 | } | ||
| 432 | } else { | ||
| 433 | // Placeholder exists - allow push (git-data-first scenario) | ||
| 434 | debug!( | ||
| 435 | "Found placeholder already for PR event {} in purgatory - as we dont have the event and therefore dont know the required commit_id we allow overwriting with a different commit_id", | ||
| 436 | event_id_hex | ||
| 437 | ); | ||
| 438 | } | ||
| 439 | } else { | ||
| 440 | // No entry in purgatory - check database for existing event | ||
| 441 | let nostr_refs_owned = vec![(String::new(), new_oid.clone(), ref_name.clone())]; | ||
| 442 | if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await { | ||
| 443 | warn!("refs/nostr/ validation failed: {}", e); | ||
| 444 | return Ok(AuthorizationResult::denied(format!( | ||
| 445 | "refs/nostr/ validation failed: {}", | ||
| 446 | e | ||
| 447 | ))); | ||
| 448 | } | ||
| 449 | debug!( | ||
| 450 | "No purgatory entry for {} - validated against database", | ||
| 451 | event_id_hex | ||
| 452 | ); | ||
| 453 | } | ||
| 454 | } | ||
| 353 | } | 455 | } |
| 354 | debug!("refs/nostr/ push validated successfully"); | ||
| 355 | } | 456 | } |
| 356 | 457 | ||
| 357 | // If only refs/nostr/ refs, we're done - return success | 458 | // Handle normal refs - validate against state events |
| 358 | if other_refs.is_empty() { | 459 | if !state_refs.is_empty() { |
| 359 | debug!("Only refs/nostr/ refs in push - authorization complete"); | 460 | debug!( |
| 360 | return Ok(AuthorizationResult { | 461 | "Found {} non-refs/nostr/ refs - checking state authorization", |
| 361 | authorized: true, | 462 | state_refs.len() |
| 362 | reason: "Push to refs/nostr/ validated against events".to_string(), | 463 | ); |
| 363 | state: None, | ||
| 364 | maintainers: vec![], | ||
| 365 | }); | ||
| 366 | } | ||
| 367 | 464 | ||
| 368 | // For non-refs/nostr/ refs, require state validation | 465 | let auth_result = get_state_authorization_for_specific_owner_repo( |
| 369 | debug!( | 466 | database, |
| 370 | "Found {} non-refs/nostr/ refs - checking state authorization", | 467 | identifier, |
| 371 | other_refs.len() | 468 | owner_pubkey, |
| 372 | ); | 469 | purgatory, |
| 373 | let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; | 470 | &pushed_refs, //it would be better to accept state_refs but thats in different format |
| 471 | repo_path, | ||
| 472 | ) | ||
| 473 | .await?; | ||
| 374 | 474 | ||
| 375 | if !auth_result.authorized { | 475 | if !auth_result.authorized { |
| 376 | return Ok(auth_result); | 476 | return Ok(auth_result); |
| 377 | } | 477 | } |
| 378 | 478 | ||
| 379 | // Convert other_refs for validation | 479 | // Collect state events from purgatory |
| 380 | let other_refs_owned: Vec<(String, String, String)> = other_refs | 480 | purgatory_events.extend(auth_result.purgatory_events); |
| 381 | .into_iter() | ||
| 382 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) | ||
| 383 | .collect(); | ||
| 384 | 481 | ||
| 385 | // Validate non-refs/nostr/ refs against state | 482 | // Validate refs against state |
| 386 | if let Some(ref state) = auth_result.state { | 483 | let other_refs_owned: Vec<(String, String, String)> = state_refs |
| 387 | debug!( | 484 | .into_iter() |
| 388 | "Validating against state with {} branches", | 485 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) |
| 389 | state.branches.len() | 486 | .collect(); |
| 390 | ); | ||
| 391 | 487 | ||
| 392 | // If we have a state event but couldn't parse any refs, reject the push. | 488 | if let Some(ref state) = auth_result.state { |
| 393 | // This protects against parsing failures allowing unauthorized pushes. | 489 | debug!( |
| 394 | if other_refs_owned.is_empty() && !state.branches.is_empty() { | 490 | "Validating against state with {} branches", |
| 395 | warn!("No refs parsed from push request but state event has branches - rejecting"); | 491 | state.branches.len() |
| 396 | return Ok(AuthorizationResult::denied( | 492 | ); |
| 397 | "Failed to parse refs from push request - cannot validate against state", | 493 | |
| 398 | )); | 494 | if other_refs_owned.is_empty() && !state.branches.is_empty() { |
| 399 | } | 495 | warn!("No refs parsed from push request but state event has branches - rejecting"); |
| 496 | return Ok(AuthorizationResult::denied( | ||
| 497 | "Failed to parse refs from push request - cannot validate against state", | ||
| 498 | )); | ||
| 499 | } | ||
| 400 | 500 | ||
| 401 | if let Err(e) = validate_push_refs(state, &other_refs_owned) { | 501 | if let Err(e) = validate_push_refs(state, &other_refs_owned) { |
| 402 | warn!("Ref validation failed: {}", e); | 502 | warn!("Ref validation failed: {}", e); |
| 403 | return Ok(AuthorizationResult::denied(format!( | 503 | return Ok(AuthorizationResult::denied(format!( |
| 404 | "Ref validation failed: {}", | 504 | "Ref validation failed: {}", |
| 405 | e | 505 | e |
| 406 | ))); | 506 | ))); |
| 507 | } | ||
| 508 | debug!("Ref validation passed"); | ||
| 407 | } | 509 | } |
| 408 | debug!("Ref validation passed"); | 510 | |
| 409 | } else { | 511 | // Return result with purgatory events |
| 410 | warn!("No state in auth_result - cannot validate refs"); | 512 | return Ok(AuthorizationResult { |
| 513 | authorized: true, | ||
| 514 | reason: auth_result.reason, | ||
| 515 | state: auth_result.state, | ||
| 516 | maintainers: auth_result.maintainers, | ||
| 517 | purgatory_events, | ||
| 518 | }); | ||
| 411 | } | 519 | } |
| 412 | 520 | ||
| 413 | Ok(auth_result) | 521 | // Only refs/nostr/ refs - return success with collected events |
| 522 | Ok(AuthorizationResult { | ||
| 523 | authorized: true, | ||
| 524 | reason: "Push to refs/nostr/ validated".to_string(), | ||
| 525 | state: None, | ||
| 526 | maintainers: vec![], | ||
| 527 | purgatory_events, | ||
| 528 | }) | ||
| 414 | } | 529 | } |
| 415 | 530 | ||
| 416 | /// Errors that can occur in Git handlers | 531 | /// Errors that can occur in Git handlers |
diff --git a/src/git/mod.rs b/src/git/mod.rs index 599a94b..5c99b3e 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs | |||
| @@ -340,6 +340,74 @@ pub fn validate_nostr_ref( | |||
| 340 | Ok(true) | 340 | Ok(true) |
| 341 | } | 341 | } |
| 342 | 342 | ||
| 343 | /// Clean up placeholder refs from all repositories on shutdown. | ||
| 344 | /// | ||
| 345 | /// Walks through all git repositories in the git_data_path and deletes | ||
| 346 | /// `refs/nostr/<event-id>` refs for the given event IDs. This is called | ||
| 347 | /// on shutdown to clean up placeholders created when git data arrived | ||
| 348 | /// before the corresponding PR event. | ||
| 349 | /// | ||
| 350 | /// # Arguments | ||
| 351 | /// * `git_data_path` - Base directory containing git repositories | ||
| 352 | /// * `event_ids` - Event IDs whose refs/nostr/ refs should be deleted | ||
| 353 | /// | ||
| 354 | /// # Returns | ||
| 355 | /// Number of refs successfully deleted | ||
| 356 | pub fn cleanup_placeholder_refs(git_data_path: &str, event_ids: &[String]) -> usize { | ||
| 357 | if event_ids.is_empty() { | ||
| 358 | return 0; | ||
| 359 | } | ||
| 360 | |||
| 361 | let git_path = PathBuf::from(git_data_path); | ||
| 362 | if !git_path.exists() { | ||
| 363 | debug!("Git data path does not exist: {}", git_data_path); | ||
| 364 | return 0; | ||
| 365 | } | ||
| 366 | |||
| 367 | let mut deleted_count = 0; | ||
| 368 | |||
| 369 | // Walk through all repositories (npub/repo.git structure) | ||
| 370 | if let Ok(npub_entries) = std::fs::read_dir(&git_path) { | ||
| 371 | for npub_entry in npub_entries.flatten() { | ||
| 372 | if !npub_entry.path().is_dir() { | ||
| 373 | continue; | ||
| 374 | } | ||
| 375 | |||
| 376 | // For each npub directory, check repos | ||
| 377 | if let Ok(repo_entries) = std::fs::read_dir(npub_entry.path()) { | ||
| 378 | for repo_entry in repo_entries.flatten() { | ||
| 379 | let repo_path = repo_entry.path(); | ||
| 380 | if !repo_path.is_dir() || !repo_path.to_string_lossy().ends_with(".git") { | ||
| 381 | continue; | ||
| 382 | } | ||
| 383 | |||
| 384 | // Try to delete refs/nostr/<event-id> for each placeholder event | ||
| 385 | for event_id in event_ids { | ||
| 386 | let ref_name = format!("refs/nostr/{}", event_id); | ||
| 387 | if delete_ref(&repo_path, &ref_name).is_ok() { | ||
| 388 | deleted_count += 1; | ||
| 389 | info!( | ||
| 390 | "Cleaned up placeholder ref {} from {}", | ||
| 391 | ref_name, | ||
| 392 | repo_path.display() | ||
| 393 | ); | ||
| 394 | } | ||
| 395 | } | ||
| 396 | } | ||
| 397 | } | ||
| 398 | } | ||
| 399 | } | ||
| 400 | |||
| 401 | if deleted_count > 0 { | ||
| 402 | info!( | ||
| 403 | "Shutdown cleanup: removed {} placeholder refs from git repositories", | ||
| 404 | deleted_count | ||
| 405 | ); | ||
| 406 | } | ||
| 407 | |||
| 408 | deleted_count | ||
| 409 | } | ||
| 410 | |||
| 343 | /// Get the current HEAD ref from a repository | 411 | /// Get the current HEAD ref from a repository |
| 344 | /// | 412 | /// |
| 345 | /// # Arguments | 413 | /// # Arguments |