rload 0.2.2

A Rust HTTP/1.1 load generator with Nginx access-log and JSONL replay
Documentation
use std::net::{SocketAddr, ToSocketAddrs};
use std::sync::Arc;

use rustls::pki_types::ServerName;

use crate::access_log::ReplayRequest;
use crate::{Method, RunError};

pub(crate) struct Target {
    authority: String,
    host_header: String,
    path: String,
    tls_server_name: Option<ServerName<'static>>,
}

impl Target {
    pub(crate) fn parse(url: &str) -> Result<Self, RunError> {
        let (remainder, default_port, tls) = if let Some(remainder) = url.strip_prefix("http://") {
            (remainder, 80, false)
        } else if let Some(remainder) = url.strip_prefix("https://") {
            (remainder, 443, true)
        } else {
            return Err(RunError::InvalidUrl(
                "only http:// and https:// are supported".into(),
            ));
        };
        let boundary = remainder
            .char_indices()
            .find(|(_, character)| matches!(character, '/' | '?' | '#'))
            .map_or(remainder.len(), |(index, _)| index);
        let authority = &remainder[..boundary];
        let suffix = &remainder[boundary..];
        let path = request_path(suffix);
        if authority.is_empty() {
            return Err(RunError::InvalidUrl("host is missing".into()));
        }
        let host = authority_host(authority)?;
        let socket_authority = if has_explicit_port(authority) {
            authority.to_owned()
        } else {
            format!("{authority}:{default_port}")
        };
        let tls_server_name = tls
            .then(|| {
                ServerName::try_from(host.to_owned())
                    .map_err(|_| RunError::InvalidUrl("invalid TLS server name".into()))
            })
            .transpose()?;
        Ok(Self {
            authority: socket_authority,
            host_header: authority.to_owned(),
            path,
            tls_server_name,
        })
    }

    pub(crate) fn tls_server_name(&self) -> Option<ServerName<'static>> {
        self.tls_server_name.clone()
    }

    pub(crate) fn resolve(&self) -> Result<Arc<[SocketAddr]>, RunError> {
        let addresses: Arc<[SocketAddr]> =
            self.authority.to_socket_addrs()?.collect::<Vec<_>>().into();
        if addresses.is_empty() {
            return Err(RunError::Io(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "URL resolved to no addresses",
            )));
        }
        Ok(addresses)
    }

    pub(crate) fn request(&self, method: Method) -> Arc<[u8]> {
        self.request_for(method, &self.path)
    }

    pub(crate) fn path(&self) -> &str {
        &self.path
    }

    pub(crate) fn request_for(&self, method: Method, path: &str) -> Arc<[u8]> {
        format!(
            "{} {} HTTP/1.1\r\nHost: {}\r\nConnection: keep-alive\r\n\r\n",
            method.as_str(),
            path,
            self.host_header
        )
        .into_bytes()
        .into()
    }

    pub(crate) fn replay_request(&self, request: &ReplayRequest) -> Arc<[u8]> {
        let mut encoded = format!(
            "{} {} HTTP/1.1\r\nHost: {}\r\n",
            request.method.as_str(),
            request.path,
            self.host_header
        )
        .into_bytes();
        for (name, value) in &request.headers {
            encoded.extend_from_slice(name.as_bytes());
            encoded.extend_from_slice(b": ");
            encoded.extend_from_slice(value.as_bytes());
            encoded.extend_from_slice(b"\r\n");
        }
        if !matches!(request.method, Method::Get | Method::Head) {
            encoded.extend_from_slice(
                format!("Content-Length: {}\r\n", request.body.len()).as_bytes(),
            );
        }
        encoded.extend_from_slice(b"Connection: keep-alive\r\n\r\n");
        encoded.extend_from_slice(&request.body);
        encoded.into()
    }
}

fn authority_host(authority: &str) -> Result<&str, RunError> {
    if authority.contains('@') {
        return Err(RunError::InvalidUrl("userinfo is not supported".into()));
    }
    if let Some(bracketed) = authority.strip_prefix('[') {
        let (host, suffix) = bracketed
            .split_once(']')
            .filter(|(host, _)| !host.is_empty())
            .ok_or_else(|| RunError::InvalidUrl("invalid IPv6 host".into()))?;
        if !suffix.is_empty()
            && suffix
                .strip_prefix(':')
                .is_none_or(|port| port.parse::<u16>().is_err())
        {
            return Err(RunError::InvalidUrl("invalid IPv6 authority".into()));
        }
        return Ok(host);
    }
    if let Some((host, port)) = authority.rsplit_once(':') {
        if host.is_empty() || port.parse::<u16>().is_err() {
            return Err(RunError::InvalidUrl("invalid port".into()));
        }
        return Ok(host);
    }
    Ok(authority)
}

fn request_path(suffix: &str) -> String {
    let without_fragment = suffix.split_once('#').map_or(suffix, |(path, _)| path);
    match without_fragment.chars().next() {
        Some('/') => without_fragment.to_owned(),
        Some('?') => format!("/{without_fragment}"),
        _ => "/".to_owned(),
    }
}

fn has_explicit_port(authority: &str) -> bool {
    if authority.starts_with('[') {
        return authority.contains("]:");
    }
    authority
        .rsplit_once(':')
        .is_some_and(|(_, port)| port.parse::<u16>().is_ok())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_https_default_port_and_server_name() {
        let target = Target::parse("https://example.com/health").unwrap();

        assert_eq!(target.authority, "example.com:443");
        assert_eq!(target.host_header, "example.com");
        assert_eq!(target.path, "/health");
        assert_eq!(
            target.tls_server_name,
            Some(ServerName::try_from("example.com").unwrap())
        );
    }

    #[test]
    fn parses_query_without_path_and_removes_fragment() {
        let target = Target::parse("https://example.com?key=value#ignored").unwrap();

        assert_eq!(target.authority, "example.com:443");
        assert_eq!(target.path, "/?key=value");
    }

    #[test]
    fn rejects_invalid_bracketed_ipv6_suffix() {
        assert!(Target::parse("https://[::1]junk/").is_err());
        assert!(Target::parse("https://[::1]:443junk/").is_err());
    }
}