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.rs18.6 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 an image committed to a repository for use inside a rendered README.
190///
191/// This is deliberately narrower than `blob`: only an allow-listed image content
192/// type reaches this function, no SVG is admitted, and bytes still come through
193/// the same repository read rule as every page. A README image needs to render
194/// inline; serving it as the raw attachment would turn the visual into a download.
195pub fn image(
196 bytes: Vec<u8>,
197 content_type: &'static str,
198 sha: &str,
199 immutable: bool,
200) -> Response<Body> {
201 let cache = if immutable {
202 "public, max-age=31536000, immutable"
203 } else {
204 // The same branch may show a private image to an owner and no image to a
205 // stranger after visibility changes. Never let a shared cache bridge that.
206 "no-store"
207 };
208 build(StatusCode::OK)
209 .header("content-type", content_type)
210 .header("x-content-type-options", "nosniff")
211 .header("content-security-policy", "sandbox")
212 .header("cache-control", cache)
213 .header("etag", format!("\"{sha}\""))
214 .body(body_from(bytes))
215 .expect("image response is well-formed")
216}
217
218/// Serve one executable Deno module from the package origin.
219///
220/// This is intentionally separate from `blob`: `/raw` remains a download for
221/// hostile bytes on the main origin, while package modules have an explicit source
222/// MIME type and live only behind the separate package host. The caller supplies a
223/// full commit-derived blob, so this response is immutable by construction.
224pub fn module(bytes: Vec<u8>, content_type: &'static str, sha: &str) -> Response<Body> {
225 build(StatusCode::OK)
226 .header("content-type", content_type)
227 .header("cache-control", "public, max-age=31536000, immutable")
228 .header("etag", format!("\"{sha}\""))
229 .header("x-content-type-options", "nosniff")
230 // The package origin has no cookies, sessions, browser UI or management
231 // routes. This lets public browser modules load without making the main
232 // origin a source of attacker-controlled executable code.
233 .header("access-control-allow-origin", "*")
234 .header("cross-origin-resource-policy", "cross-origin")
235 .header(
236 "content-security-policy",
237 "default-src 'none'; sandbox; base-uri 'none'; frame-ancestors 'none'",
238 )
239 .header("referrer-policy", "no-referrer")
240 .header("x-robots-tag", "noindex")
241 .body(body_from(bytes))
242 .expect("module response is well-formed")
243}
244
245/// The package origin's only HTML page.
246///
247/// Package modules deliberately live on a separate executable-code origin with no
248/// product UI. The root gets one quiet sentence so a person who opens the hostname
249/// is not met with a machine error; every SEO/social field repeats that exact
250/// sentence rather than inventing a second marketing message.
251pub fn package_landing() -> Response<Body> {
252 build(StatusCode::OK)
253 .header("content-type", "text/html; charset=utf-8")
254 .header("cache-control", "public, max-age=300")
255 .header("x-content-type-options", "nosniff")
256 .header("x-frame-options", "DENY")
257 .header("referrer-policy", "no-referrer")
258 .header(
259 "content-security-policy",
260 "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
261 )
262 .body(body_from(package_landing_html()))
263 .expect("package landing response is well-formed")
264}
265
266fn package_landing_html() -> String {
267 const TITLE: &str = "The package manager for agents";
268 format!(
269 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
270 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
271 <title>{TITLE}</title>\
272 <meta name=\"description\" content=\"{TITLE}\">\
273 <meta name=\"application-name\" content=\"{TITLE}\">\
274 <meta property=\"og:title\" content=\"{TITLE}\">\
275 <meta property=\"og:site_name\" content=\"{TITLE}\">\
276 <meta property=\"og:description\" content=\"{TITLE}\">\
277 <meta name=\"twitter:card\" content=\"summary\">\
278 <meta name=\"twitter:title\" content=\"{TITLE}\">\
279 <meta name=\"twitter:description\" content=\"{TITLE}\">\
280 <style>html,body{{min-height:100%;margin:0;background:#fff;color:#111}}\
281 body{{display:grid;place-items:center;padding:16px;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}}\
282 h1{{margin:0;font-size:clamp(1.05rem,3vw,2.2rem);font-weight:500;letter-spacing:-.035em;line-height:1.1;white-space:nowrap}}</style>\
283 </head><body><h1>{TITLE}</h1></body></html>"
284 )
285}
286
287/// A plain-text document: `llms.txt` and whatever joins it.
288///
289/// Distinct from `text`, which is protocol chatter with a caller-chosen status —
290/// this is page content, `no-store` for the same reason as the HTML pages: whether
291/// the document exists at all can depend on the credential that asked.
292pub fn plain(body: String) -> Response<Body> {
293 build(StatusCode::OK)
294 .header("content-type", "text/plain; charset=utf-8")
295 .header("cache-control", "no-store")
296 .header("x-content-type-options", "nosniff")
297 .body(body_from(body))
298 .expect("plain response is well-formed")
299}
300
301/// Serve a run's captured output.
302///
303/// Cacheable only once the run is finished: until then the same URL legitimately
304/// returns more each time.
305pub fn log(bytes: Vec<u8>, terminal: bool) -> Response<Body> {
306 build(StatusCode::OK)
307 .header("content-type", "text/plain; charset=utf-8")
308 .header("x-content-type-options", "nosniff")
309 .header(
310 "cache-control",
311 if terminal {
312 "public, max-age=300"
313 } else {
314 "no-store"
315 },
316 )
317 .body(body_from(bytes))
318 .expect("log response is well-formed")
319}
320
321/// Serve a pre-rendered JSON document.
322pub fn json_str(status: StatusCode, body: &'static str) -> Response<Body> {
323 build(status)
324 .header("content-type", "application/json; charset=utf-8")
325 .body(body_from(body))
326 .expect("json response is well-formed")
327}
328
329/// Wrap an arbitrary streaming body, used for CGI output.
330pub fn stream(
331 status: StatusCode,
332 headers: Vec<(String, String)>,
333 extra: &[(&str, &str)],
334 body: Body,
335) -> Result<Response<Body>, hyper::http::Error> {
336 let mut builder = Response::builder().status(status);
337 for (name, value) in &headers {
338 builder = builder.header(name, value);
339 }
340 for (name, value) in extra {
341 builder = builder.header(*name, *value);
342 }
343 builder.body(body)
344}
345
346/// Headers applied to every response.
347pub fn apply_defaults(response: &mut Response<Body>) {
348 let headers = response.headers_mut();
349 headers
350 .entry(HeaderName::from_static("x-content-type-options"))
351 .or_insert(HeaderValue::from_static("nosniff"));
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn a_401_always_carries_a_basic_challenge_so_git_prompts() {
360 let response = problem(&Error::Unauthorized);
361 let challenge = response
362 .headers()
363 .get("www-authenticate")
364 .expect("401 must challenge, or git fails instead of prompting");
365 assert!(challenge.to_str().unwrap().starts_with("Basic realm="));
366 }
367
368 #[test]
369 fn problem_responses_use_the_rfc_9457_media_type() {
370 let response = problem(&Error::NotFound("repository"));
371 assert_eq!(
372 response.headers().get("content-type").unwrap(),
373 "application/problem+json"
374 );
375 }
376
377 #[test]
378 fn created_sets_location() {
379 let response = created("/v1/repos/alice/site", &serde_json::json!({}));
380 assert_eq!(response.status(), StatusCode::CREATED);
381 assert_eq!(
382 response.headers().get("location").unwrap(),
383 "/v1/repos/alice/site"
384 );
385 }
386
387 #[test]
388 fn defaults_add_nosniff_without_overwriting_an_explicit_value() {
389 let mut response = json(StatusCode::OK, &serde_json::json!({}));
390 apply_defaults(&mut response);
391 assert_eq!(
392 response.headers().get("x-content-type-options").unwrap(),
393 "nosniff"
394 );
395 }
396
397 #[test]
398 fn every_git_error_is_plain_text_not_problem_json() {
399 for error in [
400 Error::Unauthorized,
401 Error::Forbidden,
402 Error::NotFound("repository"),
403 Error::invalid("invalid-name", "bad"),
404 Error::Internal(anyhow::anyhow!("boom")),
405 ] {
406 let response = git_error(&error);
407 let content_type = response.headers().get("content-type").unwrap();
408 assert!(
409 content_type.to_str().unwrap().starts_with("text/plain"),
410 "git renders problem+json as noise at the user ({})",
411 error.slug()
412 );
413 }
414 }
415
416 #[test]
417 fn a_git_401_still_challenges_but_a_403_does_not() {
418 assert!(git_error(&Error::Unauthorized)
419 .headers()
420 .contains_key("www-authenticate"));
421 assert!(
422 !git_error(&Error::Forbidden)
423 .headers()
424 .contains_key("www-authenticate"),
425 "challenging on 403 makes git retry credentials that already worked"
426 );
427 }
428
429 #[test]
430 fn a_git_error_never_leaks_internal_detail() {
431 let response = git_error(&Error::Internal(anyhow::anyhow!("db password is hunter2")));
432 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
433 }
434
435 #[test]
436 fn a_package_module_has_an_immutable_typed_public_contract() {
437 let response = module(
438 b"export const answer = 42;\n".to_vec(),
439 "application/typescript; charset=utf-8",
440 "aabbcc",
441 );
442 assert_eq!(
443 response.headers().get("content-type").unwrap(),
444 "application/typescript; charset=utf-8"
445 );
446 assert_eq!(
447 response.headers().get("cache-control").unwrap(),
448 "public, max-age=31536000, immutable"
449 );
450 assert_eq!(response.headers().get("etag").unwrap(), "\"aabbcc\"");
451 assert_eq!(
452 response
453 .headers()
454 .get("access-control-allow-origin")
455 .unwrap(),
456 "*"
457 );
458 assert!(!response.headers().contains_key("content-disposition"));
459 }
460
461 #[test]
462 fn the_package_root_says_one_thing_everywhere() {
463 let html = package_landing_html();
464 const TITLE: &str = "The package manager for agents";
465 for expected in [
466 format!("<title>{TITLE}</title>"),
467 format!("<meta name=\"description\" content=\"{TITLE}\">"),
468 format!("<meta name=\"application-name\" content=\"{TITLE}\">"),
469 format!("<meta property=\"og:title\" content=\"{TITLE}\">"),
470 format!("<meta property=\"og:site_name\" content=\"{TITLE}\">"),
471 format!("<meta name=\"twitter:title\" content=\"{TITLE}\">"),
472 format!("<h1>{TITLE}</h1>"),
473 ] {
474 assert!(html.contains(&expected), "missing {expected} in {html}");
475 }
476 assert!(html.contains("background:#fff"), "{html}");
477
478 let response = package_landing();
479 assert_eq!(
480 response.headers().get("content-type").unwrap(),
481 "text/html; charset=utf-8"
482 );
483 assert!(response
484 .headers()
485 .get("content-security-policy")
486 .unwrap()
487 .to_str()
488 .unwrap()
489 .contains("default-src 'none'"));
490 }
491}