haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
//! The door's HTTP/1.1, bounded (ACCESS-002 R2).
//!
//! One request per connection, a head of at most `head_cap` bytes, a body
//! only by Content-Length and at most `body_cap`. A chunked body is refused. A
//! response is written whole, or streamed until it ends with the connection's
//! close (an event stream); it is never cached unless it says so, and a
//! header value that would split it is refused before a byte is written.

use std::io::{self, Read, Write};
use std::net::TcpStream;

/// One request as the door reads it.
#[derive(Debug)]
pub struct Request {
    /// The method, as sent.
    pub method: String,
    /// The path, without its query.
    pub path: String,
    /// The query, without its `?`.
    pub query: String,
    /// Each header as sent, its name lowercased.
    pub headers: Vec<(String, String)>,
    /// The body, exactly Content-Length bytes.
    pub body: Vec<u8>,
}

impl Request {
    /// The first header named `name`, case-insensitively.
    #[must_use]
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(named, _)| named.eq_ignore_ascii_case(name))
            .map(|(_, value)| value.as_str())
    }

    /// The cookie named `name`.
    #[must_use]
    pub fn cookie(&self, name: &str) -> Option<&str> {
        self.headers
            .iter()
            .filter(|(named, _)| named == "cookie")
            .flat_map(|(_, value)| value.split(';'))
            .filter_map(|pair| pair.trim().split_once('='))
            .find(|(named, _)| *named == name)
            .map(|(_, value)| value)
    }

    /// The query parameter named `name`, percent-decoded.
    #[must_use]
    pub fn parameter(&self, name: &str) -> Option<String> {
        url::form_urlencoded::parse(self.query.as_bytes())
            .find(|(named, _)| named == name)
            .map(|(_, value)| value.into_owned())
    }
}

fn refused(words: &'static str) -> io::Error {
    io::Error::new(io::ErrorKind::InvalidData, words)
}

/// Read one request from `input`.
///
/// # Errors
///
/// `InvalidData` for a head past `head_cap`, a malformed head, a chunked
/// body or a Content-Length past `body_cap`; the read's own error, or
/// `UnexpectedEof` for a body cut short.
pub fn read(input: &mut impl Read, head_cap: usize, body_cap: usize) -> io::Result<Request> {
    let mut head = Vec::new();
    let mut byte = [0_u8; 1];
    while !head.ends_with(b"\r\n\r\n") {
        if head.len() >= head_cap {
            return Err(refused("the request head is past its bound"));
        }
        input.read_exact(&mut byte)?;
        head.push(byte[0]);
    }
    let text = std::str::from_utf8(&head).map_err(|_| refused("the request head is not UTF-8"))?;
    let mut lines = text.split("\r\n");
    let start = lines
        .next()
        .ok_or_else(|| refused("the request has no line"))?;
    let mut parts = start.split(' ');
    let (Some(method), Some(target), Some("HTTP/1.1" | "HTTP/1.0"), None) =
        (parts.next(), parts.next(), parts.next(), parts.next())
    else {
        return Err(refused("the request line is malformed"));
    };
    let (path, query) = target.split_once('?').unwrap_or((target, ""));
    let mut headers = Vec::new();
    for line in lines.filter(|line| !line.is_empty()) {
        let (name, value) = line
            .split_once(':')
            .ok_or_else(|| refused("a header is malformed"))?;
        headers.push((name.trim().to_ascii_lowercase(), value.trim().to_owned()));
    }
    let mut request = Request {
        method: method.to_owned(),
        path: path.to_owned(),
        query: query.to_owned(),
        headers,
        body: Vec::new(),
    };
    if request.header("transfer-encoding").is_some() {
        return Err(refused("a chunked body is not read; send Content-Length"));
    }
    let length = match request.header("content-length") {
        Some(length) => length
            .parse::<usize>()
            .map_err(|_| refused("Content-Length is not a number"))?,
        None => 0,
    };
    if length > body_cap {
        return Err(refused("the request body is past its bound"));
    }
    request.body = vec![0; length];
    input.read_exact(&mut request.body)?;
    Ok(request)
}

/// A streamed body: written after the head until it returns.
type Streamed = Box<dyn FnOnce(&mut dyn Write, Option<&TcpStream>) -> io::Result<()> + Send>;

/// What follows a response's head.
enum Body {
    /// Every byte, counted by Content-Length.
    Whole(Vec<u8>),
    /// Written as it is made, with no Content-Length: the connection's close
    /// ends it.
    Stream(Streamed),
}

impl std::fmt::Debug for Body {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Whole(bytes) => write!(formatter, "Whole({} bytes)", bytes.len()),
            Self::Stream(_) => formatter.write_str("Stream"),
        }
    }
}

