OXYGENHUB
# 454236d1108d oxygen / crates / oxy-core / src / portable.rs
5.2 KiB · Rust · last changed Portability: exec bits, symlinks and path names on macOS and Windows 1 month ago source blame history raw
1
//! What the file system of the working copy can represent (spec §7.7),
2
//! and which paths of a state it cannot.
3
//!
4
//! Executable bits and symbolic links exist on Unix only; elsewhere a
5
//! snapshot keeps the recorded executable bit and reads a symlink written
6
//! out as a plain file back as the symlink. macOS and Windows file systems
7
//! ignore case by default and Windows forbids some names, so paths that
8
//! differ only in case or use such names are reported before they are
9
//! written.
10
11
use std::collections::BTreeMap;
12
13
/// Capabilities of the working copy's file system.
14
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15
pub struct Platform {
16
    pub exec_bits: bool,
17
    pub symlinks: bool,
18
    pub case_insensitive: bool,
19
    /// Windows' name rules: reserved device names, `<>:"\|?*`, trailing dots and spaces.
20
    pub windows_names: bool,
21
}
22
23
impl Platform {
24
    /// The platform oxy runs on, with its default file system.
25
    pub fn current() -> Platform {
26
        Platform {
27
            exec_bits: cfg!(unix),
28
            symlinks: cfg!(unix),
29
            case_insensitive: cfg!(any(target_os = "macos", target_os = "windows")),
30
            windows_names: cfg!(target_os = "windows"),
31
        }
32
    }
33
34
    pub fn windows() -> Platform {
35
        Platform {
36
            exec_bits: false,
37
            symlinks: false,
38
            case_insensitive: true,
39
            windows_names: true,
40
        }
41
    }
42
43
    pub fn macos() -> Platform {
44
        Platform {
45
            exec_bits: true,
46
            symlinks: true,
47
            case_insensitive: true,
48
            windows_names: false,
49
        }
50
    }
51
52
    /// Problems this platform would have with these paths.
53
    pub fn problems<'a>(&self, paths: impl IntoIterator<Item = &'a str>) -> Vec<String> {
54
        let paths: Vec<&str> = paths.into_iter().collect();
55
        let mut out = Vec::new();
56
        if self.case_insensitive {
57
            out.extend(
58
                case_collisions(&paths).into_iter().map(|(a, b)| {
59
                    format!("{a} and {b} differ only in case; one will hide the other")
60
                }),
61
            );
62
        }
63
        if self.windows_names {
64
            out.extend(
65
                paths
66
                    .iter()
67
                    .filter_map(|p| windows_name_problem(p).map(|why| format!("{p}: {why}"))),
68
            );
69
        }
70
        out
71
    }
72
}
73
74
/// Pairs of paths equal but for case (including a directory's case).
75
pub fn case_collisions(paths: &[&str]) -> Vec<(String, String)> {
76
    let mut seen: BTreeMap<String, &str> = BTreeMap::new();
77
    let mut out = Vec::new();
78
    // Every prefix counts: `Docs/a` and `docs/b` put two directories in one place.
79
    let mut prefixes: Vec<&str> = Vec::new();
80
    for p in paths {
81
        let mut end = 0;
82
        for part in p.split('/') {
83
            end += part.len();
84
            prefixes.push(&p[..end]);
85
            end += 1;
86
        }
87
    }
88
    prefixes.sort();
89
    prefixes.dedup();
90
    for p in prefixes {
91
        let key = p.to_lowercase();
92
        match seen.get(&key) {
93
            Some(first) if *first != p => out.push((first.to_string(), p.to_string())),
94
            Some(_) => {}
95
            None => {
96
                seen.insert(key, p);
97
            }
98
        }
99
    }
100
    out
101
}
102
103
/// Why Windows would refuse a path, if it would.
104
pub fn windows_name_problem(path: &str) -> Option<&'static str> {
105
    const RESERVED: &[&str] = &[
106
        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
107
        "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
108
    ];
109
    for part in path.split('/') {
110
        if part
111
            .chars()
112
            .any(|c| matches!(c, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') || (c as u32) < 32)
113
        {
114
            return Some("contains a character Windows does not allow in names");
115
        }
116
        if part.ends_with('.') || part.ends_with(' ') {
117
            return Some("a name ending in a dot or space cannot be created on Windows");
118
        }
119
        let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase();
120
        if RESERVED.contains(&stem.as_str()) {
121
            return Some("a reserved device name on Windows");
122
        }
123
    }
124
    None
125
}
126
127
#[cfg(test)]
128
mod tests {
129
    use super::*;
130
131
    #[test]
132
    fn collisions_and_names() {
133
        assert_eq!(
134
            case_collisions(&["README.md", "readme.md", "src/a.rs"]),
135
            vec![("README.md".into(), "readme.md".into())]
136
        );
137
        assert_eq!(
138
            case_collisions(&["Docs/a", "docs/b"]),
139
            vec![("Docs".into(), "docs".into())]
140
        );
141
        assert!(case_collisions(&["a/b", "a/c", "b"]).is_empty());
142
        assert!(windows_name_problem("src/con.rs").is_some());
143
        assert!(windows_name_problem("notes/a:b.txt").is_some());
144
        assert!(windows_name_problem("trailing./x").is_some());
145
        assert!(windows_name_problem("src/console.rs").is_none());
146
        let w = Platform::windows();
147
        assert_eq!(w.problems(["A.txt", "a.txt", "aux.c"]).len(), 2);
148
        assert!(Platform {
149
            exec_bits: true,
150
            symlinks: true,
151
            case_insensitive: false,
152
            windows_names: false
153
        }
154
        .problems(["A", "a"])
155
        .is_empty());
156
    }
157
}