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 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
96/// Render an entry detail as deterministic terminal text.
97pub 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        // header value redacted
168        let auth = d
169            .req_headers
170            .iter()
171            .find(|(n, _)| n == "Authorization")
172            .unwrap();
173        assert_eq!(auth.1, "<redacted>");
174        // sensitive query redacted, safe one kept
175        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        // body token redacted
180        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        // unsafe shows raw
203        let d2 = entry_detail(&e, true);
204        assert!(d2.url.contains("eyJrZXki"));
205    }
206}