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
10pub struct SocorroClient {
11    base_url: String,
12    client: Client,
13}
14
15impl SocorroClient {
16    pub fn new(base_url: String) -> Self {
17        Self {
18            base_url,
19            client: Client::new(),
20        }
21    }
22
23    fn get_auth_header(&self) -> Option<String> {
24        auth::get_token()
25    }
26
27    pub fn get_crash(&self, crash_id: &str, use_auth: bool) -> Result<ProcessedCrash> {
28        if !crash_id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
29            return Err(Error::InvalidCrashId(crash_id.to_string()));
30        }
31
32        let url = format!("{}/ProcessedCrash/", self.base_url);
33        let mut request = self.client.get(&url).query(&[("crash_id", crash_id)]);
34
35        if use_auth {
36            if let Some(token) = self.get_auth_header() {
37                request = request.header("Auth-Token", token);
38            }
39        }
40
41        let response = request.send()?;
42
43        match response.status() {
44            StatusCode::OK => {
45                let text = response.text()?;
46                serde_json::from_str(&text).map_err(|e| {
47                    Error::ParseError(format!("{}: {}", e, &text[..text.len().min(200)]))
48                })
49            }
50            StatusCode::NOT_FOUND => Err(Error::NotFound(crash_id.to_string())),
51            StatusCode::TOO_MANY_REQUESTS => Err(Error::RateLimited),
52            _ => Err(Error::Http(response.error_for_status().unwrap_err())),
53        }
54    }
55
56    pub fn search(&self, params: SearchParams) -> Result<SearchResponse> {
57        let url = format!("{}/SuperSearch/", self.base_url);
58
59        let mut query_params = vec![
60            ("product", params.product),
61            ("_results_number", params.limit.to_string()),
62            ("_sort", params.sort),
63        ];
64
65        for col in [
66            "uuid",
67            "date",
68            "signature",
69            "product",
70            "version",
71            "platform",
72            "build_id",
73            "release_channel",
74            "platform_version",
75        ] {
76            query_params.push(("_columns", col.to_string()));
77        }
78
79        let days_ago = chrono::Utc::now() - chrono::Duration::days(params.days as i64);
80        query_params.push(("date", format!(">={}", days_ago.format("%Y-%m-%d"))));
81
82        if let Some(sig) = params.signature {
83            query_params.push(("signature", sig));
84        }
85
86        if let Some(ver) = params.version {
87            query_params.push(("version", ver));
88        }
89
90        if let Some(plat) = params.platform {
91            query_params.push(("platform", plat));
92        }
93
94        if let Some(arch) = params.cpu_arch {
95            query_params.push(("cpu_arch", arch));
96        }
97
98        if let Some(channel) = params.release_channel {
99            query_params.push(("release_channel", channel));
100        }
101
102        if let Some(platform_version) = params.platform_version {
103            query_params.push(("platform_version", platform_version));
104        }
105
106        if let Some(process_type) = params.process_type {
107            query_params.push(("process_type", process_type));
108        }
109
110        for facet in params.facets {
111            query_params.push(("_facets", facet));
112        }
113
114        if let Some(size) = params.facets_size {
115            query_params.push(("_facets_size", size.to_string()));
116        }
117
118        let mut request = self.client.get(&url);
119        for (key, value) in query_params {
120            request = request.query(&[(key, value)]);
121        }
122
123        if let Some(token) = self.get_auth_header() {
124            request = request.header("Auth-Token", token);
125        }
126
127        let response = request.send()?;
128
129        match response.status() {
130            StatusCode::OK => {
131                let text = response.text()?;
132                serde_json::from_str(&text).map_err(|e| {
133                    Error::ParseError(format!("{}: {}", e, &text[..text.len().min(200)]))
134                })
135            }
136            StatusCode::TOO_MANY_REQUESTS => Err(Error::RateLimited),
137            _ => Err(Error::Http(response.error_for_status().unwrap_err())),
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn test_client() -> SocorroClient {
147        SocorroClient::new("https://crash-stats.mozilla.org/api".to_string())
148    }
149
150    #[test]
151    fn test_invalid_crash_id_with_spaces() {
152        let client = test_client();
153        let result = client.get_crash("invalid crash id", true);
154        assert!(matches!(result, Err(Error::InvalidCrashId(_))));
155    }
156
157    #[test]
158    fn test_invalid_crash_id_with_special_chars() {
159        let client = test_client();
160        let result = client.get_crash("abc123!@#$", true);
161        assert!(matches!(result, Err(Error::InvalidCrashId(_))));
162    }
163
164    #[test]
165    fn test_invalid_crash_id_with_semicolon() {
166        // This could be an injection attempt
167        let client = test_client();
168        let result = client.get_crash("abc123; DROP TABLE crashes;", true);
169        assert!(matches!(result, Err(Error::InvalidCrashId(_))));
170    }
171
172    #[test]
173    fn test_valid_crash_id_format() {
174        // Valid UUIDs should contain only hex chars and dashes
175        let crash_id = "247653e8-7a18-4836-97d1-42a720260120";
176        // We can't test the full request without mocking, but we can verify
177        // the validation passes by checking the ID is considered valid syntactically
178        assert!(crash_id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
179    }
180
181    #[test]
182    fn test_crash_id_validation_allows_hex_and_dashes() {
183        // Test that the validation logic correctly allows valid characters
184        let valid_id = "abcdef01-2345-6789-abcd-ef0123456789";
185        assert!(valid_id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
186
187        let invalid_id = "abcdef01-2345-6789-abcd-ef012345678g"; // 'g' is not hex
188        assert!(!invalid_id
189            .chars()
190            .all(|c| c.is_ascii_hexdigit() || c == '-'));
191    }
192}