Skip to main content

har/analysis/
show_entry.rs

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
33/// Find an entry by its `eNNNNNN` id, or by bare index (`123` or `e123`).
34pub 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
43/// Build a redacted, serializable detail view of one entry.
44pub 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
98/// Render an entry detail as deterministic terminal text.
99pub 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        // header value redacted
170        let auth = d
171            .req_headers
172            .iter()
173            .find(|(n, _)| n == "Authorization")
174            .unwrap();
175        assert_eq!(auth.1, "<redacted>");
176        // sensitive query redacted, safe one kept
177        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        // body token redacted
182        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        // unsafe shows raw
215        let d2 = entry_detail(&e, true);
216        assert!(d2.url.contains("eyJrZXki"));
217    }
218}