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 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
62pub 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
105pub 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}