use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use russh::client::Handle;
use russh::ChannelMsg;
use tokio::sync::mpsc;
use crate::event::CoreEvent;
use crate::ssh::client::Host;
use crate::ssh::session::{connect_and_auth, KnownHostsHandler};
pub type SessionId = u64;
enum Ctrl {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
Close,
}
struct PtySession {
id: SessionId,
parser: Arc<Mutex<vt100::Parser>>,
ctrl_tx: mpsc::UnboundedSender<Ctrl>,
}
fn feed_parser(parser: &Arc<Mutex<vt100::Parser>>, data: &[u8]) {
const CHUNK: usize = 256;
let mut off = 0;
while off < data.len() {
let end = (off + CHUNK).min(data.len());
if let Ok(mut p) = parser.lock() {
p.process(&data[off..end]);
}
off = end;
}
}
fn is_utf8_locale(value: &str) -> bool {
match value.rsplit_once('.') {
Some((_, codeset)) => {
let codeset = codeset.split('@').next().unwrap_or(codeset);
codeset.eq_ignore_ascii_case("utf-8") || codeset.eq_ignore_ascii_case("utf8")
}
None => false,
}
}
fn locale_env<I>(vars: I) -> Vec<(String, String)>
where
I: IntoIterator<Item = (String, String)>,
{
let mut out = Vec::new();
let (mut lc_all, mut lc_ctype, mut lang) = (None, None, None);
for (name, value) in vars {
if name == "LANG" || name.starts_with("LC_") {
match name.as_str() {
"LC_ALL" => lc_all = Some(value.clone()),
"LC_CTYPE" => lc_ctype = Some(value.clone()),
"LANG" => lang = Some(value.clone()),
_ => {}
}
out.push((name, value));
}
}
let force_utf8 = match lc_all {
Some(_) => false,
None => !lc_ctype
.as_deref()
.or(lang.as_deref())
.is_some_and(is_utf8_locale),
};
if force_utf8 {
out.retain(|(name, _)| name != "LC_CTYPE");
out.push(("LC_CTYPE".to_string(), "C.UTF-8".to_string()));
}
out
}
async fn forward_locale(channel: &russh::Channel<russh::client::Msg>) {
let vars = std::env::vars_os()
.filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)));
for (name, value) in locale_env(vars) {
let _ = channel.set_env(false, name, value).await;
}
}
async fn open_shell(
handle: &Handle<KnownHostsHandler>,
cols: u16,
rows: u16,
) -> Result<russh::Channel<russh::client::Msg>> {
let channel = handle
.channel_open_session()
.await
.context("open terminal channel")?;
forward_locale(&channel).await;
channel
.request_pty(
false,
"xterm-256color",
cols as u32,
rows as u32,
0,
0,
&[(russh::Pty::IUTF8, 1)],
)
.await
.context("request remote pty")?;
channel
.request_shell(false)
.await
.context("request remote shell")?;
Ok(channel)
}
#[allow(clippy::too_many_arguments)] async fn session_task(
id: SessionId,
host: Host,
cols: u16,
rows: u16,
parser: Arc<Mutex<vt100::Parser>>,
mut ctrl_rx: mpsc::UnboundedReceiver<Ctrl>,
tx: mpsc::Sender<CoreEvent>,
raw_output: Option<mpsc::Sender<(SessionId, Vec<u8>)>>,
) {
let result = async {
let handle = connect_and_auth(&host).await?;
open_shell(&handle, cols, rows).await.map(|ch| (handle, ch))
}
.await;
let (_handle, mut channel) = match result {
Ok(pair) => pair,
Err(e) => {
let _ = tx.send(CoreEvent::Error(format!("Terminal: {e}"))).await;
let _ = tx.send(CoreEvent::PtyExited(id)).await;
return;
}
};
loop {
tokio::select! {
msg = channel.wait() => match msg {
Some(ChannelMsg::Data { data })
| Some(ChannelMsg::ExtendedData { data, .. }) => {
feed_parser(&parser, &data);
let _ = tx.send(CoreEvent::PtyOutput(id)).await;
if let Some(raw) = &raw_output {
let _ = raw.send((id, data.to_vec())).await;
}
}
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
_ => {} },
cmd = ctrl_rx.recv() => match cmd {
Some(Ctrl::Input(bytes)) => {
let _ = channel.data(&bytes[..]).await;
}
Some(Ctrl::Resize { cols, rows }) => {
let _ = channel.window_change(cols as u32, rows as u32, 0, 0).await;
}
Some(Ctrl::Close) | None => break,
},
}
}
let _ = channel.eof().await;
let _ = tx.send(CoreEvent::PtyExited(id)).await;
}
pub struct PtyManager {
sessions: Vec<PtySession>,
next_id: u64,
raw_output: Option<mpsc::Sender<(SessionId, Vec<u8>)>>,
}
impl PtyManager {
pub fn new() -> Self {
Self {
sessions: Vec::new(),
next_id: 1,
raw_output: None,
}
}
pub fn with_raw_output(raw_output: mpsc::Sender<(SessionId, Vec<u8>)>) -> Self {
Self {
sessions: Vec::new(),
next_id: 1,
raw_output: Some(raw_output),
}
}
pub fn open(
&mut self,
host: &Host,
cols: u16,
rows: u16,
tx: mpsc::Sender<CoreEvent>,
) -> Result<SessionId> {
if host.proxy_jump.is_some() {
anyhow::bail!("ProxyJump is not yet supported in the terminal");
}
let id = self.next_id;
self.next_id += 1;
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 1000)));
let (ctrl_tx, ctrl_rx) = mpsc::unbounded_channel();
tokio::spawn(session_task(
id,
host.clone(),
cols,
rows,
Arc::clone(&parser),
ctrl_rx,
tx,
self.raw_output.clone(),
));
self.sessions.push(PtySession {
id,
parser,
ctrl_tx,
});
tracing::info!("terminal session {} opened for host '{}'", id, host.name);
Ok(id)
}
pub fn write(&mut self, id: SessionId, data: &[u8]) -> Result<()> {
if let Some(s) = self.sessions.iter().find(|s| s.id == id) {
let _ = s.ctrl_tx.send(Ctrl::Input(data.to_vec()));
}
Ok(())
}
pub fn resize(&mut self, id: SessionId, cols: u16, rows: u16) -> Result<()> {
if let Some(s) = self.sessions.iter().find(|s| s.id == id) {
let _ = s.ctrl_tx.send(Ctrl::Resize { cols, rows });
}
Ok(())
}
pub fn close(&mut self, id: SessionId) {
if let Some(pos) = self.sessions.iter().position(|s| s.id == id) {
let s = self.sessions.remove(pos);
let _ = s.ctrl_tx.send(Ctrl::Close);
tracing::info!("terminal session {} closed", id);
}
}
pub fn shutdown(self) {
for s in self.sessions {
let _ = s.ctrl_tx.send(Ctrl::Close);
}
}
pub fn parser_for(&self, id: SessionId) -> Option<Arc<Mutex<vt100::Parser>>> {
self.sessions
.iter()
.find(|s| s.id == id)
.map(|s| Arc::clone(&s.parser))
}
}
impl Default for PtyManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_tx() -> mpsc::Sender<CoreEvent> {
mpsc::channel(1).0
}
#[test]
fn default_construction_has_no_raw_tap() {
assert!(PtyManager::new().raw_output.is_none());
assert!(PtyManager::default().raw_output.is_none());
}
#[test]
fn with_raw_output_installs_the_tap() {
let (raw_tx, _raw_rx) = mpsc::channel::<(SessionId, Vec<u8>)>(1);
assert!(PtyManager::with_raw_output(raw_tx).raw_output.is_some());
}
#[tokio::test]
async fn open_assigns_incrementing_ids() {
let mut mgr = PtyManager::new();
let host = Host::default();
let a = mgr.open(&host, 80, 24, dummy_tx()).unwrap();
let b = mgr.open(&host, 80, 24, dummy_tx()).unwrap();
assert_eq!((a, b), (1, 2));
assert_eq!(mgr.sessions.len(), 2);
}
#[tokio::test]
async fn close_removes_only_the_target() {
let mut mgr = PtyManager::new();
let host = Host::default();
let a = mgr.open(&host, 80, 24, dummy_tx()).unwrap();
let b = mgr.open(&host, 80, 24, dummy_tx()).unwrap();
mgr.close(a);
assert_eq!(mgr.sessions.len(), 1);
assert_eq!(mgr.sessions[0].id, b);
}
#[tokio::test]
async fn write_and_resize_unknown_id_are_noops() {
let mut mgr = PtyManager::new();
assert!(mgr.write(999, b"x").is_ok());
assert!(mgr.resize(999, 80, 24).is_ok());
}
#[tokio::test]
async fn parser_for_returns_open_session_only() {
let mut mgr = PtyManager::new();
let id = mgr.open(&Host::default(), 80, 24, dummy_tx()).unwrap();
assert!(mgr.parser_for(id).is_some());
assert!(mgr.parser_for(id + 1).is_none());
}
fn env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn is_utf8_locale_recognizes_common_forms() {
assert!(is_utf8_locale("en_US.UTF-8"));
assert!(is_utf8_locale("ru_RU.utf8"));
assert!(is_utf8_locale("de_DE.UTF-8@euro"));
assert!(!is_utf8_locale("C"));
assert!(!is_utf8_locale("POSIX"));
assert!(!is_utf8_locale("en_US.ISO8859-1"));
}
#[test]
fn locale_env_defaults_ctype_to_utf8_when_no_locale_env() {
let got = locale_env(env(&[("PATH", "/bin"), ("HOME", "/root")]));
assert_eq!(got, env(&[("LC_CTYPE", "C.UTF-8")]));
}
#[test]
fn locale_env_forces_utf8_when_lang_is_not_utf8() {
let got = locale_env(env(&[("LANG", "C")]));
assert!(got.contains(&("LANG".into(), "C".into())));
assert!(got.contains(&("LC_CTYPE".into(), "C.UTF-8".into())));
}
#[test]
fn locale_env_keeps_a_utf8_lang_and_adds_no_fallback() {
let got = locale_env(env(&[
("LANG", "ru_RU.UTF-8"),
("LC_MESSAGES", "ru_RU.UTF-8"),
]));
assert!(got.contains(&("LANG".into(), "ru_RU.UTF-8".into())));
assert!(got.iter().all(|(k, _)| k != "LC_CTYPE"));
}
#[test]
fn locale_env_replaces_a_non_utf8_ctype_without_duplicating() {
let got = locale_env(env(&[("LC_CTYPE", "C")]));
assert_eq!(got, env(&[("LC_CTYPE", "C.UTF-8")]));
}
#[test]
fn locale_env_respects_an_explicit_utf8_ctype() {
let got = locale_env(env(&[("LC_CTYPE", "ru_RU.UTF-8")]));
assert_eq!(got, env(&[("LC_CTYPE", "ru_RU.UTF-8")]));
}
#[test]
fn locale_env_respects_lc_all() {
let got = locale_env(env(&[("LC_ALL", "C")]));
assert_eq!(got, env(&[("LC_ALL", "C")]));
}
}