Skip to main content

rusty_stack/telemetry/
submit.rs

1//! HTTPS telemetry submission client.
2//!
3//! Submits anonymous telemetry payloads exclusively over HTTPS.
4//! HTTP URLs are rejected before any network access occurs.
5//!
6//! # Design
7//!
8//! - **HTTPS-only**: HTTP URLs are rejected at validation time, before any
9//!   network connection is made.
10//! - **Fire-and-forget**: Submission runs in a background task via
11//!   [`submit_fire_and_forget`] and does not block the main pipeline.
12//! - **Thank-you confirmation**: On successful submission (HTTP 200), a
13//!   thank-you message is displayed to the user.
14//!
15//! # Validation Contract
16//!
17//! - **VAL-TELE-008**: Submission exclusively over HTTPS, HTTP rejected.
18//! - **VAL-TELE-009**: Thank-you confirmation displayed after successful
19//!   submission.
20
21use crate::core::telemetry_types::TelemetryPayload;
22use crate::telemetry::payload::serialize_payload;
23use anyhow::{Context, Result};
24use std::sync::mpsc;
25
26/// Default telemetry submission endpoint.
27pub const DEFAULT_ENDPOINT: &str = "https://telemetry.rusty-stack.example.com/api/v1/submit";
28
29/// Thank-you message displayed after successful submission.
30pub const THANK_YOU_MESSAGE: &str =
31    "✓ Thank you! Your anonymous hardware data has been submitted successfully.";
32
33/// Error message when submission fails (non-blocking).
34const SUBMISSION_ERROR_PREFIX: &str = "Telemetry submission failed:";
35
36/// Possible outcomes of a telemetry submission attempt.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum SubmitOutcome {
39    /// Submission succeeded (HTTP 200 OK).
40    Success,
41    /// Submission failed with an error message.
42    Failed(String),
43    /// URL was rejected because it is not HTTPS.
44    HttpRejected,
45}
46
47/// Validate that a URL uses the HTTPS scheme.
48///
49/// Returns `Ok(())` if the URL starts with `https://`, or an error
50/// describing why it was rejected. This check happens **before** any
51/// network access.
52pub 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
70/// Submit a telemetry payload to the given endpoint synchronously.
71///
72/// This function:
73/// 1. Validates the URL is HTTPS (rejects HTTP before network access)
74/// 2. Serializes and validates the payload
75/// 3. Sends the payload via HTTPS POST
76/// 4. Returns [`SubmitOutcome::Success`] on HTTP 200
77///
78/// # Errors
79///
80/// Returns [`SubmitOutcome::HttpRejected`] for HTTP URLs.
81/// Returns [`SubmitOutcome::Failed`] for network or server errors.
82pub fn submit_payload(payload: &TelemetryPayload, endpoint: &str) -> Result<SubmitOutcome> {
83    // Step 1: Validate HTTPS (VAL-TELE-008)
84    if validate_https_url(endpoint).is_err() {
85        return Ok(SubmitOutcome::HttpRejected);
86    }
87
88    // Step 2: Serialize and validate payload
89    let json = serialize_payload(payload).context("Payload validation failed")?;
90
91    // Step 3: Submit via HTTPS
92    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
127/// Fire-and-forget telemetry submission.
128///
129/// Spawns a background thread to submit the payload. Does not block
130/// the caller. The submission result is logged but not returned.
131///
132/// This is the primary submission API for the main pipeline — it adds
133/// no measurable latency since it returns immediately.
134pub fn submit_fire_and_forget(payload: TelemetryPayload, endpoint: String) -> mpsc::Sender<()> {
135    let (tx, rx) = mpsc::channel();
136
137    std::thread::spawn(move || {
138        // Attempt submission
139        let outcome = submit_payload(&payload, &endpoint);
140
141        match outcome {
142            Ok(SubmitOutcome::Success) => {
143                // VAL-TELE-009: Display thank-you confirmation
144                println!("{}", THANK_YOU_MESSAGE);
145            }
146            Ok(SubmitOutcome::Failed(msg)) => {
147                // Non-blocking: log error but don't fail pipeline
148                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        // Keep the thread alive until caller drops the sender
162        let _ = rx.recv();
163    });
164
165    tx
166}
167
168/// Submit a telemetry payload and return the outcome directly.
169///
170/// Unlike [`submit_fire_and_forget`], this function blocks until
171/// the submission completes (or fails) and returns the result.
172/// Useful for testing and CLI tools that want to report the outcome.
173pub fn submit_blocking(payload: &TelemetryPayload, endpoint: &str) -> Result<SubmitOutcome> {
174    submit_payload(payload, endpoint)
175}
176
177// ===========================================================================
178// Tests
179// ===========================================================================
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    // =======================================================================
186    // VAL-TELE-008: Submission — HTTPS Transport
187    // =======================================================================
188
189    #[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    // =======================================================================
258    // VAL-TELE-009: Submission — Thank-You Confirmation
259    // =======================================================================
260
261    #[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    // =======================================================================
296    // Fire-and-forget behavior
297    // =======================================================================
298
299    #[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        // This should return immediately (fire-and-forget)
305        let tx = submit_fire_and_forget(payload, endpoint);
306
307        // Drop the sender to let the background thread finish
308        drop(tx);
309
310        // If we got here, the function returned immediately
311    }
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        // Should not panic even with HTTP URL
322    }
323
324    // =======================================================================
325    // Default endpoint
326    // =======================================================================
327
328    #[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    // =======================================================================
338    // Network failure handling (real HTTPS request to nonexistent host)
339    // =======================================================================
340
341    #[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    // =======================================================================
355    // Blocking submit
356    // =======================================================================
357
358    #[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    // =======================================================================
367    // Helpers
368    // =======================================================================
369
370    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}