From e7c18cf2a96b1f45e5f21a83ee1fe2e18a6dc7e2 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 7 Jan 2026 17:36:44 +0000 Subject: test: add SimpleGitServer helper for dumb HTTP git serving Add a test helper that serves git repositories over HTTP using git's dumb HTTP protocol. This enables integration tests that need to fetch git data from an external URL without requiring a full git HTTP backend. Features: - Creates bare clone of source repository - Runs git update-server-info to generate required metadata - Serves static files over HTTP using hyper - Automatic cleanup on drop - Path traversal protection Tests included: - Server starts and stops correctly - info/refs endpoint is accessible - git ls-remote works against server - git fetch works against server - Path traversal is blocked This helper will be used in Phase 3 to test PR clone tag sync, where the PR event's clone URL points to an external git server that is NOT an ngit-grasp relay. --- tests/common/git_server.rs | 487 +++++++++++++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 2 + 2 files changed, 489 insertions(+) create mode 100644 tests/common/git_server.rs 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 @@ +//! 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)); + } +} 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 @@ #![allow(dead_code)] // Test helpers may not be used in all test configurations #![allow(unused_imports)] // Re-exports may not be used in all test configurations +pub mod git_server; pub mod purgatory_helpers; pub mod relay; pub mod sync_helpers; +pub use git_server::SimpleGitServer; pub use purgatory_helpers::*; pub use relay::TestRelay; pub use sync_helpers::*; -- cgit v1.2.3