rload 0.1.1

A Rust HTTP/1.1 load generator with Nginx access-log and JSONL replay
Documentation
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;

use serde::Deserialize;

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

const MAX_LINE_BYTES: u64 = 1024 * 1024;
const MAX_URI_BYTES: usize = 8 * 1024;
const MAX_HEADER_BYTES: usize = 64 * 1024;

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonRequest {
    method: String,
    uri: String,
    #[serde(default)]
    headers: BTreeMap<String, String>,
    #[serde(default)]
    body: Option<String>,
}

pub(crate) fn read(path: &Path) -> Result<Vec<ReplayRequest>, RunError> {
    let file = File::open(path)?;
    read_from(BufReader::new(file))
}

fn read_from(mut reader: impl BufRead) -> Result<Vec<ReplayRequest>, RunError> {
    let mut requests = Vec::new();
    let mut line_number = 0;
    loop {
        let mut bytes = Vec::new();
        let read = reader
            .by_ref()
            .take(MAX_LINE_BYTES + 1)
            .read_until(b'\n', &mut bytes)?;
        if read == 0 {
            break;
        }
        line_number += 1;
        if bytes.len() as u64 > MAX_LINE_BYTES {
            return Err(invalid(line_number, "record exceeds 1 MiB"));
        }
        if bytes.last() == Some(&b'\n') {
            bytes.pop();
        }
        if bytes.last() == Some(&b'\r') {
            bytes.pop();
        }
        let line = std::str::from_utf8(&bytes)
            .map_err(|_| invalid(line_number, "record is not valid UTF-8"))?;
        if line.trim().is_empty() {
            continue;
        }
        let request: JsonRequest = serde_json::from_str(line)
            .map_err(|error| invalid(line_number, &format!("invalid JSON: {error}")))?;
        requests.push(validate(request, line_number)?);
    }
    if requests.is_empty() {
        return Err(RunError::InvalidRequestFile(
            "request file contains no requests".into(),
        ));
    }
    Ok(requests)
}

fn validate(request: JsonRequest, line: usize) -> Result<ReplayRequest, RunError> {
    let method = match request.method.as_str() {
        "GET" => Method::Get,
        "HEAD" => Method::Head,
        "POST" => Method::Post,
        "PUT" => Method::Put,
        "PATCH" => Method::Patch,
        "DELETE" => Method::Delete,
        "OPTIONS" => Method::Options,
        method => return Err(invalid(line, &format!("unsupported method {method}"))),
    };
    let replay = ReplayRequest {
        method,
        path: request.uri,
        headers: request.headers.into_iter().collect(),
        body_present: request.body.is_some(),
        body: request.body.unwrap_or_default().into_bytes(),
    };
    validate_request(&replay).map_err(|message| invalid(line, &message))?;
    Ok(replay)
}

pub(crate) fn validate_request(request: &ReplayRequest) -> Result<(), String> {
    if request.path.len() > MAX_URI_BYTES || !valid_origin_form(&request.path) {
        return Err("URI must use origin form".into());
    }
    if request.body.len() > MAX_REQUEST_BODY_BYTES {
        return Err("body exceeds 512 KiB".into());
    }
    if matches!(request.method, Method::Get | Method::Head) && request.body_present {
        return Err("GET and HEAD requests must not contain a body".into());
    }
    let mut header_bytes = 0;
    for (name, value) in &request.headers {
        if !valid_header_name(name) {
            return Err(format!("invalid header name {name:?}"));
        }
        if [
            "host",
            "connection",
            "content-length",
            "transfer-encoding",
            "trailer",
            "expect",
        ]
        .iter()
        .any(|reserved| name.eq_ignore_ascii_case(reserved))
        {
            return Err(format!("header {name} is managed by rload"));
        }
        if !value
            .bytes()
            .all(|byte| byte == b'\t' || (b' '..=b'~').contains(&byte))
        {
            return Err(format!("invalid value for header {name}"));
        }
        header_bytes += name.len() + value.len() + 4;
    }
    if header_bytes > MAX_HEADER_BYTES {
        return Err("headers exceed 64 KiB".into());
    }
    Ok(())
}

fn valid_origin_form(uri: &str) -> bool {
    let bytes = uri.as_bytes();
    if bytes.first() != Some(&b'/') || uri.contains('#') {
        return false;
    }
    let mut index = 0;
    while index < bytes.len() {
        let byte = bytes[index];
        if byte == b'%' {
            if index + 2 >= bytes.len()
                || !bytes[index + 1].is_ascii_hexdigit()
                || !bytes[index + 2].is_ascii_hexdigit()
            {
                return false;
            }
            index += 3;
            continue;
        }
        if !(byte.is_ascii_alphanumeric()
            || matches!(
                byte,
                b'/' | b'?'
                    | b'-'
                    | b'.'
                    | b'_'
                    | b'~'
                    | b'!'
                    | b'$'
                    | b'&'
                    | b'\''
                    | b'('
                    | b')'
                    | b'*'
                    | b'+'
                    | b','
                    | b';'
                    | b'='
                    | b':'
                    | b'@'
            ))
        {
            return false;
        }
        index += 1;
    }
    true
}

fn valid_header_name(name: &str) -> bool {
    !name.is_empty()
        && name.bytes().all(|byte| {
            byte.is_ascii_alphanumeric()
                || matches!(
                    byte,
                    b'!' | b'#'
                        | b'$'
                        | b'%'
                        | b'&'
                        | b'\''
                        | b'*'
                        | b'+'
                        | b'-'
                        | b'.'
                        | b'^'
                        | b'_'
                        | b'`'
                        | b'|'
                        | b'~'
                )
        })
}

fn invalid(line: usize, message: &str) -> RunError {
    RunError::InvalidRequestFile(format!("line {line}: {message}"))
}

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

    #[test]
    fn rejects_managed_headers_with_line_number() {
        let request = JsonRequest {
            method: "POST".into(),
            uri: "/".into(),
            headers: BTreeMap::from([("Content-Length".into(), "9".into())]),
            body: Some("payload".into()),
        };

        let error = validate(request, 4).unwrap_err();

        assert!(error.to_string().contains("line 4"));
        assert!(error.to_string().contains("managed by rload"));
    }

    #[test]
    fn rejects_transfer_encoding_and_invalid_percent_escape() {
        let request = JsonRequest {
            method: "POST".into(),
            uri: "/valid".into(),
            headers: BTreeMap::from([("Transfer-Encoding".into(), "chunked".into())]),
            body: Some("payload".into()),
        };

        let error = validate(request, 2).unwrap_err();

        assert!(error.to_string().contains("managed by rload"));
        let invalid_uri = JsonRequest {
            method: "GET".into(),
            uri: "/bad%escape".into(),
            headers: BTreeMap::new(),
            body: None,
        };
        let error = validate(invalid_uri, 3).unwrap_err();
        assert!(error.to_string().contains("URI must use origin form"));
    }

    #[test]
    fn rejects_oversized_record_before_json_parsing() {
        let input = vec![b'x'; MAX_LINE_BYTES as usize + 1];

        let error = read_from(std::io::Cursor::new(input)).unwrap_err();

        assert!(error.to_string().contains("line 1"));
        assert!(error.to_string().contains("exceeds 1 MiB"));
    }
}