use zenith_api::CanonicalResponse;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResponseSerializeError {
CrlfInjection,
InvalidHeaderName,
}
impl std::fmt::Display for ResponseSerializeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CrlfInjection => write!(f, "CRLF injection detected in header"),
Self::InvalidHeaderName => write!(f, "invalid header name character"),
}
}
}
impl std::error::Error for ResponseSerializeError {}
#[inline]
fn reason_phrase(status: u16) -> &'static str {
match status {
100 => "Continue",
101 => "Switching Protocols",
200 => "OK",
201 => "Created",
202 => "Accepted",
203 => "Non-Authoritative Information",
204 => "No Content",
205 => "Reset Content",
206 => "Partial Content",
301 => "Moved Permanently",
302 => "Found",
303 => "See Other",
304 => "Not Modified",
307 => "Temporary Redirect",
308 => "Permanent Redirect",
400 => "Bad Request",
401 => "Unauthorized",
402 => "Payment Required",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
406 => "Not Acceptable",
407 => "Proxy Authentication Required",
408 => "Request Timeout",
409 => "Conflict",
410 => "Gone",
411 => "Length Required",
412 => "Precondition Failed",
413 => "Payload Too Large",
414 => "URI Too Long",
415 => "Unsupported Media Type",
416 => "Range Not Satisfiable",
417 => "Expectation Failed",
421 => "Misdirected Request",
422 => "Unprocessable Entity",
425 => "Too Early",
426 => "Upgrade Required",
428 => "Precondition Required",
429 => "Too Many Requests",
431 => "Request Header Fields Too Large",
451 => "Unavailable For Legal Reasons",
500 => "Internal Server Error",
501 => "Not Implemented",
502 => "Bad Gateway",
503 => "Service Unavailable",
504 => "Gateway Timeout",
505 => "HTTP Version Not Supported",
_ => "Unknown",
}
}
#[derive(Debug)]
pub struct Http1ResponseEncoder;
impl Http1ResponseEncoder {
pub fn encode(
response: &CanonicalResponse,
out: &mut Vec<u8>,
) -> Result<usize, ResponseSerializeError> {
let start = out.len();
out.extend_from_slice(b"HTTP/1.1 ");
out.extend_from_slice(response.status_code.to_string().as_bytes());
out.push(b' ');
out.extend_from_slice(reason_phrase(response.status_code).as_bytes());
out.extend_from_slice(b"\r\n");
let mut has_content_length = false;
let mut has_transfer_encoding = false;
for header in response.headers_iter() {
let name = header.name_str();
let value = header.value_str();
if value.contains('\r') || value.contains('\n') {
return Err(ResponseSerializeError::CrlfInjection);
}
if !zenith_api::normalize::is_valid_header_name(name) {
return Err(ResponseSerializeError::InvalidHeaderName);
}
let lower = name.to_ascii_lowercase();
if lower == "content-length" {
has_content_length = true;
}
if lower == "transfer-encoding" {
has_transfer_encoding = true;
}
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(b": ");
out.extend_from_slice(value.as_bytes());
out.extend_from_slice(b"\r\n");
}
let body = response.body();
let status = response.status_code;
let forbid_content_length = matches!(status, 100..=199) || status == 204 || status == 304;
if !forbid_content_length && !has_content_length && !has_transfer_encoding {
out.extend_from_slice(b"content-length: ");
out.extend_from_slice(body.len().to_string().as_bytes());
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(b"\r\n");
if !forbid_content_length && !body.is_empty() {
out.extend_from_slice(body);
}
Ok(out.len() - start)
}
#[inline]
pub fn encode_to_vec(response: &CanonicalResponse) -> Result<Vec<u8>, ResponseSerializeError> {
let mut out = Vec::with_capacity(256 + response.body().len());
Http1ResponseEncoder::encode(response, &mut out)?;
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use zenith_api::CanonicalResponse;
#[test]
fn test_encode_simple_response() {
let mut resp = CanonicalResponse::new(200);
resp.add_header(b"content-type", b"text/plain").unwrap();
resp.set_body(b"Hello, World!");
let mut out = Vec::new();
let n = Http1ResponseEncoder::encode(&resp, &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(s.contains("content-type: text/plain\r\n"));
assert!(s.contains("content-length: 13\r\n"));
assert!(s.ends_with("\r\nHello, World!"));
assert_eq!(n, s.len());
}
#[test]
fn test_encode_no_body() {
let resp = CanonicalResponse::new(204);
let mut out = Vec::new();
Http1ResponseEncoder::encode(&resp, &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.starts_with("HTTP/1.1 204 No Content\r\n"));
assert!(s.ends_with("\r\n\r\n"));
assert!(!s.contains("content-length"));
}
#[test]
fn test_encode_with_explicit_content_length() {
let mut resp = CanonicalResponse::new(200);
resp.add_header(b"content-length", b"42").unwrap();
resp.set_body(b"Hello");
let mut out = Vec::new();
Http1ResponseEncoder::encode(&resp, &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
let cl_count = s.matches("content-length").count();
assert_eq!(cl_count, 1);
assert!(s.contains("content-length: 42\r\n"));
}
#[test]
fn test_encode_crlf_injection_blocked() {
let mut resp = CanonicalResponse::new(200);
let add_result = resp.add_header(b"x-evil", b"val\r\nInjected: yes");
assert!(add_result.is_err(), "add_header must reject CRLF in value");
let mut out = Vec::new();
let result = Http1ResponseEncoder::encode(&resp, &mut out);
assert!(result.is_ok(), "encode should succeed on clean response");
}
#[test]
fn test_encode_reason_phrases() {
for (code, phrase) in &[
(200u16, "OK"),
(404, "Not Found"),
(500, "Internal Server Error"),
(301, "Moved Permanently"),
(429, "Too Many Requests"),
(451, "Unavailable For Legal Reasons"),
] {
let resp = CanonicalResponse::new(*code);
let out = Http1ResponseEncoder::encode_to_vec(&resp).unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.starts_with(&format!("HTTP/1.1 {code} {phrase}\r\n")));
}
}
#[test]
fn test_encode_unknown_status() {
let resp = CanonicalResponse::new(599);
let out = Http1ResponseEncoder::encode_to_vec(&resp).unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.starts_with("HTTP/1.1 599 Unknown\r\n"));
}
#[test]
fn test_encode_to_vec() {
let mut resp = CanonicalResponse::new(200);
resp.set_body(b"test");
let out = Http1ResponseEncoder::encode_to_vec(&resp).unwrap();
assert!(!out.is_empty());
assert!(out.windows(4).any(|w| w == b"test"));
}
#[test]
fn test_encode_multiple_headers() {
let mut resp = CanonicalResponse::new(200);
resp.add_header(b"x-custom-1", b"value1").unwrap();
resp.add_header(b"x-custom-2", b"value2").unwrap();
resp.add_header(b"server", b"Zenith/1.0").unwrap();
resp.set_body(b"OK");
let s = String::from_utf8(Http1ResponseEncoder::encode_to_vec(&resp).unwrap()).unwrap();
assert!(s.contains("x-custom-1: value1\r\n"));
assert!(s.contains("x-custom-2: value2\r\n"));
assert!(s.contains("server: Zenith/1.0\r\n"));
assert!(s.ends_with("\r\nOK"));
}
#[test]
fn test_encode_empty_body_with_content_type() {
let mut resp = CanonicalResponse::new(200);
resp.add_header(b"content-type", b"application/json").unwrap();
let s = String::from_utf8(Http1ResponseEncoder::encode_to_vec(&resp).unwrap()).unwrap();
assert!(s.contains("content-type: application/json\r\n"));
assert!(
s.contains("content-length: 0"),
"empty-body 200 response must include Content-Length: 0 for keep-alive compliance. Raw: {s}"
);
}
#[test]
fn test_reason_phrase_all_variants() {
let codes = [100, 101, 200, 201, 204, 206, 301, 302, 304, 400, 401, 403, 404, 500];
for &code in &codes {
let resp = CanonicalResponse::new(code);
let s = String::from_utf8(Http1ResponseEncoder::encode_to_vec(&resp).unwrap()).unwrap();
assert!(!s.contains("Unknown"), "status {code} should have known reason phrase");
}
}
}