Skip to main content

omnyssh_core/ssh/
pty.rs

1//! russh-backed SSH sessions for the multi-session terminal.
2//!
3//! Each session is a tokio task that owns a russh [`Channel`] with a
4//! server-allocated PTY (via `request_pty` + `request_shell`), drives a
5//! [`vt100::Parser`], and multiplexes I/O over a control channel. The parsed
6//! screen state is exposed via an `Arc<Mutex<vt100::Parser>>` that the render
7//! loop can snapshot without blocking. Using russh over a plain TCP socket
8//! avoids the local pseudo-console entirely, so the same code path works on
9//! every OS (notably fixing the dead Windows terminal).
10//!
11//! [`PtyManager`] owns all active sessions and provides a simple API for the
12//! application layer.
13
14use 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
25/// Stable numeric identifier for a PTY session (mirrors [`crate::event::SessionId`]).
26pub type SessionId = u64;
27
28// ---------------------------------------------------------------------------
29// Control messages — main thread → session task
30// ---------------------------------------------------------------------------
31
32/// A command sent to a session's owning task.
33enum Ctrl {
34    /// Keystrokes or pasted bytes for the remote shell.
35    Input(Vec<u8>),
36    /// Window resize; forwarded to the server as `window_change`.
37    Resize { cols: u16, rows: u16 },
38    /// Request the task to end and close the connection.
39    Close,
40}
41
42// ---------------------------------------------------------------------------
43// PtySession — a handle to a running session task
44// ---------------------------------------------------------------------------
45
46/// Handle to a session task. Holds the shared parser for rendering and the
47/// control sender; dropping the sender (or sending [`Ctrl::Close`]) ends the
48/// task, which closes the russh connection.
49struct PtySession {
50    /// Unique identifier for this session.
51    id: SessionId,
52    /// Shared VT100 parser. The task writes into it; the render loop takes a
53    /// read-side snapshot. Held only for microseconds to avoid blocking.
54    parser: Arc<Mutex<vt100::Parser>>,
55    /// Control channel to the session task (input / resize / close).
56    ctrl_tx: mpsc::UnboundedSender<Ctrl>,
57}
58
59/// Feeds bytes to the parser in 256-byte sub-chunks, releasing the lock between
60/// chunks so a large burst does not starve the render loop.
61fn 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
73/// Whether a locale value names the UTF-8 codeset (`en_US.UTF-8`, `ru_RU.utf8`,
74/// `de_DE.UTF-8@euro`, …).
75fn 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
85/// The locale environment to forward to a remote shell: every `LANG`/`LC_*`
86/// variable from `vars`, plus an `LC_CTYPE=C.UTF-8` fallback *only* when the
87/// forwarded set doesn't already resolve the character type to UTF-8. Mirrors an
88/// ssh client's `SendEnv LANG LC_*` while guaranteeing a UTF-8 character type for
89/// a process with no locale env (a GUI launched from Finder) — without clobbering
90/// an already-UTF-8 `LANG` with a `C.UTF-8` the server may not have. Pure, so it
91/// can be unit-tested; [`forward_locale`] just relays the result.
92fn 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    // `LC_ALL` overrides every category, so respect the user's explicit choice
110    // verbatim. Otherwise the character type resolves from `LC_CTYPE` then `LANG`;
111    // force UTF-8 only when neither already provides it, replacing a non-UTF-8
112    // `LC_CTYPE` rather than sending a duplicate.
113    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
127/// Forwards a UTF-8 locale to the remote shell, mirroring an ssh client's
128/// default `SendEnv LANG LC_*`. Best-effort: servers without `AcceptEnv` ignore
129/// it, so behaviour is unchanged there.
130async fn forward_locale(channel: &russh::Channel<russh::client::Msg>) {
131    // `vars_os` (not `vars`) so a non-UTF-8 variable elsewhere in the environment
132    // can't panic this session task; locale values are ASCII, so dropping any
133    // undecodable entry is safe.
134    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
141/// Opens a channel and requests a remote PTY + shell (the `ssh -t` equivalent).
142async 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    // Sent before the shell starts so it inherits the locale.
152    forward_locale(&channel).await;
153    // IUTF8 tells the server's line discipline that input is UTF-8, so multibyte
154    // (e.g. Cyrillic) editing works in canonical mode. Unknown modes are ignored.
155    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/// Owns the russh channel for one session and multiplexes I/O until close/EOF.
175#[allow(clippy::too_many_arguments)] // additive raw-output sink (§3.6) is the 8th
176async 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    // Phase A/B: connect, authenticate, and open the remote shell. Failures are
187    // reported in the status bar and tear the tab down via PtyExited.
188    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    // Phase C: pump loop (official russh interactive idiom). `wait` is the only
203    // &mut method, so a single owning task can select over it and the control
204    // receiver without aliasing.
205    loop {
206        tokio::select! {
207            msg = channel.wait() => match msg {
208                // stdout and remote stderr both render in a real terminal.
209                Some(ChannelMsg::Data { data })
210                | Some(ChannelMsg::ExtendedData { data, .. }) => {
211                    feed_parser(&parser, &data);
212                    let _ = tx.send(CoreEvent::PtyOutput(id)).await;
213                    // Additive GUI tap (tech-gui.md §3.6): mirror the raw bytes to
214                    // the frontend channel. Absent for the TUI → no behaviour change.
215                    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                _ => {} // ExitStatus etc.: ignore, wait for Close.
221            },
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    // _handle drops here → russh closes the TCP connection.
237}
238
239// ---------------------------------------------------------------------------
240// PtyManager
241// ---------------------------------------------------------------------------
242
243/// Manages all active terminal sessions.
244///
245/// Stored by the frontend outside of its shared state to avoid reference cycles.
246/// Dropping `PtyManager` or calling [`PtyManager::shutdown`] closes all
247/// sessions gracefully.
248pub struct PtyManager {
249    sessions: Vec<PtySession>,
250    next_id: u64,
251    /// Optional raw-output tap (GUI only). When set, each session task ALSO
252    /// forwards every data chunk here, tagged by its [`SessionId`], alongside the
253    /// existing parser feed + `PtyOutput` nudge. `None` for the TUI, whose path is
254    /// byte-for-byte unchanged.
255    raw_output: Option<mpsc::Sender<(SessionId, Vec<u8>)>>,
256}
257
258impl PtyManager {
259    /// Creates an empty manager with no raw-output tap (the TUI path).
260    pub fn new() -> Self {
261        Self {
262            sessions: Vec::new(),
263            next_id: 1,
264            raw_output: None,
265        }
266    }
267
268    /// Creates an empty manager that also mirrors every session's raw bytes into
269    /// `raw_output`, keyed by [`SessionId`] (the GUI terminal tap). Sessions and
270    /// the id space are identical to [`PtyManager::new`]; only the extra sink
271    /// differs, so the TUI construction path is unaffected.
272    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    /// Opens a new terminal tab for `host` and returns the assigned [`SessionId`].
281    ///
282    /// Returns immediately; the connection runs in a background task. Connection
283    /// or auth errors surface later via `CoreEvent::Error` + `PtyExited`.
284    ///
285    /// # Errors
286    /// Currently infallible, but kept fallible so the caller's error handling
287    /// (and the public API) stays unchanged.
288    pub fn open(
289        &mut self,
290        host: &Host,
291        cols: u16,
292        rows: u16,
293        tx: mpsc::Sender<CoreEvent>,
294    ) -> Result<SessionId> {
295        // ProxyJump is not yet wired into the russh terminal path. Refuse rather
296        // than silently connecting direct to the wrong host.
297        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    /// Sends raw bytes to the session identified by `id`. Unknown id is a no-op.
324    ///
325    /// # Errors
326    /// Infallible in practice; a dropped task is ignored like a closed session.
327    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    /// Forwards a resize to the session identified by `id`. No-op if not found.
335    ///
336    /// The vt100 parser is resized by the app layer; the task only relays
337    /// `window_change` to the server.
338    ///
339    /// # Errors
340    /// Infallible; kept fallible to preserve the public signature.
341    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    /// Closes and removes the session with the given `id`.
349    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    /// Gracefully shuts down all sessions.
358    pub fn shutdown(self) {
359        for s in self.sessions {
360            let _ = s.ctrl_tx.send(Ctrl::Close);
361        }
362        // Dropping each ctrl_tx also ends its task as a backstop.
363    }
364
365    /// Returns the parser `Arc` for the session with the given `id`, if any.
366    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        // The TUI path must be byte-for-byte unchanged (tech-gui.md §3.6): the
391        // additive sink is absent unless a GUI opts in via `with_raw_output`.
392        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        // A process with no locale env (e.g. a GUI launched from Finder) still
458        // gets a UTF-8 character type forwarded.
459        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        // Regression guard: a working UTF-8 LANG must not be overridden by a
473        // C.UTF-8 the server might not have.
474        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        // LC_ALL overrides every category, so leave the explicit choice untouched.
497        let got = locale_env(env(&[("LC_ALL", "C")]));
498        assert_eq!(got, env(&[("LC_ALL", "C")]));
499    }
500}