1use std::{
2 path::{Path, PathBuf},
3 time::{Duration, SystemTime, UNIX_EPOCH},
4};
5
6use futures_util::StreamExt;
7use serde::{Deserialize, Serialize};
8use tokio::io::{AsyncReadExt, AsyncWriteExt};
9use url::Url;
10
11use crate::{error::io_path, fs_ops::replace_file, Error, Result};
12
13const MAX_TEXT_RESPONSE_BYTES: u64 = 4 * 1024 * 1024;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct HttpHeader {
18 pub name: String,
19 pub value: String,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "event", content = "data", rename_all = "camelCase")]
24pub enum DownloadEvent {
25 Started { content_length: Option<u64> },
26 Progress { chunk_length: usize },
27 Finished,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct DownloadStats {
33 pub path: PathBuf,
34 pub bytes_written: u64,
35}
36
37pub async fn read_url_to_string(
38 url_or_path: &str,
39 headers: &[HttpHeader],
40 timeout_secs: Option<u64>,
41) -> Result<String> {
42 if let Some(url) = parse_supported_url_or_path(url_or_path)? {
43 match url.scheme() {
44 "http" | "https" => {
45 let client = client(timeout_secs)?;
46 let mut request = client.get(url);
47 for header in headers {
48 request = request.header(&header.name, &header.value);
49 }
50 let response = request.send().await?;
51 ensure_success_status(&response)?;
52 return response_text_limited(response).await;
53 }
54 "file" => {
55 let path = url
56 .to_file_path()
57 .map_err(|_| Error::UnsupportedUrl(url_or_path.to_string()))?;
58 return read_text_file_limited(&path).await;
59 }
60 _ => return Err(Error::UnsupportedUrl(url_or_path.to_string())),
61 }
62 }
63
64 read_text_file_limited(Path::new(url_or_path)).await
65}
66
67async fn response_text_limited(response: reqwest::Response) -> Result<String> {
68 if response
69 .content_length()
70 .is_some_and(|length| length > MAX_TEXT_RESPONSE_BYTES)
71 {
72 return Err(Error::DownloadLimitExceeded {
73 limit: MAX_TEXT_RESPONSE_BYTES,
74 attempted: response.content_length().unwrap_or_default(),
75 });
76 }
77 let mut stream = response.bytes_stream();
78 let mut bytes = Vec::new();
79 while let Some(chunk) = stream.next().await {
80 let chunk = chunk?;
81 let attempted = bytes.len() as u64 + chunk.len() as u64;
82 if attempted > MAX_TEXT_RESPONSE_BYTES {
83 return Err(Error::DownloadLimitExceeded {
84 limit: MAX_TEXT_RESPONSE_BYTES,
85 attempted,
86 });
87 }
88 bytes.extend_from_slice(&chunk);
89 }
90 String::from_utf8(bytes)
91 .map_err(|error| Error::Message(format!("text response is not valid UTF-8: {error}")))
92}
93
94async fn read_text_file_limited(path: &Path) -> Result<String> {
95 let metadata = tokio::fs::metadata(path)
96 .await
97 .map_err(|error| io_path(path, error))?;
98 if metadata.len() > MAX_TEXT_RESPONSE_BYTES {
99 return Err(Error::DownloadLimitExceeded {
100 limit: MAX_TEXT_RESPONSE_BYTES,
101 attempted: metadata.len(),
102 });
103 }
104 tokio::fs::read_to_string(path)
105 .await
106 .map_err(|error| io_path(path, error))
107}
108
109pub async fn download_to_file<F>(
110 url_or_path: &str,
111 destination: impl AsRef<Path>,
112 headers: &[HttpHeader],
113 timeout_secs: Option<u64>,
114 maximum_bytes: Option<u64>,
115 on_event: F,
116) -> Result<DownloadStats>
117where
118 F: FnMut(DownloadEvent),
119{
120 let destination = destination.as_ref();
121 if let Some(parent) = destination.parent() {
122 tokio::fs::create_dir_all(parent)
123 .await
124 .map_err(|error| io_path(parent, error))?;
125 }
126
127 let temporary_destination = temporary_path_for(destination);
128 let result = download_to_temporary_file(
129 url_or_path,
130 &temporary_destination,
131 headers,
132 timeout_secs,
133 maximum_bytes,
134 on_event,
135 )
136 .await;
137
138 match result {
139 Ok(mut stats) => {
140 let temporary = temporary_destination.clone();
141 let destination_path = destination.to_path_buf();
142 tokio::task::spawn_blocking(move || replace_file(&temporary, &destination_path))
143 .await
144 .map_err(|error| {
145 Error::Message(format!("download replace task failed: {error}"))
146 })??;
147 stats.path = destination.to_path_buf();
148 Ok(stats)
149 }
150 Err(error) => {
151 let _ = tokio::fs::remove_file(&temporary_destination).await;
152 Err(error)
153 }
154 }
155}
156
157async fn download_to_temporary_file<F>(
158 url_or_path: &str,
159 destination: &Path,
160 headers: &[HttpHeader],
161 timeout_secs: Option<u64>,
162 maximum_bytes: Option<u64>,
163 on_event: F,
164) -> Result<DownloadStats>
165where
166 F: FnMut(DownloadEvent),
167{
168 if let Some(url) = parse_supported_url_or_path(url_or_path)? {
169 return match url.scheme() {
170 "http" | "https" => {
171 download_http(
172 url,
173 destination,
174 headers,
175 timeout_secs,
176 maximum_bytes,
177 on_event,
178 )
179 .await
180 }
181 "file" => {
182 let source = url
183 .to_file_path()
184 .map_err(|_| Error::UnsupportedUrl(url_or_path.to_string()))?;
185 copy_file_with_progress(&source, destination, maximum_bytes, on_event).await
186 }
187 _ => Err(Error::UnsupportedUrl(url_or_path.to_string())),
188 };
189 }
190
191 copy_file_with_progress(url_or_path, destination, maximum_bytes, on_event).await
192}
193
194fn parse_supported_url_or_path(value: &str) -> Result<Option<Url>> {
195 match Url::parse(value) {
196 Ok(url) if matches!(url.scheme(), "http" | "https" | "file") => Ok(Some(url)),
197 Ok(_) if value.contains("://") => Err(Error::UnsupportedUrl(value.to_string())),
198 Ok(_) | Err(_) => Ok(None),
199 }
200}
201
202async fn download_http<F>(
203 url: Url,
204 destination: &Path,
205 headers: &[HttpHeader],
206 timeout_secs: Option<u64>,
207 maximum_bytes: Option<u64>,
208 mut on_event: F,
209) -> Result<DownloadStats>
210where
211 F: FnMut(DownloadEvent),
212{
213 let client = client(timeout_secs)?;
214 let mut request = client.get(url);
215 for header in headers {
216 request = request.header(&header.name, &header.value);
217 }
218
219 let response = request.send().await?;
220 ensure_success_status(&response)?;
221 let content_length = response.content_length();
222 if let (Some(limit), Some(content_length)) = (maximum_bytes, content_length) {
223 if content_length > limit {
224 return Err(Error::DownloadLimitExceeded {
225 limit,
226 attempted: content_length,
227 });
228 }
229 }
230 on_event(DownloadEvent::Started { content_length });
231
232 let mut stream = response.bytes_stream();
233 let mut file = tokio::fs::File::create(destination)
234 .await
235 .map_err(|error| io_path(destination, error))?;
236 let mut written = 0_u64;
237
238 while let Some(chunk) = stream.next().await {
239 let chunk = chunk?;
240 let attempted = written
241 .checked_add(chunk.len() as u64)
242 .ok_or_else(|| Error::Message("download size overflow".to_string()))?;
243 if maximum_bytes.is_some_and(|limit| attempted > limit) {
244 return Err(Error::DownloadLimitExceeded {
245 limit: maximum_bytes.unwrap_or_default(),
246 attempted,
247 });
248 }
249 file.write_all(&chunk)
250 .await
251 .map_err(|error| io_path(destination, error))?;
252 written = attempted;
253 on_event(DownloadEvent::Progress {
254 chunk_length: chunk.len(),
255 });
256 }
257
258 file.flush()
259 .await
260 .map_err(|error| io_path(destination, error))?;
261 file.sync_all()
262 .await
263 .map_err(|error| io_path(destination, error))?;
264 on_event(DownloadEvent::Finished);
265
266 Ok(DownloadStats {
267 path: destination.to_path_buf(),
268 bytes_written: written,
269 })
270}
271
272async fn copy_file_with_progress<F>(
273 source: impl AsRef<Path>,
274 destination: impl AsRef<Path>,
275 maximum_bytes: Option<u64>,
276 mut on_event: F,
277) -> Result<DownloadStats>
278where
279 F: FnMut(DownloadEvent),
280{
281 let source = source.as_ref();
282 let destination = destination.as_ref();
283 let mut input = tokio::fs::File::open(source)
284 .await
285 .map_err(|error| io_path(source, error))?;
286 let metadata = input
287 .metadata()
288 .await
289 .map_err(|error| io_path(source, error))?;
290 if maximum_bytes.is_some_and(|limit| metadata.len() > limit) {
291 return Err(Error::DownloadLimitExceeded {
292 limit: maximum_bytes.unwrap_or_default(),
293 attempted: metadata.len(),
294 });
295 }
296 let mut output = tokio::fs::File::create(destination)
297 .await
298 .map_err(|error| io_path(destination, error))?;
299 let mut buf = vec![0_u8; 256 * 1024];
300 let mut written = 0_u64;
301
302 on_event(DownloadEvent::Started {
303 content_length: Some(metadata.len()),
304 });
305 loop {
306 let read = input
307 .read(&mut buf)
308 .await
309 .map_err(|error| io_path(source, error))?;
310 if read == 0 {
311 break;
312 }
313 let attempted = written
314 .checked_add(read as u64)
315 .ok_or_else(|| Error::Message("download size overflow".to_string()))?;
316 if maximum_bytes.is_some_and(|limit| attempted > limit) {
317 return Err(Error::DownloadLimitExceeded {
318 limit: maximum_bytes.unwrap_or_default(),
319 attempted,
320 });
321 }
322 output
323 .write_all(&buf[..read])
324 .await
325 .map_err(|error| io_path(destination, error))?;
326 written = attempted;
327 on_event(DownloadEvent::Progress { chunk_length: read });
328 }
329
330 output
331 .flush()
332 .await
333 .map_err(|error| io_path(destination, error))?;
334 output
335 .sync_all()
336 .await
337 .map_err(|error| io_path(destination, error))?;
338 on_event(DownloadEvent::Finished);
339
340 Ok(DownloadStats {
341 path: destination.to_path_buf(),
342 bytes_written: written,
343 })
344}
345
346fn client(timeout_secs: Option<u64>) -> Result<reqwest::Client> {
347 let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
348 if let Some(timeout_secs) = timeout_secs {
349 builder = builder.timeout(Duration::from_secs(timeout_secs));
350 }
351 Ok(builder.build()?)
352}
353
354fn ensure_success_status(response: &reqwest::Response) -> Result<()> {
355 if response.status().is_success() {
356 return Ok(());
357 }
358 Err(Error::UnexpectedHttpStatus {
359 status: response.status().as_u16(),
360 })
361}
362
363fn temporary_path_for(destination: &Path) -> PathBuf {
364 let file_name = destination
365 .file_name()
366 .map(|name| name.to_string_lossy())
367 .unwrap_or_else(|| "download".into());
368 let suffix = SystemTime::now()
369 .duration_since(UNIX_EPOCH)
370 .map(|duration| duration.as_nanos())
371 .unwrap_or_default();
372 destination.with_file_name(format!(".{file_name}.part-{}-{suffix}", std::process::id()))
373}
374
375#[cfg(test)]
376mod tests {
377 use std::fs;
378
379 use tempfile::tempdir;
380
381 use super::download_to_file;
382 use crate::Error;
383
384 #[tokio::test]
385 async fn local_download_stops_at_the_signed_size_limit() {
386 let dir = tempdir().unwrap();
387 let source = dir.path().join("source.bin");
388 let destination = dir.path().join("destination.bin");
389 fs::write(&source, b"0123456789").unwrap();
390
391 let error = download_to_file(
392 source.to_str().unwrap(),
393 &destination,
394 &[],
395 None,
396 Some(5),
397 |_| {},
398 )
399 .await
400 .unwrap_err();
401
402 assert!(matches!(error, Error::DownloadLimitExceeded { .. }));
403 assert!(!destination.exists());
404 }
405}