Skip to main content

har/analysis/
redirects.rs

1use crate::filter::Filter;
2use crate::model::{Capture, Entry};
3use ahash::AHashMap;
4use serde::Serialize;
5
6const STORM_THRESHOLD: usize = 5;
7const REDIRECT_STATUSES: &[i64] = &[301, 302, 303, 307, 308];
8
9#[derive(Debug, Serialize)]
10pub struct RedirectsResult {
11    pub groups: Vec<RedirectGroup>,
12}
13
14#[derive(Debug, Serialize)]
15pub struct RedirectGroup {
16    pub host: String,
17    pub method: String,
18    pub norm_path: String,
19    pub status: i64,
20    pub count: usize,
21    pub target_host: Option<String>,
22    pub cross_host: bool,
23    pub is_storm: bool,
24    pub entry_ids: Vec<String>,
25    pub first_offset_ms: f64,
26    pub last_offset_ms: f64,
27}
28
29fn is_redirect(e: &Entry) -> bool {
30    REDIRECT_STATUSES.contains(&e.status)
31        || e.redirect_url.as_deref().is_some_and(|u| !u.is_empty())
32}
33
34fn host_of(url: &str) -> Option<String> {
35    url::Url::parse(url)
36        .ok()
37        .and_then(|u| u.host_str().map(|h| h.to_string()))
38}
39
40/// Group redirect responses by (host, method, norm_path, status); flag storms
41/// (count >= 5) and cross-host hops. `top` bounds the list.
42pub fn compute_redirects(cap: &Capture, filter: &Filter, top: usize) -> RedirectsResult {
43    let mut by_key: AHashMap<(String, String, String, i64), Vec<&Entry>> = AHashMap::new();
44    for e in cap
45        .entries
46        .iter()
47        .filter(|e| filter.matches(e) && is_redirect(e))
48    {
49        let key = (
50            e.host.clone(),
51            e.method.to_ascii_uppercase(),
52            e.norm_path.clone(),
53            e.status,
54        );
55        by_key.entry(key).or_default().push(e);
56    }
57
58    let mut groups: Vec<RedirectGroup> = by_key
59        .into_iter()
60        .map(|((host, method, norm_path, status), mut g)| {
61            g.sort_by(|a, b| {
62                a.started_offset_ms
63                    .partial_cmp(&b.started_offset_ms)
64                    .unwrap_or(std::cmp::Ordering::Equal)
65                    .then(a.index.cmp(&b.index))
66            });
67            let target_host = g
68                .iter()
69                .find_map(|e| e.redirect_url.as_deref())
70                .and_then(host_of);
71            let cross_host = target_host
72                .as_deref()
73                .is_some_and(|t| !t.is_empty() && t != host);
74            RedirectGroup {
75                count: g.len(),
76                is_storm: g.len() >= STORM_THRESHOLD,
77                cross_host,
78                target_host,
79                entry_ids: g.iter().map(|e| e.id.clone()).collect(),
80                first_offset_ms: g.first().map(|e| e.started_offset_ms).unwrap_or(0.0),
81                last_offset_ms: g.last().map(|e| e.started_offset_ms).unwrap_or(0.0),
82                host,
83                method,
84                norm_path,
85                status,
86            }
87        })
88        .collect();
89
90    groups.sort_by(|a, b| {
91        b.count
92            .cmp(&a.count)
93            .then(a.host.cmp(&b.host))
94            .then(a.norm_path.cmp(&b.norm_path))
95    });
96    groups.truncate(top);
97    RedirectsResult { groups }
98}
99
100/// Render redirects as deterministic terminal text.
101pub fn render_redirects_text(r: &RedirectsResult) -> String {
102    let mut out = String::new();
103    out.push_str("== wiretrail redirects ==\n");
104    for g in &r.groups {
105        let mut tags = Vec::new();
106        if g.is_storm {
107            tags.push("storm");
108        }
109        if g.cross_host {
110            tags.push("cross-host");
111        }
112        let tagstr = if tags.is_empty() {
113            String::new()
114        } else {
115            format!(" [{}]", tags.join(", "))
116        };
117        out.push_str(&format!(
118            "\n{:>4}x  [{}] {} {}{}{}\n",
119            g.count, g.status, g.method, g.host, g.norm_path, tagstr
120        ));
121        if let Some(t) = &g.target_host {
122            out.push_str(&format!("  -> {t}\n"));
123        }
124        out.push_str(&format!("  entries: {}\n", g.entry_ids.join(", ")));
125    }
126    out
127}
128
129#[cfg(test)]
130mod tests {
131    use super::compute_redirects;
132    use crate::filter::Filter;
133    use crate::model::{sample_capture, sample_entry};
134
135    fn redirect(
136        index: usize,
137        host: &str,
138        path: &str,
139        status: i64,
140        target: &str,
141    ) -> crate::model::Entry {
142        let mut e = sample_entry(index, host, "GET", path, status);
143        e.redirect_url = Some(target.to_string());
144        e
145    }
146
147    fn cap() -> crate::model::Capture {
148        let mut entries = Vec::new();
149        // 6 x 308 to torii manifest -> storm
150        for i in 0..6 {
151            entries.push(redirect(
152                i,
153                "torii.app",
154                "/manifest.json",
155                308,
156                "https://torii.app/v2/manifest.json",
157            ));
158        }
159        // one cross-host 302
160        entries.push(redirect(6, "a.com", "/go", 302, "https://b.com/landing"));
161        // a normal 200 (ignored)
162        entries.push(sample_entry(7, "a.com", "GET", "/ok", 200));
163        sample_capture(entries)
164    }
165
166    #[test]
167    fn groups_redirects_and_flags_storm() {
168        let r = compute_redirects(&cap(), &Filter::parse(&[]).unwrap(), 10);
169        let storm = r
170            .groups
171            .iter()
172            .find(|g| g.norm_path == "/manifest.json")
173            .unwrap();
174        assert_eq!(storm.count, 6);
175        assert_eq!(storm.status, 308);
176        assert!(storm.is_storm);
177        assert!(!storm.cross_host);
178    }
179
180    #[test]
181    fn flags_cross_host() {
182        let r = compute_redirects(&cap(), &Filter::parse(&[]).unwrap(), 10);
183        let x = r.groups.iter().find(|g| g.norm_path == "/go").unwrap();
184        assert!(x.cross_host);
185        assert_eq!(x.target_host.as_deref(), Some("b.com"));
186    }
187
188    #[test]
189    fn ignores_non_redirects() {
190        let r = compute_redirects(&cap(), &Filter::parse(&[]).unwrap(), 10);
191        assert!(r.groups.iter().all(|g| g.norm_path != "/ok"));
192    }
193}