1use crate::types::{PathClassification, ProbeResult, Protocol};
6
7#[derive(Debug, Clone, Default)]
9pub struct ProtocolDifferentialScore {
10 pub icmp_tcp_diff: f64,
12 pub icmp_udp_diff: f64,
14 pub tcp_udp_diff: f64,
16 pub consistency: f64,
18 pub latency_variance_ms: f64,
20}
21
22impl ProtocolDifferentialScore {
23 pub fn calculate(results: &[ProbeResult]) -> Self {
25 let icmp_success = results.iter()
26 .filter(|r| matches!(r.protocol, Protocol::Icmp))
27 .map(|r| if r.success { 1.0 } else { 0.0 })
28 .next();
29
30 let tcp_success_rate = Self::success_rate(results, |p| matches!(p, Protocol::Tcp(_)));
31 let udp_success_rate = Self::success_rate(results, |p| matches!(p, Protocol::Udp(_)));
32
33 let icmp_tcp_diff = match icmp_success {
34 Some(icmp) if tcp_success_rate.is_some() => {
35 (icmp - tcp_success_rate.unwrap()).abs()
36 }
37 _ => 0.0,
38 };
39
40 let icmp_udp_diff = match icmp_success {
41 Some(icmp) if udp_success_rate.is_some() => {
42 (icmp - udp_success_rate.unwrap()).abs()
43 }
44 _ => 0.0,
45 };
46
47 let tcp_udp_diff = match (tcp_success_rate, udp_success_rate) {
48 (Some(tcp), Some(udp)) => (tcp - udp).abs(),
49 _ => 0.0,
50 };
51
52 let total_diff = icmp_tcp_diff + icmp_udp_diff + tcp_udp_diff;
54 let consistency = 1.0 - (total_diff / 3.0).min(1.0);
55
56 let latencies: Vec<f64> = results.iter()
58 .filter(|r| r.success)
59 .map(|r| r.timing.total_ms())
60 .collect();
61
62 let latency_variance_ms = if latencies.len() >= 2 {
63 let mean = latencies.iter().sum::<f64>() / latencies.len() as f64;
64 let variance = latencies.iter()
65 .map(|l| (l - mean).powi(2))
66 .sum::<f64>() / latencies.len() as f64;
67 variance.sqrt()
68 } else {
69 0.0
70 };
71
72 Self {
73 icmp_tcp_diff,
74 icmp_udp_diff,
75 tcp_udp_diff,
76 consistency,
77 latency_variance_ms,
78 }
79 }
80
81 fn success_rate<F>(results: &[ProbeResult], filter: F) -> Option<f64>
82 where
83 F: Fn(&Protocol) -> bool,
84 {
85 let matching: Vec<_> = results.iter()
86 .filter(|r| filter(&r.protocol))
87 .collect();
88
89 if matching.is_empty() {
90 None
91 } else {
92 let success_count = matching.iter().filter(|r| r.success).count();
93 Some(success_count as f64 / matching.len() as f64)
94 }
95 }
96
97 pub fn interpret(&self) -> NetworkBehavior {
99 if self.consistency > 0.9
100 && self.icmp_tcp_diff < 0.1 && self.tcp_udp_diff < 0.1 {
101 return NetworkBehavior::Direct;
102 }
103
104 if self.icmp_tcp_diff > 0.5 && self.icmp_udp_diff > 0.5 {
105 return NetworkBehavior::IcmpFiltering;
106 }
107
108 if self.tcp_udp_diff > 0.5 {
109 return NetworkBehavior::ProtocolSpecificFiltering;
110 }
111
112 if self.latency_variance_ms > 50.0 {
113 return NetworkBehavior::AsymmetricRouting;
114 }
115
116 NetworkBehavior::Unknown
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum NetworkBehavior {
123 Direct,
125 IcmpFiltering,
127 ProtocolSpecificFiltering,
129 AsymmetricRouting,
131 Unknown,
133}
134
135impl std::fmt::Display for NetworkBehavior {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 match self {
138 NetworkBehavior::Direct => write!(f, "Direct path"),
139 NetworkBehavior::IcmpFiltering => write!(f, "ICMP filtering"),
140 NetworkBehavior::ProtocolSpecificFiltering => write!(f, "Protocol-specific filtering"),
141 NetworkBehavior::AsymmetricRouting => write!(f, "Asymmetric routing"),
142 NetworkBehavior::Unknown => write!(f, "Unknown"),
143 }
144 }
145}
146
147pub struct Classifier;
149
150impl Classifier {
151 pub fn classify(results: &[ProbeResult]) -> PathClassification {
153 if results.is_empty() {
154 return PathClassification::Unknown;
155 }
156
157 let icmp_results: Vec<_> = results.iter()
158 .filter(|r| matches!(r.protocol, Protocol::Icmp))
159 .collect();
160 let icmp_success = icmp_results.iter().any(|r| r.success);
161 let has_icmp = !icmp_results.is_empty();
162
163 let tcp_results: Vec<_> = results.iter()
164 .filter(|r| matches!(r.protocol, Protocol::Tcp(_)))
165 .collect();
166
167 let udp_results: Vec<_> = results.iter()
168 .filter(|r| matches!(r.protocol, Protocol::Udp(_)))
169 .collect();
170
171 let tcp_success_count = tcp_results.iter().filter(|r| r.success).count();
172 let udp_success_count = udp_results.iter().filter(|r| r.success).count();
173
174 let any_tcp = !tcp_results.is_empty();
175 let any_udp = !udp_results.is_empty();
176 let any_tcp_success = tcp_success_count > 0;
177 let any_udp_success = udp_success_count > 0;
178
179 if icmp_success && (!any_tcp || any_tcp_success) && (!any_udp || any_udp_success) {
181 let all_tcp_success = tcp_results.iter().all(|r| r.success);
182 let all_udp_success = udp_results.iter().all(|r| r.success);
183
184 if all_tcp_success && all_udp_success {
185 return PathClassification::Open;
186 }
187
188 let open_ports: Vec<u16> = results.iter()
189 .filter(|r| r.success)
190 .filter_map(|r| r.protocol.port())
191 .collect();
192
193 let closed_ports: Vec<u16> = results.iter()
194 .filter(|r| !r.success)
195 .filter_map(|r| r.protocol.port())
196 .collect();
197
198 if !open_ports.is_empty() && !closed_ports.is_empty() {
199 return PathClassification::SelectiveFirewall { open_ports, closed_ports };
200 }
201 }
202
203 if !has_icmp && (any_tcp_success || any_udp_success) {
205 let all_tcp_success = tcp_results.is_empty() || tcp_results.iter().all(|r| r.success);
206 let all_udp_success = udp_results.is_empty() || udp_results.iter().all(|r| r.success);
207
208 if all_tcp_success && all_udp_success {
209 return PathClassification::IcmpFiltered; }
212 }
213
214 if has_icmp && !icmp_success && (any_tcp_success || any_udp_success) {
216 return PathClassification::IcmpFiltered;
217 }
218
219 if icmp_success && any_tcp && !any_tcp_success {
221 return PathClassification::TcpFiltered;
222 }
223
224 let all_fail = (!has_icmp || !icmp_success) && !any_tcp_success && !any_udp_success;
226 if all_fail && (has_icmp || any_tcp || any_udp) {
227 return PathClassification::Blocked;
228 }
229
230 let ttls: Vec<u8> = results.iter()
232 .filter_map(|r| r.ttl)
233 .collect();
234
235 if ttls.len() >= 2 {
236 let min_ttl = *ttls.iter().min().unwrap();
237 let max_ttl = *ttls.iter().max().unwrap();
238
239 if max_ttl - min_ttl > 5 {
240 return PathClassification::NatDetected;
241 }
242 }
243
244 PathClassification::Unknown
245 }
246
247 pub fn differential_score(results: &[ProbeResult]) -> ProtocolDifferentialScore {
249 ProtocolDifferentialScore::calculate(results)
250 }
251
252 pub fn fingerprint(results: &[ProbeResult]) -> String {
254 let mut parts = Vec::new();
255
256 let icmp = results.iter()
258 .find(|r| matches!(r.protocol, Protocol::Icmp));
259 if let Some(r) = icmp {
260 parts.push(format!("ICMP:{}", if r.success { "open" } else { "filtered" }));
261 }
262
263 let tcp_open: Vec<_> = results.iter()
265 .filter(|r| matches!(r.protocol, Protocol::Tcp(_)) && r.success)
266 .filter_map(|r| r.protocol.port())
267 .collect();
268 let tcp_closed: Vec<_> = results.iter()
269 .filter(|r| matches!(r.protocol, Protocol::Tcp(_)) && !r.success)
270 .filter_map(|r| r.protocol.port())
271 .collect();
272
273 if !tcp_open.is_empty() {
274 parts.push(format!("TCP-open:{tcp_open:?}"));
275 }
276 if !tcp_closed.is_empty() {
277 parts.push(format!("TCP-closed:{tcp_closed:?}"));
278 }
279
280 let udp_open: Vec<_> = results.iter()
282 .filter(|r| matches!(r.protocol, Protocol::Udp(_)) && r.success)
283 .filter_map(|r| r.protocol.port())
284 .collect();
285 let udp_closed: Vec<_> = results.iter()
286 .filter(|r| matches!(r.protocol, Protocol::Udp(_)) && !r.success)
287 .filter_map(|r| r.protocol.port())
288 .collect();
289
290 if !udp_open.is_empty() {
291 parts.push(format!("UDP-open:{udp_open:?}"));
292 }
293 if !udp_closed.is_empty() {
294 parts.push(format!("UDP-closed:{udp_closed:?}"));
295 }
296
297 if parts.is_empty() {
298 "Unknown".to_string()
299 } else {
300 parts.join(" | ")
301 }
302 }
303
304 pub fn fingerprint_hash(results: &[ProbeResult]) -> u64 {
306 use std::collections::hash_map::DefaultHasher;
307 use std::hash::{Hash, Hasher};
308
309 let mut hasher = DefaultHasher::new();
310
311 let mut sorted: Vec<_> = results.iter().collect();
313 sorted.sort_by_key(|r| format!("{}", r.protocol));
314
315 for result in sorted {
316 result.protocol.to_string().hash(&mut hasher);
317 result.success.hash(&mut hasher);
318 }
319
320 hasher.finish()
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::types::TimingBreakdown;
328 use std::net::IpAddr;
329 use std::time::Duration;
330
331 fn make_result(protocol: Protocol, success: bool) -> ProbeResult {
332 let ip: IpAddr = "1.2.3.4".parse().unwrap();
333 let timing = TimingBreakdown::new(None, Duration::from_millis(10), Duration::from_millis(20));
334
335 if success {
336 ProbeResult::success("test".to_string(), ip, protocol, timing)
337 } else {
338 ProbeResult::failure("test".to_string(), ip, protocol, "failed".to_string(), timing)
339 }
340 }
341
342 #[test]
343 fn test_classify_open() {
344 let results = vec![
345 make_result(Protocol::Icmp, true),
346 make_result(Protocol::Tcp(80), true),
347 make_result(Protocol::Tcp(443), true),
348 ];
349
350 assert_eq!(Classifier::classify(&results), PathClassification::Open);
351 }
352
353 #[test]
354 fn test_classify_icmp_filtered() {
355 let results = vec![
356 make_result(Protocol::Icmp, false),
357 make_result(Protocol::Tcp(443), true),
358 ];
359
360 assert_eq!(Classifier::classify(&results), PathClassification::IcmpFiltered);
361 }
362
363 #[test]
364 fn test_classify_blocked() {
365 let results = vec![
366 make_result(Protocol::Icmp, false),
367 make_result(Protocol::Tcp(80), false),
368 make_result(Protocol::Tcp(443), false),
369 ];
370
371 assert_eq!(Classifier::classify(&results), PathClassification::Blocked);
372 }
373
374 #[test]
375 fn test_fingerprint() {
376 let results = vec![
377 make_result(Protocol::Icmp, false),
378 make_result(Protocol::Tcp(80), false),
379 make_result(Protocol::Tcp(443), true),
380 ];
381
382 let fp = Classifier::fingerprint(&results);
383 assert!(fp.contains("ICMP:filtered"));
384 assert!(fp.contains("TCP-open:[443]"));
385 assert!(fp.contains("TCP-closed:[80]"));
386 }
387
388 #[test]
389 fn test_differential_score_consistent() {
390 let results = vec![
391 make_result(Protocol::Icmp, true),
392 make_result(Protocol::Tcp(80), true),
393 make_result(Protocol::Tcp(443), true),
394 make_result(Protocol::Udp(53), true),
395 ];
396
397 let score = Classifier::differential_score(&results);
398 assert!(score.consistency > 0.9);
399 assert_eq!(score.interpret(), NetworkBehavior::Direct);
400 }
401
402 #[test]
403 fn test_differential_score_icmp_filtered() {
404 let results = vec![
405 make_result(Protocol::Icmp, false),
406 make_result(Protocol::Tcp(80), true),
407 make_result(Protocol::Tcp(443), true),
408 make_result(Protocol::Udp(53), true),
409 ];
410
411 let score = Classifier::differential_score(&results);
412 assert!(score.icmp_tcp_diff > 0.5);
413 assert_eq!(score.interpret(), NetworkBehavior::IcmpFiltering);
414 }
415
416 #[test]
417 fn test_fingerprint_hash_consistency() {
418 let results1 = vec![
419 make_result(Protocol::Tcp(80), true),
420 make_result(Protocol::Tcp(443), true),
421 ];
422
423 let results2 = vec![
424 make_result(Protocol::Tcp(443), true),
425 make_result(Protocol::Tcp(80), true),
426 ];
427
428 assert_eq!(
430 Classifier::fingerprint_hash(&results1),
431 Classifier::fingerprint_hash(&results2)
432 );
433 }
434}