use std::fs::File;
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::Command;
const RESIZE_POLL: std::time::Duration = std::time::Duration::from_millis(500);
pub fn run(argv: &[String]) -> anyhow::Result<i32> {
if argv.is_empty() {
anyhow::bail!("usage: cctop run <command> [args…] (e.g. cctop run claude)");
}
let (mut child, master) = spawn_on_pty(argv)?;
let pid = child.id();
let listener = listen(pid)?;
let socket = socket_path(pid);
let raw = crossterm::terminal::enable_raw_mode().is_ok();
for task in [pump_output, pump_input, watch_resize] {
let master = master.try_clone()?;
std::thread::spawn(move || task(master));
}
let control = master.try_clone()?;
std::thread::spawn(move || serve(listener, control));
let status = child.wait();
if raw {
let _ = crossterm::terminal::disable_raw_mode();
}
if let Some(socket) = socket {
let _ = std::fs::remove_file(socket);
}
Ok(status?.code().unwrap_or(1))
}
pub fn is_command(word: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
let executable = |p: PathBuf| {
std::fs::metadata(&p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
};
if word.contains('/') {
return executable(PathBuf::from(word));
}
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
.any(|dir| executable(dir.join(word)))
}
pub fn socket_path(pid: u32) -> Option<PathBuf> {
Some(base_dir()?.join(format!("{pid}.sock")))
}
fn base_dir() -> Option<PathBuf> {
dirs::runtime_dir()
.or_else(dirs::cache_dir)
.map(|d| d.join("cctop"))
}
fn listen(pid: u32) -> anyhow::Result<std::os::unix::net::UnixListener> {
let path = socket_path(pid).ok_or_else(|| anyhow::anyhow!("no runtime or cache directory"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
let _ =
std::fs::set_permissions(parent, std::os::unix::fs::PermissionsExt::from_mode(0o700));
}
let _ = std::fs::remove_file(&path);
Ok(std::os::unix::net::UnixListener::bind(&path)?)
}
fn serve(listener: std::os::unix::net::UnixListener, mut master: File) {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let mut line = String::new();
if stream.take(4096).read_to_string(&mut line).is_ok() && !line.is_empty() {
let _ = master.write_all(line.as_bytes());
let _ = master.flush();
}
}
}
fn spawn_on_pty(argv: &[String]) -> anyhow::Result<(std::process::Child, File)> {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
let mut size = winsize(cols, rows);
let mut master_fd = -1;
let mut slave_fd = -1;
let rc = unsafe {
libc::openpty(
&mut master_fd,
&mut slave_fd,
std::ptr::null_mut(),
std::ptr::null_mut::<libc::termios>(),
&raw mut size,
)
};
if rc != 0 {
return Err(std::io::Error::last_os_error().into());
}
let master = unsafe { File::from_raw_fd(master_fd) };
let slave = unsafe { File::from_raw_fd(slave_fd) };
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..]);
unsafe {
cmd.pre_exec(move || {
if libc::setsid() == -1 || libc::ioctl(slave_fd, libc::TIOCSCTTY as _, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
for target in 0..3 {
if libc::dup2(slave_fd, target) == -1 {
return Err(std::io::Error::last_os_error());
}
}
if slave_fd > 2 {
libc::close(slave_fd);
}
libc::close(master_fd);
Ok(())
})
};
let child = cmd.spawn()?;
drop(slave);
Ok((child, master))
}
fn pump_output(mut master: File) {
let mut out = std::io::stdout();
let mut buf = [0u8; 8192];
while let Ok(n) = master.read(&mut buf) {
if n == 0 || out.write_all(&buf[..n]).is_err() || out.flush().is_err() {
break;
}
}
}
fn pump_input(mut master: File) {
let mut stdin = std::io::stdin();
let mut buf = [0u8; 1024];
while let Ok(n) = stdin.read(&mut buf) {
if n == 0 || master.write_all(&buf[..n]).is_err() {
break;
}
}
}
fn watch_resize(master: File) {
let mut last = crossterm::terminal::size().unwrap_or((80, 24));
loop {
std::thread::sleep(RESIZE_POLL);
let Ok(size) = crossterm::terminal::size() else {
continue;
};
if size != last {
last = size;
let ws = winsize(size.0, size.1);
unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
}
}
}
fn winsize(cols: u16, rows: u16) -> libc::winsize {
libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
#[test]
fn a_command_is_told_apart_from_a_stray_word() {
assert!(is_command("sh"));
assert!(is_command("/bin/sh"));
assert!(!is_command("cctop-no-such-command"));
assert!(!is_command("/etc/hostname"));
}
#[test]
fn a_line_sent_to_the_socket_becomes_the_childs_input() {
let out = std::env::temp_dir().join("cctop-shim-test.txt");
let _ = std::fs::remove_file(&out);
let (mut child, master) = spawn_on_pty(&[
"sh".into(),
"-c".into(),
format!("tee {} >/dev/null; :", out.display()),
])
.unwrap();
let pid = child.id();
let listener = listen(pid).unwrap();
std::thread::spawn(move || serve(listener, master));
let sent = crate::inject::send_line(pid, "continue");
let text = (0..50).find_map(|_| {
std::thread::sleep(std::time::Duration::from_millis(100));
std::fs::read_to_string(&out).ok().filter(|t| !t.is_empty())
});
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(&out);
let _ = socket_path(pid).map(std::fs::remove_file);
sent.unwrap();
assert_eq!(text.unwrap().trim_end(), "continue");
}
#[test]
fn a_pty_child_without_a_socket_reports_the_missing_precondition() {
if unsafe { libc::geteuid() } == 0 {
eprintln!("skipping: running as root, so no precondition is missing");
return;
}
let (mut child, _master) =
spawn_on_pty(&["sh".into(), "-c".into(), "sleep 30".into()]).expect("pty child");
let error = crate::inject::send_line(child.id(), "continue").unwrap_err();
let _ = child.kill();
let _ = child.wait();
assert!(
error.contains("legacy_tiocsti") || error.contains("root"),
"expected a named precondition, got: {error}"
);
}
}