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
40pub 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
100pub 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 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 entries.push(redirect(6, "a.com", "/go", 302, "https://b.com/landing"));
161 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}