rload 0.2.2

A Rust HTTP/1.1 load generator with Nginx access-log and JSONL replay
Documentation
use crate::access_log::ReplayRequest;
use crate::{ReplayFilter, RunError};

const MAX_URI_PATTERNS: usize = 32;
const MAX_PATTERN_BYTES: usize = 256;

pub(crate) fn apply(
    requests: Vec<ReplayRequest>,
    filter: &ReplayFilter,
) -> Result<(Vec<ReplayRequest>, u64), RunError> {
    validate(filter)?;
    let original = requests.len();
    let requests: Vec<_> = requests
        .into_iter()
        .filter(|request| {
            (filter.allowed_methods.is_empty() || filter.allowed_methods.contains(&request.method))
                && (filter.allowed_uris.is_empty()
                    || filter
                        .allowed_uris
                        .iter()
                        .any(|pattern| glob_matches(pattern, &request.path)))
        })
        .collect();
    if requests.is_empty() {
        return Err(RunError::InvalidConfig(
            "replay whitelist excludes all requests".into(),
        ));
    }
    let filtered = (original - requests.len()) as u64;
    Ok((requests, filtered))
}

fn validate(filter: &ReplayFilter) -> Result<(), RunError> {
    if filter.allowed_uris.len() > MAX_URI_PATTERNS {
        return Err(RunError::InvalidConfig(
            "at most 32 allowed URI patterns may be supplied".into(),
        ));
    }
    for pattern in &filter.allowed_uris {
        if pattern.len() > MAX_PATTERN_BYTES
            || !pattern.starts_with('/')
            || !pattern.is_ascii()
            || pattern.bytes().any(|byte| byte <= b' ' || byte == b'#')
        {
            return Err(RunError::InvalidConfig(format!(
                "invalid allowed URI pattern: {pattern:?}"
            )));
        }
    }
    Ok(())
}

fn glob_matches(pattern: &str, value: &str) -> bool {
    let pattern = pattern.as_bytes();
    let value = value.as_bytes();
    let (mut pattern_index, mut value_index) = (0, 0);
    let (mut star, mut retry_value) = (None, 0);
    while value_index < value.len() {
        if pattern.get(pattern_index) == Some(&value[value_index]) {
            pattern_index += 1;
            value_index += 1;
        } else if pattern.get(pattern_index) == Some(&b'*') {
            star = Some(pattern_index);
            pattern_index += 1;
            retry_value = value_index;
        } else if let Some(star_index) = star {
            retry_value += 1;
            value_index = retry_value;
            pattern_index = star_index + 1;
        } else {
            return false;
        }
    }
    while pattern.get(pattern_index) == Some(&b'*') {
        pattern_index += 1;
    }
    pattern_index == pattern.len()
}

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

    #[test]
    fn glob_matches_literal_and_multiple_wildcards() {
        assert!(glob_matches("/api/*/items/*", "/api/v1/items/42?full=1"));
        assert!(glob_matches("/health", "/health"));
        assert!(!glob_matches("/health", "/health/live"));
    }

    #[test]
    fn method_and_uri_filters_form_an_intersection() {
        let requests = vec![
            request(Method::Get, "/api/read"),
            request(Method::Post, "/api/write"),
            request(Method::Post, "/admin"),
        ];
        let filter = ReplayFilter {
            allowed_methods: vec![Method::Post],
            allowed_uris: vec!["/api/*".into()],
        };

        let (filtered, skipped) = apply(requests, &filter).unwrap();

        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].path, "/api/write");
        assert_eq!(skipped, 2);
    }

    #[test]
    fn rejects_filter_that_excludes_every_request() {
        let error = apply(
            vec![request(Method::Get, "/health")],
            &ReplayFilter {
                allowed_methods: vec![Method::Post],
                allowed_uris: Vec::new(),
            },
        )
        .unwrap_err();

        assert!(error.to_string().contains("excludes all requests"));
    }

    #[test]
    fn rejects_excessive_uri_pattern_work() {
        let too_many = ReplayFilter {
            allowed_methods: Vec::new(),
            allowed_uris: vec!["/*".into(); 33],
        };
        let too_long = ReplayFilter {
            allowed_methods: Vec::new(),
            allowed_uris: vec![format!("/{}", "a".repeat(256))],
        };

        assert!(validate(&too_many).is_err());
        assert!(validate(&too_long).is_err());
    }

    fn request(method: Method, path: &str) -> ReplayRequest {
        ReplayRequest {
            method,
            path: path.into(),
            headers: Vec::new(),
            body: Vec::new(),
            body_present: false,
            timestamp_micros: None,
        }
    }
}