diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index edd3334..b4fa6ea 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -48,7 +48,7 @@ jobs: name: Compile strategy: matrix: - target: [x86_64-unknown-linux-musl, wasm32-wasi] + target: [x86_64-unknown-linux-musl, wasm32-wasi, wasm32-unknown-unknown] runs-on: ubuntu-latest steps: - name: Setup | Checkout @@ -61,7 +61,18 @@ jobs: target: ${{ matrix.target }} - name: Build | Check - run: cargo check --workspace --target ${{ matrix.target }} + if: ${{ matrix.target != 'wasm32-unknown-unknown' }} + run: | + cargo check --workspace --target ${{ matrix.target }} + cargo check --workspace --target ${{ matrix.target }} --features regex + cargo check --workspace --target ${{ matrix.target }} --no-default-features + cargo check --workspace --target ${{ matrix.target }} --no-default-features --features regex + + - name: Check wasm32-unknown-unknown + if: ${{ matrix.target == 'wasm32-unknown-unknown' }} + run: | + cargo check --workspace --target ${{ matrix.target }} --no-default-features + cargo check --workspace --target ${{ matrix.target }} --no-default-features --features regex # Run tests on Linux, macOS, and Windows # On both Rust stable and Rust nightly diff --git a/Cargo.toml b/Cargo.toml index 54e2a72..9c526b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,10 @@ categories = ["os", "filesystem"] keywords = ["which", "which-rs", "unix", "command"] [features] +default = ["real-sys"] regex = ["dep:regex"] tracing = ["dep:tracing"] +real-sys = ["env_home", "rustix", "winsafe"] [dependencies] either = "1.9.0" @@ -22,13 +24,13 @@ regex = { version = "1.10.2", optional = true } tracing = { version = "0.1.40", default-features = false, optional = true } [target.'cfg(any(windows, unix, target_os = "redox"))'.dependencies] -env_home = "0.1.0" +env_home = { version = "0.1.0", optional = true } [target.'cfg(any(unix, target_os = "wasi", target_os = "redox"))'.dependencies] -rustix = { version = "0.38.30", default-features = false, features = ["fs", "std"] } +rustix = { version = "0.38.30", default-features = false, features = ["fs", "std"], optional = true } [target.'cfg(windows)'.dependencies] -winsafe = { version = "0.0.19", features = ["kernel"] } +winsafe = { version = "0.0.19", features = ["kernel"], optional = true } [dev-dependencies] tempfile = "3.9.0" diff --git a/README.md b/README.md index 0597e0a..6e332cd 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,9 @@ A Rust equivalent of Unix command "which". Locate installed executable in cross ### A note on WebAssembly -This project aims to support WebAssembly with the [wasi](https://wasi.dev/) extension. This extension is a requirement. `which` is a library for exploring a filesystem, and -WebAssembly without wasi does not have a filesystem. `which` cannot do anything useful without this extension. Issues and PRs relating to -`wasm32-unknown-unknown` and `wasm64-unknown-unknown` will not be resolved or merged. All `wasm32-wasi*` targets are officially supported. +This project aims to support WebAssembly with the [WASI](https://wasi.dev/) extension. All `wasm32-wasi*` targets are officially supported. -If you need to add a conditional dependency on `which` for this reason please refer to [the relevant cargo documentation for platform specific dependencies.](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#platform-specific-dependencies) +If you need to add a conditional dependency on `which` please refer to [the relevant cargo documentation for platform specific dependencies.](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#platform-specific-dependencies) Here's an example of how to conditionally add `which`. You should tweak this to your needs. @@ -26,6 +24,8 @@ Here's an example of how to conditionally add `which`. You should tweak this to which = "7.0.0" ``` +Note that non-WASI environments have no access to the system. Using this in that situation requires disabling the default features of this crate and providing a custom `which::sys::Sys` implementation to `which::WhichConfig`. + ## Examples 1) To find which rustc executable binary is using. diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..5f08391 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,48 @@ +disallowed-methods = [ + { path = "std::env::current_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::canonicalize", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::is_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::is_file", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::is_symlink", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::metadata", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::read_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::read_link", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::symlink_metadata", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::try_exists", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::exists", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::canonicalize", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::is_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::is_file", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::is_symlink", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::metadata", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::read_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::read_link", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::symlink_metadata", reason = "System operations should be done using Sys trait" }, + { path = "std::path::PathBuf::try_exists", reason = "System operations should be done using Sys trait" }, + { path = "std::env::set_current_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::env::split_paths", reason = "System operations should be done using Sys trait" }, + { path = "std::env::temp_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::env::var", reason = "System operations should be done using Sys trait" }, + { path = "std::env::var_os", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::canonicalize", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::copy", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::create_dir_all", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::create_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::DirBuilder::new", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::hard_link", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::metadata", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::OpenOptions::new", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::read_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::read_link", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::read_to_string", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::read", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::remove_dir_all", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::remove_dir", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::remove_file", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::rename", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::set_permissions", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::symlink_metadata", reason = "System operations should be done using Sys trait" }, + { path = "std::fs::write", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::canonicalize", reason = "System operations should be done using Sys trait" }, + { path = "std::path::Path::exists", reason = "System operations should be done using Sys trait" }, +] diff --git a/src/checker.rs b/src/checker.rs index 41490ef..e356f3e 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -1,139 +1,111 @@ use crate::finder::Checker; +use crate::sys::Sys; +use crate::sys::SysMetadata; use crate::{NonFatalError, NonFatalErrorHandler}; -use std::fs; use std::path::Path; -pub struct ExecutableChecker; +pub struct ExecutableChecker { + sys: TSys, +} -impl ExecutableChecker { - pub fn new() -> ExecutableChecker { - ExecutableChecker +impl ExecutableChecker { + pub fn new(sys: TSys) -> Self { + Self { sys } } } -impl Checker for ExecutableChecker { - #[cfg(any(unix, target_os = "wasi", target_os = "redox"))] +impl Checker for ExecutableChecker { fn is_valid( &self, path: &Path, nonfatal_error_handler: &mut F, ) -> bool { - use std::io; - - use rustix::fs as rfs; - let ret = rfs::access(path, rfs::Access::EXEC_OK) - .map_err(|e| { - nonfatal_error_handler.handle(NonFatalError::Io(io::Error::from_raw_os_error( - e.raw_os_error(), - ))) - }) - .is_ok(); - #[cfg(feature = "tracing")] - tracing::trace!("{} EXEC_OK = {ret}", path.display()); - ret - } - - #[cfg(windows)] - fn is_valid( - &self, - _path: &Path, - _nonfatal_error_handler: &mut F, - ) -> bool { - true + if self.sys.is_windows() && path.extension().is_some() { + true + } else { + let ret = self + .sys + .is_valid_executable(path) + .map_err(|e| nonfatal_error_handler.handle(NonFatalError::Io(e))) + .unwrap_or(false); + #[cfg(feature = "tracing")] + tracing::trace!("{} EXEC_OK = {ret}", path.display()); + ret + } } } -pub struct ExistedChecker; - -impl ExistedChecker { - pub fn new() -> ExistedChecker { - ExistedChecker - } +pub struct ExistedChecker { + sys: TSys, } -impl Checker for ExistedChecker { - #[cfg(target_os = "windows")] - fn is_valid( - &self, - path: &Path, - nonfatal_error_handler: &mut F, - ) -> bool { - let ret = fs::symlink_metadata(path) - .map(|metadata| { - let file_type = metadata.file_type(); - #[cfg(feature = "tracing")] - tracing::trace!( - "{} is_file() = {}, is_symlink() = {}", - path.display(), - file_type.is_file(), - file_type.is_symlink() - ); - file_type.is_file() || file_type.is_symlink() - }) - .map_err(|e| { - nonfatal_error_handler.handle(NonFatalError::Io(e)); - }) - .unwrap_or(false) - && (path.extension().is_some() || matches_arch(path, nonfatal_error_handler)); - #[cfg(feature = "tracing")] - tracing::trace!( - "{} has_extension = {}, ExistedChecker::is_valid() = {ret}", - path.display(), - path.extension().is_some() - ); - ret +impl ExistedChecker { + pub fn new(sys: TSys) -> Self { + Self { sys } } +} - #[cfg(not(target_os = "windows"))] +impl Checker for ExistedChecker { fn is_valid( &self, path: &Path, nonfatal_error_handler: &mut F, ) -> bool { - let ret = fs::metadata(path).map(|metadata| metadata.is_file()); - #[cfg(feature = "tracing")] - tracing::trace!("{} is_file() = {ret:?}", path.display()); - match ret { - Ok(ret) => ret, - Err(e) => { - nonfatal_error_handler.handle(NonFatalError::Io(e)); - false + if self.sys.is_windows() { + let ret = self + .sys + .symlink_metadata(path) + .map(|metadata| { + #[cfg(feature = "tracing")] + tracing::trace!( + "{} is_file() = {}, is_symlink() = {}", + path.display(), + metadata.is_file(), + metadata.is_symlink() + ); + metadata.is_file() || metadata.is_symlink() + }) + .map_err(|e| { + nonfatal_error_handler.handle(NonFatalError::Io(e)); + }) + .unwrap_or(false); + #[cfg(feature = "tracing")] + tracing::trace!( + "{} has_extension = {}, ExistedChecker::is_valid() = {ret}", + path.display(), + path.extension().is_some() + ); + ret + } else { + let ret = self.sys.metadata(path).map(|metadata| metadata.is_file()); + #[cfg(feature = "tracing")] + tracing::trace!("{} is_file() = {ret:?}", path.display()); + match ret { + Ok(ret) => ret, + Err(e) => { + nonfatal_error_handler.handle(NonFatalError::Io(e)); + false + } } } } } -#[cfg(target_os = "windows")] -fn matches_arch(path: &Path, nonfatal_error_handler: &mut F) -> bool { - use std::io; - - let ret = winsafe::GetBinaryType(&path.display().to_string()) - .map_err(|e| { - nonfatal_error_handler.handle(NonFatalError::Io(io::Error::from_raw_os_error( - e.raw() as i32 - ))) - }) - .is_ok(); - #[cfg(feature = "tracing")] - tracing::trace!("{} matches_arch() = {ret}", path.display()); - ret -} - -pub struct CompositeChecker { - existed_checker: ExistedChecker, - executable_checker: ExecutableChecker, +pub struct CompositeChecker { + existed_checker: ExistedChecker, + executable_checker: ExecutableChecker, } -impl CompositeChecker { - pub fn new() -> CompositeChecker { +impl CompositeChecker { + pub fn new(sys: TSys) -> Self { CompositeChecker { - executable_checker: ExecutableChecker::new(), - existed_checker: ExistedChecker::new(), + executable_checker: ExecutableChecker::new(sys.clone()), + existed_checker: ExistedChecker::new(sys), } } } -impl Checker for CompositeChecker { +impl Checker for CompositeChecker { fn is_valid( &self, path: &Path, diff --git a/src/finder.rs b/src/finder.rs index 7c59a5e..ae3a7ae 100644 --- a/src/finder.rs +++ b/src/finder.rs @@ -1,29 +1,16 @@ use crate::checker::CompositeChecker; -#[cfg(windows)] use crate::helper::has_executable_extension; +use crate::sys::Sys; +use crate::sys::SysReadDirEntry; use crate::{error::*, NonFatalErrorHandler}; use either::Either; #[cfg(feature = "regex")] use regex::Regex; -#[cfg(feature = "regex")] -use std::borrow::Borrow; use std::borrow::Cow; -use std::env; use std::ffi::OsStr; -#[cfg(any(feature = "regex", target_os = "windows"))] -use std::fs; use std::iter; use std::path::{Component, Path, PathBuf}; -// Home dir shim, use env_home crate when possible. Otherwise, return None -#[cfg(any(windows, unix, target_os = "redox"))] -use env_home::env_home_dir; - -#[cfg(not(any(windows, unix, target_os = "redox")))] -fn env_home_dir() -> Option { - None -} - pub trait Checker { fn is_valid( &self, @@ -59,11 +46,13 @@ impl PathExt for PathBuf { } } -pub struct Finder; +pub struct Finder { + sys: TSys, +} -impl Finder { - pub fn new() -> Finder { - Finder +impl Finder { + pub fn new(sys: TSys) -> Self { + Finder { sys } } pub fn find<'a, T, U, V, F: NonFatalErrorHandler + 'a>( @@ -71,7 +60,7 @@ impl Finder { binary_name: T, paths: Option, cwd: Option, - binary_checker: CompositeChecker, + binary_checker: CompositeChecker, mut nonfatal_error_handler: F, ) -> Result + 'a> where @@ -97,25 +86,30 @@ impl Finder { path.display() ); // Search binary in cwd if the path have a path separator. - Either::Left(Self::cwd_search_candidates(path, cwd)) + Either::Left(Self::cwd_search_candidates(&self.sys, path, cwd)) } _ => { #[cfg(feature = "tracing")] tracing::trace!("{} has no path seperators, so only paths in PATH environment variable will be searched.", path.display()); // Search binary in PATHs(defined in environment variable). let paths = paths.ok_or(Error::CannotGetCurrentDirAndPathListEmpty)?; - let paths = env::split_paths(&paths).collect::>(); + let paths = self.sys.env_split_paths(paths.as_ref()); if paths.is_empty() { return Err(Error::CannotGetCurrentDirAndPathListEmpty); } - Either::Right(Self::path_search_candidates(path, paths)) + Either::Right(Self::path_search_candidates( + &self.sys, + path, + paths.into_iter(), + )) } }; - let ret = binary_path_candidates.into_iter().filter_map(move |p| { + let sys = self.sys.clone(); + let ret = binary_path_candidates.filter_map(move |p| { binary_checker .is_valid(&p, &mut nonfatal_error_handler) - .then(|| correct_casing(p, &mut nonfatal_error_handler)) + .then(|| correct_casing(&sys, p, &mut nonfatal_error_handler)) }); #[cfg(feature = "tracing")] let ret = ret.inspect(|p| { @@ -127,23 +121,21 @@ impl Finder { #[cfg(feature = "regex")] pub fn find_re( &self, - binary_regex: impl Borrow, + binary_regex: impl std::borrow::Borrow, paths: Option, - binary_checker: CompositeChecker, + binary_checker: CompositeChecker, mut nonfatal_error_handler: F, ) -> Result> where T: AsRef, { let p = paths.ok_or(Error::CannotGetCurrentDirAndPathListEmpty)?; - // Collect needs to happen in order to not have to - // change the API to borrow on `paths`. - #[allow(clippy::needless_collect)] - let paths: Vec<_> = env::split_paths(&p).collect(); + let paths = self.sys.env_split_paths(p.as_ref()); + let sys = self.sys.clone(); let matching_re = paths .into_iter() - .flat_map(fs::read_dir) + .flat_map(move |p| sys.read_dir(&p)) .flatten() .flatten() .map(|e| e.path()) @@ -159,114 +151,116 @@ impl Finder { Ok(matching_re) } - fn cwd_search_candidates(binary_name: PathBuf, cwd: C) -> impl IntoIterator + fn cwd_search_candidates( + sys: &TSys, + binary_name: PathBuf, + cwd: C, + ) -> impl Iterator where C: AsRef, { let path = binary_name.to_absolute(cwd); - Self::append_extension(iter::once(path)) + Self::append_extension(sys, iter::once(path)) } fn path_search_candidates

