diff --git a/Cargo.toml b/Cargo.toml index d7b6884..bfc7d7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/grasp06/endpoint.rs b/src/grasp06/endpoint.rs new file mode 100644 index 0000000..0f31dff --- /dev/null +++ b/src/grasp06/endpoint.rs @@ -0,0 +1,134 @@ +//! URL parsing for the GRASP-06 `/prs/` endpoint. +//! +//! Per the spec, the path layout is: +//! +//! ```text +//! /prs//.git/ +//! ``` +//! +//! 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//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//.git/` 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//.git/` URL. +/// +/// Returns `None` if the path is not in the `/prs/` URL space, if the +/// `` 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 { + 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 `/.git/` (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()?; + + // `.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//.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"); + } +} diff --git a/src/grasp06/fetch.rs b/src/grasp06/fetch.rs new file mode 100644 index 0000000..654fafb --- /dev/null +++ b/src/grasp06/fetch.rs @@ -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//.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//.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>, 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//.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>, 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//.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 +/// 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 +/// `/prs/.empty-template.git`; do not optimise until +/// measurable. +fn init_empty_bare_repo() -> Result { + 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) +} diff --git a/src/grasp06/mod.rs b/src/grasp06/mod.rs new file mode 100644 index 0000000..d43265a --- /dev/null +++ b/src/grasp06/mod.rs @@ -0,0 +1,21 @@ +//! GRASP-06 — Contributor Pull Request Submission. +//! +//! Spec: +//! 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//.git/`. +//! - 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; diff --git a/src/grasp06/paths.rs b/src/grasp06/paths.rs new file mode 100644 index 0000000..d971a59 --- /dev/null +++ b/src/grasp06/paths.rs @@ -0,0 +1,89 @@ +//! On-disk path conventions for the GRASP-06 `/prs/` endpoint. +//! +//! Repositories submitted via `/prs//.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 +//! / +//! prs/ <-- PRS_DISK_PREFIX +//! / +//! .git/ +//! ``` + +use std::path::{Path, PathBuf}; + +/// URL prefix used in HTTP paths: `/prs//.git/...`. +pub const PRS_URL_PREFIX: &str = "prs"; + +/// Directory name used on disk under ``. 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//.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 + )); + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 12d404b..bc53951 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -164,6 +164,157 @@ impl Service> for HttpService { }); } + // GRASP-06: route /prs//.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>, 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 diff --git a/src/lib.rs b/src/lib.rs index 7b01a07..cdeb91e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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;