git-ents.gitmain
⌘K
foforge
commit 56bb7e5
refactor: 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

Joseph D. Carpinelli · 2 months ago

Reviews

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

Start a review

verdict

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) -}