feat(grasp06): /prs/ endpoint reachable with empty-repo fetch

Adds the `/prs/<npub>/<identifier>.git/*` URL space behind the existing
`NGIT_GRASP06_ENABLE` flag. Fetches synthesise an empty bare repo per
request (via `git init --bare` in a tempdir) when no real repo exists
on disk; if a real repo exists at `<git_data_path>/prs/<hex>/<id>.git`
it delegates to the standard `handle_info_refs` / `handle_upload_pack`.

The receive-pack endpoint is a deliberate stub that returns HTTP 200
with an ERR pkt-line ("GRASP-06 receive-pack not yet implemented"),
matching `build_git_protocol_error_response`'s shape so git clients
print the message and exit non-zero rather than seeing a 404. The real
receive-pack handler is not yet implemented.

Module layout under `src/grasp06/`:
  paths.rs    — PRS_URL_PREFIX, PRS_DISK_PREFIX, prs_repo_path,
                prs_base_path, is_prs_repo_path (hex on disk).
  endpoint.rs — PrsUrl + parse_prs_url (npub-only, percent-decoded id).
  fetch.rs    — handle_prs_info_refs, handle_prs_upload_pack, and the
                receive-pack stub.

Routing is wired into HttpService::call ahead of `parse_git_url` so
`/prs/...` paths can't be misclassified as standard owner repos. When
the flag is off the branch is skipped and `/prs/*` falls through to
the existing 404 path (preserving the discovery-gate contract).

Tests flipped green:
  - prs_fetch_unknown_path_serves_empty_repo_with_grasp_06
  - prs_push_other_refs_rejected_with_grasp_06       (ERR pkt-line)
  - standard_push_does_not_mirror_to_prs_with_grasp_06

Tests still green (no regression):
  - prs_namespace_404_when_grasp06_not_advertised_no_grasp_06
  - nip11_advertises_grasp_06_when_enabled
  - pr_event_rejected_when_clone_tag_does_not_name_prs_endpoint_with_grasp_06

Tests still red (deferred):
  - prs_push_refs_nostr_event_id_accepted_with_grasp_06
  - pr_event_accepted_when_clone_tag_names_prs_endpoint_with_grasp_06
  - prs_push_mirrors_to_announced_repo_with_grasp_06

