//! `forge hook `: the client side of the internal hook API. //! //! Git runs this from its hook shims. It reads ` ` lines from stdin, //! POSTs them as JSON to the internal unix socket and prints the server's message on //! stderr. Git relays stderr from hooks to the pusher as `remote: ...`. use std::collections::BTreeMap; use std::io; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::UnixStream; use crate::internal::{HookRequest, HookResponse, HookUpdate, QUARANTINE_VARS}; /// Upper bound for the whole internal API exchange. const TIMEOUT: Duration = Duration::from_secs(30); /// Runs one hook stage. A denied update prints the message and exits the process /// with status 1, so git rejects the push. Infrastructure failures return `Err`. pub async fn run(stage: &str) -> forge_core::Result<()> { let path = match stage { "pre-receive" => "/hook/pre-receive", "post-receive" => "/hook/post-receive", other => { return Err(forge_core::Error::Invalid(format!( "unknown hook stage: {other}" ))); } }; let socket = required_env("FORGE_INTERNAL_SOCKET")?; let token = required_env("FORGE_INTERNAL_TOKEN")?; let actor_id = required_env("FORGE_ACTOR_ID")?; let repo_id = required_env("FORGE_REPO_ID")?; let protocol = required_env("FORGE_PROTOCOL")?; let updates = read_updates().await?; let quarantine_env: BTreeMap = QUARANTINE_VARS .iter() .filter_map(|&key| { std::env::var(key) .ok() .map(|value| (key.to_string(), value)) }) .collect(); let wiki = std::env::var("FORGE_WIKI").is_ok_and(|v| v == "1"); let request = HookRequest { repo_id, actor_id, protocol, wiki, updates, quarantine_env, }; let body = serde_json::to_vec(&request) .map_err(|e| forge_core::Error::Invalid(format!("encode hook request: {e}")))?; let (status, payload) = tokio::time::timeout(TIMEOUT, post_json(&socket, path, &token, &body)) .await .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "internal API request timed out"))??; if status != 200 { return Err(forge_core::Error::Invalid(format!( "internal API returned HTTP {status}" ))); } let response: HookResponse = serde_json::from_slice(&payload) .map_err(|e| forge_core::Error::Invalid(format!("decode hook response: {e}")))?; if !response.message.is_empty() { eprintln!("{}", response.message.trim_end()); } if response.allow { return Ok(()); } if response.message.is_empty() { eprintln!("push rejected"); } std::process::exit(1); } fn required_env(name: &str) -> forge_core::Result { std::env::var(name) .map_err(|_| forge_core::Error::Invalid(format!("missing environment variable {name}"))) } /// Parses stdin lines ` `. Blank lines are skipped. async fn read_updates() -> forge_core::Result> { let mut raw = Vec::new(); tokio::io::stdin().read_to_end(&mut raw).await?; let input = String::from_utf8(raw) .map_err(|_| forge_core::Error::Invalid("hook input is not UTF-8".into()))?; input .lines() .filter(|line| !line.trim().is_empty()) .map(|line| { let mut parts = line.split(' '); match (parts.next(), parts.next(), parts.next(), parts.next()) { (Some(old), Some(new), Some(ref_name), None) => Ok(HookUpdate { old: old.to_string(), new: new.to_string(), ref_name: ref_name.to_string(), }), _ => Err(forge_core::Error::Invalid(format!( "malformed hook input line: {line:?}" ))), } }) .collect() } /// Sends one `POST` with a JSON body over the unix socket and returns the status /// code and the decoded body. Sends `Connection: close` and reads to EOF. async fn post_json( socket: &str, path: &str, token: &str, body: &[u8], ) -> io::Result<(u16, Vec)> { let mut stream = UnixStream::connect(socket).await?; let head = format!( "POST {path} HTTP/1.1\r\n\ Host: forge-internal\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ X-Forge-Internal-Token: {token}\r\n\ Connection: close\r\n\r\n", body.len() ); stream.write_all(head.as_bytes()).await?; stream.write_all(body).await?; stream.flush().await?; let mut raw = Vec::new(); stream.read_to_end(&mut raw).await?; parse_response(&raw) } fn invalid(message: &str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message.to_string()) } fn find(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } /// Parses an HTTP/1.1 response with `Content-Length` or chunked framing. pub(crate) fn parse_response(raw: &[u8]) -> io::Result<(u16, Vec)> { let split = find(raw, b"\r\n\r\n").ok_or_else(|| invalid("missing end of HTTP headers"))?; let head = std::str::from_utf8(&raw[..split]).map_err(|_| invalid("HTTP headers are not UTF-8"))?; let mut lines = head.split("\r\n"); let status_line = lines.next().unwrap_or_default(); let status = status_line .split_whitespace() .nth(1) .and_then(|code| code.parse::().ok()) .ok_or_else(|| invalid("malformed HTTP status line"))?; let mut chunked = false; let mut content_length = None; for line in lines { if let Some((name, value)) = line.split_once(':') { let value = value.trim(); match name.trim().to_ascii_lowercase().as_str() { "transfer-encoding" => chunked = value.to_ascii_lowercase().contains("chunked"), "content-length" => content_length = value.parse::().ok(), _ => {} } } } let payload = &raw[split + 4..]; let body = if chunked { decode_chunked(payload)? } else if let Some(len) = content_length { payload .get(..len) .ok_or_else(|| invalid("response body shorter than Content-Length"))? .to_vec() } else { payload.to_vec() }; Ok((status, body)) } fn decode_chunked(mut data: &[u8]) -> io::Result> { let mut out = Vec::new(); loop { let line_end = find(data, b"\r\n").ok_or_else(|| invalid("missing chunk size line"))?; let size_line = std::str::from_utf8(&data[..line_end]) .map_err(|_| invalid("chunk size is not UTF-8"))?; let size_hex = size_line.split(';').next().unwrap_or_default().trim(); let size = usize::from_str_radix(size_hex, 16).map_err(|_| invalid("malformed chunk size"))?; data = &data[line_end + 2..]; if size == 0 { return Ok(out); } let chunk = data.get(..size).ok_or_else(|| invalid("truncated chunk"))?; out.extend_from_slice(chunk); data = data .get(size + 2..) .ok_or_else(|| invalid("truncated chunk"))?; } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_content_length_response() { let raw = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 2\r\n\r\n{}"; assert_eq!(parse_response(raw).expect("parse"), (200, b"{}".to_vec())); } #[test] fn parses_chunked_response() { let raw = b"HTTP/1.1 403 Forbidden\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nwiki\r\n5;ext=1\r\npedia\r\n0\r\n\r\n"; assert_eq!( parse_response(raw).expect("parse"), (403, b"wikipedia".to_vec()) ); } #[test] fn rejects_truncated_response() { assert!(parse_response(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc").is_err()); assert!(parse_response(b"garbage").is_err()); } }