//! Filesystem utilities for Windows. use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use crate::error; // Selectively re-export items from stubs that we don't override. pub(crate) use crate::sys::stubs::fs::MetadataExt; /// Cached list of executable extensions from the `PATHEXT` environment /// variable. Each entry retains its leading dot (e.g. `".exe"`) and is stored /// lowercased so case-insensitive comparisons can be done without allocating. /// /// NOTE: This is cached for the process lifetime. Changes to `PATHEXT` made /// inside the running shell are not reflected here. Bash itself has no /// `PATHEXT` semantics, so this is generally acceptable for now. static PATHEXT_EXTENSIONS: LazyLock> = LazyLock::new(|| { std::env::var("PATHEXT") .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()) .split(';') .filter(|s| !s.is_empty()) .map(|s| s.to_ascii_lowercase()) .collect() }); /// Returns the stem of a PATHEXT entry (with any leading `.` removed). /// /// `PATHEXT` canonically stores entries like `.EXE`, but tolerant parsing /// accepts entries without the leading dot too. fn pathext_entry_stem(entry: &str) -> &str { entry.strip_prefix('.').unwrap_or(entry) } /// Returns true if the path's extension is in the PATHEXT list. /// /// Performs case-insensitive comparison against the cached PATHEXT entries /// without allocating. fn has_executable_extension(path: &Path) -> bool { path.extension().is_some_and(|ext| { PATHEXT_EXTENSIONS .iter() .any(|e| ext.eq_ignore_ascii_case(pathext_entry_stem(e))) }) } /// Returns true if `path` is, by itself, an existing executable file. /// /// Used both for the initial check in [`resolve_executable`] and for /// [`PathExt::executable`]. fn is_executable_file(path: &Path) -> bool { has_executable_extension(path) && path.is_file() } /// Resolves an owned path to the actual on-disk executable file, if any. /// /// If the path is already a file with a `PATHEXT` extension, it is returned /// unchanged (no allocation). Otherwise, each `PATHEXT` extension is appended /// in turn and the first existing file is returned. pub fn resolve_executable(path: PathBuf) -> Option { if is_executable_file(&path) { return Some(path); } // Try appending each PATHEXT extension. for ext in PATHEXT_EXTENSIONS.iter() { let mut name = path.as_os_str().to_owned(); name.push(ext); let candidate = PathBuf::from(name); if candidate.is_file() { return Some(candidate); } } None } impl crate::sys::fs::PathExt for Path { fn readable(&self) -> bool { self.exists() } fn writable(&self) -> bool { self.metadata().is_ok_and(|m| !m.permissions().readonly()) } fn executable(&self) -> bool { if is_executable_file(self) { return true; } // Try each PATHEXT extension without allocating a separate PathBuf // per candidate until one exists. PATHEXT_EXTENSIONS.iter().any(|ext| { let mut name = self.as_os_str().to_owned(); name.push(ext); Self::new(&name).is_file() }) } fn exists_and_is_block_device(&self) -> bool { false } fn exists_and_is_char_device(&self) -> bool { false } fn exists_and_is_fifo(&self) -> bool { false } fn exists_and_is_socket(&self) -> bool { false } fn exists_and_is_setgid(&self) -> bool { false } fn exists_and_is_setuid(&self) -> bool { false } fn exists_and_is_sticky_bit(&self) -> bool { false } fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> { // TODO(windows): implement using file index / volume serial number. Err(error::ErrorKind::NotSupportedOnThisPlatform("get_device_and_inode").into()) } } /// Splits a platform-specific PATH-like value into individual paths. /// /// On Windows, this delegates to [`std::env::split_paths`]. pub fn split_paths + ?Sized>(s: &T) -> std::env::SplitPaths<'_> { std::env::split_paths(s) } /// Opens a null file that will discard all I/O. pub fn open_null_file() -> Result { let f = std::fs::File::options() .read(true) .write(true) .open("NUL")?; Ok(f) } /// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`). pub fn try_open_special_file(path: &Path) -> Option> { if path.ends_with("dev/null") && path.is_absolute() { Some(open_null_file().map_err(std::io::Error::other)) } else { None } } /// Returns the default paths where executables are typically found on Windows. pub(crate) fn get_default_executable_search_paths() -> Vec { default_system_paths() } /// Returns the default paths where standard system utilities are found on Windows. pub fn get_default_standard_utils_paths() -> Vec { default_system_paths() } fn default_system_paths() -> Vec { let mut paths = Vec::new(); if let Ok(sysroot) = std::env::var("SystemRoot") { paths.push(PathBuf::from(&sysroot).join("system32")); paths.push(PathBuf::from(&sysroot)); paths.push(PathBuf::from(&sysroot).join("System32").join("Wbem")); paths.push( PathBuf::from(&sysroot) .join("System32") .join("WindowsPowerShell") .join("v1.0"), ); } if let Ok(userprofile) = std::env::var("USERPROFILE") { paths.push( PathBuf::from(userprofile) .join("AppData") .join("Local") .join("Microsoft") .join("WindowsApps"), ); } paths } /// Returns the path to the system-wide shell profile script. /// /// On Windows, no system profile is loaded by default. pub const fn get_system_profile_path() -> Option<&'static Path> { None } /// Returns the path to the system-wide shell rc script. /// /// On Windows, no system rc file is loaded by default. pub const fn get_system_rc_path() -> Option<&'static Path> { None } /// Returns the platform default for case-insensitive pathname expansion. /// /// On Windows, filesystems are typically case-insensitive, so this returns `true`. pub const fn default_case_insensitive_path_expansion() -> bool { true } /// Path separator characters on Windows. const PATH_SEPARATORS: [char; 2] = ['/', '\\']; /// Returns true if the string contains a path separator character. /// /// On Windows, both `/` and `\` are considered path separators. pub fn contains_path_separator(s: &str) -> bool { s.contains(PATH_SEPARATORS) } /// Returns true if the string ends with a path separator character. /// /// On Windows, both `/` and `\` are considered path separators. pub fn ends_with_path_separator(s: &str) -> bool { s.ends_with(PATH_SEPARATORS) } /// Returns the string with a trailing path separator removed, if present. /// /// On Windows, both `/` and `\` are considered path separators. pub fn strip_path_separator_suffix(s: &str) -> &str { s.strip_suffix(PATH_SEPARATORS).unwrap_or(s) } /// Finds the byte index of the last path separator in the string. /// /// On Windows, both `/` and `\` are considered path separators. pub fn rfind_path_separator(s: &str) -> Option { s.rfind(PATH_SEPARATORS) } /// Splits a string on path separator characters, returning an iterator of components. /// /// On Windows, both `/` and `\` are used as separators. pub fn split_path_for_pattern(s: &str) -> impl Iterator { s.split(PATH_SEPARATORS) } /// Returns the root path for an absolute pattern, if the first component indicates one. /// /// On Windows, recognizes both a leading separator (empty first component from splitting /// a path like `/foo`) and a drive-letter prefix like `C:` as absolute. /// /// TODO(windows): UNC paths like `\\server\share\foo` are not yet handled /// specially; they split into `["", "", "server", "share", "foo"]`, and the /// leading empty component causes them to be treated as if they were rooted /// at `/`, which drops the server/share portion. Supporting UNC requires /// peeking further into the component list. pub fn pattern_path_root(first_component: &str) -> Option { if first_component.is_empty() { // Leading separator, e.g. `/foo` split into ["", "foo"]. Some(PathBuf::from("/")) } else if first_component.len() == 2 && first_component.as_bytes()[0].is_ascii_alphabetic() && first_component.as_bytes()[1] == b':' { // Drive letter prefix, e.g. `c:/foo` split into ["c:", "foo"]. let mut root = String::with_capacity(3); root.push_str(first_component); root.push('/'); Some(PathBuf::from(root)) } else { None } } /// Pushes a component onto a path for pattern expansion. /// /// On Windows, `PathBuf::push` has special drive-letter and root-replacement /// semantics that conflict with shell path construction (e.g. pushing `C:foo` /// onto `D:\bar` replaces the whole path). This function always appends the /// component as a child, operating on the underlying `OsString` so non-UTF-8 /// content in the path is preserved and no reallocation is needed. pub fn push_path_for_pattern(path: &mut PathBuf, component: &str) { // Separator characters are ASCII, and WTF-8-encoded OsStr bytes are a // superset of UTF-8, so checking the last byte directly is safe. let bytes = path.as_os_str().as_encoded_bytes(); let needs_sep = !bytes.is_empty() && !matches!(bytes.last(), Some(b'/' | b'\\')); let buf = path.as_mut_os_string(); if needs_sep { buf.push("/"); } buf.push(component); } /// Normalizes path separators for shell output. /// /// On Windows, replaces `\` with `/` since backslash is the shell escape character. pub fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> { if s.contains('\\') { std::borrow::Cow::Owned(s.replace('\\', "/")) } else { std::borrow::Cow::Borrowed(s) } } #[cfg(test)] mod tests { use super::*; #[test] fn path_separator_helpers_both_slashes() { assert!(contains_path_separator("foo/bar")); assert!(contains_path_separator(r"foo\bar")); assert!(contains_path_separator(r"mixed/and\back")); assert!(!contains_path_separator("foobar")); assert!(ends_with_path_separator("foo/")); assert!(ends_with_path_separator(r"foo\")); assert!(!ends_with_path_separator("foo")); assert_eq!(strip_path_separator_suffix("foo/"), "foo"); assert_eq!(strip_path_separator_suffix(r"foo\"), "foo"); assert_eq!(strip_path_separator_suffix("foo"), "foo"); assert_eq!(rfind_path_separator("a/b/c"), Some(3)); assert_eq!(rfind_path_separator(r"a\b\c"), Some(3)); assert_eq!(rfind_path_separator(r"a/b\c"), Some(3)); assert_eq!(rfind_path_separator("abc"), None); } #[test] fn split_path_for_pattern_both_slashes() { let parts: Vec<_> = split_path_for_pattern("a/b/c").collect(); assert_eq!(parts, vec!["a", "b", "c"]); let parts: Vec<_> = split_path_for_pattern(r"a\b\c").collect(); assert_eq!(parts, vec!["a", "b", "c"]); let parts: Vec<_> = split_path_for_pattern(r"a/b\c").collect(); assert_eq!(parts, vec!["a", "b", "c"]); let parts: Vec<_> = split_path_for_pattern("/a/b").collect(); assert_eq!(parts, vec!["", "a", "b"]); } #[test] fn pattern_path_root_leading_separator() { assert_eq!(pattern_path_root(""), Some(PathBuf::from("/"))); } #[test] fn pattern_path_root_drive_letters() { assert_eq!(pattern_path_root("c:"), Some(PathBuf::from("c:/"))); assert_eq!(pattern_path_root("C:"), Some(PathBuf::from("C:/"))); assert_eq!(pattern_path_root("Z:"), Some(PathBuf::from("Z:/"))); } #[test] fn pattern_path_root_rejects_non_drive_two_char_prefix() { // "1:" is not a valid drive letter — must be alphabetic. assert_eq!(pattern_path_root("1:"), None); // Longer drive-like strings are not treated as roots. assert_eq!(pattern_path_root("cd"), None); assert_eq!(pattern_path_root("c:\\"), None); assert_eq!(pattern_path_root("foo"), None); } #[test] fn push_path_for_pattern_appends_with_forward_slash() { let mut p = PathBuf::from(r"C:\Users\reuben"); push_path_for_pattern(&mut p, "foo"); // Forward slash is used as the appended separator, yielding mixed // separators — acceptable because `normalize_path_separators` is // applied downstream before display. assert_eq!(p, PathBuf::from(r"C:\Users\reuben/foo")); } #[test] fn push_path_for_pattern_no_double_separator() { let mut p = PathBuf::from("C:/Users/reuben/"); push_path_for_pattern(&mut p, "foo"); assert_eq!(p, PathBuf::from("C:/Users/reuben/foo")); let mut p = PathBuf::from(r"C:\Users\reuben\"); push_path_for_pattern(&mut p, "foo"); assert_eq!(p, PathBuf::from(r"C:\Users\reuben\foo")); } #[test] fn push_path_for_pattern_onto_drive_root() { let mut p = PathBuf::from("c:/"); push_path_for_pattern(&mut p, "foo"); assert_eq!(p, PathBuf::from("c:/foo")); } #[test] fn push_path_for_pattern_onto_empty() { let mut p = PathBuf::new(); push_path_for_pattern(&mut p, "foo"); // Empty path stays un-prefixed — we only add a separator between // existing content and the new component. assert_eq!(p, PathBuf::from("foo")); } #[test] fn normalize_path_separators_converts_backslashes() { use std::borrow::Cow; // Already-forward-slashed input is borrowed (no allocation). assert!(matches!( normalize_path_separators("c:/foo/bar"), Cow::Borrowed("c:/foo/bar") )); // Mixed or backslashed input becomes owned and fully forward-slashed. let normalized = normalize_path_separators(r"c:\foo\bar"); assert_eq!(normalized.as_ref(), "c:/foo/bar"); let normalized = normalize_path_separators(r"c:\foo/bar"); assert_eq!(normalized.as_ref(), "c:/foo/bar"); } #[test] fn default_case_insensitive_is_true() { assert!(default_case_insensitive_path_expansion()); } #[test] fn has_executable_extension_is_case_insensitive() { // Force the PATHEXT cache for this test's defaults. assert!(has_executable_extension(Path::new("foo.exe"))); assert!(has_executable_extension(Path::new("foo.EXE"))); assert!(has_executable_extension(Path::new("foo.Cmd"))); assert!(!has_executable_extension(Path::new("foo.txt"))); assert!(!has_executable_extension(Path::new("foo"))); } #[test] fn pathext_entry_stem_strips_dot() { assert_eq!(pathext_entry_stem(".exe"), "exe"); assert_eq!(pathext_entry_stem(".cmd"), "cmd"); // Tolerant: entries without a leading dot are returned as-is. assert_eq!(pathext_entry_stem("exe"), "exe"); assert_eq!(pathext_entry_stem(""), ""); } #[test] fn resolve_executable_for_nonexistent_returns_none() { // A path that cannot exist on any test host. let path = PathBuf::from(r"C:\__brush_test_definitely_missing__"); assert!(resolve_executable(path).is_none()); } }