//! What the file system of the working copy can represent (spec §7.7), //! and which paths of a state it cannot. //! //! Executable bits and symbolic links exist on Unix only; elsewhere a //! snapshot keeps the recorded executable bit and reads a symlink written //! out as a plain file back as the symlink. macOS and Windows file systems //! ignore case by default and Windows forbids some names, so paths that //! differ only in case or use such names are reported before they are //! written. use std::collections::BTreeMap; /// Capabilities of the working copy's file system. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Platform { pub exec_bits: bool, pub symlinks: bool, pub case_insensitive: bool, /// Windows' name rules: reserved device names, `<>:"\|?*`, trailing dots and spaces. pub windows_names: bool, } impl Platform { /// The platform oxy runs on, with its default file system. pub fn current() -> Platform { Platform { exec_bits: cfg!(unix), symlinks: cfg!(unix), case_insensitive: cfg!(any(target_os = "macos", target_os = "windows")), windows_names: cfg!(target_os = "windows"), } } pub fn windows() -> Platform { Platform { exec_bits: false, symlinks: false, case_insensitive: true, windows_names: true, } } pub fn macos() -> Platform { Platform { exec_bits: true, symlinks: true, case_insensitive: true, windows_names: false, } } /// Problems this platform would have with these paths. pub fn problems<'a>(&self, paths: impl IntoIterator) -> Vec { let paths: Vec<&str> = paths.into_iter().collect(); let mut out = Vec::new(); if self.case_insensitive { out.extend( case_collisions(&paths).into_iter().map(|(a, b)| { format!("{a} and {b} differ only in case; one will hide the other") }), ); } if self.windows_names { out.extend( paths .iter() .filter_map(|p| windows_name_problem(p).map(|why| format!("{p}: {why}"))), ); } out } } /// Pairs of paths equal but for case (including a directory's case). pub fn case_collisions(paths: &[&str]) -> Vec<(String, String)> { let mut seen: BTreeMap = BTreeMap::new(); let mut out = Vec::new(); // Every prefix counts: `Docs/a` and `docs/b` put two directories in one place. let mut prefixes: Vec<&str> = Vec::new(); for p in paths { let mut end = 0; for part in p.split('/') { end += part.len(); prefixes.push(&p[..end]); end += 1; } } prefixes.sort(); prefixes.dedup(); for p in prefixes { let key = p.to_lowercase(); match seen.get(&key) { Some(first) if *first != p => out.push((first.to_string(), p.to_string())), Some(_) => {} None => { seen.insert(key, p); } } } out } /// `path.canonicalize()`, but on Windows without the verbatim `\\?\` /// prefix when the plain form names the same file (oxygen#143): a root /// shown as `\\?\C:\work\demo` in every message otherwise. pub fn canonical(path: &std::path::Path) -> std::io::Result { path.canonicalize().map(plain) } /// [`canonical`]'s rule alone: `\\?\C:\…` as `C:\…` and `\\?\UNC\…` as /// `\\…`, when the result is under Windows' old length limit and has no /// component the plain form would read differently (`.`, `..`, a trailing /// dot or space, a reserved name). Anything else is left as it is. pub fn plain(p: std::path::PathBuf) -> std::path::PathBuf { let Some(s) = p.to_str() else { return p }; let unchanged_by_plain = |rest: &str| { rest.len() < 260 && !rest.split('\\').any(|c| { c == "." || c == ".." || c.ends_with('.') || c.ends_with(' ') || (!c.is_empty() && windows_name_problem(c).is_some()) }) }; if let Some(rest) = s.strip_prefix(r"\\?\UNC\") { if unchanged_by_plain(rest) { return std::path::PathBuf::from(format!(r"\\{rest}")); } } else if let Some(rest) = s.strip_prefix(r"\\?\") { let b = rest.as_bytes(); let drive = b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\'; if drive && unchanged_by_plain(&rest[3..]) { return std::path::PathBuf::from(rest); } } p } /// Why Windows would refuse a path, if it would. pub fn windows_name_problem(path: &str) -> Option<&'static str> { const RESERVED: &[&str] = &[ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; for part in path.split('/') { if part .chars() .any(|c| matches!(c, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') || (c as u32) < 32) { return Some("contains a character Windows does not allow in names"); } if part.ends_with('.') || part.ends_with(' ') { return Some("a name ending in a dot or space cannot be created on Windows"); } let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase(); if RESERVED.contains(&stem.as_str()) { return Some("a reserved device name on Windows"); } } None } #[cfg(test)] mod tests { use super::*; /// oxygen#143: Windows' verbatim prefix is dropped where the plain path /// says the same, and kept where it would not. #[test] fn a_verbatim_windows_path_is_shown_plain_when_it_can_be() { use std::path::PathBuf; let p = |s: &str| plain(PathBuf::from(s)).to_string_lossy().into_owned(); assert_eq!(p(r"\\?\C:\work\demo"), r"C:\work\demo"); assert_eq!(p(r"\\?\UNC\server\share\repo"), r"\\server\share\repo"); assert_eq!( p(r"\\?\C:\work\CON"), r"\\?\C:\work\CON", "a reserved name stays verbatim" ); assert_eq!(p(r"\\?\C:\work\trailing."), r"\\?\C:\work\trailing."); assert_eq!( p(r"\\?\Volume{1234}\x"), r"\\?\Volume{1234}\x", "not a drive path" ); let long = format!(r"\\?\C:\{}", "a".repeat(300)); assert_eq!(p(&long), long, "past the old length limit"); assert_eq!( p("/home/andy/repo"), "/home/andy/repo", "nothing to do elsewhere" ); } #[test] fn collisions_and_names() { assert_eq!( case_collisions(&["README.md", "readme.md", "src/a.rs"]), vec![("README.md".into(), "readme.md".into())] ); assert_eq!( case_collisions(&["Docs/a", "docs/b"]), vec![("Docs".into(), "docs".into())] ); assert!(case_collisions(&["a/b", "a/c", "b"]).is_empty()); assert!(windows_name_problem("src/con.rs").is_some()); assert!(windows_name_problem("notes/a:b.txt").is_some()); assert!(windows_name_problem("trailing./x").is_some()); assert!(windows_name_problem("src/console.rs").is_none()); let w = Platform::windows(); assert_eq!(w.problems(["A.txt", "a.txt", "aux.c"]).len(), 2); assert!(Platform { exec_bits: true, symlinks: true, case_insensitive: false, windows_names: false } .problems(["A", "a"]) .is_empty()); } }