`tempfile` is promoted from a dev-dependency to a runtime dependency
because the empty-repo synthesis path needs it. No new config options,
so the four-place config-sync contract is unaffected.
This commit is contained in:
DanConwayDev
2026-05-15 15:48:34 +00:00
parent 312840a885
commit 32217fe288
7 changed files with 562 additions and 0 deletions
+3
View File
@@ -57,6 +57,9 @@ anyhow = "1.0"
# Async traits
async-trait = "0.1"
# Temporary directories (used for GRASP-06 empty-repo synthesis)
tempfile = "3"
# Git (for future use)
# git-http-backend = "0.3"
+134
View File
@@ -0,0 +1,134 @@
//! URL parsing for the GRASP-06 `/prs/` endpoint.
//!
//! Per the spec, the path layout is:
//!
//! ```text
//! /prs/<npub>/<identifier>.git/<subpath>
//! ```
//!
//! The submitter is always supplied as a bech32 `npub1...` — hex pubkeys
//! are rejected per the spec. The identifier is percent-decoded so URLs
//! like `/prs/<npub>/my%20repo.git/info/refs` resolve to the same
//! identifier as the standard endpoint stores on disk.
use nostr_sdk::prelude::*;
use crate::git::percent_decode;
use crate::grasp06::paths::PRS_URL_PREFIX;
/// A parsed `/prs/<npub>/<id>.git/<subpath>` URL.
#[derive(Debug, Clone)]
pub struct PrsUrl {
/// The submitter's public key, decoded from the `npub1...` URL segment.
pub submitter: PublicKey,
/// The percent-decoded NIP-34 `d` identifier (no `.git` suffix).
pub identifier: String,
/// Everything after `.git/` in the URL path. May be empty.
pub subpath: String,
}
/// Parse a `/prs/<npub>/<identifier>.git/<subpath>` URL.
///
/// Returns `None` if the path is not in the `/prs/` URL space, if the
/// `<npub>` segment is not a valid bech32 npub, or if the second segment
/// does not end in `.git`. Identifiers are percent-decoded.
///
/// This intentionally rejects hex pubkeys: GRASP-06 specifies the URL
/// uses an `npub`, and accepting hex would silently change the on-disk
/// path layout assumed by [`crate::grasp06::paths::prs_repo_path`].
pub fn parse_prs_url(path: &str) -> Option<PrsUrl> {
let path = path.strip_prefix('/').unwrap_or(path);
// Require the literal `prs/` prefix.
let rest = path.strip_prefix(PRS_URL_PREFIX)?;
let rest = rest.strip_prefix('/')?;
// Split off `<npub>/<id>.git/<subpath>` (subpath may be empty).
let mut parts = rest.splitn(3, '/');
let npub_segment = parts.next()?;
let repo_segment = parts.next()?;
let subpath = parts.next().unwrap_or("").to_string();
if npub_segment.is_empty() || repo_segment.is_empty() {
return None;
}
// Spec is npub-only: reject hex (and anything else that isn't a
// valid bech32 npub).
if !npub_segment.starts_with("npub1") {
return None;
}
let submitter = PublicKey::from_bech32(npub_segment).ok()?;
// `<identifier>.git` (URL-encoded). Decode then strip the suffix.
let decoded = percent_decode(repo_segment);
let identifier = decoded.strip_suffix(".git")?.to_string();
if identifier.is_empty() {
return None;
}
Some(PrsUrl {
submitter,
identifier,
subpath,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// A valid bech32 npub for use in tests (generated once, deterministic).
fn sample_npub() -> String {
let keys = Keys::generate();
keys.public_key().to_bech32().unwrap()
}
#[test]
fn parses_info_refs() {
let npub = sample_npub();
let path = format!("/prs/{}/my-repo.git/info/refs", npub);
let parsed = parse_prs_url(&path).expect("should parse");
assert_eq!(parsed.identifier, "my-repo");
assert_eq!(parsed.subpath, "info/refs");
assert_eq!(parsed.submitter.to_bech32().unwrap(), npub);
}
#[test]
fn parses_with_empty_subpath() {
// Tolerate trailing `.git` with no subpath (e.g. `/prs/<npub>/<id>.git/`).
let npub = sample_npub();
let path = format!("/prs/{}/my-repo.git/", npub);
let parsed = parse_prs_url(&path).expect("should parse");
assert_eq!(parsed.subpath, "");
}
#[test]
fn requires_trailing_dot_git() {
let npub = sample_npub();
assert!(parse_prs_url(&format!("/prs/{}/my-repo/info/refs", npub)).is_none());
}
#[test]
fn rejects_hex_pubkey() {
// 64-hex is a valid pubkey form but the spec wants bech32.
let hex = "0".repeat(64);
assert!(parse_prs_url(&format!("/prs/{}/my-repo.git/info/refs", hex)).is_none());
}
#[test]
fn rejects_paths_outside_prs() {
let npub = sample_npub();
assert!(parse_prs_url(&format!("/{}/my-repo.git/info/refs", npub)).is_none());
assert!(parse_prs_url("/").is_none());
assert!(parse_prs_url("/prs/").is_none());
}
#[test]
fn percent_decodes_identifier() {
let npub = sample_npub();
let path = format!("/prs/{}/my%20repo.git/info/refs", npub);
let parsed = parse_prs_url(&path).expect("should parse");
assert_eq!(parsed.identifier, "my repo");
}
}
+163
View File
@@ -0,0 +1,163 @@
//! GRASP-06 `/prs/` fetch handlers and receive-pack stub.
//!
//! 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 is intentionally a stub for now: it returns an HTTP 200
//! response carrying an `ERR` pkt-line. The real handler is not yet
//! implemented.
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::{Response, StatusCode};
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::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 and be rejected by
/// the receive-pack stub via an ERR pkt-line.
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
}
/// 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
/// 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)
}
+21
View File
@@ -0,0 +1,21 @@
//! GRASP-06 — Contributor Pull Request Submission.
//!
//! Spec: <https://github.com/DanConwayDev/grasp/blob/main/06.md>
//! Design: `docs/explanation/grasp-06-contributor-pr-submission.md`
//!
//! This module is gated behind [`crate::config::Config::grasp06_enable`].
//! When disabled, `/prs/*` requests fall through to the standard 404 path.
//!
//! ## Current scope
//!
//! - URL parsing for `/prs/<npub>/<identifier>.git/<subpath>`.
//! - On-disk path conventions for future phases.
//! - 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.
//!
//! Real receive-pack, purgatory scoping, event-acceptance relaxation, and
//! cross-service mirroring are not yet implemented.
pub mod endpoint;
pub mod fetch;
pub mod paths;
+89
View File
@@ -0,0 +1,89 @@
//! On-disk path conventions for the GRASP-06 `/prs/` endpoint.
//!
//! Repositories submitted via `/prs/<npub>/<identifier>.git` are stored
//! under a dedicated subtree of the git data directory so existing
//! subsystems (cleanup, sync, listings) can exclude them with a single
//! path-prefix check. The submitter pubkey is stored as **hex** on disk;
//! the identifier is stored verbatim (URL percent-decoding happens at
//! parse time in [`crate::grasp06::endpoint`]).
//!
//! ```text
//! <git_data_path>/
//! prs/ <-- PRS_DISK_PREFIX
//! <submitter-hex>/
//! <identifier>.git/
//! ```
use std::path::{Path, PathBuf};
/// URL prefix used in HTTP paths: `/prs/<npub>/<identifier>.git/...`.
pub const PRS_URL_PREFIX: &str = "prs";
/// Directory name used on disk under `<git_data_path>`. Kept identical to
/// `PRS_URL_PREFIX` so an operator scanning the filesystem can match the
/// URL space at a glance.
pub const PRS_DISK_PREFIX: &str = "prs";
/// Return the base directory under which all `/prs/` repos live.
pub fn prs_base_path(git_data_path: &Path) -> PathBuf {
git_data_path.join(PRS_DISK_PREFIX)
}
/// Build the on-disk path for `/prs/<npub>/<identifier>.git`.
///
/// `submitter_hex` is the 64-character lowercase hex of the submitter's
/// public key. `identifier` is the percent-decoded NIP-34 `d` value as
/// stored on disk (verbatim).
pub fn prs_repo_path(git_data_path: &Path, submitter_hex: &str, identifier: &str) -> PathBuf {
let identifier = identifier.strip_suffix(".git").unwrap_or(identifier);
prs_base_path(git_data_path)
.join(submitter_hex)
.join(format!("{}.git", identifier))
}
/// True if `path` is inside the `/prs/` subtree of `git_data_path`.
///
/// Used by future cleanup and sync subsystems to skip these repos —
/// they have their own lifecycle (zero-ref cleanup, no announcement
/// promotion) and must not be treated as standard owner repos.
pub fn is_prs_repo_path(path: &Path, git_data_path: &Path) -> bool {
path.starts_with(prs_base_path(git_data_path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prs_base_path_appends_prs() {
assert_eq!(
prs_base_path(Path::new("/data/git")),
PathBuf::from("/data/git/prs")
);
}
#[test]
fn prs_repo_path_uses_hex_pubkey() {
let p = prs_repo_path(Path::new("/data/git"), "deadbeef", "my-repo");
assert_eq!(p, PathBuf::from("/data/git/prs/deadbeef/my-repo.git"));
}
#[test]
fn prs_repo_path_strips_trailing_git() {
let p = prs_repo_path(Path::new("/data/git"), "deadbeef", "my-repo.git");
assert_eq!(p, PathBuf::from("/data/git/prs/deadbeef/my-repo.git"));
}
#[test]
fn is_prs_repo_path_detects_subtree() {
let root = Path::new("/data/git");
assert!(is_prs_repo_path(
&PathBuf::from("/data/git/prs/abc/repo.git"),
root
));
assert!(!is_prs_repo_path(
&PathBuf::from("/data/git/npub1xyz/repo.git"),
root
));
}
}
+151
View File
@@ -164,6 +164,157 @@ impl Service<Request<Incoming>> for HttpService {
});
}
// GRASP-06: route /prs/<npub>/<id>.git/* before the standard git URL
// parser. When disabled, the path falls through to existing 404
// handling (preserving the discovery-gate contract).
if self.config.grasp06_enable {
if let Some(prs) = crate::grasp06::endpoint::parse_prs_url(&path) {
let git_protocol = req
.headers()
.get("git-protocol")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let content_encoding = req
.headers()
.get("content-encoding")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_lowercase());
tracing::debug!(
"/prs/ request: {} {} (submitter={}, id={}, subpath={}, protocol={:?})",
method,
path,
prs.submitter.to_hex(),
prs.identifier,
prs.subpath,
git_protocol
);
let subpath = prs.subpath.clone();
let method_clone = method.clone();
let metrics_clone = self.metrics.clone();
return Box::pin(async move {
// Collect (and gunzip if needed) the request body just like
// the standard git branch does.
let raw_body = req
.collect()
.await
.map(|collected| collected.to_bytes())
.unwrap_or_else(|_| Bytes::new());
let body_bytes = if content_encoding.as_deref() == Some("gzip") {
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(&raw_body[..]);
let mut decompressed = Vec::new();
match decoder.read_to_end(&mut decompressed) {
Ok(_) => Bytes::from(decompressed),
Err(e) => {
tracing::warn!("/prs/ gzip decompress failed: {}", e);
raw_body
}
}
} else {
raw_body
};
let result: Result<Response<Full<Bytes>>, git::handlers::GitError> =
match (method_clone.as_ref(), subpath.as_str()) {
// GET /info/refs?service=git-{upload,receive}-pack
(m, sp) if m == Method::GET && sp.starts_with("info/refs") => {
let service = query
.as_deref()
.unwrap_or("")
.strip_prefix("service=")
.and_then(git::protocol::GitService::from_query_param);
match service {
Some(svc) => {
let r = crate::grasp06::fetch::handle_prs_info_refs(
&prs,
&git_data_path,
svc,
git_protocol.as_deref(),
)
.await;
if let Some(ref m) = metrics_clone {
let status =
if r.is_ok() { "success" } else { "error" };
let op = match svc {
git::protocol::GitService::UploadPack => "fetch",
git::protocol::GitService::ReceivePack => "push",
};
m.record_git_operation(op, status);
}
r
}
None => Err(git::handlers::GitError::RepositoryNotFound),
}
}
// POST /git-upload-pack — clone/fetch.
(m, "git-upload-pack") if m == Method::POST => {
let r = crate::grasp06::fetch::handle_prs_upload_pack(
&prs,
&git_data_path,
body_bytes,
git_protocol.as_deref(),
)
.await;
if let Some(ref m) = metrics_clone {
let status = if r.is_ok() { "success" } else { "error" };
m.record_git_operation("clone", status);
}
r
}
// POST /git-receive-pack — stub for now.
// Returns HTTP 200 with an ERR pkt-line; the
// real handler is not yet implemented.
(m, "git-receive-pack") if m == Method::POST => {
if let Some(ref m) = metrics_clone {
m.record_git_operation("push", "error");
}
Ok(crate::grasp06::fetch::handle_prs_receive_pack_stub())
}
_ => Err(git::handlers::GitError::RepositoryNotFound),
};
match result {
Ok(response) => {
let (parts, body) = response.into_parts();
Ok(add_cors_headers(Response::builder().status(parts.status))
.header(
"content-type",
parts
.headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream"),
)
.header(
"cache-control",
parts
.headers
.get("cache-control")
.and_then(|v| v.to_str().ok())
.unwrap_or("no-cache"),
)
.body(body)
.unwrap())
}
Err(e) => {
let error_msg = format!("Git error: {}", e);
Ok(add_cors_headers(Response::builder())
.status(e.status_code())
.body(Full::new(Bytes::from(error_msg)))
.unwrap())
}
}
});
}
}
// Check for Git HTTP requests first
if let Some((npub, identifier, subpath)) = git::parse_git_url(&path) {
// Extract Git-Protocol header for protocol v2 support
+1
View File
@@ -2,6 +2,7 @@ pub mod audit_cleanup;
pub mod cleanup_empty_repos;
pub mod config;
pub mod git;
pub mod grasp06;
pub mod http;
pub mod metrics;
pub mod nostr;