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