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 std::collections::HashSet;
use std::fmt::Write;

use super::HeaderStyle;

const Q_START_TENTHS: u8 = 10;
const Q_STEP_TENTHS: u8 = 1;
const Q_FLOOR_TENTHS: u8 = 1;
const GECKO_Q_ROUND: f32 = 0.005;
const GECKO_TENTHS_BELOW: usize = 10;
const PRIVATE_BASES: [&str; 2] = ["x", "i"];
const SUBTAG_MAX: usize = 8;
const PRIMARY_MIN: usize = 2;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LanguageRule {
    expand_base: bool,
    canonical_case: bool,
    limit: Option<usize>,
    weights: Weights,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Weights {
    Tenths,
    Gecko,
}

const CHROMIUM: LanguageRule = LanguageRule {
    expand_base: true,
    canonical_case: false,
    limit: None,
    weights: Weights::Tenths,
};

const GECKO: LanguageRule = LanguageRule {
    expand_base: false,
    canonical_case: true,
    limit: None,
    weights: Weights::Gecko,
};

const WEBKIT: LanguageRule = LanguageRule {
    expand_base: false,
    canonical_case: false,
    limit: Some(1),
    weights: Weights::Tenths,
};

const RULES: [(HeaderStyle, LanguageRule); 4] = [
    (HeaderStyle::Gecko, GECKO),
    (HeaderStyle::WebKit, WEBKIT),
    (HeaderStyle::WebKit26, WEBKIT),
    (HeaderStyle::WebKit17, WEBKIT),
];

fn rule_for(style: HeaderStyle) -> LanguageRule {
    RULES
        .iter()
        .find(|(listed, _)| *listed == style)
        .map_or(CHROMIUM, |(_, rule)| *rule)
}

pub(crate) fn validate_language(tag: &str) -> Result<(), String> {
    let mut subtags = tag.split('-');
    let primary = subtags.next().unwrap_or_default();
    let primary_ok = ((PRIMARY_MIN..=SUBTAG_MAX).contains(&primary.len())
        && primary.bytes().all(|b| b.is_ascii_alphabetic()))
        || PRIVATE_BASES
            .iter()
            .any(|base| primary.eq_ignore_ascii_case(base));
    let rest_ok = subtags.all(|subtag| {
        (1..=SUBTAG_MAX).contains(&subtag.len())
            && subtag.bytes().all(|b| b.is_ascii_alphanumeric())
    });
    if primary_ok && rest_ok {
        Ok(())
    } else {
        Err(format!(
            "invalid language tag `{}`: expected a BCP 47 tag such as `de-DE`",
            tag.escape_debug()
        ))
    }
}

pub(crate) fn accept_language(tags: &[String], style: HeaderStyle) -> String {
    let rule = rule_for(style);
    let limit = rule.limit.unwrap_or(usize::MAX);
    let listed: Vec<String> = tags
        .iter()
        .take(limit)
        .map(|tag| cased(tag, rule.canonical_case))
        .collect();
    let ordered = if rule.expand_base {
        expand_bases(&listed)
    } else {
        listed
    };
    match rule.weights {
        Weights::Tenths => weighted(&ordered),
        Weights::Gecko => gecko_weighted(&ordered),
    }
}

fn base_of(tag: &str) -> &str {
    tag.split('-').next().unwrap_or(tag)
}

fn expand_bases(tags: &[String]) -> Vec<String> {
    let mut seen = HashSet::new();
    let mut out = Vec::new();
    let mut push = |tag: &str, out: &mut Vec<String>| {
        if seen.insert(tag.to_owned()) {
            out.push(tag.to_owned());
        }
    };
    for (index, tag) in tags.iter().enumerate() {
        push(tag, &mut out);
        let base = base_of(tag);
        if PRIVATE_BASES.contains(&base) {
            continue;
        }
        if tags.get(index + 1).is_none_or(|next| base_of(next) != base) {
            push(base, &mut out);
        }
    }
    out
}

fn weighted(tags: &[String]) -> String {
    let mut q = Q_START_TENTHS;
    let mut out = String::new();
    for tag in tags {
        if q == Q_START_TENTHS {
            out.push_str(tag);
        } else {
            write!(out, ",{tag};q=0.{q}").expect("writing to a String cannot fail");
        }
        if q > Q_STEP_TENTHS.max(Q_FLOOR_TENTHS) {
            q -= Q_STEP_TENTHS;
        }
    }
    out
}

fn gecko_weighted(tags: &[String]) -> String {
    let count = tags.len();
    let mut out = String::new();
    for (index, tag) in tags.iter().enumerate() {
        if index > 0 {
            out.push(',');
        }
        out.push_str(tag);
        let q = 1.0_f32 - index as f32 / count as f32;
        let hundredths = ((q + GECKO_Q_ROUND) * 100.0) as u32;
        if hundredths >= 100 {
            continue;
        }
        if count < GECKO_TENTHS_BELOW || hundredths.is_multiple_of(10) {
            write!(out, ";q=0.{}", (hundredths + 5) / 10).expect("writing to a String cannot fail");
        } else {
            write!(out, ";q=0.{hundredths:02}").expect("writing to a String cannot fail");
        }
    }
    out
}

fn cased(tag: &str, canonical: bool) -> String {
    if !canonical {
        return tag.to_owned();
    }
    let lower = tag.to_ascii_lowercase();
    let mut out = Vec::new();
    let mut singleton = false;
    for (index, subtag) in lower.split('-').enumerate() {
        singleton |= index > 0 && subtag.len() == 1;
        out.push(if index == 0 || singleton {
            subtag.to_owned()
        } else {
            canonical_subtag(subtag)
        });
    }
    out.join("-")
}

fn canonical_subtag(subtag: &str) -> String {
    match subtag.len() {
        2 => subtag.to_ascii_uppercase(),
        4 => {
            let (head, tail) = subtag.split_at(1);
            format!("{}{tail}", head.to_ascii_uppercase())
        }
        _ => subtag.to_owned(),
    }
}