//! **Another git server, on loopback**: stock `git http-backend` behind a
//! forty-line HTTP/1.1 front, serving a directory of bare repositories over
//! smart HTTP.
//!
//! The import rows need an upstream that is NOT gunnar — the whole point of a
//! mirror is that it pulls from somebody else's server — and that speaks the
//! protocol a real Gitea, cgit or `git daemon --enable=http` box speaks. That
//! is `git http-backend`, git's own CGI, which is exactly what those servers
//! run underneath. What is written here is only the CGI glue: one request per
//! connection, the environment the CGI contract names (`GIT_PROTOCOL` from the
//! `Git-Protocol` header included, which is what makes it answer protocol v2),
//! and the CGI's `Status:` line turned back into an HTTP status line.
//!
//! Zero dependencies, for the reason the crate gives in `Cargo.toml`, and the
//! same shelling-out exemption `rig.rs` records: `git` here IS the thing a
//! person runs.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
pub struct GitHttp {
pub port: u16,
/// Requests answered so far — the evidence the box actually dialled us.
pub served: Arc<AtomicUsize>,
stop: Arc<AtomicBool>,
}
impl GitHttp {
/// Serve every bare repository under `root` at `http://127.0.0.1:<port>/<name>`.
pub fn start(root: &Path) -> Result<GitHttp, String> {
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("bind: {e}"))?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
let stop = Arc::new(AtomicBool::new(false));
let served = Arc::new(AtomicUsize::new(0));
let (halt, count, root) = (Arc::clone(&stop), Arc::clone(&served), root.to_path_buf());
std::thread::spawn(move || {
while !halt.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let root = root.clone();
let count = Arc::clone(&count);
std::thread::spawn(move || {
if answer(stream, &root).is_ok() {
count.fetch_add(1, Ordering::Relaxed);
}
});
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => std::thread::sleep(Duration::from_millis(20)),
}
}
});
Ok(GitHttp { port, served, stop })
}
pub fn url(&self, repo: &str) -> String {
format!("http://127.0.0.1:{}/{repo}", self.port)
}
}
impl Drop for GitHttp {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
/// One request, one CGI run, one response, then close.
fn answer(stream: TcpStream, root: &Path) -> std::io::Result<()> {
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(60)))?;
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
reader.read_line(&mut line)?;
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or("GET").to_owned();
let target = parts.next().unwrap_or("/").to_owned();
let (path, query) = match target.split_once('?') {
Some((p, q)) => (p.to_owned(), q.to_owned()),
None => (target.clone(), String::new()),
};
let mut length = 0usize;
let mut content_type = String::new();
let mut protocol = String::new();
loop {
let mut header = String::new();
if reader.read_line(&mut header)? == 0 {
break;
}
let header = header.trim_end();
if header.is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':') {
let value = value.trim().to_owned();
match name.trim().to_ascii_lowercase().as_str() {
"content-length" => length = value.parse().unwrap_or(0),
"content-type" => content_type = value,
"git-protocol" => protocol = value,
_ => {}
}
}
}
let mut body = vec![0u8; length];
reader.read_exact(&mut body)?;
let mut child = Command::new("git")
.arg("http-backend")
.env("GIT_PROJECT_ROOT", root)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("REQUEST_METHOD", &method)
.env("PATH_INFO", &path)
.env("QUERY_STRING", &query)
.env("CONTENT_TYPE", &content_type)
.env("CONTENT_LENGTH", length.to_string())
.env("GIT_PROTOCOL", &protocol)
.env("REMOTE_ADDR", "127.0.0.1")
.env("SERVER_PROTOCOL", "HTTP/1.1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
child.stdin.take().expect("piped").write_all(&body)?;
let out = child.wait_with_output()?;
// CGI: headers, a blank line, the body. `Status:` is the one header that
// is not an HTTP header.
let raw = out.stdout;
let split = find(&raw, b"\r\n\r\n")
.map(|at| (at, 4))
.or_else(|| find(&raw, b"\n\n").map(|at| (at, 2)));
let (head, rest) = match split {
Some((at, skip)) => (&raw[..at], &raw[at + skip..]),
None => (&raw[..0], &raw[..]),
};
let mut status = "200 OK".to_owned();
let mut headers = String::new();
for header in String::from_utf8_lossy(head).lines() {
match header.split_once(':') {
Some((name, value)) if name.eq_ignore_ascii_case("status") => {
status = value.trim().to_owned();
}
Some(_) => {
headers.push_str(header.trim_end());
headers.push_str("\r\n");
}
None => {}
}
}
let mut stream = stream;
write!(stream, "HTTP/1.1 {status}\r\n{headers}Connection: close\r\n\r\n")?;
stream.write_all(rest)?;
stream.flush()
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
/// A bare repository at `<root>/<name>` with `commits` commits on `main`, made
/// by stock git, at `format` (`sha1` or `sha256`). Returns the head's oid.
pub fn bare_upstream(root: &Path, name: &str, format: &str, commits: usize) -> Result<String, String> {
let work: PathBuf = root.join(format!("{name}-work"));
let bare = root.join(name);
let _ = std::fs::remove_dir_all(&work);
let _ = std::fs::remove_dir_all(&bare);
std::fs::create_dir_all(&work).map_err(|e| e.to_string())?;
let git = |dir: &Path, args: &[&str]| -> Result<String, String> {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("HOME", root)
.output()
.map_err(|e| format!("git {args:?}: {e}"))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
} else {
Err(format!(
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr).trim()
))
}
};
let format_arg = format!("--object-format={format}");
git(&work, &["init", "-q", &format_arg, "-b", "main", "."])?;
git(&work, &["config", "user.name", "upstream"])?;
git(&work, &["config", "user.email", "upstream@example.invalid"])?;
for n in 0..commits {
std::fs::write(work.join("README"), format!("an upstream that is not gunnar, commit {n}\n"))
.map_err(|e| e.to_string())?;
git(&work, &["add", "README"])?;
git(&work, &["commit", "-q", "-m", &format!("upstream commit {n}")])?;
}
let head = git(&work, &["rev-parse", "HEAD"])?;
let bare_str = bare.display().to_string();
git(root, &["clone", "-q", "--bare", work.to_str().unwrap_or_default(), &bare_str])?;
// http-backend serves a repository only when it says so, or when
// GIT_HTTP_EXPORT_ALL is set (it is); the marker costs nothing and makes a
// hand-run `git http-backend` behave the same.
std::fs::write(bare.join("git-daemon-export-ok"), b"").map_err(|e| e.to_string())?;
Ok(head)
}