Skip to main content

computer_transfer/
rules.rs

1//! Decisions about where entries land and which names are allowed. No file system access.
2
3use std::{
4    ffi::OsStr,
5    path::{Component, Path, PathBuf},
6};
7
8/// The system entries are written to, which decides the names and links it can hold.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Platform {
11    Unix,
12    /// A Unix system whose default file system ignores case.
13    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    /// Whether `A.txt` and `a.txt` are the same file here.
30    #[must_use]
31    pub const fn ignores_case(self) -> bool {
32        !matches!(self, Self::Unix)
33    }
34}
35
36/// What exists at a path.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Kind {
39    File,
40    Folder,
41    Link,
42    Other,
43}
44
45/// What an archive entry wants to put in place.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Wanted {
48    File,
49    Folder,
50    Link,
51}
52
53/// What to do with an entry once its place is known.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Action {
56    Create,
57    Replace,
58    Merge,
59}
60
61/// Decides what to do with an entry that wants `wanted` at `path`, where `existing` is what is there now.
62///
63/// # Errors
64///
65/// Refuses, naming the path, when something other than a folder exists and `overwrite` is off, or
66/// when a file would meet a folder. A folder meeting a folder merges, since conflicts between
67/// their contents are decided entry by entry.
68pub 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/// Where the root of a transfer lands and what happens there.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Destination {
104    pub path: PathBuf,
105    pub action: Action,
106}
107
108/// Applies the destination rule of `cp -r`.
109///
110/// An existing folder at `dest` receives the source under its own `name`. Anything else makes
111/// `dest` the new path. `dest_kind` is what `dest` is with links followed. `probe` tells what
112/// exists at the final path without following a link.
113///
114/// # Errors
115///
116/// Refuses with the reason from [`check_existing`], and also when the final path is an existing
117/// folder and `overwrite` is off.
118pub 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/// Why a path inside an archive is refused.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum PathProblem {
143    Absolute,
144    ParentDir,
145    Empty,
146}
147
148/// The names of `path` when it stays inside its root: every part is a plain name or `.`.
149///
150/// # Errors
151///
152/// Fails for absolute paths, drive prefixes, `..`, and paths with no name.
153pub 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/// Why `name` cannot exist as a file or folder name on `platform`, when it cannot.
174#[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}