use core::fmt;
use std::collections::BTreeMap;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
pub const MAX_HEADER_BYTES: usize = 8 * 1024;
pub const MAX_BODY_BYTES: usize = 64 * 1024;
#[derive(Debug, PartialEq, Eq)]
pub struct HttpRequest {
pub method: String,
pub target: String,
pub headers: BTreeMap<String, String>,
pub body: Vec<u8>,
}
#[derive(Debug)]
pub enum HttpError {
Io(std::io::Error),
Malformed(&'static str),
TooLarge {
what: &'static str,
limit: usize,
},
Timeout,
BadHeader {
what: &'static str,
},
}
impl fmt::Display for HttpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "http i/o error: {err}"),
Self::Malformed(reason) => write!(f, "malformed http request: {reason}"),
Self::TooLarge { what, limit } => {
write!(f, "{what} exceeded the {limit}-byte ceiling")
}
Self::Timeout => f.write_str("no request arrived within the read timeout"),
Self::BadHeader { what } => {
write!(f, "{what} carried a byte outside the printable ASCII range")
}
}
}
}
impl core::error::Error for HttpError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Malformed(_) | Self::TooLarge { .. } | Self::Timeout | Self::BadHeader { .. } => {
None
}
}
}
}
impl From<std::io::Error> for HttpError {
fn from(source: std::io::Error) -> Self {
Self::Io(source)
}
}
pub async fn read_request<R: AsyncRead + Unpin>(
stream: &mut R,
read_timeout: Duration,
) -> Result<HttpRequest, HttpError> {
match tokio::time::timeout(read_timeout, read_request_unbounded_time(stream)).await {
Ok(result) => result,
Err(_elapsed) => Err(HttpError::Timeout),
}
}
async fn read_request_unbounded_time<R: AsyncRead + Unpin>(
stream: &mut R,
) -> Result<HttpRequest, HttpError> {
let mut reader = BufReader::new(stream.take(MAX_HEADER_BYTES as u64 + 1));
let head = read_head(&mut reader).await?;
let (method, target, headers) = parse_head(&head)?;
let body = if let Some(declared) = headers.get("content-length") {
let len: usize = declared
.parse()
.map_err(|_err| HttpError::Malformed("content-length is not a number"))?;
if len > MAX_BODY_BYTES {
return Err(HttpError::TooLarge {
what: "declared content-length",
limit: MAX_BODY_BYTES,
});
}
reader.get_mut().set_limit(len as u64);
let mut body = vec![0_u8; len];
reader.read_exact(&mut body).await?;
body
} else {
Vec::new()
};
Ok(HttpRequest {
method,
target,
headers,
body,
})
}
async fn read_head<S: AsyncRead + Unpin>(reader: &mut BufReader<S>) -> Result<Vec<u8>, HttpError> {
let mut head = Vec::new();
loop {
let mut line = Vec::new();
let read = reader.read_until(b'\n', &mut line).await?;
if read == 0 {
return Err(HttpError::Io(std::io::Error::from(
std::io::ErrorKind::UnexpectedEof,
)));
}
head.extend_from_slice(&line);
if head.len() > MAX_HEADER_BYTES {
return Err(HttpError::TooLarge {
what: "head",
limit: MAX_HEADER_BYTES,
});
}
if line == b"\r\n" || line == b"\n" {
break;
}
}
Ok(head)
}
fn parse_head(head: &[u8]) -> Result<(String, String, BTreeMap<String, String>), HttpError> {
let mut lines = head.split(|&b| b == b'\n');
let request_line = lines
.next()
.ok_or(HttpError::Malformed("no request line"))?;
let request_line = strip_trailing_cr(request_line);
let request_line = core::str::from_utf8(request_line)
.map_err(|_err| HttpError::Malformed("request line is not valid utf-8"))?;
let mut parts = request_line.splitn(3, ' ');
let method = parts
.next()
.filter(|token| !token.is_empty())
.ok_or(HttpError::Malformed("no method in the request line"))?;
let target = parts
.next()
.filter(|token| !token.is_empty())
.ok_or(HttpError::Malformed("no target in the request line"))?;
let mut headers = BTreeMap::new();
for line in lines {
let line = strip_trailing_cr(line);
if line.is_empty() {
break;
}
let line = core::str::from_utf8(line)
.map_err(|_err| HttpError::Malformed("header line is not valid utf-8"))?;
let (name, value) = line
.split_once(':')
.ok_or(HttpError::Malformed("header line has no colon"))?;
headers.insert(name.trim().to_lowercase(), value.trim().to_string());
}
Ok((method.to_string(), target.to_string(), headers))
}
fn strip_trailing_cr(line: &[u8]) -> &[u8] {
line.strip_suffix(b"\r").unwrap_or(line)
}
pub async fn write_response<W: AsyncWrite + Unpin>(
stream: &mut W,
status: u16,
content_type: &str,
body: &[u8],
) -> Result<(), HttpError> {
let head = format!(
"HTTP/1.1 {status} \r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(head.as_bytes()).await?;
stream.write_all(body).await?;
Ok(())
}
pub struct Header<'a> {
pub name: &'a str,
pub value: &'a str,
}
pub async fn write_head<W: AsyncWrite + Unpin>(
stream: &mut W,
status: u16,
content_type: &str,
content_length: u64,
headers: &[Header<'_>],
) -> Result<(), HttpError> {
for header in headers {
if has_control_byte(header.name) {
return Err(HttpError::BadHeader {
what: "a header name",
});
}
if has_control_byte(header.value) {
return Err(HttpError::BadHeader {
what: "a header value",
});
}
}
let mut head = format!(
"HTTP/1.1 {status} \r\nContent-Type: {content_type}\r\nContent-Length: {content_length}\r\n"
);
for header in headers {
head.push_str(header.name);
head.push_str(": ");
head.push_str(header.value);
head.push_str("\r\n");
}
head.push_str("Connection: close\r\n\r\n");
stream.write_all(head.as_bytes()).await?;
Ok(())
}
fn has_control_byte(s: &str) -> bool {
s.bytes().any(|b| !(0x20..=0x7e).contains(&b))
}
#[cfg(test)]
mod tests {
use tokio::io::AsyncWriteExt;
use super::*;
#[tokio::test]
async fn a_request_is_read_to_its_declared_length_and_no_further() {
let (mut client, mut server) = tokio::io::duplex(4096);
client
.write_all(
b"POST /hook HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello-and-then-some",
)
.await
.unwrap();
let req = tokio::time::timeout(
Duration::from_secs(5),
read_request(&mut server, Duration::from_secs(1)),
)
.await
.expect("read_request must not hang on a body it already has")
.unwrap();
assert_eq!(req.method, "POST");
assert_eq!(req.target, "/hook");
assert_eq!(req.body, b"hello");
assert_eq!(
req.headers.get("content-length").map(String::as_str),
Some("5")
);
assert_eq!(
req.headers.get("host").map(String::as_str),
Some("x"),
"names lowercase"
);
}
#[tokio::test]
async fn a_head_past_the_ceiling_is_refused_rather_than_buffered() {
let (mut client, mut server) = tokio::io::duplex(64 * 1024);
let mut flood = b"GET / HTTP/1.1\r\n".to_vec();
flood.extend(std::iter::repeat_n(b'x', MAX_HEADER_BYTES + 1));
client.write_all(&flood).await.unwrap();
assert!(matches!(
tokio::time::timeout(
Duration::from_secs(5),
read_request(&mut server, Duration::from_secs(1))
)
.await
.expect("the ceiling must fail, never hang")
.unwrap_err(),
HttpError::TooLarge { .. }
));
}
#[tokio::test(start_paused = true)]
async fn a_peer_that_says_nothing_is_dropped_at_the_timeout() {
let (_client, mut server) = tokio::io::duplex(64);
let err = read_request(&mut server, Duration::from_secs(1))
.await
.unwrap_err();
assert!(matches!(err, HttpError::Timeout), "{err:?}");
}
#[tokio::test]
async fn every_response_closes_its_connection() {
let (mut client, mut server) = tokio::io::duplex(4096);
write_response(&mut server, 200, "text/plain", b"ok")
.await
.unwrap();
drop(server);
let mut buf = Vec::new();
client.read_to_end(&mut buf).await.unwrap();
let response = String::from_utf8(buf).unwrap();
assert!(response.contains("Connection: close\r\n"), "{response:?}");
assert!(response.starts_with("HTTP/1.1 200 "), "{response:?}");
assert!(response.ends_with("ok"), "{response:?}");
}
#[tokio::test]
async fn a_header_value_with_a_control_byte_is_refused_before_anything_is_written() {
for (name, value) in [
("Location", "/a\r\nSet-Cookie: x=1"), ("Location", "/a\rSet-Cookie: x=1"), ("Location", "/a\nSet-Cookie: x=1"), ("Location", "/a\u{7f}b"), ("X-Bad\r\nInjected", "ok"), ] {
let (mut client, mut server) = tokio::io::duplex(4096);
let err = write_head(&mut server, 301, "text/html", 0, &[Header { name, value }])
.await
.unwrap_err();
assert!(
matches!(err, HttpError::BadHeader { .. }),
"{name}: {err:?}"
);
drop(server);
let mut buf = Vec::new();
client.read_to_end(&mut buf).await.unwrap();
assert!(
buf.is_empty(),
"{name}: nothing may reach the stream: {buf:?}"
);
}
}
#[tokio::test]
async fn a_head_carries_its_extra_headers_and_its_declared_length() {
let (mut client, mut server) = tokio::io::duplex(4096);
write_head(
&mut server,
200,
"text/css",
42,
&[Header {
name: "X-Content-Type-Options",
value: "nosniff",
}],
)
.await
.unwrap();
drop(server);
let mut buf = Vec::new();
client.read_to_end(&mut buf).await.unwrap();
let head = String::from_utf8(buf).unwrap();
assert!(head.starts_with("HTTP/1.1 200 "), "{head:?}");
assert!(head.contains("Content-Length: 42\r\n"), "{head:?}");
assert!(head.contains("Content-Type: text/css\r\n"), "{head:?}");
assert!(
head.contains("X-Content-Type-Options: nosniff\r\n"),
"{head:?}"
);
assert!(head.contains("Connection: close\r\n"), "{head:?}");
assert!(
head.ends_with("\r\n\r\n"),
"a head and nothing else: {head:?}"
);
}
}