1use std::sync::{Arc, Mutex};
15
16use anyhow::{Context, Result};
17use russh::client::Handle;
18use russh::ChannelMsg;
19use tokio::sync::mpsc;
20
21use crate::event::CoreEvent;
22use crate::ssh::client::Host;
23use crate::ssh::session::{connect_and_auth, KnownHostsHandler};
24
25pub type SessionId = u64;
27
28enum Ctrl {
34 Input(Vec<u8>),
36 Resize { cols: u16, rows: u16 },
38 Close,
40}
41
42struct PtySession {
50 id: SessionId,
52 parser: Arc<Mutex<vt100::Parser>>,
55 ctrl_tx: mpsc::UnboundedSender<Ctrl>,
57}
58
59fn feed_parser(parser: &Arc<Mutex<vt100::Parser>>, data: &[u8]) {
62 const CHUNK: usize = 256;
63 let mut off = 0;
64 while off < data.len() {
65 let end = (off + CHUNK).min(data.len());
66 if let Ok(mut p) = parser.lock() {
67 p.process(&data[off..end]);
68 }
69 off = end;
70 }
71}
72
73fn is_utf8_locale(value: &str) -> bool {
76 match value.rsplit_once('.') {
77 Some((_, codeset)) => {
78 let codeset = codeset.split('@').next().unwrap_or(codeset);
79 codeset.eq_ignore_ascii_case("utf-8") || codeset.eq_ignore_ascii_case("utf8")
80 }
81 None => false,
82 }
83}
84
85fn locale_env<I>(vars: I) -> Vec<(String, String)>
93where
94 I: IntoIterator<Item = (String, String)>,
95{
96 let mut out = Vec::new();
97 let (mut lc_all, mut lc_ctype, mut lang) = (None, None, None);
98 for (name, value) in vars {
99 if name == "LANG" || name.starts_with("LC_") {
100 match name.as_str() {
101 "LC_ALL" => lc_all = Some(value.clone()),
102 "LC_CTYPE" => lc_ctype = Some(value.clone()),
103 "LANG" => lang = Some(value.clone()),
104 _ => {}
105 }
106 out.push((name, value));
107 }
108 }
109 let force_utf8 = match lc_all {
114 Some(_) => false,
115 None => !lc_ctype
116 .as_deref()
117 .or(lang.as_deref())
118 .is_some_and(is_utf8_locale),
119 };
120 if force_utf8 {
121 out.retain(|(name, _)| name != "LC_CTYPE");
122 out.push(("LC_CTYPE".to_string(), "C.UTF-8".to_string()));
123 }
124 out
125}
126
127async fn forward_locale(channel: &russh::Channel<russh::client::Msg>) {
131 let vars = std::env::vars_os()
135 .filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)));
136 for (name, value) in locale_env(vars) {
137 let _ = channel.set_env(false, name, value).await;
138 }
139}
140
141async fn open_shell(
143 handle: &Handle<KnownHostsHandler>,
144 cols: u16,
145 rows: u16,
146) -> Result<russh::Channel<russh::client::Msg>> {
147 let channel = handle
148 .channel_open_session()
149 .await
150 .context("open terminal channel")?;
151 forward_locale(&channel).await;
153 channel
156 .request_pty(
157 false,
158 "xterm-256color",
159 cols as u32,
160 rows as u32,
161 0,
162 0,
163 &[(russh::Pty::IUTF8, 1)],
164 )
165 .await
166 .context("request remote pty")?;
167 channel
168 .request_shell(false)
169 .await
170 .context("request remote shell")?;
171 Ok(channel)
172}
173
174#[allow(clippy::too_many_arguments)] async fn session_task(
177 id: SessionId,
178 host: Host,
179 cols: u16,
180 rows: u16,
181 parser: Arc<Mutex<vt100::Parser>>,
182 mut ctrl_rx: mpsc::UnboundedReceiver<Ctrl>,
183 tx: mpsc::Sender<CoreEvent>,
184 raw_output: Option<mpsc::Sender<(SessionId, Vec<u8>)>>,
185) {
186 let result = async {
189 let handle = connect_and_auth(&host).await?;
190 open_shell(&handle, cols, rows).await.map(|ch| (handle, ch))
191 }
192 .await;
193 let (_handle, mut channel) = match result {
194 Ok(pair) => pair,
195 Err(e) => {
196 let _ = tx.send(CoreEvent::Error(format!("Terminal: {e}"))).await;
197 let _ = tx.send(CoreEvent::PtyExited(id)).await;
198 return;
199 }
200 };
201
202 loop {
206 tokio::select! {
207 msg = channel.wait() => match msg {
208 Some(ChannelMsg::Data { data })
210 | Some(ChannelMsg::ExtendedData { data, .. }) => {
211 feed_parser(&parser, &data);
212 let _ = tx.send(CoreEvent::PtyOutput(id)).await;
213 if let Some(raw) = &raw_output {
216 let _ = raw.send((id, data.to_vec())).await;
217 }
218 }
219 Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
220 _ => {} },
222 cmd = ctrl_rx.recv() => match cmd {
223 Some(Ctrl::Input(bytes)) => {
224 let _ = channel.data(&bytes[..]).await;
225 }
226 Some(Ctrl::Resize { cols, rows }) => {
227 let _ = channel.window_change(cols as u32, rows as u32, 0, 0).await;
228 }
229 Some(Ctrl::Close) | None => break,
230 },
231 }
232 }
233
234 let _ = channel.eof().await;
235 let _ = tx.send(CoreEvent::PtyExited(id)).await;
236 }
238
239pub struct PtyManager {
249 sessions: Vec<PtySession>,
250 next_id: u64,
251 raw_output: Option<mpsc::Sender<(SessionId, Vec<u8>)>>,
256}
257
258impl PtyManager {
259 pub fn new() -> Self {
261 Self {
262 sessions: Vec::new(),
263 next_id: 1,
264 raw_output: None,
265 }
266 }
267
268 pub fn with_raw_output(raw_output: mpsc::Sender<(SessionId, Vec<u8>)>) -> Self {
273 Self {
274 sessions: Vec::new(),
275 next_id: 1,
276 raw_output: Some(raw_output),
277 }
278 }
279
280 pub fn open(
289 &mut self,
290 host: &Host,
291 cols: u16,
292 rows: u16,
293 tx: mpsc::Sender<CoreEvent>,
294 ) -> Result<SessionId> {
295 if host.proxy_jump.is_some() {
298 anyhow::bail!("ProxyJump is not yet supported in the terminal");
299 }
300 let id = self.next_id;
301 self.next_id += 1;
302 let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 1000)));
303 let (ctrl_tx, ctrl_rx) = mpsc::unbounded_channel();
304 tokio::spawn(session_task(
305 id,
306 host.clone(),
307 cols,
308 rows,
309 Arc::clone(&parser),
310 ctrl_rx,
311 tx,
312 self.raw_output.clone(),
313 ));
314 self.sessions.push(PtySession {
315 id,
316 parser,
317 ctrl_tx,
318 });
319 tracing::info!("terminal session {} opened for host '{}'", id, host.name);
320 Ok(id)
321 }
322
323 pub fn write(&mut self, id: SessionId, data: &[u8]) -> Result<()> {
328 if let Some(s) = self.sessions.iter().find(|s| s.id == id) {
329 let _ = s.ctrl_tx.send(Ctrl::Input(data.to_vec()));
330 }
331 Ok(())
332 }
333
334 pub fn resize(&mut self, id: SessionId, cols: u16, rows: u16) -> Result<()> {
342 if let Some(s) = self.sessions.iter().find(|s| s.id == id) {
343 let _ = s.ctrl_tx.send(Ctrl::Resize { cols, rows });
344 }
345 Ok(())
346 }
347
348 pub fn close(&mut self, id: SessionId) {
350 if let Some(pos) = self.sessions.iter().position(|s| s.id == id) {
351 let s = self.sessions.remove(pos);
352 let _ = s.ctrl_tx.send(Ctrl::Close);
353 tracing::info!("terminal session {} closed", id);
354 }
355 }
356
357 pub fn shutdown(self) {
359 for s in self.sessions {
360 let _ = s.ctrl_tx.send(Ctrl::Close);
361 }
362 }
364
365 pub fn parser_for(&self, id: SessionId) -> Option<Arc<Mutex<vt100::Parser>>> {
367 self.sessions
368 .iter()
369 .find(|s| s.id == id)
370 .map(|s| Arc::clone(&s.parser))
371 }
372}
373
374impl Default for PtyManager {
375 fn default() -> Self {
376 Self::new()
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn dummy_tx() -> mpsc::Sender<CoreEvent> {
385 mpsc::channel(1).0
386 }
387
388 #[test]
389 fn default_construction_has_no_raw_tap() {
390 assert!(PtyManager::new().raw_output.is_none());
393 assert!(PtyManager::default().raw_output.is_none());
394 }
395
396 #[test]
397 fn with_raw_output_installs_the_tap() {
398 let (raw_tx, _raw_rx) = mpsc::channel::<(SessionId, Vec<u8>)>(1);
399 assert!(PtyManager::with_raw_output(raw_tx).raw_output.is_some());
400 }
401
402 #[tokio::test]
403 async fn open_assigns_incrementing_ids() {
404 let mut mgr = PtyManager::new();
405 let host = Host::default();
406 let a = mgr.open(&host, 80, 24, dummy_tx()).unwrap();
407 let b = mgr.open(&host, 80, 24, dummy_tx()).unwrap();
408 assert_eq!((a, b), (1, 2));
409 assert_eq!(mgr.sessions.len(), 2);
410 }
411
412 #[tokio::test]
413 async fn close_removes_only_the_target() {
414 let mut mgr = PtyManager::new();
415 let host = Host::default();
416 let a = mgr.open(&host, 80, 24, dummy_tx()).unwrap();
417 let b = mgr.open(&host, 80, 24, dummy_tx()).unwrap();
418 mgr.close(a);
419 assert_eq!(mgr.sessions.len(), 1);
420 assert_eq!(mgr.sessions[0].id, b);
421 }
422
423 #[tokio::test]
424 async fn write_and_resize_unknown_id_are_noops() {
425 let mut mgr = PtyManager::new();
426 assert!(mgr.write(999, b"x").is_ok());
427 assert!(mgr.resize(999, 80, 24).is_ok());
428 }
429
430 #[tokio::test]
431 async fn parser_for_returns_open_session_only() {
432 let mut mgr = PtyManager::new();
433 let id = mgr.open(&Host::default(), 80, 24, dummy_tx()).unwrap();
434 assert!(mgr.parser_for(id).is_some());
435 assert!(mgr.parser_for(id + 1).is_none());
436 }
437
438 fn env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
439 pairs
440 .iter()
441 .map(|(k, v)| (k.to_string(), v.to_string()))
442 .collect()
443 }
444
445 #[test]
446 fn is_utf8_locale_recognizes_common_forms() {
447 assert!(is_utf8_locale("en_US.UTF-8"));
448 assert!(is_utf8_locale("ru_RU.utf8"));
449 assert!(is_utf8_locale("de_DE.UTF-8@euro"));
450 assert!(!is_utf8_locale("C"));
451 assert!(!is_utf8_locale("POSIX"));
452 assert!(!is_utf8_locale("en_US.ISO8859-1"));
453 }
454
455 #[test]
456 fn locale_env_defaults_ctype_to_utf8_when_no_locale_env() {
457 let got = locale_env(env(&[("PATH", "/bin"), ("HOME", "/root")]));
460 assert_eq!(got, env(&[("LC_CTYPE", "C.UTF-8")]));
461 }
462
463 #[test]
464 fn locale_env_forces_utf8_when_lang_is_not_utf8() {
465 let got = locale_env(env(&[("LANG", "C")]));
466 assert!(got.contains(&("LANG".into(), "C".into())));
467 assert!(got.contains(&("LC_CTYPE".into(), "C.UTF-8".into())));
468 }
469
470 #[test]
471 fn locale_env_keeps_a_utf8_lang_and_adds_no_fallback() {
472 let got = locale_env(env(&[
475 ("LANG", "ru_RU.UTF-8"),
476 ("LC_MESSAGES", "ru_RU.UTF-8"),
477 ]));
478 assert!(got.contains(&("LANG".into(), "ru_RU.UTF-8".into())));
479 assert!(got.iter().all(|(k, _)| k != "LC_CTYPE"));
480 }
481
482 #[test]
483 fn locale_env_replaces_a_non_utf8_ctype_without_duplicating() {
484 let got = locale_env(env(&[("LC_CTYPE", "C")]));
485 assert_eq!(got, env(&[("LC_CTYPE", "C.UTF-8")]));
486 }
487
488 #[test]
489 fn locale_env_respects_an_explicit_utf8_ctype() {
490 let got = locale_env(env(&[("LC_CTYPE", "ru_RU.UTF-8")]));
491 assert_eq!(got, env(&[("LC_CTYPE", "ru_RU.UTF-8")]));
492 }
493
494 #[test]
495 fn locale_env_respects_lc_all() {
496 let got = locale_env(env(&[("LC_ALL", "C")]));
498 assert_eq!(got, env(&[("LC_ALL", "C")]));
499 }
500}