Skip to main content

browser_forensic_core/
analyze.rs

1//! Browser event analysis utilities.
2
3use crate::BrowserEvent;
4
5/// Count URL domains from events' `attrs["url"]` and return those with
6/// `count <= cap`, sorted by count ascending.
7///
8/// Only events that have a valid URL in `attrs["url"]` are considered.
9pub fn rare_domains(events: &[BrowserEvent], cap: usize) -> Vec<(String, usize)> {
10    let mut domain_counts: std::collections::HashMap<String, usize> =
11        std::collections::HashMap::new();
12
13    for event in events {
14        if let Some(url_val) = event.attrs.get("url") {
15            if let Some(url_str) = url_val.as_str() {
16                if let Ok(parsed) = url::Url::parse(url_str) {
17                    if let Some(host) = parsed.host_str() {
18                        *domain_counts.entry(host.to_string()).or_insert(0) += 1;
19                    }
20                }
21            }
22        }
23    }
24
25    let mut result: Vec<(String, usize)> = domain_counts
26        .into_iter()
27        .filter(|(_, count)| *count <= cap)
28        .collect();
29
30    result.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
31    result
32}
33
34#[cfg(test)]
35mod tests {
36    use super::*;
37    use crate::{ArtifactKind, BrowserEvent, BrowserFamily};
38    use serde_json::json;
39
40    fn make_history_event(url: &str) -> BrowserEvent {
41        BrowserEvent::new(
42            0,
43            BrowserFamily::Chromium,
44            ArtifactKind::History,
45            "source",
46            url,
47        )
48        .with_attr("url", json!(url))
49    }
50
51    #[test]
52    fn rare_domains_empty_events_returns_empty() {
53        let result = rare_domains(&[], 5);
54        assert!(result.is_empty());
55    }
56
57    #[test]
58    fn rare_domains_below_cap_returned() {
59        let events = vec![make_history_event("https://rare.example.com/page")];
60        let result = rare_domains(&events, 5);
61        assert!(result.iter().any(|(d, _)| d == "rare.example.com"));
62    }
63
64    #[test]
65    fn rare_domains_above_cap_excluded() {
66        // 10 visits to popular.com — count 10 > cap 5 — should be excluded
67        let events: Vec<BrowserEvent> = (0..10)
68            .map(|i| make_history_event(&format!("https://popular.com/page{i}")))
69            .collect();
70        let result = rare_domains(&events, 5);
71        assert!(!result.iter().any(|(d, _)| d == "popular.com"));
72    }
73
74    #[test]
75    fn rare_domains_sorted_by_count_ascending() {
76        // rare.com appears 1 time
77        let mut events = vec![make_history_event("https://rare.com/page")];
78        // semi-rare.com appears 2 times
79        events.push(make_history_event("https://semi-rare.com/a"));
80        events.push(make_history_event("https://semi-rare.com/b"));
81
82        let result = rare_domains(&events, 5);
83        let rare_pos = result.iter().position(|(d, _)| d == "rare.com");
84        let semi_pos = result.iter().position(|(d, _)| d == "semi-rare.com");
85        assert!(rare_pos.is_some() && semi_pos.is_some());
86        assert!(rare_pos.unwrap() < semi_pos.unwrap());
87    }
88}