1use serde::{Deserialize, Serialize};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum CheckId {
44 WebDriverFlag,
46 ChromeObject,
48 PluginCount,
50 LanguagesPresent,
52 CanvasConsistency,
54 WebGlVendor,
56 AutomationGlobals,
58 OuterWindowSize,
60 HeadlessUserAgent,
62 NotificationPermission,
64 MatchMediaPresent,
66 ElementFromPointPresent,
68 RequestAnimationFramePresent,
70 GetComputedStylePresent,
72 CssSupportsPresent,
74 SendBeaconPresent,
76 ExecCommandPresent,
78 NodeJsAbsent,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct CheckResult {
87 pub id: CheckId,
89 pub description: String,
91 pub passed: bool,
93 pub details: String,
95}
96
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct TransportObservations {
100 pub ja3_hash: Option<String>,
102 pub ja4: Option<String>,
104 pub http3_perk_text: Option<String>,
106 pub http3_perk_hash: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct TransportDiagnostic {
113 pub user_agent: String,
115 pub expected_profile: Option<String>,
117 pub expected_ja3_raw: Option<String>,
119 pub expected_ja3_hash: Option<String>,
121 pub expected_ja4: Option<String>,
123 pub expected_http3_perk_text: Option<String>,
125 pub expected_http3_perk_hash: Option<String>,
127 pub observed: TransportObservations,
129 pub transport_match: Option<bool>,
132 pub mismatches: Vec<String>,
134}
135
136impl TransportDiagnostic {
137 #[must_use]
139 pub fn from_user_agent_and_observations(
140 user_agent: &str,
141 observed: Option<&TransportObservations>,
142 ) -> Self {
143 let observed = observed.cloned().unwrap_or_default();
144
145 let expected_profile = crate::tls::expected_tls_profile_from_user_agent(user_agent);
147 let expected_ja3 = expected_profile.map(crate::tls::TlsProfile::ja3);
148 let expected_ja4 = expected_profile.map(crate::tls::TlsProfile::ja4);
149 let expected_http3 = expected_profile.and_then(crate::tls::TlsProfile::http3_perk);
150
151 let mut mismatches = Vec::new();
152
153 if let (Some(expected), Some(observed_hash)) = (
154 expected_ja3.as_ref().map(|j| j.hash.as_str()),
155 observed.ja3_hash.as_deref(),
156 ) && !observed_hash.eq_ignore_ascii_case(expected)
157 {
158 mismatches.push(format!(
159 "ja3_hash mismatch: expected '{expected}', observed '{observed_hash}'"
160 ));
161 }
162
163 if let (Some(expected), Some(observed_ja4)) = (
164 expected_ja4.as_ref().map(|j| j.fingerprint.as_str()),
165 observed.ja4.as_deref(),
166 ) && observed_ja4 != expected
167 {
168 mismatches.push(format!(
169 "ja4 mismatch: expected '{expected}', observed '{observed_ja4}'"
170 ));
171 }
172
173 if let Some(expected) = expected_http3.as_ref() {
174 let cmp = expected.compare(
175 observed.http3_perk_text.as_deref(),
176 observed.http3_perk_hash.as_deref(),
177 );
178 mismatches.extend(cmp.mismatches);
179 }
180
181 if observed.ja3_hash.is_some() && expected_ja3.is_none() {
185 mismatches.push(
186 "ja3_hash was provided but no expected JA3 could be derived from user-agent"
187 .to_string(),
188 );
189 }
190 if observed.ja4.is_some() && expected_ja4.is_none() {
191 mismatches.push(
192 "ja4 was provided but no expected JA4 could be derived from user-agent".to_string(),
193 );
194 }
195 if (observed.http3_perk_text.is_some() || observed.http3_perk_hash.is_some())
196 && expected_http3.is_none()
197 {
198 mismatches.push(
199 "http3 perk observation was provided but no expected HTTP/3 fingerprint could be derived from user-agent"
200 .to_string(),
201 );
202 }
203
204 let has_observed = observed.ja3_hash.is_some()
205 || observed.ja4.is_some()
206 || observed.http3_perk_text.is_some()
207 || observed.http3_perk_hash.is_some();
208
209 Self {
210 user_agent: user_agent.to_string(),
211 expected_profile: expected_profile.map(|p| p.name.clone()),
212 expected_ja3_raw: expected_ja3.as_ref().map(|j| j.raw.clone()),
213 expected_ja3_hash: expected_ja3.as_ref().map(|j| j.hash.clone()),
214 expected_ja4: expected_ja4.as_ref().map(|j| j.fingerprint.clone()),
215 expected_http3_perk_text: expected_http3
216 .as_ref()
217 .map(crate::tls::Http3Perk::perk_text),
218 expected_http3_perk_hash: expected_http3
219 .as_ref()
220 .map(crate::tls::Http3Perk::perk_hash),
221 observed,
222 transport_match: has_observed.then_some(mismatches.is_empty()),
223 mismatches,
224 }
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct DiagnosticReport {
247 pub checks: Vec<CheckResult>,
249 pub passed_count: usize,
251 pub failed_count: usize,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub transport: Option<TransportDiagnostic>,
256}
257
258impl DiagnosticReport {
259 pub fn new(checks: Vec<CheckResult>) -> Self {
261 let passed_count = checks.iter().filter(|r| r.passed).count();
262 let failed_count = checks.len() - passed_count;
263 Self {
264 checks,
265 passed_count,
266 failed_count,
267 transport: None,
268 }
269 }
270
271 #[must_use]
273 pub fn with_transport(mut self, transport: TransportDiagnostic) -> Self {
274 self.transport = Some(transport);
275 self
276 }
277
278 #[must_use]
280 pub const fn is_clean(&self) -> bool {
281 self.failed_count == 0
282 }
283
284 #[allow(clippy::cast_precision_loss)]
286 pub fn coverage_pct(&self) -> f64 {
287 if self.checks.is_empty() {
288 return 0.0;
289 }
290 self.passed_count as f64 / self.checks.len() as f64 * 100.0
291 }
292
293 pub fn failures(&self) -> impl Iterator<Item = &CheckResult> {
295 self.checks.iter().filter(|r| !r.passed)
296 }
297}
298
299pub struct DetectionCheck {
304 pub id: CheckId,
306 pub description: &'static str,
308 pub script: &'static str,
314}
315
316impl DetectionCheck {
317 pub fn parse_output(&self, json: &str) -> CheckResult {
323 #[derive(Deserialize)]
324 struct Output {
325 passed: bool,
326 #[serde(default)]
327 details: String,
328 }
329
330 match serde_json::from_str::<Output>(json) {
331 Ok(o) => CheckResult {
332 id: self.id,
333 description: self.description.to_string(),
334 passed: o.passed,
335 details: o.details,
336 },
337 Err(e) => CheckResult {
338 id: self.id,
339 description: self.description.to_string(),
340 passed: false,
341 details: format!("parse error: {e} | raw: {json}"),
342 },
343 }
344 }
345}
346
347const SCRIPT_WEBDRIVER: &str = concat!(
350 "JSON.stringify({",
351 "passed:navigator.webdriver===false||navigator.webdriver===undefined,",
352 "details:String(navigator.webdriver)",
353 "})"
354);
355
356const SCRIPT_CHROME_OBJECT: &str = concat!(
357 "JSON.stringify({",
358 "passed:typeof window.chrome!=='undefined'&&window.chrome!==null",
359 "&&typeof window.chrome.runtime!=='undefined',",
360 "details:typeof window.chrome",
361 "})"
362);
363
364const SCRIPT_PLUGIN_COUNT: &str = concat!(
365 "JSON.stringify({",
366 "passed:navigator.plugins.length>0,",
367 "details:navigator.plugins.length+' plugins'",
368 "})"
369);
370
371const SCRIPT_LANGUAGES: &str = concat!(
372 "JSON.stringify({",
373 "passed:Array.isArray(navigator.languages)&&navigator.languages.length>0,",
374 "details:JSON.stringify(navigator.languages)",
375 "})"
376);
377
378const SCRIPT_CANVAS: &str = concat!(
379 "(function(){",
380 "var c=document.createElement('canvas');",
381 "c.width=200;c.height=50;",
382 "var ctx=c.getContext('2d');",
383 "ctx.fillStyle='#1a2b3c';ctx.fillRect(0,0,200,50);",
384 "ctx.font='16px Arial';ctx.fillStyle='#fafafa';",
385 "ctx.fillText('stygian-diag',10,30);",
386 "var d=c.toDataURL();",
387 "return JSON.stringify({passed:d.length>200,details:'len='+d.length});",
388 "})()"
389);
390
391const SCRIPT_WEBGL_VENDOR: &str = concat!(
392 "(function(){",
393 "var gl=document.createElement('canvas').getContext('webgl');",
394 "if(!gl)return JSON.stringify({passed:false,details:'webgl unavailable'});",
395 "var ext=gl.getExtension('WEBGL_debug_renderer_info');",
396 "if(!ext)return JSON.stringify({passed:true,details:'debug ext absent (normal)'});",
397 "var v=gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)||'';",
398 "var r=gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)||'';",
399 "var sw=v.includes('SwiftShader')||r.includes('SwiftShader');",
400 "return JSON.stringify({passed:!sw,details:v+'/'+r});",
401 "})()"
402);
403
404const SCRIPT_AUTOMATION_GLOBALS: &str = concat!(
405 "JSON.stringify({",
406 "passed:typeof window.__puppeteer__==='undefined'",
407 "&&typeof window.__playwright==='undefined'",
408 "&&typeof window.__webdriverFunc==='undefined'",
409 "&&typeof window._phantom==='undefined',",
410 "details:'automation globals checked'",
411 "})"
412);
413
414const SCRIPT_OUTER_WINDOW: &str = concat!(
415 "JSON.stringify({",
416 "passed:window.outerWidth>0&&window.outerHeight>0,",
417 "details:window.outerWidth+'x'+window.outerHeight",
418 "})"
419);
420
421const SCRIPT_HEADLESS_UA: &str = concat!(
422 "JSON.stringify({",
423 "passed:!navigator.userAgent.includes('HeadlessChrome'),",
424 "details:navigator.userAgent.substring(0,100)",
425 "})"
426);
427
428const SCRIPT_NOTIFICATION: &str = concat!(
429 "JSON.stringify({",
430 "passed:typeof Notification==='undefined'||Notification.permission!=='granted',",
431 "details:typeof Notification!=='undefined'?Notification.permission:'unavailable'",
432 "})"
433);
434
435const SCRIPT_MATCH_MEDIA: &str = concat!(
436 "JSON.stringify({",
437 "passed:typeof window.matchMedia==='function',",
438 "details:typeof window.matchMedia",
439 "})"
440);
441
442const SCRIPT_ELEMENT_FROM_POINT: &str = concat!(
443 "JSON.stringify({",
444 "passed:typeof document.elementFromPoint==='function',",
445 "details:typeof document.elementFromPoint",
446 "})"
447);
448
449const SCRIPT_RAF: &str = concat!(
450 "JSON.stringify({",
451 "passed:typeof window.requestAnimationFrame==='function',",
452 "details:typeof window.requestAnimationFrame",
453 "})"
454);
455
456const SCRIPT_GET_COMPUTED_STYLE: &str = concat!(
457 "JSON.stringify({",
458 "passed:typeof window.getComputedStyle==='function',",
459 "details:typeof window.getComputedStyle",
460 "})"
461);
462
463const SCRIPT_CSS_SUPPORTS: &str = concat!(
464 "JSON.stringify({",
465 "passed:typeof CSS!=='undefined'&&typeof CSS.supports==='function',",
466 "details:typeof CSS!=='undefined'?typeof CSS.supports:'undefined'",
467 "})"
468);
469
470const SCRIPT_SEND_BEACON: &str = concat!(
471 "JSON.stringify({",
472 "passed:typeof navigator.sendBeacon==='function',",
473 "details:typeof navigator.sendBeacon",
474 "})"
475);
476
477const SCRIPT_EXEC_COMMAND: &str = concat!(
478 "JSON.stringify({",
479 "passed:typeof document.execCommand==='function',",
480 "details:typeof document.execCommand",
481 "})"
482);
483
484const SCRIPT_NODEJS_ABSENT: &str = concat!(
485 "JSON.stringify({",
486 "passed:typeof process==='undefined'",
487 "||process.versions==null",
488 "||typeof process.versions.node==='undefined',",
489 "details:typeof process",
490 "})"
491);
492
493pub fn all_checks() -> &'static [DetectionCheck] {
500 CHECKS
501}
502
503static CHECKS: &[DetectionCheck] = &[
504 DetectionCheck {
505 id: CheckId::WebDriverFlag,
506 description: "navigator.webdriver must be false/undefined",
507 script: SCRIPT_WEBDRIVER,
508 },
509 DetectionCheck {
510 id: CheckId::ChromeObject,
511 description: "window.chrome.runtime must exist",
512 script: SCRIPT_CHROME_OBJECT,
513 },
514 DetectionCheck {
515 id: CheckId::PluginCount,
516 description: "navigator.plugins must be non-empty",
517 script: SCRIPT_PLUGIN_COUNT,
518 },
519 DetectionCheck {
520 id: CheckId::LanguagesPresent,
521 description: "navigator.languages must be non-empty",
522 script: SCRIPT_LANGUAGES,
523 },
524 DetectionCheck {
525 id: CheckId::CanvasConsistency,
526 description: "canvas toDataURL must return non-trivial image data",
527 script: SCRIPT_CANVAS,
528 },
529 DetectionCheck {
530 id: CheckId::WebGlVendor,
531 description: "WebGL vendor must not be SwiftShader (software renderer)",
532 script: SCRIPT_WEBGL_VENDOR,
533 },
534 DetectionCheck {
535 id: CheckId::AutomationGlobals,
536 description: "automation globals (Puppeteer/Playwright) must be absent",
537 script: SCRIPT_AUTOMATION_GLOBALS,
538 },
539 DetectionCheck {
540 id: CheckId::OuterWindowSize,
541 description: "window.outerWidth/outerHeight must be non-zero",
542 script: SCRIPT_OUTER_WINDOW,
543 },
544 DetectionCheck {
545 id: CheckId::HeadlessUserAgent,
546 description: "User-Agent must not contain 'HeadlessChrome'",
547 script: SCRIPT_HEADLESS_UA,
548 },
549 DetectionCheck {
550 id: CheckId::NotificationPermission,
551 description: "Notification.permission must not be pre-granted",
552 script: SCRIPT_NOTIFICATION,
553 },
554 DetectionCheck {
555 id: CheckId::MatchMediaPresent,
556 description: "window.matchMedia must be a function (PX env-bitmask bit 0)",
557 script: SCRIPT_MATCH_MEDIA,
558 },
559 DetectionCheck {
560 id: CheckId::ElementFromPointPresent,
561 description: "document.elementFromPoint must be a function (PX env-bitmask bit 1)",
562 script: SCRIPT_ELEMENT_FROM_POINT,
563 },
564 DetectionCheck {
565 id: CheckId::RequestAnimationFramePresent,
566 description: "window.requestAnimationFrame must be a function (PX env-bitmask bit 2)",
567 script: SCRIPT_RAF,
568 },
569 DetectionCheck {
570 id: CheckId::GetComputedStylePresent,
571 description: "window.getComputedStyle must be a function (PX env-bitmask bit 3)",
572 script: SCRIPT_GET_COMPUTED_STYLE,
573 },
574 DetectionCheck {
575 id: CheckId::CssSupportsPresent,
576 description: "CSS.supports must exist and be callable (PX env-bitmask bit 4)",
577 script: SCRIPT_CSS_SUPPORTS,
578 },
579 DetectionCheck {
580 id: CheckId::SendBeaconPresent,
581 description: "navigator.sendBeacon must be a function (PX env-bitmask bit 5)",
582 script: SCRIPT_SEND_BEACON,
583 },
584 DetectionCheck {
585 id: CheckId::ExecCommandPresent,
586 description: "document.execCommand must be a function (PX env-bitmask bit 6)",
587 script: SCRIPT_EXEC_COMMAND,
588 },
589 DetectionCheck {
590 id: CheckId::NodeJsAbsent,
591 description: "process.versions.node must be absent — not a Node.js environment (PX env-bitmask bit 7)",
592 script: SCRIPT_NODEJS_ABSENT,
593 },
594];
595
596#[cfg(test)]
599#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
600mod tests {
601 use super::*;
602 use std::collections::HashSet;
603
604 #[test]
605 fn all_checks_returns_eighteen_entries() {
606 assert_eq!(all_checks().len(), 18);
607 }
608
609 #[test]
610 fn all_checks_have_unique_ids() {
611 let ids: HashSet<_> = all_checks().iter().map(|c| c.id).collect();
612 assert_eq!(
613 ids.len(),
614 all_checks().len(),
615 "duplicate check ids detected"
616 );
617 }
618
619 #[test]
620 fn all_checks_have_non_empty_scripts_with_json_stringify() {
621 for check in all_checks() {
622 assert!(
623 !check.script.is_empty(),
624 "check {:?} has empty script",
625 check.id
626 );
627 assert!(
628 check.script.contains("JSON.stringify"),
629 "check {:?} script must produce a JSON string",
630 check.id
631 );
632 }
633 }
634
635 #[test]
636 fn parse_output_valid_passing_json() {
637 let check = &all_checks()[0]; let result = check.parse_output(r#"{"passed":true,"details":"undefined"}"#);
639 assert!(result.passed);
640 assert_eq!(result.id, CheckId::WebDriverFlag);
641 assert_eq!(result.details, "undefined");
642 }
643
644 #[test]
645 fn parse_output_valid_failing_json() {
646 let check = &all_checks()[0];
647 let result = check.parse_output(r#"{"passed":false,"details":"true"}"#);
648 assert!(!result.passed);
649 }
650
651 #[test]
652 fn parse_output_invalid_json_returns_fail_with_details() {
653 let check = &all_checks()[0];
654 let result = check.parse_output("not json at all");
655 assert!(!result.passed);
656 assert!(result.details.contains("parse error"));
657 }
658
659 #[test]
660 fn parse_output_preserves_check_id() {
661 let check = all_checks()
662 .iter()
663 .find(|c| c.id == CheckId::ChromeObject)
664 .unwrap();
665 let result = check.parse_output(r#"{"passed":true,"details":"object"}"#);
666 assert_eq!(result.id, CheckId::ChromeObject);
667 assert_eq!(result.description, check.description);
668 }
669
670 #[test]
671 fn parse_output_missing_details_defaults_to_empty() {
672 let check = &all_checks()[0];
673 let result = check.parse_output(r#"{"passed":true}"#);
674 assert!(result.passed);
675 assert!(result.details.is_empty());
676 }
677
678 #[test]
679 fn diagnostic_report_all_passing() {
680 let results: Vec<CheckResult> = all_checks()
681 .iter()
682 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
683 .collect();
684 let report = DiagnosticReport::new(results);
685 assert!(report.is_clean());
686 assert_eq!(report.passed_count, 18);
687 assert_eq!(report.failed_count, 0);
688 assert!((report.coverage_pct() - 100.0).abs() < 0.001);
689 assert_eq!(report.failures().count(), 0);
690 }
691
692 #[test]
693 fn diagnostic_report_some_failing() {
694 let mut results: Vec<CheckResult> = all_checks()
695 .iter()
696 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
697 .collect();
698 results[0].passed = false;
699 results[2].passed = false;
700 let report = DiagnosticReport::new(results);
701 assert!(!report.is_clean());
702 assert_eq!(report.failed_count, 2);
703 assert_eq!(report.passed_count, 16);
704 assert_eq!(report.failures().count(), 2);
705 }
706
707 #[test]
708 fn diagnostic_report_empty_checks() {
709 let report = DiagnosticReport::new(Vec::new());
710 assert!(report.is_clean()); assert!((report.coverage_pct()).abs() < 0.001);
712 }
713
714 #[test]
715 fn check_result_serializes_with_snake_case_id() {
716 let result = CheckResult {
717 id: CheckId::WebDriverFlag,
718 description: "test".to_string(),
719 passed: true,
720 details: "ok".to_string(),
721 };
722 let json = serde_json::to_string(&result).unwrap();
723 assert!(json.contains("\"web_driver_flag\""), "got: {json}");
724 assert!(json.contains("\"passed\":true"));
725 }
726
727 #[test]
728 fn diagnostic_report_serializes_and_deserializes() {
729 let results: Vec<CheckResult> = all_checks()
730 .iter()
731 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
732 .collect();
733 let report = DiagnosticReport::new(results);
734 let json = serde_json::to_string(&report).unwrap();
735 let restored: DiagnosticReport = serde_json::from_str(&json).unwrap();
736 assert_eq!(restored.passed_count, report.passed_count);
737 assert!(restored.is_clean());
738 }
739
740 #[test]
741 fn transport_diagnostic_reports_match_for_matching_observations() {
742 let user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
743 let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
744
745 assert!(
747 expected.expected_profile.is_some()
748 || expected.expected_ja3_hash.is_some()
749 || expected.expected_ja4.is_some()
750 || expected.expected_http3_perk_text.is_some()
751 );
752
753 let observed = TransportObservations {
754 ja3_hash: expected.expected_ja3_hash.clone(),
755 ja4: expected.expected_ja4.clone(),
756 http3_perk_text: expected.expected_http3_perk_text.clone(),
757 http3_perk_hash: expected.expected_http3_perk_hash,
758 };
759 let diagnostic =
760 TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
761
762 assert_eq!(diagnostic.transport_match, Some(true));
763 assert!(diagnostic.mismatches.is_empty());
764 }
765
766 #[test]
767 fn transport_diagnostic_reports_mismatch_for_mismatching_observations() {
768 let user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
769 let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
770
771 assert!(expected.expected_ja3_hash.is_some());
772
773 let observed = TransportObservations {
774 ja3_hash: Some("definitely-not-the-expected-ja3".to_string()),
775 ja4: expected.expected_ja4.clone(),
776 http3_perk_text: expected.expected_http3_perk_text.clone(),
777 http3_perk_hash: expected.expected_http3_perk_hash,
778 };
779 let diagnostic =
780 TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
781
782 assert_eq!(diagnostic.transport_match, Some(false));
783 assert!(!diagnostic.mismatches.is_empty());
784 assert!(
785 diagnostic
786 .mismatches
787 .iter()
788 .any(|m| m.contains("ja3_hash mismatch"))
789 );
790 }
791
792 #[test]
793 fn transport_diagnostic_flags_observations_when_no_expectations_derivable() {
794 let user_agent = "UnknownBrowser/0.0";
795 let diagnostic_without_observed =
796 TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
797
798 assert_eq!(diagnostic_without_observed.expected_profile, None);
800 assert_eq!(diagnostic_without_observed.expected_ja3_hash, None);
801 assert_eq!(diagnostic_without_observed.expected_ja4, None);
802 assert_eq!(diagnostic_without_observed.expected_http3_perk_text, None);
803
804 let observed = TransportObservations {
805 ja3_hash: Some("some-observed-ja3".to_string()),
806 ja4: Some("some-observed-ja4".to_string()),
807 http3_perk_text: Some("some-observed-http3-perk-text".to_string()),
808 http3_perk_hash: Some("some-observed-http3-perk-hash".to_string()),
809 };
810
811 let diagnostic =
812 TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
813
814 assert_eq!(diagnostic.transport_match, Some(false));
816 assert!(!diagnostic.mismatches.is_empty());
817 assert!(
818 diagnostic
819 .mismatches
820 .iter()
821 .any(|m| m.contains("no expected JA3 could be derived"))
822 );
823 }
824}