1use std::{
4 ffi::OsStr,
5 path::{Component, Path, PathBuf},
6};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Platform {
11 Unix,
12 Mac,
14 Windows,
15}
16
17impl Platform {
18 #[must_use]
19 pub const fn current() -> Self {
20 if cfg!(windows) {
21 Self::Windows
22 } else if cfg!(target_os = "macos") {
23 Self::Mac
24 } else {
25 Self::Unix
26 }
27 }
28
29 #[must_use]
31 pub const fn ignores_case(self) -> bool {
32 !matches!(self, Self::Unix)
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Kind {
39 File,
40 Folder,
41 Link,
42 Other,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Wanted {
48 File,
49 Folder,
50 Link,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Action {
56 Create,
57 Replace,
58 Merge,
59}
60
61pub fn check_existing(
69 path: &Path,
70 existing: Option<Kind>,
71 wanted: Wanted,
72 overwrite: bool,
73) -> Result<Action, String> {
74 let shown = path.display();
75 let Some(existing) = existing else {
76 return Ok(Action::Create);
77 };
78 match (existing, wanted) {
79 (Kind::Folder, Wanted::Folder) => Ok(Action::Merge),
80 (Kind::Folder, _) => Err(format!(
81 "{shown} is a folder and a file would replace it, which is never done"
82 )),
83 (_, Wanted::Folder) => Err(format!(
84 "{shown} is not a folder and a folder would replace it, which is never done"
85 )),
86 _ if !overwrite => Err(already_exists(path)),
87 (Kind::Other, _) => Err(format!(
88 "{shown} is a pipe, device, or socket and cannot be replaced"
89 )),
90 (Kind::File | Kind::Link, _) => Ok(Action::Replace),
91 }
92}
93
94fn already_exists(path: &Path) -> String {
95 format!(
96 "{} already exists, set overwrite to true to replace it",
97 path.display()
98 )
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Destination {
104 pub path: PathBuf,
105 pub action: Action,
106}
107
108pub fn destination(
119 dest: &Path,
120 dest_kind: Option<Kind>,
121 name: &str,
122 source: Wanted,
123 overwrite: bool,
124 probe: impl FnOnce(&Path) -> Option<Kind>,
125) -> Result<Destination, String> {
126 let (path, existing) = if dest_kind == Some(Kind::Folder) {
127 let inside = dest.join(name);
128 let existing = probe(&inside);
129 (inside, existing)
130 } else {
131 (dest.to_path_buf(), probe(dest))
132 };
133 let action = check_existing(&path, existing, source, overwrite)?;
134 if action == Action::Merge && !overwrite {
135 return Err(already_exists(&path));
136 }
137 Ok(Destination { path, action })
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum PathProblem {
143 Absolute,
144 ParentDir,
145 Empty,
146}
147
148pub fn inside(path: &Path) -> Result<Vec<&OsStr>, PathProblem> {
154 let mut names = Vec::new();
155 for part in path.components() {
156 match part {
157 Component::Normal(name) => names.push(name),
158 Component::CurDir => {}
159 Component::ParentDir => return Err(PathProblem::ParentDir),
160 Component::RootDir | Component::Prefix(_) => return Err(PathProblem::Absolute),
161 }
162 }
163 if names.is_empty() {
164 Err(PathProblem::Empty)
165 } else {
166 Ok(names)
167 }
168}
169
170const WINDOWS_BAD_CHARS: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
171const WINDOWS_DEVICES: &[&str] = &["CON", "PRN", "AUX", "NUL"];
172
173#[must_use]
175pub fn name_problem(name: &str, platform: Platform) -> Option<String> {
176 if name.is_empty() {
177 return Some("the name is empty".to_owned());
178 }
179 if name.contains('\0') {
180 return Some("the name contains a null character".to_owned());
181 }
182 if platform != Platform::Windows {
183 return None;
184 }
185 if let Some(bad) = name
186 .chars()
187 .find(|c| WINDOWS_BAD_CHARS.contains(c) || c.is_control())
188 {
189 return Some(format!(
190 "Windows does not allow {} in names",
191 bad.escape_debug()
192 ));
193 }
194 if name.ends_with('.') || name.ends_with(' ') {
195 return Some("Windows does not allow names that end in a dot or a space".to_owned());
196 }
197 let stem = name
198 .split('.')
199 .next()
200 .unwrap_or(name)
201 .trim_end()
202 .to_ascii_uppercase();
203 let numbered = |prefix: &str| {
204 stem.strip_prefix(prefix)
205 .is_some_and(|n| matches!(n.as_bytes(), [b'1'..=b'9']))
206 };
207 if WINDOWS_DEVICES.contains(&stem.as_str()) || numbered("COM") || numbered("LPT") {
208 return Some(format!("{stem} is a reserved device name on Windows"));
209 }
210 None
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn dest(
218 dest_kind: Option<Kind>,
219 existing: Option<Kind>,
220 source: Wanted,
221 overwrite: bool,
222 ) -> Result<Destination, String> {
223 destination(Path::new("/d"), dest_kind, "x", source, overwrite, |_| {
224 existing
225 })
226 }
227
228 #[test]
229 fn an_existing_folder_receives_the_source_under_its_own_name() {
230 let got = dest(Some(Kind::Folder), None, Wanted::File, false).unwrap();
231 assert_eq!(got.path, Path::new("/d/x"));
232 assert_eq!(got.action, Action::Create);
233 }
234
235 #[test]
236 fn anything_else_makes_the_destination_the_new_name() {
237 let got = dest(None, None, Wanted::Folder, false).unwrap();
238 assert_eq!(got.path, Path::new("/d"));
239 assert_eq!(got.action, Action::Create);
240 let got = dest(Some(Kind::File), Some(Kind::File), Wanted::File, true).unwrap();
241 assert_eq!(got.path, Path::new("/d"));
242 assert_eq!(got.action, Action::Replace);
243 }
244
245 #[test]
246 fn an_existing_final_path_is_refused_by_name_unless_overwrite_is_on() {
247 let error = dest(Some(Kind::Folder), Some(Kind::File), Wanted::File, false).unwrap_err();
248 assert!(error.contains("already exists"), "{error}");
249 let got = dest(Some(Kind::Folder), Some(Kind::File), Wanted::File, true).unwrap();
250 assert_eq!(got.action, Action::Replace);
251 let got = dest(Some(Kind::Folder), Some(Kind::Link), Wanted::Link, true).unwrap();
252 assert_eq!(got.action, Action::Replace);
253 }
254
255 #[test]
256 fn a_root_folder_that_exists_is_refused_unless_overwrite_merges_into_it() {
257 let existing = Some(Kind::Folder);
258 let error = dest(Some(Kind::Folder), existing, Wanted::Folder, false).unwrap_err();
259 assert!(error.contains("already exists"), "{error}");
260 let got = dest(Some(Kind::Folder), existing, Wanted::Folder, true).unwrap();
261 assert_eq!(got.action, Action::Merge);
262 assert_eq!(
263 check_existing(Path::new("/d/x"), existing, Wanted::Folder, false),
264 Ok(Action::Merge)
265 );
266 }
267
268 #[test]
269 fn a_file_never_replaces_a_folder_or_the_other_way_round() {
270 for overwrite in [false, true] {
271 let error = dest(
272 Some(Kind::Folder),
273 Some(Kind::Folder),
274 Wanted::File,
275 overwrite,
276 )
277 .unwrap_err();
278 assert!(error.contains("is a folder"), "{error}");
279 for existing in [Kind::File, Kind::Link] {
280 let error = dest(
281 Some(Kind::Folder),
282 Some(existing),
283 Wanted::Folder,
284 overwrite,
285 )
286 .unwrap_err();
287 assert!(error.contains("not a folder"), "{error}");
288 }
289 }
290 }
291
292 #[test]
293 fn pipes_and_devices_are_never_replaced() {
294 let error = dest(Some(Kind::Folder), Some(Kind::Other), Wanted::File, true).unwrap_err();
295 assert!(error.contains("pipe, device, or socket"), "{error}");
296 }
297
298 #[test]
299 fn entry_paths_stay_inside_their_root() {
300 let count = |p: &str| inside(Path::new(p)).map(|v| v.len());
301 assert_eq!(count("a/b/c.txt"), Ok(3));
302 assert_eq!(count("./a/b"), Ok(2));
303 assert_eq!(count("/etc/passwd"), Err(PathProblem::Absolute));
304 assert_eq!(count("a/../../b"), Err(PathProblem::ParentDir));
305 assert_eq!(count(".."), Err(PathProblem::ParentDir));
306 assert_eq!(count("."), Err(PathProblem::Empty));
307 }
308
309 #[test]
310 fn only_unix_distinguishes_names_by_case() {
311 assert!(!Platform::Unix.ignores_case());
312 assert!(Platform::Mac.ignores_case() && Platform::Windows.ignores_case());
313 assert!(name_problem("a:b", Platform::Mac).is_none());
314 }
315
316 #[test]
317 fn windows_names_are_checked_and_unix_names_are_not() {
318 let bad = |name: &str| name_problem(name, Platform::Windows).is_some();
319 for name in [
320 "a:b",
321 "a?",
322 "a*",
323 "q\"q",
324 "a<b",
325 "a>b",
326 "a|b",
327 "a\\b",
328 "dots.",
329 "space ",
330 "CON",
331 "con.txt",
332 "Nul",
333 "aux.tar.gz",
334 "COM1",
335 "lpt9.log",
336 "tab\tname",
337 ] {
338 assert!(bad(name), "{name} should be refused");
339 }
340 for name in [
341 "file.txt", "COM0", "COM10", "CONSOLE", "lpt", ".hidden", "a b", "é",
342 ] {
343 assert!(!bad(name), "{name} should be allowed");
344 }
345 assert!(name_problem("a:b?", Platform::Unix).is_none());
346 assert!(name_problem("", Platform::Unix).is_some());
347 assert!(name_problem("a\0b", Platform::Unix).is_some());
348 }
349}