Skip to main content

falcon_library/
disk_name.rs

1//! What an item's name is called on disk.
2//!
3//! An item's name stays in `metadata.json` exactly as it came, so a library
4//! made on a Mac round-trips byte for byte, but not every such name can be a
5//! Windows file: `:` would write to a hidden stream of another file, `\` and
6//! a drive letter would lead out of the item's folder, and `CON` is a device.
7//! [`on_disk`] turns a file name into the one Windows keeps it under, which is
8//! the name the usual tools give such a file when they copy a library over.
9//! Elsewhere every name is its own.
10
11use std::borrow::Cow;
12
13/// The name `file` is kept under on this system.
14#[cfg(not(windows))]
15pub fn on_disk(file: &str) -> Cow<'_, str> {
16    Cow::Borrowed(file)
17}
18
19/// The name `file` is kept under on this system: `_` for what Windows forbids,
20/// no trailing dots or spaces, and `_` after a device name.
21#[cfg(windows)]
22pub fn on_disk(file: &str) -> Cow<'_, str> {
23    if !needs_change(file) {
24        return Cow::Borrowed(file);
25    }
26    let cleaned: String = file.chars().map(|c| if forbidden(c) { '_' } else { c }).collect();
27    let cleaned = cleaned.trim_end_matches(['.', ' ']);
28    let base = cleaned.split('.').next().unwrap_or(cleaned);
29    Cow::Owned(match (cleaned.is_empty(), is_device(base)) {
30        (true, _) => "_".to_string(),
31        (_, true) => format!("{base}_{}", &cleaned[base.len()..]),
32        _ => cleaned.to_string(),
33    })
34}
35
36#[cfg(windows)]
37fn forbidden(c: char) -> bool {
38    c.is_control() || "<>:\"/\\|?*".contains(c)
39}
40
41#[cfg(windows)]
42fn needs_change(file: &str) -> bool {
43    file.is_empty() || file.ends_with(['.', ' ']) || file.chars().any(forbidden) || is_device(file.split('.').next().unwrap_or(file))
44}
45
46/// Whether Windows reads `base`, with any extension, as a device.
47#[cfg(windows)]
48pub(crate) fn is_device(base: &str) -> bool {
49    let upper = base.trim_end_matches(' ').to_ascii_uppercase();
50    match upper.as_str() {
51        "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" => true,
52        _ => (upper.starts_with("COM") || upper.starts_with("LPT")) && {
53            let mut rest = base.trim_end_matches(' ')[3..].chars();
54            matches!((rest.next(), rest.next()), (Some('1'..='9' | '¹' | '²' | '³'), None))
55        },
56    }
57}
58
59/// Clears the read-only attribute a copy brings from a read-only original on
60/// Windows, where it stops the library setting the file's times or replacing
61/// it. Elsewhere a copy's permissions stay as they are.
62#[cfg(windows)]
63pub(crate) fn make_writable(path: &std::path::Path) -> std::io::Result<()> {
64    let mut permissions = std::fs::metadata(path)?.permissions();
65    if permissions.readonly() {
66        #[allow(clippy::permissions_set_readonly_false)]
67        permissions.set_readonly(false);
68        std::fs::set_permissions(path, permissions)?;
69    }
70    Ok(())
71}
72
73#[cfg(not(windows))]
74pub(crate) fn make_writable(_path: &std::path::Path) -> std::io::Result<()> {
75    Ok(())
76}
77
78#[cfg(all(test, not(windows)))]
79mod tests {
80    use super::on_disk;
81
82    #[test]
83    fn every_name_is_its_own() {
84        for name in ["a:b.png", "CON.png", "trailing.", r"back\slash.png"] {
85            assert_eq!(on_disk(name), name);
86        }
87    }
88}
89
90#[cfg(all(test, windows))]
91mod tests {
92    use super::on_disk;
93
94    #[test]
95    fn what_windows_can_keep_is_left_alone() {
96        for name in ["a b.png", "été.jpeg", ".hidden", "CONSOLE.png", "COM10.txt", "COM.txt", "a.CON", "..png"] {
97            assert_eq!(on_disk(name), name);
98            assert!(matches!(on_disk(name), std::borrow::Cow::Borrowed(_)));
99        }
100    }
101
102    #[test]
103    fn what_windows_cannot_keep_changes() {
104        assert_eq!(on_disk("a:b.png"), "a_b.png");
105        assert_eq!(on_disk(r"..\..\evil.png"), ".._.._evil.png");
106        assert_eq!(on_disk("../evil.png"), ".._evil.png");
107        assert_eq!(on_disk(r"C:\Windows\x.png"), "C__Windows_x.png");
108        assert_eq!(on_disk("what?*.png"), "what__.png");
109        assert_eq!(on_disk("trailing. . "), "trailing");
110        assert_eq!(on_disk("..."), "_");
111        assert_eq!(on_disk(""), "_");
112        assert_eq!(on_disk("con.png"), "con_.png");
113        assert_eq!(on_disk("NUL"), "NUL_");
114        assert_eq!(on_disk("Lpt1.tar.gz"), "Lpt1_.tar.gz");
115        assert_eq!(on_disk("COM¹.png"), "COM¹_.png");
116    }
117
118    /// The whole point: a Mac name never writes a stream or leaves the folder.
119    #[test]
120    fn names_land_as_plain_files_in_their_folder() {
121        let tmp = tempfile::tempdir().unwrap();
122        for name in ["a:b.png", r"..\up.png", "NUL.png", "what?.png", "dot. "] {
123            std::fs::write(tmp.path().join(&*on_disk(name)), name).unwrap();
124        }
125        let mut names: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
126        names.sort();
127        assert_eq!(names, [".._up.png", "NUL_.png", "a_b.png", "dot", "what_.png"]);
128        assert_eq!(std::fs::read_dir(tmp.path().parent().unwrap()).unwrap().filter(|e| e.as_ref().unwrap().file_name() == "up.png").count(), 0);
129    }
130}