use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use serde::Deserialize; use serde::Serialize; #[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] pub enum ShellType { Zsh, Bash, PowerShell, Sh, Cmd, } impl ShellType { pub fn name(self) -> &'static str { match self { Self::Zsh => "zsh", Self::Bash => "bash", Self::PowerShell => "powershell", Self::Sh => "sh", Self::Cmd => "cmd", } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DetectedShell { pub shell_type: ShellType, pub shell_path: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ModelShellResolveError { Unsupported(PathBuf), MissingBareName(PathBuf), MissingPath(PathBuf), NotAFile(PathBuf), NotExecutable(PathBuf), UnsupportedWindowsLaunch(PathBuf), UnsupportedWindowsPathNamespace(PathBuf), UnresolvedWindowsRelativePath(PathBuf), ParentDirectoryComponent(PathBuf), NonUtf8ResolvedPath(PathBuf), RelativeWorkingDirectory(PathBuf), } impl std::fmt::Display for ModelShellResolveError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Unsupported(path) => { write!( formatter, "unsupported model-provided shell `{}`", path.display() ) } Self::MissingBareName(path) => write!( formatter, "model-provided shell `{}` was not found on the invocation PATH", path.display() ), Self::MissingPath(path) => write!( formatter, "model-provided shell `{}` does not exist", path.display() ), Self::NotAFile(path) => write!( formatter, "model-provided shell `{}` is not a regular file", path.display() ), Self::NotExecutable(path) => write!( formatter, "model-provided shell `{}` is not executable", path.display() ), Self::UnsupportedWindowsLaunch(path) => write!( formatter, "model-provided Windows shell `{}` must resolve to an .exe executable", path.display() ), Self::UnsupportedWindowsPathNamespace(path) => write!( formatter, "Windows shell resolution refuses remote, device, or verbatim path namespace `{}`", path.display() ), Self::UnresolvedWindowsRelativePath(path) => write!( formatter, "model-provided Windows shell path `{}` is drive-relative and cannot be resolved against the selected working directory", path.display() ), Self::ParentDirectoryComponent(path) => write!( formatter, "model-provided shell resolution refuses parent-directory component in `{}`", path.display() ), Self::NonUtf8ResolvedPath(path) => write!( formatter, "resolved model-provided shell path `{}` is not representable in command argv", path.display() ), Self::RelativeWorkingDirectory(path) => write!( formatter, "cannot resolve a model-provided shell against relative working directory `{}`", path.display() ), } } } impl std::error::Error for ModelShellResolveError {} impl DetectedShell { pub fn name(&self) -> &'static str { self.shell_type.name() } } pub fn detect_shell_type(shell_path: impl AsRef) -> Option { let shell_path = shell_path.as_ref(); #[cfg(windows)] { shell_path .as_os_str() .to_str() .and_then(detect_shell_type_from_hint) } #[cfg(not(windows))] match shell_path.as_os_str().to_str() { Some("zsh") => Some(ShellType::Zsh), Some("sh") => Some(ShellType::Sh), Some("cmd") => Some(ShellType::Cmd), Some("bash") => Some(ShellType::Bash), Some("pwsh") => Some(ShellType::PowerShell), Some("powershell") => Some(ShellType::PowerShell), _ => { let shell_name = shell_path.file_stem(); if let Some(shell_name) = shell_name { let shell_name_path = std::path::Path::new(shell_name); if shell_name_path != shell_path { return detect_shell_type(shell_name_path); } } None } } } /// Detects a requested remote shell type without interpreting the spelling /// using the controller's native path rules. /// /// The returned type is only a compatibility hint. Callers must discard the /// spelling and launch the environment-reported shell executable. pub fn detect_shell_type_from_hint(shell_hint: &str) -> Option { let file_name = shell_hint.rsplit(['/', '\\']).next()?; if file_name.is_empty() { return None; } let stem = file_name .rsplit_once('.') .filter(|(_, extension)| extension.eq_ignore_ascii_case("exe")) .map_or(file_name, |(stem, _)| stem); if stem.eq_ignore_ascii_case("zsh") { Some(ShellType::Zsh) } else if stem.eq_ignore_ascii_case("sh") { Some(ShellType::Sh) } else if stem.eq_ignore_ascii_case("cmd") { Some(ShellType::Cmd) } else if stem.eq_ignore_ascii_case("bash") { Some(ShellType::Bash) } else if stem.eq_ignore_ascii_case("pwsh") || stem.eq_ignore_ascii_case("powershell") { Some(ShellType::PowerShell) } else { None } } #[cfg(unix)] fn get_user_shell_path() -> Option { let uid = unsafe { libc::getuid() }; use std::ffi::CStr; use std::mem::MaybeUninit; use std::ptr; let mut passwd = MaybeUninit::::uninit(); // We cannot use getpwuid here: it returns pointers into libc-managed // storage, which is not safe to read concurrently on all targets (the musl // static build used by the CLI can segfault when parallel callers race on // that buffer). getpwuid_r keeps the passwd data in caller-owned memory. let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; let buffer_len = usize::try_from(suggested_buffer_len) .ok() .filter(|len| *len > 0) .unwrap_or(1024); let mut buffer = vec![0; buffer_len]; loop { let mut result = ptr::null_mut(); let status = unsafe { libc::getpwuid_r( uid, passwd.as_mut_ptr(), buffer.as_mut_ptr().cast(), buffer.len(), &mut result, ) }; if status == 0 { if result.is_null() { return None; } let passwd = unsafe { passwd.assume_init_ref() }; if passwd.pw_shell.is_null() { return None; } let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } .to_string_lossy() .into_owned(); return Some(PathBuf::from(shell_path)); } if status != libc::ERANGE { return None; } // Retry with a larger buffer until libc can materialize the passwd entry. let new_len = buffer.len().checked_mul(2)?; if new_len > 1024 * 1024 { return None; } buffer.resize(new_len, 0); } } #[cfg(not(unix))] fn get_user_shell_path() -> Option { None } fn file_exists(path: &std::path::Path) -> Option { if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) { Some(PathBuf::from(path)) } else { None } } fn get_shell_path( shell_type: ShellType, provided_path: Option<&PathBuf>, binary_name: &str, fallback_paths: &[&str], ) -> Option { if let Some(path) = provided_path.and_then(|path| file_exists(path)) { return Some(path); } let default_shell_path = get_user_shell_path(); if let Some(default_shell_path) = default_shell_path && detect_shell_type(&default_shell_path) == Some(shell_type) && file_exists(&default_shell_path).is_some() { return Some(default_shell_path); } if let Ok(path) = which::which(binary_name) { return Some(path); } for path in fallback_paths { if let Some(path) = file_exists(std::path::Path::new(path)) { return Some(path); } } None } const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"]; fn get_zsh_shell(path: Option<&PathBuf>) -> Option { let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS); shell_path.map(|shell_path| DetectedShell { shell_type: ShellType::Zsh, shell_path, }) } const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash", "/usr/bin/bash"]; fn get_bash_shell(path: Option<&PathBuf>) -> Option { let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); shell_path.map(|shell_path| DetectedShell { shell_type: ShellType::Bash, shell_path, }) } const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"]; fn get_sh_shell(path: Option<&PathBuf>) -> Option { let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS); shell_path.map(|shell_path| DetectedShell { shell_type: ShellType::Sh, shell_path, }) } // Note the `pwsh` and `powershell` fallback paths are where the respective // shells are commonly installed on GitHub Actions Windows runners, but may not // be present on all Windows machines: // https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell #[cfg(windows)] const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#]; #[cfg(not(windows))] const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"]; #[cfg(windows)] const POWERSHELL_FALLBACK_PATHS: &[&str] = &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#]; #[cfg(not(windows))] const POWERSHELL_FALLBACK_PATHS: &[&str] = &[]; fn get_powershell_shell(path: Option<&PathBuf>) -> Option { let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS) .or_else(|| { get_shell_path( ShellType::PowerShell, path, "powershell", POWERSHELL_FALLBACK_PATHS, ) }); shell_path.map(|shell_path| DetectedShell { shell_type: ShellType::PowerShell, shell_path, }) } fn get_cmd_shell(path: Option<&PathBuf>) -> Option { let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]); shell_path.map(|shell_path| DetectedShell { shell_type: ShellType::Cmd, shell_path, }) } pub fn ultimate_fallback_shell() -> DetectedShell { if cfg!(windows) { DetectedShell { shell_type: ShellType::Cmd, shell_path: PathBuf::from("cmd.exe"), } } else { DetectedShell { shell_type: ShellType::Sh, shell_path: PathBuf::from("/bin/sh"), } } } /// Legacy configured-shell compatibility helper. /// /// This may fall back to the user's configured/default shell and therefore /// must not be used for model-selected executable input. Model-selected shells /// must go through [`resolve_model_provided_shell_in`]. pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> DetectedShell { detect_shell_type(shell_path) .and_then(|shell_type| get_shell(shell_type, Some(shell_path))) .unwrap_or_else(ultimate_fallback_shell) } /// Resolves a model-selected shell exactly once against the invocation's PATH /// and selected execution cwd. /// /// Unlike configured-shell detection, this never falls back to another /// executable. The returned path is the one policy and runtime must both use. pub fn resolve_model_provided_shell_in( shell_path: &Path, search_path: &OsStr, path_ext: Option<&OsStr>, cwd: &Path, ) -> Result { let shell_type = detect_shell_type(shell_path) .ok_or_else(|| ModelShellResolveError::Unsupported(shell_path.to_path_buf()))?; if !cwd.is_absolute() { return Err(ModelShellResolveError::RelativeWorkingDirectory( cwd.to_path_buf(), )); } #[cfg(not(windows))] let is_bare_name = shell_path.components().count() == 1; #[cfg(windows)] let resolved_path = resolve_model_shell_path_windows( shell_path, search_path, path_ext.unwrap_or_else(|| OsStr::new("")), cwd, )?; #[cfg(not(windows))] let resolved_path = { let _ = path_ext; if is_bare_name { let mut resolved = None; for directory in std::env::split_paths(search_path) { let candidate = resolve_relative_path(&directory, cwd)?.join(shell_path); if validate_model_shell_path(&candidate).is_ok() { resolved = Some(candidate); break; } } resolved .ok_or_else(|| ModelShellResolveError::MissingBareName(shell_path.to_path_buf()))? } else { resolve_relative_path(shell_path, cwd)? } }; if resolved_path.to_str().is_none() { return Err(ModelShellResolveError::NonUtf8ResolvedPath(resolved_path)); } validate_model_shell_path(&resolved_path)?; Ok(DetectedShell { shell_type, shell_path: resolved_path, }) } fn resolve_relative_path(path: &Path, cwd: &Path) -> Result { let absolute = if path.is_absolute() { path.to_path_buf() } else { cwd.join(path) }; if absolute .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(ModelShellResolveError::ParentDirectoryComponent(absolute)); } Ok(absolute .components() .filter(|component| !matches!(component, std::path::Component::CurDir)) .collect()) } #[cfg(any(windows, test))] #[cfg_attr(not(windows), allow(dead_code))] fn resolve_model_shell_path_windows( shell_path: &Path, search_path: &OsStr, path_ext: &OsStr, cwd: &Path, ) -> Result { validate_windows_local_path_namespace(shell_path)?; let path_extensions = path_ext .to_string_lossy() .split(';') .filter(|extension| extension.starts_with('.') && extension.len() > 1) .map(str::to_owned) .collect::>(); let candidate_names = windows_executable_candidates(shell_path, &path_extensions); let has_separator = shell_path.components().count() > 1; if has_separator { let exact_path = resolve_windows_path_against_cwd(shell_path, cwd)?; if !exact_path.is_absolute() { return Err(ModelShellResolveError::UnresolvedWindowsRelativePath( shell_path.to_path_buf(), )); } let mut first_specific_error = None; for candidate in candidate_names { let candidate = resolve_relative_path(&candidate, cwd)?; match validate_model_shell_path(&candidate) { Ok(()) => return Ok(candidate), Err(ModelShellResolveError::MissingPath(_)) => {} Err(err) if first_specific_error.is_none() => first_specific_error = Some(err), Err(_) => {} } } if first_specific_error.is_none() && shell_path.extension().is_none() && std::fs::metadata(&exact_path).is_ok_and(|metadata| metadata.is_file()) { return Err(ModelShellResolveError::UnsupportedWindowsLaunch(exact_path)); } return Err(first_specific_error.unwrap_or(ModelShellResolveError::MissingPath(exact_path))); } for directory in std::env::split_paths(search_path) { // Preserve PATH ordering: validate an entry immediately before it // would be searched, without inspecting later unused entries. let resolved_directory = resolve_windows_path_against_cwd(&directory, cwd)?; if !resolved_directory.is_absolute() { return Err(ModelShellResolveError::UnresolvedWindowsRelativePath( directory, )); } for candidate_name in &candidate_names { let candidate = resolved_directory.join(candidate_name); if validate_model_shell_path(&candidate).is_ok() { return Ok(candidate); } } } Err(ModelShellResolveError::MissingBareName( shell_path.to_path_buf(), )) } #[cfg(any(windows, test))] #[cfg_attr(not(windows), allow(dead_code))] fn validate_windows_local_path_namespace(path: &Path) -> Result<(), ModelShellResolveError> { let Some(std::path::Component::Prefix(prefix)) = path.components().next() else { return Ok(()); }; // Only ordinary drive-letter paths are accepted as prefixed syntax because // they do not directly encode a remote host or device namespace. UNC, // device, and every verbatim namespace can cause remote or device I/O // during metadata lookup and must not be probed. // // This is not proof that the storage is local: a mapped drive or a reparse // point can still redirect a normal drive path before approval. Closing // that residual requires a two-phase or handle-based resolution design. match prefix.kind() { std::path::Prefix::Disk(_) => Ok(()), _ => Err(ModelShellResolveError::UnsupportedWindowsPathNamespace( path.to_path_buf(), )), } } #[cfg(any(windows, test))] #[cfg_attr(not(windows), allow(dead_code))] fn resolve_windows_path_against_cwd( path: &Path, cwd: &Path, ) -> Result { validate_windows_local_path_namespace(path)?; let has_prefix = matches!( path.components().next(), Some(std::path::Component::Prefix(_)) ); if !path.is_absolute() && !has_prefix { // Relative and root-relative paths inherit directory or drive context // from cwd, so validate that namespace before joining or probing. validate_windows_local_path_namespace(cwd)?; } resolve_relative_path(path, cwd) } #[cfg(any(windows, test))] #[cfg_attr(not(windows), allow(dead_code))] fn windows_executable_candidates(shell_path: &Path, path_extensions: &[String]) -> Vec { match shell_path.extension() { Some(extension) if extension.eq_ignore_ascii_case("exe") => { vec![shell_path.to_path_buf()] } Some(_) => Vec::new(), None => path_extensions .iter() .filter(|extension| extension.eq_ignore_ascii_case(".exe")) .map(|extension| { let mut candidate = shell_path.as_os_str().to_os_string(); candidate.push(extension); PathBuf::from(candidate) }) .collect(), } } fn validate_model_shell_path(path: &Path) -> Result<(), ModelShellResolveError> { let metadata = std::fs::metadata(path) .map_err(|_| ModelShellResolveError::MissingPath(path.to_path_buf()))?; if !metadata.is_file() { return Err(ModelShellResolveError::NotAFile(path.to_path_buf())); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if metadata.permissions().mode() & 0o111 == 0 { return Err(ModelShellResolveError::NotExecutable(path.to_path_buf())); } } Ok(()) } pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option { match shell_type { ShellType::Zsh => get_zsh_shell(path), ShellType::Bash => get_bash_shell(path), ShellType::PowerShell => get_powershell_shell(path), ShellType::Sh => get_sh_shell(path), ShellType::Cmd => get_cmd_shell(path), } } pub fn default_user_shell() -> DetectedShell { default_user_shell_from_path(get_user_shell_path()) } pub fn default_user_shell_from_path(user_shell_path: Option) -> DetectedShell { if cfg!(windows) { get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or_else(ultimate_fallback_shell) } else { let user_default_shell = user_shell_path .and_then(|shell| detect_shell_type(&shell)) .and_then(|shell_type| get_shell(shell_type, /*path*/ None)); let shell_with_fallback = if cfg!(target_os = "macos") { user_default_shell .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) } else { user_default_shell .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) }; shell_with_fallback.unwrap_or_else(ultimate_fallback_shell) } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn test_detect_shell_type() { assert_eq!( detect_shell_type(PathBuf::from("zsh")), Some(ShellType::Zsh) ); assert_eq!( detect_shell_type(PathBuf::from("bash")), Some(ShellType::Bash) ); assert_eq!( detect_shell_type(PathBuf::from("pwsh")), Some(ShellType::PowerShell) ); assert_eq!( detect_shell_type(PathBuf::from("powershell")), Some(ShellType::PowerShell) ); assert_eq!(detect_shell_type(PathBuf::from("fish")), None); assert_eq!(detect_shell_type(PathBuf::from("other")), None); assert_eq!( detect_shell_type(PathBuf::from("/bin/zsh")), Some(ShellType::Zsh) ); assert_eq!( detect_shell_type(PathBuf::from("/bin/bash")), Some(ShellType::Bash) ); assert_eq!( detect_shell_type(PathBuf::from("/usr/bin/bash")), Some(ShellType::Bash) ); assert_eq!( detect_shell_type(PathBuf::from("powershell.exe")), Some(ShellType::PowerShell) ); assert_eq!( detect_shell_type(PathBuf::from(if cfg!(windows) { "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" } else { "/usr/local/bin/pwsh" })), Some(ShellType::PowerShell) ); assert_eq!( detect_shell_type(PathBuf::from("pwsh.exe")), Some(ShellType::PowerShell) ); assert_eq!( detect_shell_type(PathBuf::from("/usr/local/bin/pwsh")), Some(ShellType::PowerShell) ); assert_eq!( detect_shell_type(PathBuf::from("/bin/sh")), Some(ShellType::Sh) ); assert_eq!(detect_shell_type(PathBuf::from("sh")), Some(ShellType::Sh)); assert_eq!( detect_shell_type(PathBuf::from("cmd")), Some(ShellType::Cmd) ); assert_eq!( detect_shell_type(PathBuf::from("cmd.exe")), Some(ShellType::Cmd) ); } #[test] fn detects_remote_shell_hints_independently_of_controller_path_syntax() { for (hint, expected) in [ ("bash", Some(ShellType::Bash)), ("/attacker/bash", Some(ShellType::Bash)), (r"C:\attacker\BASH.ExE", Some(ShellType::Bash)), (r"\\server\share\PwSh.EXE", Some(ShellType::PowerShell)), ("/opt/PowerShell", Some(ShellType::PowerShell)), (r"C:\Windows\System32\cmd.exe", Some(ShellType::Cmd)), ("/tmp/fish", None), (r"C:\attacker\powershell.exe:payload", None), ("/tmp/bash/", None), (r"C:\tmp\bash\", None), ] { assert_eq!( detect_shell_type_from_hint(hint), expected, "unexpected type for {hint:?}" ); } } #[cfg(unix)] fn write_executable(path: &Path) { use std::os::unix::fs::PermissionsExt; std::fs::write(path, "#!/bin/sh\nexit 0\n").expect("write fake shell"); let mut permissions = std::fs::metadata(path) .expect("fake shell metadata") .permissions(); permissions.set_mode(0o755); std::fs::set_permissions(path, permissions).expect("make fake shell executable"); } #[cfg(unix)] #[test] fn model_shell_resolver_uses_only_supplied_path() { let temp_dir = tempfile::tempdir().expect("temp dir"); let bin = temp_dir.path().join("bin"); std::fs::create_dir(&bin).expect("create bin"); let shell = bin.join("sh"); write_executable(&shell); assert_eq!( resolve_model_provided_shell_in( Path::new("sh"), bin.as_os_str(), /*path_ext*/ None, temp_dir.path(), ), Ok(DetectedShell { shell_type: ShellType::Sh, shell_path: shell, }) ); assert_eq!( resolve_model_provided_shell_in( Path::new("sh"), OsStr::new(""), /*path_ext*/ None, temp_dir.path(), ), Err(ModelShellResolveError::MissingBareName(PathBuf::from("sh"))) ); } #[cfg(unix)] #[test] fn model_shell_resolver_resolves_relative_input_and_path_against_selected_cwd() { let temp_dir = tempfile::tempdir().expect("temp dir"); let tools = temp_dir.path().join("tools"); std::fs::create_dir(&tools).expect("create tools"); let shell = tools.join("bash"); write_executable(&shell); for (requested, search_path) in [ (Path::new("./tools/bash"), OsStr::new("")), (Path::new("bash"), OsStr::new("tools")), ] { assert_eq!( resolve_model_provided_shell_in( requested, search_path, /*path_ext*/ None, temp_dir.path(), ), Ok(DetectedShell { shell_type: ShellType::Bash, shell_path: shell.clone(), }) ); } } #[cfg(unix)] #[test] fn model_shell_resolver_rejects_parent_components_in_shell_and_cwd() { let temp_dir = tempfile::tempdir().expect("temp dir"); let cwd = temp_dir.path().join("workspace"); let outside = temp_dir.path().join("outside"); let tools = cwd.join("tools"); std::fs::create_dir_all(&outside).expect("create outside"); std::fs::create_dir_all(&tools).expect("create tools"); write_executable(&outside.join("bash")); write_executable(&tools.join("bash")); for (requested, selected_cwd, rejected) in [ ( PathBuf::from("../outside/bash"), cwd.clone(), cwd.join("../outside/bash"), ), ( cwd.join("../outside/bash"), cwd.clone(), cwd.join("../outside/bash"), ), ( PathBuf::from("tools/bash"), cwd.join("../workspace"), cwd.join("../workspace/tools/bash"), ), ] { assert_eq!( resolve_model_provided_shell_in( &requested, OsStr::new(""), /*path_ext*/ None, &selected_cwd, ), Err(ModelShellResolveError::ParentDirectoryComponent(rejected)), "parent components must be rejected for {requested:?} against {selected_cwd:?}", ); } } #[cfg(unix)] #[test] fn model_shell_resolver_rejects_parent_path_entries_in_lookup_order() { let temp_dir = tempfile::tempdir().expect("temp dir"); let cwd = temp_dir.path().join("workspace"); let valid_bin = temp_dir.path().join("valid-bin"); let outside = temp_dir.path().join("outside"); std::fs::create_dir(&cwd).expect("create cwd"); std::fs::create_dir(&valid_bin).expect("create valid bin"); std::fs::create_dir(&outside).expect("create outside"); let valid_shell = valid_bin.join("bash"); write_executable(&valid_shell); write_executable(&outside.join("bash")); let parent_first = std::env::join_paths([Path::new("../outside"), valid_bin.as_path()]) .expect("join PATH"); assert_eq!( resolve_model_provided_shell_in( Path::new("bash"), &parent_first, /*path_ext*/ None, &cwd, ), Err(ModelShellResolveError::ParentDirectoryComponent( cwd.join("../outside") )), ); let valid_first = std::env::join_paths([valid_bin.as_path(), Path::new("../outside")]) .expect("join PATH"); assert_eq!( resolve_model_provided_shell_in( Path::new("bash"), &valid_first, /*path_ext*/ None, &cwd, ), Ok(DetectedShell { shell_type: ShellType::Bash, shell_path: valid_shell, }), "an earlier valid PATH candidate wins before an unused parent-containing entry", ); } #[cfg(unix)] #[test] fn model_shell_resolver_rejects_unsupported_missing_and_non_launchable_inputs() { let temp_dir = tempfile::tempdir().expect("temp dir"); let non_executable = temp_dir.path().join("bash"); std::fs::write(&non_executable, "#!/bin/sh\n").expect("write non-executable shell"); let directory = temp_dir.path().join("sh"); std::fs::create_dir(&directory).expect("create shell-named directory"); let missing = temp_dir.path().join("zsh"); assert_eq!( resolve_model_provided_shell_in( &non_executable, OsStr::new(""), /*path_ext*/ None, temp_dir.path(), ), Err(ModelShellResolveError::NotExecutable(non_executable)) ); assert_eq!( resolve_model_provided_shell_in( &directory, OsStr::new(""), /*path_ext*/ None, temp_dir.path(), ), Err(ModelShellResolveError::NotAFile(directory)) ); assert_eq!( resolve_model_provided_shell_in( &missing, OsStr::new(""), /*path_ext*/ None, temp_dir.path(), ), Err(ModelShellResolveError::MissingPath(missing)) ); assert_eq!( resolve_model_provided_shell_in( Path::new("fish"), OsStr::new(""), /*path_ext*/ None, temp_dir.path(), ), Err(ModelShellResolveError::Unsupported(PathBuf::from("fish"))) ); assert_eq!( resolve_model_provided_shell_in( Path::new("sh"), OsStr::new(""), /*path_ext*/ None, Path::new("."), ), Err(ModelShellResolveError::RelativeWorkingDirectory( PathBuf::from(".") )) ); } #[cfg(unix)] #[test] fn model_shell_resolver_rejects_non_utf8_resolved_path() { use std::os::unix::ffi::OsStringExt; let temp_dir = tempfile::tempdir().expect("temp dir"); let non_utf8_cwd = temp_dir .path() .join(std::ffi::OsString::from_vec(b"cwd-\xff".to_vec())); let shell = non_utf8_cwd.join("bash"); assert_eq!( resolve_model_provided_shell_in( Path::new("./bash"), OsStr::new(""), /*path_ext*/ None, &non_utf8_cwd, ), Err(ModelShellResolveError::NonUtf8ResolvedPath(shell)) ); } #[cfg(windows)] #[test] fn windows_model_shell_resolver_uses_supplied_pathext() { let temp_dir = tempfile::tempdir().expect("temp dir"); let bin = temp_dir.path().join("bin"); std::fs::create_dir(&bin).expect("create bin"); for (requested, file_name) in [("PowerShell.ExE", "PowerShell.ExE"), ("PWSH", "PWSH.EXE")] { let shell = bin.join(file_name); std::fs::write(&shell, "fixture").expect("write fake executable"); assert_eq!( resolve_model_provided_shell_in( Path::new(requested), bin.as_os_str(), Some(OsStr::new(".EXE")), temp_dir.path(), ), Ok(DetectedShell { shell_type: ShellType::PowerShell, shell_path: shell, }) ); } assert_eq!( resolve_model_provided_shell_in( Path::new("powershell"), bin.as_os_str(), Some(OsStr::new(".CMD")), temp_dir.path(), ), Err(ModelShellResolveError::MissingBareName(PathBuf::from( "powershell" ))) ); let extensionless = temp_dir.path().join("bash"); std::fs::write(&extensionless, "fixture").expect("write extensionless executable"); assert_eq!( resolve_model_provided_shell_in( &extensionless, OsStr::new(""), Some(OsStr::new("")), temp_dir.path(), ), Err(ModelShellResolveError::UnsupportedWindowsLaunch( extensionless.clone() )) ); let sibling_exe = temp_dir.path().join("bash.EXE"); std::fs::write(&sibling_exe, "fixture").expect("write sibling exe"); assert_eq!( resolve_model_provided_shell_in( &extensionless, OsStr::new(""), Some(OsStr::new(".EXE")), temp_dir.path(), ), Ok(DetectedShell { shell_type: ShellType::Bash, shell_path: sibling_exe, }) ); for (requested, file_name, path_ext) in [ ("cmd", "cmd.CMD", ".CMD"), ("powershell", "powershell.BAT", ".BAT"), ] { std::fs::write(bin.join(file_name), "fixture").expect("write script candidate"); assert_eq!( resolve_model_provided_shell_in( Path::new(requested), bin.as_os_str(), Some(OsStr::new(path_ext)), temp_dir.path(), ), Err(ModelShellResolveError::MissingBareName(PathBuf::from( requested ))) ); } let directory = temp_dir.path().join("sh.exe"); std::fs::create_dir(&directory).expect("create shell-named directory"); assert_eq!( resolve_model_provided_shell_in( &directory, OsStr::new(""), Some(OsStr::new(".EXE")), temp_dir.path(), ), Err(ModelShellResolveError::NotAFile(directory)) ); let drive_relative = PathBuf::from(r"C:relative\powershell.exe"); assert_eq!( resolve_model_provided_shell_in( &drive_relative, OsStr::new(""), Some(OsStr::new(".EXE")), temp_dir.path(), ), Err(ModelShellResolveError::UnresolvedWindowsRelativePath( drive_relative )) ); let later_shell = bin.join("bash.EXE"); std::fs::write(&later_shell, "fixture").expect("write later PATH shell"); let drive_relative_path = PathBuf::from(r"C:relative-bin"); let search_path = std::env::join_paths([drive_relative_path.as_path(), bin.as_path()]) .expect("join PATH entries"); assert_eq!( resolve_model_provided_shell_in( Path::new("bash"), &search_path, Some(OsStr::new(".EXE")), temp_dir.path(), ), Err(ModelShellResolveError::UnresolvedWindowsRelativePath( drive_relative_path )) ); } #[cfg(windows)] #[test] fn windows_model_shell_resolver_rejects_remote_device_and_verbatim_namespaces() { let temp_dir = tempfile::tempdir().expect("temp dir"); for requested in [ PathBuf::from(r"\\server\share\powershell.exe"), PathBuf::from(r"//server/share/powershell.exe"), PathBuf::from(r"\/server\share/powershell.exe"), PathBuf::from(r"\\.\GLOBALROOT\Device\HarddiskVolume1\powershell.exe"), PathBuf::from(r"\\?\C:\tools\powershell.exe"), PathBuf::from(r"\\?\C:/tools/powershell.exe"), PathBuf::from(r"\\?\UNC\server\share\powershell.exe"), PathBuf::from(r"\\?\UNC\server/share/powershell.exe"), PathBuf::from(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\powershell.exe"), PathBuf::from(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}/powershell.exe"), ] { assert_eq!( resolve_model_provided_shell_in( &requested, OsStr::new(""), Some(OsStr::new(".EXE")), temp_dir.path(), ), Err(ModelShellResolveError::UnsupportedWindowsPathNamespace( requested.clone() )), "unsafe namespace should fail before any executable probe: {requested:?}" ); } } #[cfg(windows)] #[test] fn windows_model_shell_resolver_validates_path_namespaces_in_lookup_order() { let temp_dir = tempfile::tempdir().expect("temp dir"); let bin = temp_dir.path().join("bin"); std::fs::create_dir(&bin).expect("create bin"); let shell = bin.join("powershell.EXE"); std::fs::write(&shell, "fixture").expect("write fake executable"); let unsafe_directory = PathBuf::from(r"\\server\share\bin"); let unsafe_first = std::env::join_paths([unsafe_directory.as_path(), bin.as_path()]).expect("join PATH"); assert_eq!( resolve_model_provided_shell_in( Path::new("powershell"), &unsafe_first, Some(OsStr::new(".EXE")), temp_dir.path(), ), Err(ModelShellResolveError::UnsupportedWindowsPathNamespace( unsafe_directory.clone() )) ); let local_first = std::env::join_paths([bin.as_path(), unsafe_directory.as_path()]).expect("join PATH"); assert_eq!( resolve_model_provided_shell_in( Path::new("powershell"), &local_first, Some(OsStr::new(".EXE")), temp_dir.path(), ), Ok(DetectedShell { shell_type: ShellType::PowerShell, shell_path: shell, }), "a valid earlier PATH candidate must return before an unused unsafe entry" ); } #[cfg(windows)] #[test] fn windows_model_shell_resolver_rejects_parent_components_in_lookup_order() { let temp_dir = tempfile::tempdir().expect("temp dir"); let bin = temp_dir.path().join("bin"); let tools = temp_dir.path().join("tools"); std::fs::create_dir(&bin).expect("create bin"); std::fs::create_dir(&tools).expect("create tools"); let shell = bin.join("powershell.EXE"); std::fs::write(&shell, "fixture").expect("write fake executable"); let requested = PathBuf::from(r"tools\..\bin\powershell.exe"); assert_eq!( resolve_model_provided_shell_in( &requested, OsStr::new(""), Some(OsStr::new(".EXE")), temp_dir.path(), ), Err(ModelShellResolveError::ParentDirectoryComponent( temp_dir.path().join(&requested) )), ); let parent_entry = PathBuf::from(r"tools\.."); let parent_first = std::env::join_paths([parent_entry.as_path(), bin.as_path()]).expect("join PATH"); assert_eq!( resolve_model_provided_shell_in( Path::new("powershell"), &parent_first, Some(OsStr::new(".EXE")), temp_dir.path(), ), Err(ModelShellResolveError::ParentDirectoryComponent( temp_dir.path().join(&parent_entry) )), ); let valid_first = std::env::join_paths([bin.as_path(), parent_entry.as_path()]).expect("join PATH"); assert_eq!( resolve_model_provided_shell_in( Path::new("powershell"), &valid_first, Some(OsStr::new(".EXE")), temp_dir.path(), ), Ok(DetectedShell { shell_type: ShellType::PowerShell, shell_path: shell, }), "an earlier valid PATH candidate wins before an unused parent-containing entry", ); } #[cfg(windows)] #[test] fn windows_model_shell_resolver_checks_unsafe_cwd_only_when_resolution_uses_it() { let temp_dir = tempfile::tempdir().expect("temp dir"); let relative_dir = temp_dir.path().join("tools"); std::fs::create_dir(&relative_dir).expect("create tools"); let shell = relative_dir.join("pwsh.exe"); std::fs::write(&shell, "fixture").expect("write fake executable"); assert_eq!( resolve_model_provided_shell_in( Path::new(r"tools\pwsh.exe"), OsStr::new(""), Some(OsStr::new(".EXE")), temp_dir.path(), ), Ok(DetectedShell { shell_type: ShellType::PowerShell, shell_path: shell.clone(), }) ); let unsafe_cwd = PathBuf::from(r"\\server\share\cwd"); assert_eq!( resolve_model_provided_shell_in( Path::new(r".\pwsh.exe"), OsStr::new(""), Some(OsStr::new(".EXE")), &unsafe_cwd, ), Err(ModelShellResolveError::UnsupportedWindowsPathNamespace( unsafe_cwd.clone() )) ); assert_eq!( resolve_model_provided_shell_in( &shell, OsStr::new(""), Some(OsStr::new(".EXE")), &unsafe_cwd, ), Ok(DetectedShell { shell_type: ShellType::PowerShell, shell_path: shell, }), "an absolute local shell does not use cwd during resolution" ); } }