From 9a8c551adfada379704d594a6ff3862f13857b8e Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 21 Nov 2025 13:37:57 +0000 Subject: add git http handling --- src/git/handlers.rs | 209 +++++++++++++++++++++++++++++++++++++++++ src/git/mod.rs | 127 +++++++++++++++++++++++++ src/git/protocol.rs | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/git/subprocess.rs | 140 ++++++++++++++++++++++++++++ 4 files changed, 730 insertions(+) create mode 100644 src/git/handlers.rs create mode 100644 src/git/mod.rs create mode 100644 src/git/protocol.rs create mode 100644 src/git/subprocess.rs (limited to 'src/git') diff --git a/src/git/handlers.rs b/src/git/handlers.rs new file mode 100644 index 0000000..0efc9d0 --- /dev/null +++ b/src/git/handlers.rs @@ -0,0 +1,209 @@ +//! Git HTTP Protocol Handlers +//! +//! This module implements the HTTP handlers for Git Smart HTTP protocol. + +use std::path::PathBuf; +use hyper::{body::Bytes, Response, StatusCode}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::{debug, error, warn}; + +use super::protocol::{GitService, PktLine}; +use super::subprocess::GitSubprocess; + +/// Handle GET /info/refs?service=git-{upload,receive}-pack +/// +/// This advertises the repository's refs to the client. +pub async fn handle_info_refs( + repo_path: PathBuf, + service: GitService, +) -> Result, GitError> { + debug!("Handling info/refs for {:?} with service {:?}", repo_path, service); + + // Check if repository exists + if !repo_path.exists() { + warn!("Repository not found: {:?}", repo_path); + return Err(GitError::RepositoryNotFound); + } + + // Spawn git with --advertise-refs + let mut git = GitSubprocess::spawn(service, &repo_path, true) + .map_err(|e| { + error!("Failed to spawn git process: {}", e); + GitError::ProcessSpawnFailed(e) + })?; + + // Read the output from git + let mut output = Vec::new(); + if let Some(stdout) = git.take_stdout() { + let mut stdout = stdout; + stdout.read_to_end(&mut output).await + .map_err(|e| { + error!("Failed to read git output: {}", e); + GitError::IoError(e) + })?; + } + + // Wait for process to complete + let status = git.wait().await + .map_err(|e| { + error!("Failed to wait for git process: {}", e); + GitError::IoError(e) + })?; + + if !status.success() { + error!("Git process failed with status: {:?}", status); + return Err(GitError::GitFailed(status.code())); + } + + // Build response with pkt-line header + let mut response_body = Vec::new(); + + // First line: service advertisement + let service_line = format!("# service={}\n", service.as_str()); + response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode()); + response_body.extend_from_slice(&PktLine::flush().encode()); + + // Then the git output + response_body.extend_from_slice(&output); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", service.advertisement_content_type()) + .header("cache-control", "no-cache") + .body(String::from_utf8_lossy(&response_body).to_string()) + .unwrap()) +} + +/// Handle POST /git-upload-pack (clone/fetch) +pub async fn handle_upload_pack( + repo_path: PathBuf, + request_body: Bytes, +) -> Result, GitError> { + debug!("Handling upload-pack for {:?}", repo_path); + + if !repo_path.exists() { + return Err(GitError::RepositoryNotFound); + } + + // Spawn git upload-pack + let mut git = GitSubprocess::spawn(GitService::UploadPack, &repo_path, false) + .map_err(GitError::ProcessSpawnFailed)?; + + // Write request to git's stdin + if let Some(mut stdin) = git.take_stdin() { + stdin.write_all(&request_body).await + .map_err(GitError::IoError)?; + // Close stdin to signal end of input + drop(stdin); + } + + // Read response from git's stdout + let mut 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)?; + } + + // Wait for process + let status = git.wait().await + .map_err(GitError::IoError)?; + + if !status.success() { + return Err(GitError::GitFailed(status.code())); + } + + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", GitService::UploadPack.result_content_type()) + .header("cache-control", "no-cache") + .body(String::from_utf8_lossy(&output).to_string()) + .unwrap()) +} + +/// Handle POST /git-receive-pack (push) +/// +/// This includes an authorization hook point where GRASP validation will be added. +pub async fn handle_receive_pack( + repo_path: PathBuf, + request_body: Bytes, +) -> Result, GitError> { + debug!("Handling receive-pack for {:?}", repo_path); + + if !repo_path.exists() { + return Err(GitError::RepositoryNotFound); + } + + // TODO: Add GRASP authorization here + // For now, we'll accept all pushes to enable testing + debug!("Authorization check would go here (currently accepting all pushes)"); + + // Spawn git receive-pack + let mut git = GitSubprocess::spawn(GitService::ReceivePack, &repo_path, false) + .map_err(GitError::ProcessSpawnFailed)?; + + // Write request to git's stdin + if let Some(mut stdin) = git.take_stdin() { + stdin.write_all(&request_body).await + .map_err(GitError::IoError)?; + drop(stdin); + } + + // Read response from git's stdout + let mut 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)?; + } + + // Wait for process + let status = git.wait().await + .map_err(GitError::IoError)?; + + if !status.success() { + 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(String::from_utf8_lossy(&output).to_string()) + .unwrap()) +} + +/// Errors that can occur in Git handlers +#[derive(Debug)] +pub enum GitError { + RepositoryNotFound, + ProcessSpawnFailed(std::io::Error), + IoError(std::io::Error), + GitFailed(Option), + Unauthorized, +} + +impl std::fmt::Display for GitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::RepositoryNotFound => write!(f, "repository not found"), + Self::ProcessSpawnFailed(e) => write!(f, "failed to spawn git process: {}", e), + Self::IoError(e) => write!(f, "IO error: {}", e), + Self::GitFailed(code) => write!(f, "git process failed with code: {:?}", code), + Self::Unauthorized => write!(f, "unauthorized"), + } + } +} + +impl std::error::Error for GitError {} + +impl GitError { + /// Convert to HTTP status code + pub fn status_code(&self) -> StatusCode { + match self { + Self::RepositoryNotFound => StatusCode::NOT_FOUND, + Self::Unauthorized => StatusCode::FORBIDDEN, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} \ No newline at end of file diff --git a/src/git/mod.rs b/src/git/mod.rs new file mode 100644 index 0000000..bd3b9e8 --- /dev/null +++ b/src/git/mod.rs @@ -0,0 +1,127 @@ +//! Git Smart HTTP Backend +//! +//! This module implements Git Smart HTTP protocol support for ngit-grasp. +//! It provides handlers for clone, fetch, and push operations over HTTP. +//! +//! # Architecture +//! +//! - `protocol` - Git pkt-line format parsing and utilities +//! - `subprocess` - Git process spawning and management +//! - `handlers` - HTTP request handlers for Git operations +//! +//! # URL Patterns +//! +//! The following URL patterns are supported: +//! - `GET //.git/info/refs?service=git-upload-pack` - Clone/fetch advertisement +//! - `GET //.git/info/refs?service=git-receive-pack` - Push advertisement +//! - `POST //.git/git-upload-pack` - Clone/fetch operation +//! - `POST //.git/git-receive-pack` - Push operation + +pub mod handlers; +pub mod protocol; +pub mod subprocess; + +use std::path::PathBuf; + +/// Parse a Git repository path from URL components +/// +/// Converts //.git/* to a filesystem path +/// +/// # Arguments +/// * `git_data_path` - Base directory for Git repositories +/// * `npub` - The npub (Nostr public key in bech32 format) +/// * `identifier` - The repository identifier +/// +/// # Returns +/// Path to the bare Git repository +pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { + // Remove .git suffix if present + let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); + + PathBuf::from(git_data_path) + .join(npub) + .join(format!("{}.git", identifier)) +} + +/// Extract npub and identifier from a Git URL path +/// +/// Parses paths like `//.git/info/refs` +/// +/// Returns (npub, identifier, subpath) where subpath is the part after .git/ +pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { + // Remove leading slash + let path = path.strip_prefix('/').unwrap_or(path); + + // Split into components + let parts: Vec<&str> = path.splitn(3, '/').collect(); + + if parts.len() < 3 { + return None; + } + + let npub = parts[0]; + let repo_part = parts[1]; + let subpath = parts[2]; + + // Extract identifier (remove .git suffix if present for the middle part) + let identifier = if repo_part.ends_with(".git") { + &repo_part[..repo_part.len() - 4] + } else { + repo_part + }; + + Some((npub, identifier, subpath)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_repo_path() { + let path = resolve_repo_path( + "/data/git", + "npub1abc123", + "my-repo" + ); + assert_eq!( + path, + PathBuf::from("/data/git/npub1abc123/my-repo.git") + ); + } + + #[test] + fn test_resolve_repo_path_with_git_suffix() { + let path = resolve_repo_path( + "/data/git", + "npub1abc123", + "my-repo.git" + ); + assert_eq!( + path, + PathBuf::from("/data/git/npub1abc123/my-repo.git") + ); + } + + #[test] + fn test_parse_git_url_info_refs() { + let (npub, id, subpath) = parse_git_url("/npub1abc/repo.git/info/refs").unwrap(); + assert_eq!(npub, "npub1abc"); + assert_eq!(id, "repo"); + assert_eq!(subpath, "info/refs"); + } + + #[test] + fn test_parse_git_url_upload_pack() { + let (npub, id, subpath) = parse_git_url("/npub1abc/repo.git/git-upload-pack").unwrap(); + assert_eq!(npub, "npub1abc"); + assert_eq!(id, "repo"); + assert_eq!(subpath, "git-upload-pack"); + } + + #[test] + fn test_parse_git_url_invalid() { + assert!(parse_git_url("/npub1abc").is_none()); + assert!(parse_git_url("/npub1abc/repo").is_none()); + } +} \ No newline at end of file diff --git a/src/git/protocol.rs b/src/git/protocol.rs new file mode 100644 index 0000000..84da131 --- /dev/null +++ b/src/git/protocol.rs @@ -0,0 +1,254 @@ +//! Git Smart HTTP Protocol Implementation +//! +//! This module implements the Git pkt-line format and protocol utilities. +//! +//! # Pkt-line Format +//! +//! A pkt-line is a variable length binary string with a 4-byte length prefix: +//! - First 4 bytes: hex digits representing total length (including these 4 bytes) +//! - Remaining bytes: payload data +//! - Special case "0000": flush packet (end of section) +//! +//! # References +//! - https://git-scm.com/docs/protocol-common#_pkt_line_format + +use std::fmt; + +/// Represents a Git pkt-line packet +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PktLine { + /// Data packet with payload + Data(Vec), + /// Flush packet (0000) + Flush, +} + +impl PktLine { + /// Create a data packet from bytes + pub fn data(data: impl Into>) -> Self { + Self::Data(data.into()) + } + + /// Create a flush packet + pub fn flush() -> Self { + Self::Flush + } + + /// Encode this packet to wire format + pub fn encode(&self) -> Vec { + match self { + PktLine::Flush => b"0000".to_vec(), + PktLine::Data(data) => { + let len = data.len() + 4; + let mut result = Vec::with_capacity(len); + result.extend_from_slice(format!("{:04x}", len).as_bytes()); + result.extend_from_slice(data); + result + } + } + } + + /// Parse a single pkt-line from bytes + /// Returns (packet, remaining_bytes) + pub fn parse(input: &[u8]) -> Result<(Self, &[u8]), ProtocolError> { + if input.len() < 4 { + return Err(ProtocolError::InsufficientData); + } + + let len_str = std::str::from_utf8(&input[0..4]) + .map_err(|_| ProtocolError::InvalidLength)?; + + let len = u16::from_str_radix(len_str, 16) + .map_err(|_| ProtocolError::InvalidLength)? as usize; + + if len == 0 { + // Flush packet + return Ok((PktLine::Flush, &input[4..])); + } + + if len < 4 { + return Err(ProtocolError::InvalidLength); + } + + if input.len() < len { + return Err(ProtocolError::InsufficientData); + } + + let data = input[4..len].to_vec(); + Ok((PktLine::Data(data), &input[len..])) + } + + /// Parse all pkt-lines from bytes + pub fn parse_all(mut input: &[u8]) -> Result, ProtocolError> { + let mut packets = Vec::new(); + + while !input.is_empty() { + let (packet, remaining) = Self::parse(input)?; + let is_flush = matches!(packet, PktLine::Flush); + packets.push(packet); + input = remaining; + + // Stop at flush packet + if is_flush { + break; + } + } + + Ok(packets) + } +} + +/// Errors that can occur during protocol parsing +#[derive(Debug)] +pub enum ProtocolError { + /// Not enough data to parse a complete packet + InsufficientData, + /// Invalid length prefix + InvalidLength, + /// Invalid UTF-8 in packet data + InvalidUtf8, + /// IO error + Io(std::io::Error), +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InsufficientData => write!(f, "insufficient data for pkt-line"), + Self::InvalidLength => write!(f, "invalid pkt-line length"), + Self::InvalidUtf8 => write!(f, "invalid UTF-8 in pkt-line"), + Self::Io(e) => write!(f, "IO error: {}", e), + } + } +} + +impl std::error::Error for ProtocolError {} + +impl From for ProtocolError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +/// Git service type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitService { + /// Upload pack (clone/fetch) + UploadPack, + /// Receive pack (push) + ReceivePack, +} + +impl GitService { + /// Parse service from query parameter + pub fn from_query_param(service: &str) -> Option { + match service { + "git-upload-pack" => Some(Self::UploadPack), + "git-receive-pack" => Some(Self::ReceivePack), + _ => None, + } + } + + /// Get the service name as used in Git protocol + pub fn as_str(&self) -> &'static str { + match self { + Self::UploadPack => "git-upload-pack", + Self::ReceivePack => "git-receive-pack", + } + } + + /// Get the content type for the service advertisement + pub fn advertisement_content_type(&self) -> &'static str { + match self { + Self::UploadPack => "application/x-git-upload-pack-advertisement", + Self::ReceivePack => "application/x-git-receive-pack-advertisement", + } + } + + /// Get the content type for the service result + pub fn result_content_type(&self) -> &'static str { + match self { + Self::UploadPack => "application/x-git-upload-pack-result", + Self::ReceivePack => "application/x-git-receive-pack-result", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pktline_encode_flush() { + let pkt = PktLine::flush(); + assert_eq!(pkt.encode(), b"0000"); + } + + #[test] + fn test_pktline_encode_data() { + let pkt = PktLine::data(b"hello"); + assert_eq!(pkt.encode(), b"0009hello"); + } + + #[test] + fn test_pktline_parse_flush() { + let (pkt, remaining) = PktLine::parse(b"0000extra").unwrap(); + assert_eq!(pkt, PktLine::Flush); + assert_eq!(remaining, b"extra"); + } + + #[test] + fn test_pktline_parse_data() { + let (pkt, remaining) = PktLine::parse(b"0009helloworld").unwrap(); + assert_eq!(pkt, PktLine::data(b"hello")); + assert_eq!(remaining, b"world"); + } + + #[test] + fn test_pktline_parse_insufficient_data() { + let result = PktLine::parse(b"000"); + assert!(matches!(result, Err(ProtocolError::InsufficientData))); + } + + #[test] + fn test_pktline_parse_invalid_length() { + let result = PktLine::parse(b"xxxx"); + assert!(matches!(result, Err(ProtocolError::InvalidLength))); + } + + #[test] + fn test_pktline_parse_all() { + let input = b"0009hello000aworld\n0000"; + let packets = PktLine::parse_all(input).unwrap(); + assert_eq!(packets.len(), 3); + assert_eq!(packets[0], PktLine::data(b"hello")); + assert_eq!(packets[1], PktLine::data(b"world\n")); + assert_eq!(packets[2], PktLine::Flush); + } + + #[test] + fn test_git_service_from_query() { + assert_eq!( + GitService::from_query_param("git-upload-pack"), + Some(GitService::UploadPack) + ); + assert_eq!( + GitService::from_query_param("git-receive-pack"), + Some(GitService::ReceivePack) + ); + assert_eq!(GitService::from_query_param("invalid"), None); + } + + #[test] + fn test_git_service_content_types() { + let upload = GitService::UploadPack; + assert_eq!( + upload.advertisement_content_type(), + "application/x-git-upload-pack-advertisement" + ); + assert_eq!( + upload.result_content_type(), + "application/x-git-upload-pack-result" + ); + } +} \ No newline at end of file diff --git a/src/git/subprocess.rs b/src/git/subprocess.rs new file mode 100644 index 0000000..96eed26 --- /dev/null +++ b/src/git/subprocess.rs @@ -0,0 +1,140 @@ +//! Git Subprocess Management +//! +//! This module provides utilities for spawning and managing Git subprocesses +//! for upload-pack and receive-pack operations. + +use std::path::Path; +use std::process::Stdio; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::process::{Child, Command}; + +use super::protocol::GitService; + +/// Git subprocess wrapper +pub struct GitSubprocess { + child: Child, +} + +impl GitSubprocess { + /// Spawn a git subprocess for the given service and repository path + /// + /// # Arguments + /// * `service` - The Git service (upload-pack or receive-pack) + /// * `repo_path` - Path to the bare Git repository + /// * `advertise` - If true, run with --advertise-refs flag + pub fn spawn( + service: GitService, + repo_path: impl AsRef, + advertise: bool, + ) -> std::io::Result { + let repo_path = repo_path.as_ref(); + + let mut cmd = Command::new("git"); + cmd.arg(service.as_str()); + + if advertise { + cmd.arg("--advertise-refs"); + } + + cmd.arg("--stateless-rpc"); + cmd.arg(repo_path); + + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let child = cmd.spawn()?; + + Ok(Self { child }) + } + + /// Get a mutable reference to stdin + pub fn stdin(&mut self) -> Option<&mut (impl AsyncWrite + Unpin)> { + self.child.stdin.as_mut() + } + + /// Get a mutable reference to stdout + pub fn stdout(&mut self) -> Option<&mut (impl AsyncRead + Unpin)> { + self.child.stdout.as_mut() + } + + /// Get a mutable reference to stderr + pub fn stderr(&mut self) -> Option<&mut (impl AsyncRead + Unpin)> { + self.child.stderr.as_mut() + } + + /// Take ownership of stdin + pub fn take_stdin(&mut self) -> Option { + self.child.stdin.take() + } + + /// Take ownership of stdout + pub fn take_stdout(&mut self) -> Option { + self.child.stdout.take() + } + + /// Take ownership of stderr + pub fn take_stderr(&mut self) -> Option { + self.child.stderr.take() + } + + /// Wait for the subprocess to complete + pub async fn wait(mut self) -> std::io::Result { + self.child.wait().await + } + + /// Kill the subprocess + pub async fn kill(&mut self) -> std::io::Result<()> { + self.child.kill().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + use std::process::Command as StdCommand; + + fn create_bare_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + let status = StdCommand::new("git") + .args(["init", "--bare"]) + .arg(dir.path()) + .status() + .expect("Failed to run git init"); + assert!(status.success()); + dir + } + + #[tokio::test] + async fn test_spawn_upload_pack_advertise() { + let repo = create_bare_repo(); + let mut proc = GitSubprocess::spawn( + GitService::UploadPack, + repo.path(), + true, + ).expect("Failed to spawn git"); + + // Should have spawned successfully + assert!(proc.stdout().is_some()); + assert!(proc.stdin().is_some()); + + // Clean up + let _ = proc.kill().await; + } + + #[tokio::test] + async fn test_spawn_receive_pack() { + let repo = create_bare_repo(); + let mut proc = GitSubprocess::spawn( + GitService::ReceivePack, + repo.path(), + false, + ).expect("Failed to spawn git"); + + assert!(proc.stdout().is_some()); + assert!(proc.stdin().is_some()); + + let _ = proc.kill().await; + } +} \ No newline at end of file -- cgit v1.2.3