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 serde::Deserialize;

use crate::profile::TlsProfile;

const LOCAL_TRANSPORT_PARAMS: [u64; 14] = [
    0x01, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0e, 0x0f, 0x20,
];
const ENFORCED_TRANSPORT_PARAMS: [u64; 8] = [0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0e, 0x0f];
const SERVER_TRANSPORT_PARAMS: [u64; 4] = [0x00, 0x02, 0x0d, 0x10];
const INITIAL_SOURCE_CONNECTION_ID: u64 = 0x0f;
const HTTP2_RESERVED_SETTINGS: [u64; 4] = [0x02, 0x03, 0x04, 0x05];
const MAX_VARINT: u64 = (1 << 62) - 1;
const MIN_INITIAL_DATAGRAM_SIZE: u16 = 1200;

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct H3Profile {
    pub initial_max_data: u64,
    pub initial_max_stream_data_bidi_local: u64,
    pub initial_max_stream_data_bidi_remote: u64,
    pub initial_max_stream_data_uni: u64,
    pub initial_max_streams_bidi: u64,
    pub initial_max_streams_uni: u64,
    pub max_idle_timeout_secs: u64,
    pub max_udp_payload_size: u16,
    #[serde(default)]
    pub initial_datagram_size: Option<u16>,
    #[serde(default)]
    pub initial_crypto_split: H3CryptoSplit,
    #[serde(default)]
    pub initial_crypto_reorder: H3CryptoReorder,
    pub active_connection_id_limit: u64,
    pub dcid_length: H3ConnectionIdLength,
    #[serde(default)]
    pub scid_length: Option<usize>,
    #[serde(default)]
    pub qpack_max_table_capacity: Option<u64>,
    #[serde(default)]
    pub qpack_blocked_streams: Option<u64>,
    #[serde(default)]
    pub max_field_section_size: Option<u64>,
    #[serde(default)]
    pub race: bool,
    #[serde(default)]
    pub max_ack_delay_ms: Option<u64>,
    #[serde(default)]
    pub transport_parameters: Option<Vec<H3TransportParam>>,
    #[serde(default)]
    pub transport_order: H3Order,
    #[serde(default)]
    pub settings: Option<Vec<H3Setting>>,
    #[serde(default)]
    pub control_grease_frame: Option<H3Grease>,
    #[serde(default)]
    pub pseudo_order: Option<Vec<String>>,
    #[serde(default)]
    pub priority_update: bool,
    #[serde(default)]
    pub tls: Option<TlsProfile>,
}

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum H3ConnectionIdLength {
    Fixed(usize),
    Weighted { weights: Vec<(usize, u32)> },
}

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum H3Order {
    #[default]
    Fixed,
    Shuffle,
    Rotate,
}

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum H3CryptoSplit {
    #[default]
    Fill,
    Even,
}

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum H3CryptoReorder {
    #[default]
    None,
    SniMidpoint,
}

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct H3Grease {
    pub id_bits: u32,
    #[serde(default)]
    pub max_len: usize,
    #[serde(default)]
    pub value_bits: u32,
}

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct H3TransportParam {
    #[serde(default)]
    pub id: Option<u64>,
    #[serde(default)]
    pub varint: Option<u64>,
    #[serde(default)]
    pub hex: Option<String>,
    #[serde(default)]
    pub grease: Option<H3Grease>,
    #[serde(default)]
    pub versions: Option<H3VersionInformation>,
    #[serde(default)]
    pub pinned: bool,
}

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct H3VersionInformation {
    pub chosen: u32,
    pub available: Vec<u32>,
    #[serde(default)]
    pub grease: Option<H3VersionGrease>,
}

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum H3VersionGrease {
    First,
    Random,
}

#[expect(
    missing_docs,
    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct H3Setting {
    #[serde(default)]
    pub id: Option<u64>,
    #[serde(default)]
    pub value: Option<u64>,
    #[serde(default)]
    pub grease: Option<H3Grease>,
}

impl H3Profile {
    pub(crate) fn validate(&self) -> Result<(), String> {
        let legacy_settings = self.qpack_max_table_capacity.is_some()
            || self.qpack_blocked_streams.is_some()
            || self.max_field_section_size.is_some();
        if self.settings.is_some() == legacy_settings {
            return Err(
                "[h3] needs either a settings list or the qpack_max_table_capacity, \
                 qpack_blocked_streams and max_field_section_size fields, not both"
                    .into(),
            );
        }
        if let Some(settings) = &self.settings {
            validate_settings(settings)?;
        }
        if let Some(tls) = &self.tls {
            tls.validate(true)?;
        }
        if let Some(params) = &self.transport_parameters {
            validate_transport_parameters(params)?;
        }
        if let Some(size) = self.initial_datagram_size
            && !(MIN_INITIAL_DATAGRAM_SIZE..=self.max_udp_payload_size).contains(&size)
        {
            return Err(format!(
                "[h3] initial_datagram_size {size} must be {MIN_INITIAL_DATAGRAM_SIZE}..={}",
                self.max_udp_payload_size
            ));
        }
        if self.initial_crypto_reorder != H3CryptoReorder::None
            && self.initial_crypto_split != H3CryptoSplit::Even
        {
            return Err("[h3] initial_crypto_reorder needs initial_crypto_split = \"even\"".into());
        }
        if let H3ConnectionIdLength::Weighted { weights } = &self.dcid_length
            && weights.iter().all(|(_, weight)| *weight == 0)
        {
            return Err("[h3] dcid_length weights must not all be zero".into());
        }
        Ok(())
    }
}

