use std::cell::Cell;
use anyhow::Result;
use portable_pty::{Child, CommandBuilder, MasterPty, SlavePty};
pub type PaneChild = Box<dyn Child + Send + Sync>;
thread_local! {
static BATCHES: Cell<usize> = const { Cell::new(0) };
}
pub struct Batch(());
pub fn batch() -> Batch {
BATCHES.with(|b| b.set(b.get() + 1));
Batch(())
}
impl Drop for Batch {
fn drop(&mut self) {
BATCHES.with(|b| b.set(b.get().saturating_sub(1)));
}
}
pub fn batching() -> bool {
BATCHES.with(|b| b.get() > 0)
}
pub struct Launch {
how: How,
slave: Box<dyn SlavePty + Send>,
}
enum How {
Spawn(native::Prepared),
Portable(CommandBuilder),
}
pub(super) fn prepare(master: &dyn MasterPty, slave: Box<dyn SlavePty + Send>, cmd: CommandBuilder) -> Result<Launch> {
if let Some(tty) = master.tty_name().filter(|_| native::environment_is_text()) {
return Ok(Launch { how: How::Spawn(native::Prepared::new(&cmd, &tty)?), slave });
}
Ok(Launch { how: How::Portable(cmd), slave })
}
impl Launch {
pub fn run(self) -> Result<PaneChild> {
let child = match self.how {
#[cfg(target_os = "linux")]
How::Spawn(prepared) => Box::new(prepared.spawn()?) as PaneChild,
#[cfg(not(target_os = "linux"))]
How::Spawn(prepared) => Box::new(prepared.fork()?) as PaneChild,
How::Portable(cmd) => self.slave.spawn_command(cmd)?,
};
drop(self.slave);
Ok(child)
}
}
mod native {
use std::ffi::{CString, OsStr, OsString};
use std::os::unix::ffi::OsStrExt;
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, bail};
use portable_pty::CommandBuilder;
#[cfg(target_os = "linux")]
use portable_pty::{Child, ChildKiller, ExitStatus};
pub(super) fn find_program(exe: &OsStr, dir: &Path, path: Option<&OsStr>) -> Result<PathBuf> {
let exe_path = Path::new(exe);
if exe_path.is_relative() {
if matches!(exe_path.components().next(), Some(Component::CurDir | Component::ParentDir)) {
let abs = dir.join(exe_path);
if abs.is_dir() {
bail!("Unable to spawn {} because it is a directory", abs.display());
} else if access(&abs, libc::X_OK) {
return Ok(abs);
} else if access(&abs, libc::F_OK) {
bail!("Unable to spawn {} because it is not executable", abs.display());
}
bail!("Unable to spawn {} because it does not exist", abs.display());
}
let mut errors = Vec::new();
match path {
Some(path) => {
for entry in std::env::split_paths(path) {
let candidate = dir.join(entry).join(exe);
if candidate.is_dir() {
errors.push(format!("{} exists but is a directory", candidate.display()));
} else if access(&candidate, libc::X_OK) {
return Ok(candidate);
} else if access(&candidate, libc::F_OK) {
errors.push(format!("{} exists but is not executable", candidate.display()));
}
}
errors.push(format!("No viable candidates found in PATH {path:?}"));
}
None => errors.push("Unable to resolve the PATH".to_string()),
}
bail!("Unable to spawn {} because:\n{}", exe_path.display(), errors.join(".\n"));
}
if exe_path.is_dir() {
bail!("Unable to spawn {} because it is a directory", exe_path.display());
}
if !access(exe_path, libc::X_OK) {
if access(exe_path, libc::F_OK) {
bail!("Unable to spawn {} because it is not executable", exe_path.display());
}
bail!("Unable to spawn {} because it doesn't exist on the filesystem", exe_path.display());
}
Ok(exe_path.to_path_buf())
}
fn access(path: &Path, mode: libc::c_int) -> bool {
let Ok(c) = CString::new(path.as_os_str().as_bytes()) else { return false };
unsafe { libc::access(c.as_ptr(), mode) == 0 }
}
pub(super) fn environment_is_text() -> bool {
static TEXT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*TEXT.get_or_init(|| std::env::vars_os().all(|(k, v)| k.to_str().is_some() && v.to_str().is_some()))
}
fn passwd_shell() -> OsString {
let shell = unsafe {
let ent = libc::getpwuid(libc::getuid());
(!ent.is_null() && !(*ent).pw_shell.is_null())
.then(|| OsStr::from_bytes(std::ffi::CStr::from_ptr((*ent).pw_shell).to_bytes()).to_owned())
};
shell.filter(|s| access(Path::new(s), libc::X_OK)).unwrap_or_else(|| "/bin/sh".into())
}
fn cstring(s: &OsStr) -> Result<CString> {
CString::new(s.as_bytes()).with_context(|| format!("{s:?} holds a NUL byte"))
}
pub(super) struct Prepared {
program: CString,
argv: Vec<CString>,
env: Vec<CString>,
dir: CString,
tty: CString,
#[cfg(target_os = "linux")]
script: Vec<CString>,
}
impl Prepared {
pub(super) fn new(cmd: &CommandBuilder, tty: &Path) -> Result<Prepared> {
let argv = cmd.get_argv();
let exe = argv.first().context("no program to run")?;
let home =
cmd.get_env("HOME").map(PathBuf::from).or_else(dirs::home_dir).unwrap_or_else(|| PathBuf::from("/"));
let dir = cmd.get_cwd().map(PathBuf::from).filter(|d| d.is_dir()).unwrap_or(home);
let program = find_program(exe, &dir, cmd.get_env("PATH"))?;
let mut env: Vec<CString> = Vec::new();
let mut shell = false;
for (k, v) in cmd.iter_full_env_as_str() {
shell |= k == "SHELL";
env.push(CString::new(format!("{k}={v}")).with_context(|| format!("${k} holds a NUL byte"))?);
}
if !shell {
let mut entry = b"SHELL=".to_vec();
entry.extend_from_slice(passwd_shell().as_bytes());
env.push(CString::new(entry).context("the login shell's name holds a NUL byte")?);
}
let program = cstring(program.as_os_str())?;
let argv: Vec<CString> = argv.iter().map(|a| cstring(a)).collect::<Result<_>>()?;
#[cfg(target_os = "linux")]
let script = {
let mut script = vec![CString::new("/bin/sh").expect("no NUL"), program.clone()];
script.extend(argv.iter().skip(1).cloned());
script
};
Ok(Prepared {
program,
argv,
env,
dir: cstring(dir.as_os_str())?,
tty: cstring(tty.as_os_str())?,
#[cfg(target_os = "linux")]
script,
})
}
#[cfg(any(not(target_os = "linux"), test))]
pub(super) fn fork(self) -> Result<std::process::Child> {
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::process::CommandExt;
let os = |c: &CString| OsStr::from_bytes(c.as_bytes()).to_owned();
let tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOCTTY)
.open(os(&self.tty))
.context("opening the pane's terminal")?;
let mut cmd = std::process::Command::new(os(&self.program));
cmd.arg0(os(&self.argv[0])).args(self.argv[1..].iter().map(os));
cmd.env_clear().envs(self.env.iter().map(|entry| {
let entry = entry.as_bytes();
let (k, v) = entry.split_at(entry.iter().position(|b| *b == b'=').unwrap_or(entry.len()));
(OsStr::from_bytes(k).to_owned(), OsStr::from_bytes(v.get(1..).unwrap_or_default()).to_owned())
}));
cmd.current_dir(os(&self.dir));
cmd.stdin(tty.try_clone()?).stdout(tty.try_clone()?).stderr(tty);
let last = super::open_descriptors().into_iter().max().unwrap_or(1024);
unsafe {
cmd.pre_exec(move || {
for sig in 1..32 {
if sig != libc::SIGKILL && sig != libc::SIGSTOP {
libc::signal(sig, libc::SIG_DFL);
}
}
let mut none: libc::sigset_t = std::mem::zeroed();
libc::sigemptyset(&mut none);
libc::sigprocmask(libc::SIG_SETMASK, &none, std::ptr::null_mut());
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
for fd in 3..=last {
let flags = libc::fcntl(fd, libc::F_GETFD);
if flags >= 0 && flags & libc::FD_CLOEXEC == 0 {
libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
}
}
Ok(())
});
}
Ok(cmd.spawn()?)
}
#[cfg(target_os = "linux")]
pub(super) fn spawn(self) -> Result<Forked> {
match self.spawn_program(&self.program, &self.argv) {
Err(e) if e.raw_os_error() == Some(libc::ENOEXEC) => {
Ok(self.spawn_program(&self.script[0], &self.script)?)
}
other => Ok(other?),
}
}
#[cfg(target_os = "linux")]
fn spawn_program(&self, program: &CString, argv: &[CString]) -> std::io::Result<Forked> {
let pointers = |s: &[CString]| -> Vec<*mut libc::c_char> {
s.iter().map(|s| s.as_ptr().cast_mut()).chain(std::iter::once(std::ptr::null_mut())).collect()
};
let (argv, envp) = (pointers(argv), pointers(&self.env));
let check = |r: libc::c_int| if r == 0 { Ok(()) } else { Err(std::io::Error::from_raw_os_error(r)) };
unsafe {
let mut attr: libc::posix_spawnattr_t = std::mem::zeroed();
let mut actions: libc::posix_spawn_file_actions_t = std::mem::zeroed();
check(libc::posix_spawnattr_init(&mut attr))?;
if let Err(e) = check(libc::posix_spawn_file_actions_init(&mut actions)) {
libc::posix_spawnattr_destroy(&mut attr);
return Err(e);
}
let result = (|| {
let mut all: libc::sigset_t = std::mem::zeroed();
let mut none: libc::sigset_t = std::mem::zeroed();
libc::sigfillset(&mut all);
libc::sigemptyset(&mut none);
check(libc::posix_spawnattr_setsigdefault(&mut attr, &all))?;
check(libc::posix_spawnattr_setsigmask(&mut attr, &none))?;
let flags = libc::POSIX_SPAWN_SETSID
| libc::POSIX_SPAWN_SETSIGDEF as libc::c_short
| libc::POSIX_SPAWN_SETSIGMASK as libc::c_short;
check(libc::posix_spawnattr_setflags(&mut attr, flags))?;
check(libc::posix_spawn_file_actions_addopen(&mut actions, 0, self.tty.as_ptr(), libc::O_RDWR, 0))?;
check(libc::posix_spawn_file_actions_adddup2(&mut actions, 0, 1))?;
check(libc::posix_spawn_file_actions_adddup2(&mut actions, 0, 2))?;
check(libc::posix_spawn_file_actions_addchdir_np(&mut actions, self.dir.as_ptr()))?;
#[cfg(target_env = "gnu")]
check(libc::posix_spawn_file_actions_addclosefrom_np(&mut actions, 3))?;
let mut pid: libc::pid_t = 0;
check(libc::posix_spawn(
&mut pid,
program.as_ptr(),
&actions,
&attr,
argv.as_ptr(),
envp.as_ptr(),
))?;
Ok(Forked { pid: pid as u32, status: None })
})();
libc::posix_spawn_file_actions_destroy(&mut actions);
libc::posix_spawnattr_destroy(&mut attr);
result
}
}
}
#[cfg(target_os = "linux")]
#[derive(Debug)]
pub(super) struct Forked {
pid: u32,
status: Option<ExitStatus>,
}
#[cfg(target_os = "linux")]
fn exit_status(status: libc::c_int) -> ExitStatus {
if libc::WIFSIGNALED(status) {
ExitStatus::with_signal(&format!("signal {}", libc::WTERMSIG(status)))
} else {
ExitStatus::with_exit_code(libc::WEXITSTATUS(status) as u32)
}
}
#[cfg(target_os = "linux")]
impl Child for Forked {
fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
if let Some(s) = &self.status {
return Ok(Some(s.clone()));
}
let mut status = 0;
match unsafe { libc::waitpid(self.pid as libc::pid_t, &mut status, libc::WNOHANG) } {
0 => Ok(None),
n if n < 0 => Err(std::io::Error::last_os_error()),
_ => {
self.status = Some(exit_status(status));
Ok(self.status.clone())
}
}
}
fn wait(&mut self) -> std::io::Result<ExitStatus> {
if let Some(s) = &self.status {
return Ok(s.clone());
}
loop {
let mut status = 0;
if unsafe { libc::waitpid(self.pid as libc::pid_t, &mut status, 0) } >= 0 {
let s = exit_status(status);
self.status = Some(s.clone());
return Ok(s);
}
let e = std::io::Error::last_os_error();
if e.kind() != std::io::ErrorKind::Interrupted {
return Err(e);
}
}
}
fn process_id(&self) -> Option<u32> {
Some(self.pid)
}
}
#[cfg(target_os = "linux")]
impl ChildKiller for Forked {
fn kill(&mut self) -> std::io::Result<()> {
if self.try_wait()?.is_some() {
return Ok(());
}
if unsafe { libc::kill(self.pid as libc::pid_t, libc::SIGHUP) } != 0 {
return Err(std::io::Error::last_os_error());
}
for attempt in 0..5 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
if self.try_wait()?.is_some() {
return Ok(());
}
}
unsafe { libc::kill(self.pid as libc::pid_t, libc::SIGKILL) };
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(Killer(self.pid))
}
}
#[cfg(target_os = "linux")]
#[derive(Debug)]
struct Killer(u32);
#[cfg(target_os = "linux")]
impl ChildKiller for Killer {
fn kill(&mut self) -> std::io::Result<()> {
if unsafe { libc::kill(self.0 as libc::pid_t, libc::SIGHUP) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(Killer(self.0))
}
}
}
pub fn reserve_descriptors() {
unsafe {
let mut lim: libc::rlimit = std::mem::zeroed();
if libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) != 0 {
return;
}
let top = lim.rlim_cur.min(1024) as libc::c_int - 1;
if top > 64 && libc::fcntl(top, libc::F_GETFD) == -1 && libc::dup2(0, top) == top {
libc::close(top);
}
}
}
pub fn close_inherited() {
for fd in open_descriptors().into_iter().filter(|fd| *fd > 2) {
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFD);
if flags >= 0 {
libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
}
}
}
}
fn open_descriptors() -> Vec<libc::c_int> {
#[cfg(target_os = "linux")]
let dir = "/proc/self/fd";
#[cfg(not(target_os = "linux"))]
let dir = "/dev/fd";
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
entries.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect()
}
#[cfg(test)]
mod tests {
use std::io::Read;
use std::path::PathBuf;
use std::time::Duration;
use portable_pty::{PtySize, native_pty_system};
use super::*;
fn temp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("kraal-spawn-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.canonicalize().unwrap()
}
fn pty() -> portable_pty::PtyPair {
native_pty_system().openpty(PtySize { rows: 50, cols: 200, pixel_width: 0, pixel_height: 0 }).unwrap()
}
#[derive(Clone, Copy, Debug)]
enum Way {
Here,
Fork,
Portable,
}
const OURS: [Way; 2] = [Way::Here, Way::Fork];
fn start(
master: &dyn MasterPty,
slave: Box<dyn SlavePty + Send>,
cmd: CommandBuilder,
way: Way,
) -> Result<PaneChild> {
match way {
Way::Here => prepare(master, slave, cmd)?.run(),
Way::Fork => match prepare(master, slave, cmd)?.how {
How::Spawn(prepared) => Ok(Box::new(prepared.fork()?)),
How::Portable(_) => panic!("not started here"),
},
Way::Portable => slave.spawn_command(cmd),
}
}
fn started(master: &dyn MasterPty, slave: Box<dyn SlavePty + Send>, cmd: CommandBuilder, way: Way) -> PaneChild {
start(master, slave, cmd, way).unwrap()
}
fn run(cmd: CommandBuilder, way: Way) -> (String, u32) {
let pair = pty();
let mut reader = pair.master.try_clone_reader().unwrap();
let (tx, rx) = crossbeam_channel::bounded(1);
std::thread::spawn(move || {
let mut out = Vec::new();
let _ = reader.read_to_end(&mut out);
let _ = tx.send(out);
});
let mut child = started(&*pair.master, pair.slave, cmd, way);
let code = child.wait().unwrap().exit_code();
let out = rx.recv_timeout(Duration::from_secs(10)).unwrap();
drop(pair.master);
(String::from_utf8_lossy(&out).replace('\r', ""), code)
}
#[test]
fn a_program_starts_as_portable_pty_starts_it() {
let dir = temp("same");
let cmd = || {
let mut c = CommandBuilder::new("sh");
c.args(["-c", "env | LC_ALL=C sort; pwd -P; echo \"$0 $1\"; exit 3", "zero", "one"]);
c.cwd(&dir);
c.env("KRAAL_SPAWN_TEST", "yes");
c.env_remove("KRAAL_SPAWN_NOT_THERE");
c
};
let (portable, code) = run(cmd(), Way::Portable);
assert_eq!(code, 3);
assert!(portable.contains("KRAAL_SPAWN_TEST=yes\n"), "{portable}");
assert!(portable.contains(&format!("{}\nzero one\n", dir.display())), "{portable}");
for way in OURS {
assert_eq!(
run(cmd(), way),
(portable.clone(), 3),
"{way:?}: the same environment, directory and arguments"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_program_gets_its_terminal_and_session() {
for way in OURS {
let pair = pty();
let mut cmd = CommandBuilder::new("sleep");
cmd.arg("5");
let mut child = started(&*pair.master, pair.slave, cmd, way);
let pid = child.process_id().unwrap() as libc::pid_t;
assert_eq!(unsafe { libc::getsid(pid) }, pid, "{way:?}");
assert_eq!(pair.master.process_group_leader(), Some(pid), "{way:?}");
child.kill().unwrap();
let _ = child.wait();
}
}
#[test]
fn a_program_that_cannot_be_found_or_run_is_said() {
use std::ffi::OsStr;
use std::path::Path;
use super::native::find_program;
let pair = pty();
let err = prepare(&*pair.master, pair.slave, CommandBuilder::new("no-such-program-kraal")).err();
let err = format!("{:#}", err.expect("not found"));
assert!(err.contains("Unable to spawn no-such-program-kraal"), "{err}");
let dir = temp("find");
assert!(find_program(OsStr::new("./x"), &dir, None).is_err());
crate::testing::write_script(&dir.join("x"), "#!/bin/sh\n");
assert_eq!(find_program(OsStr::new("./x"), &dir, None).unwrap(), dir.join("./x"));
let found = find_program(OsStr::new("x"), Path::new("/"), Some(dir.as_os_str())).unwrap();
assert_eq!(found, dir.join("x"));
crate::testing::write_script(&dir.join("broken"), "#!/no/such/interpreter\n");
crate::testing::write_script(&dir.join("plain"), "echo from-plain-script\n");
for way in OURS {
let mut cmd = CommandBuilder::new("./broken");
cmd.cwd(&dir);
let pair = pty();
let err = start(&*pair.master, pair.slave, cmd, way).map(|_| ()).expect_err("cannot run");
assert!(format!("{err:#}").contains("No such file or directory"), "{way:?}: {err:#}");
let mut cmd = CommandBuilder::new("./plain");
cmd.cwd(&dir);
let (out, code) = run(cmd, way);
assert_eq!(code, 0, "{way:?}");
assert!(out.contains("from-plain-script"), "{way:?}: {out}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_batch_holds_on_this_thread_only() {
assert!(!batching());
let outer = batch();
let inner = batch();
assert!(batching());
assert!(!std::thread::spawn(batching).join().unwrap(), "not another thread's");
drop(inner);
assert!(batching(), "the outer batch still holds");
drop(outer);
assert!(!batching());
}
#[test]
fn nothing_of_the_server_stays_open_in_the_program() {
let file = std::fs::File::open("/dev/null").unwrap();
let fd = unsafe { libc::fcntl(std::os::fd::AsRawFd::as_raw_fd(&file), libc::F_DUPFD, 200) };
assert!(fd >= 200);
for way in OURS {
if matches!(way, Way::Here) && cfg!(all(target_os = "linux", not(target_env = "gnu"))) {
continue;
}
let mut cmd = CommandBuilder::new("sh");
cmd.args(["-c", &format!("[ -e /dev/fd/{fd} ] && echo leaked || echo closed")]);
let (out, _) = run(cmd, way);
assert!(out.contains("closed"), "{way:?}: {out}");
}
unsafe { libc::close(fd) };
}
}