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::{Error, Result, auth};
7use reqwest::StatusCode;
8use reqwest::blocking::Client;
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 && 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        // This could be an injection attempt
330        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        // Valid UUIDs should contain only hex chars and dashes
338        let crash_id = "247653e8-7a18-4836-97d1-42a720260120";
339        // We can't test the full request without mocking, but we can verify
340        // the validation passes by checking the ID is considered valid syntactically
341        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        // Test that the validation logic correctly allows valid characters
347        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"; // 'g' is not hex
351        assert!(
352            !invalid_id
353                .chars()
354                .all(|c| c.is_ascii_hexdigit() || c == '-')
355        );
356    }
357}