Files
ngit-grasp/src/grasp06/fetch.rs
T
DanConwayDev 77f63ea2a9 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.
2026-05-15 16:04:00 +00:00

132 lines
4.4 KiB
Rust

//! GRASP-06 `/prs/` fetch handlers.
//!
//! Spec 06.md line 13:
//!
//! > MUST respond to upload-pack requests for any well-formed path as if
//! > serving an empty bare repository.
//!
//! When a real `/prs/<submitter>/<identifier>.git` repo exists on disk
//! we delegate to the standard handlers in [`crate::git::handlers`].
//! 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 lives in [`crate::grasp06::receive`].
use http_body_util::Full;
use hyper::body::Bytes;
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;
use crate::grasp06::endpoint::PrsUrl;
use crate::grasp06::paths::prs_repo_path;
/// Handle `GET /prs/<npub>/<id>.git/info/refs?service=...`.
///
/// 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 (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,
service: GitService,
git_protocol: Option<&str>,
) -> Result<Response<Full<Bytes>>, GitError> {
let real_repo = prs_repo_path(
Path::new(git_data_path),
&prs.submitter.to_hex(),
&prs.identifier,
);
if real_repo.exists() {
debug!(
"/prs/ info/refs: real repo found at {} — delegating",
real_repo.display()
);
return handle_info_refs(real_repo, service, git_protocol).await;
}
debug!(
"/prs/ info/refs: synthesising empty bare repo for prs={}/{}",
prs.submitter.to_hex(),
prs.identifier
);
let temp = init_empty_bare_repo()?;
let repo_path = temp.path().to_path_buf();
let response = handle_info_refs(repo_path, service, git_protocol).await;
// `temp` is dropped here, deleting the directory. Git has already
// read everything it needs by the time `handle_info_refs` returns.
drop(temp);
response
}
/// Handle `POST /prs/<npub>/<id>.git/git-upload-pack`.
///
/// Same synthesise-or-delegate behaviour as [`handle_prs_info_refs`].
pub async fn handle_prs_upload_pack(
prs: &PrsUrl,
git_data_path: &str,
body: Bytes,
git_protocol: Option<&str>,
) -> Result<Response<Full<Bytes>>, GitError> {
let real_repo = prs_repo_path(
Path::new(git_data_path),
&prs.submitter.to_hex(),
&prs.identifier,
);
if real_repo.exists() {
debug!(
"/prs/ upload-pack: real repo found at {} — delegating",
real_repo.display()
);
return handle_upload_pack(real_repo, body, git_protocol).await;
}
debug!(
"/prs/ upload-pack: synthesising empty bare repo for prs={}/{}",
prs.submitter.to_hex(),
prs.identifier
);
let temp = init_empty_bare_repo()?;
let repo_path = temp.path().to_path_buf();
let response = handle_upload_pack(repo_path, body, git_protocol).await;
drop(temp);
response
}
/// Create a fresh empty bare repo in a temp directory.
///
/// Per-request temp dirs are deliberate. They are cheap on
/// any reasonable filesystem (one `mkdir`, one `git init --bare`) and
/// avoid any concurrency hazards a shared template would introduce. If
/// profiling shows this is a bottleneck we can switch to a shared
/// `<git_data_path>/prs/.empty-template.git`; do not optimise until
/// measurable.
fn init_empty_bare_repo() -> Result<TempDir, GitError> {
let temp = TempDir::new().map_err(GitError::IoError)?;
let path = temp.path();
let output = Command::new("git")
.args(["init", "--bare", "--quiet"])
.arg(path)
.output()
.map_err(GitError::ProcessSpawnFailed)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"/prs/ empty-repo synthesis: git init --bare failed in {}: {}",
path.display(),
stderr.trim()
);
return Err(GitError::GitFailed(output.status.code()));
}
Ok(temp)
}