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()); } }