sim-table-http 0.1.0

Capability-gated HTTP table directory backend for SIM.
Documentation
//! Bounded HTTP transport used by `HttpDir`.

use std::{
    io::{self, BufReader, Read, Write},
    net::{TcpStream, ToSocketAddrs},
    time::Duration,
};

use sim_kernel::{Error, Result};
use sim_lib_net_core::{HeadOutcome, HttpBodyMode, UrlParts};

#[cfg(feature = "tls")]
use rustls::{
    ClientConfig, ClientConnection, RootCertStore, StreamOwned,
    pki_types::{CertificateDer, ServerName},
};
#[cfg(feature = "tls")]
use std::sync::Arc;

pub(crate) struct HttpRequest {
    pub(crate) method: &'static str,
    pub(crate) url: String,
    pub(crate) headers: Vec<(String, String)>,
    pub(crate) body: Vec<u8>,
    pub(crate) timeout: Duration,
    pub(crate) max_body_bytes: usize,
}

pub(crate) struct HttpResponse {
    pub(crate) status: u16,
    pub(crate) reason: Option<String>,
    pub(crate) body: Vec<u8>,
}

trait ReadWrite: Read + Write {}

impl<T: Read + Write> ReadWrite for T {}

pub(crate) fn send(request: HttpRequest) -> Result<HttpResponse> {
    let parts = sim_lib_net_core::parse_url(&request.url)
        .map_err(|err| Error::Eval(format!("table/http: {err}")))?;
    let stream = connect_tcp(&parts, request.timeout)?;
    let mut stream = connect_stream(&parts, stream)?;
    let body_len = if request.body.is_empty() {
        None
    } else {
        Some(request.body.len())
    };
    let head = build_http_request_head(
        request.method,
        &parts.path,
        &host_header(&parts),
        body_len,
        &request.headers,
    )?;

    stream
        .write_all(head.as_bytes())
        .map_err(|err| io_error("write request head", err))?;
    if !request.body.is_empty() {
        stream
            .write_all(&request.body)
            .map_err(|err| io_error("write request body", err))?;
    }
    stream
        .flush()
        .map_err(|err| io_error("flush request", err))?;

    read_response(
        &mut *stream,
        request.max_body_bytes,
        request.method != "HEAD",
    )
}

fn connect_tcp(parts: &UrlParts, timeout: Duration) -> Result<TcpStream> {
    let mut addrs = (parts.host.as_str(), parts.port)
        .to_socket_addrs()
        .map_err(|err| io_error("resolve host", err))?;
    let addr = addrs
        .next()
        .ok_or_else(|| Error::HostError(format!("table/http: no address for {}", parts.host)))?;
    let stream =
        TcpStream::connect_timeout(&addr, timeout).map_err(|err| io_error("connect", err))?;
    stream
        .set_read_timeout(Some(timeout))
        .map_err(|err| io_error("set read timeout", err))?;
    stream
        .set_write_timeout(Some(timeout))
        .map_err(|err| io_error("set write timeout", err))?;
    Ok(stream)
}

fn connect_stream(parts: &UrlParts, stream: TcpStream) -> Result<Box<dyn ReadWrite>> {
    match parts.scheme.as_str() {
        "http" => Ok(Box::new(stream)),
        "https" => connect_tls(parts, stream),
        other => Err(Error::Eval(format!(
            "table/http: unsupported url scheme {other}"
        ))),
    }
}

#[cfg(feature = "tls")]
fn connect_tls(parts: &UrlParts, stream: TcpStream) -> Result<Box<dyn ReadWrite>> {
    let config = tls_client_config()?;
    let server_name = ServerName::try_from(parts.host.clone()).map_err(|_| {
        Error::Eval(format!(
            "table/http: invalid tls server name {}",
            parts.host
        ))
    })?;
    let connection = ClientConnection::new(config, server_name)
        .map_err(|err| Error::HostError(format!("table/http: tls {err}")))?;
    Ok(Box::new(StreamOwned::new(connection, stream)))
}

#[cfg(not(feature = "tls"))]
fn connect_tls(_parts: &UrlParts, _stream: TcpStream) -> Result<Box<dyn ReadWrite>> {
    Err(Error::Eval(
        "table/http: https URLs require the tls feature".to_owned(),
    ))
}

#[cfg(feature = "tls")]
fn tls_client_config() -> Result<Arc<ClientConfig>> {
    let mut roots = RootCertStore::empty();
    let cert_result = rustls_native_certs::load_native_certs();
    for certificate in cert_result.certs {
        roots
            .add(certificate)
            .map_err(|err| Error::HostError(format!("table/http: tls root {err}")))?;
    }
    if roots.is_empty() {
        return Err(Error::HostError(
            "table/http: no tls root certificates available".to_owned(),
        ));
    }
    Ok(Arc::new(
        ClientConfig::builder()
            .with_root_certificates(roots)
            .with_no_client_auth(),
    ))
}

