1use crate::model::{Capture, Entry};
2use crate::redact::{redact_body, redact_header_value, redact_query_value, redact_url};
3use crate::timing::PhaseBreakdown;
4use serde::Serialize;
5
6const BODY_MAX: usize = 2000;
7
8#[derive(Debug, Serialize)]
9pub struct EntryDetail {
10 pub id: String,
11 pub index: usize,
12 pub method: String,
13 pub url: String,
14 pub host: String,
15 pub norm_path: String,
16 pub status: i64,
17 pub status_text: String,
18 pub http_version: String,
19 pub server_ip: Option<String>,
20 pub resource_type: String,
21 pub content_type: Option<String>,
22 pub started_offset_ms: f64,
23 pub duration_ms: f64,
24 pub query: Vec<(String, String)>,
25 pub req_headers: Vec<(String, String)>,
26 pub resp_headers: Vec<(String, String)>,
27 pub correlation: Vec<(String, String)>,
28 pub timings: PhaseBreakdown,
29 pub req_body_snippet: Option<String>,
30 pub resp_body_snippet: Option<String>,
31}
32
33pub fn find_entry<'a>(cap: &'a Capture, id_arg: &str) -> Option<&'a Entry> {
35 if let Some(e) = cap.entries.iter().find(|e| e.id == id_arg) {
36 return Some(e);
37 }
38 let digits = id_arg.strip_prefix('e').unwrap_or(id_arg);
39 let idx: usize = digits.parse().ok()?;
40 cap.entries.iter().find(|e| e.index == idx)
41}
42
43pub fn entry_detail(e: &Entry, unsafe_include: bool) -> EntryDetail {
45 let body_max = if unsafe_include { usize::MAX } else { BODY_MAX };
46
47 let query = e
48 .query
49 .iter()
50 .map(|(k, v)| (k.clone(), redact_query_value(k, v, unsafe_include)))
51 .collect();
52 let req_headers = e
53 .req_headers
54 .iter()
55 .map(|(k, v)| (k.clone(), redact_header_value(k, v, unsafe_include)))
56 .collect();
57 let resp_headers = e
58 .resp_headers
59 .iter()
60 .map(|(k, v)| (k.clone(), redact_header_value(k, v, unsafe_include)))
61 .collect();
62 let req_body_snippet = e
63 .req_body
64 .as_deref()
65 .filter(|b| !b.is_empty())
66 .map(|b| redact_body(b, unsafe_include, body_max));
67 let resp_body_snippet = e
68 .resp_body
69 .as_deref()
70 .filter(|b| !b.is_empty())
71 .map(|b| redact_body(b, unsafe_include, body_max));
72
73 EntryDetail {
74 id: e.id.clone(),
75 index: e.index,
76 method: e.method.to_ascii_uppercase(),
77 url: redact_url(&e.url, unsafe_include),
78 host: e.host.clone(),
79 norm_path: e.norm_path.clone(),
80 status: e.status,
81 status_text: e.status_text.clone(),
82 http_version: e.http_version.clone(),
83 server_ip: e.server_ip.clone(),
84 resource_type: format!("{:?}", e.resource_type).to_ascii_lowercase(),
85 content_type: e.content_type.clone(),
86 started_offset_ms: e.started_offset_ms,
87 duration_ms: e.duration_ms,
88 query,
89 req_headers,
90 resp_headers,
91 correlation: e.correlation.clone(),
92 timings: PhaseBreakdown::from_phases(&e.timings),
93 req_body_snippet,
94 resp_body_snippet,
95 }
96}
97
98pub fn render_entry_detail_text(d: &EntryDetail) -> String {
100 let mut out = String::new();
101 out.push_str(&format!("== wiretrail entry {} ==\n", d.id));
102 out.push_str(&format!(
103 "{} {} [{}] {}\n",
104 d.method, d.url, d.status, d.status_text
105 ));
106 out.push_str(&format!(
107 "host: {} http: {} type: {}\n",
108 d.host, d.http_version, d.resource_type
109 ));
110 if let Some(ip) = &d.server_ip {
111 out.push_str(&format!("server ip: {ip}\n"));
112 }
113 out.push_str(&format!(
114 "offset: {}ms duration: {}ms\n",
115 d.started_offset_ms as i64, d.duration_ms as i64
116 ));
117 if !d.query.is_empty() {
118 out.push_str("query:\n");
119 for (k, v) in &d.query {
120 out.push_str(&format!(" {k} = {v}\n"));
121 }
122 }
123 out.push_str("request headers:\n");
124 for (k, v) in &d.req_headers {
125 out.push_str(&format!(" {k}: {v}\n"));
126 }
127 out.push_str("response headers:\n");
128 for (k, v) in &d.resp_headers {
129 out.push_str(&format!(" {k}: {v}\n"));
130 }
131 if let Some(b) = &d.req_body_snippet {
132 out.push_str(&format!("request body: {b}\n"));
133 }
134 if let Some(b) = &d.resp_body_snippet {
135 out.push_str(&format!("response body: {b}\n"));
136 }
137 out
138}
139
140#[cfg(test)]
141mod tests {
142 use super::{BODY_MAX, entry_detail, find_entry};
143 use crate::model::{sample_capture, sample_entry};
144
145 fn cap() -> crate::model::Capture {
146 let mut e = sample_entry(0, "api.x", "POST", "/login", 200);
147 e.req_headers = vec![("Authorization".into(), "Bearer secret".into())];
148 e.query = vec![
149 ("access_token".into(), "leak".into()),
150 ("page".into(), "2".into()),
151 ];
152 e.resp_body = Some(r#"{"token":"abc","ok":true}"#.to_string());
153 let e1 = sample_entry(1, "api.x", "GET", "/other", 200);
154 sample_capture(vec![e, e1])
155 }
156
157 #[test]
158 fn finds_by_id_and_index() {
159 let c = cap();
160 assert_eq!(find_entry(&c, "e000001").unwrap().norm_path, "/other");
161 assert_eq!(find_entry(&c, "1").unwrap().norm_path, "/other");
162 assert!(find_entry(&c, "e999999").is_none());
163 }
164
165 #[test]
166 fn redacts_headers_query_and_body_by_default() {
167 let c = cap();
168 let d = entry_detail(find_entry(&c, "e000000").unwrap(), false);
169 let auth = d
171 .req_headers
172 .iter()
173 .find(|(n, _)| n == "Authorization")
174 .unwrap();
175 assert_eq!(auth.1, "<redacted>");
176 let tok = d.query.iter().find(|(n, _)| n == "access_token").unwrap();
178 assert_eq!(tok.1, "<redacted>");
179 let page = d.query.iter().find(|(n, _)| n == "page").unwrap();
180 assert_eq!(page.1, "2");
181 assert!(!d.resp_body_snippet.as_deref().unwrap().contains("abc"));
183 }
184
185 #[test]
186 fn unsafe_shows_raw() {
187 let c = cap();
188 let d = entry_detail(find_entry(&c, "e000000").unwrap(), true);
189 let auth = d
190 .req_headers
191 .iter()
192 .find(|(n, _)| n == "Authorization")
193 .unwrap();
194 assert_eq!(auth.1, "Bearer secret");
195 }
196
197 #[test]
198 fn unsafe_show_entry_does_not_truncate_body() {
199 let mut e = sample_entry(0, "api.x", "GET", "/manifest.mpd", 200);
200 e.resp_body = Some(format!("{}END_SENTINEL", "x".repeat(BODY_MAX + 50)));
201
202 let d = entry_detail(&e, true);
203
204 assert!(d.resp_body_snippet.as_deref().unwrap().contains("END_SENTINEL"));
205 }
206
207 #[test]
208 fn url_path_blob_is_redacted_by_default() {
209 let mut e = sample_entry(0, "h.example.com", "GET", "/manifest.json", 200);
210 e.url = "https://h.example.com/cfg/eyJrZXkiOiJzZWNyZXQiLCJuIjoxMjN9==/manifest.json".into();
211 let d = entry_detail(&e, false);
212 assert!(d.url.contains("<redacted>"));
213 assert!(!d.url.contains("eyJrZXki"));
214 let d2 = entry_detail(&e, true);
216 assert!(d2.url.contains("eyJrZXki"));
217 }
218}