Skip to main content

netspeed_cli/
http.rs

1use crate::common;
2use crate::config::Config;
3use crate::error::SpeedtestError;
4use reqwest::Client;
5
6/// Create an HTTP client with the given configuration.
7///
8/// # Errors
9///
10/// Returns [`SpeedtestError::Context`] if the source IP is invalid.
11/// Returns [`SpeedtestError::NetworkError`] if the client fails to build.
12pub fn create_client(config: &Config) -> Result<Client, SpeedtestError> {
13    let mut builder = Client::builder()
14        .timeout(std::time::Duration::from_secs(config.timeout))
15        .http1_only()
16        .no_gzip()
17        .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
18
19    if let Some(ref source_ip) = config.source {
20        let addr: std::net::SocketAddr = source_ip
21            .parse()
22            .map_err(|e| SpeedtestError::with_source("Invalid source IP", e))?;
23        builder = builder.local_address(addr.ip());
24    }
25
26    let client = builder.build().map_err(SpeedtestError::NetworkError)?;
27
28    Ok(client)
29}
30
31/// Discover the client's public IP address via speedtest.net.
32///
33/// # Errors
34///
35/// Returns [`SpeedtestError::NetworkError`] if all IP discovery endpoints fail.
36pub async fn discover_client_ip(client: &Client) -> Result<String, SpeedtestError> {
37    if let Ok(response) = client
38        .get("https://www.speedtest.net/api/ip.php")
39        .send()
40        .await
41    {
42        if let Ok(text) = response.text().await {
43            let trimmed = text.trim().to_string();
44            if common::is_valid_ipv4(&trimmed) {
45                return Ok(trimmed);
46            }
47        }
48    }
49
50    if let Ok(response) = client
51        .get("https://www.speedtest.net/api/ios-config.php")
52        .send()
53        .await
54    {
55        if let Ok(text) = response.text().await {
56            if let Some(ip) = parse_ip_from_xml(&text) {
57                return Ok(ip);
58            }
59        }
60    }
61
62    Ok("unknown".to_string())
63}
64
65fn parse_ip_from_xml(xml: &str) -> Option<String> {
66    // Use structured XML deserialization instead of manual string scanning
67    // to handle edge cases (comments, CDATA, nested elements) correctly.
68    #[derive(serde::Deserialize)]
69    struct Settings {
70        client: ClientElement,
71    }
72    #[derive(serde::Deserialize)]
73    struct ClientElement {
74        #[serde(rename = "@ip")]
75        ip: Option<String>,
76    }
77
78    let settings: Settings = quick_xml::de::from_str(xml).ok()?;
79    let ip = settings.client.ip?;
80    if common::is_valid_ipv4(&ip) {
81        Some(ip)
82    } else {
83        None
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_parse_ip_from_xml() {
93        let xml = r#"<settings><client country="CA" ip="173.35.57.235" isp="Rogers"/></settings>"#;
94        assert_eq!(parse_ip_from_xml(xml), Some("173.35.57.235".to_string()));
95    }
96
97    #[test]
98    fn test_parse_ip_from_xml_full_response() {
99        let xml = r#"<?xml version="1.0"?>
100<settings>
101 <config downloadThreadCountV3="4"/>
102 <client country="CA" ip="173.35.57.235" isp="Rogers"/>
103</settings>"#;
104        assert_eq!(parse_ip_from_xml(xml), Some("173.35.57.235".to_string()));
105    }
106
107    #[test]
108    fn test_parse_ip_from_xml_invalid() {
109        assert!(parse_ip_from_xml("not xml").is_none());
110        assert!(parse_ip_from_xml("<html></html>").is_none());
111        assert!(parse_ip_from_xml("<settings><client ip=\"invalid\"/></settings>").is_none());
112    }
113
114    #[test]
115    fn test_create_client_invalid_source_ip() {
116        use crate::cli::CliArgs;
117        use clap::Parser;
118        let args = CliArgs::parse_from(["netspeed-cli"]);
119        let mut config = Config::from_args(&args);
120        config.source = Some("invalid-ip".to_string());
121        let result = create_client(&config);
122        assert!(result.is_err());
123        assert!(matches!(
124            result.unwrap_err(),
125            SpeedtestError::Context { .. }
126        ));
127    }
128
129    #[test]
130    fn test_create_client_valid_config() {
131        use crate::cli::CliArgs;
132        use clap::Parser;
133        let args = CliArgs::parse_from(["netspeed-cli"]);
134        let config = Config::from_args(&args);
135        let result = create_client(&config);
136        assert!(result.is_ok());
137    }
138
139    #[test]
140    fn test_create_client_with_source_ip() {
141        use crate::cli::CliArgs;
142        use clap::Parser;
143        let args = CliArgs::parse_from(["netspeed-cli", "--source", "0.0.0.0"]);
144        let config = Config::from_args(&args);
145        let result = create_client(&config);
146        match result {
147            Ok(_) | Err(SpeedtestError::NetworkError(_)) | Err(SpeedtestError::Context { .. }) => {}
148            Err(e) => panic!("Unexpected error type: {e:?}"),
149        }
150    }
151
152    #[test]
153    fn test_create_client_custom_timeout() {
154        use crate::cli::CliArgs;
155        use clap::Parser;
156        let args = CliArgs::parse_from(["netspeed-cli", "--timeout", "30"]);
157        let config = Config::from_args(&args);
158        let result = create_client(&config);
159        assert!(result.is_ok());
160    }
161}