Skip to main content

browser_forensic_core/
reconstruct.rs

1//! Browser-agnostic navigation reconstruction over per-visit [`BrowserEvent`]s.
2//!
3//! Chromium (`visits`) and Firefox (`moz_historyvisits`) both record each visit
4//! with a `visit_id`, a `from_visit` back-reference to the visit that led to it,
5//! a normalized `transition` token, and redirect flags. This module rebuilds the
6//! higher-level structure those fields encode:
7//!
8//! - [`resolve_referrer_chains`] — follow `from_visit` to attach each visit's
9//!   referrer URL and its depth in the navigation path (bounded, so cyclic or
10//!   dangling links never loop or panic).
11//! - [`redirect_chains`] / [`tag_redirect_chains`] — group the redirect hops
12//!   between a navigation's origin and its final landing, tagging each as a
13//!   client- or server-side redirect.
14//! - [`sessionize`] — group visits into browsing sessions by an idle-gap
15//!   threshold (a documented, configurable heuristic — sessions are *inferred*,
16//!   not recorded) and by any browser-recorded `session` boundary.
17//! - [`tabs_open_at`] — the set of session tabs whose window was last active at
18//!   or before a given instant, reusing the SNSS/`sessionstore` reader output.
19//!
20//! Everything reads the attrs the visit parsers already emit and writes new attrs
21//! back; missing attrs are treated as "absent" (fail-open — reconstruction never
22//! drops or corrupts a visit it cannot fully link).
23
24use std::collections::{HashMap, HashSet};
25
26use serde_json::json;
27
28use crate::{ArtifactKind, BrowserEvent};
29
30/// Default idle-gap for [`sessionize`]: 30 minutes. A *heuristic* boundary
31/// (sessions are inferred, not recorded by the browser); override via
32/// [`SessionConfig`].
33pub const DEFAULT_IDLE_GAP_MINUTES: i64 = 30;
34
35/// Upper bound on `from_visit` graph traversal. Guards cyclic and dangling links
36/// in hostile or corrupt data so reconstruction is always finite and panic-free.
37const MAX_CHAIN_DEPTH: usize = 4096;
38
39// ---------------------------------------------------------------------------
40// attr accessors (fail-open)
41// ---------------------------------------------------------------------------
42
43fn attr_i64(e: &BrowserEvent, key: &str) -> Option<i64> {
44    e.attrs.get(key).and_then(serde_json::Value::as_i64)
45}
46
47fn attr_str<'a>(e: &'a BrowserEvent, key: &str) -> Option<&'a str> {
48    e.attrs.get(key).and_then(serde_json::Value::as_str)
49}
50
51fn attr_bool(e: &BrowserEvent, key: &str) -> Option<bool> {
52    e.attrs.get(key).and_then(serde_json::Value::as_bool)
53}
54
55/// Index visits by their `visit_id` attr (first occurrence wins). Events without
56/// a `visit_id` are skipped.
57fn index_by_visit_id(events: &[BrowserEvent]) -> HashMap<i64, usize> {
58    let mut map = HashMap::new();
59    for (i, e) in events.iter().enumerate() {
60        if let Some(id) = attr_i64(e, "visit_id") {
61            map.entry(id).or_insert(i);
62        }
63    }
64    map
65}
66
67// ---------------------------------------------------------------------------
68// human_transition_label
69// ---------------------------------------------------------------------------
70
71/// A human-readable label for a normalized `transition` token (the tokens the
72/// Chromium/Firefox visit parsers emit). Unknown tokens map to `"unknown"`; the
73/// raw token stays available in the event's `transition` attr.
74#[must_use]
75pub fn human_transition_label(token: &str) -> &'static str {
76    match token {
77        "link" => "clicked link",
78        "typed" => "typed URL",
79        "auto_bookmark" | "bookmark" => "bookmark",
80        "auto_subframe" => "subframe (auto)",
81        "manual_subframe" => "subframe (manual)",
82        "generated" => "generated",
83        "auto_toplevel" | "start_page" => "start page",
84        "form_submit" => "form submit",
85        "reload" => "reload",
86        "keyword" | "keyword_generated" => "keyword search",
87        "embed" => "embedded object",
88        "redirect_permanent" => "redirect (permanent)",
89        "redirect_temporary" => "redirect (temporary)",
90        "download" => "download",
91        "framed_link" => "framed link",
92        _ => "unknown",
93    }
94}
95
96// ---------------------------------------------------------------------------
97// resolve_referrer_chains
98// ---------------------------------------------------------------------------
99
100/// Attach each visit's referrer URL and navigation-path depth by following
101/// `from_visit`.
102///
103/// For every event with a resolvable `from_visit`, adds `referrer_url` (the URL
104/// of the visit it came from) and `nav_depth` (the number of resolved referrer
105/// hops back to a navigation root). Root visits (`from_visit == 0`) and visits
106/// whose `from_visit` dangles get `nav_depth = 0` and no `referrer_url`.
107///
108/// Traversal is depth-bounded ([`MAX_CHAIN_DEPTH`]) and cycle-guarded: a cyclic
109/// or dangling `from_visit` graph never loops, overflows the stack, or panics.
110pub fn resolve_referrer_chains(events: &mut [BrowserEvent]) {
111    // Snapshot the linkage so the per-event mutation below has no borrow conflict.
112    let url_of: HashMap<i64, String> = events
113        .iter()
114        .filter_map(|e| {
115            let id = attr_i64(e, "visit_id")?;
116            Some((id, attr_str(e, "url").unwrap_or_default().to_string()))
117        })
118        .collect();
119    let from_of: HashMap<i64, i64> = events
120        .iter()
121        .filter_map(|e| {
122            Some((
123                attr_i64(e, "visit_id")?,
124                attr_i64(e, "from_visit").unwrap_or(0),
125            ))
126        })
127        .collect();
128
129    for e in events.iter_mut() {
130        let from = attr_i64(e, "from_visit").unwrap_or(0);
131        if from != 0 {
132            if let Some(u) = url_of.get(&from) {
133                e.attrs.insert("referrer_url".to_string(), json!(u));
134            }
135        }
136        // Depth = resolved referrer hops back to a root, bounded and cycle-guarded.
137        let mut depth: i64 = 0;
138        let mut cur = from;
139        let mut seen: HashSet<i64> = HashSet::new();
140        while cur != 0 && (depth as usize) < MAX_CHAIN_DEPTH {
141            if !url_of.contains_key(&cur) || !seen.insert(cur) {
142                break; // dangling link or a cycle — stop cleanly
143            }
144            depth += 1;
145            cur = from_of.get(&cur).copied().unwrap_or(0);
146        }
147        e.attrs.insert("nav_depth".to_string(), json!(depth));
148    }
149}
150
151// ---------------------------------------------------------------------------
152// redirect_chains
153// ---------------------------------------------------------------------------
154
155/// One hop in a reconstructed redirect chain.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct RedirectHop {
158    /// The hop's `visit_id`.
159    pub visit_id: i64,
160    /// The hop's URL.
161    pub url: String,
162    /// `Some("client")` / `Some("server")` for a redirect hop; `None` for the
163    /// non-redirect origin that started the navigation.
164    pub kind: Option<String>,
165    /// `"start"`, `"hop"`, or `"landing"`.
166    pub role: &'static str,
167}
168
169/// A reconstructed redirect chain: the origin (when known) followed by its
170/// redirect hops, ending at the landing page.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct RedirectChain {
173    /// Stable id assigned in reconstruction order.
174    pub id: usize,
175    /// Hops in navigation order (origin/start first, landing last).
176    pub hops: Vec<RedirectHop>,
177}
178
179/// Reconstruct redirect chains from a per-visit event slice.
180///
181/// A redirect chain is a maximal run of visits linked by `from_visit` whose
182/// members carry the redirect flag (`is_redirect`), plus the non-redirect origin
183/// that initiated it when that origin is resolvable. Client vs server flavour is
184/// read from each hop's `redirect_kind` attr. Chains are linear paths; grouping
185/// is cycle-guarded and depth-bounded.
186/// Role of a hop at `pos` in a chain of `total` members.
187fn role_for(pos: usize, total: usize) -> &'static str {
188    if total <= 1 || pos == total - 1 {
189        "landing"
190    } else if pos == 0 {
191        "start"
192    } else {
193        "hop"
194    }
195}
196
197#[must_use]
198pub fn redirect_chains(events: &[BrowserEvent]) -> Vec<RedirectChain> {
199    let id_to_idx = index_by_visit_id(events);
200    let is_red = |i: usize| attr_bool(&events[i], "is_redirect") == Some(true);
201
202    // Redirect children keyed by their parent's visit_id (from_visit).
203    let mut redirect_children: HashMap<i64, Vec<usize>> = HashMap::new();
204    for (i, e) in events.iter().enumerate() {
205        if is_red(i) {
206            let from = attr_i64(e, "from_visit").unwrap_or(0);
207            redirect_children.entry(from).or_default().push(i);
208        }
209    }
210
211    let hop = |idx: usize, kind: Option<String>, role: &'static str| RedirectHop {
212        visit_id: attr_i64(&events[idx], "visit_id").unwrap_or(0),
213        url: attr_str(&events[idx], "url")
214            .unwrap_or_default()
215            .to_string(),
216        kind,
217        role,
218    };
219
220    let mut chains: Vec<RedirectChain> = Vec::new();
221    let mut assigned: HashSet<usize> = HashSet::new();
222    for (i, e) in events.iter().enumerate() {
223        if !is_red(i) || assigned.contains(&i) {
224            continue;
225        }
226        let from = attr_i64(e, "from_visit").unwrap_or(0);
227        let parent_idx = id_to_idx.get(&from).copied();
228        // Only a redirect whose parent is not itself a redirect starts a chain;
229        // any redirect with a redirect parent is reached forward from its head.
230        if matches!(parent_idx, Some(pi) if is_red(pi)) {
231            continue;
232        }
233
234        // Forward-follow the redirect run (linear; cycle-guarded, depth-bounded).
235        let mut run: Vec<usize> = Vec::new();
236        let mut cur = i;
237        let mut seen: HashSet<usize> = HashSet::new();
238        while run.len() < MAX_CHAIN_DEPTH && seen.insert(cur) {
239            run.push(cur);
240            assigned.insert(cur);
241            let cur_id = attr_i64(&events[cur], "visit_id").unwrap_or(0);
242            let next = redirect_children
243                .get(&cur_id)
244                .and_then(|kids| kids.iter().copied().find(|k| !seen.contains(k)));
245            match next {
246                Some(n) => cur = n,
247                None => break,
248            }
249        }
250
251        // Prepend the non-redirect origin when it is resolvable.
252        let origin = parent_idx.filter(|&pi| !is_red(pi));
253        let total = run.len() + usize::from(origin.is_some());
254        let mut hops: Vec<RedirectHop> = Vec::with_capacity(total);
255        let mut pos = 0;
256        if let Some(oi) = origin {
257            hops.push(hop(oi, None, role_for(pos, total)));
258            pos += 1;
259        }
260        for &ri in &run {
261            let kind = attr_str(&events[ri], "redirect_kind").map(str::to_string);
262            hops.push(hop(ri, kind, role_for(pos, total)));
263            pos += 1;
264        }
265        chains.push(RedirectChain {
266            id: chains.len(),
267            hops,
268        });
269    }
270    chains
271}
272
273/// Tag each event that belongs to a redirect chain with `redirect_chain_id`
274/// (usize) and `redirect_role` (`"start"`/`"hop"`/`"landing"`) attrs, via
275/// [`redirect_chains`].
276pub fn tag_redirect_chains(events: &mut [BrowserEvent]) {
277    let chains = redirect_chains(events);
278    let mut tag: HashMap<i64, (usize, &'static str)> = HashMap::new();
279    for c in &chains {
280        for h in &c.hops {
281            tag.insert(h.visit_id, (c.id, h.role));
282        }
283    }
284    for e in events.iter_mut() {
285        if let Some(id) = attr_i64(e, "visit_id") {
286            if let Some((cid, role)) = tag.get(&id) {
287                e.attrs.insert("redirect_chain_id".to_string(), json!(cid));
288                e.attrs.insert("redirect_role".to_string(), json!(role));
289            }
290        }
291    }
292}
293
294// ---------------------------------------------------------------------------
295// sessionize
296// ---------------------------------------------------------------------------
297
298/// Configuration for [`sessionize`].
299#[derive(Debug, Clone, Copy)]
300pub struct SessionConfig {
301    /// Idle gap, in nanoseconds, above which a new session starts.
302    pub idle_gap_ns: i64,
303}
304
305impl Default for SessionConfig {
306    fn default() -> Self {
307        Self {
308            idle_gap_ns: DEFAULT_IDLE_GAP_MINUTES * 60 * 1_000_000_000,
309        }
310    }
311}
312
313/// Group visits into inferred browsing sessions by idle gap.
314///
315/// Walking the events in time order, a new session begins whenever the gap since
316/// the previous visit exceeds `cfg.idle_gap_ns`, or the browser-recorded
317/// `session` attr changes. Each event gains a `session_id` attr (0-based,
318/// assigned in time order). The slice is not reordered.
319///
320/// Sessions are *inferred* from the idle-gap heuristic, not recorded by the
321/// browser: report them as "sessions inferred at an N-minute idle gap".
322pub fn sessionize(events: &mut [BrowserEvent], cfg: SessionConfig) {
323    if events.is_empty() {
324        return;
325    }
326    let mut order: Vec<usize> = (0..events.len()).collect();
327    order.sort_by_key(|&i| events[i].timestamp_ns);
328
329    let mut session: i64 = 0;
330    let mut prev_ts: Option<i64> = None;
331    let mut prev_sess: Option<Option<i64>> = None;
332    for &i in &order {
333        let ts = events[i].timestamp_ns;
334        let recorded = attr_i64(&events[i], "session");
335        if let Some(pt) = prev_ts {
336            let gap = ts.saturating_sub(pt);
337            // A recorded-session change is a boundary only when both sides record one.
338            let sess_changed =
339                prev_sess.is_some_and(|ps| ps.is_some() && recorded.is_some() && ps != recorded);
340            if gap > cfg.idle_gap_ns || sess_changed {
341                session += 1;
342            }
343        }
344        events[i]
345            .attrs
346            .insert("session_id".to_string(), json!(session));
347        prev_ts = Some(ts);
348        prev_sess = Some(recorded);
349    }
350}
351
352// ---------------------------------------------------------------------------
353// tabs_open_at
354// ---------------------------------------------------------------------------
355
356/// The session tabs whose window was last active at or before `t_ns`.
357///
358/// Reuses the SNSS / `sessionstore` reader output ([`ArtifactKind::Session`]
359/// events, each timestamped with its window's last-active time): returns those
360/// with `0 < timestamp_ns <= t_ns` — the tabs known open as of the latest
361/// recorded activity at or before `t_ns`.
362#[must_use]
363pub fn tabs_open_at(session_events: &[BrowserEvent], t_ns: i64) -> Vec<&BrowserEvent> {
364    session_events
365        .iter()
366        .filter(|e| {
367            e.artifact == ArtifactKind::Session && e.timestamp_ns > 0 && e.timestamp_ns <= t_ns
368        })
369        .collect()
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::{BrowserEvent, BrowserFamily};
376
377    fn visit(id: i64, from: i64, ts_ns: i64, url: &str) -> BrowserEvent {
378        BrowserEvent::new(
379            ts_ns,
380            BrowserFamily::Chromium,
381            ArtifactKind::History,
382            "src",
383            url,
384        )
385        .with_attr("url", json!(url))
386        .with_attr("visit_id", json!(id))
387        .with_attr("from_visit", json!(from))
388    }
389
390    fn redirect_visit(id: i64, from: i64, ts_ns: i64, url: &str, kind: &str) -> BrowserEvent {
391        visit(id, from, ts_ns, url)
392            .with_attr("is_redirect", json!(true))
393            .with_attr("redirect_kind", json!(kind))
394    }
395
396    // ---- human_transition_label ----
397
398    #[test]
399    fn transition_labels_are_human_readable() {
400        assert_eq!(human_transition_label("typed"), "typed URL");
401        assert_eq!(human_transition_label("link"), "clicked link");
402        assert_eq!(human_transition_label("form_submit"), "form submit");
403        assert_eq!(human_transition_label("reload"), "reload");
404        assert_eq!(
405            human_transition_label("redirect_permanent"),
406            "redirect (permanent)"
407        );
408        assert_eq!(human_transition_label("auto_bookmark"), "bookmark");
409        assert_eq!(human_transition_label("something_new"), "unknown");
410    }
411
412    // ---- resolve_referrer_chains ----
413
414    #[test]
415    fn referrer_chain_sets_referrer_url_and_depth() {
416        // 1 (root) -> 2 -> 3
417        let mut events = vec![
418            visit(1, 0, 1000, "https://a.example"),
419            visit(2, 1, 2000, "https://b.example"),
420            visit(3, 2, 3000, "https://c.example"),
421        ];
422        resolve_referrer_chains(&mut events);
423        assert_eq!(events[0].attrs["nav_depth"], json!(0));
424        assert!(!events[0].attrs.contains_key("referrer_url"));
425        assert_eq!(events[1].attrs["referrer_url"], json!("https://a.example"));
426        assert_eq!(events[1].attrs["nav_depth"], json!(1));
427        assert_eq!(events[2].attrs["referrer_url"], json!("https://b.example"));
428        assert_eq!(events[2].attrs["nav_depth"], json!(2));
429    }
430
431    #[test]
432    fn dangling_from_visit_leaves_no_referrer() {
433        // from_visit 999 does not exist.
434        let mut events = vec![visit(1, 999, 1000, "https://a.example")];
435        resolve_referrer_chains(&mut events);
436        assert!(!events[0].attrs.contains_key("referrer_url"));
437        assert_eq!(events[0].attrs["nav_depth"], json!(0));
438    }
439
440    #[test]
441    fn cyclic_from_visit_is_bounded_not_infinite() {
442        // 1 -> 2 -> 1 : a cycle. Must terminate and cap depth.
443        let mut events = vec![
444            visit(1, 2, 1000, "https://a.example"),
445            visit(2, 1, 2000, "https://b.example"),
446        ];
447        resolve_referrer_chains(&mut events);
448        // referrer resolves one hop; depth stays finite and bounded.
449        assert_eq!(events[0].attrs["referrer_url"], json!("https://b.example"));
450        let d0 = events[0].attrs["nav_depth"].as_i64().unwrap();
451        let d1 = events[1].attrs["nav_depth"].as_i64().unwrap();
452        assert!(d0 <= MAX_CHAIN_DEPTH as i64);
453        assert!(d1 <= MAX_CHAIN_DEPTH as i64);
454    }
455
456    // ---- redirect_chains ----
457
458    #[test]
459    fn redirect_chain_groups_origin_and_hops_with_roles() {
460        // origin (typed, not redirect) -> server redirect -> client redirect (landing)
461        let mut events = vec![
462            visit(1, 0, 1000, "https://origin.example"),
463            redirect_visit(2, 1, 2000, "https://hop.example", "server"),
464            redirect_visit(3, 2, 3000, "https://landing.example", "client"),
465        ];
466        let chains = redirect_chains(&events);
467        assert_eq!(chains.len(), 1);
468        let c = &chains[0];
469        assert_eq!(c.hops.len(), 3);
470        assert_eq!(c.hops[0].role, "start");
471        assert_eq!(c.hops[0].kind, None);
472        assert_eq!(c.hops[0].url, "https://origin.example");
473        assert_eq!(c.hops[1].role, "hop");
474        assert_eq!(c.hops[1].kind.as_deref(), Some("server"));
475        assert_eq!(c.hops[2].role, "landing");
476        assert_eq!(c.hops[2].kind.as_deref(), Some("client"));
477
478        // tagging writes the chain id + role back onto the events
479        tag_redirect_chains(&mut events);
480        assert_eq!(events[0].attrs["redirect_role"], json!("start"));
481        assert_eq!(events[1].attrs["redirect_role"], json!("hop"));
482        assert_eq!(events[2].attrs["redirect_role"], json!("landing"));
483        assert_eq!(
484            events[0].attrs["redirect_chain_id"],
485            events[2].attrs["redirect_chain_id"]
486        );
487    }
488
489    #[test]
490    fn no_redirects_yields_no_chains() {
491        let events = vec![
492            visit(1, 0, 1000, "https://a.example"),
493            visit(2, 0, 2000, "https://b.example"),
494        ];
495        assert!(redirect_chains(&events).is_empty());
496    }
497
498    #[test]
499    fn redirect_chain_with_dangling_origin_starts_at_first_redirect() {
500        // origin id 1 is absent; the redirect (id 2) is the chain head.
501        let events = vec![redirect_visit(2, 1, 2000, "https://only.example", "server")];
502        let chains = redirect_chains(&events);
503        assert_eq!(chains.len(), 1);
504        assert_eq!(chains[0].hops.len(), 1);
505        assert_eq!(chains[0].hops[0].role, "landing");
506    }
507
508    // ---- sessionize ----
509
510    fn min_ns(m: i64) -> i64 {
511        m * 60 * 1_000_000_000
512    }
513
514    #[test]
515    fn sessionize_groups_by_idle_gap() {
516        let mut events = vec![
517            visit(1, 0, 0, "https://a.example"),
518            visit(2, 0, min_ns(5), "https://b.example"), // +5 min: same session
519            visit(3, 0, min_ns(50), "https://c.example"), // +45 min: new session
520        ];
521        sessionize(&mut events, SessionConfig::default());
522        assert_eq!(events[0].attrs["session_id"], json!(0));
523        assert_eq!(events[1].attrs["session_id"], json!(0));
524        assert_eq!(events[2].attrs["session_id"], json!(1));
525    }
526
527    #[test]
528    fn sessionize_respects_custom_idle_gap() {
529        let mut events = vec![
530            visit(1, 0, 0, "https://a.example"),
531            visit(2, 0, min_ns(5), "https://b.example"),
532        ];
533        // 2-minute gap: the 5-minute jump now splits.
534        sessionize(
535            &mut events,
536            SessionConfig {
537                idle_gap_ns: min_ns(2),
538            },
539        );
540        assert_eq!(events[0].attrs["session_id"], json!(0));
541        assert_eq!(events[1].attrs["session_id"], json!(1));
542    }
543
544    #[test]
545    fn sessionize_splits_on_recorded_session_change() {
546        let mut events = vec![
547            visit(1, 0, 0, "https://a.example").with_attr("session", json!(7)),
548            // 1 minute later but a different recorded session -> new inferred session
549            visit(2, 0, min_ns(1), "https://b.example").with_attr("session", json!(8)),
550        ];
551        sessionize(&mut events, SessionConfig::default());
552        assert_ne!(events[0].attrs["session_id"], events[1].attrs["session_id"]);
553    }
554
555    // ---- tabs_open_at ----
556
557    fn tab_event(ts_ns: i64, url: &str) -> BrowserEvent {
558        BrowserEvent::new(
559            ts_ns,
560            BrowserFamily::Chromium,
561            ArtifactKind::Session,
562            "src",
563            url,
564        )
565        .with_attr("url", json!(url))
566    }
567
568    #[test]
569    fn tabs_open_at_filters_by_time() {
570        let events = vec![
571            tab_event(1000, "https://early.example"),
572            tab_event(5000, "https://late.example"),
573        ];
574        let open = tabs_open_at(&events, 2000);
575        assert_eq!(open.len(), 1);
576        assert_eq!(open[0].attrs["url"], json!("https://early.example"));
577    }
578}