upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-21 13:37:57 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-21 13:37:57 +0000
commit9a8c551adfada379704d594a6ff3862f13857b8e (patch)
treea902c6b313ab40a8914a34380ee194524dd67604
parent12756fa66e3ec7f9dd24c66598085772829a8063 (diff)
add git http handling
-rw-r--r--Cargo.lock42
-rw-r--r--Cargo.toml1
-rw-r--r--src/git/handlers.rs209
-rw-r--r--src/git/mod.rs127
-rw-r--r--src/git/protocol.rs254
-rw-r--r--src/git/subprocess.rs140
-rw-r--r--src/http/mod.rs78
-rw-r--r--src/lib.rs1
-rw-r--r--src/main.rs6
9 files changed, 851 insertions, 7 deletions
diff --git a/Cargo.lock b/Cargo.lock
index a53f2ec..aa78542 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3,6 +3,12 @@
3version = 3 3version = 3
4 4
5[[package]] 5[[package]]
6name = "adler2"
7version = "2.0.1"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
11[[package]]
6name = "aead" 12name = "aead"
7version = "0.5.2" 13version = "0.5.2"
8source = "registry+https://github.com/rust-lang/crates.io-index" 14source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -418,6 +424,15 @@ dependencies = [
418] 424]
419 425
420[[package]] 426[[package]]
427name = "crc32fast"
428version = "1.5.0"
429source = "registry+https://github.com/rust-lang/crates.io-index"
430checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
431dependencies = [
432 "cfg-if",
433]
434
435[[package]]
421name = "crypto-common" 436name = "crypto-common"
422version = "0.1.6" 437version = "0.1.6"
423source = "registry+https://github.com/rust-lang/crates.io-index" 438source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -506,6 +521,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
506checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" 521checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
507 522
508[[package]] 523[[package]]
524name = "flate2"
525version = "1.1.5"
526source = "registry+https://github.com/rust-lang/crates.io-index"
527checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
528dependencies = [
529 "crc32fast",
530 "miniz_oxide",
531]
532
533[[package]]
509name = "fnv" 534name = "fnv"
510version = "1.0.7" 535version = "1.0.7"
511source = "registry+https://github.com/rust-lang/crates.io-index" 536source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1169,6 +1194,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1169checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1194checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1170 1195
1171[[package]] 1196[[package]]
1197name = "miniz_oxide"
1198version = "0.8.9"
1199source = "registry+https://github.com/rust-lang/crates.io-index"
1200checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
1201dependencies = [
1202 "adler2",
1203 "simd-adler32",
1204]
1205
1206[[package]]
1172name = "mio" 1207name = "mio"
1173version = "1.1.0" 1208version = "1.1.0"
1174source = "registry+https://github.com/rust-lang/crates.io-index" 1209source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1209,6 +1244,7 @@ dependencies = [
1209 "anyhow", 1244 "anyhow",
1210 "base64 0.22.1", 1245 "base64 0.22.1",
1211 "dotenvy", 1246 "dotenvy",
1247 "flate2",
1212 "futures-util", 1248 "futures-util",
1213 "grasp-audit", 1249 "grasp-audit",
1214 "http-body-util", 1250 "http-body-util",
@@ -1986,6 +2022,12 @@ dependencies = [
1986] 2022]
1987 2023
1988[[package]] 2024[[package]]
2025name = "simd-adler32"
2026version = "0.3.7"
2027source = "registry+https://github.com/rust-lang/crates.io-index"
2028checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
2029
2030[[package]]
1989name = "slab" 2031name = "slab"
1990version = "0.4.11" 2032version = "0.4.11"
1991source = "registry+https://github.com/rust-lang/crates.io-index" 2033source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 368c090..3e836f9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,6 +25,7 @@ nostr-sdk = "0.44"
25# Utilities 25# Utilities
26futures-util = "0.3" 26futures-util = "0.3"
27base64 = "0.22" 27base64 = "0.22"
28flate2 = "1.0"
28 29
29# Serialization 30# Serialization
30serde = { version = "1.0", features = ["derive"] } 31serde = { version = "1.0", features = ["derive"] }
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 @@
1//! Git HTTP Protocol Handlers
2//!
3//! This module implements the HTTP handlers for Git Smart HTTP protocol.
4
5use std::path::PathBuf;
6use hyper::{body::Bytes, Response, StatusCode};
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8use tracing::{debug, error, warn};
9
10use super::protocol::{GitService, PktLine};
11use super::subprocess::GitSubprocess;
12
13/// Handle GET /info/refs?service=git-{upload,receive}-pack
14///
15/// This advertises the repository's refs to the client.
16pub async fn handle_info_refs(
17 repo_path: PathBuf,
18 service: GitService,
19) -> Result<Response<String>, GitError> {
20 debug!("Handling info/refs for {:?} with service {:?}", repo_path, service);
21
22 // Check if repository exists
23 if !repo_path.exists() {
24 warn!("Repository not found: {:?}", repo_path);
25 return Err(GitError::RepositoryNotFound);
26 }
27
28 // Spawn git with --advertise-refs
29 let mut git = GitSubprocess::spawn(service, &repo_path, true)
30 .map_err(|e| {
31 error!("Failed to spawn git process: {}", e);
32 GitError::ProcessSpawnFailed(e)
33 })?;
34
35 // Read the output from git
36 let mut output = Vec::new();
37 if let Some(stdout) = git.take_stdout() {
38 let mut stdout = stdout;
39 stdout.read_to_end(&mut output).await
40 .map_err(|e| {
41 error!("Failed to read git output: {}", e);
42 GitError::IoError(e)
43 })?;
44 }
45
46 // Wait for process to complete
47 let status = git.wait().await
48 .map_err(|e| {
49 error!("Failed to wait for git process: {}", e);
50 GitError::IoError(e)
51 })?;
52
53 if !status.success() {
54 error!("Git process failed with status: {:?}", status);
55 return Err(GitError::GitFailed(status.code()));
56 }
57
58 // Build response with pkt-line header
59 let mut response_body = Vec::new();
60
61 // First line: service advertisement
62 let service_line = format!("# service={}\n", service.as_str());
63 response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode());
64 response_body.extend_from_slice(&PktLine::flush().encode());
65
66 // Then the git output
67 response_body.extend_from_slice(&output);
68
69 Ok(Response::builder()
70 .status(StatusCode::OK)
71 .header("content-type", service.advertisement_content_type())
72 .header("cache-control", "no-cache")
73 .body(String::from_utf8_lossy(&response_body).to_string())
74 .unwrap())
75}
76
77/// Handle POST /git-upload-pack (clone/fetch)
78pub async fn handle_upload_pack(
79 repo_path: PathBuf,
80 request_body: Bytes,
81) -> Result<Response<String>, GitError> {
82 debug!("Handling upload-pack for {:?}", repo_path);
83
84 if !repo_path.exists() {
85 return Err(GitError::RepositoryNotFound);
86 }
87
88 // Spawn git upload-pack
89 let mut git = GitSubprocess::spawn(GitService::UploadPack, &repo_path, false)
90 .map_err(GitError::ProcessSpawnFailed)?;
91
92 // Write request to git's stdin
93 if let Some(mut stdin) = git.take_stdin() {
94 stdin.write_all(&request_body).await
95 .map_err(GitError::IoError)?;
96 // Close stdin to signal end of input
97 drop(stdin);
98 }
99
100 // Read response from git's stdout
101 let mut output = Vec::new();
102 if let Some(stdout) = git.take_stdout() {
103 let mut stdout = stdout;
104 stdout.read_to_end(&mut output).await
105 .map_err(GitError::IoError)?;
106 }
107
108 // Wait for process
109 let status = git.wait().await
110 .map_err(GitError::IoError)?;
111
112 if !status.success() {
113 return Err(GitError::GitFailed(status.code()));
114 }
115
116 Ok(Response::builder()
117 .status(StatusCode::OK)
118 .header("content-type", GitService::UploadPack.result_content_type())
119 .header("cache-control", "no-cache")
120 .body(String::from_utf8_lossy(&output).to_string())
121 .unwrap())
122}
123
124/// Handle POST /git-receive-pack (push)
125///
126/// This includes an authorization hook point where GRASP validation will be added.
127pub async fn handle_receive_pack(
128 repo_path: PathBuf,
129 request_body: Bytes,
130) -> Result<Response<String>, GitError> {
131 debug!("Handling receive-pack for {:?}", repo_path);
132
133 if !repo_path.exists() {
134 return Err(GitError::RepositoryNotFound);
135 }
136
137 // TODO: Add GRASP authorization here
138 // For now, we'll accept all pushes to enable testing
139 debug!("Authorization check would go here (currently accepting all pushes)");
140
141 // Spawn git receive-pack
142 let mut git = GitSubprocess::spawn(GitService::ReceivePack, &repo_path, false)
143 .map_err(GitError::ProcessSpawnFailed)?;
144
145 // Write request to git's stdin
146 if let Some(mut stdin) = git.take_stdin() {
147 stdin.write_all(&request_body).await
148 .map_err(GitError::IoError)?;
149 drop(stdin);
150 }
151
152 // Read response from git's stdout
153 let mut output = Vec::new();
154 if let Some(stdout) = git.take_stdout() {
155 let mut stdout = stdout;
156 stdout.read_to_end(&mut output).await
157 .map_err(GitError::IoError)?;
158 }
159
160 // Wait for process
161 let status = git.wait().await
162 .map_err(GitError::IoError)?;
163
164 if !status.success() {
165 return Err(GitError::GitFailed(status.code()));
166 }
167
168 Ok(Response::builder()
169 .status(StatusCode::OK)
170 .header("content-type", GitService::ReceivePack.result_content_type())
171 .header("cache-control", "no-cache")
172 .body(String::from_utf8_lossy(&output).to_string())
173 .unwrap())
174}
175
176/// Errors that can occur in Git handlers
177#[derive(Debug)]
178pub enum GitError {
179 RepositoryNotFound,
180 ProcessSpawnFailed(std::io::Error),
181 IoError(std::io::Error),
182 GitFailed(Option<i32>),
183 Unauthorized,
184}
185
186impl std::fmt::Display for GitError {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 match self {
189 Self::RepositoryNotFound => write!(f, "repository not found"),
190 Self::ProcessSpawnFailed(e) => write!(f, "failed to spawn git process: {}", e),
191 Self::IoError(e) => write!(f, "IO error: {}", e),
192 Self::GitFailed(code) => write!(f, "git process failed with code: {:?}", code),
193 Self::Unauthorized => write!(f, "unauthorized"),
194 }
195 }
196}
197
198impl std::error::Error for GitError {}
199
200impl GitError {
201 /// Convert to HTTP status code
202 pub fn status_code(&self) -> StatusCode {
203 match self {
204 Self::RepositoryNotFound => StatusCode::NOT_FOUND,
205 Self::Unauthorized => StatusCode::FORBIDDEN,
206 _ => StatusCode::INTERNAL_SERVER_ERROR,
207 }
208 }
209} \ 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 @@
1//! Git Smart HTTP Backend
2//!
3//! This module implements Git Smart HTTP protocol support for ngit-grasp.
4//! It provides handlers for clone, fetch, and push operations over HTTP.
5//!
6//! # Architecture
7//!
8//! - `protocol` - Git pkt-line format parsing and utilities
9//! - `subprocess` - Git process spawning and management
10//! - `handlers` - HTTP request handlers for Git operations
11//!
12//! # URL Patterns
13//!
14//! The following URL patterns are supported:
15//! - `GET /<npub>/<identifier>.git/info/refs?service=git-upload-pack` - Clone/fetch advertisement
16//! - `GET /<npub>/<identifier>.git/info/refs?service=git-receive-pack` - Push advertisement
17//! - `POST /<npub>/<identifier>.git/git-upload-pack` - Clone/fetch operation
18//! - `POST /<npub>/<identifier>.git/git-receive-pack` - Push operation
19
20pub mod handlers;
21pub mod protocol;
22pub mod subprocess;
23
24use std::path::PathBuf;
25
26/// Parse a Git repository path from URL components
27///
28/// Converts /<npub>/<identifier>.git/* to a filesystem path
29///
30/// # Arguments
31/// * `git_data_path` - Base directory for Git repositories
32/// * `npub` - The npub (Nostr public key in bech32 format)
33/// * `identifier` - The repository identifier
34///
35/// # Returns
36/// Path to the bare Git repository
37pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf {
38 // Remove .git suffix if present
39 let identifier = identifier.strip_suffix(".git").unwrap_or(identifier);
40
41 PathBuf::from(git_data_path)
42 .join(npub)
43 .join(format!("{}.git", identifier))
44}
45
46/// Extract npub and identifier from a Git URL path
47///
48/// Parses paths like `/<npub>/<identifier>.git/info/refs`
49///
50/// Returns (npub, identifier, subpath) where subpath is the part after .git/
51pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> {
52 // Remove leading slash
53 let path = path.strip_prefix('/').unwrap_or(path);
54
55 // Split into components
56 let parts: Vec<&str> = path.splitn(3, '/').collect();
57
58 if parts.len() < 3 {
59 return None;
60 }
61
62 let npub = parts[0];
63 let repo_part = parts[1];
64 let subpath = parts[2];
65
66 // Extract identifier (remove .git suffix if present for the middle part)
67 let identifier = if repo_part.ends_with(".git") {
68 &repo_part[..repo_part.len() - 4]
69 } else {
70 repo_part
71 };
72
73 Some((npub, identifier, subpath))
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
81 fn test_resolve_repo_path() {
82 let path = resolve_repo_path(
83 "/data/git",
84 "npub1abc123",
85 "my-repo"
86 );
87 assert_eq!(
88 path,
89 PathBuf::from("/data/git/npub1abc123/my-repo.git")
90 );
91 }
92
93 #[test]
94 fn test_resolve_repo_path_with_git_suffix() {
95 let path = resolve_repo_path(
96 "/data/git",
97 "npub1abc123",
98 "my-repo.git"
99 );
100 assert_eq!(
101 path,
102 PathBuf::from("/data/git/npub1abc123/my-repo.git")
103 );
104 }
105
106 #[test]
107 fn test_parse_git_url_info_refs() {
108 let (npub, id, subpath) = parse_git_url("/npub1abc/repo.git/info/refs").unwrap();
109 assert_eq!(npub, "npub1abc");
110 assert_eq!(id, "repo");
111 assert_eq!(subpath, "info/refs");
112 }
113
114 #[test]
115 fn test_parse_git_url_upload_pack() {
116 let (npub, id, subpath) = parse_git_url("/npub1abc/repo.git/git-upload-pack").unwrap();
117 assert_eq!(npub, "npub1abc");
118 assert_eq!(id, "repo");
119 assert_eq!(subpath, "git-upload-pack");
120 }
121
122 #[test]
123 fn test_parse_git_url_invalid() {
124 assert!(parse_git_url("/npub1abc").is_none());
125 assert!(parse_git_url("/npub1abc/repo").is_none());
126 }
127} \ 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 @@
1//! Git Smart HTTP Protocol Implementation
2//!
3//! This module implements the Git pkt-line format and protocol utilities.
4//!
5//! # Pkt-line Format
6//!
7//! A pkt-line is a variable length binary string with a 4-byte length prefix:
8//! - First 4 bytes: hex digits representing total length (including these 4 bytes)
9//! - Remaining bytes: payload data
10//! - Special case "0000": flush packet (end of section)
11//!
12//! # References
13//! - https://git-scm.com/docs/protocol-common#_pkt_line_format
14
15use std::fmt;
16
17/// Represents a Git pkt-line packet
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum PktLine {
20 /// Data packet with payload
21 Data(Vec<u8>),
22 /// Flush packet (0000)
23 Flush,
24}
25
26impl PktLine {
27 /// Create a data packet from bytes
28 pub fn data(data: impl Into<Vec<u8>>) -> Self {
29 Self::Data(data.into())
30 }
31
32 /// Create a flush packet
33 pub fn flush() -> Self {
34 Self::Flush
35 }
36
37 /// Encode this packet to wire format
38 pub fn encode(&self) -> Vec<u8> {
39 match self {
40 PktLine::Flush => b"0000".to_vec(),
41 PktLine::Data(data) => {
42 let len = data.len() + 4;
43 let mut result = Vec::with_capacity(len);
44 result.extend_from_slice(format!("{:04x}", len).as_bytes());
45 result.extend_from_slice(data);
46 result
47 }
48 }
49 }
50
51 /// Parse a single pkt-line from bytes
52 /// Returns (packet, remaining_bytes)
53 pub fn parse(input: &[u8]) -> Result<(Self, &[u8]), ProtocolError> {
54 if input.len() < 4 {
55 return Err(ProtocolError::InsufficientData);
56 }
57
58 let len_str = std::str::from_utf8(&input[0..4])
59 .map_err(|_| ProtocolError::InvalidLength)?;
60
61 let len = u16::from_str_radix(len_str, 16)
62 .map_err(|_| ProtocolError::InvalidLength)? as usize;
63
64 if len == 0 {
65 // Flush packet
66 return Ok((PktLine::Flush, &input[4..]));
67 }
68
69 if len < 4 {
70 return Err(ProtocolError::InvalidLength);
71 }
72
73 if input.len() < len {
74 return Err(ProtocolError::InsufficientData);
75 }
76
77 let data = input[4..len].to_vec();
78 Ok((PktLine::Data(data), &input[len..]))
79 }
80
81 /// Parse all pkt-lines from bytes
82 pub fn parse_all(mut input: &[u8]) -> Result<Vec<Self>, ProtocolError> {
83 let mut packets = Vec::new();
84
85 while !input.is_empty() {
86 let (packet, remaining) = Self::parse(input)?;
87 let is_flush = matches!(packet, PktLine::Flush);
88 packets.push(packet);
89 input = remaining;
90
91 // Stop at flush packet
92 if is_flush {
93 break;
94 }
95 }
96
97 Ok(packets)
98 }
99}
100
101/// Errors that can occur during protocol parsing
102#[derive(Debug)]
103pub enum ProtocolError {
104 /// Not enough data to parse a complete packet
105 InsufficientData,
106 /// Invalid length prefix
107 InvalidLength,
108 /// Invalid UTF-8 in packet data
109 InvalidUtf8,
110 /// IO error
111 Io(std::io::Error),
112}
113
114impl fmt::Display for ProtocolError {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 Self::InsufficientData => write!(f, "insufficient data for pkt-line"),
118 Self::InvalidLength => write!(f, "invalid pkt-line length"),
119 Self::InvalidUtf8 => write!(f, "invalid UTF-8 in pkt-line"),
120 Self::Io(e) => write!(f, "IO error: {}", e),
121 }
122 }
123}
124
125impl std::error::Error for ProtocolError {}
126
127impl From<std::io::Error> for ProtocolError {
128 fn from(e: std::io::Error) -> Self {
129 Self::Io(e)
130 }
131}
132
133/// Git service type
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum GitService {
136 /// Upload pack (clone/fetch)
137 UploadPack,
138 /// Receive pack (push)
139 ReceivePack,
140}
141
142impl GitService {
143 /// Parse service from query parameter
144 pub fn from_query_param(service: &str) -> Option<Self> {
145 match service {
146 "git-upload-pack" => Some(Self::UploadPack),
147 "git-receive-pack" => Some(Self::ReceivePack),
148 _ => None,
149 }
150 }
151
152 /// Get the service name as used in Git protocol
153 pub fn as_str(&self) -> &'static str {
154 match self {
155 Self::UploadPack => "git-upload-pack",
156 Self::ReceivePack => "git-receive-pack",
157 }
158 }
159
160 /// Get the content type for the service advertisement
161 pub fn advertisement_content_type(&self) -> &'static str {
162 match self {
163 Self::UploadPack => "application/x-git-upload-pack-advertisement",
164 Self::ReceivePack => "application/x-git-receive-pack-advertisement",
165 }
166 }
167
168 /// Get the content type for the service result
169 pub fn result_content_type(&self) -> &'static str {
170 match self {
171 Self::UploadPack => "application/x-git-upload-pack-result",
172 Self::ReceivePack => "application/x-git-receive-pack-result",
173 }
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn test_pktline_encode_flush() {
183 let pkt = PktLine::flush();
184 assert_eq!(pkt.encode(), b"0000");
185 }
186
187 #[test]
188 fn test_pktline_encode_data() {
189 let pkt = PktLine::data(b"hello");
190 assert_eq!(pkt.encode(), b"0009hello");
191 }
192
193 #[test]
194 fn test_pktline_parse_flush() {
195 let (pkt, remaining) = PktLine::parse(b"0000extra").unwrap();
196 assert_eq!(pkt, PktLine::Flush);
197 assert_eq!(remaining, b"extra");
198 }
199
200 #[test]
201 fn test_pktline_parse_data() {
202 let (pkt, remaining) = PktLine::parse(b"0009helloworld").unwrap();
203 assert_eq!(pkt, PktLine::data(b"hello"));
204 assert_eq!(remaining, b"world");
205 }
206
207 #[test]
208 fn test_pktline_parse_insufficient_data() {
209 let result = PktLine::parse(b"000");
210 assert!(matches!(result, Err(ProtocolError::InsufficientData)));
211 }
212
213 #[test]
214 fn test_pktline_parse_invalid_length() {
215 let result = PktLine::parse(b"xxxx");
216 assert!(matches!(result, Err(ProtocolError::InvalidLength)));
217 }
218
219 #[test]
220 fn test_pktline_parse_all() {
221 let input = b"0009hello000aworld\n0000";
222 let packets = PktLine::parse_all(input).unwrap();
223 assert_eq!(packets.len(), 3);
224 assert_eq!(packets[0], PktLine::data(b"hello"));
225 assert_eq!(packets[1], PktLine::data(b"world\n"));
226 assert_eq!(packets[2], PktLine::Flush);
227 }
228
229 #[test]
230 fn test_git_service_from_query() {
231 assert_eq!(
232 GitService::from_query_param("git-upload-pack"),
233 Some(GitService::UploadPack)
234 );
235 assert_eq!(
236 GitService::from_query_param("git-receive-pack"),
237 Some(GitService::ReceivePack)
238 );
239 assert_eq!(GitService::from_query_param("invalid"), None);
240 }
241
242 #[test]
243 fn test_git_service_content_types() {
244 let upload = GitService::UploadPack;
245 assert_eq!(
246 upload.advertisement_content_type(),
247 "application/x-git-upload-pack-advertisement"
248 );
249 assert_eq!(
250 upload.result_content_type(),
251 "application/x-git-upload-pack-result"
252 );
253 }
254} \ 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 @@
1//! Git Subprocess Management
2//!
3//! This module provides utilities for spawning and managing Git subprocesses
4//! for upload-pack and receive-pack operations.
5
6use std::path::Path;
7use std::process::Stdio;
8use tokio::io::{AsyncRead, AsyncWrite};
9use tokio::process::{Child, Command};
10
11use super::protocol::GitService;
12
13/// Git subprocess wrapper
14pub struct GitSubprocess {
15 child: Child,
16}
17
18impl GitSubprocess {
19 /// Spawn a git subprocess for the given service and repository path
20 ///
21 /// # Arguments
22 /// * `service` - The Git service (upload-pack or receive-pack)
23 /// * `repo_path` - Path to the bare Git repository
24 /// * `advertise` - If true, run with --advertise-refs flag
25 pub fn spawn(
26 service: GitService,
27 repo_path: impl AsRef<Path>,
28 advertise: bool,
29 ) -> std::io::Result<Self> {
30 let repo_path = repo_path.as_ref();
31
32 let mut cmd = Command::new("git");
33 cmd.arg(service.as_str());
34
35 if advertise {
36 cmd.arg("--advertise-refs");
37 }
38
39 cmd.arg("--stateless-rpc");
40 cmd.arg(repo_path);
41
42 cmd.stdin(Stdio::piped());
43 cmd.stdout(Stdio::piped());
44 cmd.stderr(Stdio::piped());
45
46 let child = cmd.spawn()?;
47
48 Ok(Self { child })
49 }
50
51 /// Get a mutable reference to stdin
52 pub fn stdin(&mut self) -> Option<&mut (impl AsyncWrite + Unpin)> {
53 self.child.stdin.as_mut()
54 }
55
56 /// Get a mutable reference to stdout
57 pub fn stdout(&mut self) -> Option<&mut (impl AsyncRead + Unpin)> {
58 self.child.stdout.as_mut()
59 }
60
61 /// Get a mutable reference to stderr
62 pub fn stderr(&mut self) -> Option<&mut (impl AsyncRead + Unpin)> {
63 self.child.stderr.as_mut()
64 }
65
66 /// Take ownership of stdin
67 pub fn take_stdin(&mut self) -> Option<impl AsyncWrite> {
68 self.child.stdin.take()
69 }
70
71 /// Take ownership of stdout
72 pub fn take_stdout(&mut self) -> Option<impl AsyncRead> {
73 self.child.stdout.take()
74 }
75
76 /// Take ownership of stderr
77 pub fn take_stderr(&mut self) -> Option<impl AsyncRead> {
78 self.child.stderr.take()
79 }
80
81 /// Wait for the subprocess to complete
82 pub async fn wait(mut self) -> std::io::Result<std::process::ExitStatus> {
83 self.child.wait().await
84 }
85
86 /// Kill the subprocess
87 pub async fn kill(&mut self) -> std::io::Result<()> {
88 self.child.kill().await
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use tempfile::TempDir;
96 use std::process::Command as StdCommand;
97
98 fn create_bare_repo() -> TempDir {
99 let dir = TempDir::new().unwrap();
100 let status = StdCommand::new("git")
101 .args(["init", "--bare"])
102 .arg(dir.path())
103 .status()
104 .expect("Failed to run git init");
105 assert!(status.success());
106 dir
107 }
108
109 #[tokio::test]
110 async fn test_spawn_upload_pack_advertise() {
111 let repo = create_bare_repo();
112 let mut proc = GitSubprocess::spawn(
113 GitService::UploadPack,
114 repo.path(),
115 true,
116 ).expect("Failed to spawn git");
117
118 // Should have spawned successfully
119 assert!(proc.stdout().is_some());
120 assert!(proc.stdin().is_some());
121
122 // Clean up
123 let _ = proc.kill().await;
124 }
125
126 #[tokio::test]
127 async fn test_spawn_receive_pack() {
128 let repo = create_bare_repo();
129 let mut proc = GitSubprocess::spawn(
130 GitService::ReceivePack,
131 repo.path(),
132 false,
133 ).expect("Failed to spawn git");
134
135 assert!(proc.stdout().is_some());
136 assert!(proc.stdin().is_some());
137
138 let _ = proc.kill().await;
139 }
140} \ No newline at end of file
diff --git a/src/http/mod.rs b/src/http/mod.rs
index 7c0e7bb..28ccd7b 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -8,12 +8,13 @@ use std::future::Future;
8use std::net::SocketAddr; 8use std::net::SocketAddr;
9use std::pin::Pin; 9use std::pin::Pin;
10 10
11use hyper::body::Incoming; 11use hyper::body::{Bytes, Incoming};
12use hyper::header::{CONNECTION, SEC_WEBSOCKET_ACCEPT, UPGRADE}; 12use hyper::header::{CONNECTION, SEC_WEBSOCKET_ACCEPT, UPGRADE};
13use hyper::server::conn::http1; 13use hyper::server::conn::http1;
14use hyper::service::Service; 14use hyper::service::Service;
15use hyper::{Request, Response}; 15use hyper::{Method, Request, Response};
16use hyper_util::rt::TokioIo; 16use hyper_util::rt::TokioIo;
17use http_body_util::BodyExt;
17use nostr_sdk::hashes::sha1::Hash as Sha1Hash; 18use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
18use nostr_sdk::hashes::{Hash, HashEngine}; 19use nostr_sdk::hashes::{Hash, HashEngine};
19use nostr_relay_builder::LocalRelay; 20use nostr_relay_builder::LocalRelay;
@@ -21,6 +22,7 @@ use tokio::net::TcpListener;
21use base64::Engine; 22use base64::Engine;
22 23
23use crate::config::Config; 24use crate::config::Config;
25use crate::git;
24 26
25/// HTTP Service that serves both WebSocket (relay) and HTML landing page 27/// HTTP Service that serves both WebSocket (relay) and HTML landing page
26struct HttpService { 28struct HttpService {
@@ -46,6 +48,78 @@ impl Service<Request<Incoming>> for HttpService {
46 48
47 fn call(&self, req: Request<Incoming>) -> Self::Future { 49 fn call(&self, req: Request<Incoming>) -> Self::Future {
48 let base = Response::builder().header("server", "ngit-grasp"); 50 let base = Response::builder().header("server", "ngit-grasp");
51 let path = req.uri().path().to_string();
52 let query = req.uri().query().map(|s| s.to_string());
53 let method = req.method().clone();
54 let git_data_path = self.config.git_data_path.clone();
55
56 // Check for Git HTTP requests first
57 if let Some((npub, identifier, subpath)) = git::parse_git_url(&path) {
58 let npub = npub.to_string();
59 let identifier = identifier.to_string();
60 let subpath = subpath.to_string();
61
62 tracing::debug!("Git request: {} {} (npub={}, id={}, subpath={})",
63 method, path, npub, identifier, subpath);
64
65 let repo_path = git::resolve_repo_path(&git_data_path, &npub, &identifier);
66
67 return Box::pin(async move {
68 let result = match (method.as_ref(), subpath.as_str()) {
69 // GET /info/refs?service=git-upload-pack or git-receive-pack
70 (m, sp) if m == Method::GET && sp.starts_with("info/refs") => {
71 // Parse query string for service parameter
72 let service = query.as_deref().unwrap_or("")
73 .strip_prefix("service=")
74 .and_then(git::protocol::GitService::from_query_param);
75
76 match service {
77 Some(svc) => {
78 git::handlers::handle_info_refs(repo_path, svc).await
79 }
80 None => {
81 Err(git::handlers::GitError::RepositoryNotFound)
82 }
83 }
84 }
85
86 // POST /git-upload-pack (clone/fetch)
87 (m, "git-upload-pack") if m == Method::POST => {
88 // Read request body
89 let body_bytes = req.collect().await
90 .map(|collected| collected.to_bytes())
91 .unwrap_or_else(|_| Bytes::new());
92
93 git::handlers::handle_upload_pack(repo_path, body_bytes).await
94 }
95
96 // POST /git-receive-pack (push)
97 (m, "git-receive-pack") if m == Method::POST => {
98 // Read request body
99 let body_bytes = req.collect().await
100 .map(|collected| collected.to_bytes())
101 .unwrap_or_else(|_| Bytes::new());
102
103 git::handlers::handle_receive_pack(repo_path, body_bytes).await
104 }
105
106 _ => {
107 Err(git::handlers::GitError::RepositoryNotFound)
108 }
109 };
110
111 match result {
112 Ok(response) => Ok(response),
113 Err(e) => {
114 tracing::error!("Git handler error: {}", e);
115 Ok(Response::builder()
116 .status(e.status_code())
117 .body(format!("Git error: {}", e))
118 .unwrap())
119 }
120 }
121 });
122 }
49 123
50 // Check for NIP-11 relay information request (Accept: application/nostr+json) 124 // Check for NIP-11 relay information request (Accept: application/nostr+json)
51 if let Some(accept) = req.headers().get("accept") { 125 if let Some(accept) = req.headers().get("accept") {
diff --git a/src/lib.rs b/src/lib.rs
index 6460716..9ccc212 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,4 @@
1pub mod config; 1pub mod config;
2pub mod git;
2pub mod http; 3pub mod http;
3pub mod nostr; 4pub mod nostr;
diff --git a/src/main.rs b/src/main.rs
index 38a3b95..c6e50cf 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,11 +2,7 @@ use anyhow::Result;
2use tracing::{info, Level}; 2use tracing::{info, Level};
3use tracing_subscriber::FmtSubscriber; 3use tracing_subscriber::FmtSubscriber;
4 4
5mod config; 5use ngit_grasp::{config::Config, http, nostr};
6mod http;
7mod nostr;
8
9use config::Config;
10 6
11#[tokio::main] 7#[tokio::main]
12async fn main() -> Result<()> { 8async fn main() -> Result<()> {