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