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/protocol.rs | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 src/git/protocol.rs (limited to 'src/git/protocol.rs') 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 -- cgit v1.2.3