huginn_net_http/http/
ua_os.rs1use crate::http::UNKNOWN_SOFTWARE;
7use crate::matcher_api::{HttpMatcher, HttpRequestMatch};
8use std::fmt;
9use std::net::IpAddr;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13#[cfg_attr(feature = "json", derive(serde::Serialize))]
14pub struct ObservedOs {
15 pub name: String,
16}
17
18pub trait ObservedOsSource: Send + Sync {
21 fn observed_os(&self, client: IpAddr, client_port: u16) -> Option<ObservedOs>;
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ObservedOsInput<'a> {
32 NoSource,
34 Missing,
36 Present(&'a ObservedOs),
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[cfg_attr(feature = "json", derive(serde::Serialize))]
46pub enum NotCheckedReason {
47 NoMatch,
48 NotUserlandApp,
49 NoUserAgent,
50 Dishonest,
51 UaNotInTable,
52 NoSource,
53 NoObservedOs,
54}
55
56impl fmt::Display for NotCheckedReason {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(match self {
59 Self::NoMatch => "no match",
60 Self::NotUserlandApp => "not userland app",
61 Self::NoUserAgent => "no user-agent",
62 Self::Dishonest => "dishonest",
63 Self::UaNotInTable => "ua not in table",
64 Self::NoSource => "no source",
65 Self::NoObservedOs => "no observed os",
66 })
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
75#[cfg_attr(feature = "json", derive(serde::Serialize))]
76pub enum UaOsAgreement {
77 NotChecked(NotCheckedReason),
78 Consistent { os: String },
79 Divergent { ua_os: String, network_os: String },
80}
81
82impl fmt::Display for UaOsAgreement {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 Self::NotChecked(reason) => write!(f, "not checked ({reason})"),
86 Self::Consistent { os } => write!(f, "consistent ({os})"),
87 Self::Divergent { ua_os, network_os } => {
88 write!(f, "divergent (ua={ua_os}, net={network_os})")
89 }
90 }
91 }
92}
93
94fn usable_user_agent(ua: Option<&str>) -> Option<&str> {
95 let ua = ua
96 .map(str::trim)
97 .filter(|s| !s.is_empty() && *s != UNKNOWN_SOFTWARE)?;
98 Some(ua)
99}
100
101pub fn check_ua_os_agreement(
102 ua: Option<&str>,
103 matched: Option<&HttpRequestMatch>,
104 matcher: Option<&dyn HttpMatcher>,
105 observed: ObservedOsInput<'_>,
106) -> UaOsAgreement {
107 let Some(matched) = matched else {
108 return UaOsAgreement::NotChecked(NotCheckedReason::NoMatch);
109 };
110 if matched.browser.family.is_some() {
111 return UaOsAgreement::NotChecked(NotCheckedReason::NotUserlandApp);
112 }
113 let Some(ua) = usable_user_agent(ua) else {
114 return UaOsAgreement::NotChecked(NotCheckedReason::NoUserAgent);
115 };
116 if matched.dishonest {
117 return UaOsAgreement::NotChecked(NotCheckedReason::Dishonest);
118 }
119 let Some(ua_os) = matcher.and_then(|m| m.match_user_agent(ua)) else {
120 return UaOsAgreement::NotChecked(NotCheckedReason::UaNotInTable);
121 };
122 let observed = match observed {
123 ObservedOsInput::NoSource => {
124 return UaOsAgreement::NotChecked(NotCheckedReason::NoSource);
125 }
126 ObservedOsInput::Missing => {
127 return UaOsAgreement::NotChecked(NotCheckedReason::NoObservedOs);
128 }
129 ObservedOsInput::Present(os) => os,
130 };
131 if ua_os.family == observed.name {
132 UaOsAgreement::Consistent { os: ua_os.family }
133 } else {
134 UaOsAgreement::Divergent { ua_os: ua_os.family, network_os: observed.name.clone() }
135 }
136}