mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-06 07:28:23 +00:00
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.
This commit is contained in:
@@ -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<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));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user