1use std::borrow::Cow;
4use std::fs::{self, File, OpenOptions};
5use std::io;
6use std::path::{Path, PathBuf};
7
8use percent_encoding::percent_decode_str;
9use ureq::http::Uri;
10
11const FALLBACK_FILE_NAME: &str = "download";
13
14const UNSAFE_NAME_CHARS: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
16const NAME_REPLACEMENT: char = '_';
17const RESERVED_NAMES: &[&str] = &[
19 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
20 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
21];
22
23pub(crate) fn file_name(uri: &Uri) -> String {
25 let last = uri.path().rsplit('/').next().unwrap_or_default();
26 let decoded = percent_decode_str(last)
27 .decode_utf8()
28 .unwrap_or(Cow::Borrowed(last));
29 safe_file_name(&decoded)
30}
31
32pub(crate) fn safe_file_name(name: &str) -> String {
35 let safe: String = name
36 .chars()
37 .map(|c| {
38 if UNSAFE_NAME_CHARS.contains(&c) || c.is_control() {
39 NAME_REPLACEMENT
40 } else {
41 c
42 }
43 })
44 .collect();
45 let safe = safe.trim_end_matches(['.', ' ']);
47 if safe.is_empty() {
48 return FALLBACK_FILE_NAME.to_owned();
49 }
50 let stem = safe.split('.').next().unwrap_or_default();
51 if RESERVED_NAMES
52 .iter()
53 .any(|reserved| reserved.eq_ignore_ascii_case(stem))
54 {
55 format!("{NAME_REPLACEMENT}{safe}")
56 } else {
57 safe.to_owned()
58 }
59}
60
61const PARTIAL_SUFFIX: &str = ".part";
64
65#[derive(Debug)]
68pub(crate) struct Partial {
69 folder: PathBuf,
70 name: String,
71 copy: usize,
73 path: PathBuf,
74}
75
76impl Partial {
77 pub(crate) fn create(folder: &Path, name: &str) -> io::Result<(Self, File)> {
81 let mut copy = 0;
82 loop {
83 let finished = folder.join(numbered(name, copy));
84 let path = partial_path(&finished);
85 if !finished.exists()
86 && let Some(file) = create_new(&path)?
87 {
88 let partial = Self {
89 folder: folder.to_owned(),
90 name: name.to_owned(),
91 copy,
92 path,
93 };
94 return Ok((partial, file));
95 }
96 copy += 1;
97 }
98 }
99
100 pub(crate) fn path(&self) -> &Path {
102 &self.path
103 }
104
105 pub(crate) fn finish(&self) -> io::Result<PathBuf> {
108 let mut copy = self.copy;
109 loop {
110 let finished = self.folder.join(numbered(&self.name, copy));
111 if create_new(&finished)?.is_some() {
113 fs::rename(&self.path, &finished)?;
114 return Ok(finished);
115 }
116 copy += 1;
117 }
118 }
119}
120
121fn numbered(name: &str, copy: usize) -> String {
123 if copy == 0 {
124 return name.to_owned();
125 }
126 let (stem, extension) = match name.rfind('.') {
127 Some(dot) if dot > 0 => name.split_at(dot),
128 _ => (name, ""),
129 };
130 format!("{stem} ({copy}){extension}")
131}
132
133fn partial_path(finished: &Path) -> PathBuf {
134 let mut path = finished.as_os_str().to_owned();
135 path.push(PARTIAL_SUFFIX);
136 PathBuf::from(path)
137}
138
139fn create_new(path: &Path) -> io::Result<Option<File>> {
141 match OpenOptions::new().write(true).create_new(true).open(path) {
142 Ok(file) => Ok(Some(file)),
143 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(None),
144 Err(error) => Err(error),
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn name_of(address: &str) -> String {
153 file_name(&address.parse().expect("a valid test address"))
154 }
155
156 #[test]
157 fn names_the_file_after_the_end_of_the_address() {
158 assert_eq!(
159 name_of("https://example.com/files/report.pdf"),
160 "report.pdf"
161 );
162 assert_eq!(name_of("https://example.com/a.zip?version=2"), "a.zip");
163 assert_eq!(name_of("https://example.com/"), FALLBACK_FILE_NAME);
164 assert_eq!(name_of("https://example.com"), FALLBACK_FILE_NAME);
165 assert_eq!(name_of("https://example.com/.."), FALLBACK_FILE_NAME);
166 assert_eq!(name_of("https://example.com/a:b"), "a_b");
167 assert_eq!(name_of("https://example.com/my%20file.pdf"), "my file.pdf");
168 assert_eq!(name_of("https://example.com/caf%C3%A9.txt"), "café.txt");
169 assert_eq!(name_of("https://example.com/bad%FF.txt"), "bad%FF.txt");
170 assert_eq!(name_of("https://example.com/100%25.txt"), "100%.txt");
171 }
172
173 #[test]
174 fn decoded_separators_never_leave_the_folder() {
175 assert_eq!(name_of("https://example.com/..%2F..%2Fetc"), ".._.._etc");
176 assert_eq!(name_of("https://example.com/a%5Cb.txt"), "a_b.txt");
177 assert_eq!(name_of("https://example.com/%2E%2E"), FALLBACK_FILE_NAME);
178 assert_eq!(name_of("https://example.com/a%00b"), "a_b");
179 }
180
181 #[test]
182 fn a_chosen_name_is_made_safe_the_same_way() {
183 assert_eq!(safe_file_name("report.pdf"), "report.pdf");
184 assert_eq!(safe_file_name("../secrets"), ".._secrets");
185 assert_eq!(safe_file_name("a/b\\c:d"), "a_b_c_d");
186 assert_eq!(safe_file_name("my%20file"), "my%20file", "not decoded");
187 assert_eq!(safe_file_name("aux.txt"), "_aux.txt");
188 assert_eq!(safe_file_name(" . "), FALLBACK_FILE_NAME);
189 }
190
191 #[test]
192 fn avoids_names_windows_reserves_or_changes() {
193 assert_eq!(name_of("https://example.com/nul"), "_nul");
194 assert_eq!(name_of("https://example.com/CON.txt"), "_CON.txt");
195 assert_eq!(name_of("https://example.com/com1.tar.gz"), "_com1.tar.gz");
196 assert_eq!(name_of("https://example.com/console.txt"), "console.txt");
197 assert_eq!(name_of("https://example.com/notes.."), "notes");
198 assert_eq!(name_of("https://example.com/n%75l.txt"), "_nul.txt");
199 assert_eq!(name_of("https://example.com/notes%2E%20"), "notes");
200 }
201
202 fn file_names(folder: &Path) -> Vec<String> {
203 let mut names: Vec<_> = fs::read_dir(folder)
204 .expect("read the scratch folder")
205 .map(|entry| {
206 entry
207 .expect("read a folder entry")
208 .file_name()
209 .to_string_lossy()
210 .into_owned()
211 })
212 .collect();
213 names.sort();
214 names
215 }
216
217 fn scratch(name: &str) -> PathBuf {
218 let folder = std::env::temp_dir().join(format!("nf-download-test-{name}"));
219 if folder.exists() {
220 fs::remove_dir_all(&folder).expect("remove stale scratch folder");
221 }
222 fs::create_dir_all(&folder).expect("create scratch folder");
223 folder
224 }
225
226 #[test]
227 fn never_replaces_an_existing_file() {
228 let folder = scratch("unique-names");
229 fs::write(folder.join("notes.txt"), "mine").expect("write a file");
230 let (first, _) = Partial::create(&folder, "notes.txt").expect("create a partial file");
231 let (second, _) = Partial::create(&folder, "notes.txt").expect("create a partial file");
232 assert_eq!(
233 file_names(&folder),
234 ["notes (1).txt.part", "notes (2).txt.part", "notes.txt"]
235 );
236
237 fs::write(folder.join("notes (2).txt"), "taken meanwhile").expect("write a file");
238 assert_eq!(first.finish().ok(), Some(folder.join("notes (1).txt")));
239 assert_eq!(second.finish().ok(), Some(folder.join("notes (3).txt")));
240 assert_eq!(
241 file_names(&folder),
242 [
243 "notes (1).txt",
244 "notes (2).txt",
245 "notes (3).txt",
246 "notes.txt"
247 ]
248 );
249 assert_eq!(
250 fs::read_to_string(folder.join("notes (2).txt"))
251 .ok()
252 .as_deref(),
253 Some("taken meanwhile")
254 );
255 fs::remove_dir_all(&folder).expect("remove scratch folder");
256 }
257}