use std::path::Path;
pub struct Spawn<'a> {
pub command: &'a [String],
pub cwd: &'a Path,
pub cols: u16,
pub rows: u16,
}
#[cfg(unix)]
pub use unix::Pty;
#[cfg(windows)]
pub use windows::Pty;
#[cfg(unix)]
mod unix {
use super::{Spawn, default_shell};
use std::io::{self, Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::os::unix::process::CommandExt;
use std::process::{Child, Command};
pub struct Pty {
master: OwnedFd,
child: Child,
}
impl Pty {
pub fn spawn(req: Spawn<'_>) -> io::Result<Pty> {
let (master, slave) = unsafe {
let mut m: RawFd = -1;
let mut s: RawFd = -1;
let mut size = libc::winsize {
ws_row: req.rows,
ws_col: req.cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
if libc::openpty(
&mut m,
&mut s,
std::ptr::null_mut(),
std::ptr::null_mut(),
&raw mut size,
) != 0
{
return Err(io::Error::last_os_error());
}
libc::fcntl(m, libc::F_SETFD, libc::FD_CLOEXEC);
(OwnedFd::from_raw_fd(m), OwnedFd::from_raw_fd(s))
};
let argv = default_shell(req.command);
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(req.cwd)
.env("TERM", "xterm-256color");
let slave_fd = slave.as_raw_fd();
unsafe {
cmd.pre_exec(move || {
if libc::setsid() < 0 {
return Err(io::Error::last_os_error());
}
if libc::ioctl(slave_fd, libc::TIOCSCTTY as _, 0) < 0 {
return Err(io::Error::last_os_error());
}
for target in [libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO] {
if libc::dup2(slave_fd, target) < 0 {
return Err(io::Error::last_os_error());
}
}
if slave_fd > libc::STDERR_FILENO {
libc::close(slave_fd);
}
Ok(())
});
}
let child = cmd.spawn()?;
drop(slave);
Ok(Pty { master, child })
}
pub fn reader(&self) -> io::Result<Box<dyn Read + Send>> {
Ok(Box::new(std::fs::File::from(self.master.try_clone()?)))
}
pub fn writer(&self) -> io::Result<Box<dyn Write + Send>> {
Ok(Box::new(std::fs::File::from(self.master.try_clone()?)))
}
pub fn resize(&self, cols: u16, rows: u16) -> io::Result<()> {
let size = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
if unsafe { libc::ioctl(self.master.as_raw_fd(), libc::TIOCSWINSZ, &size) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub fn kill(&mut self) {
unsafe {
libc::kill(-(self.child.id() as libc::pid_t), libc::SIGKILL);
}
let _ = self.child.kill();
let _ = self.child.wait();
}
pub fn exited(&mut self) -> Option<i32> {
self.child
.try_wait()
.ok()
.flatten()
.map(|s| s.code().unwrap_or(-1))
}
}
}
#[cfg(windows)]
mod windows {
use super::{Spawn, default_shell};
use std::io::{self, Read, Write};
use std::os::windows::io::{FromRawHandle, OwnedHandle};
use std::ptr;
use windows_sys::Win32::Foundation::{
CloseHandle, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0,
};
use windows_sys::Win32::System::Console::{
COORD, ClosePseudoConsole, CreatePseudoConsole, HPCON, ResizePseudoConsole,
};
use windows_sys::Win32::System::Pipes::CreatePipe;
use windows_sys::Win32::System::Threading::{
CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT,
GetExitCodeProcess, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST,
PROCESS_INFORMATION, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute,
WaitForSingleObject,
};
const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x0002_0016;
pub struct Pty {
pc: HPCON,
input: OwnedHandle,
output: OwnedHandle,
process: OwnedHandle,
thread: OwnedHandle,
}
unsafe impl Send for Pty {}
impl Pty {
pub fn spawn(req: Spawn<'_>) -> io::Result<Pty> {
unsafe {
let (mut in_read, mut in_write) = (INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE);
let (mut out_read, mut out_write) = (INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE);
if CreatePipe(&mut in_read, &mut in_write, ptr::null(), 0) == 0 {
return Err(io::Error::last_os_error());
}
if CreatePipe(&mut out_read, &mut out_write, ptr::null(), 0) == 0 {
let e = io::Error::last_os_error();
CloseHandle(in_read);
CloseHandle(in_write);
return Err(e);
}
let mut pc: HPCON = 0;
let size = COORD {
X: req.cols.max(1) as i16,
Y: req.rows.max(1) as i16,
};
let hr = CreatePseudoConsole(size, in_read, out_write, 0, &mut pc);
CloseHandle(in_read);
CloseHandle(out_write);
if hr != 0 {
CloseHandle(in_write);
CloseHandle(out_read);
return Err(io::Error::from_raw_os_error(hr));
}
let mut bytes: usize = 0;
InitializeProcThreadAttributeList(ptr::null_mut(), 1, 0, &mut bytes);
let mut attrs = vec![0u8; bytes];
let list = attrs.as_mut_ptr() as LPPROC_THREAD_ATTRIBUTE_LIST;
if InitializeProcThreadAttributeList(list, 1, 0, &mut bytes) == 0 {
let e = io::Error::last_os_error();
ClosePseudoConsole(pc);
CloseHandle(in_write);
CloseHandle(out_read);
return Err(e);
}
if UpdateProcThreadAttribute(
list,
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
pc as *const std::ffi::c_void,
size_of::<HPCON>(),
ptr::null_mut(),
ptr::null(),
) == 0
{
let e = io::Error::last_os_error();
DeleteProcThreadAttributeList(list);
ClosePseudoConsole(pc);
CloseHandle(in_write);
CloseHandle(out_read);
return Err(e);
}
let mut si: STARTUPINFOEXW = std::mem::zeroed();
si.StartupInfo.cb = size_of::<STARTUPINFOEXW>() as u32;
si.lpAttributeList = list;
let mut pi: PROCESS_INFORMATION = std::mem::zeroed();
let mut line = wide(&super::command_line(&default_shell(req.command)));
let cwd = wide(&req.cwd.display().to_string());
let ok = CreateProcessW(
ptr::null(),
line.as_mut_ptr(),
ptr::null(),
ptr::null(),
0,
EXTENDED_STARTUPINFO_PRESENT,
ptr::null(),
cwd.as_ptr(),
&si.StartupInfo,
&mut pi,
);
DeleteProcThreadAttributeList(list);
if ok == 0 {
let e = io::Error::last_os_error();
ClosePseudoConsole(pc);
CloseHandle(in_write);
CloseHandle(out_read);
return Err(e);
}
Ok(Pty {
pc,
input: OwnedHandle::from_raw_handle(in_write as _),
output: OwnedHandle::from_raw_handle(out_read as _),
process: OwnedHandle::from_raw_handle(pi.hProcess as _),
thread: OwnedHandle::from_raw_handle(pi.hThread as _),
})
}
}
pub fn reader(&self) -> io::Result<Box<dyn Read + Send>> {
Ok(Box::new(std::fs::File::from(self.output.try_clone()?)))
}
pub fn writer(&self) -> io::Result<Box<dyn Write + Send>> {
Ok(Box::new(std::fs::File::from(self.input.try_clone()?)))
}
pub fn resize(&self, cols: u16, rows: u16) -> io::Result<()> {
let size = COORD {
X: cols.max(1) as i16,
Y: rows.max(1) as i16,
};
let hr = unsafe { ResizePseudoConsole(self.pc, size) };
if hr != 0 {
return Err(io::Error::from_raw_os_error(hr));
}
Ok(())
}
pub fn kill(&mut self) {
unsafe {
TerminateProcess(handle(&self.process), 1);
WaitForSingleObject(handle(&self.process), 2000);
}
}
pub fn exited(&mut self) -> Option<i32> {
if unsafe { WaitForSingleObject(handle(&self.process), 0) } != WAIT_OBJECT_0 {
return None;
}
let mut code: u32 = 0;
if unsafe { GetExitCodeProcess(handle(&self.process), &mut code) } == 0 {
return Some(-1);
}
Some(code as i32)
}
}
impl Drop for Pty {
fn drop(&mut self) {
unsafe { ClosePseudoConsole(self.pc) };
let _ = &self.thread;
}
}
fn handle(h: &OwnedHandle) -> HANDLE {
use std::os::windows::io::AsRawHandle;
h.as_raw_handle() as HANDLE
}
fn wide(s: &str) -> Vec<u16> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
OsStr::new(s).encode_wide().chain(Some(0)).collect()
}
}
#[cfg_attr(not(windows), allow(dead_code))]
pub(super) fn command_line(argv: &[String]) -> String {
let mut line = String::new();
for (i, part) in argv.iter().enumerate() {
if i > 0 {
line.push(' ');
}
if !part.is_empty() && !part.contains([' ', '\t', '"']) {
line.push_str(part);
continue;
}
line.push('"');
let mut backslashes = 0usize;
for c in part.chars() {
match c {
'\\' => {
backslashes += 1;
}
'"' => {
line.extend(std::iter::repeat_n('\\', backslashes * 2 + 1));
backslashes = 0;
line.push('"');
}
other => {
line.extend(std::iter::repeat_n('\\', backslashes));
backslashes = 0;
line.push(other);
}
}
}
line.extend(std::iter::repeat_n('\\', backslashes * 2));
line.push('"');
}
line
}
fn default_shell(command: &[String]) -> Vec<String> {
if !command.is_empty() {
return command.to_vec();
}
if cfg!(windows) {
vec![std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())]
} else {
vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::io::{self, Read};
#[cfg(unix)]
#[test]
fn a_program_runs_in_it_and_its_output_comes_back() {
let dir = tempfile::tempdir().unwrap();
let mut pty = Pty::spawn(Spawn {
command: &[
"/bin/sh".into(),
"-c".into(),
"printf 'hello-from-pty'".into(),
],
cwd: dir.path(),
cols: 80,
rows: 24,
})
.unwrap();
let mut out = pty.reader().unwrap();
let mut buf = Vec::new();
let _ = out.read_to_end(&mut buf);
let text = String::from_utf8_lossy(&buf);
assert!(text.contains("hello-from-pty"), "{text:?}");
pty.kill();
}
#[cfg(unix)]
#[test]
fn the_child_is_told_it_is_a_terminal() {
let dir = tempfile::tempdir().unwrap();
let mut pty = Pty::spawn(Spawn {
command: &[
"/bin/sh".into(),
"-c".into(),
"test -t 0 && printf yes || printf no".into(),
],
cwd: dir.path(),
cols: 80,
rows: 24,
})
.unwrap();
let mut buf = Vec::new();
let _ = pty.reader().unwrap().read_to_end(&mut buf);
assert_eq!(String::from_utf8_lossy(&buf).trim(), "yes");
pty.kill();
}
#[cfg(unix)]
#[test]
fn the_size_is_the_one_asked_for_and_a_resize_reaches_the_child() {
let dir = tempfile::tempdir().unwrap();
let mut pty = Pty::spawn(Spawn {
command: &[
"/bin/sh".into(),
"-c".into(),
"trap 'stty size' WINCH; stty size; sleep 2".into(),
],
cwd: dir.path(),
cols: 120,
rows: 40,
})
.unwrap();
let mut out = pty.reader().unwrap();
let first = read_until(&mut out, "40 120");
assert!(first.contains("40 120"), "{first:?}");
pty.resize(100, 30).unwrap();
let second = read_until(&mut out, "30 100");
assert!(
second.contains("30 100"),
"the resize did not reach the child: {second:?}"
);
pty.kill();
}
#[cfg(unix)]
fn read_until(out: &mut impl Read, needle: &str) -> String {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut seen = String::new();
let mut buf = [0u8; 256];
while std::time::Instant::now() < deadline {
match out.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
seen.push_str(&String::from_utf8_lossy(&buf[..n]));
if seen.contains(needle) {
break;
}
}
}
}
seen
}
#[cfg(unix)]
#[test]
fn an_answer_split_across_reads_is_still_found() {
struct Chunks(Vec<&'static [u8]>);
impl Read for Chunks {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.0.is_empty() {
return Ok(0);
}
let c = self.0.remove(0);
buf[..c.len()].copy_from_slice(c);
Ok(c.len())
}
}
let mut chunks = Chunks(vec![b"\r\n", b"30 1", b"00\r\n"]);
assert_eq!(read_until(&mut chunks, "30 100"), "\r\n30 100\r\n");
let mut none = Chunks(vec![b"\r\n"]);
assert_eq!(read_until(&mut none, "30 100"), "\r\n");
}
#[cfg(unix)]
#[test]
fn what_is_typed_reaches_the_program() {
let dir = tempfile::tempdir().unwrap();
let mut pty = Pty::spawn(Spawn {
command: &[
"/bin/sh".into(),
"-c".into(),
"read line; printf \"got:%s\" \"$line\"".into(),
],
cwd: dir.path(),
cols: 80,
rows: 24,
})
.unwrap();
pty.writer().unwrap().write_all(b"typed-this\n").unwrap();
let mut buf = Vec::new();
let _ = pty.reader().unwrap().read_to_end(&mut buf);
assert!(
String::from_utf8_lossy(&buf).contains("got:typed-this"),
"{:?}",
String::from_utf8_lossy(&buf)
);
pty.kill();
}
#[test]
fn a_windows_command_line_escapes_the_way_windows_parses() {
let line =
|argv: &[&str]| command_line(&argv.iter().map(|s| s.to_string()).collect::<Vec<_>>());
assert_eq!(line(&[r"C:\Users\me\tool.exe"]), r"C:\Users\me\tool.exe");
assert_eq!(
line(&[r"C:\Program Files\Git\bin\bash.exe"]),
r#""C:\Program Files\Git\bin\bash.exe""#
);
assert_eq!(line(&[r"C:\Program Files\"]), r#""C:\Program Files\\""#);
assert_eq!(line(&[r#"a\"b c"#]), r#""a\\\"b c""#);
assert_eq!(
line(&["ssh", "-i", r"C:\keys\id_rsa", "root@host"]),
r"ssh -i C:\keys\id_rsa root@host"
);
assert_eq!(line(&["x", ""]), r#"x """#);
}
#[cfg(unix)]
#[test]
fn a_program_in_the_terminal_does_not_inherit_the_terminal_itself() {
use std::io::Read;
let dir = tempfile::tempdir().unwrap();
let mut pty = Pty::spawn(Spawn {
command: &[
"/bin/sh".into(),
"-c".into(),
"ls -l /proc/self/fd; exit".into(),
],
cwd: dir.path(),
cols: 80,
rows: 24,
})
.unwrap();
let mut out = Vec::new();
let _ = pty.reader().unwrap().read_to_end(&mut out);
let listing = String::from_utf8_lossy(&out);
assert!(
!listing.contains("ptmx"),
"the child inherited the master side of its own terminal:\n{listing}"
);
pty.kill();
}
#[cfg(unix)]
#[test]
fn killing_a_session_ends_what_it_started() {
use std::io::Read;
let dir = tempfile::tempdir().unwrap();
let mut pty = Pty::spawn(Spawn {
command: &[
"/bin/sh".into(),
"-c".into(),
"trap '' HUP; sleep 60 & echo $!; wait".into(),
],
cwd: dir.path(),
cols: 80,
rows: 24,
})
.unwrap();
let mut buf = [0u8; 64];
let n = pty.reader().unwrap().read(&mut buf).unwrap();
let pid: i32 = String::from_utf8_lossy(&buf[..n])
.trim()
.parse()
.expect("the shell printed the background pid");
assert!(alive(pid), "the background program should be running");
pty.kill();
for _ in 0..50 {
if !alive(pid) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
unsafe { libc::kill(pid, libc::SIGKILL) };
panic!("{pid} outlived the terminal it was started in");
}
#[cfg(unix)]
fn alive(pid: i32) -> bool {
unsafe { libc::kill(pid, 0) == 0 }
}
#[test]
fn a_named_command_wins_over_the_default_shell() {
let named = vec!["ssh".to_string(), "root@example".to_string()];
assert_eq!(default_shell(&named), named);
assert_eq!(default_shell(&[]).len(), 1);
}
}