Skip to main content

socorro_cli/
client.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5use crate::models::bugs::BugsResponse;
6use crate::models::{ProcessedCrash, SearchParams, SearchResponse};
7use crate::{Error, Result, auth};
8use reqwest::StatusCode;
9use reqwest::blocking::Client;
10
11/// Push a SuperSearch filter parameter onto `query_params`.
12///
13/// The SuperSearch API has two kinds of filter fields:
14///   - **String fields** (signature, proto_signature, platform_version, process_type):
15///     The API default (no prefix) does a word-level match, NOT exact match.
16///     We prepend `=` for exact match, unless the user already provided an
17///     operator prefix (~, $, ^, !, @, etc.).
18///   - **Enum fields** (product, version, platform, cpu_arch, release_channel, …):
19///     The API default already does exact match.  Prepending `=` silently
20///     returns 0 results.  Values are passed through unchanged.
21///
22/// This function decides which behaviour to apply based on `field`.
23/// When adding a new filter field, check its type in the SuperSearch API docs
24/// (https://crash-stats.mozilla.org/documentation/supersearch/api/) and add it
25/// to STRING_FIELDS if it is a "string" type.
26fn push_filter(query_params: &mut Vec<(&str, String)>, field: &'static str, value: String) {
27    /// Fields typed "string" in the SuperSearch API.
28    /// Verify against https://crash-stats.mozilla.org/documentation/supersearch/api/
29    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
43/// Prepend `=` to make the Socorro SuperSearch API perform an exact match,
44/// unless the value already has a SuperSearch operator prefix.
45/// See https://github.com/mozilla-services/socorro/blob/main/webapp/crashstats/supersearch/form_fields.py
46fn exact_match_default(value: String) -> String {
47    const PREFIXES: &[&str] = &[
48        // Negated operators (check longest first)
49        "!__true__",
50        "!__null__",
51        "!$",
52        "!~",
53        "!^",
54        "!@",
55        "!=",
56        "!",
57        // Special tokens
58        "__true__",
59        "__null__",
60        // Single-char operators
61        "=",
62        "~",
63        "$",
64        "^",
65        "@",
66        // Comparison operators (two-char before one-char)
67        "<=",
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        // This could be an injection attempt
383        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        // Valid UUIDs should contain only hex chars and dashes
391        let crash_id = "247653e8-7a18-4836-97d1-42a720260120";
392        // We can't test the full request without mocking, but we can verify
393        // the validation passes by checking the ID is considered valid syntactically
394        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        // Test that the validation logic correctly allows valid characters
400        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"; // 'g' is not hex
404        assert!(
405            !invalid_id
406                .chars()
407                .all(|c| c.is_ascii_hexdigit() || c == '-')
408        );
409    }
410}