use std::collections::HashSet;
use std::ffi::CString;
use std::fs::File;
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use super::{ExternalCommand, FileDescriptor, SignalDispositionGuard, SpawnStdio};
fn cstring_arg(arg: &str) -> Result<CString, std::io::Error> {
CString::new(arg)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "argument contains NUL"))
}
fn cstring_env(key: &str, value: &str) -> Result<CString, std::io::Error> {
CString::new(format!("{key}={value}")).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"environment entry contains NUL",
)
})
}
fn cstring_path(path: &Path) -> Result<CString, std::io::Error> {
CString::new(path.as_os_str().as_bytes())
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL"))
}
fn resolve_exec_candidate(cwd: &Path, path: impl AsRef<Path>) -> PathBuf {
let path = path.as_ref();
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
fn executable_candidate(path: PathBuf) -> Result<PathBuf, std::io::Error> {
let metadata = match std::fs::metadata(&path) {
Ok(metadata) => metadata,
Err(err) => {
return Err(std::io::Error::new(err.kind(), err.to_string()));
}
};
if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("{}: Permission denied", path.display()),
));
}
Ok(path)
}
fn exec_search_path<'a>(env: &'a [(String, String)]) -> &'a str {
env.iter()
.find_map(|(key, value)| (key == "PATH").then_some(value.as_str()))
.unwrap_or("/usr/bin:/bin")
}
fn resolve_exec_program(
program: &str,
env: &[(String, String)],
cwd: &Path,
) -> Result<PathBuf, std::io::Error> {
if program.contains('/') {
return executable_candidate(resolve_exec_candidate(cwd, program));
}
let mut permission_denied = None;
for dir in exec_search_path(env).split(':') {
let candidate = if dir.is_empty() {
resolve_exec_candidate(cwd, program)
} else {
resolve_exec_candidate(cwd, Path::new(dir).join(program))
};
match executable_candidate(candidate) {
Ok(candidate) => return Ok(candidate),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
permission_denied.get_or_insert(err);
}
Err(err) => return Err(err),
}
}
if let Some(err) = permission_denied {
return Err(err);
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{program}: command not found"),
))
}
struct ExecCwdGuard {
saved: File,
active: bool,
}
impl ExecCwdGuard {
fn enter(cwd: &Path) -> Result<Self, std::io::Error> {
let saved = File::open(".")?;
let c_cwd = cstring_path(cwd)?;
if unsafe { libc::chdir(c_cwd.as_ptr()) } < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(Self {
saved,
active: true,
})
}
fn restore(mut self) -> Result<(), std::io::Error> {
self.restore_inner()
}
fn restore_inner(&mut self) -> Result<(), std::io::Error> {
if !self.active {
return Ok(());
}
if unsafe { libc::fchdir(self.saved.as_raw_fd()) } < 0 {
return Err(std::io::Error::last_os_error());
}
self.active = false;
Ok(())
}
}
impl Drop for ExecCwdGuard {
fn drop(&mut self) {
let _ = self.restore_inner();
}
}
pub(crate) fn exec_replace(
program: &str,
argv: &[String],
env: &[(String, String)],
cwd: &Path,
) -> Result<(), std::io::Error> {
let program = resolve_exec_program(program, env, cwd)?;
let c_args: Vec<CString> = argv
.iter()
.map(|a| cstring_arg(a))
.collect::<Result<_, _>>()?;
let c_ptrs: Vec<*const libc::c_char> = c_args
.iter()
.map(|a| a.as_ptr())
.chain(std::iter::once(std::ptr::null()))
.collect();
let c_env: Vec<CString> = env
.iter()
.map(|(k, v)| cstring_env(k, v))
.collect::<Result<_, _>>()?;
let c_env_ptrs: Vec<*const libc::c_char> = c_env
.iter()
.map(|e| e.as_ptr())
.chain(std::iter::once(std::ptr::null()))
.collect();
let c_prog = cstring_path(&program)?;
let cwd_guard = ExecCwdGuard::enter(cwd)?;
unsafe { libc::execve(c_prog.as_ptr(), c_ptrs.as_ptr(), c_env_ptrs.as_ptr()) };
let err = std::io::Error::last_os_error();
cwd_guard.restore()?;
Err(err)
}
struct SavedFd {
target: FileDescriptor,
saved: Option<FileDescriptor>,
}
struct SavedFdFlags {
fd: FileDescriptor,
flags: libc::c_int,
}
impl SavedFd {
fn capture(target: FileDescriptor) -> Result<Self, std::io::Error> {
let saved = match target.dup() {
Ok(fd) => Some(fd),
Err(err) if err.raw_os_error() == Some(libc::EBADF) => None,
Err(err) => return Err(err),
};
Ok(Self { target, saved })
}
}
struct AppliedChildPlan {
saved_fds: Vec<SavedFd>,
saved_flags: Vec<SavedFdFlags>,
}
fn fd_flags(fd: FileDescriptor) -> Result<libc::c_int, std::io::Error> {
let flags = unsafe { libc::fcntl(fd.into_raw_fd(), libc::F_GETFD) };
if flags < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(flags)
}
}
fn set_fd_flags(fd: FileDescriptor, flags: libc::c_int) -> Result<(), std::io::Error> {
if unsafe { libc::fcntl(fd.into_raw_fd(), libc::F_SETFD, flags) } < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
fn clear_cloexec_for_child_fd(fd: FileDescriptor) -> Result<Option<SavedFdFlags>, std::io::Error> {
let flags = match fd_flags(fd) {
Ok(flags) => flags,
Err(err) if err.raw_os_error() == Some(libc::EBADF) => return Ok(None),
Err(err) => return Err(err),
};
if (flags & libc::FD_CLOEXEC) == 0 {
return Ok(None);
}
set_fd_flags(fd, flags & !libc::FD_CLOEXEC)?;
Ok(Some(SavedFdFlags { fd, flags }))
}
fn restore_fds(saved: Vec<SavedFd>) {
for entry in saved.into_iter().rev() {
if let Some(saved) = entry.saved {
let _ = saved.dup2(entry.target);
saved.close();
} else {
entry.target.close();
}
}
}
fn restore_fd_flags(saved: Vec<SavedFdFlags>) {
for entry in saved.into_iter().rev() {
let _ = set_fd_flags(entry.fd, entry.flags);
}
}
impl AppliedChildPlan {
fn restore(self) {
restore_fds(self.saved_fds);
restore_fd_flags(self.saved_flags);
}
}
fn shell_script_fallback_command(command: &ExternalCommand) -> ExternalCommand {
let mut argv = Vec::with_capacity(command.argv.len() + 1);
argv.push("sh".to_string());
argv.push(command.program.clone());
argv.extend(command.argv.iter().skip(1).cloned());
ExternalCommand {
program: "/bin/sh".to_string(),
argv,
env: command.env.clone(),
cwd: command.cwd.clone(),
create_process_group: command.create_process_group,
join_process_group: command.join_process_group,
passed_fds: command.passed_fds.clone(),
signal_plan: command.signal_plan.clone(),
}
}
fn apply_child_fd_plan(
stdio: SpawnStdio,
close_fds: &[FileDescriptor],
command: &ExternalCommand,
) -> Result<AppliedChildPlan, std::io::Error> {
let mut explicit_open = HashSet::new();
for (from_fd, to_fd) in [
(stdio.stdin_fd, FileDescriptor::STDIN),
(stdio.stdout_fd, FileDescriptor::STDOUT),
(stdio.stderr_fd, FileDescriptor::STDERR),
] {
if from_fd.is_valid() {
explicit_open.insert(to_fd.as_i32());
}
}
for passed in &command.passed_fds {
if passed.parent_fd.is_valid() {
explicit_open.insert(passed.child_fd.as_i32());
}
}
let mut source_fds_to_close = Vec::new();
for from_fd in [stdio.stdin_fd, stdio.stdout_fd, stdio.stderr_fd] {
if from_fd.as_i32() > 2 && !explicit_open.contains(&from_fd.as_i32()) {
source_fds_to_close.push(from_fd);
}
}
for passed in &command.passed_fds {
let parent_fd = passed.parent_fd;
if parent_fd.as_i32() > 2 && !explicit_open.contains(&parent_fd.as_i32()) {
source_fds_to_close.push(parent_fd);
}
}
source_fds_to_close.sort_by_key(|fd| fd.as_i32());
source_fds_to_close.dedup();
let mut targets = vec![
FileDescriptor::STDIN,
FileDescriptor::STDOUT,
FileDescriptor::STDERR,
];
targets.extend(command.passed_fds.iter().map(|passed| passed.child_fd));
targets.extend(close_fds.iter().copied());
targets.extend(source_fds_to_close.iter().copied());
targets.sort_by_key(|fd| fd.as_i32());
targets.dedup();
let mut saved = Vec::new();
for target in targets {
saved.push(SavedFd::capture(target)?);
}
for (from_fd, to_fd) in [
(stdio.stdin_fd, FileDescriptor::STDIN),
(stdio.stdout_fd, FileDescriptor::STDOUT),
(stdio.stderr_fd, FileDescriptor::STDERR),
] {
if from_fd.is_valid() {
from_fd.dup2(to_fd)?;
} else {
to_fd.close();
}
}
for passed in &command.passed_fds {
if passed.parent_fd != passed.child_fd {
passed.parent_fd.dup2(passed.child_fd)?;
}
}
let mut saved_flags = Vec::new();
for fd in explicit_open.iter().copied() {
if let Some(saved) = clear_cloexec_for_child_fd(FileDescriptor::new(fd))? {
saved_flags.push(saved);
}
}
for &fd in close_fds {
if !explicit_open.contains(&fd.as_i32()) {
fd.close();
}
}
for fd in source_fds_to_close {
fd.close();
}
Ok(AppliedChildPlan {
saved_fds: saved,
saved_flags,
})
}
fn signal_plan_guard(command: &ExternalCommand) -> SignalDispositionGuard {
let mut guard = SignalDispositionGuard::new();
let mut seen = HashSet::new();
for (&sig, disposition) in command
.signal_plan
.default_signals
.iter()
.map(|sig| (sig, libc::SIG_DFL))
.chain(
command
.signal_plan
.ignored_signals
.iter()
.map(|sig| (sig, libc::SIG_IGN)),
)
{
if seen.insert(sig) {
guard.set(sig, disposition);
}
}
guard
}
pub(crate) fn exec_replace_command(
command: &ExternalCommand,
stdio: SpawnStdio,
close_fds: &[FileDescriptor],
) -> Result<(), std::io::Error> {
let applied_plan = apply_child_fd_plan(stdio, close_fds, command)?;
let _signal_guard = signal_plan_guard(command);
let result = match exec_replace(&command.program, &command.argv, &command.env, &command.cwd) {
Err(err) if err.raw_os_error() == Some(libc::ENOEXEC) => {
let fallback = shell_script_fallback_command(command);
exec_replace(
&fallback.program,
&fallback.argv,
&fallback.env,
&fallback.cwd,
)
}
result => result,
};
applied_plan.restore();
result
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_exec_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"mxsh-unix-exec-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp exec dir should be creatable");
dir
}
fn write_tool(path: &Path, mode: u32) {
std::fs::write(path, "#!/bin/sh\nexit 0\n").expect("tool should be writable");
let mut perms = std::fs::metadata(path)
.expect("tool metadata should be readable")
.permissions();
perms.set_mode(mode);
std::fs::set_permissions(path, perms).expect("tool permissions should be set");
}
#[test]
fn exec_resolution_continues_past_non_executable_path_entries() {
let base = temp_exec_dir("nonexec-shadow");
let nonexec_dir = base.join("nonexec");
let exec_dir = base.join("exec");
std::fs::create_dir_all(&nonexec_dir).expect("nonexec dir");
std::fs::create_dir_all(&exec_dir).expect("exec dir");
write_tool(&nonexec_dir.join("tool"), 0o644);
write_tool(&exec_dir.join("tool"), 0o755);
let env = vec![(
"PATH".to_string(),
format!("{}:{}", nonexec_dir.display(), exec_dir.display()),
)];
let resolved =
resolve_exec_program("tool", &env, Path::new("/")).expect("tool should resolve");
assert_eq!(resolved, exec_dir.join("tool"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn exec_resolution_rejects_non_executable_explicit_paths() {
let base = temp_exec_dir("explicit-nonexec");
let tool = base.join("tool");
write_tool(&tool, 0o644);
let err = resolve_exec_program(
tool.to_str().expect("tool path should be utf-8"),
&[],
Path::new("/"),
)
.expect_err("non-executable explicit path should fail");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
let _ = std::fs::remove_dir_all(base);
}
}