456 lines
15 KiB
Rust
456 lines
15 KiB
Rust
//! 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<Vec<String>> = 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<PathBuf> {
|
|
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<T: AsRef<OsStr> + ?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<std::fs::File, error::Error> {
|
|
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<Result<std::fs::File, std::io::Error>> {
|
|
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<PathBuf> {
|
|
default_system_paths()
|
|
}
|
|
|
|
/// Returns the default paths where standard system utilities are found on Windows.
|
|
pub fn get_default_standard_utils_paths() -> Vec<PathBuf> {
|
|
default_system_paths()
|
|
}
|
|
|
|
fn default_system_paths() -> Vec<PathBuf> {
|
|
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<usize> {
|
|
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<Item = &str> {
|
|
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<PathBuf> {
|
|
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());
|
|
}
|
|
}
|