fn read_response(
    stream: &mut dyn Read,
    max_body_bytes: usize,
    expect_body: bool,
) -> Result<HttpResponse> {
    let mut reader = BufReader::new(stream);
    let head = match sim_lib_net_core::read_head_until_double_crlf(&mut reader, 64 * 1024) {
        Ok(HeadOutcome::Head(head)) => head,
        Ok(HeadOutcome::TooLarge) => {
            return Err(Error::HostError(
                "table/http: response headers exceed size limit".to_owned(),
            ));
        }
        Ok(HeadOutcome::Eof | HeadOutcome::Truncated(_)) => {
            return Err(Error::HostError(
                "table/http: truncated response head".to_owned(),
            ));
        }
        Err(err) => return Err(io_error("read response head", err)),
    };
    let head_text = std::str::from_utf8(&head)
        .map_err(|_| Error::HostError("table/http: response headers are not utf-8".to_owned()))?;
    let parsed = sim_lib_net_core::parse_http_head(head_text)
        .map_err(|err| Error::HostError(format!("table/http: {err}")))?;
    if !expect_body {
        return Ok(HttpResponse {
            status: parsed.status,
            reason: Some(parsed.reason),
            body: Vec::new(),
        });
    }
    let mode = sim_lib_net_core::body_mode(&parsed)
        .map_err(|err| Error::HostError(format!("table/http: {err}")))?;
    let body = match mode {
        HttpBodyMode::ContentLength(length) => {
            if length > max_body_bytes {
                return Err(body_limit_error(max_body_bytes));
            }
            read_content_length(&mut reader, length)?
        }
        HttpBodyMode::Chunked => read_chunked(&mut reader, max_body_bytes)?,
        HttpBodyMode::UntilEof | HttpBodyMode::Empty => {
            read_to_end_limited(&mut reader, max_body_bytes)?
        }
    };
    Ok(HttpResponse {
        status: parsed.status,
        reason: Some(parsed.reason),
        body,
    })
}

fn read_content_length(reader: &mut BufReader<&mut dyn Read>, length: usize) -> Result<Vec<u8>> {
    let mut body = vec![0; length];
    reader
        .read_exact(&mut body)
        .map_err(|err| io_error("read response body", err))?;
    Ok(body)
}

fn read_to_end_limited(
    reader: &mut BufReader<&mut dyn Read>,
    max_body_bytes: usize,
) -> Result<Vec<u8>> {
    let mut body = Vec::new();
    let mut chunk = [0; 8192];
    loop {
        let read = reader
            .read(&mut chunk)
            .map_err(|err| io_error("read response body", err))?;
        if read == 0 {
            return Ok(body);
        }
        if body.len().saturating_add(read) > max_body_bytes {
            return Err(body_limit_error(max_body_bytes));
        }
        body.extend_from_slice(&chunk[..read]);
    }
}

fn read_chunked(reader: &mut BufReader<&mut dyn Read>, max_body_bytes: usize) -> Result<Vec<u8>> {
    let mut encoded = Vec::new();
    let mut chunk = [0; 8192];
    let encoded_cap = max_body_bytes.saturating_add(64 * 1024);
    loop {
        match sim_lib_net_core::decode_chunked(&encoded, max_body_bytes) {
            Ok(body) => return Ok(body),
            Err(sim_lib_net_core::NetError::TruncatedChunk) => {}
            Err(sim_lib_net_core::NetError::OversizeBody(_)) => {
                return Err(body_limit_error(max_body_bytes));
            }
            Err(err) => return Err(Error::HostError(format!("table/http: {err}"))),
        }
        let read = reader
            .read(&mut chunk)
            .map_err(|err| io_error("read chunked response body", err))?;
        if read == 0 {
            return Err(Error::HostError(
                "table/http: truncated chunked response body".to_owned(),
            ));
        }
        if encoded.len().saturating_add(read) > encoded_cap {
            return Err(body_limit_error(max_body_bytes));
        }
        encoded.extend_from_slice(&chunk[..read]);
    }
}

fn host_header(parts: &UrlParts) -> String {
    let default = match parts.scheme.as_str() {
        "http" => 80,
        "https" => 443,
        _ => 0,
    };
    if parts.port == default {
        parts.host.clone()
    } else {
        format!("{}:{}", parts.host, parts.port)
    }
}

fn build_http_request_head(
    method: &str,
    target: &str,
    host: &str,
    content_length: Option<usize>,
    headers: &[(String, String)],
) -> Result<String> {
    validate_token("method", method)?;
    validate_field_value("target", target)?;
    validate_field_value("host", host)?;

    let mut head = format!("{method} {target} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n");
    if let Some(length) = content_length {
        head.push_str(&format!("Content-Length: {length}\r\n"));
    }
    for (name, value) in headers {
        validate_token("header name", name)?;
        validate_field_value("header value", value)?;
        head.push_str(name);
        head.push_str(": ");
        head.push_str(value);
        head.push_str("\r\n");
    }
    head.push_str("\r\n");
    Ok(head)
}

fn validate_token(label: &str, value: &str) -> Result<()> {
    if value.is_empty()
        || value
            .bytes()
            .any(|byte| byte <= b' ' || byte == b':' || byte >= 0x7f)
    {
        return Err(Error::Eval(format!("table/http: invalid {label}")));
    }
    Ok(())
}

fn validate_field_value(label: &str, value: &str) -> Result<()> {
    if value.contains('\r') || value.contains('\n') {
        return Err(Error::Eval(format!("table/http: invalid {label}")));
    }
    Ok(())
}

fn io_error(context: &str, err: io::Error) -> Error {
    Error::HostError(format!("table/http: {context}: {err}"))
}

fn body_limit_error(max_body_bytes: usize) -> Error {
    Error::Eval(format!(
        "table/http: response exceeded max body bytes {max_body_bytes}"
    ))
}