Gunnar

nordisk/gunnar-ui

uitest/src/githttp.rs

//! **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)
}