mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
- Update nostr-relay-builder, nostr-sdk, nostr-lmdb to latest revision - Update grasp-audit nostr-sdk dependency - Fix clippy warnings: - Replace .clone() with std::slice::from_ref() in src/git/sync.rs - Change &PathBuf to &Path in tests/common/git_server.rs - Replace vec![] with array literal in src/purgatory/sync/functions.rs - Update PR_TEST_COMMIT_HASH in grasp-audit due to event generation changes All 249 tests passing, no breaking changes required.
1166 lines
39 KiB
Rust
1166 lines
39 KiB
Rust
//! HTTP Git Servers for Testing
|
|
//!
|
|
//! This module provides two git server implementations for integration tests:
|
|
//!
|
|
//! ## `SimpleGitServer` (Dumb HTTP Protocol)
|
|
//!
|
|
//! Serves static files from a bare git repository using Git's "dumb HTTP" protocol.
|
|
//! This is lightweight but does NOT support shallow fetches (`git fetch --depth=1`).
|
|
//!
|
|
//! ## `SmartGitServer` (Smart HTTP Protocol)
|
|
//!
|
|
//! Implements the Git Smart HTTP protocol by spawning `git upload-pack` subprocesses.
|
|
//! This supports all git fetch operations including shallow fetches.
|
|
//!
|
|
//! # Usage
|
|
//!
|
|
//! ```ignore
|
|
//! use common::{SimpleGitServer, SmartGitServer};
|
|
//!
|
|
//! #[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();
|
|
//!
|
|
//! // Use SmartGitServer for full protocol support (including shallow fetches)
|
|
//! let server = SmartGitServer::start(temp_dir.path()).await;
|
|
//!
|
|
//! // Git operations work against server.url()
|
|
//! let output = Command::new("git")
|
|
//! .args(["clone", "--depth=1", server.url(), "/tmp/clone"])
|
|
//! .output()
|
|
//! .unwrap();
|
|
//! assert!(output.status.success());
|
|
//!
|
|
//! // Server cleans up on drop
|
|
//! server.stop().await;
|
|
//! }
|
|
//! ```
|
|
//!
|
|
//! # When to Use Which
|
|
//!
|
|
//! - **SimpleGitServer**: Fast, lightweight, good for basic `git fetch` without depth limits
|
|
//! - **SmartGitServer**: Full protocol support, required for `--depth=1` shallow fetches
|
|
//!
|
|
//! The purgatory sync system uses `git fetch --depth=1`, so tests involving purgatory
|
|
//! sync should use `SmartGitServer`.
|
|
|
|
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 {
|
|
println!("[SmartGitServer] Server loop started on port {}", port);
|
|
eprintln!("[SmartGitServer] Server loop started on port {}", port);
|
|
loop {
|
|
tokio::select! {
|
|
accept_result = listener.accept() => {
|
|
match accept_result {
|
|
Ok((stream, addr)) => {
|
|
eprintln!("[SmartGitServer] Accepted connection from {}", addr);
|
|
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: &Path) -> &'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 (using tokio::process::Command)
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["ls-remote", server.url()])
|
|
.output()
|
|
.await
|
|
.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 (using tokio::process::Command)
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["init"])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.expect("Failed to init dest repo");
|
|
assert!(output.status.success());
|
|
|
|
// Add the server as a remote
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["remote", "add", "origin", server.url()])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.expect("Failed to add remote");
|
|
assert!(output.status.success());
|
|
|
|
// Fetch from the server
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["fetch", "origin"])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.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 = tokio::process::Command::new("git")
|
|
.args(["rev-parse", "origin/main"])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.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
|
|
));
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SmartGitServer - Git Smart HTTP Protocol Server
|
|
// =============================================================================
|
|
|
|
/// Smart HTTP server for serving git repositories with full protocol support.
|
|
///
|
|
/// Unlike `SimpleGitServer` which uses the "dumb HTTP" protocol (static files),
|
|
/// this server implements the Git Smart HTTP protocol by spawning `git upload-pack`
|
|
/// subprocesses. This enables:
|
|
///
|
|
/// - Shallow clones (`git clone --depth=1`)
|
|
/// - Shallow fetches (`git fetch --depth=1`)
|
|
/// - Full protocol negotiation
|
|
///
|
|
/// This is required for testing purgatory sync, which uses `git fetch --depth=1`.
|
|
pub struct SmartGitServer {
|
|
/// 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 SmartGitServer {
|
|
/// Start a smart HTTP git server serving the given repository.
|
|
///
|
|
/// Creates a bare clone of the source repository and starts an HTTP server
|
|
/// that implements the Git Smart HTTP protocol.
|
|
///
|
|
/// # Arguments
|
|
/// * `source_repo` - Path to the source git repository (can be non-bare)
|
|
///
|
|
/// # Returns
|
|
/// A `SmartGitServer` 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. Create and bind listener (eliminates port race condition)
|
|
let std_listener =
|
|
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
|
|
let port = std_listener
|
|
.local_addr()
|
|
.expect("Failed to get local addr")
|
|
.port();
|
|
|
|
// Convert to tokio listener (keeps port bound)
|
|
std_listener
|
|
.set_nonblocking(true)
|
|
.expect("Failed to set non-blocking");
|
|
let listener =
|
|
TcpListener::from_std(std_listener).expect("Failed to convert to tokio listener");
|
|
|
|
// 4. Create shutdown channel
|
|
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
|
|
|
|
// 5. Start the HTTP server
|
|
let repo_path = Arc::new(bare_repo_path);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
loop {
|
|
tokio::select! {
|
|
accept_result = listener.accept() => {
|
|
match accept_result {
|
|
Ok((stream, _addr)) => {
|
|
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_smart_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!("SmartGitServer connection error: {}", e);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
Err(e) => {
|
|
eprintln!("SmartGitServer accept error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
_ = &mut shutdown_rx => {
|
|
// Shutdown signal received
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let url = format!("http://127.0.0.1:{}", port);
|
|
|
|
// 6. 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 SmartGitServer {
|
|
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 using the Git Smart HTTP protocol.
|
|
async fn handle_smart_request(
|
|
req: Request<hyper::body::Incoming>,
|
|
repo_path: &Path,
|
|
) -> Result<Response<Full<Bytes>>, hyper::Error> {
|
|
let path = req.uri().path();
|
|
let query = req.uri().query().unwrap_or("");
|
|
let method = req.method();
|
|
|
|
// Extract Git-Protocol header (for protocol version 2)
|
|
// We need to clone it to avoid borrowing issues when moving req
|
|
let git_protocol = req
|
|
.headers()
|
|
.get("Git-Protocol")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
|
|
// Route: GET /info/refs?service=git-upload-pack
|
|
if method == hyper::Method::GET && path.ends_with("/info/refs") {
|
|
// Parse service from query string
|
|
let service = query.split('&').find_map(|param| {
|
|
let mut parts = param.splitn(2, '=');
|
|
match (parts.next(), parts.next()) {
|
|
(Some("service"), Some(svc)) => Some(svc),
|
|
_ => None,
|
|
}
|
|
});
|
|
|
|
match service {
|
|
Some("git-upload-pack") => {
|
|
return handle_info_refs_upload_pack(repo_path, git_protocol.as_deref()).await;
|
|
}
|
|
Some("git-receive-pack") => {
|
|
// We only support upload-pack for testing (fetch/clone)
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::FORBIDDEN)
|
|
.body(Full::new(Bytes::from("receive-pack not supported")))
|
|
.unwrap());
|
|
}
|
|
_ => {
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::BAD_REQUEST)
|
|
.body(Full::new(Bytes::from(
|
|
"Missing or invalid service parameter",
|
|
)))
|
|
.unwrap());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Route: POST /git-upload-pack
|
|
if method == hyper::Method::POST && path.ends_with("/git-upload-pack") {
|
|
return handle_upload_pack(req, repo_path, git_protocol.as_deref()).await;
|
|
}
|
|
|
|
// Not found
|
|
Ok(Response::builder()
|
|
.status(StatusCode::NOT_FOUND)
|
|
.body(Full::new(Bytes::from("Not Found")))
|
|
.unwrap())
|
|
}
|
|
|
|
/// Handle GET /info/refs?service=git-upload-pack
|
|
///
|
|
/// This advertises the repository's refs to the client using the smart protocol.
|
|
async fn handle_info_refs_upload_pack(
|
|
repo_path: &Path,
|
|
git_protocol_version: Option<&str>,
|
|
) -> Result<Response<Full<Bytes>>, hyper::Error> {
|
|
use std::process::Stdio;
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::process::Command as TokioCommand;
|
|
|
|
// Spawn git upload-pack --advertise-refs
|
|
let mut cmd = TokioCommand::new("git");
|
|
cmd.arg("-c")
|
|
.arg("uploadpack.allowReachableSHA1InWant=true")
|
|
.arg("-c")
|
|
.arg("uploadpack.allowTipSHA1InWant=true")
|
|
.arg("upload-pack")
|
|
.arg("--advertise-refs")
|
|
.arg("--stateless-rpc");
|
|
|
|
// Set GIT_PROTOCOL environment variable if version 2 is requested
|
|
if let Some(version) = git_protocol_version {
|
|
cmd.env("GIT_PROTOCOL", version);
|
|
}
|
|
|
|
cmd.arg(repo_path)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let mut child = match cmd.spawn() {
|
|
Ok(child) => child,
|
|
Err(e) => {
|
|
eprintln!("Failed to spawn git upload-pack: {}", e);
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(Full::new(Bytes::from("Failed to spawn git process")))
|
|
.unwrap());
|
|
}
|
|
};
|
|
|
|
// Read stdout
|
|
let mut output = Vec::new();
|
|
if let Some(mut stdout) = child.stdout.take() {
|
|
if let Err(e) = stdout.read_to_end(&mut output).await {
|
|
eprintln!("Failed to read git output: {}", e);
|
|
}
|
|
}
|
|
|
|
// Wait for process
|
|
let status = child.wait().await;
|
|
if let Ok(s) = &status {
|
|
if !s.success() {
|
|
eprintln!("git upload-pack --advertise-refs failed");
|
|
}
|
|
}
|
|
|
|
// Build response with pkt-line header
|
|
// Format: pkt-line("# service=git-upload-pack\n") + flush + git output
|
|
let mut response_body = Vec::new();
|
|
|
|
// First line: service advertisement
|
|
let service_line = "# service=git-upload-pack\n";
|
|
let len = service_line.len() + 4;
|
|
response_body.extend_from_slice(format!("{:04x}", len).as_bytes());
|
|
response_body.extend_from_slice(service_line.as_bytes());
|
|
|
|
// Flush packet
|
|
response_body.extend_from_slice(b"0000");
|
|
|
|
// Then the git output
|
|
response_body.extend_from_slice(&output);
|
|
|
|
Ok(Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(
|
|
"Content-Type",
|
|
"application/x-git-upload-pack-advertisement",
|
|
)
|
|
.header("Cache-Control", "no-cache")
|
|
.body(Full::new(Bytes::from(response_body)))
|
|
.unwrap())
|
|
}
|
|
|
|
/// Handle POST /git-upload-pack
|
|
///
|
|
/// This handles the actual fetch negotiation and pack data transfer.
|
|
async fn handle_upload_pack(
|
|
req: Request<hyper::body::Incoming>,
|
|
repo_path: &Path,
|
|
git_protocol_version: Option<&str>,
|
|
) -> Result<Response<Full<Bytes>>, hyper::Error> {
|
|
use http_body_util::BodyExt;
|
|
use std::process::Stdio;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::process::Command as TokioCommand;
|
|
|
|
// Read request body
|
|
let body_bytes = req.collect().await?.to_bytes();
|
|
|
|
// Spawn git upload-pack
|
|
let mut cmd = TokioCommand::new("git");
|
|
cmd.arg("-c")
|
|
.arg("uploadpack.allowReachableSHA1InWant=true")
|
|
.arg("-c")
|
|
.arg("uploadpack.allowTipSHA1InWant=true")
|
|
.arg("upload-pack")
|
|
.arg("--stateless-rpc");
|
|
|
|
// Set GIT_PROTOCOL environment variable if version 2 is requested
|
|
if let Some(version) = git_protocol_version {
|
|
cmd.env("GIT_PROTOCOL", version);
|
|
}
|
|
|
|
cmd.arg(repo_path)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let mut child = match cmd.spawn() {
|
|
Ok(child) => child,
|
|
Err(e) => {
|
|
eprintln!("Failed to spawn git upload-pack: {}", e);
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(Full::new(Bytes::from("Failed to spawn git process")))
|
|
.unwrap());
|
|
}
|
|
};
|
|
|
|
// Write request body to stdin
|
|
if let Some(mut stdin) = child.stdin.take() {
|
|
if let Err(e) = stdin.write_all(&body_bytes).await {
|
|
eprintln!("Failed to write to git stdin: {}", e);
|
|
}
|
|
// Close stdin to signal end of input
|
|
drop(stdin);
|
|
}
|
|
|
|
// Read stdout
|
|
let mut output = Vec::new();
|
|
if let Some(mut stdout) = child.stdout.take() {
|
|
if let Err(e) = stdout.read_to_end(&mut output).await {
|
|
eprintln!("Failed to read git output: {}", e);
|
|
}
|
|
}
|
|
|
|
// Read stderr for debugging
|
|
let mut stderr_output = Vec::new();
|
|
if let Some(mut stderr) = child.stderr.take() {
|
|
let _ = stderr.read_to_end(&mut stderr_output).await;
|
|
}
|
|
|
|
// Wait for process
|
|
let status = child.wait().await;
|
|
if let Ok(s) = &status {
|
|
if !s.success() {
|
|
let stderr_str = String::from_utf8_lossy(&stderr_output);
|
|
eprintln!("git upload-pack failed: {}", stderr_str);
|
|
}
|
|
}
|
|
|
|
Ok(Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header("Content-Type", "application/x-git-upload-pack-result")
|
|
.header("Cache-Control", "no-cache")
|
|
.body(Full::new(Bytes::from(output)))
|
|
.unwrap())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod smart_git_server_tests {
|
|
use super::*;
|
|
use crate::common::purgatory_helpers::{create_test_repo_with_commit, CommitVariant};
|
|
|
|
#[tokio::test]
|
|
async fn test_smart_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 = SmartGitServer::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_smart_git_server_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 = SmartGitServer::start(temp_dir.path()).await;
|
|
|
|
// Fetch info/refs with service parameter
|
|
let info_refs_url = format!("{}/info/refs?service=git-upload-pack", 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"
|
|
);
|
|
|
|
// Check content type
|
|
let content_type = response
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(
|
|
content_type.contains("application/x-git-upload-pack-advertisement"),
|
|
"Content-Type should be git-upload-pack-advertisement, got: {}",
|
|
content_type
|
|
);
|
|
|
|
let body = response
|
|
.bytes()
|
|
.await
|
|
.expect("Failed to read response body");
|
|
|
|
// Should start with service advertisement pkt-line
|
|
let body_str = String::from_utf8_lossy(&body);
|
|
assert!(
|
|
body_str.contains("# service=git-upload-pack"),
|
|
"Response should contain service advertisement, got: {}",
|
|
body_str
|
|
);
|
|
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_smart_git_server_ls_remote() {
|
|
// 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 = SmartGitServer::start(temp_dir.path()).await;
|
|
|
|
// Run git ls-remote against the server (using tokio::process::Command)
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["ls-remote", server.url()])
|
|
.output()
|
|
.await
|
|
.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_smart_git_server_fetch() {
|
|
// 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 = SmartGitServer::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 = tokio::process::Command::new("git")
|
|
.args(["init"])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.expect("Failed to init dest repo");
|
|
assert!(output.status.success());
|
|
|
|
// Add the server as a remote
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["remote", "add", "origin", server.url()])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.expect("Failed to add remote");
|
|
assert!(output.status.success());
|
|
|
|
// Fetch from the server
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["fetch", "origin"])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.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 = tokio::process::Command::new("git")
|
|
.args(["rev-parse", "origin/main"])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.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;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_smart_git_server_shallow_fetch() {
|
|
// This is the KEY test - shallow fetch requires smart HTTP protocol
|
|
|
|
// 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 = SmartGitServer::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 = tokio::process::Command::new("git")
|
|
.args(["init"])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.expect("Failed to init dest repo");
|
|
assert!(output.status.success());
|
|
|
|
// Add the server as a remote
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["remote", "add", "origin", server.url()])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.expect("Failed to add remote");
|
|
assert!(output.status.success());
|
|
|
|
// Shallow fetch from the server - THIS IS WHAT PURGATORY SYNC USES
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["fetch", "--depth=1", "origin", &commit_hash])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.expect("Failed to fetch");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"git fetch --depth=1 should succeed with smart HTTP: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// Verify the commit was fetched
|
|
let output = tokio::process::Command::new("git")
|
|
.args(["cat-file", "-t", &commit_hash])
|
|
.current_dir(dest_dir.path())
|
|
.output()
|
|
.await
|
|
.expect("Failed to cat-file");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"Commit should exist after shallow fetch"
|
|
);
|
|
let object_type = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
assert_eq!(object_type, "commit", "Object should be a commit");
|
|
|
|
server.stop().await;
|
|
}
|
|
}
|