mirror of
https://github.com/openai/codex.git
synced 2026-09-08 15:50:34 +00:00
1282 lines
43 KiB
Rust
1282 lines
43 KiB
Rust
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<std::path::Path>) -> Option<ShellType> {
|
|
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<ShellType> {
|
|
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<PathBuf> {
|
|
let uid = unsafe { libc::getuid() };
|
|
use std::ffi::CStr;
|
|
use std::mem::MaybeUninit;
|
|
use std::ptr;
|
|
|
|
let mut passwd = MaybeUninit::<libc::passwd>::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<PathBuf> {
|
|
None
|
|
}
|
|
|
|
fn file_exists(path: &std::path::Path) -> Option<PathBuf> {
|
|
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<PathBuf> {
|
|
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<DetectedShell> {
|
|
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<DetectedShell> {
|
|
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<DetectedShell> {
|
|
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<DetectedShell> {
|
|
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<DetectedShell> {
|
|
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<DetectedShell, ModelShellResolveError> {
|
|
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<PathBuf, ModelShellResolveError> {
|
|
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<PathBuf, ModelShellResolveError> {
|
|
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::<Vec<_>>();
|
|
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<PathBuf, ModelShellResolveError> {
|
|
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<PathBuf> {
|
|
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<DetectedShell> {
|
|
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<PathBuf>) -> 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"
|
|
);
|
|
}
|
|
}
|