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 query = e
46 .query
47 .iter()
48 .map(|(k, v)| (k.clone(), redact_query_value(k, v, unsafe_include)))
49 .collect();
50 let req_headers = e
51 .req_headers
52 .iter()
53 .map(|(k, v)| (k.clone(), redact_header_value(k, v, unsafe_include)))
54 .collect();
55 let resp_headers = e
56 .resp_headers
57 .iter()
58 .map(|(k, v)| (k.clone(), redact_header_value(k, v, unsafe_include)))
59 .collect();
60 let req_body_snippet = e
61 .req_body
62 .as_deref()
63 .filter(|b| !b.is_empty())
64 .map(|b| redact_body(b, unsafe_include, BODY_MAX));
65 let resp_body_snippet = e
66 .resp_body
67 .as_deref()
68 .filter(|b| !b.is_empty())
69 .map(|b| redact_body(b, unsafe_include, BODY_MAX));
70
71 EntryDetail {
72 id: e.id.clone(),
73 index: e.index,
74 method: e.method.to_ascii_uppercase(),
75 url: redact_url(&e.url, unsafe_include),
76 host: e.host.clone(),
77 norm_path: e.norm_path.clone(),
78 status: e.status,
79 status_text: e.status_text.clone(),
80 http_version: e.http_version.clone(),
81 server_ip: e.server_ip.clone(),
82 resource_type: format!("{:?}", e.resource_type).to_ascii_lowercase(),
83 content_type: e.content_type.clone(),
84 started_offset_ms: e.started_offset_ms,
85 duration_ms: e.duration_ms,
86 query,
87 req_headers,
88 resp_headers,
89 correlation: e.correlation.clone(),
90 timings: PhaseBreakdown::from_phases(&e.timings),
91 req_body_snippet,
92 resp_body_snippet,
93 }
94}
95
96pub fn render_entry_detail_text(d: &EntryDetail) -> String {
98 let mut out = String::new();
99 out.push_str(&format!("== wiretrail entry {} ==\n", d.id));
100 out.push_str(&format!(
101 "{} {} [{}] {}\n",
102 d.method, d.url, d.status, d.status_text
103 ));
104 out.push_str(&format!(
105 "host: {} http: {} type: {}\n",
106 d.host, d.http_version, d.resource_type
107 ));
108 if let Some(ip) = &d.server_ip {
109 out.push_str(&format!("server ip: {ip}\n"));
110 }
111 out.push_str(&format!(
112 "offset: {}ms duration: {}ms\n",
113 d.started_offset_ms as i64, d.duration_ms as i64
114 ));
115 if !d.query.is_empty() {
116 out.push_str("query:\n");
117 for (k, v) in &d.query {
118 out.push_str(&format!(" {k} = {v}\n"));
119 }
120 }
121 out.push_str("request headers:\n");
122 for (k, v) in &d.req_headers {
123 out.push_str(&format!(" {k}: {v}\n"));
124 }
125 out.push_str("response headers:\n");
126 for (k, v) in &d.resp_headers {
127 out.push_str(&format!(" {k}: {v}\n"));
128 }
129 if let Some(b) = &d.req_body_snippet {
130 out.push_str(&format!("request body: {b}\n"));
131 }
132 if let Some(b) = &d.resp_body_snippet {
133 out.push_str(&format!("response body: {b}\n"));
134 }
135 out
136}
137
138#[cfg(test)]
139mod tests {
140 use super::{entry_detail, find_entry};
141 use crate::model::{sample_capture, sample_entry};
142
143 fn cap() -> crate::model::Capture {
144 let mut e = sample_entry(0, "api.x", "POST", "/login", 200);
145 e.req_headers = vec![("Authorization".into(), "Bearer secret".into())];
146 e.query = vec![
147 ("access_token".into(), "leak".into()),
148 ("page".into(), "2".into()),
149 ];
150 e.resp_body = Some(r#"{"token":"abc","ok":true}"#.to_string());
151 let e1 = sample_entry(1, "api.x", "GET", "/other", 200);
152 sample_capture(vec![e, e1])
153 }
154
155 #[test]
156 fn finds_by_id_and_index() {
157 let c = cap();
158 assert_eq!(find_entry(&c, "e000001").unwrap().norm_path, "/other");
159 assert_eq!(find_entry(&c, "1").unwrap().norm_path, "/other");
160 assert!(find_entry(&c, "e999999").is_none());
161 }
162
163 #[test]
164 fn redacts_headers_query_and_body_by_default() {
165 let c = cap();
166 let d = entry_detail(find_entry(&c, "e000000").unwrap(), false);
167 let auth = d
169 .req_headers
170 .iter()
171 .find(|(n, _)| n == "Authorization")
172 .unwrap();
173 assert_eq!(auth.1, "<redacted>");
174 let tok = d.query.iter().find(|(n, _)| n == "access_token").unwrap();
176 assert_eq!(tok.1, "<redacted>");
177 let page = d.query.iter().find(|(n, _)| n == "page").unwrap();
178 assert_eq!(page.1, "2");
179 assert!(!d.resp_body_snippet.as_deref().unwrap().contains("abc"));
181 }
182
183 #[test]
184 fn unsafe_shows_raw() {
185 let c = cap();
186 let d = entry_detail(find_entry(&c, "e000000").unwrap(), true);
187 let auth = d
188 .req_headers
189 .iter()
190 .find(|(n, _)| n == "Authorization")
191 .unwrap();
192 assert_eq!(auth.1, "Bearer secret");
193 }
194
195 #[test]
196 fn url_path_blob_is_redacted_by_default() {
197 let mut e = sample_entry(0, "h.example.com", "GET", "/manifest.json", 200);
198 e.url = "https://h.example.com/cfg/eyJrZXkiOiJzZWNyZXQiLCJuIjoxMjN9==/manifest.json".into();
199 let d = entry_detail(&e, false);
200 assert!(d.url.contains("<redacted>"));
201 assert!(!d.url.contains("eyJrZXki"));
202 let d2 = entry_detail(&e, true);
204 assert!(d2.url.contains("eyJrZXki"));
205 }
206}