Skip to main content

har/analysis/
search.rs

1use crate::filter::Filter;
2use crate::model::Capture;
3use crate::redact::redact_value;
4use serde::Serialize;
5
6const CONTEXT: usize = 40;
7
8#[derive(Debug, Serialize)]
9pub struct SearchResult {
10    pub matches: Vec<SearchMatch>,
11}
12
13#[derive(Debug, Serialize)]
14pub struct SearchMatch {
15    pub id: String,
16    pub location: String,
17    pub snippet: String,
18}
19
20enum Matcher {
21    Regex(regex::Regex),
22    Substr { needle: String, ignore_case: bool },
23}
24
25impl Matcher {
26    /// Byte offset of the first match, if any.
27    fn find(&self, hay: &str) -> Option<usize> {
28        match self {
29            Matcher::Regex(re) => re.find(hay).map(|m| m.start()),
30            Matcher::Substr {
31                needle,
32                ignore_case,
33            } => {
34                if *ignore_case {
35                    hay.to_ascii_lowercase().find(&needle.to_ascii_lowercase())
36                } else {
37                    hay.find(needle)
38                }
39            }
40        }
41    }
42}
43
44fn nearest_boundary(s: &str, mut i: usize, forward: bool) -> usize {
45    i = i.min(s.len());
46    while i > 0 && i < s.len() && !s.is_char_boundary(i) {
47        if forward {
48            i += 1;
49        } else {
50            i -= 1;
51        }
52    }
53    i
54}
55
56fn snippet(body: &str, at: usize, unsafe_include: bool) -> String {
57    let start = nearest_boundary(body, at.saturating_sub(CONTEXT), false);
58    let end = nearest_boundary(body, (at + CONTEXT).min(body.len()), true);
59    redact_value(&body[start..end], unsafe_include)
60}
61
62/// Search request/response bodies for a pattern; redaction-safe snippets.
63pub fn compute_search(
64    cap: &Capture,
65    filter: &Filter,
66    pattern: &str,
67    regex: bool,
68    ignore_case: bool,
69    top: usize,
70    unsafe_include: bool,
71) -> Result<SearchResult, String> {
72    let matcher = if regex {
73        let re = regex::RegexBuilder::new(pattern)
74            .case_insensitive(ignore_case)
75            .build()
76            .map_err(|e| format!("invalid regex: {e}"))?;
77        Matcher::Regex(re)
78    } else {
79        Matcher::Substr {
80            needle: pattern.to_string(),
81            ignore_case,
82        }
83    };
84
85    let mut matches = Vec::new();
86    'outer: for e in cap.entries.iter().filter(|e| filter.matches(e)) {
87        for (loc, body) in [("req.body", &e.req_body), ("resp.body", &e.resp_body)] {
88            if let Some(b) = body.as_deref().filter(|s| !s.is_empty())
89                && let Some(at) = matcher.find(b)
90            {
91                matches.push(SearchMatch {
92                    id: e.id.clone(),
93                    location: loc.to_string(),
94                    snippet: snippet(b, at, unsafe_include),
95                });
96                if matches.len() >= top {
97                    break 'outer;
98                }
99            }
100        }
101    }
102    Ok(SearchResult { matches })
103}
104
105/// Render search matches as deterministic terminal text.
106pub fn render_search_text(r: &SearchResult) -> String {
107    let mut out = String::new();
108    out.push_str("== wiretrail search ==\n");
109    for m in &r.matches {
110        out.push_str(&format!("\n{} ({})\n  …{}…\n", m.id, m.location, m.snippet));
111    }
112    out
113}
114
115#[cfg(test)]
116mod tests {
117    use super::compute_search;
118    use crate::filter::Filter;
119    use crate::model::{Entry, sample_capture, sample_entry};
120
121    fn with_resp(index: usize, body: &str) -> Entry {
122        let mut e = sample_entry(index, "api.x", "GET", "/a", 200);
123        e.resp_body = Some(body.to_string());
124        e
125    }
126
127    #[test]
128    fn substring_match_with_snippet() {
129        let cap = sample_capture(vec![with_resp(0, r#"{"message":"internal error here"}"#)]);
130        let r = compute_search(
131            &cap,
132            &Filter::parse(&[]).unwrap(),
133            "internal error",
134            false,
135            false,
136            10,
137            false,
138        )
139        .unwrap();
140        assert_eq!(r.matches.len(), 1);
141        assert_eq!(r.matches[0].location, "resp.body");
142        assert!(r.matches[0].snippet.contains("internal error"));
143    }
144
145    #[test]
146    fn ignore_case() {
147        let cap = sample_capture(vec![with_resp(0, "Fatal Boom")]);
148        let hit = compute_search(
149            &cap,
150            &Filter::parse(&[]).unwrap(),
151            "fatal",
152            false,
153            true,
154            10,
155            false,
156        )
157        .unwrap();
158        assert_eq!(hit.matches.len(), 1);
159        let miss = compute_search(
160            &cap,
161            &Filter::parse(&[]).unwrap(),
162            "fatal",
163            false,
164            false,
165            10,
166            false,
167        )
168        .unwrap();
169        assert!(miss.matches.is_empty());
170    }
171
172    #[test]
173    fn regex_match() {
174        let cap = sample_capture(vec![with_resp(0, r#"{"code":"E1234"}"#)]);
175        let r = compute_search(
176            &cap,
177            &Filter::parse(&[]).unwrap(),
178            r"E\d{4}",
179            true,
180            false,
181            10,
182            false,
183        )
184        .unwrap();
185        assert_eq!(r.matches.len(), 1);
186    }
187
188    #[test]
189    fn invalid_regex_errors() {
190        let cap = sample_capture(vec![with_resp(0, "x")]);
191        assert!(
192            compute_search(
193                &cap,
194                &Filter::parse(&[]).unwrap(),
195                "(",
196                true,
197                false,
198                10,
199                false
200            )
201            .is_err()
202        );
203    }
204
205    #[test]
206    fn secret_in_snippet_is_redacted() {
207        let cap = sample_capture(vec![with_resp(
208            0,
209            "token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9abcXYZ123 end",
210        )]);
211        let r = compute_search(
212            &cap,
213            &Filter::parse(&[]).unwrap(),
214            "token",
215            false,
216            false,
217            10,
218            false,
219        )
220        .unwrap();
221        assert!(!r.matches[0].snippet.contains("eyJhbGci"));
222    }
223}