feat(grasp06): accept pushes to refs/nostr/<event-id> on /prs/

Replaces the /prs/ git-receive-pack stub with a real handler that
satisfies 06.md line 15: pushes to refs/nostr/<64-lowercase-hex> are
accepted, everything else is rejected with an ERR pkt-line before any
on-disk state changes.

The new src/grasp06/receive module:

- Pre-scans the pkt-line ref-update list and fails the whole push
  before touching disk if any ref is outside the refs/nostr/<event-id>
  shape.
- Creates the bare repo on demand at the GRASP-06 path under a
  per-(submitter, identifier) tokio Mutex held only across git init,
  so concurrent first-pushes serialise their init step but the push
  itself relies on git's ref locking.
- Runs git-receive-pack with the same subprocess plumbing as the
  standard handler, reusing build_git_protocol_error_response /
  is_git_protocol_error (promoted to pub(crate)) for matching error
  framing.
- Validates each accepted refs/nostr/<event-id> against the database
  first, then purgatory, deleting the ref (and discarding any
  purgatory placeholder) on signer / a-tag identifier / c-tag commit
  mismatch. When no event is known, registers a PR placeholder so the
  standard 30-minute purgatory sweep can clean it up.
- Removes the repo directory entirely if validation leaves it with
  zero refs (a probe push that produced nothing valid).
- Drives process_newly_available_git_data so PR events already in
  purgatory waiting on these commits get promoted.

Shared helpers added in src/git/authorization.rs: get_pr_event_by_id
and extract_commit_tag, both reused by get_event_commit_tag so the
existing /<npub>/<id>.git push path is unchanged.
This commit is contained in:
DanConwayDev
2026-05-15 16:04:00 +00:00
parent 32217fe288
commit 77f63ea2a9
6 changed files with 583 additions and 70 deletions
+28 -20
View File
@@ -1161,7 +1161,25 @@ pub async fn get_event_commit_tag(
database: &SharedDatabase,
event_id: &EventId,
) -> Result<Option<String>> {
// 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<Option<Event>> {
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<String> {
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
+9 -2
View File
@@ -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<Full<Bytes>> {
@@ -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<i32>, 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<i32>, 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()
+7 -39
View File
@@ -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/<npub>/<id>.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<Full<Bytes>> {
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 " <message>)` 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<Full<Bytes>> {
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
+7 -4
View File
@@ -9,13 +9,16 @@
//! ## Current scope
//!
//! - URL parsing for `/prs/<npub>/<identifier>.git/<subpath>`.
//! - 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/<event-id>` 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;
+498
View File
@@ -0,0 +1,498 @@
//! GRASP-06 `/prs/` `git-receive-pack` handler.
//!
//! Spec 06.md line 15:
//!
//! > MUST accept pushes to `refs/nostr/<event-id>`. 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/<event-id>` 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/<submitter>/<identifier>.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<DashMap<PathBuf, Arc<Mutex<()>>>>;
/// 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/<npub>/<identifier>.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<Purgatory>,
write_policy: Arc<Nip34WritePolicy>,
rejected_events_index: Arc<RejectedEventsIndex>,
git_data_path: &str,
git_protocol: Option<&str>,
repo_init_locks: RepoInitLocks,
) -> Result<Response<Full<Bytes>>, 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/<event-id> 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/<event-id>`, 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<String> = 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<String> {
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<Response<Full<Bytes>>, 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/<event-id>` 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<String> {
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()),
}
}
+34 -5
View File
@@ -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<Nip34WritePolicy>,
/// Rejected events index for hot-cache re-processing after git push promotion
rejected_events_index: Arc<RejectedEventsIndex>,
/// 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<Purgatory>,
write_policy: Arc<Nip34WritePolicy>,
rejected_events_index: Arc<RejectedEventsIndex>,
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<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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/<event-id>, 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/<submitter>/<id>.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 {