rusty_stack/telemetry/
submit.rs1use crate::core::telemetry_types::TelemetryPayload;
22use crate::telemetry::payload::serialize_payload;
23use anyhow::{Context, Result};
24use std::sync::mpsc;
25
26pub const DEFAULT_ENDPOINT: &str = "https://telemetry.rusty-stack.example.com/api/v1/submit";
28
29pub const THANK_YOU_MESSAGE: &str =
31 "✓ Thank you! Your anonymous hardware data has been submitted successfully.";
32
33const SUBMISSION_ERROR_PREFIX: &str = "Telemetry submission failed:";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum SubmitOutcome {
39 Success,
41 Failed(String),
43 HttpRejected,
45}
46
47pub fn validate_https_url(url: &str) -> Result<()> {
53 let trimmed = url.trim();
54
55 if trimmed.starts_with("https://") {
56 Ok(())
57 } else if trimmed.starts_with("http://") {
58 anyhow::bail!(
59 "HTTP URL rejected — telemetry must be submitted over HTTPS only. URL: {}",
60 trimmed
61 )
62 } else {
63 anyhow::bail!(
64 "Invalid telemetry URL — must start with https://. URL: {}",
65 trimmed
66 )
67 }
68}
69
70pub fn submit_payload(payload: &TelemetryPayload, endpoint: &str) -> Result<SubmitOutcome> {
83 if validate_https_url(endpoint).is_err() {
85 return Ok(SubmitOutcome::HttpRejected);
86 }
87
88 let json = serialize_payload(payload).context("Payload validation failed")?;
90
91 let rt = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
93 let result = rt.block_on(async {
94 let client = match reqwest::Client::builder()
95 .timeout(std::time::Duration::from_secs(30))
96 .build()
97 {
98 Ok(c) => c,
99 Err(e) => {
100 return SubmitOutcome::Failed(format!("Failed to build HTTP client: {}", e));
101 }
102 };
103
104 let response = client
105 .post(endpoint)
106 .header("Content-Type", "application/json")
107 .body(json)
108 .send()
109 .await;
110
111 match response {
112 Ok(resp) => {
113 let status = resp.status();
114 if status == reqwest::StatusCode::OK {
115 SubmitOutcome::Success
116 } else {
117 SubmitOutcome::Failed(format!("Server returned HTTP {}", status))
118 }
119 }
120 Err(e) => SubmitOutcome::Failed(format!("Network error: {}", e)),
121 }
122 });
123
124 Ok(result)
125}
126
127pub fn submit_fire_and_forget(payload: TelemetryPayload, endpoint: String) -> mpsc::Sender<()> {
135 let (tx, rx) = mpsc::channel();
136
137 std::thread::spawn(move || {
138 let outcome = submit_payload(&payload, &endpoint);
140
141 match outcome {
142 Ok(SubmitOutcome::Success) => {
143 println!("{}", THANK_YOU_MESSAGE);
145 }
146 Ok(SubmitOutcome::Failed(msg)) => {
147 eprintln!("{} {}", SUBMISSION_ERROR_PREFIX, msg);
149 }
150 Ok(SubmitOutcome::HttpRejected) => {
151 eprintln!(
152 "{} HTTP URL rejected — telemetry requires HTTPS",
153 SUBMISSION_ERROR_PREFIX
154 );
155 }
156 Err(e) => {
157 eprintln!("{} {}", SUBMISSION_ERROR_PREFIX, e);
158 }
159 }
160
161 let _ = rx.recv();
163 });
164
165 tx
166}
167
168pub fn submit_blocking(payload: &TelemetryPayload, endpoint: &str) -> Result<SubmitOutcome> {
174 submit_payload(payload, endpoint)
175}
176
177#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
190 fn test_submit_https_url_accepted() {
191 let result = validate_https_url("https://telemetry.example.com/submit");
192 assert!(result.is_ok(), "HTTPS URL should be accepted: {:?}", result);
193 }
194
195 #[test]
196 fn test_submit_http_url_rejected_before_network() {
197 let result = validate_https_url("http://telemetry.example.com/submit");
198 assert!(
199 result.is_err(),
200 "HTTP URL should be rejected before network access"
201 );
202 let msg = result.unwrap_err().to_string();
203 assert!(
204 msg.contains("HTTP URL rejected"),
205 "Error should mention rejection: {}",
206 msg
207 );
208 assert!(
209 msg.contains("HTTPS only"),
210 "Error should mention HTTPS requirement: {}",
211 msg
212 );
213 }
214
215 #[test]
216 fn test_submit_http_url_returns_http_rejected_outcome() {
217 let payload = create_test_payload();
218 let outcome = submit_payload(&payload, "http://telemetry.example.com/submit")
219 .expect("Should return outcome without error");
220 assert_eq!(
221 outcome,
222 SubmitOutcome::HttpRejected,
223 "HTTP URL should produce HttpRejected outcome"
224 );
225 }
226
227 #[test]
228 fn test_submit_invalid_url_rejected() {
229 let result = validate_https_url("ftp://telemetry.example.com/submit");
230 assert!(result.is_err(), "Non-HTTP URL should be rejected");
231 let msg = result.unwrap_err().to_string();
232 assert!(
233 msg.contains("must start with https://"),
234 "Error should mention https:// requirement: {}",
235 msg
236 );
237 }
238
239 #[test]
240 fn test_submit_empty_url_rejected() {
241 let result = validate_https_url("");
242 assert!(result.is_err(), "Empty URL should be rejected");
243 }
244
245 #[test]
246 fn test_submit_https_with_path_accepted() {
247 let result = validate_https_url("https://example.com/api/v1/submit");
248 assert!(result.is_ok(), "HTTPS URL with path should be accepted");
249 }
250
251 #[test]
252 fn test_submit_https_with_port_accepted() {
253 let result = validate_https_url("https://example.com:8443/submit");
254 assert!(result.is_ok(), "HTTPS URL with port should be accepted");
255 }
256
257 #[test]
262 fn test_thank_you_message_is_non_empty() {
263 assert!(
264 !THANK_YOU_MESSAGE.is_empty(),
265 "Thank-you message must not be empty"
266 );
267 assert!(
268 THANK_YOU_MESSAGE.contains("Thank you"),
269 "Thank-you message must contain 'Thank you'"
270 );
271 }
272
273 #[test]
274 fn test_submit_outcome_success_display() {
275 let outcome = SubmitOutcome::Success;
276 assert_eq!(outcome, SubmitOutcome::Success);
277 }
278
279 #[test]
280 fn test_submit_outcome_failed_contains_message() {
281 let outcome = SubmitOutcome::Failed("Server returned HTTP 500".to_string());
282 if let SubmitOutcome::Failed(msg) = outcome {
283 assert!(msg.contains("500"));
284 } else {
285 panic!("Expected Failed variant");
286 }
287 }
288
289 #[test]
290 fn test_submit_outcome_http_rejected() {
291 let outcome = SubmitOutcome::HttpRejected;
292 assert_eq!(outcome, SubmitOutcome::HttpRejected);
293 }
294
295 #[test]
300 fn test_fire_and_forget_returns_immediately() {
301 let payload = create_test_payload();
302 let endpoint = "https://nonexistent.invalid/submit".to_string();
303
304 let tx = submit_fire_and_forget(payload, endpoint);
306
307 drop(tx);
309
310 }
312
313 #[test]
314 fn test_fire_and_forget_with_http_url_does_not_panic() {
315 let payload = create_test_payload();
316 let endpoint = "http://insecure.example.com/submit".to_string();
317
318 let tx = submit_fire_and_forget(payload, endpoint);
319 drop(tx);
320
321 }
323
324 #[test]
329 fn test_default_endpoint_is_https() {
330 assert!(
331 DEFAULT_ENDPOINT.starts_with("https://"),
332 "Default endpoint must use HTTPS: {}",
333 DEFAULT_ENDPOINT
334 );
335 }
336
337 #[test]
342 fn test_submit_to_nonexistent_host_returns_failed() {
343 let payload = create_test_payload();
344 let outcome = submit_payload(&payload, "https://nonexistent.invalid/submit")
345 .expect("Should return outcome");
346
347 assert!(
348 matches!(outcome, SubmitOutcome::Failed(_)),
349 "Should fail for nonexistent host: {:?}",
350 outcome
351 );
352 }
353
354 #[test]
359 fn test_submit_blocking_http_rejected() {
360 let payload = create_test_payload();
361 let outcome =
362 submit_blocking(&payload, "http://example.com/submit").expect("Should return outcome");
363 assert_eq!(outcome, SubmitOutcome::HttpRejected);
364 }
365
366 fn create_test_payload() -> crate::core::telemetry_types::TelemetryPayload {
371 crate::core::telemetry_types::TelemetryPayload::new(
372 "gfx1100",
373 "AMD Radeon RX 7900 XTX",
374 "7.2.1",
375 1,
376 16,
377 54.0,
378 )
379 }
380}