use serde::{Deserialize, Serialize};
use crate::tls_validation::compare_http2_settings;
use super::observations::{TransportObservation, compare_header_order};
use super::profile::TransportProfile;
pub const HTTP2_CHECK_KIND_COUNT: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Http2CheckKind {
Settings,
PseudoHeaderOrder,
HeaderOrder,
}
impl Http2CheckKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Settings => "http2_settings",
Self::PseudoHeaderOrder => "http2_pseudo_header_order",
Self::HeaderOrder => "http2_header_order",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Http2CheckResult {
pub kind: Http2CheckKind,
pub matched: bool,
pub score: f64,
pub observed_count: usize,
pub expected_count: usize,
pub position_match_ratio: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TransportCompatibility {
pub checks: Vec<Http2CheckResult>,
pub score: f64,
pub confidence: f64,
pub coverage: f64,
pub matched_count: usize,
pub total_checks: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mismatches: Vec<String>,
}
impl TransportCompatibility {
#[must_use]
pub fn is_well_covered(&self) -> bool {
self.coverage >= 0.5
}
#[must_use]
pub fn is_high_confidence(&self) -> bool {
self.confidence >= 0.5
}
#[must_use]
pub fn is_strong_match(&self) -> bool {
self.score >= 0.95
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TransportRealismReport {
pub profile_name: String,
pub compatibility: TransportCompatibility,
}
impl TransportRealismReport {
#[must_use]
pub fn is_strong_match(&self) -> bool {
self.compatibility.is_strong_match()
}
#[must_use]
pub fn is_high_confidence(&self) -> bool {
self.compatibility.is_high_confidence()
}
}
#[must_use]
pub fn score(
profile: &TransportProfile,
observation: &TransportObservation,
) -> TransportRealismReport {
let total_checks = profile.expected_http2_check_count();
if total_checks == 0 {
return TransportRealismReport {
profile_name: profile.name.clone(),
compatibility: TransportCompatibility {
checks: Vec::new(),
score: 1.0,
confidence: 1.0,
coverage: 1.0,
matched_count: 0,
total_checks: 0,
mismatches: Vec::new(),
},
};
}
if !observation.has_http2() {
return TransportRealismReport {
profile_name: profile.name.clone(),
compatibility: TransportCompatibility {
checks: Vec::new(),
score: super::DEFAULT_SCORE_WHEN_HTTP2_UNAVAILABLE,
confidence: super::DEFAULT_CONFIDENCE_WHEN_HTTP2_UNAVAILABLE,
coverage: super::DEFAULT_COVERAGE_WHEN_HTTP2_UNAVAILABLE,
matched_count: 0,
total_checks,
mismatches: vec!["http2_observations_unavailable".to_string()],
},
};
}
score_with_observations(profile, observation, total_checks)
}
fn score_with_observations(
profile: &TransportProfile,
observation: &TransportObservation,
total_checks: usize,
) -> TransportRealismReport {
let mut state = ScoringState::default();
if profile.expectations.contains(TransportProfile::SETTINGS) {
score_settings_check(profile, observation, &mut state);
}
if profile
.expectations
.contains(TransportProfile::PSEUDO_HEADER_ORDER)
{
score_pseudo_header_check(profile, observation, &mut state);
}
if profile
.expectations
.contains(TransportProfile::HEADER_ORDER)
{
score_header_order_check(profile, observation, &mut state);
}
state.finalize(profile, total_checks)
}
#[derive(Default)]
struct ScoringState {
checks: Vec<Http2CheckResult>,
mismatches: Vec<String>,
observed_count: usize,
matched_count: usize,
sum_scores: f64,
sum_weights: f64,
}
impl ScoringState {
fn record(&mut self, check: Http2CheckResult, weight: f64, observed: bool, matched: bool) {
if observed {
self.observed_count += 1;
}
if matched {
self.matched_count += 1;
}
self.sum_scores = weight.mul_add(check.score, self.sum_scores);
self.sum_weights += weight;
self.checks.push(check);
}
fn push_mismatch(&mut self, description: String) {
self.mismatches.push(description);
}
fn finalize(self, profile: &TransportProfile, total_checks: usize) -> TransportRealismReport {
let observed_count = self.observed_count;
let final_score = if self.sum_weights > 0.0 {
(self.sum_scores / self.sum_weights).clamp(0.0, 1.0)
} else {
0.0
};
#[allow(clippy::cast_precision_loss)]
let coverage = if total_checks == 0 {
1.0
} else {
observed_count as f64 / total_checks as f64
};
let confidence = coverage.clamp(0.0, 1.0);
let mut mismatches = self.mismatches;
if profile.require_http2_observations && observed_count < total_checks {
mismatches.push("require_http2_observations_unmet".to_string());
}
TransportRealismReport {
profile_name: profile.name.clone(),
compatibility: TransportCompatibility {
checks: self.checks,
score: round4(final_score),
confidence: round4(confidence),
coverage: round4(coverage),
matched_count: self.matched_count,
total_checks,
mismatches,
},
}
}
}
fn score_settings_check(
profile: &TransportProfile,
observation: &TransportObservation,
state: &mut ScoringState,
) {
let (matched, check_score, observed_count_opt, position_ratio) =
score_http2_settings(profile, observation);
let observed = observed_count_opt.is_some();
if !matched {
state.push_mismatch(format!(
"{}: fingerprint mismatch",
Http2CheckKind::Settings.as_str()
));
}
state.record(
Http2CheckResult {
kind: Http2CheckKind::Settings,
matched,
score: check_score,
observed_count: observed_count_opt.unwrap_or(0),
expected_count: profile.expected_http2_settings.len(),
position_match_ratio: position_ratio,
},
0.5,
observed,
matched,
);
}
fn score_pseudo_header_check(
profile: &TransportProfile,
observation: &TransportObservation,
state: &mut ScoringState,
) {
let Some(observed) = observation.http2_pseudo_header_order.as_deref() else {
state.push_mismatch(format!(
"{}: observation missing",
Http2CheckKind::PseudoHeaderOrder.as_str()
));
state.record(
Http2CheckResult {
kind: Http2CheckKind::PseudoHeaderOrder,
matched: false,
score: 0.0,
observed_count: 0,
expected_count: profile.expected_pseudo_header_order.len(),
position_match_ratio: 0.0,
},
0.2,
false,
false,
);
return;
};
let m = compare_header_order(
&profile
.expected_pseudo_header_order
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
observed,
);
let matched = m.matched_positions == m.reference_length && m.reference_length > 0;
let position_ratio = m.position_match_ratio();
if !matched {
state.push_mismatch(format!(
"{}: order mismatch ({}/{} matched)",
Http2CheckKind::PseudoHeaderOrder.as_str(),
m.matched_positions,
m.reference_length
));
}
state.record(
Http2CheckResult {
kind: Http2CheckKind::PseudoHeaderOrder,
matched,
score: position_ratio,
observed_count: m.observed_length,
expected_count: m.reference_length,
position_match_ratio: position_ratio,
},
0.2,
true,
matched,
);
}
fn score_header_order_check(
profile: &TransportProfile,
observation: &TransportObservation,
state: &mut ScoringState,
) {
let Some(observed) = observation.http2_header_order.as_deref() else {
state.push_mismatch(format!(
"{}: observation missing",
Http2CheckKind::HeaderOrder.as_str()
));
state.record(
Http2CheckResult {
kind: Http2CheckKind::HeaderOrder,
matched: false,
score: 0.0,
observed_count: 0,
expected_count: profile.expected_header_order.len(),
position_match_ratio: 0.0,
},
0.3,
false,
false,
);
return;
};
let m = compare_header_order(
&profile
.expected_header_order
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
observed,
);
let matched = m.matched_positions == m.reference_length && m.reference_length > 0;
let position_ratio = m.position_match_ratio();
if !matched {
state.push_mismatch(format!(
"{}: order mismatch ({}/{} matched)",
Http2CheckKind::HeaderOrder.as_str(),
m.matched_positions,
m.reference_length
));
}
state.record(
Http2CheckResult {
kind: Http2CheckKind::HeaderOrder,
matched,
score: position_ratio,
observed_count: m.observed_length,
expected_count: m.reference_length,
position_match_ratio: position_ratio,
},
0.3,
true,
matched,
);
}
fn score_http2_settings(
profile: &TransportProfile,
observation: &TransportObservation,
) -> (bool, f64, Option<usize>, f64) {
let Some(observed) = observation.http2_settings.as_deref() else {
return (false, 0.0, None, 0.0);
};
let (matched, issues) = compare_http2_settings(&profile.expected_http2_settings, observed);
let position_ratio = if profile.expected_http2_settings.is_empty() {
1.0
} else {
let expected_ids: std::collections::HashSet<u32> = profile
.expected_http2_settings
.iter()
.map(|(id, _)| *id)
.collect();
let observed_ids: std::collections::HashSet<u32> =
observed.iter().map(|(id, _)| *id).collect();
let intersection = expected_ids.intersection(&observed_ids).count();
#[allow(clippy::cast_precision_loss)]
let ratio = intersection as f64 / expected_ids.len() as f64;
ratio
};
let score = if matched {
1.0
} else {
#[allow(clippy::cast_precision_loss)]
let discount = (issues.len() as f64) * 0.1;
(1.0 - discount).max(0.0)
};
(
matched,
round4(score),
Some(observed.len()),
round4(position_ratio),
)
}
fn round4(v: f64) -> f64 {
(v * 10_000.0).round() / 10_000.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tls_validation::{CHROME_136_HTTP2_SETTINGS, CHROME_136_JA4};
use crate::transport_realism::observations::{
HEADER_ORDER_CHROME_136, PSEUDO_HEADER_ORDER_CHROME_136,
};
fn chrome_obs() -> TransportObservation {
TransportObservation::chrome_136_reference()
}
fn approx_eq(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn chrome_136_observation_against_chrome_136_profile_scores_high() {
let profile = TransportProfile::default();
let report = score(&profile, &chrome_obs());
assert!(
report.compatibility.score > 0.95,
"chrome 136 reference vs chrome 136 observation should be a strong match, got: {}",
report.compatibility.score
);
assert_eq!(report.compatibility.matched_count, 3);
assert_eq!(report.compatibility.total_checks, 3);
assert!(report.compatibility.mismatches.is_empty());
assert!(report.compatibility.is_high_confidence());
assert!(report.compatibility.is_well_covered());
assert!(report.is_strong_match());
}
#[test]
fn mismatched_settings_score_below_one() {
let profile = TransportProfile::default();
let observed = TransportObservation {
http2_settings: Some(vec![(1, 1), (2, 0)]),
..chrome_obs()
};
let report = score(&profile, &observed);
assert!(
report.compatibility.score < 1.0,
"settings mismatch must reduce score, got: {}",
report.compatibility.score
);
assert!(
report
.compatibility
.mismatches
.iter()
.any(|m| m.contains(Http2CheckKind::Settings.as_str())),
"settings mismatch must be reported, got: {:?}",
report.compatibility.mismatches
);
}
#[test]
fn mismatched_header_order_reduces_score() {
let profile = TransportProfile::default();
let observed = TransportObservation {
http2_header_order: Some(vec!["host".into(), "accept".into()]),
..chrome_obs()
};
let report = score(&profile, &observed);
assert!(report.compatibility.score < 1.0);
assert!(
report
.compatibility
.mismatches
.iter()
.any(|m| m.contains(Http2CheckKind::HeaderOrder.as_str())),
"header order mismatch must be reported"
);
}
#[test]
fn missing_http2_observations_uses_known_default_markers() {
let profile = TransportProfile::default();
let report = score(&profile, &TransportObservation::default());
assert!(
approx_eq(
report.compatibility.score,
super::super::DEFAULT_SCORE_WHEN_HTTP2_UNAVAILABLE
),
"score default mismatch, got: {}",
report.compatibility.score
);
assert!(
approx_eq(
report.compatibility.confidence,
super::super::DEFAULT_CONFIDENCE_WHEN_HTTP2_UNAVAILABLE
),
"confidence default mismatch, got: {}",
report.compatibility.confidence
);
assert!(
approx_eq(
report.compatibility.coverage,
super::super::DEFAULT_COVERAGE_WHEN_HTTP2_UNAVAILABLE
),
"coverage default mismatch, got: {}",
report.compatibility.coverage
);
assert!(
report
.compatibility
.mismatches
.iter()
.any(|m| m == "http2_observations_unavailable"),
"missing observations must emit the deterministic mismatch tag, got: {:?}",
report.compatibility.mismatches
);
assert!(!report.is_strong_match());
}
#[test]
fn require_http2_observations_surfaces_partial_observation() {
let profile = TransportProfile::default().with_require_http2_observations(true);
let observed = TransportObservation {
http2_settings: Some(CHROME_136_HTTP2_SETTINGS.to_vec()),
..TransportObservation::default()
};
let report = score(&profile, &observed);
assert!(
report
.compatibility
.mismatches
.iter()
.any(|m| m == "require_http2_observations_unmet"),
"partial observation must surface unmet requirement, got: {:?}",
report.compatibility.mismatches
);
assert!(report.compatibility.coverage < 1.0);
}
#[test]
fn profile_with_no_expectations_reports_perfect_score() {
let profile = TransportProfile::default().with_expectation_bits(0);
let report = score(&profile, &TransportObservation::default());
assert!(approx_eq(report.compatibility.score, 1.0));
assert!(approx_eq(report.compatibility.confidence, 1.0));
assert!(approx_eq(report.compatibility.coverage, 1.0));
assert_eq!(report.compatibility.total_checks, 0);
}
#[test]
fn profile_name_is_propagated_to_report() {
let profile = TransportProfile::default().with_name("firefox-130");
let report = score(&profile, &chrome_obs());
assert_eq!(report.profile_name, "firefox-130");
}
#[test]
fn references_used_in_tests_are_stable() {
assert!(CHROME_136_JA4.starts_with('t'));
assert!(CHROME_136_HTTP2_SETTINGS.iter().any(|(id, _)| *id == 4));
assert!(HEADER_ORDER_CHROME_136.contains(&"host"));
assert!(PSEUDO_HEADER_ORDER_CHROME_136.contains(&":method"));
}
#[test]
fn per_check_kind_results_carry_kind_label() {
let profile = TransportProfile::default();
let report = score(&profile, &chrome_obs());
for result in &report.compatibility.checks {
assert!(!result.kind.as_str().is_empty());
}
}
}