use crate::application::response_wire;
use crate::http::{HttpRequest, HttpResponse};
use armature_h1::{HeaderId, Response as H1Response, ResponseBody, header as header_id};
use bytes::Bytes;
use response_wire::{
name_is_token, report_transport_field, report_unemittable, transport_field, value_is_emittable,
};
use std::collections::HashMap;
pub(crate) fn request_from_head(
head: armature_h1::Head,
peer: Option<std::net::SocketAddr>,
) -> HttpRequest {
let target = head.target().clone();
let mut req = HttpRequest::new(head.method.clone(), target).with_peer(peer);
for (id, value) in head.headers {
req.headers.append_id(id, value);
}
req
}
fn unemittable_response(cors: Option<&crate::CorsConfig>) -> H1Response {
let envelope = response_wire::internal_error_envelope();
let mut out = H1Response::new(envelope.status);
for (key, value) in HashMap::from(envelope.headers) {
out.headers
.push((header_id::intern(&key), Bytes::from(value)));
}
if let Some(cors) = cors {
apply_cors(&mut out, &response_wire::cors_additions(None, cors));
}
out.with_body(ResponseBody::Full(envelope.body))
}
fn apply_cors(out: &mut H1Response, additions: &response_wire::CorsAdditions) {
if let Some(origin) = &additions.origin {
out.headers.push((
header_id::intern("access-control-allow-origin"),
Bytes::from(origin.clone()),
));
}
if additions.credentials {
out.headers.push((
header_id::intern("access-control-allow-credentials"),
Bytes::from_static(b"true"),
));
}
if additions.vary_origin {
out.headers
.push((header_id::intern("vary"), Bytes::from_static(b"Origin")));
}
}
pub(crate) fn to_h1_response(
response: HttpResponse,
cors: Option<&crate::CorsConfig>,
method: &crate::Method,
path: &str,
) -> H1Response {
let HttpResponse {
status,
headers,
cookies,
body,
} = response;
let mut out = H1Response::new(status);
for (key, value) in HashMap::from(headers) {
if !name_is_token(&key) || !value_is_emittable(value.as_bytes()) {
report_unemittable(&key, value.as_bytes(), method, path, "header");
return unemittable_response(cors);
}
if let Some(field) = transport_field(&key) {
report_transport_field(&field, &key);
continue;
}
out.headers
.push((header_id::intern(&key), Bytes::from(value)));
}
for cookie in cookies {
if !value_is_emittable(cookie.as_bytes()) {
report_unemittable("set-cookie", cookie.as_bytes(), method, path, "set-cookie");
return unemittable_response(cors);
}
out.headers.push((HeaderId::SetCookie, Bytes::from(cookie)));
}
if let Some(cors) = cors {
let handler_origin = out
.headers
.iter()
.find(|(id, _)| id.as_str() == "access-control-allow-origin")
.map(|(_, value)| value.clone());
let additions = response_wire::cors_additions(
handler_origin
.as_ref()
.map(|value| std::str::from_utf8(value).unwrap_or("\u{fffd}")),
cors,
);
apply_cors(&mut out, &additions);
}
if body.is_empty() {
out.with_body(ResponseBody::Empty)
} else {
out.with_body(ResponseBody::Full(body))
}
}
#[cfg(test)]
mod tests {
use super::*;
use armature_h1::{Limits, parse_head};
fn head(raw: &'static [u8]) -> armature_h1::Head {
parse_head(&Bytes::from_static(raw), &Limits::default())
.expect("parse")
.expect("complete")
.0
}
fn convert(response: HttpResponse, cors: Option<&crate::CorsConfig>) -> H1Response {
to_h1_response(response, cors, &crate::Method::Get, "/t")
}
#[test]
fn the_target_arrives_whole_query_included() {
let req = request_from_head(head(b"GET /a/b?x=1&y=2 HTTP/1.1\r\nHost: a\r\n\r\n"), None);
assert_eq!(req.path.as_str(), "/a/b?x=1&y=2");
assert_eq!(req.query().get("x"), Some("1"));
}
#[test]
fn headers_cross_without_being_re_interned_or_copied() {
let raw = Bytes::from_static(
b"GET / HTTP/1.1\r\nHost: a\r\nX-Trace-Id: abc\r\nContent-Type: application/json\r\n\r\n",
);
let parsed = parse_head(&raw, &Limits::default())
.expect("parse")
.expect("complete")
.0;
let req = request_from_head(parsed, None);
assert_eq!(req.headers.get("host"), Some("a"));
assert_eq!(req.headers.get("x-trace-id"), Some("abc"));
assert_eq!(req.headers.get("X-Trace-Id"), Some("abc"));
assert_eq!(req.headers.get("content-type"), Some("application/json"));
let stored = req.headers.get_bytes("x-trace-id").expect("stored");
let base = raw.as_ptr() as usize;
let got = stored.as_ptr() as usize;
assert!(
got >= base && got < base + raw.len(),
"header value must point into the parsed buffer, not a copy of it"
);
}
#[test]
fn a_repeated_field_keeps_every_occurrence() {
let req = request_from_head(
head(b"GET / HTTP/1.1\r\nHost: a\r\nAccept: text/html\r\nAccept: text/plain\r\n\r\n"),
None,
);
let all = req.headers.get_all("accept");
assert_eq!(all.len(), 2, "a repeated field must not collapse: {all:?}");
assert_eq!(req.headers.get("accept"), Some("text/html"));
}
#[test]
fn the_peer_address_is_carried_onto_the_request() {
let peer = "203.0.113.7:44321".parse().expect("addr");
let req = request_from_head(head(b"GET / HTTP/1.1\r\nHost: a\r\n\r\n"), Some(peer));
assert_eq!(req.peer, Some(peer));
}
#[test]
fn an_empty_body_is_named_empty_rather_than_a_zero_length_full() {
let out = convert(HttpResponse::new(204), None);
assert_eq!(out.status, 204);
assert!(
matches!(out.body, ResponseBody::Empty),
"an absent body must be spelled Empty, not Full(0) — the wire is \
the same either way, so the variant is what carries the intent"
);
}
#[test]
fn cookies_and_cors_reach_the_response() {
let mut resp = HttpResponse::new(200);
resp.cookies.push("a=1".to_string());
resp.cookies.push("b=2".to_string());
let cors = crate::CorsConfig::new("https://example.test").with_credentials();
let out = convert(resp, Some(&cors));
let cookies: Vec<_> = out
.headers
.iter()
.filter(|(id, _)| *id == HeaderId::SetCookie)
.map(|(_, v)| v.clone())
.collect();
assert_eq!(cookies.len(), 2, "each cookie is its own Set-Cookie field");
assert!(
out.headers
.iter()
.any(|(id, v)| id.as_str() == "access-control-allow-origin"
&& v.as_ref() == b"https://example.test")
);
assert!(
out.headers
.iter()
.any(|(id, v)| id.as_str() == "access-control-allow-credentials"
&& v.as_ref() == b"true")
);
}
fn value_of<'a>(out: &'a H1Response, name: &str) -> Option<&'a Bytes> {
out.headers
.iter()
.find(|(id, _)| id.as_str() == name)
.map(|(_, v)| v)
}
fn count_of(out: &H1Response, name: &str) -> usize {
out.headers
.iter()
.filter(|(id, _)| id.as_str() == name)
.count()
}
#[test]
fn a_handler_cannot_override_the_connection_loops_framing() {
let mut resp = HttpResponse::new(413);
resp.headers
.insert("Connection".to_string(), "keep-alive".to_string());
resp.headers
.insert("Transfer-Encoding".to_string(), "chunked".to_string());
resp.headers
.insert("Upgrade".to_string(), "websocket".to_string());
resp.headers
.insert("Content-Length".to_string(), "999".to_string());
resp.headers
.insert("Content-Type".to_string(), "text/plain".to_string());
let out = convert(resp, None);
for field in [
"connection",
"transfer-encoding",
"upgrade",
"content-length",
] {
assert_eq!(
count_of(&out, field),
0,
"{field} is the transport's to decide, not the handler's"
);
}
assert_eq!(
value_of(&out, "content-type").map(|v| v.as_ref()),
Some(&b"text/plain"[..])
);
}
#[test]
fn an_unwritable_header_fails_the_whole_response_closed() {
for (name, value) in [
("X-Bad", "a\r\nx-injected: 1"),
("X-Bad", "a\nb"),
("X-Bad", "a\0b"),
("X Bad", "fine"),
("bad:name", "fine"),
] {
let mut resp = HttpResponse::new(200);
resp.headers.insert(
"Content-Security-Policy".to_string(),
"default-src 'none'".to_string(),
);
resp.headers.insert(name.to_string(), value.to_string());
let out = convert(resp, None);
assert_eq!(out.status, 500, "{name}: {value:?} must fail closed");
assert_eq!(count_of(&out, "content-security-policy"), 0);
let body = match &out.body {
ResponseBody::Full(bytes) => bytes.clone(),
other => panic!("{name}: expected an envelope body, got {other:?}"),
};
assert_eq!(
String::from_utf8_lossy(&body),
r#"{"error":"Internal Server Error","status":500}"#
);
assert_eq!(
value_of(&out, "content-type").map(|v| v.as_ref()),
Some(&b"application/json"[..])
);
}
}
#[test]
fn the_fail_closed_500_still_carries_the_configured_cors_headers() {
let mut resp = HttpResponse::new(200);
resp.headers.insert("X Bad".to_string(), "fine".to_string());
let cors = crate::CorsConfig::new("https://configured.test").with_credentials();
let out = convert(resp, Some(&cors));
assert_eq!(out.status, 500);
assert_eq!(
value_of(&out, "access-control-allow-origin").map(|v| v.as_ref()),
Some(&b"https://configured.test"[..]),
"without these a credentialed fetch sees an opaque CORS failure \
rather than the 500 that actually happened"
);
assert_eq!(count_of(&out, "access-control-allow-credentials"), 1);
}
#[test]
fn an_unwritable_cookie_fails_the_whole_response_closed() {
let mut resp = HttpResponse::new(200);
resp.cookies
.push("session=abc; Secure; HttpOnly\r\nx-injected: 1".to_string());
let out = convert(resp, None);
assert_eq!(out.status, 500);
assert_eq!(count_of(&out, "set-cookie"), 0);
}
#[test]
fn a_valid_response_still_passes_the_writability_check() {
let mut resp = HttpResponse::new(200);
resp.headers
.insert("X-Trace-Id".to_string(), "abc-123".to_string());
resp.cookies.push("a=1; Secure".to_string());
let out = convert(resp, None);
assert_eq!(out.status, 200);
assert_eq!(count_of(&out, "x-trace-id"), 1);
assert_eq!(count_of(&out, "set-cookie"), 1);
}
#[test]
fn a_handler_supplied_cors_origin_is_not_duplicated() {
let mut resp = HttpResponse::new(200);
resp.headers.insert(
"Access-Control-Allow-Origin".to_string(),
"https://configured.test".to_string(),
);
let cors = crate::CorsConfig::new("https://configured.test").with_credentials();
let out = convert(resp, Some(&cors));
assert_eq!(
count_of(&out, "access-control-allow-origin"),
1,
"two origin fields make a browser reject the response outright"
);
assert_eq!(
value_of(&out, "access-control-allow-origin").map(|v| v.as_ref()),
Some(&b"https://configured.test"[..])
);
assert_eq!(
count_of(&out, "access-control-allow-credentials"),
1,
"the handler named the origin the configuration authorises, so the \
credentialed answer is the one the operator asked for"
);
assert_eq!(
value_of(&out, "vary").map(|v| v.as_ref()),
Some(&b"Origin"[..]),
"the origin came from the handler, so the response varies by it and \
a shared cache has to be told"
);
}
#[test]
fn a_reflected_origin_the_configuration_does_not_authorise_gets_no_credentials() {
let mut resp = HttpResponse::new(200);
resp.headers.insert(
"Access-Control-Allow-Origin".to_string(),
"https://evil.test".to_string(),
);
let cors = crate::CorsConfig::new("https://configured.test").with_credentials();
let out = convert(resp, Some(&cors));
assert_eq!(
value_of(&out, "access-control-allow-origin").map(|v| v.as_ref()),
Some(&b"https://evil.test"[..]),
"the handler's field still stands alone; a second one would only \
make the browser discard the response"
);
assert_eq!(
count_of(&out, "access-control-allow-credentials"),
0,
"a handler reflecting Origin unchecked plus credentials is a full \
credentialed cross-origin read for any site that asks"
);
}
#[test]
fn credentials_are_withheld_from_a_wildcard_origin() {
let cors = crate::CorsConfig::new("*").with_credentials();
let out = convert(HttpResponse::new(200), Some(&cors));
assert_eq!(
value_of(&out, "access-control-allow-origin").map(|v| v.as_ref()),
Some(&b"*"[..])
);
assert_eq!(
count_of(&out, "access-control-allow-credentials"),
0,
"browsers reject `*` with credentials, so emitting both fails the \
request rather than degrading it"
);
}
}