( + sys: &TSys, binary_name: PathBuf, paths: P, - ) -> impl IntoIterator + ) -> impl Iterator where - P: IntoIterator, + P: Iterator, { - let new_paths = paths - .into_iter() - .map(move |p| tilde_expansion(&p).join(binary_name.clone())); - - Self::append_extension(new_paths) - } + let new_paths = paths.map({ + let sys = sys.clone(); + move |p| tilde_expansion(&sys, &p).join(binary_name.clone()) + }); - #[cfg(not(windows))] - fn append_extension

(paths: P) -> impl IntoIterator - where - P: IntoIterator, - { - paths + Self::append_extension(sys, new_paths) } - #[cfg(windows)] - fn append_extension

(paths: P) -> impl IntoIterator + fn append_extension

(sys: &TSys, paths: P) -> impl Iterator where - P: IntoIterator, + P: Iterator, { - use std::sync::OnceLock; + struct PathsIter

+ where + P: Iterator, + { + paths: P, + current_path_with_index: Option<(PathBuf, usize)>, + path_extensions: Cow<'static, [String]>, + } - // Sample %PATHEXT%: .COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC - // PATH_EXTENSIONS is then [".COM", ".EXE", ".BAT", …]. - // (In one use of PATH_EXTENSIONS we skip the dot, but in the other we need it; - // hence its retention.) - static PATH_EXTENSIONS: OnceLock> = OnceLock::new(); + impl

Iterator for PathsIter

+ where + P: Iterator, + { + type Item = PathBuf; - paths - .into_iter() - .flat_map(move |p| -> Box> { - let path_extensions = PATH_EXTENSIONS.get_or_init(|| { - env::var("PATHEXT") - .map(|pathext| { - pathext - .split(';') - .filter_map(|s| { - if s.as_bytes().first() == Some(&b'.') { - Some(s.to_owned()) - } else { - // Invalid segment; just ignore it. - None - } - }) - .collect() - }) - // PATHEXT not being set or not being a proper Unicode string is exceedingly - // improbable and would probably break Windows badly. Still, don't crash: - .unwrap_or_default() - }); - // Check if path already have executable extension - if has_executable_extension(&p, path_extensions) { + fn next(&mut self) -> Option { + if self.path_extensions.is_empty() { + self.paths.next() + } else if let Some((p, index)) = self.current_path_with_index.take() { + let next_index = index + 1; + if next_index < self.path_extensions.len() { + self.current_path_with_index = Some((p.clone(), next_index)); + } + // Append the extension. + let mut p = p.into_os_string(); + p.push(&self.path_extensions[index]); + let ret = PathBuf::from(p); #[cfg(feature = "tracing")] - tracing::trace!( - "{} already has an executable extension, not modifying it further", - p.display() - ); - Box::new(iter::once(p)) + tracing::trace!("possible extension: {}", ret.display()); + Some(ret) } else { - #[cfg(feature = "tracing")] - tracing::trace!( - "{} has no extension, using PATHEXT environment variable to infer one", - p.display() - ); - // Appended paths with windows executable extensions. - // e.g. path `c:/windows/bin[.ext]` will expand to: - // [c:/windows/bin.ext] - // c:/windows/bin[.ext].COM - // c:/windows/bin[.ext].EXE - // c:/windows/bin[.ext].CMD - // ... - Box::new( - iter::once(p.clone()).chain(path_extensions.iter().map(move |e| { - // Append the extension. - let mut p = p.clone().into_os_string(); - p.push(e); - let ret = PathBuf::from(p); - #[cfg(feature = "tracing")] - tracing::trace!("possible extension: {}", ret.display()); - ret - })), - ) + let p = self.paths.next()?; + if has_executable_extension(&p, &self.path_extensions) { + #[cfg(feature = "tracing")] + tracing::trace!( + "{} already has an executable extension, not modifying it further", + p.display() + ); + } else { + #[cfg(feature = "tracing")] + tracing::trace!( + "{} has no extension, using PATHEXT environment variable to infer one", + p.display() + ); + // Appended paths with windows executable extensions. + // e.g. path `c:/windows/bin[.ext]` will expand to: + // [c:/windows/bin.ext] + // c:/windows/bin[.ext].COM + // c:/windows/bin[.ext].EXE + // c:/windows/bin[.ext].CMD + // ... + self.current_path_with_index = Some((p.clone(), 0)); + } + Some(p) } - }) + } + } + + let path_extensions = if sys.is_windows() { + sys.env_windows_path_ext() + } else { + Cow::Borrowed(Default::default()) + }; + + PathsIter { + paths, + current_path_with_index: None, + path_extensions, + } } } -fn tilde_expansion(p: &PathBuf) -> Cow<'_, PathBuf> { +fn tilde_expansion<'a, TSys: Sys>(sys: &TSys, p: &'a PathBuf) -> Cow<'a, PathBuf> { let mut component_iter = p.components(); if let Some(Component::Normal(o)) = component_iter.next() { if o == "~" { - let new_path = env_home_dir(); + let new_path = sys.home_dir(); if let Some(mut new_path) = new_path { new_path.extend(component_iter); #[cfg(feature = "tracing")] @@ -284,24 +278,26 @@ fn tilde_expansion(p: &PathBuf) -> Cow<'_, PathBuf> { Cow::Borrowed(p) } -#[cfg(target_os = "windows")] -fn correct_casing( +fn correct_casing( + sys: &TSys, mut p: PathBuf, nonfatal_error_handler: &mut F, ) -> PathBuf { - if let (Some(parent), Some(file_name)) = (p.parent(), p.file_name()) { - if let Ok(iter) = fs::read_dir(parent) { - for e in iter { - match e { - Ok(e) => { - if e.file_name().eq_ignore_ascii_case(file_name) { - p.pop(); - p.push(e.file_name()); - break; + if sys.is_windows() { + if let (Some(parent), Some(file_name)) = (p.parent(), p.file_name()) { + if let Ok(iter) = sys.read_dir(parent) { + for e in iter { + match e { + Ok(e) => { + if e.file_name().eq_ignore_ascii_case(file_name) { + p.pop(); + p.push(e.file_name()); + break; + } + } + Err(e) => { + nonfatal_error_handler.handle(NonFatalError::Io(e)); } - } - Err(e) => { - nonfatal_error_handler.handle(NonFatalError::Io(e)); } } } @@ -309,8 +305,3 @@ fn correct_casing( } p } - -#[cfg(not(target_os = "windows"))] -fn correct_casing(p: PathBuf, _nonfatal_error_handler: &mut F) -> PathBuf { - p -} diff --git a/src/helper.rs b/src/helper.rs index eb96891..ad2a9db 100644 --- a/src/helper.rs +++ b/src/helper.rs @@ -6,7 +6,7 @@ pub fn has_executable_extension, S: AsRef>(path: T, pathext: match ext { Some(ext) => pathext .iter() - .any(|e| ext.eq_ignore_ascii_case(&e.as_ref()[1..])), + .any(|e| !e.as_ref().is_empty() && ext.eq_ignore_ascii_case(&e.as_ref()[1..])), _ => false, } } @@ -37,4 +37,12 @@ mod test { &[".COM", ".EXE", ".CMD"] )); } + + #[test] + fn test_invalid_exts() { + assert!(!has_executable_extension( + PathBuf::from("foo.bar"), + &["", "."] + )); + } } diff --git a/src/lib.rs b/src/lib.rs index 78fd19a..355ca12 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,12 +19,9 @@ mod checker; mod error; mod finder; -#[cfg(windows)] mod helper; +pub mod sys; -#[cfg(feature = "regex")] -use std::borrow::Borrow; -use std::env; use std::fmt; use std::path; @@ -33,6 +30,7 @@ use std::ffi::{OsStr, OsString}; use crate::checker::CompositeChecker; pub use crate::error::*; use crate::finder::Finder; +use crate::sys::Sys; /// Find an executable binary's path by name. /// @@ -55,6 +53,7 @@ use crate::finder::Finder; /// assert_eq!(result, PathBuf::from("/usr/bin/rustc")); /// /// ``` +#[cfg(feature = "real-sys")] pub fn which>(binary_name: T) -> Result { which_all(binary_name).and_then(|mut i| i.next().ok_or(Error::CannotFindBinaryPath)) } @@ -79,32 +78,35 @@ pub fn which>(binary_name: T) -> Result { /// assert_eq!(result, PathBuf::from("/usr/bin/rustc")); /// /// ``` +#[cfg(feature = "real-sys")] pub fn which_global>(binary_name: T) -> Result { which_all_global(binary_name).and_then(|mut i| i.next().ok_or(Error::CannotFindBinaryPath)) } /// Find all binaries with `binary_name` using `cwd` to resolve relative paths. +#[cfg(feature = "real-sys")] pub fn which_all>(binary_name: T) -> Result> { - let cwd = env::current_dir().ok(); + let cwd = sys::RealSys.current_dir().ok(); - Finder::new().find( + Finder::new(sys::RealSys).find( binary_name, - env::var_os("PATH"), + sys::RealSys.env_var_os("PATH"), cwd, - CompositeChecker::new(), + CompositeChecker::new(sys::RealSys), Noop, ) } /// Find all binaries with `binary_name` ignoring `cwd`. +#[cfg(feature = "real-sys")] pub fn which_all_global>( binary_name: T, ) -> Result> { - Finder::new().find( + Finder::new(sys::RealSys).find( binary_name, - env::var_os("PATH"), + sys::RealSys.env_var_os("PATH"), Option::<&Path>::None, - CompositeChecker::new(), + CompositeChecker::new(sys::RealSys), Noop, ) } @@ -141,12 +143,15 @@ pub fn which_all_global>( /// which_re(Regex::new("^cargo-.*").unwrap()).unwrap() /// .for_each(|pth| println!("{}", pth.to_string_lossy())); /// ``` -#[cfg(feature = "regex")] -pub fn which_re(regex: impl Borrow) -> Result> { - which_re_in(regex, env::var_os("PATH")) +#[cfg(all(feature = "regex", feature = "real-sys"))] +pub fn which_re( + regex: impl std::borrow::Borrow, +) -> Result> { + which_re_in(regex, sys::RealSys.env_var_os("PATH")) } /// Find `binary_name` in the path list `paths`, using `cwd` to resolve relative paths. +#[cfg(feature = "real-sys")] pub fn which_in(binary_name: T, paths: Option, cwd: V) -> Result where T: AsRef, @@ -180,18 +185,19 @@ where /// let python_paths = vec![PathBuf::from("/usr/bin/python2"), PathBuf::from("/usr/bin/python3")]; /// assert_eq!(binaries, python_paths); /// ``` -#[cfg(feature = "regex")] +#[cfg(all(feature = "regex", feature = "real-sys"))] pub fn which_re_in( - regex: impl Borrow, + regex: impl std::borrow::Borrow, paths: Option, ) -> Result> where T: AsRef, { - Finder::new().find_re(regex, paths, CompositeChecker::new(), Noop) + Finder::new(sys::RealSys).find_re(regex, paths, CompositeChecker::new(sys::RealSys), Noop) } /// Find all binaries with `binary_name` in the path list `paths`, using `cwd` to resolve relative paths. +#[cfg(feature = "real-sys")] pub fn which_in_all<'a, T, U, V>( binary_name: T, paths: Option, @@ -202,10 +208,17 @@ where U: AsRef, V: AsRef + 'a, { - Finder::new().find(binary_name, paths, Some(cwd), CompositeChecker::new(), Noop) + Finder::new(sys::RealSys).find( + binary_name, + paths, + Some(cwd), + CompositeChecker::new(sys::RealSys), + Noop, + ) } /// Find all binaries with `binary_name` in the path list `paths`, ignoring `cwd`. +#[cfg(feature = "real-sys")] pub fn which_in_global( binary_name: T, paths: Option, @@ -214,23 +227,24 @@ where T: AsRef, U: AsRef, { - Finder::new().find( + Finder::new(sys::RealSys).find( binary_name, paths, Option::<&Path>::None, - CompositeChecker::new(), + CompositeChecker::new(sys::RealSys), Noop, ) } /// A wrapper containing all functionality in this crate. -pub struct WhichConfig { +pub struct WhichConfig { cwd: Option>, custom_path_list: Option, binary_name: Option, nonfatal_error_handler: F, #[cfg(feature = "regex")] regex: Option, + sys: TSys, } /// A handler for non-fatal errors which does nothing with them. @@ -261,7 +275,8 @@ where } } -impl Default for WhichConfig { +#[cfg(feature = "real-sys")] +impl Default for WhichConfig { fn default() -> Self { Self { cwd: Some(either::Either::Left(true)), @@ -270,6 +285,7 @@ impl Default for WhichConfig { nonfatal_error_handler: F::default(), #[cfg(feature = "regex")] regex: None, + sys: sys::RealSys, } } } @@ -280,13 +296,32 @@ type Regex = regex::Regex; #[cfg(not(feature = "regex"))] type Regex = (); -impl WhichConfig { +#[cfg(feature = "real-sys")] +impl WhichConfig { pub fn new() -> Self { - Self::default() + Self::new_with_sys(sys::RealSys) } } -impl<'a, F: NonFatalErrorHandler + 'a> WhichConfig { +impl WhichConfig { + /// Creates a new `WhichConfig` with the given `sys::Sys`. + /// + /// This is useful for providing all the system related + /// functionality to this crate. + pub fn new_with_sys(sys: TSys) -> Self { + Self { + cwd: Some(either::Either::Left(true)), + custom_path_list: None, + binary_name: None, + nonfatal_error_handler: Noop, + #[cfg(feature = "regex")] + regex: None, + sys, + } + } +} + +impl<'a, TSys: Sys, F: NonFatalErrorHandler + 'a> WhichConfig { /// Whether or not to use the current working directory. `true` by default. /// /// # Panics @@ -402,7 +437,7 @@ impl<'a, F: NonFatalErrorHandler + 'a> WhichConfig { /// .unwrap() /// .collect::>(); /// ``` - pub fn nonfatal_error_handler(self, handler: NewF) -> WhichConfig { + pub fn nonfatal_error_handler(self, handler: NewF) -> WhichConfig { WhichConfig { custom_path_list: self.custom_path_list, cwd: self.cwd, @@ -410,6 +445,7 @@ impl<'a, F: NonFatalErrorHandler + 'a> WhichConfig { nonfatal_error_handler: handler, #[cfg(feature = "regex")] regex: self.regex, + sys: self.sys, } } @@ -421,15 +457,17 @@ impl<'a, F: NonFatalErrorHandler + 'a> WhichConfig { /// Finishes configuring, runs the query and returns all results. pub fn all_results(self) -> Result + 'a> { - let paths = self.custom_path_list.or_else(|| env::var_os("PATH")); + let paths = self + .custom_path_list + .or_else(|| self.sys.env_var_os("PATH")); #[cfg(feature = "regex")] if let Some(regex) = self.regex { - return Finder::new() + return Finder::new(self.sys.clone()) .find_re( regex, paths, - CompositeChecker::new(), + CompositeChecker::new(self.sys), self.nonfatal_error_handler, ) .map(|i| Box::new(i) as Box + 'a>); @@ -438,17 +476,17 @@ impl<'a, F: NonFatalErrorHandler + 'a> WhichConfig { let cwd = match self.cwd { Some(either::Either::Left(false)) => None, Some(either::Either::Right(custom)) => Some(custom), - None | Some(either::Either::Left(true)) => env::current_dir().ok(), + None | Some(either::Either::Left(true)) => self.sys.current_dir().ok(), }; - Finder::new() + Finder::new(self.sys.clone()) .find( self.binary_name.expect( "binary_name not set! You must set binary_name or regex before searching!", ), paths, cwd, - CompositeChecker::new(), + CompositeChecker::new(self.sys), self.nonfatal_error_handler, ) .map(|i| Box::new(i) as Box + 'a>) @@ -474,6 +512,7 @@ impl Path { /// Returns the path of an executable binary by name. /// /// This calls `which` and maps the result into a `Path`. + #[cfg(feature = "real-sys")] pub fn new>(binary_name: T) -> Result { which(binary_name).map(|inner| Path { inner }) } @@ -481,6 +520,7 @@ impl Path { /// Returns the paths of all executable binaries by a name. /// /// this calls `which_all` and maps the results into `Path`s. + #[cfg(feature = "real-sys")] pub fn all>(binary_name: T) -> Result> { which_all(binary_name).map(|inner| inner.map(|inner| Path { inner })) } @@ -489,6 +529,7 @@ impl Path { /// current working directory `cwd` to resolve relative paths. /// /// This calls `which_in` and maps the result into a `Path`. + #[cfg(feature = "real-sys")] pub fn new_in(binary_name: T, paths: Option, cwd: V) -> Result where T: AsRef, @@ -502,6 +543,7 @@ impl Path { /// current working directory `cwd` to resolve relative paths. /// /// This calls `which_in_all` and maps the results into a `Path`. + #[cfg(feature = "real-sys")] pub fn all_in<'a, T, U, V>( binary_name: T, paths: Option, @@ -586,22 +628,28 @@ impl CanonicalPath { /// Returns the canonical path of an executable binary by name. /// /// This calls `which` and `Path::canonicalize` and maps the result into a `CanonicalPath`. + #[cfg(feature = "real-sys")] pub fn new>(binary_name: T) -> Result { which(binary_name) - .and_then(|p| p.canonicalize().map_err(|_| Error::CannotCanonicalize)) + .and_then(|p| { + sys::RealSys + .canonicalize(&p) + .map_err(|_| Error::CannotCanonicalize) + }) .map(|inner| CanonicalPath { inner }) } /// Returns the canonical paths of an executable binary by name. /// /// This calls `which_all` and `Path::canonicalize` and maps the results into `CanonicalPath`s. + #[cfg(feature = "real-sys")] pub fn all>( binary_name: T, ) -> Result>> { which_all(binary_name).map(|inner| { inner.map(|inner| { - inner - .canonicalize() + sys::RealSys + .canonicalize(&inner) .map_err(|_| Error::CannotCanonicalize) .map(|inner| CanonicalPath { inner }) }) @@ -612,6 +660,7 @@ impl CanonicalPath { /// using the current working directory `cwd` to resolve relative paths. /// /// This calls `which_in` and `Path::canonicalize` and maps the result into a `CanonicalPath`. + #[cfg(feature = "real-sys")] pub fn new_in(binary_name: T, paths: Option, cwd: V) -> Result where T: AsRef, @@ -619,7 +668,11 @@ impl CanonicalPath { V: AsRef, { which_in(binary_name, paths, cwd) - .and_then(|p| p.canonicalize().map_err(|_| Error::CannotCanonicalize)) + .and_then(|p| { + sys::RealSys + .canonicalize(&p) + .map_err(|_| Error::CannotCanonicalize) + }) .map(|inner| CanonicalPath { inner }) } @@ -627,6 +680,7 @@ impl CanonicalPath { /// using the current working directory `cwd` to resolve relative paths. /// /// This calls `which_in_all` and `Path::canonicalize` and maps the result into a `CanonicalPath`. + #[cfg(feature = "real-sys")] pub fn all_in<'a, T, U, V>( binary_name: T, paths: Option, @@ -639,8 +693,8 @@ impl CanonicalPath { { which_in_all(binary_name, paths, cwd).map(|inner| { inner.map(|inner| { - inner - .canonicalize() + sys::RealSys + .canonicalize(&inner) .map_err(|_| Error::CannotCanonicalize) .map(|inner| CanonicalPath { inner }) }) diff --git a/src/sys.rs b/src/sys.rs new file mode 100644 index 0000000..6f7350d --- /dev/null +++ b/src/sys.rs @@ -0,0 +1,229 @@ +use std::borrow::Cow; +use std::env::VarError; +use std::ffi::OsStr; +use std::ffi::OsString; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +pub trait SysReadDirEntry { + /// Gets the file name of the directory entry, not the full path. + fn file_name(&self) -> OsString; + /// Gets the full path of the directory entry. + fn path(&self) -> PathBuf; +} + +pub trait SysMetadata { + /// Gets if the path is a symlink. + fn is_symlink(&self) -> bool; + /// Gets if the path is a file. + fn is_file(&self) -> bool; +} + +/// Represents the system that `which` interacts with to get information +/// about the environment and file system. +/// +/// ### How to use in Wasm without WASI +/// +/// WebAssembly without WASI does not have a filesystem, but using this crate is possible in `wasm32-unknown-unknown` targets by disabling default features: +/// +/// ```toml +/// which = { version = "...", default-features = false } +/// ``` +/// +// Then providing your own implementation of the `which::sys::Sys` trait: +/// +/// ```rs +/// use which::WhichConfig; +/// +/// struct WasmSys; +/// +/// impl which::sys::Sys for WasmSys { +/// // it is up to you to implement this trait based on the +/// // environment you are running WebAssembly in +/// } +/// +/// let paths = WhichConfig::new_with_sys(WasmSys) +/// .all_results() +/// .unwrap() +/// .collect::>(); +/// ``` +pub trait Sys: Clone { + type ReadDirEntry: SysReadDirEntry; + type Metadata: SysMetadata; + + /// Check if the current platform is Windows. + /// + /// This can be set to true in wasm32-unknown-unknown targets that + /// are running on Windows systems. + fn is_windows(&self) -> bool; + /// Gets the current working directory. + fn current_dir(&self) -> io::Result; + /// Gets the home directory of the current user. + fn home_dir(&self) -> Option; + /// Splits a platform-specific PATH variable into a list of paths. + fn env_split_paths(&self, paths: &OsStr) -> Vec; + /// Gets the value of an environment variable. + fn env_var_os(&self, name: &str) -> Option; + fn env_var(&self, key: &str) -> Result { + match self.env_var_os(key) { + Some(val) => val.into_string().map_err(VarError::NotUnicode), + None => Err(VarError::NotPresent), + } + } + /// Gets and parses the PATHEXT environment variable on Windows. + /// + /// Override this to disable globally caching the parsed PATHEXT. + fn env_windows_path_ext(&self) -> Cow<'static, [String]> { + use std::sync::OnceLock; + + // Sample %PATHEXT%: .COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC + // PATH_EXTENSIONS is then [".COM", ".EXE", ".BAT", …]. + // (In one use of PATH_EXTENSIONS we skip the dot, but in the other we need it; + // hence its retention.) + static PATH_EXTENSIONS: OnceLock> = OnceLock::new(); + let path_extensions = PATH_EXTENSIONS.get_or_init(|| { + self.env_var("PATHEXT") + .map(|pathext| { + pathext + .split(';') + .filter_map(|s| { + if s.as_bytes().first() == Some(&b'.') { + Some(s.to_owned()) + } else { + // Invalid segment; just ignore it. + None + } + }) + .collect() + }) + // PATHEXT not being set or not being a proper Unicode string is exceedingly + // improbable and would probably break Windows badly. Still, don't crash: + .unwrap_or_default() + }); + Cow::Borrowed(path_extensions) + } + /// Gets the metadata of the provided path, following symlinks. + fn metadata(&self, path: &Path) -> io::Result; + /// Gets the metadata of the provided path, not following symlinks. + fn symlink_metadata(&self, path: &Path) -> io::Result; + /// Reads the directory entries of the provided path. + fn read_dir( + &self, + path: &Path, + ) -> io::Result>>>; + /// Checks if the provided path is a valid executable. + fn is_valid_executable(&self, path: &Path) -> io::Result; +} + +impl SysReadDirEntry for std::fs::DirEntry { + fn file_name(&self) -> OsString { + self.file_name() + } + + fn path(&self) -> PathBuf { + self.path() + } +} + +impl SysMetadata for std::fs::Metadata { + fn is_symlink(&self) -> bool { + self.file_type().is_symlink() + } + + fn is_file(&self) -> bool { + self.file_type().is_file() + } +} + +#[cfg(feature = "real-sys")] +#[derive(Default, Clone)] +pub struct RealSys; + +#[cfg(feature = "real-sys")] +impl RealSys { + #[inline] + pub(crate) fn canonicalize(&self, path: &Path) -> io::Result { + #[allow(clippy::disallowed_methods)] // ok, sys implementation + std::fs::canonicalize(path) + } +} + +#[cfg(feature = "real-sys")] +impl Sys for RealSys { + type ReadDirEntry = std::fs::DirEntry; + type Metadata = std::fs::Metadata; + + #[inline] + fn is_windows(&self) -> bool { + cfg!(windows) + } + + #[inline] + fn current_dir(&self) -> io::Result { + #[allow(clippy::disallowed_methods)] // ok, sys implementation + std::env::current_dir() + } + + #[inline] + fn home_dir(&self) -> Option { + // Home dir shim, use env_home crate when possible. Otherwise, return None + #[cfg(any(windows, unix, target_os = "redox"))] + { + env_home::env_home_dir() + } + #[cfg(not(any(windows, unix, target_os = "redox")))] + { + None + } + } + + #[inline] + fn env_split_paths(&self, paths: &OsStr) -> Vec { + #[allow(clippy::disallowed_methods)] // ok, sys implementation + std::env::split_paths(paths).collect() + } + + #[inline] + fn env_var_os(&self, name: &str) -> Option { + #[allow(clippy::disallowed_methods)] // ok, sys implementation + std::env::var_os(name) + } + + #[inline] + fn read_dir( + &self, + path: &Path, + ) -> io::Result>>> { + #[allow(clippy::disallowed_methods)] // ok, sys implementation + let iter = std::fs::read_dir(path)?; + Ok(Box::new(iter)) + } + + #[inline] + fn metadata(&self, path: &Path) -> io::Result { + #[allow(clippy::disallowed_methods)] // ok, sys implementation + std::fs::metadata(path) + } + + #[inline] + fn symlink_metadata(&self, path: &Path) -> io::Result { + #[allow(clippy::disallowed_methods)] // ok, sys implementation + std::fs::symlink_metadata(path) + } + + #[cfg(any(unix, target_os = "wasi", target_os = "redox"))] + fn is_valid_executable(&self, path: &Path) -> io::Result { + use rustix::fs as rfs; + rfs::access(path, rfs::Access::EXEC_OK) + .map(|_| true) + .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error())) + } + + #[cfg(windows)] + fn is_valid_executable(&self, path: &Path) -> io::Result { + winsafe::GetBinaryType(&path.display().to_string()) + .map(|_| true) + .map_err(|e| io::Error::from_raw_os_error(e.raw() as i32)) + } +} diff --git a/tests/basic.rs b/tests/basic.rs index 120f878..81772c0 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -1,3 +1,5 @@ +#![allow(clippy::disallowed_methods)] + extern crate which; #[cfg(all(unix, feature = "regex"))]