Skip to main content

hdiff_update_core/
download.rs

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}