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}