refactor: serve smart-HTTP gateway with Axum on Tokio
commit
56bb7e5refactor: serve smart-HTTP gateway with Axum on Tokio
Replace the tiny_http listener with an Axum router on a Tokio runtime,
keeping the git http-backend CGI delegation and all git plumbing
shell-outs. Request body and CGI stdout now stream concurrently to
avoid pipe deadlocks on large pushes.
refactor: route / and /healthz to an Axum health handler
refactor: gate git requests behind an Axum fallback handler
refactor: count requests via middleware for --max-requests shutdown
deprecates: tiny_http dependency
Assisted-by: Claude:claude-opus-4-8
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
Cargo.lock
@@ -53,10 +53,62 @@
]
[[package]]
-name = "ascii"
-version = "1.1.0"
+name = "atomic-waker"
+version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
[[package]]
name = "bitflags"
@@ -64,18 +116,18 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+[[package]]
+name = "bytes"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
+
[[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"
@@ -148,6 +200,48 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -167,10 +261,11 @@
name = "git-ents-server"
version = "0.0.0"
dependencies = [
+ "axum",
"clap",
"clap_mangen",
"tempfile",
- "tiny_http",
+ "tokio",
]
[[package]]
@@ -179,18 +274,98 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
[[package]]
name = "libc"
version = "0.2.186"
@@ -209,6 +384,35 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -221,6 +425,18 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -264,6 +480,109 @@
"windows-sys",
]
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -281,6 +600,12 @@
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -295,15 +620,78 @@
]
[[package]]
-name = "tiny_http"
-version = "0.12.0"
+name = "tokio"
+version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
- "ascii",
- "chunked_transfer",
- "httpdate",
"log",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
]
[[package]]
@@ -318,6 +706,12 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -332,3 +726,9 @@
dependencies = [
"windows-link",
]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml
@@ -12,12 +12,20 @@
missing_docs = "warn"
[workspace.dependencies]
+axum = "0.8"
clap = { version = "4.5.60", features = ["derive"] }
clap_mangen = "0.2.31"
gix = { version = "0.83", features = ["no-default-features"] }
tempfile = "3"
thiserror = "2"
-tiny_http = "0.12"
+tokio = { version = "1", features = [
+ "rt-multi-thread",
+ "macros",
+ "net",
+ "process",
+ "io-util",
+ "sync",
+] }
# 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
@@ -6,9 +6,10 @@
license.workspace = true
[dependencies]
+axum = { workspace = true }
clap = { workspace = true }
clap_mangen = { workspace = true }
-tiny_http = { workspace = true }
+tokio = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
crates/git-ents-server/src/http.rs
@@ -2,56 +2,73 @@
//!
//! 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.
+//! `git-receive-pack` for push). This module only translates between Axum
+//! requests/responses and the CGI's stdin/stdout.
-use std::io::Write;
use std::path::{Path, PathBuf};
-use std::process::{Command, Stdio};
+use std::process::Stdio;
-use tiny_http::{Header, Request, Response};
+use axum::body::{Body, Bytes};
+use axum::extract::State;
+use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
+use axum::response::{IntoResponse, Response};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::process::Command;
+
+use crate::AppState;
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()),
- };
+/// A liveness probe (and the `/` root) that does not touch git.
+pub async fn health() -> &'static str {
+ "ok"
+}
- if path_info.contains("..") {
- return request.respond(Response::from_string("bad request").with_status_code(400));
+/// Delegate a single request to `git http-backend` and reply with its output.
+pub async fn git(
+ State(state): State<AppState>,
+ method: Method,
+ uri: Uri,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ if let Some(expected) = state.access_token.as_deref()
+ && !authorized(&headers, expected)
+ {
+ return unauthorized();
}
- let method = request.method().as_str().to_owned();
- let content_type = header_value(&request, "Content-Type");
- let content_length = header_value(&request, "Content-Length");
+ let path_info = uri.path().to_owned();
+ let query_string = uri.query().unwrap_or_default().to_owned();
+
+ if path_info.contains("..") {
+ return (StatusCode::BAD_REQUEST, "bad request").into_response();
+ }
// 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");
- let push_repo = is_push.then(|| repo_dir(data_dir, &path_info)).flatten();
+ let push_repo = is_push
+ .then(|| repo_dir(&state.data_dir, &path_info))
+ .flatten();
if let Some(repo) = &push_repo
&& !repo.exists()
- && let Err(e) = init_bare_repo(repo)
+ && let Err(e) = init_bare_repo(repo).await
{
- return request
- .respond(Response::from_string(format!("init failed: {e}")).with_status_code(500));
+ return (StatusCode::INTERNAL_SERVER_ERROR, format!("init failed: {e}")).into_response();
}
- let mut body = Vec::new();
- request.as_reader().read_to_end(&mut body)?;
+ let content_type = header_value(&headers, "Content-Type");
+ let content_length = header_value(&headers, "Content-Length");
let mut cmd = Command::new("git");
cmd.arg("http-backend")
- .env("GIT_PROJECT_ROOT", data_dir)
+ .env("GIT_PROJECT_ROOT", &state.data_dir)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("PATH_INFO", &path_info)
.env("QUERY_STRING", &query_string)
- .env("REQUEST_METHOD", &method)
+ .env("REQUEST_METHOD", method.as_str())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
@@ -65,32 +82,51 @@
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),
- );
+ return (StatusCode::INTERNAL_SERVER_ERROR, format!("spawn failed: {e}"))
+ .into_response();
}
};
- if let Some(mut stdin) = child.stdin.take() {
- stdin.write_all(&body)?;
+ // Feed the request body and drain stdout concurrently; receive-pack streams
+ // progress to stdout while still reading the pack, so a sequential
+ // write-then-read would deadlock on a full pipe.
+ let writer = child.stdin.take().map(|mut stdin| {
+ tokio::spawn(async move {
+ let _write = stdin.write_all(&body).await;
+ // `stdin` drops here, closing the pipe so the CGI sees EOF.
+ })
+ });
+
+ let mut stdout = Vec::new();
+ if let Some(mut out) = child.stdout.take() {
+ let _read = out.read_to_end(&mut stdout).await;
+ }
+ if let Some(writer) = writer {
+ let _joined = writer.await;
}
- let output = child.wait_with_output()?;
+ let status = match child.wait().await {
+ Ok(status) => status,
+ Err(e) => {
+ return (StatusCode::INTERNAL_SERVER_ERROR, format!("backend failed: {e}"))
+ .into_response();
+ }
+ };
// A fresh bare repo's `HEAD` points at its initial branch, which may not be
// the branch the client just pushed; without a valid `HEAD`, clones check
// out nothing. Adopt a pushed branch so the repo stays clonable.
if let Some(repo) = &push_repo
- && output.status.success()
+ && status.success()
{
- reconcile_head(repo);
+ reconcile_head(repo).await;
}
- request.respond(build_response(&output.stdout))
+ build_response(&stdout)
}
/// Translate a CGI response (header block, blank line, body) into HTTP.
-fn build_response(stdout: &[u8]) -> Response<std::io::Cursor<Vec<u8>>> {
+fn build_response(stdout: &[u8]) -> Response {
let (header_block, body) = match find_subsequence(stdout, CGI_HEADER_SEP) {
Some(pos) => {
let body_start = pos.saturating_add(CGI_HEADER_SEP.len());
@@ -103,7 +139,7 @@
};
let mut status = 200u16;
- let mut headers: Vec<Header> = Vec::new();
+ let mut builder = Response::builder();
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 {
@@ -114,17 +150,16 @@
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);
+ // Axum sets this from the body length itself.
+ } else {
+ builder = builder.header(name, value);
}
}
- let mut response = Response::from_data(body.to_vec()).with_status_code(status);
- for header in headers {
- response.add_header(header);
- }
- response
+ builder
+ .status(status)
+ .body(Body::from(body.to_vec()))
+ .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
/// Resolve the bare repository directory from the first path segment.
@@ -137,7 +172,7 @@
}
/// Create a bare repo that accepts pushes over smart-HTTP.
-fn init_bare_repo(repo: &Path) -> std::io::Result<()> {
+async fn init_bare_repo(repo: &Path) -> std::io::Result<()> {
let init = Command::new("git")
.arg("init")
.arg("--bare")
@@ -146,7 +181,8 @@
.arg(repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
- .status()?;
+ .status()
+ .await?;
if !init.success() {
return Err(std::io::Error::other("git init --bare failed"));
}
@@ -158,7 +194,8 @@
.arg("true")
.stdout(Stdio::null())
.stderr(Stdio::null())
- .status()?;
+ .status()
+ .await?;
if !config.success() {
return Err(std::io::Error::other("git config http.receivepack failed"));
}
@@ -168,7 +205,7 @@
/// Point `HEAD` at a real branch when it dangles after a push.
///
/// Best-effort: the push already succeeded, so failures here are ignored.
-fn reconcile_head(repo: &Path) {
+async fn reconcile_head(repo: &Path) {
let head_valid = Command::new("git")
.arg("-C")
.arg(repo)
@@ -176,6 +213,7 @@
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
+ .await
.map(|status| status.success())
.unwrap_or(false);
if head_valid {
@@ -188,6 +226,7 @@
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads/"])
.stderr(Stdio::null())
.output()
+ .await
else {
return;
};
@@ -208,15 +247,77 @@
.args(["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")])
.stdout(Stdio::null())
.stderr(Stdio::null())
- .status();
+ .status()
+ .await;
}
-pub(crate) 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())
+/// Check the request's HTTP Basic credentials against the expected token.
+///
+/// As with GitHub's HTTPS git auth, the username is ignored and the password
+/// field carries the bearer token.
+fn authorized(headers: &HeaderMap, expected: &str) -> bool {
+ let Some(value) = header_value(headers, "Authorization") else {
+ return false;
+ };
+ let Some(encoded) = value
+ .strip_prefix("Basic ")
+ .or_else(|| value.strip_prefix("basic "))
+ else {
+ return false;
+ };
+ let Some(decoded) = base64_decode(encoded.trim()) else {
+ return false;
+ };
+ let Some(colon) = decoded.iter().position(|byte| *byte == b':') else {
+ return false;
+ };
+ decoded.get(colon.saturating_add(1)..) == Some(expected.as_bytes())
+}
+
+/// A `401` carrying the Basic challenge git expects before retrying with creds.
+fn unauthorized() -> Response {
+ let mut response = (StatusCode::UNAUTHORIZED, "unauthorized").into_response();
+ response.headers_mut().insert(
+ header::WWW_AUTHENTICATE,
+ HeaderValue::from_static(r#"Basic realm="git-ents""#),
+ );
+ response
+}
+
+fn header_value(headers: &HeaderMap, field: &str) -> Option<String> {
+ headers
+ .get(field)
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned)
+}
+
+/// Decode standard base64 with optional `=` padding; `None` on any bad input.
+fn base64_decode(input: &str) -> Option<Vec<u8>> {
+ fn sextet(byte: u8) -> Option<u32> {
+ let value = u32::from(byte);
+ match byte {
+ b'A'..=b'Z' => Some(value.saturating_sub(u32::from(b'A'))),
+ b'a'..=b'z' => Some(value.saturating_sub(u32::from(b'a')).saturating_add(26)),
+ b'0'..=b'9' => Some(value.saturating_sub(u32::from(b'0')).saturating_add(52)),
+ b'+' => Some(62),
+ b'/' => Some(63),
+ _ => None,
+ }
+ }
+
+ let bytes: &[u8] = input.trim_end_matches('=').as_bytes();
+ let mut out = Vec::new();
+ let mut acc: u32 = 0;
+ let mut bits: u32 = 0;
+ for &byte in bytes {
+ acc = (acc << 6) | sextet(byte)?;
+ bits = bits.saturating_add(6);
+ if bits >= 8 {
+ bits = bits.saturating_sub(8);
+ out.push(u8::try_from((acc >> bits) & 0xFF).ok()?);
+ }
+ }
+ Some(out)
}
fn parse_status(value: &[u8]) -> Option<u16> {
crates/git-ents-server/src/main.rs
@@ -2,10 +2,16 @@
mod http;
+use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use axum::Router;
+use axum::routing::get;
use clap::{CommandFactory, Parser};
+use tokio::sync::Notify;
#[derive(Parser)]
#[command(
@@ -34,6 +40,13 @@
max_requests: Option<usize>,
}
+/// Shared handler state: where repos live and the optional auth token.
+#[derive(Clone)]
+pub(crate) struct AppState {
+ pub(crate) data_dir: PathBuf,
+ pub(crate) access_token: Option<String>,
+}
+
fn main() -> ExitCode {
let args = Args::parse();
@@ -46,101 +59,66 @@
return ExitCode::SUCCESS;
}
- let server = match tiny_http::Server::http(format!("0.0.0.0:{}", args.port)) {
- Ok(server) => server,
+ let runtime = match tokio::runtime::Runtime::new() {
+ Ok(runtime) => runtime,
+ Err(e) => {
+ eprintln!("error: failed to start runtime: {e}");
+ return ExitCode::FAILURE;
+ }
+ };
+ runtime.block_on(serve(args))
+}
+
+/// Bind the listener and serve until shutdown.
+async fn serve(args: Args) -> ExitCode {
+ let state = AppState {
+ data_dir: args.data_dir,
+ access_token: args.access_token,
+ };
+
+ let mut app = Router::new()
+ .route("/", get(http::health))
+ .route("/healthz", get(http::health))
+ .fallback(http::git)
+ .with_state(state);
+
+ // When `--max-requests` is set, count every response and signal shutdown
+ // once the limit is reached so the process exits on its own.
+ let shutdown = Arc::new(Notify::new());
+ if let Some(max) = args.max_requests {
+ let counter = Arc::new(AtomicUsize::new(0));
+ let notify = shutdown.clone();
+ app = app.layer(axum::middleware::from_fn(
+ move |req: axum::extract::Request, next: axum::middleware::Next| {
+ let counter = counter.clone();
+ let notify = notify.clone();
+ async move {
+ let response = next.run(req).await;
+ let handled = counter.fetch_add(1, Ordering::SeqCst).saturating_add(1);
+ if handled >= max {
+ notify.notify_one();
+ }
+ response
+ }
+ },
+ ));
+ }
+
+ let addr = SocketAddr::from(([0, 0, 0, 0], args.port));
+ let listener = match tokio::net::TcpListener::bind(addr).await {
+ Ok(listener) => listener,
Err(e) => {
eprintln!("error: failed to bind to port {}: {e}", args.port);
return ExitCode::FAILURE;
}
};
- 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 args
- .access_token
- .as_deref()
- .is_some_and(|token| !authorized(&request, token))
- {
- let _denied = request.respond(unauthorized());
- } else if let Err(e) = http::handle(request, &args.data_dir) {
- eprintln!("error: {e}");
- }
- count = count.saturating_add(1);
- if args.max_requests.is_some_and(|max| count >= max) {
- break;
- }
+ let server = axum::serve(listener, app)
+ .with_graceful_shutdown(async move { shutdown.notified().await });
+ if let Err(e) = server.await {
+ eprintln!("error: {e}");
+ return ExitCode::FAILURE;
}
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")
-}
-
-/// Check the request's HTTP Basic credentials against the expected token.
-///
-/// As with GitHub's HTTPS git auth, the username is ignored and the password
-/// field carries the bearer token.
-fn authorized(request: &tiny_http::Request, expected: &str) -> bool {
- let Some(value) = http::header_value(request, "Authorization") else {
- return false;
- };
- let Some(encoded) = value
- .strip_prefix("Basic ")
- .or_else(|| value.strip_prefix("basic "))
- else {
- return false;
- };
- let Some(decoded) = base64_decode(encoded.trim()) else {
- return false;
- };
- let Some(colon) = decoded.iter().position(|byte| *byte == b':') else {
- return false;
- };
- decoded.get(colon.saturating_add(1)..) == Some(expected.as_bytes())
-}
-
-/// A `401` carrying the Basic challenge git expects before retrying with creds.
-fn unauthorized() -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
- let mut response = tiny_http::Response::from_string("unauthorized").with_status_code(401);
- if let Ok(header) = tiny_http::Header::from_bytes(
- &b"WWW-Authenticate"[..],
- &br#"Basic realm="git-ents""#[..],
- ) {
- response.add_header(header);
- }
- response
-}
-
-/// Decode standard base64 with optional `=` padding; `None` on any bad input.
-fn base64_decode(input: &str) -> Option<Vec<u8>> {
- fn sextet(byte: u8) -> Option<u32> {
- let value = u32::from(byte);
- match byte {
- b'A'..=b'Z' => Some(value.saturating_sub(u32::from(b'A'))),
- b'a'..=b'z' => Some(value.saturating_sub(u32::from(b'a')).saturating_add(26)),
- b'0'..=b'9' => Some(value.saturating_sub(u32::from(b'0')).saturating_add(52)),
- b'+' => Some(62),
- b'/' => Some(63),
- _ => None,
- }
- }
-
- let bytes: &[u8] = input.trim_end_matches('=').as_bytes();
- let mut out = Vec::new();
- let mut acc: u32 = 0;
- let mut bits: u32 = 0;
- for &byte in bytes {
- acc = (acc << 6) | sextet(byte)?;
- bits = bits.saturating_add(6);
- if bits >= 8 {
- bits = bits.saturating_sub(8);
- out.push(u8::try_from((acc >> bits) & 0xFF).ok()?);
- }
- }
- Some(out)
-}