OXYGENHUB
# 135bdcadec4a oxygen / crates / oxy-core / src / portable.rs
7.7 KiB · Rust · last changed Windows paths print plainly (closes #143) 10 days 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
/// `path.canonicalize()`, but on Windows without the verbatim `\\?\`
104
/// prefix when the plain form names the same file (oxygen#143): a root
105
/// shown as `\\?\C:\work\demo` in every message otherwise.
106
pub fn canonical(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
107
    path.canonicalize().map(plain)
108
}
109
110
/// [`canonical`]'s rule alone: `\\?\C:\…` as `C:\…` and `\\?\UNC\…` as
111
/// `\\…`, when the result is under Windows' old length limit and has no
112
/// component the plain form would read differently (`.`, `..`, a trailing
113
/// dot or space, a reserved name). Anything else is left as it is.
114
pub fn plain(p: std::path::PathBuf) -> std::path::PathBuf {
115
    let Some(s) = p.to_str() else { return p };
116
    let unchanged_by_plain = |rest: &str| {
117
        rest.len() < 260
118
            && !rest.split('\\').any(|c| {
119
                c == "."
120
                    || c == ".."
121
                    || c.ends_with('.')
122
                    || c.ends_with(' ')
123
                    || (!c.is_empty() && windows_name_problem(c).is_some())
124
            })
125
    };
126
    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
127
        if unchanged_by_plain(rest) {
128
            return std::path::PathBuf::from(format!(r"\\{rest}"));
129
        }
130
    } else if let Some(rest) = s.strip_prefix(r"\\?\") {
131
        let b = rest.as_bytes();
132
        let drive = b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\';
133
        if drive && unchanged_by_plain(&rest[3..]) {
134
            return std::path::PathBuf::from(rest);
135
        }
136
    }
137
    p
138
}
139
140
/// Why Windows would refuse a path, if it would.
141
pub fn windows_name_problem(path: &str) -> Option<&'static str> {
142
    const RESERVED: &[&str] = &[
143
        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
144
        "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
145
    ];
146
    for part in path.split('/') {
147
        if part
148
            .chars()
149
            .any(|c| matches!(c, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') || (c as u32) < 32)
150
        {
151
            return Some("contains a character Windows does not allow in names");
152
        }
153
        if part.ends_with('.') || part.ends_with(' ') {
154
            return Some("a name ending in a dot or space cannot be created on Windows");
155
        }
156
        let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase();
157
        if RESERVED.contains(&stem.as_str()) {
158
            return Some("a reserved device name on Windows");
159
        }
160
    }
161
    None
162
}
163
164
#[cfg(test)]
165
mod tests {
166
    use super::*;
167
168
    /// oxygen#143: Windows' verbatim prefix is dropped where the plain path
169
    /// says the same, and kept where it would not.
170
    #[test]
171
    fn a_verbatim_windows_path_is_shown_plain_when_it_can_be() {
172
        use std::path::PathBuf;
173
        let p = |s: &str| plain(PathBuf::from(s)).to_string_lossy().into_owned();
174
        assert_eq!(p(r"\\?\C:\work\demo"), r"C:\work\demo");
175
        assert_eq!(p(r"\\?\UNC\server\share\repo"), r"\\server\share\repo");
176
        assert_eq!(
177
            p(r"\\?\C:\work\CON"),
178
            r"\\?\C:\work\CON",
179
            "a reserved name stays verbatim"
180
        );
181
        assert_eq!(p(r"\\?\C:\work\trailing."), r"\\?\C:\work\trailing.");
182
        assert_eq!(
183
            p(r"\\?\Volume{1234}\x"),
184
            r"\\?\Volume{1234}\x",
185
            "not a drive path"
186
        );
187
        let long = format!(r"\\?\C:\{}", "a".repeat(300));
188
        assert_eq!(p(&long), long, "past the old length limit");
189
        assert_eq!(
190
            p("/home/andy/repo"),
191
            "/home/andy/repo",
192
            "nothing to do elsewhere"
193
        );
194
    }
195
196
    #[test]
197
    fn collisions_and_names() {
198
        assert_eq!(
199
            case_collisions(&["README.md", "readme.md", "src/a.rs"]),
200
            vec![("README.md".into(), "readme.md".into())]
201
        );
202
        assert_eq!(
203
            case_collisions(&["Docs/a", "docs/b"]),
204
            vec![("Docs".into(), "docs".into())]
205
        );
206
        assert!(case_collisions(&["a/b", "a/c", "b"]).is_empty());
207
        assert!(windows_name_problem("src/con.rs").is_some());
208
        assert!(windows_name_problem("notes/a:b.txt").is_some());
209
        assert!(windows_name_problem("trailing./x").is_some());
210
        assert!(windows_name_problem("src/console.rs").is_none());
211
        let w = Platform::windows();
212
        assert_eq!(w.problems(["A.txt", "a.txt", "aux.c"]).len(), 2);
213
        assert!(Platform {
214
            exec_bits: true,
215
            symlinks: true,
216
            case_insensitive: false,
217
            windows_names: false
218
        }
219
        .problems(["A", "a"])
220
        .is_empty());
221
    }
222
}