use std::env;
use std::path::{Path, PathBuf};
use crate::PlatformError;
use crate::host::{HostPlatform, OperatingSystem};
use super::{PowerShellEdition, ShellKind, ShellSpec};
pub struct ShellResolver
{
host: HostPlatform,
probe: P,
}
impl
ShellResolver
where
P: Fn(&str) -> Option,
{
pub fn new(host: HostPlatform, probe: P) -> Self {
Self { host, probe }
}
pub fn resolve(&self, explicit_override: Option<&str>) -> Result {
if let Some(value) = explicit_override {
return self.resolve_override(value);
}
match self.host.os {
OperatingSystem::Windows => self.resolve_windows_default(),
OperatingSystem::MacOs | OperatingSystem::Linux => self.resolve_unix_default(),
_ => Err(PlatformError::UnsupportedHost {
platform: self.host.clone(),
}),
}
}
pub fn resolve_from_environment(&self) -> Result {
let override_value = env::var_os("ORCA_SHELL")
.map(|value| {
value
.into_string()
.map_err(|_| PlatformError::InvalidShellOverride {
value: "".to_string(),
reason: "ORCA_SHELL must be valid UTF-8".to_string(),
})
})
.transpose()?;
self.resolve(override_value.as_deref())
}
fn resolve_override(&self, value: &str) -> Result {
if value.trim().is_empty() {
return Err(PlatformError::InvalidShellOverride {
value: value.to_string(),
reason: "the value is empty".to_string(),
});
}
let executable =
(self.probe)(value).ok_or_else(|| PlatformError::InvalidShellOverride {
value: value.to_string(),
reason: "the executable does not exist or is not available".to_string(),
})?;
let kind = match self.host.os {
OperatingSystem::Windows => windows_override_kind(value)?,
OperatingSystem::MacOs | OperatingSystem::Linux => ShellKind::Posix,
_ => {
return Err(PlatformError::UnsupportedHost {
platform: self.host.clone(),
});
}
};
Ok(ShellSpec::new(executable, kind))
}
fn resolve_windows_default(&self) -> Result {
// Windows PowerShell 5.1 enters ConstrainedLanguage inside Orca's
// AppContainer sandbox, which cannot satisfy the general shell
// contract. Prefer cmd.exe as the built-in restricted-mode fallback;
// callers can still select powershell.exe explicitly for full access.
for (candidate, kind) in [
("pwsh.exe", ShellKind::PowerShell(PowerShellEdition::Core)),
("cmd.exe", ShellKind::Cmd),
(
"powershell.exe",
ShellKind::PowerShell(PowerShellEdition::Windows),
),
] {
if let Some(executable) = (self.probe)(candidate) {
return Ok(ShellSpec::new(executable, kind));
}
}
Err(PlatformError::ExecutableNotFound {
executable: "pwsh.exe, cmd.exe, or powershell.exe".to_string(),
})
}
fn resolve_unix_default(&self) -> Result {
(self.probe)("sh")
.map(|executable| ShellSpec::new(executable, ShellKind::Posix))
.ok_or_else(|| PlatformError::ExecutableNotFound {
executable: "sh".to_string(),
})
}
}
impl ShellResolver Option> {
pub fn for_current_host() -> Self {
Self::new(HostPlatform::current(), find_executable)
}
/// Like [`Self::for_current_host`], but the executable guard also rejects anything inside
/// `workspace` — which is the directory the caller actually runs in, and is frequently not
/// the process working directory (`orca exec --cwd …` from elsewhere).
pub fn for_current_host_in(
workspace: impl Into,
) -> ShellResolver Option> {
let workspace = workspace.into();
ShellResolver::new(HostPlatform::current(), move |candidate: &str| {
find_executable_in(candidate, Some(&workspace))
})
}
}
fn windows_override_kind(value: &str) -> Result {
let normalized = value.replace('\\', "/");
let name = normalized.rsplit('/').next().unwrap_or(&normalized);
match name.to_ascii_lowercase().as_str() {
"pwsh" | "pwsh.exe" => Ok(ShellKind::PowerShell(PowerShellEdition::Core)),
"powershell" | "powershell.exe" => Ok(ShellKind::PowerShell(PowerShellEdition::Windows)),
"cmd" | "cmd.exe" => Ok(ShellKind::Cmd),
"bash" | "bash.exe" => Ok(ShellKind::GitBash),
_ => Err(PlatformError::InvalidShellOverride {
value: value.to_string(),
reason: "expected pwsh.exe, powershell.exe, cmd.exe, or explicitly selected bash.exe"
.to_string(),
}),
}
}
fn find_executable(candidate: &str) -> Option {
find_executable_in(candidate, None)
}
/// Resolve `candidate` on `PATH`, refusing anything that lives inside the process working
/// directory *or* inside `workspace` (the caller's actual project root).
///
/// Both roots are checked on purpose: the process cwd is a shadowing vector of its own, and a
/// workspace passed via `--cwd` is the directory whose executables the guard is really about
/// (issue #71 — a repository-supplied `sh` used to win whenever the two differed).
fn find_executable_in(candidate: &str, workspace: Option<&Path>) -> Option {
let candidate_path = Path::new(candidate);
if candidate_path.components().count() > 1 {
return absolute_existing_path(candidate_path);
}
let mut guard_roots: Vec = Vec::new();
if let Some(current_directory) = env::current_dir().ok() {
guard_roots.push(current_directory);
}
if let Some(workspace) = workspace {
guard_roots.push(workspace.to_path_buf());
}
let from_path = env::var_os("PATH")
.into_iter()
.flat_map(|value| env::split_paths(&value).collect::>())
.find_map(|directory| {
let candidate_path = directory.join(candidate);
if guard_roots
.iter()
.any(|root| is_current_directory_executable(&candidate_path, root))
{
return None;
}
absolute_existing_path(&candidate_path)
});
from_path.or_else(|| {
find_standard_windows_executable(candidate, guard_roots.first().map(PathBuf::as_path))
})
}
#[cfg(windows)]
fn find_standard_windows_executable(candidate: &str, cwd: Option<&Path>) -> Option {
let program_files = env::var_os("ProgramFiles").map(PathBuf::from);
let system_root = env::var_os("SystemRoot").map(PathBuf::from);
let comspec = env::var_os("COMSPEC").map(PathBuf::from);
standard_windows_executable_candidates(
candidate,
program_files.as_deref(),
system_root.as_deref(),
comspec.as_deref(),
)
.into_iter()
.find_map(|candidate_path| {
if cwd.is_some_and(|cwd| is_current_directory_executable(&candidate_path, cwd)) {
return None;
}
absolute_existing_path(&candidate_path)
})
}
#[cfg(not(windows))]
fn find_standard_windows_executable(_candidate: &str, _cwd: Option<&Path>) -> Option {
None
}
#[cfg(windows)]
fn standard_windows_executable_candidates(
candidate: &str,
program_files: Option<&Path>,
system_root: Option<&Path>,
comspec: Option<&Path>,
) -> Vec {
let mut candidates = Vec::new();
match candidate.to_ascii_lowercase().as_str() {
"pwsh" | "pwsh.exe" => {
if let Some(program_files) = program_files {
candidates.push(program_files.join("PowerShell").join("7").join("pwsh.exe"));
}
candidates.push(PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"));
}
"powershell" | "powershell.exe" => {
if let Some(system_root) = system_root {
candidates.push(
system_root
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe"),
);
}
candidates.push(PathBuf::from(
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
));
}
"cmd" | "cmd.exe" => {
if let Some(comspec) = comspec {
candidates.push(comspec.to_path_buf());
}
if let Some(system_root) = system_root {
candidates.push(system_root.join("System32").join("cmd.exe"));
}
candidates.push(PathBuf::from(r"C:\Windows\System32\cmd.exe"));
}
_ => {}
}
candidates
}
/// Resolve a bare child-process name to an absolute executable path before a
/// broker clears the child environment. On Windows this also handles
/// `.cmd`/`.bat` launchers exposed through `PATHEXT` (for example npm's
/// `npx.cmd`).
pub fn resolve_program(program: &str) -> Option {
resolve_program_in(program, None)
}
/// Like [`resolve_program`], with the workspace root added to the shadowing guard.
pub fn resolve_program_in(program: &str, workspace: Option<&Path>) -> Option {
if program.contains('/') || program.contains('\\') {
return None;
}
let resolved = find_executable_in(program, workspace);
#[cfg(windows)]
{
resolved.or_else(|| {
env::var_os("PATHEXT")
.into_iter()
.flat_map(|value| {
value
.to_string_lossy()
.split(';')
.map(str::to_owned)
.collect::>()
})
.map(|extension| format!("{program}{extension}"))
.find_map(|candidate| find_executable_in(&candidate, workspace))
})
}
#[cfg(not(windows))]
{
resolved
}
}
#[cfg(all(test, windows))]
fn plan_program(
program: &str,
is_windows: bool,
resolve: impl Fn(&str) -> Option,
) -> Option {
if !is_windows || program.contains('/') || program.contains('\\') {
return None;
}
resolve(program)
}
fn is_current_directory_executable(path: &Path, cwd: &Path) -> bool {
let candidate = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let current = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
#[cfg(windows)]
{
let candidate = candidate
.to_string_lossy()
.replace('/', "\\")
.to_ascii_lowercase();
let current = current
.to_string_lossy()
.replace('/', "\\")
.to_ascii_lowercase();
return candidate == current || candidate.starts_with(&format!("{current}\\"));
}
#[cfg(not(windows))]
{
candidate.starts_with(¤t)
}
}
fn absolute_existing_path(path: &Path) -> Option {
if !path.is_file() {
return None;
}
// Keep the path that was found instead of its canonical target. BusyBox-style multicall
// binaries pick their applet from argv[0], so canonicalising `/bin/sh` (a symlink to
// `/bin/busybox`) made every shell command run as `/bin/busybox -c …` and fail with
// `-c: applet not found` (exit 127) — issue #70. The kernel still resolves the symlink at
// exec time and argv[0] keeps the invoked name.
if path.is_absolute() {
Some(path.to_path_buf())
} else {
env::current_dir().ok().map(|cwd| cwd.join(path))
}
}
#[cfg(all(test, not(windows)))]
mod multicall_shell_tests {
use super::absolute_existing_path;
use std::os::unix::fs::{PermissionsExt, symlink};
#[test]
fn symlinked_shell_keeps_its_invoked_name() {
let dir = tempfile::tempdir().unwrap();
let multicall = dir.path().join("busybox");
std::fs::write(&multicall, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&multicall, std::fs::Permissions::from_mode(0o755)).unwrap();
let sh = dir.path().join("sh");
symlink(&multicall, &sh).unwrap();
// BusyBox decides its applet from argv[0]; resolving `/bin/sh` to `/bin/busybox`
// turned `-c` into an applet lookup (issue #70).
assert_eq!(absolute_existing_path(&sh), Some(sh.clone()));
assert_ne!(absolute_existing_path(&sh), Some(multicall));
}
}
#[cfg(all(test, not(windows)))]
mod workspace_guard_tests {
use super::{find_executable_in, resolve_program_in};
#[test]
fn workspace_executable_is_not_resolved_even_when_the_process_cwd_differs() {
let workspace = tempfile::tempdir().unwrap();
let bin = workspace.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let shim = bin.join("orca-guard-probe");
std::fs::write(&shim, "#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let previous = std::env::var_os("PATH").unwrap_or_default();
let mut entries = vec![bin.clone()];
entries.extend(std::env::split_paths(&previous));
let patched_path = std::env::join_paths(entries).unwrap();
// SAFETY: the variable is restored before the test returns.
unsafe { std::env::set_var("PATH", &patched_path) };
let guarded = find_executable_in("orca-guard-probe", Some(workspace.path()));
let unguarded = find_executable_in("orca-guard-probe", None);
unsafe { std::env::set_var("PATH", &previous) };
assert!(
guarded.is_none(),
"an executable inside the configured workspace must be rejected"
);
assert!(
unguarded.is_some(),
"the guard is scoped to the workspace root and the process cwd"
);
assert!(resolve_program_in("orca-guard-probe", Some(workspace.path())).is_none());
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::{
is_current_directory_executable, plan_program, standard_windows_executable_candidates,
};
use std::path::Path;
#[test]
fn shell_lookup_rejects_current_directory_and_descendants() {
let cwd = Path::new(r"C:\Work\repo");
assert!(is_current_directory_executable(
Path::new(r"C:\Work\repo\pwsh.exe"),
cwd,
));
assert!(is_current_directory_executable(
Path::new(r"C:\Work\repo\tools\cmd.exe"),
cwd,
));
assert!(!is_current_directory_executable(
Path::new(r"C:\Windows\System32\cmd.exe"),
cwd,
));
}
#[test]
fn bare_windows_launcher_can_be_resolved_without_touching_absolute_paths() {
let resolved = plan_program("npx", true, |program| {
assert_eq!(program, "npx");
Some(Path::new(r"C:\Node\npx.cmd").to_path_buf())
});
assert_eq!(resolved, Some(Path::new(r"C:\Node\npx.cmd").to_path_buf()));
assert_eq!(
plan_program(r"C:\Node\npx.cmd", true, |_| {
panic!("absolute launcher paths must not be resolved")
}),
None,
);
}
#[test]
fn native_shell_fallbacks_use_standard_windows_install_roots() {
let program_files = Path::new(r"D:\Program Files");
let system_root = Path::new(r"D:\Windows");
let comspec = Path::new(r"D:\Windows\System32\cmd.exe");
assert_eq!(
standard_windows_executable_candidates(
"pwsh.exe",
Some(program_files),
Some(system_root),
Some(comspec),
)[0],
Path::new(r"D:\Program Files\PowerShell\7\pwsh.exe")
);
assert_eq!(
standard_windows_executable_candidates(
"powershell.exe",
Some(program_files),
Some(system_root),
Some(comspec),
)[0],
Path::new(r"D:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")
);
assert_eq!(
standard_windows_executable_candidates(
"cmd.exe",
Some(program_files),
Some(system_root),
Some(comspec),
)[0],
comspec
);
}
}
#[cfg(all(test, unix))]
mod unix_tests {
use super::resolve_program;
#[test]
fn bare_program_resolves_to_an_absolute_path_for_cleared_child_environments() {
let resolved = resolve_program("sh").expect("the POSIX shell must be discoverable");
assert!(resolved.is_absolute());
assert!(resolved.is_file());
}
}