zuka
zuka/src/http/response.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/http/response.rs
RSresponse.rs9.4 KBDownload
1// Response construction. Every response the service emits is built here, so the
2// header contract is enforced in one place rather than per handler.
3
4use crate::brand;
5use crate::error::Error;
6use bytes::Bytes;
7use http_body_util::{combinators::BoxBody, BodyExt, Full};
8use hyper::header::{HeaderName, HeaderValue};
9use hyper::{Response, StatusCode};
10use serde::Serialize;
11use std::convert::Infallible;
12
13pub type Body = BoxBody<Bytes, std::io::Error>;
14
15pub fn body_from(bytes: impl Into<Bytes>) -> Body {
16 Full::new(bytes.into())
17 .map_err(|e: Infallible| match e {})
18 .boxed()
19}
20
21pub fn empty() -> Body {
22 body_from(Bytes::new())
23}
24
25/// Build a response, defaulting to an empty body.
26fn build(status: StatusCode) -> hyper::http::response::Builder {
27 Response::builder().status(status)
28}
29
30pub fn json<T: Serialize>(status: StatusCode, value: &T) -> Response<Body> {
31 let body = serde_json::to_vec(value)
32 .unwrap_or_else(|_| br#"{"title":"serialization failed"}"#.to_vec());
33 build(status)
34 .header("content-type", "application/json; charset=utf-8")
35 .header("cache-control", "no-store")
36 .body(body_from(body))
37 .expect("json response is well-formed")
38}
39
40pub fn created<T: Serialize>(location: &str, value: &T) -> Response<Body> {
41 let mut response = json(StatusCode::CREATED, value);
42 if let Ok(value) = HeaderValue::from_str(location) {
43 response.headers_mut().insert("location", value);
44 }
45 response
46}
47
48pub fn text(status: StatusCode, body: impl Into<Bytes>) -> Response<Body> {
49 build(status)
50 .header("content-type", "text/plain; charset=utf-8")
51 .body(body_from(body))
52 .expect("text response is well-formed")
53}
54
55pub fn no_content() -> Response<Body> {
56 build(StatusCode::NO_CONTENT)
57 .body(empty())
58 .expect("204 response is well-formed")
59}
60
61/// RFC 9457 problem response.
62///
63/// `401` carries `WWW-Authenticate` unconditionally: without it `git` never invokes
64/// its credential helper and a clone fails instead of prompting (SPEC §4.7).
65pub fn problem(error: &Error) -> Response<Body> {
66 let status = StatusCode::from_u16(error.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
67 let body = serde_json::to_vec(&error.problem())
68 .unwrap_or_else(|_| br#"{"title":"internal"}"#.to_vec());
69
70 let mut builder = build(status)
71 .header("content-type", "application/problem+json")
72 .header("cache-control", "no-store");
73
74 if status == StatusCode::UNAUTHORIZED {
75 builder = builder.header(
76 "www-authenticate",
77 format!(r#"Basic realm="{}", charset="UTF-8""#, brand::realm()),
78 );
79 }
80
81 if let Error::RateLimited { retry_after } = error {
82 builder = builder.header("retry-after", retry_after.to_string());
83 }
84
85 builder
86 .body(body_from(body))
87 .expect("problem response is well-formed")
88}
89
90/// An error shaped for the git wire protocol.
91///
92/// RFC 9457 does not apply to the git paths: git prints the response body straight
93/// at the user, so a JSON document renders as noise. This covers the whole error
94/// range, not just 401 (SPEC §4.7).
95pub fn git_error(error: &Error) -> Response<Body> {
96 let status = StatusCode::from_u16(error.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
97
98 let message = match status {
99 StatusCode::UNAUTHORIZED => "authentication required".to_string(),
100 StatusCode::FORBIDDEN => "you do not have access to this repository".to_string(),
101 StatusCode::NOT_FOUND => "repository not found".to_string(),
102 StatusCode::INSUFFICIENT_STORAGE => "the server is out of storage".to_string(),
103 _ if status.is_server_error() => "the server failed to handle this request".to_string(),
104 _ => "request refused".to_string(),
105 };
106
107 let mut builder = build(status).header("content-type", "text/plain; charset=utf-8");
108 // Without the challenge git never invokes its credential helper, so a clone
109 // fails outright instead of prompting.
110 if status == StatusCode::UNAUTHORIZED {
111 builder = builder.header(
112 "www-authenticate",
113 format!(r#"Basic realm="{}""#, brand::realm()),
114 );
115 }
116 builder
117 .body(body_from(brand::say(format_args!("{message}\n"))))
118 .expect("git error response is well-formed")
119}
120
121/// Serve raw repository bytes.
122///
123/// Always octet-stream, never sniffed, with a sandbox CSP and an attachment
124/// disposition: this returns attacker-controlled bytes on the same origin as the
125/// API, which is why GitHub serves raw content from a separate host (SPEC §4.8).
126pub fn blob(bytes: Vec<u8>, sha: &str, immutable: bool) -> Response<Body> {
127 let cache = if immutable {
128 "public, max-age=31536000, immutable"
129 } else {
130 "no-cache"
131 };
132 build(StatusCode::OK)
133 .header("content-type", "application/octet-stream")
134 .header("content-disposition", "attachment")
135 .header("x-content-type-options", "nosniff")
136 .header("content-security-policy", "sandbox")
137 .header("cache-control", cache)
138 .header("etag", format!("\"{sha}\""))
139 .body(body_from(bytes))
140 .expect("blob response is well-formed")
141}
142
143/// Serve a run's captured output.
144///
145/// Cacheable only once the run is finished: until then the same URL legitimately
146/// returns more each time.
147pub fn log(bytes: Vec<u8>, terminal: bool) -> Response<Body> {
148 build(StatusCode::OK)
149 .header("content-type", "text/plain; charset=utf-8")
150 .header("x-content-type-options", "nosniff")
151 .header(
152 "cache-control",
153 if terminal {
154 "public, max-age=300"
155 } else {
156 "no-store"
157 },
158 )
159 .body(body_from(bytes))
160 .expect("log response is well-formed")
161}
162
163/// Serve a pre-rendered JSON document.
164pub fn json_str(status: StatusCode, body: &'static str) -> Response<Body> {
165 build(status)
166 .header("content-type", "application/json; charset=utf-8")
167 .body(body_from(body))
168 .expect("json response is well-formed")
169}
170
171/// Wrap an arbitrary streaming body, used for CGI output.
172pub fn stream(
173 status: StatusCode,
174 headers: Vec<(String, String)>,
175 extra: &[(&str, &str)],
176 body: Body,
177) -> Result<Response<Body>, hyper::http::Error> {
178 let mut builder = Response::builder().status(status);
179 for (name, value) in &headers {
180 builder = builder.header(name, value);
181 }
182 for (name, value) in extra {
183 builder = builder.header(*name, *value);
184 }
185 builder.body(body)
186}
187
188/// Headers applied to every response.
189pub fn apply_defaults(response: &mut Response<Body>) {
190 let headers = response.headers_mut();
191 headers
192 .entry(HeaderName::from_static("x-content-type-options"))
193 .or_insert(HeaderValue::from_static("nosniff"));
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn a_401_always_carries_a_basic_challenge_so_git_prompts() {
202 let response = problem(&Error::Unauthorized);
203 let challenge = response
204 .headers()
205 .get("www-authenticate")
206 .expect("401 must challenge, or git fails instead of prompting");
207 assert!(challenge.to_str().unwrap().starts_with("Basic realm="));
208 }
209
210 #[test]
211 fn problem_responses_use_the_rfc_9457_media_type() {
212 let response = problem(&Error::NotFound("repository"));
213 assert_eq!(
214 response.headers().get("content-type").unwrap(),
215 "application/problem+json"
216 );
217 }
218
219 #[test]
220 fn created_sets_location() {
221 let response = created("/v1/repos/alice/site", &serde_json::json!({}));
222 assert_eq!(response.status(), StatusCode::CREATED);
223 assert_eq!(
224 response.headers().get("location").unwrap(),
225 "/v1/repos/alice/site"
226 );
227 }
228
229 #[test]
230 fn defaults_add_nosniff_without_overwriting_an_explicit_value() {
231 let mut response = json(StatusCode::OK, &serde_json::json!({}));
232 apply_defaults(&mut response);
233 assert_eq!(
234 response.headers().get("x-content-type-options").unwrap(),
235 "nosniff"
236 );
237 }
238
239 #[test]
240 fn every_git_error_is_plain_text_not_problem_json() {
241 for error in [
242 Error::Unauthorized,
243 Error::Forbidden,
244 Error::NotFound("repository"),
245 Error::invalid("invalid-name", "bad"),
246 Error::Internal(anyhow::anyhow!("boom")),
247 ] {
248 let response = git_error(&error);
249 let content_type = response.headers().get("content-type").unwrap();
250 assert!(
251 content_type.to_str().unwrap().starts_with("text/plain"),
252 "git renders problem+json as noise at the user ({})",
253 error.slug()
254 );
255 }
256 }
257
258 #[test]
259 fn a_git_401_still_challenges_but_a_403_does_not() {
260 assert!(git_error(&Error::Unauthorized)
261 .headers()
262 .contains_key("www-authenticate"));
263 assert!(
264 !git_error(&Error::Forbidden)
265 .headers()
266 .contains_key("www-authenticate"),
267 "challenging on 403 makes git retry credentials that already worked"
268 );
269 }
270
271 #[test]
272 fn a_git_error_never_leaks_internal_detail() {
273 let response = git_error(&Error::Internal(anyhow::anyhow!("db password is hunter2")));
274 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
275 }
276}