//! Simple HTTP Git Server for Testing //! //! Provides a dumb HTTP server for serving git repositories in integration tests. //! This server serves static files from a bare git repository, enabling `git fetch` //! operations without requiring a full git HTTP backend. //! //! # Usage //! //! ```ignore //! use common::SimpleGitServer; //! //! #[tokio::test] //! async fn test_git_fetch() { //! // Create a test repo //! let temp_dir = tempfile::tempdir().unwrap(); //! create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest).unwrap(); //! //! // Start the server //! let server = SimpleGitServer::start(temp_dir.path()).await; //! //! // Git operations work against server.url() //! let output = Command::new("git") //! .args(["ls-remote", server.url()]) //! .output() //! .unwrap(); //! assert!(output.status.success()); //! //! // Server cleans up on drop //! server.stop().await; //! } //! ``` //! //! # How It Works //! //! Git's "dumb HTTP" protocol just needs static file access to: //! - `info/refs` - List of refs (generated by `git update-server-info`) //! - `objects/info/packs` - List of pack files //! - `objects/pack/*` - Pack files //! - `objects/??/*` - Loose objects //! //! The server creates a bare clone of the source repository, runs //! `git update-server-info` to generate the required metadata files, //! and serves them over HTTP. use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; use http_body_util::Full; use hyper::body::Bytes; use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use tokio::net::TcpListener; use tokio::sync::oneshot; /// Simple HTTP server for serving git repositories. /// /// Creates a bare clone of a source repository and serves it over HTTP /// using git's "dumb HTTP" protocol. Useful for testing git fetch operations /// without needing a full git HTTP backend. pub struct SimpleGitServer { /// Shutdown signal sender shutdown_tx: Option>, /// Server task handle handle: Option>, /// Server URL (http://127.0.0.1:) url: String, /// Server port #[allow(dead_code)] port: u16, /// Temporary directory containing the bare repository /// Kept alive for the lifetime of the server _temp_dir: tempfile::TempDir, } impl SimpleGitServer { /// Start a simple HTTP git server serving the given repository. /// /// Creates a bare clone of the source repository, runs `git update-server-info`, /// and starts an HTTP server to serve the repository files. /// /// # Arguments /// * `source_repo` - Path to the source git repository (can be non-bare) /// /// # Returns /// A `SimpleGitServer` instance with the server running /// /// # Panics /// Panics if the git operations fail or the server cannot start pub async fn start(source_repo: &Path) -> Self { // 1. Create temp directory for bare repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git server"); let bare_repo_path = temp_dir.path().join("repo.git"); // 2. Create bare clone let output = Command::new("git") .args(["clone", "--bare"]) .arg(source_repo) .arg(&bare_repo_path) .output() .expect("Failed to run git clone --bare"); if !output.status.success() { panic!( "git clone --bare failed: {}", String::from_utf8_lossy(&output.stderr) ); } // 3. Run git update-server-info to generate info/refs and objects/info/packs let output = Command::new("git") .args(["update-server-info"]) .current_dir(&bare_repo_path) .output() .expect("Failed to run git update-server-info"); if !output.status.success() { panic!( "git update-server-info failed: {}", String::from_utf8_lossy(&output.stderr) ); } // 4. Find a free port let port = find_free_port(); let addr: SocketAddr = ([127, 0, 0, 1], port).into(); // 5. Create shutdown channel let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); // 6. Start the HTTP server let repo_path = Arc::new(bare_repo_path); let listener = TcpListener::bind(addr) .await .expect("Failed to bind to address"); let handle = tokio::spawn(async move { loop { tokio::select! { accept_result = listener.accept() => { match accept_result { Ok((stream, _)) => { let repo_path = Arc::clone(&repo_path); let io = TokioIo::new(stream); tokio::spawn(async move { let service = service_fn(move |req| { let repo_path = Arc::clone(&repo_path); async move { handle_request(req, &repo_path).await } }); if let Err(e) = http1::Builder::new() .serve_connection(io, service) .await { // Connection errors are expected when client disconnects if !e.to_string().contains("connection") { eprintln!("SimpleGitServer connection error: {}", e); } } }); } Err(e) => { eprintln!("SimpleGitServer accept error: {}", e); } } } _ = &mut shutdown_rx => { // Shutdown signal received break; } } } }); let url = format!("http://127.0.0.1:{}", port); // 7. Wait for server to be ready wait_for_server_ready(port).await; Self { shutdown_tx: Some(shutdown_tx), handle: Some(handle), url, port, _temp_dir: temp_dir, } } /// Get the server URL. /// /// Returns the HTTP URL where the git repository is served. /// Can be used directly with `git clone`, `git fetch`, or `git ls-remote`. pub fn url(&self) -> &str { &self.url } /// Stop the server. /// /// Sends a shutdown signal and waits for the server to stop. /// The temporary directory is cleaned up when the server is dropped. pub async fn stop(mut self) { // Send shutdown signal if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } // Wait for server task to complete if let Some(handle) = self.handle.take() { let _ = handle.await; } } } impl Drop for SimpleGitServer { fn drop(&mut self) { // Send shutdown signal if not already sent if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } // Note: We can't await the handle in drop, but the temp_dir cleanup // will happen automatically when _temp_dir is dropped } } /// Handle an HTTP request by serving files from the git repository. async fn handle_request( req: Request, repo_path: &Path, ) -> Result>, hyper::Error> { let path = req.uri().path(); // Remove leading slash and construct file path let relative_path = path.trim_start_matches('/'); let file_path = repo_path.join(relative_path); // Security: ensure the path doesn't escape the repo directory if !is_safe_path(&file_path, repo_path) { return Ok(Response::builder() .status(StatusCode::FORBIDDEN) .body(Full::new(Bytes::from("Forbidden"))) .unwrap()); } // Try to read the file match tokio::fs::read(&file_path).await { Ok(contents) => { let content_type = guess_content_type(&file_path); Ok(Response::builder() .status(StatusCode::OK) .header("Content-Type", content_type) .body(Full::new(Bytes::from(contents))) .unwrap()) } Err(_) => Ok(Response::builder() .status(StatusCode::NOT_FOUND) .body(Full::new(Bytes::from("Not Found"))) .unwrap()), } } /// Check if a path is safe (doesn't escape the repository directory). fn is_safe_path(path: &Path, repo_path: &Path) -> bool { match path.canonicalize() { Ok(canonical) => canonical.starts_with(repo_path), Err(_) => { // If canonicalize fails, check if the path would escape // by looking for .. components !path.to_string_lossy().contains("..") } } } /// Guess the content type for a git-related file. fn guess_content_type(path: &PathBuf) -> &'static str { let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if filename == "info/refs" || filename == "refs" { "text/plain; charset=utf-8" } else if filename.ends_with(".pack") { "application/x-git-packed-objects" } else if filename.ends_with(".idx") { "application/x-git-packed-objects-toc" } else { "application/octet-stream" } } /// Find a free port to use for the server. fn find_free_port() -> u16 { use std::net::TcpListener; let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port"); let port = listener.local_addr().expect("Failed to get local addr").port(); drop(listener); port } /// Wait for the server to be ready to accept connections. async fn wait_for_server_ready(port: u16) { let max_attempts = 50; // 5 seconds total let delay = std::time::Duration::from_millis(100); for attempt in 0..max_attempts { match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await { Ok(_) => { // Connection successful, server is ready tokio::time::sleep(std::time::Duration::from_millis(50)).await; return; } Err(_) => { if attempt == max_attempts - 1 { panic!("SimpleGitServer failed to start after {} attempts", max_attempts); } tokio::time::sleep(delay).await; } } } } #[cfg(test)] mod tests { use super::*; use crate::common::purgatory_helpers::{create_test_repo_with_commit, CommitVariant}; #[tokio::test] async fn test_simple_git_server_starts_and_stops() { // Create a test repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server let server = SimpleGitServer::start(temp_dir.path()).await; // Verify URL is set assert!(server.url().starts_with("http://127.0.0.1:")); // Stop server server.stop().await; } #[tokio::test] async fn test_simple_git_server_serves_git_info_refs() { // Create a test repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server let server = SimpleGitServer::start(temp_dir.path()).await; // Fetch info/refs let info_refs_url = format!("{}/info/refs", server.url()); let response = reqwest::get(&info_refs_url) .await .expect("Failed to fetch info/refs"); assert!(response.status().is_success(), "info/refs should be accessible"); let body = response.text().await.expect("Failed to read response body"); // Should contain at least one ref (HEAD or refs/heads/main) assert!( body.contains("refs/heads/main") || body.contains("HEAD"), "info/refs should contain refs, got: {}", body ); server.stop().await; } #[tokio::test] async fn test_git_ls_remote_from_simple_server() { // Create a test repo let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server let server = SimpleGitServer::start(temp_dir.path()).await; // Run git ls-remote against the server let output = Command::new("git") .args(["ls-remote", server.url()]) .output() .expect("Failed to run git ls-remote"); assert!( output.status.success(), "git ls-remote should succeed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); // Should list the main branch with the correct commit assert!( stdout.contains(&commit_hash), "ls-remote output should contain commit {}, got: {}", commit_hash, stdout ); assert!( stdout.contains("refs/heads/main"), "ls-remote output should contain refs/heads/main, got: {}", stdout ); server.stop().await; } #[tokio::test] async fn test_git_fetch_from_simple_server() { // Create a source repo with a commit let source_dir = tempfile::tempdir().expect("Failed to create source dir"); let commit_hash = create_test_repo_with_commit(source_dir.path(), CommitVariant::StateTest) .expect("Failed to create test repo"); // Start server serving the source repo let server = SimpleGitServer::start(source_dir.path()).await; // Create a destination repo to fetch into let dest_dir = tempfile::tempdir().expect("Failed to create dest dir"); // Initialize empty repo let output = Command::new("git") .args(["init"]) .current_dir(dest_dir.path()) .output() .expect("Failed to init dest repo"); assert!(output.status.success()); // Add the server as a remote let output = Command::new("git") .args(["remote", "add", "origin", server.url()]) .current_dir(dest_dir.path()) .output() .expect("Failed to add remote"); assert!(output.status.success()); // Fetch from the server let output = Command::new("git") .args(["fetch", "origin"]) .current_dir(dest_dir.path()) .output() .expect("Failed to fetch"); assert!( output.status.success(), "git fetch should succeed: {}", String::from_utf8_lossy(&output.stderr) ); // Verify the commit was fetched let output = Command::new("git") .args(["rev-parse", "origin/main"]) .current_dir(dest_dir.path()) .output() .expect("Failed to rev-parse"); assert!(output.status.success()); let fetched_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!( fetched_commit, commit_hash, "Fetched commit should match source commit" ); server.stop().await; } #[test] fn test_is_safe_path_blocks_traversal() { let repo_path = Path::new("/tmp/repo"); // Safe paths assert!(is_safe_path(Path::new("/tmp/repo/info/refs"), repo_path)); assert!(is_safe_path(Path::new("/tmp/repo/objects/pack/file.pack"), repo_path)); // Unsafe paths (path traversal) assert!(!is_safe_path(Path::new("/tmp/repo/../etc/passwd"), repo_path)); assert!(!is_safe_path(Path::new("/tmp/repo/../../etc/passwd"), repo_path)); } }