Skip to main content

nifty_download/
name.rs

1//! Choosing a downloaded file's name and creating it without replacing anything.
2
3use 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
11/// The name a file gets when its address doesn't end in one, such as `https://example.com/`.
12const FALLBACK_FILE_NAME: &str = "download";
13
14/// Characters some file systems refuse in names, replaced by [`NAME_REPLACEMENT`].
15const UNSAFE_NAME_CHARS: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
16const NAME_REPLACEMENT: char = '_';
17/// Names Windows keeps for devices, with or without an extension, such as `nul.txt`.
18const 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
23/// The last part of the address's path, decoded and made safe to use as a file name.
24pub(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
32/// `name` made safe to use as a file name in any folder, on every system: characters some
33/// file systems refuse become [`NAME_REPLACEMENT`], so it can't leave its folder either.
34pub(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    // Windows drops trailing dots and spaces, so `a.` and `a` would be the same file.
46    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
61/// Added to a file's name while it downloads, so a file left behind, as when the app
62/// quits mid-download on macOS, reads as unfinished.
63const PARTIAL_SUFFIX: &str = ".part";
64
65/// A file being downloaded: `name.part` in `folder`, which becomes `name`, or `name (1)`
66/// and so on, once it is saved.
67#[derive(Debug)]
68pub(crate) struct Partial {
69    folder: PathBuf,
70    name: String,
71    /// The copy number its name was free at, which finishing tries first.
72    copy: usize,
73    path: PathBuf,
74}
75
76impl Partial {
77    /// Creates the partial file for `name` in `folder`, at the first copy number whose
78    /// finished name and partial name are both free, the way browsers number copies, so a
79    /// download never replaces an existing file.
80    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    /// Where it is being written.
101    pub(crate) fn path(&self) -> &Path {
102        &self.path
103    }
104
105    /// Gives the file its finished name, the first one free from its own copy number on,
106    /// since another file may have taken that name while it downloaded.
107    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            // Claiming the name first means renaming over it replaces only this placeholder.
112            if create_new(&finished)?.is_some() {
113                fs::rename(&self.path, &finished)?;
114                return Ok(finished);
115            }
116            copy += 1;
117        }
118    }
119}
120
121/// `name`, or for copies after the first, `name (copy)` before its extension.
122fn 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
139/// Creates `path` for writing; `None` when something is there already.
140fn 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}