OXYGENHUB

Coverage / crates/oxy-core/src/portable.rs 97.4%

148 of 152 lines run, 28 of 47 functions · run #387 on main · run missed

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.
252831×
    pub fn current() -> Platform {
262831×
        Platform {
272831×
            exec_bits: cfg!(unix),
282831×
            symlinks: cfg!(unix),
292831×
            case_insensitive: cfg!(any(target_os = "macos", target_os = "windows")),
302831×
            windows_names: cfg!(target_os = "windows"),
312831×
        }
322831×
    }
33
348×
    pub fn windows() -> Platform {
358×
        Platform {
368×
            exec_bits: false,
378×
            symlinks: false,
388×
            case_insensitive: true,
398×
            windows_names: true,
408×
        }
418×
    }
42
431×
    pub fn macos() -> Platform {
441×
        Platform {
451×
            exec_bits: true,
461×
            symlinks: true,
471×
            case_insensitive: true,
481×
            windows_names: false,
491×
        }
501×
    }
51
52
    /// Problems this platform would have with these paths.
53580×
    pub fn problems<'a>(&self, paths: impl IntoIterator<Item = &'a str>) -> Vec<String> {
54580×
        let paths: Vec<&str> = paths.into_iter().collect();
55580×
        let mut out = Vec::new();
56580×
        if self.case_insensitive {
578×
            out.extend(
588×
                case_collisions(&paths).into_iter().map(|(a, b)| {
593×
                    format!("{a} and {b} differ only in case; one will hide the other")
603×
                }),
61
            );
62572×
        }
63580×
        if self.windows_names {
647×
            out.extend(
657×
                paths
667×
                    .iter()
677×
                    .filter_map(|p| windows_name_problem(p).map(|why| format!("{p}: {why}"))),
68
            );
69573×
        }
70580×
        out
71580×
    }
72
}
73
74
/// Pairs of paths equal but for case (including a directory's case).
7511×
pub fn case_collisions(paths: &[&str]) -> Vec<(String, String)> {
7611×
    let mut seen: BTreeMap<String, &str> = BTreeMap::new();
7711×
    let mut out = Vec::new();
78
    // Every prefix counts: `Docs/a` and `docs/b` put two directories in one place.
7911×
    let mut prefixes: Vec<&str> = Vec::new();
8019×
    for p in paths {
8119×
        let mut end = 0;
8226×
        for part in p.split('/') {
8326×
            end += part.len();
8426×
            prefixes.push(&p[..end]);
8526×
            end += 1;
8626×
        }
87
    }
8811×
    prefixes.sort();
8911×
    prefixes.dedup();
9023×
    for p in prefixes {
9123×
        let key = p.to_lowercase();
9223×
        match seen.get(&key) {
935×
            Some(first) if *first != p => out.push((first.to_string(), p.to_string())),
940×
            Some(_) => {}
9518×
            None => {
9618×
                seen.insert(key, p);
9718×
            }
98
        }
99
    }
10011×
    out
10111×
}
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.
1062889×
pub fn canonical(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
1072889×
    path.canonicalize().map(plain)
1082889×
}
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.
1142896×
pub fn plain(p: std::path::PathBuf) -> std::path::PathBuf {
1152896×
    let Some(s) = p.to_str() else { return p };
1162896×
    let unchanged_by_plain = |rest: &str| {
1175×
        rest.len() < 260
1189×
            && !rest.split('\\').any(|c| {
1199×
                c == "."
1209×
                    || c == ".."
1219×
                    || c.ends_with('.')
1228×
                    || c.ends_with(' ')
1238×
                    || (!c.is_empty() && windows_name_problem(c).is_some())
1249×
            })
1255×
    };
1262896×
    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
1271×
        if unchanged_by_plain(rest) {
1281×
            return std::path::PathBuf::from(format!(r"\\{rest}"));
1290×
        }
1302895×
    } else if let Some(rest) = s.strip_prefix(r"\\?\") {
1315×
        let b = rest.as_bytes();
1325×
        let drive = b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\';
1335×
        if drive && unchanged_by_plain(&rest[3..]) {
1341×
            return std::path::PathBuf::from(rest);
1354×
        }
1362890×
    }
1372894×
    p
1382896×
}
139
140
/// Why Windows would refuse a path, if it would.
14119×
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
    ];
14623×
    for part in path.split('/') {
14723×
        if part
14823×
            .chars()
149116×
            .any(|c| matches!(c, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') || (c as u32) < 32)
150
        {
1511×
            return Some("contains a character Windows does not allow in names");
15222×
        }
15322×
        if part.ends_with('.') || part.ends_with(' ') {
1541×
            return Some("a name ending in a dot or space cannot be created on Windows");
15521×
        }
15621×
        let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase();
15721×
        if RESERVED.contains(&stem.as_str()) {
1584×
            return Some("a reserved device name on Windows");
15917×
        }
160
    }
16113×
    None
16219×
}
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]
1711×
    fn a_verbatim_windows_path_is_shown_plain_when_it_can_be() {
172
        use std::path::PathBuf;
1737×
        let p = |s: &str| plain(PathBuf::from(s)).to_string_lossy().into_owned();
1741×
        assert_eq!(p(r"\\?\C:\work\demo"), r"C:\work\demo");
1751×
        assert_eq!(p(r"\\?\UNC\server\share\repo"), r"\\server\share\repo");
1761×
        assert_eq!(
1771×
            p(r"\\?\C:\work\CON"),
178
            r"\\?\C:\work\CON",
179
            "a reserved name stays verbatim"
180
        );
1811×
        assert_eq!(p(r"\\?\C:\work\trailing."), r"\\?\C:\work\trailing.");
1821×
        assert_eq!(
1831×
            p(r"\\?\Volume{1234}\x"),
184
            r"\\?\Volume{1234}\x",
185
            "not a drive path"
186
        );
1871×
        let long = format!(r"\\?\C:\{}", "a".repeat(300));
1881×
        assert_eq!(p(&long), long, "past the old length limit");
1891×
        assert_eq!(
1901×
            p("/home/andy/repo"),
191
            "/home/andy/repo",
192
            "nothing to do elsewhere"
193
        );
1941×
    }
195
196
    #[test]
1971×
    fn collisions_and_names() {
1981×
        assert_eq!(
1991×
            case_collisions(&["README.md", "readme.md", "src/a.rs"]),
2001×
            vec![("README.md".into(), "readme.md".into())]
201
        );
2021×
        assert_eq!(
2031×
            case_collisions(&["Docs/a", "docs/b"]),
2041×
            vec![("Docs".into(), "docs".into())]
205
        );
2061×
        assert!(case_collisions(&["a/b", "a/c", "b"]).is_empty());
2071×
        assert!(windows_name_problem("src/con.rs").is_some());
2081×
        assert!(windows_name_problem("notes/a:b.txt").is_some());
2091×
        assert!(windows_name_problem("trailing./x").is_some());
2101×
        assert!(windows_name_problem("src/console.rs").is_none());
2111×
        let w = Platform::windows();
2121×
        assert_eq!(w.problems(["A.txt", "a.txt", "aux.c"]).len(), 2);
2131×
        assert!(Platform {
2141×
            exec_bits: true,
2151×
            symlinks: true,
2161×
            case_insensitive: false,
2171×
            windows_names: false
2181×
        }
2191×
        .problems(["A", "a"])
2201×
        .is_empty());
2211×
    }
222
}