nd_util/
download_to_path.rs

1use crate::download_to_file;
2use crate::with_push_extension;
3use crate::DropRemovePath;
4use anyhow::Context;
5use cfg_if::cfg_if;
6use std::path::Path;
7use tracing::warn;
8
9/// Using the given client, download the file at a url to a given path.
10///
11/// Note that this function will overwrite the file at the given path.
12///
13/// # Temporary Files
14/// This will create a temporary ".part" file in the same directory while downloading.
15/// On failure, this file will try to be cleaned up.
16/// On success, this temporary file will be renamed to the actual file name.
17/// As a result, it may be assumed that the file created at the given path is the complete, non-erroneus download.
18///
19/// # Locking
20/// During downloads, the temporary file is locked via advisory locking on platforms that support it.
21/// If locking is not supported, overwriting a pre-existing temporary file causes an error.
22/// Currently, Unix and Windows support advisory locking.
23pub async fn download_to_path<P>(client: &reqwest::Client, url: &str, path: P) -> anyhow::Result<()>
24where
25    P: AsRef<Path>,
26{
27    // Get the path.
28    let path = path.as_ref();
29
30    // Create temporary path.
31    let temporary_path = with_push_extension(path, "part");
32
33    // Setup to open the temporary file.
34    //
35    // We do NOT use mandatory locking on Windows.
36    // This is because the file would need to be dropped to be renamed,
37    // which leads to a race as we must release ALL locks to do so.
38    //
39    // TODO: On linux, consider probing for O_TMPFILE support somehow and create an unnamed tmp file and use linkat.
40    let mut open_options = tokio::fs::OpenOptions::new();
41    open_options.write(true);
42
43    // If we don't have a mechanism to prevent stomping,
44    // at least ensure that we can't stomp.
45    cfg_if! {
46        if #[cfg(any(windows, unix))] {
47            // We prevent stomping by locking somehow.
48            // Create and overwrite the temporary file.
49            open_options.create(true);
50        } else {
51            // If the temporary file exists, return an error.
52            open_options.create_new(true);
53        }
54    }
55
56    // Open the temporary file.
57    let temporary_file = open_options
58        .open(&temporary_path)
59        .await
60        .context("failed to create temporary file")?;
61
62    // Create the remove handle for the temporary path.
63    let mut temporary_path = DropRemovePath::new(temporary_path);
64
65    let result = async {
66        // Wrap the file in a lock, if the platform supports it.
67        cfg_if! {
68            if #[cfg(any(unix, windows))] {
69                let mut temporary_file_lock = fd_lock::RwLock::new(temporary_file);
70                let mut temporary_file = temporary_file_lock.try_write().context("failed to lock temporary file")?;
71            } else {
72                let mut temporary_file = temporary_file;
73            }
74        }
75
76        // Perform download.
77        download_to_file(client, url, &mut temporary_file)
78            .await?;
79
80        // Perform rename from temporary file path to actual file path.
81        tokio::fs::rename(&temporary_path, &path)
82            .await
83            .context("failed to rename temporary file")?;
84
85        // Ensure that the file handle is dropped AFTER we rename.
86        //
87        // Uwrap the file from the file lock.
88        cfg_if! {
89            if #[cfg(any(unix, windows))] {
90                // Unlock lock.
91                drop(temporary_file);
92
93                // Get file from lock.
94                let temporary_file = temporary_file_lock.into_inner();
95            }
96        }
97
98        drop(temporary_file.into_std());
99
100        Ok(())
101    }
102    .await;
103
104    match result.as_ref() {
105        Ok(()) => {
106            // Persist the file,
107            // since it was renamed and we don't want to remove a non-existent file.
108            temporary_path.persist();
109        }
110        Err(_error) => {
111            // Try to clean up the temporary file before returning.
112            if let Err((mut temporary_path, error)) = temporary_path.try_drop().await {
113                // Don't try to delete the file again.
114                temporary_path.persist();
115
116                // Returning the original error is more important,
117                // so we just log the temporary file error here.
118                warn!("failed to delete temporary file '{error}'");
119            }
120        }
121    }
122
123    result
124}
125
126#[cfg(test)]
127mod test {
128    use super::*;
129
130    #[tokio::test]
131    async fn it_works() {
132        tokio::fs::create_dir_all("test_tmp")
133            .await
134            .expect("failed to create tmp dir");
135
136        let client = reqwest::Client::new();
137        download_to_path(&client, "http://google.com", "test_tmp/google.html")
138            .await
139            .expect("failed to download");
140    }
141}