upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tests/common/git_server.rs487
-rw-r--r--tests/common/mod.rs2
2 files changed, 489 insertions, 0 deletions
diff --git a/tests/common/git_server.rs b/tests/common/git_server.rs
new file mode 100644
index 0000000..b225084
--- /dev/null
+++ b/tests/common/git_server.rs
@@ -0,0 +1,487 @@
1//! Simple HTTP Git Server for Testing
2//!
3//! Provides a dumb HTTP server for serving git repositories in integration tests.
4//! This server serves static files from a bare git repository, enabling `git fetch`
5//! operations without requiring a full git HTTP backend.
6//!
7//! # Usage
8//!
9//! ```ignore
10//! use common::SimpleGitServer;
11//!
12//! #[tokio::test]
13//! async fn test_git_fetch() {
14//! // Create a test repo
15//! let temp_dir = tempfile::tempdir().unwrap();
16//! create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest).unwrap();
17//!
18//! // Start the server
19//! let server = SimpleGitServer::start(temp_dir.path()).await;
20//!
21//! // Git operations work against server.url()
22//! let output = Command::new("git")
23//! .args(["ls-remote", server.url()])
24//! .output()
25//! .unwrap();
26//! assert!(output.status.success());
27//!
28//! // Server cleans up on drop
29//! server.stop().await;
30//! }
31//! ```
32//!
33//! # How It Works
34//!
35//! Git's "dumb HTTP" protocol just needs static file access to:
36//! - `info/refs` - List of refs (generated by `git update-server-info`)
37//! - `objects/info/packs` - List of pack files
38//! - `objects/pack/*` - Pack files
39//! - `objects/??/*` - Loose objects
40//!
41//! The server creates a bare clone of the source repository, runs
42//! `git update-server-info` to generate the required metadata files,
43//! and serves them over HTTP.
44
45use std::net::SocketAddr;
46use std::path::{Path, PathBuf};
47use std::process::Command;
48use std::sync::Arc;
49
50use http_body_util::Full;
51use hyper::body::Bytes;
52use hyper::server::conn::http1;
53use hyper::service::service_fn;
54use hyper::{Request, Response, StatusCode};
55use hyper_util::rt::TokioIo;
56use tokio::net::TcpListener;
57use tokio::sync::oneshot;
58
59/// Simple HTTP server for serving git repositories.
60///
61/// Creates a bare clone of a source repository and serves it over HTTP
62/// using git's "dumb HTTP" protocol. Useful for testing git fetch operations
63/// without needing a full git HTTP backend.
64pub struct SimpleGitServer {
65 /// Shutdown signal sender
66 shutdown_tx: Option<oneshot::Sender<()>>,
67 /// Server task handle
68 handle: Option<tokio::task::JoinHandle<()>>,
69 /// Server URL (http://127.0.0.1:<port>)
70 url: String,
71 /// Server port
72 #[allow(dead_code)]
73 port: u16,
74 /// Temporary directory containing the bare repository
75 /// Kept alive for the lifetime of the server
76 _temp_dir: tempfile::TempDir,
77}
78
79impl SimpleGitServer {
80 /// Start a simple HTTP git server serving the given repository.
81 ///
82 /// Creates a bare clone of the source repository, runs `git update-server-info`,
83 /// and starts an HTTP server to serve the repository files.
84 ///
85 /// # Arguments
86 /// * `source_repo` - Path to the source git repository (can be non-bare)
87 ///
88 /// # Returns
89 /// A `SimpleGitServer` instance with the server running
90 ///
91 /// # Panics
92 /// Panics if the git operations fail or the server cannot start
93 pub async fn start(source_repo: &Path) -> Self {
94 // 1. Create temp directory for bare repo
95 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git server");
96 let bare_repo_path = temp_dir.path().join("repo.git");
97
98 // 2. Create bare clone
99 let output = Command::new("git")
100 .args(["clone", "--bare"])
101 .arg(source_repo)
102 .arg(&bare_repo_path)
103 .output()
104 .expect("Failed to run git clone --bare");
105
106 if !output.status.success() {
107 panic!(
108 "git clone --bare failed: {}",
109 String::from_utf8_lossy(&output.stderr)
110 );
111 }
112
113 // 3. Run git update-server-info to generate info/refs and objects/info/packs
114 let output = Command::new("git")
115 .args(["update-server-info"])
116 .current_dir(&bare_repo_path)
117 .output()
118 .expect("Failed to run git update-server-info");
119
120 if !output.status.success() {
121 panic!(
122 "git update-server-info failed: {}",
123 String::from_utf8_lossy(&output.stderr)
124 );
125 }
126
127 // 4. Find a free port
128 let port = find_free_port();
129 let addr: SocketAddr = ([127, 0, 0, 1], port).into();
130
131 // 5. Create shutdown channel
132 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
133
134 // 6. Start the HTTP server
135 let repo_path = Arc::new(bare_repo_path);
136 let listener = TcpListener::bind(addr)
137 .await
138 .expect("Failed to bind to address");
139
140 let handle = tokio::spawn(async move {
141 loop {
142 tokio::select! {
143 accept_result = listener.accept() => {
144 match accept_result {
145 Ok((stream, _)) => {
146 let repo_path = Arc::clone(&repo_path);
147 let io = TokioIo::new(stream);
148
149 tokio::spawn(async move {
150 let service = service_fn(move |req| {
151 let repo_path = Arc::clone(&repo_path);
152 async move { handle_request(req, &repo_path).await }
153 });
154
155 if let Err(e) = http1::Builder::new()
156 .serve_connection(io, service)
157 .await
158 {
159 // Connection errors are expected when client disconnects
160 if !e.to_string().contains("connection") {
161 eprintln!("SimpleGitServer connection error: {}", e);
162 }
163 }
164 });
165 }
166 Err(e) => {
167 eprintln!("SimpleGitServer accept error: {}", e);
168 }
169 }
170 }
171 _ = &mut shutdown_rx => {
172 // Shutdown signal received
173 break;
174 }
175 }
176 }
177 });
178
179 let url = format!("http://127.0.0.1:{}", port);
180
181 // 7. Wait for server to be ready
182 wait_for_server_ready(port).await;
183
184 Self {
185 shutdown_tx: Some(shutdown_tx),
186 handle: Some(handle),
187 url,
188 port,
189 _temp_dir: temp_dir,
190 }
191 }
192
193 /// Get the server URL.
194 ///
195 /// Returns the HTTP URL where the git repository is served.
196 /// Can be used directly with `git clone`, `git fetch`, or `git ls-remote`.
197 pub fn url(&self) -> &str {
198 &self.url
199 }
200
201 /// Stop the server.
202 ///
203 /// Sends a shutdown signal and waits for the server to stop.
204 /// The temporary directory is cleaned up when the server is dropped.
205 pub async fn stop(mut self) {
206 // Send shutdown signal
207 if let Some(tx) = self.shutdown_tx.take() {
208 let _ = tx.send(());
209 }
210
211 // Wait for server task to complete
212 if let Some(handle) = self.handle.take() {
213 let _ = handle.await;
214 }
215 }
216}
217
218impl Drop for SimpleGitServer {
219 fn drop(&mut self) {
220 // Send shutdown signal if not already sent
221 if let Some(tx) = self.shutdown_tx.take() {
222 let _ = tx.send(());
223 }
224 // Note: We can't await the handle in drop, but the temp_dir cleanup
225 // will happen automatically when _temp_dir is dropped
226 }
227}
228
229/// Handle an HTTP request by serving files from the git repository.
230async fn handle_request(
231 req: Request<hyper::body::Incoming>,
232 repo_path: &Path,
233) -> Result<Response<Full<Bytes>>, hyper::Error> {
234 let path = req.uri().path();
235
236 // Remove leading slash and construct file path
237 let relative_path = path.trim_start_matches('/');
238 let file_path = repo_path.join(relative_path);
239
240 // Security: ensure the path doesn't escape the repo directory
241 if !is_safe_path(&file_path, repo_path) {
242 return Ok(Response::builder()
243 .status(StatusCode::FORBIDDEN)
244 .body(Full::new(Bytes::from("Forbidden")))
245 .unwrap());
246 }
247
248 // Try to read the file
249 match tokio::fs::read(&file_path).await {
250 Ok(contents) => {
251 let content_type = guess_content_type(&file_path);
252 Ok(Response::builder()
253 .status(StatusCode::OK)
254 .header("Content-Type", content_type)
255 .body(Full::new(Bytes::from(contents)))
256 .unwrap())
257 }
258 Err(_) => Ok(Response::builder()
259 .status(StatusCode::NOT_FOUND)
260 .body(Full::new(Bytes::from("Not Found")))
261 .unwrap()),
262 }
263}
264
265/// Check if a path is safe (doesn't escape the repository directory).
266fn is_safe_path(path: &Path, repo_path: &Path) -> bool {
267 match path.canonicalize() {
268 Ok(canonical) => canonical.starts_with(repo_path),
269 Err(_) => {
270 // If canonicalize fails, check if the path would escape
271 // by looking for .. components
272 !path.to_string_lossy().contains("..")
273 }
274 }
275}
276
277/// Guess the content type for a git-related file.
278fn guess_content_type(path: &PathBuf) -> &'static str {
279 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
280
281 if filename == "info/refs" || filename == "refs" {
282 "text/plain; charset=utf-8"
283 } else if filename.ends_with(".pack") {
284 "application/x-git-packed-objects"
285 } else if filename.ends_with(".idx") {
286 "application/x-git-packed-objects-toc"
287 } else {
288 "application/octet-stream"
289 }
290}
291
292/// Find a free port to use for the server.
293fn find_free_port() -> u16 {
294 use std::net::TcpListener;
295
296 let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
297 let port = listener.local_addr().expect("Failed to get local addr").port();
298 drop(listener);
299 port
300}
301
302/// Wait for the server to be ready to accept connections.
303async fn wait_for_server_ready(port: u16) {
304 let max_attempts = 50; // 5 seconds total
305 let delay = std::time::Duration::from_millis(100);
306
307 for attempt in 0..max_attempts {
308 match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await {
309 Ok(_) => {
310 // Connection successful, server is ready
311 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
312 return;
313 }
314 Err(_) => {
315 if attempt == max_attempts - 1 {
316 panic!("SimpleGitServer failed to start after {} attempts", max_attempts);
317 }
318 tokio::time::sleep(delay).await;
319 }
320 }
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::common::purgatory_helpers::{create_test_repo_with_commit, CommitVariant};
328
329 #[tokio::test]
330 async fn test_simple_git_server_starts_and_stops() {
331 // Create a test repo
332 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
333 create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
334 .expect("Failed to create test repo");
335
336 // Start server
337 let server = SimpleGitServer::start(temp_dir.path()).await;
338
339 // Verify URL is set
340 assert!(server.url().starts_with("http://127.0.0.1:"));
341
342 // Stop server
343 server.stop().await;
344 }
345
346 #[tokio::test]
347 async fn test_simple_git_server_serves_git_info_refs() {
348 // Create a test repo
349 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
350 create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
351 .expect("Failed to create test repo");
352
353 // Start server
354 let server = SimpleGitServer::start(temp_dir.path()).await;
355
356 // Fetch info/refs
357 let info_refs_url = format!("{}/info/refs", server.url());
358 let response = reqwest::get(&info_refs_url)
359 .await
360 .expect("Failed to fetch info/refs");
361
362 assert!(response.status().is_success(), "info/refs should be accessible");
363
364 let body = response.text().await.expect("Failed to read response body");
365
366 // Should contain at least one ref (HEAD or refs/heads/main)
367 assert!(
368 body.contains("refs/heads/main") || body.contains("HEAD"),
369 "info/refs should contain refs, got: {}",
370 body
371 );
372
373 server.stop().await;
374 }
375
376 #[tokio::test]
377 async fn test_git_ls_remote_from_simple_server() {
378 // Create a test repo
379 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
380 let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
381 .expect("Failed to create test repo");
382
383 // Start server
384 let server = SimpleGitServer::start(temp_dir.path()).await;
385
386 // Run git ls-remote against the server
387 let output = Command::new("git")
388 .args(["ls-remote", server.url()])
389 .output()
390 .expect("Failed to run git ls-remote");
391
392 assert!(
393 output.status.success(),
394 "git ls-remote should succeed: {}",
395 String::from_utf8_lossy(&output.stderr)
396 );
397
398 let stdout = String::from_utf8_lossy(&output.stdout);
399
400 // Should list the main branch with the correct commit
401 assert!(
402 stdout.contains(&commit_hash),
403 "ls-remote output should contain commit {}, got: {}",
404 commit_hash,
405 stdout
406 );
407 assert!(
408 stdout.contains("refs/heads/main"),
409 "ls-remote output should contain refs/heads/main, got: {}",
410 stdout
411 );
412
413 server.stop().await;
414 }
415
416 #[tokio::test]
417 async fn test_git_fetch_from_simple_server() {
418 // Create a source repo with a commit
419 let source_dir = tempfile::tempdir().expect("Failed to create source dir");
420 let commit_hash = create_test_repo_with_commit(source_dir.path(), CommitVariant::StateTest)
421 .expect("Failed to create test repo");
422
423 // Start server serving the source repo
424 let server = SimpleGitServer::start(source_dir.path()).await;
425
426 // Create a destination repo to fetch into
427 let dest_dir = tempfile::tempdir().expect("Failed to create dest dir");
428
429 // Initialize empty repo
430 let output = Command::new("git")
431 .args(["init"])
432 .current_dir(dest_dir.path())
433 .output()
434 .expect("Failed to init dest repo");
435 assert!(output.status.success());
436
437 // Add the server as a remote
438 let output = Command::new("git")
439 .args(["remote", "add", "origin", server.url()])
440 .current_dir(dest_dir.path())
441 .output()
442 .expect("Failed to add remote");
443 assert!(output.status.success());
444
445 // Fetch from the server
446 let output = Command::new("git")
447 .args(["fetch", "origin"])
448 .current_dir(dest_dir.path())
449 .output()
450 .expect("Failed to fetch");
451
452 assert!(
453 output.status.success(),
454 "git fetch should succeed: {}",
455 String::from_utf8_lossy(&output.stderr)
456 );
457
458 // Verify the commit was fetched
459 let output = Command::new("git")
460 .args(["rev-parse", "origin/main"])
461 .current_dir(dest_dir.path())
462 .output()
463 .expect("Failed to rev-parse");
464
465 assert!(output.status.success());
466 let fetched_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
467 assert_eq!(
468 fetched_commit, commit_hash,
469 "Fetched commit should match source commit"
470 );
471
472 server.stop().await;
473 }
474
475 #[test]
476 fn test_is_safe_path_blocks_traversal() {
477 let repo_path = Path::new("/tmp/repo");
478
479 // Safe paths
480 assert!(is_safe_path(Path::new("/tmp/repo/info/refs"), repo_path));
481 assert!(is_safe_path(Path::new("/tmp/repo/objects/pack/file.pack"), repo_path));
482
483 // Unsafe paths (path traversal)
484 assert!(!is_safe_path(Path::new("/tmp/repo/../etc/passwd"), repo_path));
485 assert!(!is_safe_path(Path::new("/tmp/repo/../../etc/passwd"), repo_path));
486 }
487}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index f511163..e70bd71 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -2,10 +2,12 @@
2#![allow(dead_code)] // Test helpers may not be used in all test configurations 2#![allow(dead_code)] // Test helpers may not be used in all test configurations
3#![allow(unused_imports)] // Re-exports may not be used in all test configurations 3#![allow(unused_imports)] // Re-exports may not be used in all test configurations
4 4
5pub mod git_server;
5pub mod purgatory_helpers; 6pub mod purgatory_helpers;
6pub mod relay; 7pub mod relay;
7pub mod sync_helpers; 8pub mod sync_helpers;
8 9
10pub use git_server::SimpleGitServer;
9pub use purgatory_helpers::*; 11pub use purgatory_helpers::*;
10pub use relay::TestRelay; 12pub use relay::TestRelay;
11pub use sync_helpers::*; 13pub use sync_helpers::*;