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