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