mirror of
https://github.com/openai/codex.git
synced 2026-09-08 15:50:34 +00:00
Harden model-selected shell approval boundaries
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
@@ -30,6 +32,81 @@ pub struct DetectedShell {
|
||||
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),
|
||||
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::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()
|
||||
@@ -38,6 +115,14 @@ impl DetectedShell {
|
||||
|
||||
pub fn detect_shell_type(shell_path: impl AsRef<std::path::Path>) -> Option<ShellType> {
|
||||
let shell_path = shell_path.as_ref();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return 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),
|
||||
@@ -58,6 +143,36 @@ pub fn detect_shell_type(shell_path: impl AsRef<std::path::Path>) -> Option<Shel
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() };
|
||||
@@ -252,12 +367,231 @@ pub fn ultimate_fallback_shell() -> DetectedShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
std::env::split_paths(search_path)
|
||||
.map(|directory| resolve_relative_path(&directory, cwd).join(shell_path))
|
||||
.find(|candidate| validate_model_shell_path(candidate).is_ok())
|
||||
.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) -> PathBuf {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
};
|
||||
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
|
||||
.into_iter()
|
||||
.map(|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)?;
|
||||
}
|
||||
|
||||
Ok(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),
|
||||
@@ -365,4 +699,424 @@ mod tests {
|
||||
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_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_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user