git-ents.gitmain
⌘K
foforge
commit b351f24
feat: serve git over HTTPS by delegating to `git http-backend`

The server hands every request to git’s http-backend CGI, which speaks smart-HTTP for both fetch and push, so git clone and git push work against it over a persistent repo root. Authentication is not yet enforced.

feat: add http module delegating requests to git http-backend feat: add --data-dir flag (env GIT_PROJECT_ROOT) for the repo root feat: serve 200 OK on / and /healthz as a liveness probe feat: auto-init bare repos with http.receivepack=true on push build: add tiny_http dependency test: add push-then-clone round-trip integration test Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 2 months ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -52,6 +52,30 @@ "windows-sys", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clap" version = "4.6.1" @@ -108,6 +132,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "git-ents" version = "0.0.0" @@ -118,6 +169,8 @@ dependencies = [ "clap", "clap_mangen", + "tempfile", + "tiny_http", ] [[package]] @@ -126,12 +179,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -156,12 +239,31 @@ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "roff" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "strsim" version = "0.11.1" @@ -179,6 +281,31 @@ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "unicode-ident" version = "1.0.24"
Cargo.toml @@ -17,6 +17,7 @@ gix = { version = "0.83", features = ["no-default-features"] } tempfile = "3" thiserror = "2" +tiny_http = "0.12" # These lint configurations were originally pulled from [Evan Schwartz][1]. # [1]: https://emschwartz.me/your-clippy-config-should-be-stricter/
crates/git-ents-server/Cargo.toml @@ -8,6 +8,10 @@ [dependencies] clap = { workspace = true } clap_mangen = { workspace = true } +tiny_http = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } [lints] workspace = true
crates/git-ents-server/src/main.rs @@ -1,7 +1,7 @@ //! Git Ents server — helpful guardians of your git trees. -use std::io::{Read, Write}; -use std::net::TcpListener; +mod http; + use std::path::PathBuf; use std::process::ExitCode; @@ -21,6 +21,10 @@ #[arg(long, env = "PORT", default_value = "8080")] port: u16, + /// Directory holding the bare repositories served over HTTP. + #[arg(long, env = "GIT_PROJECT_ROOT", default_value = "/data/repos")] + data_dir: PathBuf, + /// Stop after handling this many requests. #[arg(long)] max_requests: Option<usize>, @@ -38,27 +42,31 @@ return ExitCode::SUCCESS; } - let listener = match TcpListener::bind(format!("0.0.0.0:{}", args.port)) { - Ok(l) => l, + let server = match tiny_http::Server::http(format!("0.0.0.0:{}", args.port)) { + Ok(server) => server, Err(e) => { eprintln!("error: failed to bind to port {}: {e}", args.port); return ExitCode::FAILURE; } }; - for (count, stream) in listener.incoming().enumerate() { - if let Ok(mut stream) = stream { - let mut buf = [0u8; 4096]; - let _read = stream.read(&mut buf); - let _write = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let mut count: usize = 0; + for request in server.incoming_requests() { + if is_health(&request) { + let _health = request.respond(tiny_http::Response::from_string("ok")); + } else if let Err(e) = http::handle(request, &args.data_dir) { + eprintln!("error: {e}"); } - if args - .max_requests - .is_some_and(|max| count.saturating_add(1) >= max) - { + count = count.saturating_add(1); + if args.max_requests.is_some_and(|max| count >= max) { break; } } ExitCode::SUCCESS } + +/// A liveness probe (and the `/` root) that does not touch git. +fn is_health(request: &tiny_http::Request) -> bool { + matches!(request.url(), "/" | "/healthz") +}
crates/git-ents-server/tests/server.rs @@ -1,7 +1,14 @@ -#![allow(missing_docs, reason = "integration test binary")] +#![allow( + missing_docs, + clippy::unwrap_used, + clippy::panic, + clippy::arithmetic_side_effects, + reason = "integration test binary" +)] use std::io::{Read, Write}; -use std::net::TcpStream; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; use std::process::Command; #[test] @@ -33,7 +40,7 @@ let mut response = String::new(); stream.read_to_string(&mut response).unwrap(); assert!( - response.starts_with("HTTP/1.1 200 OK"), + response.contains("200 OK"), "unexpected response: {response}" ); } @@ -41,3 +48,99 @@ let status = child.wait().unwrap(); assert!(status.success()); } + +#[test] +fn push_then_clone_round_trip() { + let data = tempfile::tempdir().unwrap(); + let port = free_port(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_git-ents-server")) + .arg("--port") + .arg(port.to_string()) + .arg("--data-dir") + .arg(data.path()) + .spawn() + .unwrap(); + + wait_for_port(port); + + let url = format!("http://127.0.0.1:{port}/test.git"); + + // Build a source repo with one commit on `main` and push it (auto-init). + let src = tempfile::tempdir().unwrap(); + run_git(Some(src.path()), &["init", "-q", "-b", "main"]); + std::fs::write(src.path().join("README.md"), "hello ents\n").unwrap(); + run_git(Some(src.path()), &["add", "."]); + run_git(Some(src.path()), &["commit", "-q", "-m", "initial"]); + run_git(Some(src.path()), &["push", "-q", &url, "main"]); + let pushed = rev_parse(src.path()); + + // Clone it back and confirm the objects round-trip. + let dst = tempfile::tempdir().unwrap(); + let clone_path = dst.path().join("clone"); + run_git( + None, + &["clone", "-q", &url, clone_path.to_str().unwrap()], + ); + let cloned = rev_parse(&clone_path); + + child.kill().unwrap(); + let _wait = child.wait(); + + assert_eq!(pushed, cloned, "cloned HEAD must match pushed HEAD"); +} + +fn free_port() -> u16 { + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = probe.local_addr().unwrap().port(); + drop(probe); + port +} + +fn wait_for_port(port: u16) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match TcpStream::connect(format!("127.0.0.1:{port}")) { + Ok(_) => return, + Err(_) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(e) => panic!("server never accepted connections: {e}"), + } + } +} + +fn run_git(dir: Option<&Path>, args: &[&str]) { + let output = git_command(dir, args).output().unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn rev_parse(dir: &Path) -> String { + let output = git_command(Some(dir), &["rev-parse", "HEAD"]).output().unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn git_command(dir: Option<&Path>, args: &[&str]) -> Command { + let mut cmd = Command::new("git"); + cmd.env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_TERMINAL_PROMPT", "0"); + if let Some(dir) = dir { + cmd.arg("-C").arg(dir); + } + cmd.args([ + "-c", + "user.name=Ent Test", + "-c", + "user.email=ent@example.com", + "-c", + "commit.gpgsign=false", + ]); + cmd.args(args); + cmd +}
crates/git-ents-server/src/http.rs @@ -1,0 +1,199 @@ +//! Smart-HTTP gateway: delegates the git protocol to `git http-backend`. +//! +//! Every request is handed to git's `http-backend` CGI, which implements the +//! full smart-HTTP protocol (running `git-upload-pack` for fetch and +//! `git-receive-pack` for push). This module only translates between +//! `tiny_http` requests/responses and the CGI's stdin/stdout. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use tiny_http::{Header, Request, Response}; + +const CGI_HEADER_SEP: &[u8] = b"\r\n\r\n"; + +/// Delegate a single request to `git http-backend` and reply with its output. +pub fn handle(mut request: Request, data_dir: &Path) -> std::io::Result<()> { + let url = request.url().to_owned(); + let (path_info, query_string) = match url.split_once('?') { + Some((path, query)) => (path.to_owned(), query.to_owned()), + None => (url, String::new()), + }; + + if path_info.contains("..") { + return request.respond(Response::from_string("bad request").with_status_code(400)); + } + + let method = request.method().as_str().to_owned(); + let content_type = header_value(&request, "Content-Type"); + let content_length = header_value(&request, "Content-Length"); + + // A push begins with `info/refs?service=git-receive-pack`; auto-init the + // bare repo so the very first request finds it. + let is_push = query_string.contains("service=git-receive-pack") + || path_info.ends_with("/git-receive-pack"); + if is_push + && let Some(repo) = repo_dir(data_dir, &path_info) + && !repo.exists() + && let Err(e) = init_bare_repo(&repo) + { + return request + .respond(Response::from_string(format!("init failed: {e}")).with_status_code(500)); + } + + let mut body = Vec::new(); + request.as_reader().read_to_end(&mut body)?; + + let mut cmd = Command::new("git"); + cmd.arg("http-backend") + .env("GIT_PROJECT_ROOT", data_dir) + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("PATH_INFO", &path_info) + .env("QUERY_STRING", &query_string) + .env("REQUEST_METHOD", &method) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + if let Some(value) = &content_type { + cmd.env("CONTENT_TYPE", value); + } + if let Some(value) = &content_length { + cmd.env("CONTENT_LENGTH", value); + } + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + return request.respond( + Response::from_string(format!("spawn failed: {e}")).with_status_code(500), + ); + } + }; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(&body)?; + } + + let output = child.wait_with_output()?; + request.respond(build_response(&output.stdout)) +} + +/// Translate a CGI response (header block, blank line, body) into HTTP. +fn build_response(stdout: &[u8]) -> Response<std::io::Cursor<Vec<u8>>> { + let (header_block, body) = match find_subsequence(stdout, CGI_HEADER_SEP) { + Some(pos) => { + let body_start = pos.saturating_add(CGI_HEADER_SEP.len()); + ( + stdout.get(..pos).unwrap_or_default(), + stdout.get(body_start..).unwrap_or_default(), + ) + } + None => (&b""[..], stdout), + }; + + let mut status = 200u16; + let mut headers: Vec<Header> = Vec::new(); + for raw in header_block.split(|byte| *byte == b'\n') { + let line = trim_cr(raw); + let Some(colon) = line.iter().position(|byte| *byte == b':') else { + continue; + }; + let name = line.get(..colon).unwrap_or_default(); + let value = trim_space(line.get(colon.saturating_add(1)..).unwrap_or_default()); + if name.eq_ignore_ascii_case(b"Status") { + status = parse_status(value).unwrap_or(200); + } else if name.eq_ignore_ascii_case(b"Content-Length") { + // tiny_http sets this from the body length itself. + } else if let Ok(header) = Header::from_bytes(name, value) { + headers.push(header); + } + } + + let mut response = Response::from_data(body.to_vec()).with_status_code(status); + for header in headers { + response.add_header(header); + } + response +} + +/// Resolve the bare repository directory from the first path segment. +fn repo_dir(data_dir: &Path, path_info: &str) -> Option<PathBuf> { + let first = path_info.trim_start_matches('/').split('/').next()?; + if first.is_empty() { + return None; + } + Some(data_dir.join(first)) +} + +/// Create a bare repo that accepts pushes over smart-HTTP. +fn init_bare_repo(repo: &Path) -> std::io::Result<()> { + let init = Command::new("git") + .arg("init") + .arg("--bare") + .arg(repo) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !init.success() { + return Err(std::io::Error::other("git init --bare failed")); + } + let config = Command::new("git") + .arg("-C") + .arg(repo) + .arg("config") + .arg("http.receivepack") + .arg("true") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !config.success() { + return Err(std::io::Error::other("git config http.receivepack failed")); + } + Ok(()) +} + +fn header_value(request: &Request, field: &str) -> Option<String> { + request + .headers() + .iter() + .find(|header| header.field.as_str().as_str().eq_ignore_ascii_case(field)) + .map(|header| header.value.as_str().to_owned()) +} + +fn parse_status(value: &[u8]) -> Option<u16> { + let token = value.split(|byte| *byte == b' ').next()?; + std::str::from_utf8(token).ok()?.parse().ok() +} + +fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> { + if needle.is_empty() || haystack.len() < needle.len() { + return None; + } + haystack.windows(needle.len()).position(|window| window == needle) +} + +fn trim_cr(line: &[u8]) -> &[u8] { + match line.split_last() { + Some((b'\r', rest)) => rest, + _ => line, + } +} + +fn trim_space(mut value: &[u8]) -> &[u8] { + while let Some((first, rest)) = value.split_first() { + if *first == b' ' || *first == b'\t' { + value = rest; + } else { + break; + } + } + while let Some((last, rest)) = value.split_last() { + if *last == b' ' || *last == b'\t' { + value = rest; + } else { + break; + } + } + value +}