use serde::{Deserialize, Serialize};
use crate::tls_validation::CHROME_136_HTTP2_SETTINGS;
pub const HEADER_ORDER_CHROME_136: &[&str] = &[
"host",
"connection",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"user-agent",
"accept",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"accept-encoding",
"accept-language",
"cookie",
];
pub const HEADER_ORDER_FIREFOX_130: &[&str] = &[
"host",
"user-agent",
"accept",
"accept-language",
"accept-encoding",
"connection",
"cookie",
"sec-fetch-dest",
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-user",
];
pub const PSEUDO_HEADER_ORDER_CHROME_136: &[&str] = &[":method", ":authority", ":scheme", ":path"];
pub type Http2SettingsObservation = Vec<(u32, u32)>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeaderOrderMatch {
pub expected: Vec<String>,
pub observed: Vec<String>,
pub matched_positions: usize,
pub matched_set: usize,
pub reference_length: usize,
pub observed_length: usize,
}
impl HeaderOrderMatch {
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn position_match_ratio(&self) -> f64 {
if self.reference_length == 0 {
return 0.0;
}
self.matched_positions as f64 / self.reference_length as f64
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn set_match_ratio(&self) -> f64 {
if self.reference_length == 0 {
return 0.0;
}
self.matched_set as f64 / self.reference_length as f64
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransportObservation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http2_settings: Option<Http2SettingsObservation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http2_pseudo_header_order: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http2_header_order: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http3_perk_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http3_perk_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alpn_protocols: Option<Vec<String>>,
}
impl TransportObservation {
#[must_use]
pub fn from_settings(settings: &[(u32, u32)]) -> Self {
Self {
http2_settings: Some(settings.to_vec()),
..Self::default()
}
}
#[must_use]
pub fn with_pseudo_header_order<I, S>(mut self, order: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.http2_pseudo_header_order = Some(order.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn with_header_order<I, S>(mut self, order: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.http2_header_order = Some(order.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn with_http3_perk_text(mut self, text: impl Into<String>) -> Self {
self.http3_perk_text = Some(text.into());
self
}
#[must_use]
pub fn with_http3_perk_hash(mut self, hash: impl Into<String>) -> Self {
self.http3_perk_hash = Some(hash.into());
self
}
#[must_use]
pub fn with_alpn<I, S>(mut self, protocols: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.alpn_protocols = Some(protocols.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn chrome_136_reference() -> Self {
Self {
http2_settings: Some(CHROME_136_HTTP2_SETTINGS.to_vec()),
http2_pseudo_header_order: Some(
PSEUDO_HEADER_ORDER_CHROME_136
.iter()
.map(|s| (*s).to_string())
.collect(),
),
http2_header_order: Some(
HEADER_ORDER_CHROME_136
.iter()
.map(|s| (*s).to_string())
.collect(),
),
http3_perk_text: None,
http3_perk_hash: None,
alpn_protocols: Some(vec!["h2".to_string(), "http/1.1".to_string()]),
}
}
#[must_use]
pub const fn has_http2(&self) -> bool {
self.http2_settings.is_some()
|| self.http2_pseudo_header_order.is_some()
|| self.http2_header_order.is_some()
}
#[must_use]
pub const fn http2_observation_count(&self) -> usize {
let mut n = 0;
if self.http2_settings.is_some() {
n += 1;
}
if self.http2_pseudo_header_order.is_some() {
n += 1;
}
if self.http2_header_order.is_some() {
n += 1;
}
n
}
}
#[must_use]
pub fn compare_header_order(expected: &[&str], observed: &[String]) -> HeaderOrderMatch {
let expected_lc: Vec<String> = expected.iter().map(|s| s.to_ascii_lowercase()).collect();
let observed_lc: Vec<String> = observed.iter().map(|s| s.to_ascii_lowercase()).collect();
let matched_positions = expected_lc
.iter()
.zip(observed_lc.iter())
.filter(|(a, b)| a == b)
.count();
let matched_set = expected_lc
.iter()
.filter(|header| observed_lc.iter().any(|o| o == *header))
.count();
HeaderOrderMatch {
expected: expected_lc,
observed: observed_lc,
matched_positions,
matched_set,
reference_length: expected.len(),
observed_length: observed.len(),
}
}
#[must_use]
pub fn compare_pseudo_header_order(observed: &[String]) -> HeaderOrderMatch {
compare_header_order(PSEUDO_HEADER_ORDER_CHROME_136, observed)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use crate::tls_validation::{CHROME_131_JA3, CHROME_136_HTTP2_SETTINGS};
#[test]
fn chrome_136_reference_seed_is_complete() {
let obs = TransportObservation::chrome_136_reference();
assert_eq!(
obs.http2_settings.as_deref(),
Some(CHROME_136_HTTP2_SETTINGS)
);
assert!(obs.has_http2());
assert_eq!(obs.http2_observation_count(), 3);
assert_eq!(
obs.http2_pseudo_header_order.as_deref(),
Some(PSEUDO_HEADER_ORDER_CHROME_136)
.map(|s| s.iter().map(|x| (*x).to_string()).collect::<Vec<_>>())
.as_ref()
.map(|v| &v[..])
);
}
#[test]
fn empty_observation_carries_no_http2_signal() {
let obs = TransportObservation::default();
assert!(!obs.has_http2());
assert_eq!(obs.http2_observation_count(), 0);
}
#[test]
fn header_order_position_match_counts_in_order_only() {
let expected = HEADER_ORDER_CHROME_136;
let observed: Vec<String> = vec![
"cookie".into(),
"accept-language".into(),
"host".into(),
"connection".into(),
];
let m = compare_header_order(expected, &observed);
assert_eq!(m.matched_set, 4);
assert_eq!(m.matched_positions, 0);
assert!(m.position_match_ratio() < m.set_match_ratio());
}
#[test]
fn header_order_position_match_perfect_for_chrome_136() {
let expected = HEADER_ORDER_CHROME_136;
let observed: Vec<String> = expected.iter().map(|s| (*s).to_string()).collect();
let m = compare_header_order(expected, &observed);
assert_eq!(m.matched_positions, expected.len());
assert_eq!(m.matched_set, expected.len());
assert!((m.position_match_ratio() - 1.0).abs() < 1e-9);
}
#[test]
fn header_order_position_match_does_not_panic_on_empty_inputs() {
let m = compare_header_order(&[], &[]);
assert_eq!(m.matched_positions, 0);
assert_eq!(m.matched_set, 0);
assert_eq!(m.reference_length, 0);
let pos_ratio = m.position_match_ratio();
assert!(pos_ratio.abs() < 1e-9, "pos_ratio={pos_ratio}");
let set_ratio = m.set_match_ratio();
assert!(set_ratio.abs() < 1e-9, "set_ratio={set_ratio}");
}
#[test]
fn pseudo_header_order_matches_chrome_136() {
let observed: Vec<String> = PSEUDO_HEADER_ORDER_CHROME_136
.iter()
.map(|s| (*s).to_string())
.collect();
let m = compare_pseudo_header_order(&observed);
assert_eq!(m.matched_positions, PSEUDO_HEADER_ORDER_CHROME_136.len());
}
#[test]
fn from_settings_preserves_order_and_values() {
let obs = TransportObservation::from_settings(CHROME_136_HTTP2_SETTINGS);
let settings = obs.http2_settings.expect("settings");
assert_eq!(settings, CHROME_136_HTTP2_SETTINGS);
}
#[test]
fn builders_chain_and_preserve_previous_fields() {
let obs = TransportObservation::from_settings(CHROME_136_HTTP2_SETTINGS)
.with_header_order(HEADER_ORDER_CHROME_136.iter().copied())
.with_alpn(["h2", "http/1.1"]);
assert!(obs.http2_settings.is_some());
assert!(obs.http2_header_order.is_some());
assert_eq!(
obs.alpn_protocols.as_deref(),
Some(&["h2".to_string(), "http/1.1".to_string()][..])
);
}
#[test]
fn unused_constants_are_reachable() {
assert!(CHROME_131_JA3.len() == 32);
}
}