huginn_net_http/
signature_matcher.rs

1use crate::http;
2use crate::observable::{ObservableHttpRequest, ObservableHttpResponse};
3use huginn_net_db::db_matching_trait::FingerprintDb;
4use huginn_net_db::{Database, Label};
5
6pub struct SignatureMatcher<'a> {
7    database: &'a Database,
8}
9
10impl<'a> SignatureMatcher<'a> {
11    pub fn new(database: &'a Database) -> Self {
12        Self { database }
13    }
14
15    pub fn matching_by_http_request(
16        &self,
17        signature: &ObservableHttpRequest,
18    ) -> Option<(&'a Label, &'a http::Signature, f32)> {
19        self.database
20            .http_request
21            .find_best_match(&signature.matching)
22    }
23
24    pub fn matching_by_http_response(
25        &self,
26        signature: &ObservableHttpResponse,
27    ) -> Option<(&'a Label, &'a http::Signature, f32)> {
28        self.database
29            .http_response
30            .find_best_match(&signature.matching)
31    }
32
33    pub fn matching_by_user_agent(
34        &self,
35        user_agent: String,
36    ) -> Option<(&'a String, &'a Option<String>)> {
37        for (ua, ua_family) in &self.database.ua_os {
38            if user_agent.contains(ua) {
39                return Some((ua, ua_family));
40            }
41        }
42        None
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use crate::http::Version as HttpVersion;
50    use huginn_net_db::Type;
51    #[test]
52    fn matching_firefox2_by_http_request() {
53        let db = match Database::load_default() {
54            Ok(db) => db,
55            Err(e) => {
56                panic!("Failed to create default database: {e}");
57            }
58        };
59
60        let firefox_signature = ObservableHttpRequest {
61            matching: huginn_net_db::observable_signals::HttpRequestObservation {
62                version: HttpVersion::V10,
63                horder: vec![
64                    http::Header::new("Host"),
65                    http::Header::new("User-Agent"),
66                    http::Header::new("Accept").with_value(",*/*;q="),
67                    http::Header::new("Accept-Language").optional(),
68                    http::Header::new("Accept-Encoding").with_value("gzip,deflate"),
69                    http::Header::new("Accept-Charset").with_value("utf-8;q=0.7,*;q=0.7"),
70                    http::Header::new("Keep-Alive").with_value("300"),
71                    http::Header::new("Connection").with_value("keep-alive"),
72                ],
73                habsent: vec![],
74                expsw: "Firefox/".to_string(),
75            },
76            lang: None,
77            user_agent: None,
78            headers: vec![],
79            cookies: vec![],
80            referer: None,
81            method: Some("GET".to_string()),
82            uri: Some("/".to_string()),
83        };
84
85        let matcher = SignatureMatcher::new(&db);
86
87        if let Some((label, _matched_db_sig, quality)) =
88            matcher.matching_by_http_request(&firefox_signature)
89        {
90            assert_eq!(label.name, "Firefox");
91            assert_eq!(label.class, None);
92            assert_eq!(label.flavor, Some("2.x".to_string()));
93            assert_eq!(label.ty, Type::Specified);
94            assert_eq!(quality, 1.0);
95        } else {
96            panic!("No match found for Firefox 2.x HTTP signature");
97        }
98    }
99
100    #[test]
101    fn matching_apache_by_http_response() {
102        let db = match Database::load_default() {
103            Ok(db) => db,
104            Err(e) => {
105                panic!("Failed to create default database: {e}");
106            }
107        };
108
109        let apache_signature = ObservableHttpResponse {
110            matching: huginn_net_db::observable_signals::HttpResponseObservation {
111                version: HttpVersion::V11,
112                horder: vec![
113                    http::Header::new("Date"),
114                    http::Header::new("Server"),
115                    http::Header::new("Last-Modified").optional(),
116                    http::Header::new("Accept-Ranges")
117                        .optional()
118                        .with_value("bytes"),
119                    http::Header::new("Content-Length").optional(),
120                    http::Header::new("Content-Range").optional(),
121                    http::Header::new("Keep-Alive").with_value("timeout"),
122                    http::Header::new("Connection").with_value("Keep-Alive"),
123                    http::Header::new("Transfer-Encoding")
124                        .optional()
125                        .with_value("chunked"),
126                    http::Header::new("Content-Type"),
127                ],
128                habsent: vec![],
129                expsw: "Apache".to_string(),
130            },
131            headers: vec![],
132            status_code: Some(200),
133        };
134
135        let matcher = SignatureMatcher::new(&db);
136
137        if let Some((label, _matched_db_sig, quality)) =
138            matcher.matching_by_http_response(&apache_signature)
139        {
140            assert_eq!(label.name, "Apache");
141            assert_eq!(label.class, None);
142            assert_eq!(label.flavor, Some("2.x".to_string()));
143            assert_eq!(label.ty, Type::Specified);
144            assert_eq!(quality, 1.0);
145        } else {
146            panic!("No match found for Apache 2.x HTTP response signature");
147        }
148    }
149
150    #[test]
151    fn matching_android_chrome_by_http_request() {
152        let db = match Database::load_default() {
153            Ok(db) => db,
154            Err(e) => {
155                panic!("Failed to create default database: {e}");
156            }
157        };
158
159        let android_chrome_signature = ObservableHttpRequest {
160            matching: huginn_net_db::observable_signals::HttpRequestObservation {
161                version: HttpVersion::V11, // HTTP/1.1
162                horder: vec![
163                    http::Header::new("Host"),
164                    http::Header::new("Connection").with_value("keep-alive"),
165                    http::Header::new("User-Agent"),
166                    http::Header::new("Accept").with_value("image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"),
167                    http::Header::new("Referer").optional(), // ?Referer
168                    http::Header::new("Accept-Encoding").with_value("gzip, deflate"),
169                    http::Header::new("Accept-Language").with_value("en-US,en;q=0.9,es;q=0.8"),
170                ],
171                habsent: vec![
172                    http::Header::new("Accept-Charset"),
173                    http::Header::new("Keep-Alive"),
174                ],
175                expsw: "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36".to_string(),
176            },
177            lang: Some("English".to_string()),
178            user_agent: Some("Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36".to_string()),
179            headers: vec![],
180            cookies: vec![],
181            referer: None,
182            method: Some("GET".to_string()),
183            uri: Some("/".to_string()),
184        };
185
186        let matcher = SignatureMatcher::new(&db);
187
188        match matcher.matching_by_http_request(&android_chrome_signature) {
189            Some((label, _matched_db_sig, quality)) => {
190                assert_eq!(label.name, "Chrome");
191                assert_eq!(label.class, None);
192                assert_eq!(label.flavor, Some("11 or newer".to_string()));
193                assert_eq!(label.ty, Type::Specified);
194                assert_eq!(quality, 0.7);
195            }
196            None => {
197                panic!("No HTTP match found for Android Chrome signature");
198            }
199        }
200    }
201}