1use crate::constants::DEFAULT_FETCH_TIMEOUT_MS;
2use crate::errors::UpdateKitError;
3use crate::utils::security::require_https;
4use std::time::Duration;
5
6#[derive(Debug, Default, Clone)]
8pub struct FetchOptions {
9 pub timeout_ms: Option<u64>,
11 pub headers: Option<Vec<(String, String)>>,
13}
14
15pub 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 let result = fetch_with_timeout("https://127.0.0.1:1/nonexistent", Some(opts)).await;
99 assert!(result.is_err());
100 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}