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.rs10.8 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
48/// An HTML page.
49///
50/// `no-store` because what a page shows depends on whether a credential was sent:
51/// an owner's view of a private repository must never be cached and then handed to
52/// somebody else. The CSP allows one stylesheet from this origin and nothing else —
53/// no script anywhere, so anything injected into a filename or a commit message has
54/// nowhere to execute even if the escaping were wrong.
55pub fn html(status: StatusCode, body: String) -> Response<Body> {
56 build(status)
57 .header("content-type", "text/html; charset=utf-8")
58 .header("cache-control", "no-store")
59 .header("x-content-type-options", "nosniff")
60 .header("x-frame-options", "DENY")
61 .header("referrer-policy", "no-referrer")
62 .header(
63 "content-security-policy",
64 "default-src 'none'; style-src 'self'; img-src 'self' data:; \
65 form-action 'none'; frame-ancestors 'none'; base-uri 'none'",
66 )
67 .body(body_from(body))
68 .expect("html response is well-formed")
69}
70
71/// The one stylesheet. Its URL carries the build, so it can be cached indefinitely.
72pub fn css(body: &'static str) -> Response<Body> {
73 build(StatusCode::OK)
74 .header("content-type", "text/css; charset=utf-8")
75 .header("cache-control", "public, max-age=31536000, immutable")
76 .header("x-content-type-options", "nosniff")
77 .body(body_from(body))
78 .expect("css response is well-formed")
79}
80
81pub fn text(status: StatusCode, body: impl Into<Bytes>) -> Response<Body> {
82 build(status)
83 .header("content-type", "text/plain; charset=utf-8")
84 .body(body_from(body))
85 .expect("text response is well-formed")
86}
87
88pub fn no_content() -> Response<Body> {
89 build(StatusCode::NO_CONTENT)
90 .body(empty())
91 .expect("204 response is well-formed")
92}
93
94/// RFC 9457 problem response.
95///
96/// `401` carries `WWW-Authenticate` unconditionally: without it `git` never invokes
97/// its credential helper and a clone fails instead of prompting (SPEC §4.7).
98pub fn problem(error: &Error) -> Response<Body> {
99 let status = StatusCode::from_u16(error.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
100 let body = serde_json::to_vec(&error.problem())
101 .unwrap_or_else(|_| br#"{"title":"internal"}"#.to_vec());
102
103 let mut builder = build(status)
104 .header("content-type", "application/problem+json")
105 .header("cache-control", "no-store");
106
107 if status == StatusCode::UNAUTHORIZED {
108 builder = builder.header(
109 "www-authenticate",
110 format!(r#"Basic realm="{}", charset="UTF-8""#, brand::realm()),
111 );
112 }
113
114 if let Error::RateLimited { retry_after } = error {
115 builder = builder.header("retry-after", retry_after.to_string());
116 }
117
118 builder
119 .body(body_from(body))
120 .expect("problem response is well-formed")
121}
122
123/// An error shaped for the git wire protocol.
124///
125/// RFC 9457 does not apply to the git paths: git prints the response body straight
126/// at the user, so a JSON document renders as noise. This covers the whole error
127/// range, not just 401 (SPEC §4.7).
128pub fn git_error(error: &Error) -> Response<Body> {
129 let status = StatusCode::from_u16(error.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
130
131 let message = match status {
132 StatusCode::UNAUTHORIZED => "authentication required".to_string(),
133 StatusCode::FORBIDDEN => "you do not have access to this repository".to_string(),
134 StatusCode::NOT_FOUND => "repository not found".to_string(),
135 StatusCode::INSUFFICIENT_STORAGE => "the server is out of storage".to_string(),
136 _ if status.is_server_error() => "the server failed to handle this request".to_string(),
137 _ => "request refused".to_string(),
138 };
139
140 let mut builder = build(status).header("content-type", "text/plain; charset=utf-8");
141 // Without the challenge git never invokes its credential helper, so a clone
142 // fails outright instead of prompting.
143 if status == StatusCode::UNAUTHORIZED {
144 builder = builder.header(
145 "www-authenticate",
146 format!(r#"Basic realm="{}""#, brand::realm()),
147 );
148 }
149 builder
150 .body(body_from(brand::say(format_args!("{message}\n"))))
151 .expect("git error response is well-formed")
152}
153
154/// Serve raw repository bytes.
155///
156/// Always octet-stream, never sniffed, with a sandbox CSP and an attachment
157/// disposition: this returns attacker-controlled bytes on the same origin as the
158/// API, which is why GitHub serves raw content from a separate host (SPEC §4.8).
159pub fn blob(bytes: Vec<u8>, sha: &str, immutable: bool) -> Response<Body> {
160 let cache = if immutable {
161 "public, max-age=31536000, immutable"
162 } else {
163 "no-cache"
164 };
165 build(StatusCode::OK)
166 .header("content-type", "application/octet-stream")
167 .header("content-disposition", "attachment")
168 .header("x-content-type-options", "nosniff")
169 .header("content-security-policy", "sandbox")
170 .header("cache-control", cache)
171 .header("etag", format!("\"{sha}\""))
172 .body(body_from(bytes))
173 .expect("blob response is well-formed")
174}
175
176/// Serve a run's captured output.
177///
178/// Cacheable only once the run is finished: until then the same URL legitimately
179/// returns more each time.
180pub fn log(bytes: Vec<u8>, terminal: bool) -> Response<Body> {
181 build(StatusCode::OK)
182 .header("content-type", "text/plain; charset=utf-8")
183 .header("x-content-type-options", "nosniff")
184 .header(
185 "cache-control",
186 if terminal {
187 "public, max-age=300"
188 } else {
189 "no-store"
190 },
191 )
192 .body(body_from(bytes))
193 .expect("log response is well-formed")
194}
195
196/// Serve a pre-rendered JSON document.
197pub fn json_str(status: StatusCode, body: &'static str) -> Response<Body> {
198 build(status)
199 .header("content-type", "application/json; charset=utf-8")
200 .body(body_from(body))
201 .expect("json response is well-formed")
202}
203
204/// Wrap an arbitrary streaming body, used for CGI output.
205pub fn stream(
206 status: StatusCode,
207 headers: Vec<(String, String)>,
208 extra: &[(&str, &str)],
209 body: Body,
210) -> Result<Response<Body>, hyper::http::Error> {
211 let mut builder = Response::builder().status(status);
212 for (name, value) in &headers {
213 builder = builder.header(name, value);
214 }
215 for (name, value) in extra {
216 builder = builder.header(*name, *value);
217 }
218 builder.body(body)
219}
220
221/// Headers applied to every response.
222pub fn apply_defaults(response: &mut Response<Body>) {
223 let headers = response.headers_mut();
224 headers
225 .entry(HeaderName::from_static("x-content-type-options"))
226 .or_insert(HeaderValue::from_static("nosniff"));
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn a_401_always_carries_a_basic_challenge_so_git_prompts() {
235 let response = problem(&Error::Unauthorized);
236 let challenge = response
237 .headers()
238 .get("www-authenticate")
239 .expect("401 must challenge, or git fails instead of prompting");
240 assert!(challenge.to_str().unwrap().starts_with("Basic realm="));
241 }
242
243 #[test]
244 fn problem_responses_use_the_rfc_9457_media_type() {
245 let response = problem(&Error::NotFound("repository"));
246 assert_eq!(
247 response.headers().get("content-type").unwrap(),
248 "application/problem+json"
249 );
250 }
251
252 #[test]
253 fn created_sets_location() {
254 let response = created("/v1/repos/alice/site", &serde_json::json!({}));
255 assert_eq!(response.status(), StatusCode::CREATED);
256 assert_eq!(
257 response.headers().get("location").unwrap(),
258 "/v1/repos/alice/site"
259 );
260 }
261
262 #[test]
263 fn defaults_add_nosniff_without_overwriting_an_explicit_value() {
264 let mut response = json(StatusCode::OK, &serde_json::json!({}));
265 apply_defaults(&mut response);
266 assert_eq!(
267 response.headers().get("x-content-type-options").unwrap(),
268 "nosniff"
269 );
270 }
271
272 #[test]
273 fn every_git_error_is_plain_text_not_problem_json() {
274 for error in [
275 Error::Unauthorized,
276 Error::Forbidden,
277 Error::NotFound("repository"),
278 Error::invalid("invalid-name", "bad"),
279 Error::Internal(anyhow::anyhow!("boom")),
280 ] {
281 let response = git_error(&error);
282 let content_type = response.headers().get("content-type").unwrap();
283 assert!(
284 content_type.to_str().unwrap().starts_with("text/plain"),
285 "git renders problem+json as noise at the user ({})",
286 error.slug()
287 );
288 }
289 }
290
291 #[test]
292 fn a_git_401_still_challenges_but_a_403_does_not() {
293 assert!(git_error(&Error::Unauthorized)
294 .headers()
295 .contains_key("www-authenticate"));
296 assert!(
297 !git_error(&Error::Forbidden)
298 .headers()
299 .contains_key("www-authenticate"),
300 "challenging on 403 makes git retry credentials that already worked"
301 );
302 }
303
304 #[test]
305 fn a_git_error_never_leaks_internal_detail() {
306 let response = git_error(&Error::Internal(anyhow::anyhow!("db password is hunter2")));
307 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
308 }
309}