forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
8.0 KiB
1//! `forge hook <pre-receive|post-receive>`: the client side of the internal hook API.
2//!
3//! Git runs this from its hook shims. It reads `<old> <new> <ref>` lines from stdin,
4//! POSTs them as JSON to the internal unix socket and prints the server's message on
5//! stderr. Git relays stderr from hooks to the pusher as `remote: ...`.
6
7use std::collections::BTreeMap;
8use std::io;
9use std::time::Duration;
10
11use tokio::io::{AsyncReadExt, AsyncWriteExt};
12use tokio::net::UnixStream;
13
14use crate::internal::{HookRequest, HookResponse, HookUpdate, QUARANTINE_VARS};
15
16/// Upper bound for the whole internal API exchange.
17const TIMEOUT: Duration = Duration::from_secs(30);
18
19/// Runs one hook stage. A denied update prints the message and exits the process
20/// with status 1, so git rejects the push. Infrastructure failures return `Err`.
21pub async fn run(stage: &str) -> forge_core::Result<()> {
22 let path = match stage {
23 "pre-receive" => "/hook/pre-receive",
24 "post-receive" => "/hook/post-receive",
25 other => {
26 return Err(forge_core::Error::Invalid(format!(
27 "unknown hook stage: {other}"
28 )));
29 }
30 };
31
32 let socket = required_env("FORGE_INTERNAL_SOCKET")?;
33 let token = required_env("FORGE_INTERNAL_TOKEN")?;
34 let actor_id = required_env("FORGE_ACTOR_ID")?;
35 let repo_id = required_env("FORGE_REPO_ID")?;
36 let protocol = required_env("FORGE_PROTOCOL")?;
37
38 let updates = read_updates().await?;
39 let quarantine_env: BTreeMap<String, String> = QUARANTINE_VARS
40 .iter()
41 .filter_map(|&key| {
42 std::env::var(key)
43 .ok()
44 .map(|value| (key.to_string(), value))
45 })
46 .collect();
47
48 let wiki = std::env::var("FORGE_WIKI").is_ok_and(|v| v == "1");
49 let request = HookRequest {
50 repo_id,
51 actor_id,
52 protocol,
53 wiki,
54 updates,
55 quarantine_env,
56 };
57 let body = serde_json::to_vec(&request)
58 .map_err(|e| forge_core::Error::Invalid(format!("encode hook request: {e}")))?;
59
60 let (status, payload) = tokio::time::timeout(TIMEOUT, post_json(&socket, path, &token, &body))
61 .await
62 .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "internal API request timed out"))??;
63 if status != 200 {
64 return Err(forge_core::Error::Invalid(format!(
65 "internal API returned HTTP {status}"
66 )));
67 }
68 let response: HookResponse = serde_json::from_slice(&payload)
69 .map_err(|e| forge_core::Error::Invalid(format!("decode hook response: {e}")))?;
70
71 if !response.message.is_empty() {
72 eprintln!("{}", response.message.trim_end());
73 }
74 if response.allow {
75 return Ok(());
76 }
77 if response.message.is_empty() {
78 eprintln!("push rejected");
79 }
80 std::process::exit(1);
81}
82
83fn required_env(name: &str) -> forge_core::Result<String> {
84 std::env::var(name)
85 .map_err(|_| forge_core::Error::Invalid(format!("missing environment variable {name}")))
86}
87
88/// Parses stdin lines `<old> <new> <ref>`. Blank lines are skipped.
89async fn read_updates() -> forge_core::Result<Vec<HookUpdate>> {
90 let mut raw = Vec::new();
91 tokio::io::stdin().read_to_end(&mut raw).await?;
92 let input = String::from_utf8(raw)
93 .map_err(|_| forge_core::Error::Invalid("hook input is not UTF-8".into()))?;
94
95 input
96 .lines()
97 .filter(|line| !line.trim().is_empty())
98 .map(|line| {
99 let mut parts = line.split(' ');
100 match (parts.next(), parts.next(), parts.next(), parts.next()) {
101 (Some(old), Some(new), Some(ref_name), None) => Ok(HookUpdate {
102 old: old.to_string(),
103 new: new.to_string(),
104 ref_name: ref_name.to_string(),
105 }),
106 _ => Err(forge_core::Error::Invalid(format!(
107 "malformed hook input line: {line:?}"
108 ))),
109 }
110 })
111 .collect()
112}
113
114/// Sends one `POST` with a JSON body over the unix socket and returns the status
115/// code and the decoded body. Sends `Connection: close` and reads to EOF.
116async fn post_json(
117 socket: &str,
118 path: &str,
119 token: &str,
120 body: &[u8],
121) -> io::Result<(u16, Vec<u8>)> {
122 let mut stream = UnixStream::connect(socket).await?;
123 let head = format!(
124 "POST {path} HTTP/1.1\r\n\
125 Host: forge-internal\r\n\
126 Content-Type: application/json\r\n\
127 Content-Length: {}\r\n\
128 X-Forge-Internal-Token: {token}\r\n\
129 Connection: close\r\n\r\n",
130 body.len()
131 );
132 stream.write_all(head.as_bytes()).await?;
133 stream.write_all(body).await?;
134 stream.flush().await?;
135
136 let mut raw = Vec::new();
137 stream.read_to_end(&mut raw).await?;
138 parse_response(&raw)
139}
140
141fn invalid(message: &str) -> io::Error {
142 io::Error::new(io::ErrorKind::InvalidData, message.to_string())
143}
144
145fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
146 haystack.windows(needle.len()).position(|w| w == needle)
147}
148
149/// Parses an HTTP/1.1 response with `Content-Length` or chunked framing.
150pub(crate) fn parse_response(raw: &[u8]) -> io::Result<(u16, Vec<u8>)> {
151 let split = find(raw, b"\r\n\r\n").ok_or_else(|| invalid("missing end of HTTP headers"))?;
152 let head =
153 std::str::from_utf8(&raw[..split]).map_err(|_| invalid("HTTP headers are not UTF-8"))?;
154
155 let mut lines = head.split("\r\n");
156 let status_line = lines.next().unwrap_or_default();
157 let status = status_line
158 .split_whitespace()
159 .nth(1)
160 .and_then(|code| code.parse::<u16>().ok())
161 .ok_or_else(|| invalid("malformed HTTP status line"))?;
162
163 let mut chunked = false;
164 let mut content_length = None;
165 for line in lines {
166 if let Some((name, value)) = line.split_once(':') {
167 let value = value.trim();
168 match name.trim().to_ascii_lowercase().as_str() {
169 "transfer-encoding" => chunked = value.to_ascii_lowercase().contains("chunked"),
170 "content-length" => content_length = value.parse::<usize>().ok(),
171 _ => {}
172 }
173 }
174 }
175
176 let payload = &raw[split + 4..];
177 let body = if chunked {
178 decode_chunked(payload)?
179 } else if let Some(len) = content_length {
180 payload
181 .get(..len)
182 .ok_or_else(|| invalid("response body shorter than Content-Length"))?
183 .to_vec()
184 } else {
185 payload.to_vec()
186 };
187 Ok((status, body))
188}
189
190fn decode_chunked(mut data: &[u8]) -> io::Result<Vec<u8>> {
191 let mut out = Vec::new();
192 loop {
193 let line_end = find(data, b"\r\n").ok_or_else(|| invalid("missing chunk size line"))?;
194 let size_line = std::str::from_utf8(&data[..line_end])
195 .map_err(|_| invalid("chunk size is not UTF-8"))?;
196 let size_hex = size_line.split(';').next().unwrap_or_default().trim();
197 let size =
198 usize::from_str_radix(size_hex, 16).map_err(|_| invalid("malformed chunk size"))?;
199 data = &data[line_end + 2..];
200 if size == 0 {
201 return Ok(out);
202 }
203 let chunk = data.get(..size).ok_or_else(|| invalid("truncated chunk"))?;
204 out.extend_from_slice(chunk);
205 data = data
206 .get(size + 2..)
207 .ok_or_else(|| invalid("truncated chunk"))?;
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn parses_content_length_response() {
217 let raw =
218 b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 2\r\n\r\n{}";
219 assert_eq!(parse_response(raw).expect("parse"), (200, b"{}".to_vec()));
220 }
221
222 #[test]
223 fn parses_chunked_response() {
224 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";
225 assert_eq!(
226 parse_response(raw).expect("parse"),
227 (403, b"wikipedia".to_vec())
228 );
229 }
230
231 #[test]
232 fn rejects_truncated_response() {
233 assert!(parse_response(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc").is_err());
234 assert!(parse_response(b"garbage").is_err());
235 }
236}