fn validate_settings(settings: &[H3Setting]) -> Result<(), String> {
    let mut seen = HashSet::new();
    for setting in settings {
        let (id, value) = match (setting.id, setting.value, &setting.grease) {
            (None, None, Some(_)) => continue,
            (Some(id), Some(value), None) => (id, value),
            _ => return Err("[h3] settings entry needs either id and value, or grease".into()),
        };
        if id > MAX_VARINT || value > MAX_VARINT {
            return Err(format!("[h3] setting {id} does not fit in a QUIC varint"));
        }
        if HTTP2_RESERVED_SETTINGS.contains(&id) {
            return Err(format!("[h3] setting {id} is reserved for HTTP/2"));
        }
        if !seen.insert(id) {
            return Err(format!("[h3] setting {id} appears twice"));
        }
    }
    Ok(())
}

fn validate_transport_parameters(params: &[H3TransportParam]) -> Result<(), String> {
    let mut seen = HashSet::new();
    for param in params {
        param.validate()?;
        if let Some(id) = param.id
            && !seen.insert(id)
        {
            return Err(format!("[h3] transport parameter {id} appears twice"));
        }
    }
    if !seen.contains(&INITIAL_SOURCE_CONNECTION_ID) {
        return Err(format!(
            "[h3] transport_parameters must list initial_source_connection_id \
             ({INITIAL_SOURCE_CONNECTION_ID})"
        ));
    }
    Ok(())
}

impl H3TransportParam {
    fn validate(&self) -> Result<(), String> {
        let kinds = [
            self.varint.is_some(),
            self.hex.is_some(),
            self.grease.is_some(),
            self.versions.is_some(),
        ]
        .iter()
        .filter(|set| **set)
        .count();
        match (self.id, kinds) {
            (Some(id), _) if id > MAX_VARINT => Err(format!(
                "[h3] transport parameter {id} does not fit in a QUIC varint"
            )),
            (Some(id), _) if SERVER_TRANSPORT_PARAMS.contains(&id) => Err(format!(
                "[h3] transport parameter {id} is sent only by servers"
            )),
            (Some(id), 0) if LOCAL_TRANSPORT_PARAMS.contains(&id) => Ok(()),
            (Some(id), 0) => Err(format!(
                "[h3] transport parameter {id} has no value and is not one Leyline fills in"
            )),
            (Some(id), 1) if ENFORCED_TRANSPORT_PARAMS.contains(&id) => Err(format!(
                "[h3] transport parameter {id} is enforced locally, so it takes no raw value"
            )),
            (Some(id), 1) if self.varint.is_some_and(|value| value > MAX_VARINT) => Err(format!(
                "[h3] transport parameter {id} value does not fit in a QUIC varint"
            )),
            (Some(_), 1) if self.versions.is_some() => self
                .versions
                .as_ref()
                .map_or(Ok(()), H3VersionInformation::validate),
            (Some(_), 1) if self.grease.is_none() => Ok(()),
            (None, 1) if self.grease.is_some() => Ok(()),
            _ => Err(format!(
                "[h3] transport parameter entry {self:?} needs an id with at most one of varint, \
                 hex or versions, or a grease table alone"
            )),
        }
    }
}

impl H3VersionInformation {
    fn validate(&self) -> Result<(), String> {
        if self.chosen != QUIC_START_VERSION || !self.available.contains(&self.chosen) {
            return Err(format!(
                "[h3] version_information chosen must be {QUIC_START_VERSION:#010x}, the version \
                 the connection starts in, and must appear in available"
            ));
        }
        match std::iter::once(&self.chosen)
            .chain(&self.available)
            .find(|version| !quic_version_supported(**version))
        {
            Some(version) => Err(format!(
                "[h3] QUIC version {version:#010x} is not supported by the QUIC stack"
            )),
            None => Ok(()),
        }
    }
}

#[cfg(feature = "http3")]
fn quic_version_supported(version: u32) -> bool {
    leyline_quiche::version_is_supported(version)
}

#[cfg(feature = "http3")]
const QUIC_START_VERSION: u32 = leyline_quiche::PROTOCOL_VERSION;

#[cfg(not(feature = "http3"))]
const QUIC_START_VERSION: u32 = 1;

#[cfg(not(feature = "http3"))]
fn quic_version_supported(_version: u32) -> bool {
    true
}