#![allow(unsafe_code)]
use nix::fcntl::{FcntlArg, OFlag, fcntl};
use nix::pty::{OpenptyResult, Winsize, openpty};
use nix::sys::signal::{self, Signal};
use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};
use nix::unistd::{ForkResult, Pid, fork, setsid};
use std::ffi::CString;
use std::os::unix::io::{AsRawFd, OwnedFd, RawFd};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PtyError {
#[error("failed to open PTY: {0}")]
OpenPty(#[source] nix::Error),
#[error("failed to fork: {0}")]
Fork(#[source] nix::Error),
#[error("failed to create session: {0}")]
Setsid(#[source] nix::Error),
#[error("failed to set controlling terminal: {0}")]
SetControllingTerminal(#[source] nix::Error),
#[error("failed to change directory: {0}")]
Chdir(#[source] std::io::Error),
#[error("failed to exec: {0}")]
Exec(#[source] nix::Error),
#[error("command is empty")]
EmptyCommand,
#[error("invalid command string: {0}")]
InvalidCommand(#[source] std::ffi::NulError),
#[error("failed to send signal: {0}")]
Signal(#[source] nix::Error),
#[error("failed to wait: {0}")]
Wait(#[source] nix::Error),
}
pub struct PtyProcess {
pub master: OwnedFd,
pub pid: Pid,
pub size: Winsize,
}
impl PtyProcess {
#[must_use]
pub fn master_fd(&self) -> RawFd {
self.master.as_raw_fd()
}
pub fn signal(&self, sig: Signal) -> Result<(), PtyError> {
signal::kill(self.pid, sig).map_err(PtyError::Signal)
}
pub fn try_wait(&self) -> Result<Option<i32>, PtyError> {
match waitpid(self.pid, Some(WaitPidFlag::WNOHANG)).map_err(PtyError::Wait)? {
WaitStatus::Exited(_, code) => Ok(Some(code)),
WaitStatus::Signaled(_, sig, _) => Ok(Some(128 + sig as i32)),
_ => Ok(None),
}
}
pub fn wait(&self) -> Result<i32, PtyError> {
match waitpid(self.pid, None).map_err(PtyError::Wait)? {
WaitStatus::Exited(_, code) => Ok(code),
WaitStatus::Signaled(_, sig, _) => Ok(128 + sig as i32),
status => {
tracing::warn!(?status, "unexpected wait status");
Ok(-1)
}
}
}
pub fn resize(&self, rows: u16, cols: u16) -> Result<(), PtyError> {
let winsize = Winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
unsafe {
let ret = libc::ioctl(self.master.as_raw_fd(), libc::TIOCSWINSZ, &winsize);
if ret < 0 {
return Err(PtyError::SetControllingTerminal(nix::Error::last()));
}
}
Ok(())
}
}
unsafe extern "C" {
static mut environ: *mut *mut libc::c_char;
}
const ESSENTIAL_ENV_VARS: &[&str] = &[
"PATH", "HOME", "USER", "TERM", "SHELL", "LANG", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", ];
#[derive(Debug, Default)]
pub struct SpawnEnv {
pub vars: Vec<(String, String)>,
}
pub fn spawn(cmd: &[String], rows: u16, cols: u16) -> Result<PtyProcess, PtyError> {
spawn_with_env(cmd, rows, cols, &SpawnEnv::default(), None)
}
pub fn spawn_with_env(
cmd: &[String],
rows: u16,
cols: u16,
env: &SpawnEnv,
cwd: Option<&str>,
) -> Result<PtyProcess, PtyError> {
if cmd.is_empty() {
return Err(PtyError::EmptyCommand);
}
let winsize = Winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
let explicit_keys: std::collections::HashSet<&str> =
env.vars.iter().map(|(k, _)| k.as_str()).collect();
let essential: Vec<(String, String)> = ESSENTIAL_ENV_VARS
.iter()
.filter(|k| !explicit_keys.contains(**k))
.filter_map(|k| std::env::var(k).ok().map(|v| (k.to_string(), v)))
.collect();
let envp: Vec<CString> = essential
.iter()
.chain(env.vars.iter())
.map(|(k, v)| CString::new(format!("{k}={v}")))
.collect::<Result<_, _>>()
.map_err(|_| PtyError::EmptyCommand)?;
let prog = CString::new(cmd[0].as_str()).map_err(|_| PtyError::EmptyCommand)?;
let args: Vec<CString> = cmd
.iter()
.map(|s| CString::new(s.as_str()))
.collect::<Result<_, _>>()
.map_err(|_| PtyError::EmptyCommand)?;
let cwd_c: Option<CString> = match cwd {
Some(dir) => Some(CString::new(dir).map_err(|_| PtyError::EmptyCommand)?),
None => None,
};
let argv_ptrs: Vec<*const libc::c_char> = args
.iter()
.map(|c| c.as_ptr())
.chain(std::iter::once(std::ptr::null()))
.collect();
let envp_ptrs: Vec<*const libc::c_char> = envp
.iter()
.map(|c| c.as_ptr())
.chain(std::iter::once(std::ptr::null()))
.collect();
let OpenptyResult { master, slave } = openpty(&winsize, None).map_err(PtyError::OpenPty)?;
match unsafe { fork() }.map_err(PtyError::Fork)? {
ForkResult::Parent { child } => {
drop(slave);
let flags = fcntl(&master, FcntlArg::F_GETFL).map_err(PtyError::OpenPty)?;
let mut flags = OFlag::from_bits_retain(flags);
flags.insert(OFlag::O_NONBLOCK);
fcntl(&master, FcntlArg::F_SETFL(flags)).map_err(PtyError::OpenPty)?;
Ok(PtyProcess {
master,
pid: child,
size: winsize,
})
}
ForkResult::Child => {
drop(master);
if setsid().is_err() {
unsafe { libc::_exit(1) };
}
unsafe {
if libc::ioctl(slave.as_raw_fd(), libc::TIOCSCTTY as _, 0) < 0 {
libc::_exit(1);
}
}
let slave_fd = slave.as_raw_fd();
unsafe {
if libc::dup2(slave_fd, libc::STDIN_FILENO) < 0
|| libc::dup2(slave_fd, libc::STDOUT_FILENO) < 0
|| libc::dup2(slave_fd, libc::STDERR_FILENO) < 0
{
libc::_exit(1);
}
}
if slave_fd > 2 {
drop(slave);
}
if let Some(dir) = &cwd_c {
unsafe {
if libc::chdir(dir.as_ptr()) < 0 {
libc::_exit(1);
}
}
}
unsafe {
environ = envp_ptrs.as_ptr().cast_mut().cast();
libc::execvp(prog.as_ptr(), argv_ptrs.as_ptr());
libc::_exit(127);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_spawn_echo() {
let pty = spawn(&["sh".into(), "-c".into(), "echo hello".into()], 24, 80).unwrap();
let exit_code = pty.wait().unwrap();
assert_eq!(exit_code, 0);
}
#[test]
fn test_spawn_exit_code() {
let pty = spawn(&["sh".into(), "-c".into(), "exit 42".into()], 24, 80).unwrap();
let exit_code = pty.wait().unwrap();
assert_eq!(exit_code, 42);
}
#[test]
fn test_spawn_empty_command() {
let result = spawn(&[], 24, 80);
assert!(matches!(result, Err(PtyError::EmptyCommand)));
}
fn drain(pty: &PtyProcess) -> String {
let mut out = Vec::new();
let mut buf = [0u8; 4096];
for _ in 0..400 {
match nix::unistd::read(&pty.master, &mut buf) {
Ok(0) => break,
Ok(n) => out.extend_from_slice(&buf[..n]),
Err(nix::errno::Errno::EAGAIN) => {
if pty.try_wait().ok().flatten().is_some() && !out.is_empty() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
Err(_) => break,
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[test]
fn explicit_env_vars_reach_the_child() {
let env = SpawnEnv {
vars: vec![("VESSEL_PROBE".into(), "probe-value".into())],
};
let pty = spawn_with_env(
&["sh".into(), "-c".into(), "echo $VESSEL_PROBE".into()],
24,
80,
&env,
None,
)
.unwrap();
let out = drain(&pty);
assert!(
out.contains("probe-value"),
"child did not see the explicit var: {out:?}"
);
}
#[test]
fn child_environment_is_otherwise_clean() {
unsafe { std::env::set_var("VESSEL_MUST_NOT_LEAK", "leaked") };
let pty = spawn(
&[
"sh".into(),
"-c".into(),
"echo [${VESSEL_MUST_NOT_LEAK:-absent}]".into(),
],
24,
80,
)
.unwrap();
let out = drain(&pty);
unsafe { std::env::remove_var("VESSEL_MUST_NOT_LEAK") };
assert!(
out.contains("[absent]"),
"parent variable leaked into the child: {out:?}"
);
}
#[test]
fn essential_vars_are_inherited() {
let pty = spawn(
&["sh".into(), "-c".into(), "echo [${HOME:-unset}]".into()],
24,
80,
)
.unwrap();
let out = drain(&pty);
assert!(
out.contains("/") && !out.contains("[unset]"),
"HOME should be inherited from the server: {out:?}"
);
}
#[test]
fn explicit_var_overrides_an_essential_one() {
let env = SpawnEnv {
vars: vec![("TERM".into(), "vessel-test-term".into())],
};
let pty = spawn_with_env(
&["sh".into(), "-c".into(), "echo $TERM".into()],
24,
80,
&env,
None,
)
.unwrap();
let out = drain(&pty);
assert!(
out.contains("vessel-test-term"),
"explicit var should win over the essential default: {out:?}"
);
}
#[test]
fn a_duplicated_key_resolves_to_the_last_entry_in_the_child() {
let env = SpawnEnv {
vars: vec![
("VESSEL_DUP".into(), "first".into()),
("VESSEL_DUP".into(), "last".into()),
],
};
let pty = spawn_with_env(
&["sh".into(), "-c".into(), "echo $VESSEL_DUP".into()],
24,
80,
&env,
None,
)
.unwrap();
let out = drain(&pty);
assert!(
out.contains("last") && !out.contains("first"),
"a duplicated key should resolve to the last entry: {out:?}"
);
}
#[test]
fn cwd_is_applied_in_the_child() {
let pty = spawn_with_env(
&["sh".into(), "-c".into(), "pwd".into()],
24,
80,
&SpawnEnv::default(),
Some("/tmp"),
)
.unwrap();
let out = drain(&pty);
assert!(out.contains("/tmp"), "cwd was not applied: {out:?}");
}
#[test]
fn command_still_resolves_through_path() {
let pty = spawn(&["true".into()], 24, 80).unwrap();
assert_eq!(pty.wait().unwrap(), 0);
}
#[test]
fn test_try_wait() {
let pty = spawn(&["sleep".into(), "0.1".into()], 24, 80).unwrap();
let result = pty.try_wait().unwrap();
assert!(result.is_none());
std::thread::sleep(Duration::from_millis(200));
let result = pty.try_wait().unwrap();
assert_eq!(result, Some(0));
}
}