feat: serve git over HTTPS by delegating to `git http-backend`
commit
b351f24feat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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
+}