use crate::core::telemetry_types::TelemetryPayload;
use crate::telemetry::payload::serialize_payload;
use anyhow::{Context, Result};
use std::sync::mpsc;
pub const DEFAULT_ENDPOINT: &str = "https://telemetry.rusty-stack.example.com/api/v1/submit";
pub const THANK_YOU_MESSAGE: &str =
"✓ Thank you! Your anonymous hardware data has been submitted successfully.";
const SUBMISSION_ERROR_PREFIX: &str = "Telemetry submission failed:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubmitOutcome {
Success,
Failed(String),
HttpRejected,
}
pub fn validate_https_url(url: &str) -> Result<()> {
let trimmed = url.trim();
if trimmed.starts_with("https://") {
Ok(())
} else if trimmed.starts_with("http://") {
anyhow::bail!(
"HTTP URL rejected — telemetry must be submitted over HTTPS only. URL: {}",
trimmed
)
} else {
anyhow::bail!(
"Invalid telemetry URL — must start with https://. URL: {}",
trimmed
)
}
}
pub fn submit_payload(payload: &TelemetryPayload, endpoint: &str) -> Result<SubmitOutcome> {
if validate_https_url(endpoint).is_err() {
return Ok(SubmitOutcome::HttpRejected);
}
let json = serialize_payload(payload).context("Payload validation failed")?;
let rt = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
let result = rt.block_on(async {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(e) => {
return SubmitOutcome::Failed(format!("Failed to build HTTP client: {}", e));
}
};
let response = client
.post(endpoint)
.header("Content-Type", "application/json")
.body(json)
.send()
.await;
match response {
Ok(resp) => {
let status = resp.status();
if status == reqwest::StatusCode::OK {
SubmitOutcome::Success
} else {
SubmitOutcome::Failed(format!("Server returned HTTP {}", status))
}
}
Err(e) => SubmitOutcome::Failed(format!("Network error: {}", e)),
}
});
Ok(result)
}
pub fn submit_fire_and_forget(payload: TelemetryPayload, endpoint: String) -> mpsc::Sender<()> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let outcome = submit_payload(&payload, &endpoint);
match outcome {
Ok(SubmitOutcome::Success) => {
println!("{}", THANK_YOU_MESSAGE);
}
Ok(SubmitOutcome::Failed(msg)) => {
eprintln!("{} {}", SUBMISSION_ERROR_PREFIX, msg);
}
Ok(SubmitOutcome::HttpRejected) => {
eprintln!(
"{} HTTP URL rejected — telemetry requires HTTPS",
SUBMISSION_ERROR_PREFIX
);
}
Err(e) => {
eprintln!("{} {}", SUBMISSION_ERROR_PREFIX, e);
}
}
let _ = rx.recv();
});
tx
}
pub fn submit_blocking(payload: &TelemetryPayload, endpoint: &str) -> Result<SubmitOutcome> {
submit_payload(payload, endpoint)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_submit_https_url_accepted() {
let result = validate_https_url("https://telemetry.example.com/submit");
assert!(result.is_ok(), "HTTPS URL should be accepted: {:?}", result);
}
#[test]
fn test_submit_http_url_rejected_before_network() {
let result = validate_https_url("http://telemetry.example.com/submit");
assert!(
result.is_err(),
"HTTP URL should be rejected before network access"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("HTTP URL rejected"),
"Error should mention rejection: {}",
msg
);
assert!(
msg.contains("HTTPS only"),
"Error should mention HTTPS requirement: {}",
msg
);
}
#[test]
fn test_submit_http_url_returns_http_rejected_outcome() {
let payload = create_test_payload();
let outcome = submit_payload(&payload, "http://telemetry.example.com/submit")
.expect("Should return outcome without error");
assert_eq!(
outcome,
SubmitOutcome::HttpRejected,
"HTTP URL should produce HttpRejected outcome"
);
}
#[test]
fn test_submit_invalid_url_rejected() {
let result = validate_https_url("ftp://telemetry.example.com/submit");
assert!(result.is_err(), "Non-HTTP URL should be rejected");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("must start with https://"),
"Error should mention https:// requirement: {}",
msg
);
}
#[test]
fn test_submit_empty_url_rejected() {
let result = validate_https_url("");
assert!(result.is_err(), "Empty URL should be rejected");
}
#[test]
fn test_submit_https_with_path_accepted() {
let result = validate_https_url("https://example.com/api/v1/submit");
assert!(result.is_ok(), "HTTPS URL with path should be accepted");
}
#[test]
fn test_submit_https_with_port_accepted() {
let result = validate_https_url("https://example.com:8443/submit");
assert!(result.is_ok(), "HTTPS URL with port should be accepted");
}
#[test]
fn test_thank_you_message_is_non_empty() {
assert!(
!THANK_YOU_MESSAGE.is_empty(),
"Thank-you message must not be empty"
);
assert!(
THANK_YOU_MESSAGE.contains("Thank you"),
"Thank-you message must contain 'Thank you'"
);
}
#[test]
fn test_submit_outcome_success_display() {
let outcome = SubmitOutcome::Success;
assert_eq!(outcome, SubmitOutcome::Success);
}
#[test]
fn test_submit_outcome_failed_contains_message() {
let outcome = SubmitOutcome::Failed("Server returned HTTP 500".to_string());
if let SubmitOutcome::Failed(msg) = outcome {
assert!(msg.contains("500"));
} else {
panic!("Expected Failed variant");
}
}
#[test]
fn test_submit_outcome_http_rejected() {
let outcome = SubmitOutcome::HttpRejected;
assert_eq!(outcome, SubmitOutcome::HttpRejected);
}
#[test]
fn test_fire_and_forget_returns_immediately() {
let payload = create_test_payload();
let endpoint = "https://nonexistent.invalid/submit".to_string();
let tx = submit_fire_and_forget(payload, endpoint);
drop(tx);
}
#[test]
fn test_fire_and_forget_with_http_url_does_not_panic() {
let payload = create_test_payload();
let endpoint = "http://insecure.example.com/submit".to_string();
let tx = submit_fire_and_forget(payload, endpoint);
drop(tx);
}
#[test]
fn test_default_endpoint_is_https() {
assert!(
DEFAULT_ENDPOINT.starts_with("https://"),
"Default endpoint must use HTTPS: {}",
DEFAULT_ENDPOINT
);
}
#[test]
fn test_submit_to_nonexistent_host_returns_failed() {
let payload = create_test_payload();
let outcome = submit_payload(&payload, "https://nonexistent.invalid/submit")
.expect("Should return outcome");
assert!(
matches!(outcome, SubmitOutcome::Failed(_)),
"Should fail for nonexistent host: {:?}",
outcome
);
}
#[test]
fn test_submit_blocking_http_rejected() {
let payload = create_test_payload();
let outcome =
submit_blocking(&payload, "http://example.com/submit").expect("Should return outcome");
assert_eq!(outcome, SubmitOutcome::HttpRejected);
}
fn create_test_payload() -> crate::core::telemetry_types::TelemetryPayload {
crate::core::telemetry_types::TelemetryPayload::new(
"gfx1100",
"AMD Radeon RX 7900 XTX",
"7.2.1",
1,
16,
54.0,
)
}
}