diff --git a/src/git/authorization.rs b/src/git/authorization.rs index da5ad4b..1c1e18f 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs @@ -1161,7 +1161,25 @@ pub async fn get_event_commit_tag( database: &SharedDatabase, event_id: &EventId, ) -> Result> { - // Query for PR (1618) and PR Update (1619) events with this ID + match get_pr_event_by_id(database, event_id).await? { + Some(event) => Ok(extract_commit_tag(&event)), + None => { + debug!("No PR/PR Update event found with ID {}", event_id); + Ok(None) + } + } +} + +/// Fetch a PR (kind 1617) or PR-Update (kind 1618) event by its ID. +/// +/// Returns the first matching event, or `None` if no such event has been +/// accepted into the database. Used by both `get_event_commit_tag` and the +/// `/prs/` receive-pack handler — the latter needs the full event so it can +/// verify the signer pubkey and the `a`-tag identifier, not just the `c` tag. +pub async fn get_pr_event_by_id( + database: &SharedDatabase, + event_id: &EventId, +) -> Result> { let filter = Filter::new() .ids([*event_id]) .kinds([Kind::GitPullRequest, Kind::GitPullRequestUpdate]); @@ -1173,29 +1191,19 @@ pub async fn get_event_commit_tag( .into_iter() .collect(); - if events.is_empty() { - debug!("No PR/PR Update event found with ID {}", event_id); - return Ok(None); - } + Ok(events.into_iter().next()) +} - // Get the first (should be only) event - let event = &events[0]; - - // Extract the `c` tag (commit hash) - // Per NIP-34, PR events have a `c` tag with the head commit - let commit = event +/// Extract the `c` (commit) tag value from a NIP-34 PR/PR-Update event. +/// +/// Per NIP-34, PR events carry a `c` tag whose second element is the head +/// commit being proposed. Returns `None` if the tag is missing or malformed. +pub fn extract_commit_tag(event: &Event) -> Option { + event .tags .iter() .find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("c")) - .and_then(|tag| tag.as_slice().get(1).map(|s| s.to_string())); - - debug!( - "Found PR event {} with commit tag: {:?}", - event_id, - commit.as_ref() - ); - - Ok(commit) + .and_then(|tag| tag.as_slice().get(1).map(|s| s.to_string())) } /// Validate refs/nostr/ pushes against existing PR/PR Update events diff --git a/src/git/handlers.rs b/src/git/handlers.rs index b615251..7fb471f 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs @@ -107,7 +107,11 @@ pub async fn handle_info_refs( /// `PKT-LINE("ERR" SP explanation-text)` /// /// This allows git clients to properly parse and display the error message. -fn build_git_protocol_error_response( +/// +/// `pub(crate)` so the `/prs/` receive-pack handler in `crate::grasp06::receive` +/// can return identically-shaped rejections without duplicating the pkt-line +/// framing. +pub(crate) fn build_git_protocol_error_response( service: GitService, error_message: &str, ) -> Response> { @@ -130,7 +134,10 @@ fn build_git_protocol_error_response( /// /// Transport errors (process spawn failures, I/O errors, signals) should /// remain as HTTP 500 errors. -fn is_git_protocol_error(exit_code: Option, stderr: &[u8]) -> bool { +/// +/// `pub(crate)` so the `/prs/` receive-pack handler in `crate::grasp06::receive` +/// can classify subprocess failures the same way without duplicating the rule. +pub(crate) fn is_git_protocol_error(exit_code: Option, stderr: &[u8]) -> bool { // Git uses exit code 128 for protocol/usage errors // If there's stderr content, it's a protocol error message exit_code == Some(128) && !stderr.is_empty() diff --git a/src/grasp06/fetch.rs b/src/grasp06/fetch.rs index 654fafb..2f96cce 100644 --- a/src/grasp06/fetch.rs +++ b/src/grasp06/fetch.rs @@ -1,4 +1,4 @@ -//! GRASP-06 `/prs/` fetch handlers and receive-pack stub. +//! GRASP-06 `/prs/` fetch handlers. //! //! Spec 06.md line 13: //! @@ -10,20 +10,18 @@ //! Otherwise we synthesise a brand-new empty bare repo in a per-request //! temporary directory and run the standard upload-pack against that. //! -//! Receive-pack is intentionally a stub for now: it returns an HTTP 200 -//! response carrying an `ERR` pkt-line. The real handler is not yet -//! implemented. +//! Receive-pack lives in [`crate::grasp06::receive`]. use http_body_util::Full; use hyper::body::Bytes; -use hyper::{Response, StatusCode}; +use hyper::Response; use std::path::Path; use std::process::Command; use tempfile::TempDir; use tracing::{debug, warn}; use crate::git::handlers::{handle_info_refs, handle_upload_pack, GitError}; -use crate::git::protocol::{GitService, PktLine}; +use crate::git::protocol::GitService; use crate::grasp06::endpoint::PrsUrl; use crate::grasp06::paths::prs_repo_path; @@ -32,8 +30,9 @@ use crate::grasp06::paths::prs_repo_path; /// Used for both `git-upload-pack` (clone/fetch) discovery and /// `git-receive-pack` discovery: in both cases we advertise the refs of /// an empty bare repo when no real repo exists at the requested path, -/// so that `git push` can proceed to its POST step and be rejected by -/// the receive-pack stub via an ERR pkt-line. +/// so that `git push` can proceed to its POST step (where +/// [`crate::grasp06::receive::handle_prs_receive_pack`] validates the +/// ref-update list and initialises the repo on demand if appropriate). pub async fn handle_prs_info_refs( prs: &PrsUrl, git_data_path: &str, @@ -103,37 +102,6 @@ pub async fn handle_prs_upload_pack( response } -/// Stub `POST /prs//.git/git-receive-pack`. -/// -/// Returns HTTP 200 with an `ERR` pkt-line so that git clients display -/// the message and exit non-zero. The real receive-pack handler (with -/// ref-name validation, init-on-push, and purgatory wiring) is not yet -/// implemented. -pub fn handle_prs_receive_pack_stub() -> Response> { - build_receive_pack_err("GRASP-06 receive-pack not yet implemented") -} - -/// Build an HTTP 200 response carrying a `git-receive-pack` ERR pkt-line. -/// -/// Per the git smart-HTTP protocol, application-level rejections are -/// communicated as a single `PKT-LINE("ERR " )` packet on a -/// 200 response. A 4xx/5xx response would prevent the client from -/// parsing and displaying the message. The shape here mirrors -/// `build_git_protocol_error_response` in [`crate::git::handlers`]. -fn build_receive_pack_err(message: &str) -> Response> { - let payload = format!("ERR {}\n", message.trim()); - let pktline = PktLine::data(payload.as_bytes()).encode(); - Response::builder() - .status(StatusCode::OK) - .header( - "content-type", - GitService::ReceivePack.result_content_type(), - ) - .header("cache-control", "no-cache") - .body(Full::new(Bytes::from(pktline))) - .unwrap() -} - /// Create a fresh empty bare repo in a temp directory. /// /// Per-request temp dirs are deliberate. They are cheap on diff --git a/src/grasp06/mod.rs b/src/grasp06/mod.rs index d43265a..7273823 100644 --- a/src/grasp06/mod.rs +++ b/src/grasp06/mod.rs @@ -9,13 +9,16 @@ //! ## Current scope //! //! - URL parsing for `/prs//.git/`. -//! - On-disk path conventions for future phases. +//! - On-disk path conventions. //! - Empty-bare-repo synthesis for `info/refs` and `git-upload-pack`. -//! - A stub `git-receive-pack` handler that returns an HTTP 200 ERR pkt-line. +//! - `git-receive-pack` accepting pushes to `refs/nostr/` and +//! rejecting any other ref namespace, with init-on-push and per-ref +//! post-push validation against the database / purgatory. //! -//! Real receive-pack, purgatory scoping, event-acceptance relaxation, and -//! cross-service mirroring are not yet implemented. +//! Event-acceptance relaxation for un-announced coords and cross-service +//! mirroring into matching announced repos are not yet implemented. pub mod endpoint; pub mod fetch; pub mod paths; +pub mod receive; diff --git a/src/grasp06/receive.rs b/src/grasp06/receive.rs new file mode 100644 index 0000000..6022306 --- /dev/null +++ b/src/grasp06/receive.rs @@ -0,0 +1,498 @@ +//! GRASP-06 `/prs/` `git-receive-pack` handler. +//! +//! Spec 06.md line 15: +//! +//! > MUST accept pushes to `refs/nostr/`. MUST reject pushes to any +//! > other ref namespace. +//! +//! The handler: +//! +//! 1. Pre-scans the pkt-line ref-update list and rejects the entire push (via +//! an `ERR` pkt-line on a 200 response) if *any* ref name is not of the +//! exact shape `refs/nostr/<64-lowercase-hex>`. The repo on disk is not +//! touched in this path — probe pushes that would have been rejected +//! leave no trace. +//! 2. Creates the bare repo on demand at the GRASP-06 path. A +//! per-`(submitter, identifier)` mutex (lazily inserted into a `DashMap`) +//! serialises only the `git init --bare` step; ref locking inside the +//! repo is left to git itself for the actual push. +//! 3. Runs `git-receive-pack` against the repo, mirroring the subprocess +//! plumbing in [`crate::git::handlers::handle_receive_pack`]. +//! 4. For each accepted `refs/nostr/` ref, validates against the +//! database first, then purgatory. On signer / `a`-tag identifier / +//! `c`-tag commit mismatch the ref is deleted and (for purgatory +//! placeholders) the placeholder is discarded. When neither the database +//! nor purgatory knows about the event, a PR placeholder is added so the +//! standard 30-minute purgatory sweep can clean it up if the event never +//! arrives. +//! 5. After validation, the repo is removed if it has zero refs left — +//! discarding probe pushes that produced no valid state. +//! 6. Triggers the standard purgatory-release path via +//! [`crate::git::sync::process_newly_available_git_data`] so PR events +//! already in purgatory waiting for these commits get promoted. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use dashmap::DashMap; +use http_body_util::Full; +use hyper::body::Bytes; +use hyper::{Response, StatusCode}; +use nostr_relay_builder::LocalRelay; +use nostr_sdk::prelude::*; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex; +use tracing::{debug, error, info, warn}; + +use crate::git::authorization::{extract_commit_tag, get_pr_event_by_id, parse_pushed_refs}; +use crate::git::handlers::{build_git_protocol_error_response, is_git_protocol_error, GitError}; +use crate::git::protocol::GitService; +use crate::git::subprocess::GitSubprocess; +use crate::git::sync::{extract_identifier_from_pr_event, process_newly_available_git_data}; +use crate::git::{delete_ref, list_refs}; +use crate::grasp06::endpoint::PrsUrl; +use crate::grasp06::paths::prs_repo_path; +use crate::nostr::builder::{Nip34WritePolicy, SharedDatabase}; +use crate::purgatory::Purgatory; +use crate::sync::rejected_index::RejectedEventsIndex; + +/// Shared lock map ensuring a single `git init --bare` runs per +/// `/prs//.git` path at a time. +/// +/// The lock is only held across the directory-creation + `git init` step. +/// Once the repo exists, git's own ref locking handles intra-push +/// concurrency, so simultaneous pushes to the same path proceed in parallel +/// after init. +pub type RepoInitLocks = Arc>>>; + +/// Create a fresh, empty [`RepoInitLocks`] for use as an [`HttpService`] +/// field. +/// +/// [`HttpService`]: crate::http +pub fn new_repo_init_locks() -> RepoInitLocks { + Arc::new(DashMap::new()) +} + +/// Handle `POST /prs//.git/git-receive-pack`. +/// +/// See the module-level docs for the full algorithm. All application-level +/// rejections are returned as HTTP 200 with an `ERR` pkt-line so the git +/// client can display the message and exit non-zero. +#[allow(clippy::too_many_arguments)] +pub async fn handle_prs_receive_pack( + prs: &PrsUrl, + request_body: Bytes, + database: SharedDatabase, + relay: LocalRelay, + purgatory: Arc, + write_policy: Arc, + rejected_events_index: Arc, + git_data_path: &str, + git_protocol: Option<&str>, + repo_init_locks: RepoInitLocks, +) -> Result>, GitError> { + // 1. Pre-scan refs and reject the whole push if any ref name is not + // `refs/nostr/<64-lowercase-hex>`. We use the same parser as the + // standard receive-pack path so behaviour stays in lock-step. + let pushed_refs = parse_pushed_refs(&request_body); + if pushed_refs.is_empty() { + warn!( + "/prs/ receive-pack: no parsable refs in push to {}/{}", + prs.submitter.to_hex(), + prs.identifier + ); + return Ok(build_git_protocol_error_response( + GitService::ReceivePack, + "no ref updates found in push", + )); + } + + for (_, _, ref_name) in &pushed_refs { + if let Some(reason) = invalid_ref_reason(ref_name) { + warn!( + "/prs/ receive-pack: rejecting push to {}/{} — {}", + prs.submitter.to_hex(), + prs.identifier, + reason + ); + return Ok(build_git_protocol_error_response( + GitService::ReceivePack, + &format!( + "GRASP-06: only pushes to refs/nostr/ are accepted ({})", + reason + ), + )); + } + } + + // 2. Create the bare repo on demand under a per-path mutex. Only the + // init step is serialised; the push itself relies on git's ref + // locking. + let repo_path = prs_repo_path( + Path::new(git_data_path), + &prs.submitter.to_hex(), + &prs.identifier, + ); + if let Err(e) = ensure_repo_initialised(&repo_path, &repo_init_locks).await { + error!( + "/prs/ receive-pack: failed to initialise repo at {}: {}", + repo_path.display(), + e + ); + return Err(e); + } + + // 3. Run git-receive-pack against the now-existing repo. + let response = run_receive_pack(&repo_path, &request_body, git_protocol).await?; + + // If the push itself failed with a protocol error (e.g. a stale OID or + // a corrupt pack) we return that ERR pkt-line straight back to the + // client without doing any post-push validation. The pre-scan in step 1 + // already gated ref-name shape, so we only land here for git-level + // failures. + if response.status() != StatusCode::OK { + return Ok(response); + } + + // 4. Per-ref post-push validation. Iterate over the same parsed ref + // list — at this point every ref name is known to be + // `refs/nostr/`, so the strip is infallible. + for (_, new_oid, ref_name) in &pushed_refs { + let event_id_hex = ref_name + .strip_prefix("refs/nostr/") + .expect("ref shape validated above"); + validate_pushed_nostr_ref( + &database, + &purgatory, + &repo_path, + prs, + event_id_hex, + new_oid, + ) + .await; + } + + // 5. If validation removed every ref, the repo is empty: a probe push + // that left no valid state. Delete the directory so we don't + // accumulate empty repos. + if let Ok(refs) = list_refs(&repo_path) { + if refs.is_empty() { + if let Err(e) = std::fs::remove_dir_all(&repo_path) { + warn!( + "/prs/ receive-pack: failed to clean up empty repo {}: {}", + repo_path.display(), + e + ); + } else { + debug!( + "/prs/ receive-pack: removed empty repo {} (probe push left no valid refs)", + repo_path.display() + ); + } + } + } + + // 6. Drive the standard purgatory-release pipeline so PR events + // already waiting on these commits can be promoted out of + // purgatory. We pass the `/prs/` repo path through unchanged — + // the cross-service mirror lives elsewhere and is a future + // addition. + let new_oids: HashSet = pushed_refs + .iter() + .filter(|(_, new_oid, _)| new_oid != "0000000000000000000000000000000000000000") + .map(|(_, new_oid, _)| new_oid.clone()) + .collect(); + + if repo_path.exists() { + if let Err(e) = process_newly_available_git_data( + &repo_path, + &new_oids, + &database, + Some(&relay), + &purgatory, + Path::new(git_data_path), + Some(&write_policy), + Some(&rejected_events_index), + ) + .await + { + warn!( + "/prs/ receive-pack: post-push processing failed for {}/{}: {}", + prs.submitter.to_hex(), + prs.identifier, + e + ); + } + } + + Ok(response) +} + +/// Return `Some(reason)` if `ref_name` is not exactly +/// `refs/nostr/<64-lowercase-hex>`. +/// +/// The shape is deliberately strict: anything else (including upper-case +/// hex, short/long event IDs, or `refs/heads/*`) is "any other ref +/// namespace" per the spec and must be rejected. +fn invalid_ref_reason(ref_name: &str) -> Option { + let Some(event_id) = ref_name.strip_prefix("refs/nostr/") else { + return Some(format!("ref {} is outside refs/nostr/", ref_name)); + }; + if event_id.len() != 64 { + return Some(format!( + "event-id segment of {} is {} chars, expected 64", + ref_name, + event_id.len() + )); + } + if !event_id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Some(format!( + "event-id segment of {} is not lowercase hex", + ref_name + )); + } + None +} + +/// Acquire the per-path init mutex, then `mkdir -p` the parent and +/// `git init --bare --initial-branch=main --quiet` into `repo_path` if it +/// does not already exist. +async fn ensure_repo_initialised(repo_path: &Path, locks: &RepoInitLocks) -> Result<(), GitError> { + let lock = locks + .entry(repo_path.to_path_buf()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .value() + .clone(); + let _guard = lock.lock().await; + + if repo_path.exists() { + return Ok(()); + } + + if let Some(parent) = repo_path.parent() { + std::fs::create_dir_all(parent).map_err(GitError::IoError)?; + } + + let output = std::process::Command::new("git") + .args(["init", "--bare", "--initial-branch=main", "--quiet"]) + .arg(repo_path) + .output() + .map_err(GitError::ProcessSpawnFailed)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!( + "/prs/ git init --bare failed at {}: {}", + repo_path.display(), + stderr.trim() + ); + return Err(GitError::GitFailed(output.status.code())); + } + + info!( + "/prs/ initialised bare repo at {} on demand", + repo_path.display() + ); + Ok(()) +} + +/// Spawn `git-receive-pack`, stream stdin/stdout/stderr, and convert the +/// result into an HTTP response — protocol errors become 200 + ERR +/// pkt-line, transport errors bubble up as [`GitError`]. Mirrors +/// [`crate::git::handlers::handle_receive_pack`]'s subprocess plumbing. +async fn run_receive_pack( + repo_path: &Path, + request_body: &Bytes, + git_protocol: Option<&str>, +) -> Result>, GitError> { + let mut git = GitSubprocess::spawn(GitService::ReceivePack, repo_path, false, git_protocol) + .map_err(GitError::ProcessSpawnFailed)?; + + if let Some(mut stdin) = git.take_stdin() { + stdin + .write_all(request_body) + .await + .map_err(GitError::IoError)?; + drop(stdin); + } + + let mut output = Vec::new(); + let mut stderr_output = Vec::new(); + + if let Some(stdout) = git.take_stdout() { + let mut stdout = stdout; + stdout + .read_to_end(&mut output) + .await + .map_err(GitError::IoError)?; + } + if let Some(stderr) = git.take_stderr() { + let mut stderr = stderr; + stderr + .read_to_end(&mut stderr_output) + .await + .map_err(GitError::IoError)?; + } + + let status = git.wait().await.map_err(GitError::IoError)?; + + if !status.success() { + let stderr_str = String::from_utf8_lossy(&stderr_output); + if is_git_protocol_error(status.code(), &stderr_output) { + warn!( + "/prs/ git-receive-pack protocol error (returning ERR pkt-line): {}", + stderr_str.trim() + ); + return Ok(build_git_protocol_error_response( + GitService::ReceivePack, + &stderr_str, + )); + } + error!( + "/prs/ git-receive-pack failed (transport): {}", + stderr_str.trim() + ); + return Err(GitError::GitFailed(status.code())); + } + + Ok(Response::builder() + .status(StatusCode::OK) + .header( + "content-type", + GitService::ReceivePack.result_content_type(), + ) + .header("cache-control", "no-cache") + .body(Full::new(Bytes::from(output))) + .unwrap()) +} + +/// Validate one accepted `refs/nostr/` ref against the database +/// and purgatory, deleting the ref (and discarding the purgatory +/// placeholder, where applicable) on any mismatch. If no event is known at +/// all, register a placeholder so the 30-minute purgatory sweep can clean +/// the ref up if the event never arrives. +async fn validate_pushed_nostr_ref( + database: &SharedDatabase, + purgatory: &Purgatory, + repo_path: &Path, + prs: &PrsUrl, + event_id_hex: &str, + pushed_commit: &str, +) { + let ref_name = format!("refs/nostr/{}", event_id_hex); + + // Parsing the event id this late ensures the on-disk ref shape and the + // in-memory key formats agree. + let event_id = match EventId::parse(event_id_hex) { + Ok(id) => id, + Err(e) => { + // Should be unreachable thanks to the pre-scan, but if it does + // happen we delete the ref defensively rather than leave an + // unparseable placeholder around. + warn!( + "/prs/ post-push: unexpected unparseable event id in ref {}: {}", + ref_name, e + ); + let _ = delete_ref(repo_path, &ref_name); + return; + } + }; + + // 4a. Database first. + match get_pr_event_by_id(database, &event_id).await { + Ok(Some(event)) => { + if let Some(reason) = describe_pr_event_mismatch(&event, prs, pushed_commit) { + warn!( + "/prs/ post-push: deleting {} — DB event mismatch ({})", + ref_name, reason + ); + let _ = delete_ref(repo_path, &ref_name); + } else { + debug!("/prs/ post-push: {} validated against DB event", ref_name); + } + return; + } + Ok(None) => {} + Err(e) => { + warn!( + "/prs/ post-push: DB query failed for {} (treating as not-found): {}", + ref_name, e + ); + } + } + + // 4b. Purgatory. + if let Some(entry) = purgatory.find_pr(event_id_hex) { + match entry.event { + Some(event) => { + if let Some(reason) = describe_pr_event_mismatch(&event, prs, pushed_commit) { + warn!( + "/prs/ post-push: deleting {} — purgatory event mismatch ({})", + ref_name, reason + ); + let _ = delete_ref(repo_path, &ref_name); + purgatory.remove_pr(event_id_hex); + } else { + debug!( + "/prs/ post-push: {} validated against purgatory event", + ref_name + ); + } + } + None => { + // Existing placeholder. The pushed commit fills in the + // commit half of the entry; the standard 30-minute sweep + // will discard it if the event never arrives. + debug!( + "/prs/ post-push: {} matched existing purgatory placeholder", + ref_name + ); + } + } + return; + } + + // 4c. Neither DB nor purgatory know about this event. Register a + // placeholder so the standard sweep can clean the ref up if the + // corresponding PR event never arrives. + purgatory.add_pr_placeholder(event_id_hex.to_string(), pushed_commit.to_string()); + debug!( + "/prs/ post-push: added PR placeholder for {} awaiting matching event", + ref_name + ); +} + +/// Cross-check a known PR/PR-Update event against the URL submitter and the +/// pushed commit. Returns `None` if everything matches, or `Some(reason)` +/// describing the first mismatch encountered. +fn describe_pr_event_mismatch(event: &Event, prs: &PrsUrl, pushed_commit: &str) -> Option { + if event.pubkey != prs.submitter { + return Some(format!( + "signer {} does not match URL submitter {}", + event.pubkey.to_hex(), + prs.submitter.to_hex() + )); + } + match extract_identifier_from_pr_event(event) { + Some(id) if id == prs.identifier => {} + Some(id) => { + return Some(format!( + "a-tag identifier {} does not match URL identifier {}", + id, prs.identifier + )) + } + None => return Some("event has no parsable a-tag identifier".to_string()), + } + match extract_commit_tag(event) { + Some(c) if c == pushed_commit => None, + Some(c) => Some(format!( + "c-tag commit {} does not match pushed commit {}", + c, pushed_commit + )), + None => Some("event has no c-tag commit".to_string()), + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs index bc53951..4f0548d 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -25,6 +25,7 @@ use tokio::net::TcpListener; use crate::config::Config; use crate::git; +use crate::grasp06::receive::{new_repo_init_locks, RepoInitLocks}; use crate::metrics::Metrics; use crate::nostr::builder::{Nip34WritePolicy, SharedDatabase}; use crate::purgatory::Purgatory; @@ -108,6 +109,9 @@ struct HttpService { write_policy: Arc, /// Rejected events index for hot-cache re-processing after git push promotion rejected_events_index: Arc, + /// Per-path init mutexes for GRASP-06 `/prs/` on-demand bare-repo + /// creation. See [`crate::grasp06::receive::RepoInitLocks`]. + repo_init_locks: RepoInitLocks, } impl HttpService { @@ -121,6 +125,7 @@ impl HttpService { purgatory: Arc, write_policy: Arc, rejected_events_index: Arc, + repo_init_locks: RepoInitLocks, ) -> Self { Self { relay, @@ -131,6 +136,7 @@ impl HttpService { purgatory, write_policy, rejected_events_index, + repo_init_locks, } } } @@ -150,6 +156,7 @@ impl Service> for HttpService { let purgatory = self.purgatory.clone(); let write_policy = self.write_policy.clone(); let rejected_events_index = self.rejected_events_index.clone(); + let repo_init_locks = self.repo_init_locks.clone(); // Handle OPTIONS preflight requests (CORS) // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content @@ -193,6 +200,7 @@ impl Service> for HttpService { let subpath = prs.subpath.clone(); let method_clone = method.clone(); let metrics_clone = self.metrics.clone(); + let relay_clone = self.relay.clone(); return Box::pin(async move { // Collect (and gunzip if needed) the request body just like @@ -267,14 +275,28 @@ impl Service> for HttpService { r } - // POST /git-receive-pack — stub for now. - // Returns HTTP 200 with an ERR pkt-line; the - // real handler is not yet implemented. + // POST /git-receive-pack — accept pushes to + // refs/nostr/, reject anything else, + // per GRASP-06 06.md line 15. (m, "git-receive-pack") if m == Method::POST => { + let r = crate::grasp06::receive::handle_prs_receive_pack( + &prs, + body_bytes, + database.clone(), + relay_clone.clone(), + purgatory.clone(), + write_policy.clone(), + rejected_events_index.clone(), + &git_data_path, + git_protocol.as_deref(), + repo_init_locks.clone(), + ) + .await; if let Some(ref m) = metrics_clone { - m.record_git_operation("push", "error"); + let status = if r.is_ok() { "success" } else { "error" }; + m.record_git_operation("push", status); } - Ok(crate::grasp06::fetch::handle_prs_receive_pack_stub()) + r } _ => Err(git::handlers::GitError::RepositoryNotFound), @@ -742,6 +764,12 @@ pub async fn run_server( let listener = TcpListener::bind(&bind_addr).await?; + // Shared per-path init mutexes for the GRASP-06 `/prs/` endpoint. Lives + // for the lifetime of the server so concurrent pushes to the same + // `/prs//.git` path serialise their on-demand + // `git init --bare` step. + let repo_init_locks = new_repo_init_locks(); + loop { let (socket, addr) = listener.accept().await?; let io = TokioIo::new(socket); @@ -754,6 +782,7 @@ pub async fn run_server( purgatory.clone(), write_policy.clone(), rejected_events_index.clone(), + repo_init_locks.clone(), ); tokio::spawn(async move {