1use 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 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 let crash_id = "247653e8-7a18-4836-97d1-42a720260120";
176 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 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"; assert!(!invalid_id
189 .chars()
190 .all(|c| c.is_ascii_hexdigit() || c == '-'));
191 }
192}