/// One response, written whole or streamed.
#[derive(Debug)]
pub struct Response {
    status: u16,
    reason: &'static str,
    headers: Vec<(&'static str, String)>,
    /// The Cache-Control value; `no-store` unless the response says otherwise.
    cache: &'static str,
    body: Body,
}

impl Response {
    /// `body` with `status` and its content type.
    #[must_use]
    pub fn new(status: u16, content_type: &'static str, body: Vec<u8>) -> Self {
        let reason = match status {
            200 => "OK",
            204 => "No Content",
            303 => "See Other",
            400 => "Bad Request",
            401 => "Unauthorized",
            403 => "Forbidden",
            404 => "Not Found",
            405 => "Method Not Allowed",
            502 => "Bad Gateway",
            503 => "Service Unavailable",
            _ => "Status",
        };
        Self {
            status,
            reason,
            headers: vec![("Content-Type", content_type.to_owned())],
            cache: "no-store",
            body: Body::Whole(body),
        }
    }

    /// A 200 whose body `body` writes after the head, as it is made, until it
    /// returns; the connection's close then ends it. It is never cached.
    #[must_use]
    pub fn stream(
        content_type: &'static str,
        body: impl FnOnce(&mut dyn Write) -> io::Result<()> + Send + 'static,
    ) -> Self {
        Self::stream_with_peer(content_type, move |output, _peer| body(output))
    }

    pub(crate) fn stream_with_peer(
        content_type: &'static str,
        body: impl FnOnce(&mut dyn Write, Option<&TcpStream>) -> io::Result<()> + Send + 'static,
    ) -> Self {
        let mut response = Self::new(200, content_type, Vec::new());
        response.body = Body::Stream(Box::new(body));
        response
    }

    /// The same response kept by caches as `policy` says, in place of
    /// `no-store`.
    #[must_use]
    pub const fn cached(mut self, policy: &'static str) -> Self {
        self.cache = policy;
        self
    }

    /// A JSON answer.
    #[must_use]
    pub fn json(status: u16, value: &serde_json::Value) -> Self {
        Self::new(status, "application/json", value.to_string().into_bytes())
    }

    /// A 204: no body, so no Content-Type and no Content-Length.
    #[must_use]
    pub fn no_content() -> Self {
        let mut response = Self::new(204, "text/plain; charset=utf-8", Vec::new());
        response.headers.clear();
        response
    }

    /// Send the browser to `location`.
    #[must_use]
    pub fn redirect(location: &str) -> Self {
        Self::new(303, "text/plain; charset=utf-8", Vec::new()).with("Location", location)
    }

    /// The same response with one more header.
    #[must_use]
    pub fn with(mut self, name: &'static str, value: &str) -> Self {
        self.headers.push((name, value.to_owned()));
        self
    }

    /// Write the response to `output`; a streamed body is written until it
    /// ends.
    ///
    /// # Errors
    ///
    /// `InvalidData`, before anything is written, for a header value holding
    /// a line break; otherwise the write's own error, or the stream's.
    pub fn write(self, output: &mut impl Write) -> io::Result<()> {
        self.write_with_peer(output, None)
    }

    pub(super) fn write_to(self, stream: &TcpStream) -> io::Result<()> {
        self.write_with_peer(&mut &*stream, Some(stream))
    }

    fn write_with_peer(self, output: &mut impl Write, peer: Option<&TcpStream>) -> io::Result<()> {
        if self
            .headers
            .iter()
            .any(|(_, value)| value.contains(['\r', '\n']))
        {
            return Err(refused("a header value holds a line break"));
        }
        let mut head = format!("HTTP/1.1 {} {}\r\n", self.status, self.reason);
        for (name, value) in &self.headers {
            head.push_str(name);
            head.push_str(": ");
            head.push_str(value);
            head.push_str("\r\n");
        }
        // A 204 carries no Content-Length (RFC 9110 8.6).
        if let Body::Whole(body) = &self.body
            && self.status != 204
        {
            head.push_str("Content-Length: ");
            head.push_str(&body.len().to_string());
            head.push_str("\r\n");
        }
        head.push_str("Cache-Control: ");
        head.push_str(self.cache);
        head.push_str("\r\nConnection: close\r\n\r\n");
        let mut bytes = head.into_bytes();
        match self.body {
            Body::Whole(body) => {
                bytes.extend_from_slice(&body);
                output.write_all(&bytes)?;
                output.flush()
            }
            Body::Stream(body) => {
                output.write_all(&bytes)?;
                output.flush()?;
                body(&mut *output, peer)?;
                output.flush()
            }
        }
    }
}

#[cfg(test)]
#[path = "http_tests.rs"]
mod tests;