Files
ngit-grasp/tests/common/git_server.rs
T
DanConwayDev e7c18cf2a9 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.
2026-01-07 17:36:44 +00:00

488 lines
16 KiB
Rust

//! 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<oneshot::Sender<()>>,
/// Server task handle
handle: Option<tokio::task::JoinHandle<()>>,
/// Server URL (http://127.0.0.1:<port>)
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<hyper::body::Incoming>,
repo_path: &Path,
) -> Result<Response<Full<Bytes>>, 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));
}
}