Skip to main content

update_kit/utils/
http.rs

1use crate::constants::DEFAULT_FETCH_TIMEOUT_MS;
2use crate::errors::UpdateKitError;
3use crate::utils::security::require_https;
4use std::time::Duration;
5
6/// Options for HTTP fetch operations.
7#[derive(Debug, Default, Clone)]
8pub struct FetchOptions {
9    /// Request timeout in milliseconds. Defaults to `DEFAULT_FETCH_TIMEOUT_MS`.
10    pub timeout_ms: Option<u64>,
11    /// Additional HTTP headers as `(name, value)` pairs.
12    pub headers: Option<Vec<(String, String)>>,
13}
14
15/// Performs an HTTP GET request with HTTPS enforcement and configurable timeout.
16///
17/// Validates that the URL uses HTTPS, then sends a GET request using `reqwest`
18/// with the specified (or default) timeout and optional custom headers.
19pub async fn fetch_with_timeout(
20    url: &str,
21    options: Option<FetchOptions>,
22) -> Result<reqwest::Response, UpdateKitError> {
23    require_https(url)?;
24
25    let opts = options.unwrap_or_default();
26    let timeout = Duration::from_millis(opts.timeout_ms.unwrap_or(DEFAULT_FETCH_TIMEOUT_MS));
27
28    let client = reqwest::Client::builder()
29        .timeout(timeout)
30        .build()
31        .map_err(UpdateKitError::NetworkError)?;
32
33    let mut request = client.get(url);
34
35    if let Some(headers) = opts.headers {
36        for (name, value) in headers {
37            request = request.header(name, value);
38        }
39    }
40
41    let response = request.send().await?;
42    Ok(response)
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[tokio::test]
50    async fn fetch_rejects_http() {
51        let result = fetch_with_timeout("http://example.com", None).await;
52        assert!(result.is_err());
53        let err = result.unwrap_err();
54        assert!(
55            matches!(err, UpdateKitError::InsecureUrl(_)),
56            "Expected InsecureUrl, got: {err:?}"
57        );
58    }
59
60    #[test]
61    fn fetch_options_default() {
62        let opts = FetchOptions::default();
63        assert!(opts.timeout_ms.is_none());
64        assert!(opts.headers.is_none());
65    }
66
67    #[tokio::test]
68    async fn fetch_rejects_ftp() {
69        let result = fetch_with_timeout("ftp://example.com", None).await;
70        assert!(matches!(
71            result.unwrap_err(),
72            UpdateKitError::InsecureUrl(_)
73        ));
74    }
75
76    #[tokio::test]
77    async fn fetch_rejects_empty_url() {
78        let result = fetch_with_timeout("", None).await;
79        assert!(result.is_err());
80    }
81
82    #[tokio::test]
83    async fn fetch_rejects_file_scheme() {
84        let result = fetch_with_timeout("file:///etc/passwd", None).await;
85        assert!(matches!(
86            result.unwrap_err(),
87            UpdateKitError::InsecureUrl(_)
88        ));
89    }
90
91    #[tokio::test]
92    async fn fetch_with_custom_timeout_connection_refused() {
93        let opts = FetchOptions {
94            timeout_ms: Some(100),
95            headers: None,
96        };
97        // Use unreachable HTTPS address - should fail with network error
98        let result = fetch_with_timeout("https://127.0.0.1:1/nonexistent", Some(opts)).await;
99        assert!(result.is_err());
100        // Should be a NetworkError, not InsecureUrl
101        assert!(!matches!(
102            result.unwrap_err(),
103            UpdateKitError::InsecureUrl(_)
104        ));
105    }
106
107    #[tokio::test]
108    async fn fetch_with_custom_headers_connection_refused() {
109        let opts = FetchOptions {
110            timeout_ms: Some(100),
111            headers: Some(vec![
112                ("X-Custom".into(), "value".into()),
113                ("Authorization".into(), "Bearer test".into()),
114            ]),
115        };
116        let result = fetch_with_timeout("https://127.0.0.1:1/nonexistent", Some(opts)).await;
117        assert!(result.is_err());
118    }
119
120    #[test]
121    fn fetch_options_with_all_fields() {
122        let opts = FetchOptions {
123            timeout_ms: Some(5000),
124            headers: Some(vec![("Accept".into(), "application/json".into())]),
125        };
126        assert_eq!(opts.timeout_ms, Some(5000));
127        assert_eq!(opts.headers.as_ref().unwrap().len(), 1);
128        assert_eq!(opts.headers.as_ref().unwrap()[0].0, "Accept");
129    }
130}