use std::{os::fd::OwnedFd, path::PathBuf, str::FromStr};
use anyhow::Context;
use rustix::{
fs::{Mode, OFlags, open},
termios::{
ControlModes, InputModes, OptionalActions, Termios, ioctl_tiocexcl, isatty, tcgetattr,
tcsetattr,
},
};
use serde::{Deserialize, Serialize};
use tocat_api::normalize;
use tokio::io::unix::AsyncFd;
use tracing::{debug, warn};
use crate::endpoint::{
Connection, EndpointStream,
parse::{Opt, ParseEndpointError},
pty::{Terminal, WinSize},
};
mod stream;
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Flow {
#[default]
None,
Rts,
Xon,
}
impl FromStr for Flow {
type Err = ParseEndpointError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match normalize(s).as_str() {
"none" | "off" => Ok(Self::None),
"rts" | "rtscts" | "hardware" | "hw" => Ok(Self::Rts),
"xon" | "xonxoff" | "software" | "sw" => Ok(Self::Xon),
_ => Err(ParseEndpointError::InvalidFlag(s.to_string())),
}
}
}
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Parity {
#[default]
None,
Even,
Odd,
}
impl FromStr for Parity {
type Err = ParseEndpointError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match normalize(s).as_str() {
"none" | "n" => Ok(Self::None),
"even" | "e" => Ok(Self::Even),
"odd" | "o" => Ok(Self::Odd),
_ => Err(ParseEndpointError::InvalidFlag(s.to_string())),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Line {
pub speed: Option<u32>,
pub bits: u8,
pub parity: Parity,
pub stop2: bool,
pub flow: Flow,
pub clocal: bool,
}
impl Default for Line {
fn default() -> Self {
Self {
speed: None,
bits: 8,
parity: Parity::None,
stop2: false,
flow: Flow::None,
clocal: true,
}
}
}
impl Line {
fn char_size(self) -> Result<ControlModes, ParseEndpointError> {
Ok(match self.bits {
5 => ControlModes::CS5,
6 => ControlModes::CS6,
7 => ControlModes::CS7,
8 => ControlModes::CS8,
other => return Err(ParseEndpointError::InvalidNumber(format!("bits={other}"))),
})
}
fn apply(self, termios: &mut Termios) -> anyhow::Result<()> {
if let Some(speed) = self.speed {
termios
.set_speed(speed)
.with_context(|| format!("setting the line speed to {speed}"))?;
}
termios.control_modes.insert(ControlModes::CREAD);
termios.control_modes.remove(ControlModes::CSIZE);
termios
.control_modes
.insert(self.char_size().expect("validated at parse"));
termios
.control_modes
.set(ControlModes::PARENB, !matches!(self.parity, Parity::None));
termios
.control_modes
.set(ControlModes::PARODD, matches!(self.parity, Parity::Odd));
termios.control_modes.set(ControlModes::CSTOPB, self.stop2);
termios.control_modes.set(ControlModes::CLOCAL, self.clocal);
termios
.control_modes
.set(ControlModes::CRTSCTS, self.flow == Flow::Rts);
termios
.input_modes
.set(InputModes::IXON | InputModes::IXOFF, self.flow == Flow::Xon);
Ok(())
}
}
struct Restore {
fd: OwnedFd,
original: Termios,
}
impl Drop for Restore {
fn drop(&mut self) {
match tcsetattr(&self.fd, OptionalActions::Now, &self.original) {
Ok(()) => debug!("terminal settings restored"),
Err(e) => warn!(error = %e, "could not restore the terminal settings"),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Tty {
pub path: PathBuf,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub exclusive: bool,
#[serde(default = "crate::endpoint::sys::default_true")]
pub raw: bool,
#[serde(default)]
pub echo: bool,
#[serde(default)]
pub size: Option<WinSize>,
#[serde(default)]
pub speed: Option<u32>,
#[serde(default = "default_bits")]
pub bits: u8,
#[serde(default)]
pub parity: Parity,
#[serde(default)]
pub stop2: bool,
#[serde(default)]
pub flow: Flow,
#[serde(default = "crate::endpoint::sys::default_true")]
pub clocal: bool,
}
fn default_bits() -> u8 {
8
}
impl Tty {
const SCHEME: &'static str = "tty";
pub(super) fn parse<'a>(
body: &str,
opts: impl Iterator<Item = Opt<'a>>,
) -> Result<Self, ParseEndpointError> {
if body.is_empty() {
return Err(ParseEndpointError::Empty);
}
let mut name = None;
let mut exclusive = false;
let mut terminal = Terminal::default();
let mut line = Line::default();
for opt in opts {
let key = normalize(opt.key);
if terminal.parse_opt(&opt, key.as_str())? {
continue;
}
match key.as_str() {
"name" => name = Some(opt.string()?),
"exclusive" | "excl" => exclusive = opt.flag()?,
"clocal" => line.clocal = opt.flag()?,
"flow" => line.flow = opt.text()?.parse()?,
"parity" => line.parity = opt.text()?.parse()?,
"stop2" => line.stop2 = opt.flag()?,
"bits" => {
line.bits = opt
.text()?
.parse()
.map_err(|_| ParseEndpointError::InvalidNumber(opt.key.to_string()))?;
}
"speed" | "baud" => {
line.speed = Some(
opt.text()?
.parse()
.map_err(|_| ParseEndpointError::InvalidNumber(opt.key.to_string()))?,
);
}
other
if other.starts_with('b')
&& other.len() > 1
&& other[1..].chars().all(|c| c.is_ascii_digit()) =>
{
line.speed = Some(
other[1..]
.parse()
.map_err(|_| ParseEndpointError::InvalidNumber(opt.key.to_string()))?,
);
}
_ => return Err(opt.unsupported(Self::SCHEME)),
}
}
line.char_size()?;
Ok(Self {
path: PathBuf::from(body),
name,
exclusive,
raw: terminal.raw,
echo: terminal.echo,
size: terminal.size,
speed: line.speed,
bits: line.bits,
parity: line.parity,
stop2: line.stop2,
flow: line.flow,
clocal: line.clocal,
})
}
fn terminal(&self) -> Terminal {
Terminal {
raw: self.raw,
echo: self.echo,
size: self.size,
}
}
fn line(&self) -> Line {
Line {
speed: self.speed,
bits: self.bits,
parity: self.parity,
stop2: self.stop2,
flow: self.flow,
clocal: self.clocal,
}
}
pub(super) fn label(&self) -> String {
format!("tty://{}", self.path.display())
}
pub(super) async fn connect(&self) -> anyhow::Result<Connection> {
let fd = open(
&self.path,
OFlags::RDWR | OFlags::NOCTTY | OFlags::NONBLOCK,
Mode::empty(),
)
.with_context(|| format!("opening {}", self.path.display()))?;
if !isatty(&fd) {
anyhow::bail!("{} is not a terminal", self.path.display());
}
if self.exclusive {
ioctl_tiocexcl(&fd)
.with_context(|| format!("claiming {} exclusively", self.path.display()))?;
}
let original = tcgetattr(&fd).context("reading the terminal settings")?;
let mut termios = original.clone();
self.terminal().fill(&mut termios);
self.line().apply(&mut termios)?;
tcsetattr(&fd, OptionalActions::Now, &termios).context("applying the terminal settings")?;
let restore = Restore {
fd: fd.try_clone().context("duplicating the terminal")?,
original,
};
self.terminal().resize(&fd)?;
let stream = AsyncFd::new(fd).context("registering the terminal with the reactor")?;
Ok(EndpointStream::Duplex(Box::new(stream::Stream(stream)))
.into_connection()
.with_keepalive(restore))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::endpoint::EndpointSpec;
fn tty(s: &str) -> Tty {
match s.parse::<EndpointSpec>().expect("parses") {
EndpointSpec::Tty(e) => e,
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn the_defaults_suit_a_relay() {
let e = tty("tty:/dev/ttyUSB0");
assert!(e.raw);
assert!(!e.echo);
assert!(e.clocal);
assert_eq!(e.bits, 8);
assert_eq!(e.parity, Parity::None);
assert_eq!(e.flow, Flow::None);
assert_eq!(e.speed, None);
}
#[test]
fn speed_is_spelled_two_ways() {
assert_eq!(tty("tty:/dev/ttyUSB0,b115200").speed, Some(115_200));
assert_eq!(tty("tty:/dev/ttyUSB0,speed=115200").speed, Some(115_200));
assert_eq!(tty("tty:/dev/ttyUSB0,baud=9600").speed, Some(9600));
}
#[test]
fn the_line_settings_have_aliases_worth_having() {
assert_eq!(tty("tty:/dev/ttyUSB0,flow=hw").flow, Flow::Rts);
assert_eq!(tty("tty:/dev/ttyUSB0,flow=xon-xoff").flow, Flow::Xon);
assert_eq!(tty("tty:/dev/ttyUSB0,parity=e").parity, Parity::Even);
assert_eq!(tty("tty:/dev/ttyUSB0,parity=odd").parity, Parity::Odd);
}
#[test]
fn an_impossible_character_size_is_rejected() {
assert!("tty:/dev/ttyUSB0,bits=9".parse::<EndpointSpec>().is_err());
assert!("tty:/dev/ttyUSB0,bits=7".parse::<EndpointSpec>().is_ok());
}
#[test]
fn a_path_is_required() {
assert!(matches!(
"tty:".parse::<EndpointSpec>(),
Err(ParseEndpointError::Empty)
));
}
#[test]
fn an_option_the_scheme_does_not_take_is_an_error() {
assert!("tty:/dev/ttyUSB0,fork".parse::<EndpointSpec>().is_err());
assert!(
"tty:/dev/ttyUSB0,link=/tmp/x"
.parse::<EndpointSpec>()
.is_err()
);
}
}