1use crate::models::{ProcessedCrash, SearchParams, SearchResponse};
6use crate::{auth, Error, Result};
7use reqwest::blocking::Client;
8use reqwest::StatusCode;
9
10fn push_filter(query_params: &mut Vec<(&str, String)>, field: &'static str, value: String) {
26 const STRING_FIELDS: &[&str] = &[
29 "signature",
30 "proto_signature",
31 "platform_version",
32 "process_type",
33 ];
34
35 if STRING_FIELDS.contains(&field) {
36 query_params.push((field, exact_match_default(value)));
37 } else {
38 query_params.push((field, value));
39 }
40}
41
42fn exact_match_default(value: String) -> String {
46 const PREFIXES: &[&str] = &[
47 "!__true__",
49 "!__null__",
50 "!$",
51 "!~",
52 "!^",
53 "!@",
54 "!=",
55 "!",
56 "__true__",
58 "__null__",
59 "=",
61 "~",
62 "$",
63 "^",
64 "@",
65 "<=",
67 ">=",
68 "<",
69 ">",
70 ];
71 if PREFIXES.iter().any(|p| value.starts_with(p)) {
72 value
73 } else {
74 format!("={}", value)
75 }
76}
77
78pub struct SocorroClient {
79 base_url: String,
80 client: Client,
81}
82
83impl SocorroClient {
84 pub fn new(base_url: String) -> Self {
85 Self {
86 base_url,
87 client: Client::new(),
88 }
89 }
90
91 fn get_auth_header(&self) -> Option<String> {
92 auth::get_token()
93 }
94
95 pub fn get_crash(&self, crash_id: &str, use_auth: bool) -> Result<ProcessedCrash> {
96 if !crash_id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
97 return Err(Error::InvalidCrashId(crash_id.to_string()));
98 }
99
100 let url = format!("{}/ProcessedCrash/", self.base_url);
101 let mut request = self.client.get(&url).query(&[("crash_id", crash_id)]);
102
103 if use_auth {
104 if let Some(token) = self.get_auth_header() {
105 request = request.header("Auth-Token", token);
106 }
107 }
108
109 let response = request.send()?;
110
111 match response.status() {
112 StatusCode::OK => {
113 let text = response.text()?;
114 serde_json::from_str(&text).map_err(|e| {
115 Error::ParseError(format!("{}: {}", e, &text[..text.len().min(200)]))
116 })
117 }
118 StatusCode::NOT_FOUND => Err(Error::NotFound(crash_id.to_string())),
119 StatusCode::TOO_MANY_REQUESTS => Err(Error::RateLimited),
120 _ => Err(Error::Http(response.error_for_status().unwrap_err())),
121 }
122 }
123
124 pub fn search(&self, params: SearchParams) -> Result<SearchResponse> {
125 let url = format!("{}/SuperSearch/", self.base_url);
126
127 let mut query_params = vec![
128 ("product", params.product),
129 ("_results_number", params.limit.to_string()),
130 ("_sort", params.sort),
131 ];
132
133 for col in [
134 "uuid",
135 "date",
136 "signature",
137 "product",
138 "version",
139 "platform",
140 "build_id",
141 "release_channel",
142 "platform_version",
143 ] {
144 query_params.push(("_columns", col.to_string()));
145 }
146
147 query_params.push(("date", format!(">={}", params.date_from)));
148 if let Some(ref to) = params.date_to {
149 let end = chrono::NaiveDate::parse_from_str(to, "%Y-%m-%d").unwrap()
150 + chrono::Duration::days(1);
151 query_params.push(("date", format!("<{}", end.format("%Y-%m-%d"))));
152 }
153
154 if let Some(sig) = params.signature {
155 push_filter(&mut query_params, "signature", sig);
156 }
157
158 if let Some(proto_sig) = params.proto_signature {
159 push_filter(&mut query_params, "proto_signature", proto_sig);
160 }
161
162 if let Some(ver) = params.version {
163 push_filter(&mut query_params, "version", ver);
164 }
165
166 if let Some(plat) = params.platform {
167 push_filter(&mut query_params, "platform", plat);
168 }
169
170 if let Some(arch) = params.cpu_arch {
171 push_filter(&mut query_params, "cpu_arch", arch);
172 }
173
174 if let Some(channel) = params.release_channel {
175 push_filter(&mut query_params, "release_channel", channel);
176 }
177
178 if let Some(platform_version) = params.platform_version {
179 push_filter(&mut query_params, "platform_version", platform_version);
180 }
181
182 if let Some(process_type) = params.process_type {
183 push_filter(&mut query_params, "process_type", process_type);
184 }
185
186 for facet in params.facets {
187 query_params.push(("_facets", facet));
188 }
189
190 if let Some(size) = params.facets_size {
191 query_params.push(("_facets_size", size.to_string()));
192 }
193
194 let mut request = self.client.get(&url);
195 for (key, value) in query_params {
196 request = request.query(&[(key, value)]);
197 }
198
199 if let Some(token) = self.get_auth_header() {
200 request = request.header("Auth-Token", token);
201 }
202
203 let response = request.send()?;
204
205 match response.status() {
206 StatusCode::OK => {
207 let text = response.text()?;
208 serde_json::from_str(&text).map_err(|e| {
209 Error::ParseError(format!("{}: {}", e, &text[..text.len().min(200)]))
210 })
211 }
212 StatusCode::TOO_MANY_REQUESTS => Err(Error::RateLimited),
213 _ => Err(Error::Http(response.error_for_status().unwrap_err())),
214 }
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn test_client() -> SocorroClient {
223 SocorroClient::new("https://crash-stats.mozilla.org/api".to_string())
224 }
225
226 #[test]
227 fn test_exact_match_default_plain_value() {
228 assert_eq!(
229 exact_match_default("OOM | small".to_string()),
230 "=OOM | small"
231 );
232 }
233
234 #[test]
235 fn test_exact_match_default_contains_prefix() {
236 assert_eq!(
237 exact_match_default("~AudioDecoder".to_string()),
238 "~AudioDecoder"
239 );
240 }
241
242 #[test]
243 fn test_exact_match_default_exact_prefix() {
244 assert_eq!(
245 exact_match_default("=OOM | small".to_string()),
246 "=OOM | small"
247 );
248 }
249
250 #[test]
251 fn test_exact_match_default_starts_with_prefix() {
252 assert_eq!(exact_match_default("$OOM".to_string()), "$OOM");
253 }
254
255 #[test]
256 fn test_exact_match_default_not_prefix() {
257 assert_eq!(
258 exact_match_default("!OOM | small".to_string()),
259 "!OOM | small"
260 );
261 }
262
263 #[test]
264 fn test_exact_match_default_negated_contains_prefix() {
265 assert_eq!(
266 exact_match_default("!~AudioDecoder".to_string()),
267 "!~AudioDecoder"
268 );
269 }
270
271 #[test]
272 fn test_exact_match_default_regex_prefix() {
273 assert_eq!(
274 exact_match_default("@OOM.*small".to_string()),
275 "@OOM.*small"
276 );
277 }
278
279 #[test]
280 fn test_exact_match_default_greater_than_prefix() {
281 assert_eq!(exact_match_default(">10.0".to_string()), ">10.0");
282 }
283
284 #[test]
285 fn test_exact_match_default_greater_equal_prefix() {
286 assert_eq!(exact_match_default(">=120.0".to_string()), ">=120.0");
287 }
288
289 #[test]
290 fn test_exact_match_default_null_token() {
291 assert_eq!(exact_match_default("__null__".to_string()), "__null__");
292 }
293
294 #[test]
295 fn test_push_filter_string_field_gets_exact_prefix() {
296 let mut params = vec![];
297 push_filter(&mut params, "signature", "OOM | small".to_string());
298 assert_eq!(params[0], ("signature", "=OOM | small".to_string()));
299 }
300
301 #[test]
302 fn test_push_filter_string_field_preserves_operator() {
303 let mut params = vec![];
304 push_filter(&mut params, "signature", "~AudioDecoder".to_string());
305 assert_eq!(params[0], ("signature", "~AudioDecoder".to_string()));
306 }
307
308 #[test]
309 fn test_push_filter_enum_field_no_prefix() {
310 let mut params = vec![];
311 push_filter(&mut params, "release_channel", "nightly".to_string());
312 assert_eq!(params[0], ("release_channel", "nightly".to_string()));
313 }
314
315 #[test]
316 fn test_invalid_crash_id_with_spaces() {
317 let client = test_client();
318 let result = client.get_crash("invalid crash id", true);
319 assert!(matches!(result, Err(Error::InvalidCrashId(_))));
320 }
321
322 #[test]
323 fn test_invalid_crash_id_with_special_chars() {
324 let client = test_client();
325 let result = client.get_crash("abc123!@#$", true);
326 assert!(matches!(result, Err(Error::InvalidCrashId(_))));
327 }
328
329 #[test]
330 fn test_invalid_crash_id_with_semicolon() {
331 let client = test_client();
333 let result = client.get_crash("abc123; DROP TABLE crashes;", true);
334 assert!(matches!(result, Err(Error::InvalidCrashId(_))));
335 }
336
337 #[test]
338 fn test_valid_crash_id_format() {
339 let crash_id = "247653e8-7a18-4836-97d1-42a720260120";
341 assert!(crash_id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
344 }
345
346 #[test]
347 fn test_crash_id_validation_allows_hex_and_dashes() {
348 let valid_id = "abcdef01-2345-6789-abcd-ef0123456789";
350 assert!(valid_id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
351
352 let invalid_id = "abcdef01-2345-6789-abcd-ef012345678g"; assert!(!invalid_id
354 .chars()
355 .all(|c| c.is_ascii_hexdigit() || c == '-'));
356 }
357}