use std::io::{BufReader, Read, Write};
pub const DEFAULT_MAX_BODY: usize = 8 * 1024 * 1024;
pub const MAX_HEADER_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone)]
pub struct Request {
pub method: String,
pub target: String,
pub version: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Request {
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
#[must_use]
pub fn keep_alive(&self) -> bool {
let conn = self.header("connection").unwrap_or_default();
if conn.eq_ignore_ascii_case("close") {
return false;
}
if self.version == "HTTP/1.0" {
return conn.eq_ignore_ascii_case("keep-alive");
}
true
}
#[must_use]
pub fn path(&self) -> &str {
self.target.split('?').next().unwrap_or(&self.target)
}
}
#[derive(Debug, Clone)]
pub struct Response {
pub status: u16,
pub reason: &'static str,
pub headers: Vec<(String, String)>,
pub content_type: Option<String>,
pub body: Vec<u8>,
}
impl Response {
#[must_use]
pub fn new(status: u16, reason: &'static str, content_type: &str, body: Vec<u8>) -> Self {
Self {
status,
reason,
headers: Vec::new(),
content_type: Some(content_type.to_string()),
body,
}
}
#[must_use]
pub fn empty(status: u16, reason: &'static str) -> Self {
Self {
status,
reason,
headers: Vec::new(),
content_type: None,
body: Vec::new(),
}
}
#[must_use]
pub fn with_header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.to_string(), value.to_string()));
self
}
}
#[derive(Debug)]
pub enum HttpError {
ConnectionClosed,
Malformed(String),
BodyTooLarge,
HeaderTooLarge,
Io(std::io::Error),
}
impl std::fmt::Display for HttpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConnectionClosed => f.write_str("connection closed"),
Self::Malformed(m) => write!(f, "malformed request: {m}"),
Self::BodyTooLarge => f.write_str("request body too large"),
Self::HeaderTooLarge => f.write_str("request header block too large"),
Self::Io(e) => write!(f, "io: {e}"),
}
}
}
impl std::error::Error for HttpError {}
impl From<std::io::Error> for HttpError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
pub fn read_request<R: Read>(
stream: &mut BufReader<R>,
max_body: usize,
) -> Result<Request, HttpError> {
let head = read_header_block(stream)?;
let mut lines = head.split("\r\n");
let request_line = lines
.next()
.ok_or_else(|| HttpError::Malformed("empty request".into()))?;
let mut parts = request_line.split(' ');
let method = parts
.next()
.ok_or_else(|| HttpError::Malformed("missing method".into()))?
.to_string();
let target = parts
.next()
.ok_or_else(|| HttpError::Malformed("missing target".into()))?
.to_string();
let version = parts.next().unwrap_or("HTTP/1.1").trim().to_string();
if !matches!(version.as_str(), "HTTP/1.0" | "HTTP/1.1") {
return Err(HttpError::Malformed(format!(
"unsupported version {version}"
)));
}
let mut headers = Vec::new();
for line in lines {
if line.is_empty() {
continue;
}
let (name, value) = line
.split_once(':')
.ok_or_else(|| HttpError::Malformed(format!("malformed header: {line}")))?;
headers.push((name.trim().to_string(), value.trim().to_string()));
}
let req_no_body = Request {
method,
target,
version,
headers,
body: Vec::new(),
};
let chunked = req_no_body
.header("transfer-encoding")
.is_some_and(|v| v.eq_ignore_ascii_case("chunked"));
let body = if chunked {
read_chunked_body(stream, max_body)?
} else if let Some(len) = req_no_body.header("content-length") {
let len: usize = len
.trim()
.parse()
.map_err(|_| HttpError::Malformed(format!("bad content-length: {len}")))?;
if len > max_body {
return Err(HttpError::BodyTooLarge);
}
let mut buf = vec![0u8; len];
stream.read_exact(&mut buf)?;
buf
} else {
Vec::new()
};
Ok(Request {
body,
..req_no_body
})
}
fn read_header_block<R: Read>(stream: &mut BufReader<R>) -> Result<String, HttpError> {
let mut raw = Vec::new();
let mut byte = [0u8; 1];
loop {
let n = stream.read(&mut byte)?;
if n == 0 {
if raw.is_empty() {
return Err(HttpError::ConnectionClosed);
}
return Err(HttpError::Malformed("connection closed mid-header".into()));
}
raw.push(byte[0]);
if raw.len() > MAX_HEADER_BYTES {
return Err(HttpError::HeaderTooLarge);
}
if raw.ends_with(b"\r\n\r\n") {
raw.truncate(raw.len() - 4);
break;
}
}
String::from_utf8(raw).map_err(|e| HttpError::Malformed(format!("header not UTF-8: {e}")))
}
fn read_chunked_body<R: Read>(
stream: &mut BufReader<R>,
max_body: usize,
) -> Result<Vec<u8>, HttpError> {
let mut body = Vec::new();
loop {
let size_line = read_line(stream)?;
let size_str = size_line.split(';').next().unwrap_or("").trim();
let size = usize::from_str_radix(size_str, 16)
.map_err(|_| HttpError::Malformed(format!("bad chunk size: {size_str}")))?;
if size == 0 {
loop {
let trailer = read_line(stream)?;
if trailer.is_empty() {
break;
}
}
break;
}
if body.len() + size > max_body {
return Err(HttpError::BodyTooLarge);
}
let mut chunk = vec![0u8; size];
stream.read_exact(&mut chunk)?;
body.extend_from_slice(&chunk);
let mut crlf = [0u8; 2];
stream.read_exact(&mut crlf)?;
if &crlf != b"\r\n" {
return Err(HttpError::Malformed("missing chunk CRLF".into()));
}
}
Ok(body)
}
fn read_line<R: Read>(stream: &mut BufReader<R>) -> Result<String, HttpError> {
let mut raw = Vec::new();
let mut byte = [0u8; 1];
loop {
let n = stream.read(&mut byte)?;
if n == 0 {
return Err(HttpError::Malformed("connection closed mid-chunk".into()));
}
if byte[0] == b'\n' {
if raw.last() == Some(&b'\r') {
raw.pop();
}
break;
}
raw.push(byte[0]);
if raw.len() > MAX_HEADER_BYTES {
return Err(HttpError::HeaderTooLarge);
}
}
String::from_utf8(raw).map_err(|e| HttpError::Malformed(format!("chunk line not UTF-8: {e}")))
}
pub fn write_response<W: Write>(
stream: &mut W,
response: &Response,
keep_alive: bool,
) -> std::io::Result<()> {
let mut out = format!("HTTP/1.1 {} {}\r\n", response.status, response.reason);
if let Some(ct) = &response.content_type {
out.push_str(&format!("Content-Type: {ct}\r\n"));
}
out.push_str(&format!("Content-Length: {}\r\n", response.body.len()));
out.push_str(&format!(
"Connection: {}\r\n",
if keep_alive { "keep-alive" } else { "close" }
));
for (name, value) in &response.headers {
out.push_str(&format!("{name}: {value}\r\n"));
}
out.push_str("\r\n");
stream.write_all(out.as_bytes())?;
stream.write_all(&response.body)?;
stream.flush()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::io::Cursor;
fn parse(raw: &[u8]) -> Request {
let mut r = BufReader::new(Cursor::new(raw.to_vec()));
read_request(&mut r, DEFAULT_MAX_BODY).expect("parses")
}
#[test]
fn parses_simple_get_with_no_body() {
let req = parse(b"GET /dds/rest1/applications HTTP/1.1\r\nHost: x\r\n\r\n");
assert_eq!(req.method, "GET");
assert_eq!(req.target, "/dds/rest1/applications");
assert!(req.body.is_empty());
assert!(req.keep_alive());
}
#[test]
fn parses_content_length_body() {
let req = parse(b"POST /x HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello");
assert_eq!(req.body, b"hello");
}
#[test]
fn parses_chunked_body() {
let raw = b"POST /x HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n";
let req = parse(raw);
assert_eq!(req.body, b"Wikipedia");
}
#[test]
fn chunked_body_with_trailer_headers() {
let raw = b"POST /x HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\nX-Trailer: y\r\n\r\n";
let req = parse(raw);
assert_eq!(req.body, b"abc");
}
#[test]
fn content_length_over_cap_is_refused() {
let mut r = BufReader::new(Cursor::new(
b"POST /x HTTP/1.1\r\nContent-Length: 100\r\n\r\n".to_vec(),
));
let err = read_request(&mut r, 10).unwrap_err();
assert!(matches!(err, HttpError::BodyTooLarge));
}
#[test]
fn chunked_body_over_cap_is_refused() {
let raw = b"POST /x HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n8\r\n01234567\r\n8\r\n89abcdef\r\n0\r\n\r\n";
let mut r = BufReader::new(Cursor::new(raw.to_vec()));
let err = read_request(&mut r, 10).unwrap_err();
assert!(matches!(err, HttpError::BodyTooLarge));
}
#[test]
fn empty_stream_is_connection_closed_not_malformed() {
let mut r = BufReader::new(Cursor::new(Vec::new()));
let err = read_request(&mut r, DEFAULT_MAX_BODY).unwrap_err();
assert!(matches!(err, HttpError::ConnectionClosed));
}
#[test]
fn http_1_0_without_keep_alive_header_closes() {
let req = parse(b"GET /x HTTP/1.0\r\n\r\n");
assert!(!req.keep_alive());
}
#[test]
fn http_1_0_with_keep_alive_header_stays_open() {
let req = parse(b"GET /x HTTP/1.0\r\nConnection: keep-alive\r\n\r\n");
assert!(req.keep_alive());
}
#[test]
fn http_1_1_with_connection_close_closes() {
let req = parse(b"GET /x HTTP/1.1\r\nConnection: close\r\n\r\n");
assert!(!req.keep_alive());
}
#[test]
fn header_lookup_is_case_insensitive() {
let req = parse(b"GET /x HTTP/1.1\r\nContent-Type: text/plain\r\n\r\n");
assert_eq!(req.header("content-type"), Some("text/plain"));
assert_eq!(req.header("CONTENT-TYPE"), Some("text/plain"));
}
#[test]
fn path_strips_query_string() {
let req = parse(b"GET /a/b?x=1 HTTP/1.1\r\n\r\n");
assert_eq!(req.path(), "/a/b");
}
#[test]
fn write_response_round_trip() {
let mut out = Vec::new();
let resp = Response::new(200, "OK", "text/plain", b"hi".to_vec());
write_response(&mut out, &resp, true).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(text.contains("Content-Length: 2\r\n"));
assert!(text.contains("Connection: keep-alive\r\n"));
assert!(text.ends_with("\r\n\r\nhi"));
}
#[test]
fn write_response_closes_when_not_keep_alive() {
let mut out = Vec::new();
let resp = Response::empty(204, "No Content");
write_response(&mut out, &resp, false).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("Connection: close\r\n"));
assert!(text.contains("Content-Length: 0\r\n"));
}
}