use std::{os::fd::AsFd, path::PathBuf, str::FromStr};
use anyhow::Context;
use rustix::termios::{
LocalModes, OptionalActions, Termios, Winsize, tcgetattr, tcsetattr, tcsetwinsize,
};
use serde::{Deserialize, Serialize, Serializer, de::Error as _};
use tocat_api::normalize;
use tracing::{info, warn};
use crate::{
child,
endpoint::{
Connection, EndpointStream,
parse::{Opt, ParseEndpointError},
sys::PathGuard,
},
};
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct WinSize {
pub rows: u16,
pub cols: u16,
}
impl FromStr for WinSize {
type Err = ParseEndpointError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let invalid = || ParseEndpointError::InvalidSize(s.to_string());
let (rows, cols) = s.split_once(['x', 'X']).ok_or_else(invalid)?;
Ok(Self {
rows: rows.trim().parse().map_err(|_| invalid())?,
cols: cols.trim().parse().map_err(|_| invalid())?,
})
}
}
impl<'de> Deserialize<'de> for WinSize {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse()
.map_err(|_| D::Error::custom(format!("invalid terminal size {s:?}, want ROWSxCOLS")))
}
}
impl Serialize for WinSize {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format!("{}x{}", self.rows, self.cols))
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Terminal {
pub raw: bool,
pub echo: bool,
pub size: Option<WinSize>,
}
impl Default for Terminal {
fn default() -> Self {
Self {
raw: true,
echo: false,
size: None,
}
}
}
impl Terminal {
pub(super) fn parse_opt(
&mut self,
opt: &Opt<'_>,
key: &str,
) -> Result<bool, ParseEndpointError> {
match key {
"raw" => self.raw = opt.flag()?,
"echo" => self.echo = opt.flag()?,
"size" => self.size = Some(opt.text()?.parse()?),
_ => return Ok(false),
}
Ok(true)
}
pub(super) fn fill(self, termios: &mut Termios) {
if self.raw {
termios.make_raw();
}
termios.local_modes.set(LocalModes::ECHO, self.echo);
}
pub(super) fn resize<F: AsFd>(self, fd: F) -> anyhow::Result<()> {
let Some(size) = self.size else {
return Ok(());
};
tcsetwinsize(
fd,
Winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: 0,
ws_ypixel: 0,
},
)
.context("setting the terminal size")
}
fn apply(self, pty: &pty_process::Pty) -> anyhow::Result<()> {
self.resize(pty.as_fd())?;
let mut termios = tcgetattr(pty.as_fd()).context("reading the terminal settings")?;
self.fill(&mut termios);
tcsetattr(pty.as_fd(), OptionalActions::Now, &termios)
.context("applying the terminal settings")
}
}
fn open(terminal: Terminal) -> anyhow::Result<(pty_process::Pty, pty_process::Pts)> {
let (pty, pts) = pty_process::open().context("allocating a pty")?;
terminal.apply(&pty)?;
Ok((pty, pts))
}
fn pts_path(pty: &pty_process::Pty) -> anyhow::Result<PathBuf> {
let name = rustix::pty::ptsname(pty.as_fd(), Vec::new()).context("reading the pts name")?;
let name = String::from_utf8(name.into_bytes()).context("pts name is not utf-8")?;
Ok(PathBuf::from(name))
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Pty {
#[serde(default)]
pub link: Option<PathBuf>,
#[serde(default)]
pub name: Option<String>,
#[serde(default = "crate::endpoint::sys::default_true")]
pub raw: bool,
#[serde(default)]
pub echo: bool,
#[serde(default)]
pub size: Option<WinSize>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PtyExec {
pub argv: Vec<String>,
#[serde(default)]
pub shell: bool,
#[serde(default)]
pub term: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default = "crate::endpoint::sys::default_true")]
pub raw: bool,
#[serde(default)]
pub echo: bool,
#[serde(default)]
pub size: Option<WinSize>,
}
impl Pty {
const SCHEME: &'static str = "pty";
pub(super) fn parse<'a>(
body: &str,
opts: impl Iterator<Item = Opt<'a>>,
) -> Result<Self, ParseEndpointError> {
let mut name = None;
let mut terminal = Terminal::default();
let mut link = (!body.is_empty()).then(|| PathBuf::from(body));
for opt in opts {
let key = normalize(opt.key);
if terminal.parse_opt(&opt, key.as_str())? {
continue;
}
match key.as_str() {
"link" => link = Some(PathBuf::from(opt.text()?)),
"name" => name = Some(opt.string()?),
_ => return Err(opt.unsupported(Self::SCHEME)),
}
}
Ok(Self {
link,
name,
raw: terminal.raw,
echo: terminal.echo,
size: terminal.size,
})
}
fn terminal(&self) -> Terminal {
Terminal {
raw: self.raw,
echo: self.echo,
size: self.size,
}
}
pub(super) fn label(&self) -> String {
self.name.clone().unwrap_or_else(|| match &self.link {
Some(link) => format!("pty://{}", link.display()),
None => "pty://".to_string(),
})
}
pub(super) async fn connect(&self) -> anyhow::Result<Connection> {
let (pty, pts) = open(self.terminal())?;
let path = pts_path(&pty)?;
let stream = EndpointStream::Duplex(Box::new(pty));
let Some(link) = &self.link else {
info!(pts = %path.display(), "pty allocated");
return Ok(stream.into_connection().with_keepalive(pts));
};
if link.is_symlink() && !link.exists() {
warn!(link = %link.display(), "removing a dangling link");
let _ = std::fs::remove_file(link);
}
std::os::unix::fs::symlink(&path, link)
.with_context(|| format!("linking {} to {}", link.display(), path.display()))?;
info!(link = %link.display(), pts = %path.display(), "pty allocated");
Ok(stream
.into_connection_with_guard(Some(PathGuard(link.clone())))
.with_keepalive(pts))
}
}
impl PtyExec {
const SCHEME: &'static str = "pty-exec";
pub(super) fn parse<'a>(
body: &str,
opts: impl Iterator<Item = Opt<'a>>,
) -> Result<Self, ParseEndpointError> {
let mut shell = false;
let mut term = None;
let mut name = None;
let mut terminal = Terminal::default();
for opt in opts {
let key = normalize(opt.key);
if terminal.parse_opt(&opt, key.as_str())? {
continue;
}
match key.as_str() {
"shell" => shell = opt.flag()?,
"term" => term = Some(opt.string()?),
"name" => name = Some(opt.string()?),
_ => return Err(opt.unsupported(Self::SCHEME)),
}
}
let argv: Vec<String> = if shell {
vec![body.to_string()]
} else {
body.split_whitespace().map(String::from).collect()
};
if argv.is_empty() || argv[0].is_empty() {
return Err(ParseEndpointError::Empty);
}
Ok(Self {
argv,
shell,
term,
name,
raw: terminal.raw,
echo: terminal.echo,
size: terminal.size,
})
}
fn terminal(&self) -> Terminal {
Terminal {
raw: self.raw,
echo: self.echo,
size: self.size,
}
}
pub(super) fn label(&self) -> String {
format!("PTY-EXEC({})", self.argv.join(" "))
}
pub(super) async fn connect(&self) -> anyhow::Result<Connection> {
let (pty, pts) = open(self.terminal())?;
let mut command = if self.shell {
let sh = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
pty_process::Command::new(sh).arg("-c").arg(&self.argv[0])
} else {
pty_process::Command::new(&self.argv[0]).args(&self.argv[1..])
};
if let Some(term) = &self.term {
command = command.env("TERM", term);
}
let child = command
.kill_on_drop(true)
.spawn(pts)
.with_context(|| format!("spawning {} on a pty", self.argv.join(" ")))?;
child::reap_in_background(child);
Ok(EndpointStream::Duplex(Box::new(pty)).into_connection())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::endpoint::EndpointSpec;
fn spec(s: &str) -> EndpointSpec {
s.parse().expect("parses")
}
#[test]
fn a_size_is_rows_by_columns() {
assert_eq!(
"24x80".parse::<WinSize>(),
Ok(WinSize { rows: 24, cols: 80 })
);
assert!("24".parse::<WinSize>().is_err());
assert!("24x".parse::<WinSize>().is_err());
assert!("axb".parse::<WinSize>().is_err());
}
#[test]
fn the_defaults_are_transparent() {
let EndpointSpec::PtyExec(e) = spec("pty-exec:cat") else {
panic!("wrong variant");
};
assert!(e.raw);
assert!(!e.echo);
assert_eq!(e.size, None);
}
#[test]
fn the_body_is_the_link_path() {
let EndpointSpec::Pty(e) = spec("pty:/tmp/ttyfake") else {
panic!("wrong variant");
};
assert_eq!(e.link, Some(PathBuf::from("/tmp/ttyfake")));
}
#[test]
fn shell_keeps_the_command_line_whole() {
let EndpointSpec::PtyExec(e) = spec("pty-exec:echo 'a b',shell") else {
panic!("wrong variant");
};
assert_eq!(e.argv, vec!["echo 'a b'".to_string()]);
let EndpointSpec::PtyExec(e) = spec("pty-exec:echo a b") else {
panic!("wrong variant");
};
assert_eq!(e.argv, vec!["echo", "a", "b"]);
}
#[test]
fn an_option_the_scheme_does_not_take_is_an_error() {
assert!("pty:,fork".parse::<EndpointSpec>().is_err());
assert!("pty-exec:cat,link=/tmp/x".parse::<EndpointSpec>().is_err());
}
#[test]
fn an_empty_command_is_rejected() {
assert!(matches!(
"pty-exec:".parse::<EndpointSpec>(),
Err(ParseEndpointError::Empty)
));
}
}