falcon_library/
disk_name.rs1use std::borrow::Cow;
12
13#[cfg(not(windows))]
15pub fn on_disk(file: &str) -> Cow<'_, str> {
16 Cow::Borrowed(file)
17}
18
19#[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#[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#[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 #[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}