leyline-http 0.1.5

HTTP client that impersonates Chrome, Firefox, and Safari on the wire: TLS (JA3/JA4), HTTP/2, and HTTP/3 fingerprints
Documentation
use serde::Deserialize;

use super::FieldOutcome;

#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HeaderOutcome {
    pub name: String,
    pub outcome: FieldOutcome,
}

#[derive(Deserialize, Default)]
pub(super) struct EchoHttp1 {
    #[serde(default)]
    headers: Vec<String>,
}

#[derive(Deserialize, Default)]
pub(super) struct EchoFrame {
    #[serde(default)]
    headers: Vec<String>,
}

pub(super) fn echoed(http2: &[EchoFrame], http1: &EchoHttp1) -> Vec<(String, String)> {
    http2
        .iter()
        .flat_map(|frame| frame.headers.iter())
        .chain(http1.headers.iter())
        .filter_map(|line| split_line(line))
        .collect()
}

fn split_line(line: &str) -> Option<(String, String)> {
    if line.starts_with(':') {
        return None;
    }
    let (name, value) = line.split_once(':')?;
    let name = name.trim();
    (!name.is_empty()).then(|| (name.to_ascii_lowercase(), value.trim().to_owned()))
}

pub(super) fn compare(
    sent: &[(String, String)],
    observed: &[(String, String)],
) -> Vec<HeaderOutcome> {
    let mut names: Vec<String> = Vec::new();
    for name in field_names(sent) {
        if !names.contains(&name) {
            names.push(name);
        }
    }
    names
        .into_iter()
        .map(|name| {
            let outcome = header_outcome(&name, sent, observed);
            HeaderOutcome { name, outcome }
        })
        .collect()
}

pub(super) fn order(sent: &[(String, String)], observed: &[(String, String)]) -> FieldOutcome {
    if observed.is_empty() {
        return FieldOutcome::NotReported;
    }
    let expected = shared_names(sent, observed).join(",");
    let seen = shared_names(observed, sent).join(",");
    if expected == seen {
        FieldOutcome::Match
    } else {
        FieldOutcome::Mismatch {
            expected,
            observed: seen,
        }
    }
}

fn field_names(list: &[(String, String)]) -> impl Iterator<Item = String> + '_ {
    list.iter()
        .filter(|(name, _)| !name.starts_with(':'))
        .map(|(name, _)| name.to_ascii_lowercase())
}

fn shared_names(from: &[(String, String)], other: &[(String, String)]) -> Vec<String> {
    field_names(from)
        .filter(|name| {
            other
                .iter()
                .any(|(held, _)| held.eq_ignore_ascii_case(name))
        })
        .collect()
}

fn values<'a>(name: &str, list: &'a [(String, String)]) -> Vec<&'a str> {
    list.iter()
        .filter(|(held, _)| held.eq_ignore_ascii_case(name))
        .map(|(_, value)| value.trim())
        .collect()
}

const COOKIE: &str = "cookie";

fn joined_cookie(list: &[(String, String)]) -> Vec<&str> {
    values(COOKIE, list)
        .into_iter()
        .flat_map(|value| value.split(';'))
        .map(str::trim)
        .filter(|pair| !pair.is_empty())
        .collect()
}

fn header_outcome(
    name: &str,
    sent: &[(String, String)],
    observed: &[(String, String)],
) -> FieldOutcome {
    if observed.is_empty() {
        return FieldOutcome::NotReported;
    }
    let (expected, seen) = if name.eq_ignore_ascii_case(COOKIE) {
        (joined_cookie(sent), joined_cookie(observed))
    } else {
        (values(name, sent), values(name, observed))
    };
    if seen.is_empty() {
        return FieldOutcome::Absent {
            expected: expected.join(", "),
        };
    }
    if seen == expected {
        return FieldOutcome::Match;
    }
    FieldOutcome::Mismatch {
        expected: expected.join(", "),
        observed: seen.join(", "),
    }
}