1use serde::{Deserialize, Serialize};
14
15use crate::tls_validation::compare_http2_settings;
16
17use super::observations::{TransportObservation, compare_header_order};
18use super::profile::TransportProfile;
19
20pub const HTTP2_CHECK_KIND_COUNT: usize = 3;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum Http2CheckKind {
34 Settings,
36 PseudoHeaderOrder,
38 HeaderOrder,
40}
41
42impl Http2CheckKind {
43 #[must_use]
45 pub const fn as_str(self) -> &'static str {
46 match self {
47 Self::Settings => "http2_settings",
48 Self::PseudoHeaderOrder => "http2_pseudo_header_order",
49 Self::HeaderOrder => "http2_header_order",
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct Http2CheckResult {
57 pub kind: Http2CheckKind,
59 pub matched: bool,
61 pub score: f64,
64 pub observed_count: usize,
67 pub expected_count: usize,
69 pub position_match_ratio: f64,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct TransportCompatibility {
90 pub checks: Vec<Http2CheckResult>,
92 pub score: f64,
94 pub confidence: f64,
99 pub coverage: f64,
103 pub matched_count: usize,
105 pub total_checks: usize,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub mismatches: Vec<String>,
111}
112
113impl TransportCompatibility {
114 #[must_use]
117 pub fn is_well_covered(&self) -> bool {
118 self.coverage >= 0.5
119 }
120
121 #[must_use]
124 pub fn is_high_confidence(&self) -> bool {
125 self.confidence >= 0.5
126 }
127
128 #[must_use]
130 pub fn is_strong_match(&self) -> bool {
131 self.score >= 0.95
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct TransportRealismReport {
145 pub profile_name: String,
147 pub compatibility: TransportCompatibility,
149}
150
151impl TransportRealismReport {
152 #[must_use]
155 pub fn is_strong_match(&self) -> bool {
156 self.compatibility.is_strong_match()
157 }
158
159 #[must_use]
162 pub fn is_high_confidence(&self) -> bool {
163 self.compatibility.is_high_confidence()
164 }
165}
166
167#[must_use]
195pub fn score(
196 profile: &TransportProfile,
197 observation: &TransportObservation,
198) -> TransportRealismReport {
199 let total_checks = profile.expected_http2_check_count();
200
201 if total_checks == 0 {
205 return TransportRealismReport {
206 profile_name: profile.name.clone(),
207 compatibility: TransportCompatibility {
208 checks: Vec::new(),
209 score: 1.0,
210 confidence: 1.0,
211 coverage: 1.0,
212 matched_count: 0,
213 total_checks: 0,
214 mismatches: Vec::new(),
215 },
216 };
217 }
218
219 if !observation.has_http2() {
222 return TransportRealismReport {
223 profile_name: profile.name.clone(),
224 compatibility: TransportCompatibility {
225 checks: Vec::new(),
226 score: super::DEFAULT_SCORE_WHEN_HTTP2_UNAVAILABLE,
227 confidence: super::DEFAULT_CONFIDENCE_WHEN_HTTP2_UNAVAILABLE,
228 coverage: super::DEFAULT_COVERAGE_WHEN_HTTP2_UNAVAILABLE,
229 matched_count: 0,
230 total_checks,
231 mismatches: vec!["http2_observations_unavailable".to_string()],
232 },
233 };
234 }
235
236 score_with_observations(profile, observation, total_checks)
237}
238
239fn score_with_observations(
242 profile: &TransportProfile,
243 observation: &TransportObservation,
244 total_checks: usize,
245) -> TransportRealismReport {
246 let mut state = ScoringState::default();
247
248 if profile.expectations.contains(TransportProfile::SETTINGS) {
249 score_settings_check(profile, observation, &mut state);
250 }
251 if profile
252 .expectations
253 .contains(TransportProfile::PSEUDO_HEADER_ORDER)
254 {
255 score_pseudo_header_check(profile, observation, &mut state);
256 }
257 if profile
258 .expectations
259 .contains(TransportProfile::HEADER_ORDER)
260 {
261 score_header_order_check(profile, observation, &mut state);
262 }
263
264 state.finalize(profile, total_checks)
265}
266
267#[derive(Default)]
270struct ScoringState {
271 checks: Vec<Http2CheckResult>,
272 mismatches: Vec<String>,
273 observed_count: usize,
274 matched_count: usize,
275 sum_scores: f64,
276 sum_weights: f64,
277}
278
279impl ScoringState {
280 fn record(&mut self, check: Http2CheckResult, weight: f64, observed: bool, matched: bool) {
282 if observed {
283 self.observed_count += 1;
284 }
285 if matched {
286 self.matched_count += 1;
287 }
288 self.sum_scores = weight.mul_add(check.score, self.sum_scores);
289 self.sum_weights += weight;
290 self.checks.push(check);
291 }
292
293 fn push_mismatch(&mut self, description: String) {
295 self.mismatches.push(description);
296 }
297
298 fn finalize(self, profile: &TransportProfile, total_checks: usize) -> TransportRealismReport {
300 let observed_count = self.observed_count;
301 let final_score = if self.sum_weights > 0.0 {
302 (self.sum_scores / self.sum_weights).clamp(0.0, 1.0)
303 } else {
304 0.0
305 };
306 #[allow(clippy::cast_precision_loss)]
307 let coverage = if total_checks == 0 {
308 1.0
309 } else {
310 observed_count as f64 / total_checks as f64
311 };
312 let confidence = coverage.clamp(0.0, 1.0);
313
314 let mut mismatches = self.mismatches;
315 if profile.require_http2_observations && observed_count < total_checks {
316 mismatches.push("require_http2_observations_unmet".to_string());
317 }
318
319 TransportRealismReport {
320 profile_name: profile.name.clone(),
321 compatibility: TransportCompatibility {
322 checks: self.checks,
323 score: round4(final_score),
324 confidence: round4(confidence),
325 coverage: round4(coverage),
326 matched_count: self.matched_count,
327 total_checks,
328 mismatches,
329 },
330 }
331 }
332}
333
334fn score_settings_check(
336 profile: &TransportProfile,
337 observation: &TransportObservation,
338 state: &mut ScoringState,
339) {
340 let (matched, check_score, observed_count_opt, position_ratio) =
341 score_http2_settings(profile, observation);
342 let observed = observed_count_opt.is_some();
343 if !matched {
344 state.push_mismatch(format!(
345 "{}: fingerprint mismatch",
346 Http2CheckKind::Settings.as_str()
347 ));
348 }
349 state.record(
350 Http2CheckResult {
351 kind: Http2CheckKind::Settings,
352 matched,
353 score: check_score,
354 observed_count: observed_count_opt.unwrap_or(0),
355 expected_count: profile.expected_http2_settings.len(),
356 position_match_ratio: position_ratio,
357 },
358 0.5,
359 observed,
360 matched,
361 );
362}
363
364fn score_pseudo_header_check(
366 profile: &TransportProfile,
367 observation: &TransportObservation,
368 state: &mut ScoringState,
369) {
370 let Some(observed) = observation.http2_pseudo_header_order.as_deref() else {
371 state.push_mismatch(format!(
372 "{}: observation missing",
373 Http2CheckKind::PseudoHeaderOrder.as_str()
374 ));
375 state.record(
376 Http2CheckResult {
377 kind: Http2CheckKind::PseudoHeaderOrder,
378 matched: false,
379 score: 0.0,
380 observed_count: 0,
381 expected_count: profile.expected_pseudo_header_order.len(),
382 position_match_ratio: 0.0,
383 },
384 0.2,
385 false,
386 false,
387 );
388 return;
389 };
390 let m = compare_header_order(
391 &profile
392 .expected_pseudo_header_order
393 .iter()
394 .map(String::as_str)
395 .collect::<Vec<_>>(),
396 observed,
397 );
398 let matched = m.matched_positions == m.reference_length && m.reference_length > 0;
399 let position_ratio = m.position_match_ratio();
400 if !matched {
401 state.push_mismatch(format!(
402 "{}: order mismatch ({}/{} matched)",
403 Http2CheckKind::PseudoHeaderOrder.as_str(),
404 m.matched_positions,
405 m.reference_length
406 ));
407 }
408 state.record(
409 Http2CheckResult {
410 kind: Http2CheckKind::PseudoHeaderOrder,
411 matched,
412 score: position_ratio,
413 observed_count: m.observed_length,
414 expected_count: m.reference_length,
415 position_match_ratio: position_ratio,
416 },
417 0.2,
418 true,
419 matched,
420 );
421}
422
423fn score_header_order_check(
425 profile: &TransportProfile,
426 observation: &TransportObservation,
427 state: &mut ScoringState,
428) {
429 let Some(observed) = observation.http2_header_order.as_deref() else {
430 state.push_mismatch(format!(
431 "{}: observation missing",
432 Http2CheckKind::HeaderOrder.as_str()
433 ));
434 state.record(
435 Http2CheckResult {
436 kind: Http2CheckKind::HeaderOrder,
437 matched: false,
438 score: 0.0,
439 observed_count: 0,
440 expected_count: profile.expected_header_order.len(),
441 position_match_ratio: 0.0,
442 },
443 0.3,
444 false,
445 false,
446 );
447 return;
448 };
449 let m = compare_header_order(
450 &profile
451 .expected_header_order
452 .iter()
453 .map(String::as_str)
454 .collect::<Vec<_>>(),
455 observed,
456 );
457 let matched = m.matched_positions == m.reference_length && m.reference_length > 0;
458 let position_ratio = m.position_match_ratio();
459 if !matched {
460 state.push_mismatch(format!(
461 "{}: order mismatch ({}/{} matched)",
462 Http2CheckKind::HeaderOrder.as_str(),
463 m.matched_positions,
464 m.reference_length
465 ));
466 }
467 state.record(
468 Http2CheckResult {
469 kind: Http2CheckKind::HeaderOrder,
470 matched,
471 score: position_ratio,
472 observed_count: m.observed_length,
473 expected_count: m.reference_length,
474 position_match_ratio: position_ratio,
475 },
476 0.3,
477 true,
478 matched,
479 );
480}
481
482fn score_http2_settings(
491 profile: &TransportProfile,
492 observation: &TransportObservation,
493) -> (bool, f64, Option<usize>, f64) {
494 let Some(observed) = observation.http2_settings.as_deref() else {
495 return (false, 0.0, None, 0.0);
496 };
497 let (matched, issues) = compare_http2_settings(&profile.expected_http2_settings, observed);
498 let position_ratio = if profile.expected_http2_settings.is_empty() {
499 1.0
500 } else {
501 let expected_ids: std::collections::HashSet<u32> = profile
502 .expected_http2_settings
503 .iter()
504 .map(|(id, _)| *id)
505 .collect();
506 let observed_ids: std::collections::HashSet<u32> =
507 observed.iter().map(|(id, _)| *id).collect();
508 let intersection = expected_ids.intersection(&observed_ids).count();
509 #[allow(clippy::cast_precision_loss)]
510 let ratio = intersection as f64 / expected_ids.len() as f64;
511 ratio
512 };
513 let score = if matched {
514 1.0
515 } else {
516 #[allow(clippy::cast_precision_loss)]
517 let discount = (issues.len() as f64) * 0.1;
518 (1.0 - discount).max(0.0)
519 };
520 (
521 matched,
522 round4(score),
523 Some(observed.len()),
524 round4(position_ratio),
525 )
526}
527
528fn round4(v: f64) -> f64 {
532 (v * 10_000.0).round() / 10_000.0
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::tls_validation::{CHROME_136_HTTP2_SETTINGS, CHROME_136_JA4};
539 use crate::transport_realism::observations::{
540 HEADER_ORDER_CHROME_136, PSEUDO_HEADER_ORDER_CHROME_136,
541 };
542
543 fn chrome_obs() -> TransportObservation {
544 TransportObservation::chrome_136_reference()
545 }
546
547 fn approx_eq(a: f64, b: f64) -> bool {
548 (a - b).abs() < 1e-9
549 }
550
551 #[test]
552 fn chrome_136_observation_against_chrome_136_profile_scores_high() {
553 let profile = TransportProfile::default();
554 let report = score(&profile, &chrome_obs());
555 assert!(
556 report.compatibility.score > 0.95,
557 "chrome 136 reference vs chrome 136 observation should be a strong match, got: {}",
558 report.compatibility.score
559 );
560 assert_eq!(report.compatibility.matched_count, 3);
561 assert_eq!(report.compatibility.total_checks, 3);
562 assert!(report.compatibility.mismatches.is_empty());
563 assert!(report.compatibility.is_high_confidence());
564 assert!(report.compatibility.is_well_covered());
565 assert!(report.is_strong_match());
566 }
567
568 #[test]
569 fn mismatched_settings_score_below_one() {
570 let profile = TransportProfile::default();
571 let observed = TransportObservation {
572 http2_settings: Some(vec![(1, 1), (2, 0)]),
573 ..chrome_obs()
574 };
575 let report = score(&profile, &observed);
576 assert!(
577 report.compatibility.score < 1.0,
578 "settings mismatch must reduce score, got: {}",
579 report.compatibility.score
580 );
581 assert!(
582 report
583 .compatibility
584 .mismatches
585 .iter()
586 .any(|m| m.contains(Http2CheckKind::Settings.as_str())),
587 "settings mismatch must be reported, got: {:?}",
588 report.compatibility.mismatches
589 );
590 }
591
592 #[test]
593 fn mismatched_header_order_reduces_score() {
594 let profile = TransportProfile::default();
595 let observed = TransportObservation {
596 http2_header_order: Some(vec!["host".into(), "accept".into()]),
597 ..chrome_obs()
598 };
599 let report = score(&profile, &observed);
600 assert!(report.compatibility.score < 1.0);
601 assert!(
602 report
603 .compatibility
604 .mismatches
605 .iter()
606 .any(|m| m.contains(Http2CheckKind::HeaderOrder.as_str())),
607 "header order mismatch must be reported"
608 );
609 }
610
611 #[test]
612 fn missing_http2_observations_uses_known_default_markers() {
613 let profile = TransportProfile::default();
614 let report = score(&profile, &TransportObservation::default());
615 assert!(
616 approx_eq(
617 report.compatibility.score,
618 super::super::DEFAULT_SCORE_WHEN_HTTP2_UNAVAILABLE
619 ),
620 "score default mismatch, got: {}",
621 report.compatibility.score
622 );
623 assert!(
624 approx_eq(
625 report.compatibility.confidence,
626 super::super::DEFAULT_CONFIDENCE_WHEN_HTTP2_UNAVAILABLE
627 ),
628 "confidence default mismatch, got: {}",
629 report.compatibility.confidence
630 );
631 assert!(
632 approx_eq(
633 report.compatibility.coverage,
634 super::super::DEFAULT_COVERAGE_WHEN_HTTP2_UNAVAILABLE
635 ),
636 "coverage default mismatch, got: {}",
637 report.compatibility.coverage
638 );
639 assert!(
640 report
641 .compatibility
642 .mismatches
643 .iter()
644 .any(|m| m == "http2_observations_unavailable"),
645 "missing observations must emit the deterministic mismatch tag, got: {:?}",
646 report.compatibility.mismatches
647 );
648 assert!(!report.is_strong_match());
649 }
650
651 #[test]
652 fn require_http2_observations_surfaces_partial_observation() {
653 let profile = TransportProfile::default().with_require_http2_observations(true);
654 let observed = TransportObservation {
655 http2_settings: Some(CHROME_136_HTTP2_SETTINGS.to_vec()),
656 ..TransportObservation::default()
657 };
658 let report = score(&profile, &observed);
659 assert!(
660 report
661 .compatibility
662 .mismatches
663 .iter()
664 .any(|m| m == "require_http2_observations_unmet"),
665 "partial observation must surface unmet requirement, got: {:?}",
666 report.compatibility.mismatches
667 );
668 assert!(report.compatibility.coverage < 1.0);
669 }
670
671 #[test]
672 fn profile_with_no_expectations_reports_perfect_score() {
673 let profile = TransportProfile::default().with_expectation_bits(0);
674 let report = score(&profile, &TransportObservation::default());
675 assert!(approx_eq(report.compatibility.score, 1.0));
676 assert!(approx_eq(report.compatibility.confidence, 1.0));
677 assert!(approx_eq(report.compatibility.coverage, 1.0));
678 assert_eq!(report.compatibility.total_checks, 0);
679 }
680
681 #[test]
682 fn profile_name_is_propagated_to_report() {
683 let profile = TransportProfile::default().with_name("firefox-130");
684 let report = score(&profile, &chrome_obs());
685 assert_eq!(report.profile_name, "firefox-130");
686 }
687
688 #[test]
689 fn references_used_in_tests_are_stable() {
690 assert!(CHROME_136_JA4.starts_with('t'));
693 assert!(CHROME_136_HTTP2_SETTINGS.iter().any(|(id, _)| *id == 4));
694 assert!(HEADER_ORDER_CHROME_136.contains(&"host"));
695 assert!(PSEUDO_HEADER_ORDER_CHROME_136.contains(&":method"));
696 }
697
698 #[test]
699 fn per_check_kind_results_carry_kind_label() {
700 let profile = TransportProfile::default();
701 let report = score(&profile, &chrome_obs());
702 for result in &report.compatibility.checks {
703 assert!(!result.kind.as_str().is_empty());
704 }
705 }
706}