Skip to main content

bssh/server/
pty.rs

1// Copyright 2025 Lablup Inc. and Jeongkyu Shin
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! PTY (pseudo-terminal) management for SSH shell sessions.
16//!
17//! This module provides Unix PTY handling for interactive shell sessions.
18//! It creates PTY master/slave pairs, manages window sizes, and provides
19//! async I/O for the PTY master file descriptor.
20//!
21//! # Platform Support
22//!
23//! This module uses POSIX PTY APIs and is Unix-specific. Windows support
24//! would require ConPTY (future enhancement).
25//!
26//! # Example
27//!
28//! ```ignore
29//! use bssh::server::pty::{PtyMaster, PtyConfig};
30//!
31//! let config = PtyConfig::new("xterm-256color".to_string(), 80, 24, 0, 0);
32//! let pty = PtyMaster::open(config)?;
33//! ```
34
35use std::io;
36use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd};
37use std::path::PathBuf;
38
39use anyhow::{Context, Result};
40use nix::libc;
41use nix::pty::{OpenptyResult, Winsize, openpty};
42use nix::unistd;
43use tokio::io::unix::AsyncFd;
44use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
45
46/// Default terminal type if not specified by client.
47pub const DEFAULT_TERM: &str = "xterm-256color";
48
49/// Default terminal columns.
50pub const DEFAULT_COLS: u32 = 80;
51
52/// Default terminal rows.
53pub const DEFAULT_ROWS: u32 = 24;
54
55/// Maximum value for terminal dimensions (u16::MAX).
56const MAX_DIMENSION: u32 = u16::MAX as u32;
57
58/// PTY configuration from SSH pty_request.
59///
60/// Contains terminal settings requested by the SSH client.
61#[derive(Debug, Clone)]
62pub struct PtyConfig {
63    /// Terminal type (e.g., "xterm-256color").
64    pub term: String,
65
66    /// Width in columns.
67    pub col_width: u32,
68
69    /// Height in rows.
70    pub row_height: u32,
71
72    /// Width in pixels (may be 0 if unknown).
73    pub pix_width: u32,
74
75    /// Height in pixels (may be 0 if unknown).
76    pub pix_height: u32,
77}
78
79impl PtyConfig {
80    /// Create a new PTY configuration.
81    pub fn new(
82        term: String,
83        col_width: u32,
84        row_height: u32,
85        pix_width: u32,
86        pix_height: u32,
87    ) -> Self {
88        Self {
89            term,
90            col_width,
91            row_height,
92            pix_width,
93            pix_height,
94        }
95    }
96
97    /// Create a Winsize struct from this configuration.
98    ///
99    /// Values exceeding u16::MAX are clamped to u16::MAX to prevent overflow.
100    pub fn winsize(&self) -> Winsize {
101        Winsize {
102            ws_row: self.row_height.min(MAX_DIMENSION) as u16,
103            ws_col: self.col_width.min(MAX_DIMENSION) as u16,
104            ws_xpixel: self.pix_width.min(MAX_DIMENSION) as u16,
105            ws_ypixel: self.pix_height.min(MAX_DIMENSION) as u16,
106        }
107    }
108}
109
110impl Default for PtyConfig {
111    fn default() -> Self {
112        Self {
113            term: DEFAULT_TERM.to_string(),
114            col_width: DEFAULT_COLS,
115            row_height: DEFAULT_ROWS,
116            pix_width: 0,
117            pix_height: 0,
118        }
119    }
120}
121
122/// PTY master handle with async I/O support.
123///
124/// Manages the master side of a PTY pair. The slave side path is provided
125/// for the shell process to open.
126pub struct PtyMaster {
127    /// The configuration used to create this PTY.
128    config: PtyConfig,
129
130    /// Async file descriptor wrapper for the master.
131    async_fd: AsyncFd<OwnedFd>,
132
133    /// Path to the slave PTY device.
134    slave_path: PathBuf,
135}
136
137impl PtyMaster {
138    /// Open a new PTY pair with the given configuration.
139    ///
140    /// # Arguments
141    ///
142    /// * `config` - PTY configuration including terminal size
143    ///
144    /// # Returns
145    ///
146    /// Returns a `PtyMaster` on success, or an error if PTY creation fails.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if:
151    /// - PTY pair creation fails
152    /// - Getting slave path fails
153    /// - Setting window size fails
154    /// - Making master non-blocking fails
155    pub fn open(config: PtyConfig) -> Result<Self> {
156        // Open PTY master/slave pair
157        let OpenptyResult {
158            master: master_fd,
159            slave: slave_fd,
160        } = openpty(None, None).context("Failed to open PTY pair")?;
161
162        // Get slave path before closing slave fd
163        let slave_path =
164            unistd::ttyname(slave_fd.as_fd()).context("Failed to get slave TTY path")?;
165
166        // Set initial window size on slave
167        Self::set_window_size_fd(slave_fd.as_fd(), &config.winsize())
168            .context("Failed to set initial window size")?;
169
170        // Close slave fd - will be reopened by child process
171        drop(slave_fd);
172
173        // Make master fd non-blocking for async I/O
174        Self::set_nonblocking(master_fd.as_fd())?;
175
176        // Wrap in AsyncFd for tokio integration
177        let async_fd = AsyncFd::new(master_fd).context("Failed to create AsyncFd")?;
178
179        Ok(Self {
180            config,
181            async_fd,
182            slave_path,
183        })
184    }
185
186    /// Get the slave PTY device path.
187    ///
188    /// This path should be used by the shell process to open the slave
189    /// side of the PTY.
190    pub fn slave_path(&self) -> &PathBuf {
191        &self.slave_path
192    }
193
194    /// Get the PTY configuration.
195    pub fn config(&self) -> &PtyConfig {
196        &self.config
197    }
198
199    /// Get the raw file descriptor for the master.
200    pub fn as_raw_fd(&self) -> RawFd {
201        self.async_fd.get_ref().as_raw_fd()
202    }
203
204    /// Resize the terminal window.
205    ///
206    /// # Arguments
207    ///
208    /// * `cols` - New width in columns
209    /// * `rows` - New height in rows
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if the ioctl to set window size fails.
214    pub fn resize(&mut self, cols: u32, rows: u32) -> Result<()> {
215        self.config.col_width = cols;
216        self.config.row_height = rows;
217
218        let winsize = self.config.winsize();
219        Self::set_window_size_fd(self.async_fd.get_ref().as_fd(), &winsize)
220    }
221
222    /// Set window size on a file descriptor.
223    fn set_window_size_fd(fd: BorrowedFd<'_>, winsize: &Winsize) -> Result<()> {
224        // SAFETY: The fd is valid and we're passing a valid Winsize struct
225        let result = unsafe { libc::ioctl(fd.as_raw_fd(), libc::TIOCSWINSZ, winsize) };
226
227        if result < 0 {
228            Err(io::Error::last_os_error()).context("Failed to set window size (TIOCSWINSZ ioctl)")
229        } else {
230            Ok(())
231        }
232    }
233
234    /// Set a file descriptor to non-blocking mode.
235    fn set_nonblocking(fd: BorrowedFd<'_>) -> Result<()> {
236        // Get current flags
237        let flags = nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_GETFL).context("F_GETFL")?;
238
239        // Add O_NONBLOCK
240        let new_flags =
241            nix::fcntl::OFlag::from_bits_truncate(flags) | nix::fcntl::OFlag::O_NONBLOCK;
242
243        nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_SETFL(new_flags)).context("F_SETFL")?;
244
245        Ok(())
246    }
247
248    /// Read data from the PTY master.
249    ///
250    /// This is an async operation that waits for data to be available.
251    pub async fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
252        loop {
253            let mut guard = self.async_fd.readable().await?;
254
255            match guard.try_io(|inner| {
256                let fd = inner.get_ref().as_raw_fd();
257                // SAFETY: fd is valid and buf is a valid slice
258                let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
259                if n < 0 {
260                    Err(io::Error::last_os_error())
261                } else {
262                    Ok(n as usize)
263                }
264            }) {
265                Ok(result) => return result,
266                Err(_would_block) => continue,
267            }
268        }
269    }
270
271    /// Write data to the PTY master.
272    ///
273    /// This is an async operation that waits for the fd to be writable.
274    pub async fn write(&self, buf: &[u8]) -> io::Result<usize> {
275        loop {
276            let mut guard = self.async_fd.writable().await?;
277
278            match guard.try_io(|inner| {
279                let fd = inner.get_ref().as_raw_fd();
280                // SAFETY: fd is valid and buf is a valid slice
281                let n = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) };
282                if n < 0 {
283                    Err(io::Error::last_os_error())
284                } else {
285                    Ok(n as usize)
286                }
287            }) {
288                Ok(result) => return result,
289                Err(_would_block) => continue,
290            }
291        }
292    }
293
294    /// Write all data to the PTY master.
295    ///
296    /// Continues writing until all bytes are written or an error occurs.
297    pub async fn write_all(&self, mut buf: &[u8]) -> io::Result<()> {
298        while !buf.is_empty() {
299            let n = self.write(buf).await?;
300            buf = &buf[n..];
301        }
302        Ok(())
303    }
304}
305
306impl std::fmt::Debug for PtyMaster {
307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308        f.debug_struct("PtyMaster")
309            .field("config", &self.config)
310            .field("slave_path", &self.slave_path)
311            .field("fd", &self.as_raw_fd())
312            .finish()
313    }
314}
315
316/// Async reader for PTY master.
317///
318/// Implements `AsyncRead` for use with tokio I/O utilities.
319pub struct PtyReader<'a> {
320    pty: &'a PtyMaster,
321}
322
323impl<'a> PtyReader<'a> {
324    /// Create a new async reader for the PTY.
325    pub fn new(pty: &'a PtyMaster) -> Self {
326        Self { pty }
327    }
328}
329
330impl AsyncRead for PtyReader<'_> {
331    fn poll_read(
332        self: std::pin::Pin<&mut Self>,
333        cx: &mut std::task::Context<'_>,
334        buf: &mut ReadBuf<'_>,
335    ) -> std::task::Poll<io::Result<()>> {
336        loop {
337            let mut guard = match self.pty.async_fd.poll_read_ready(cx) {
338                std::task::Poll::Ready(Ok(guard)) => guard,
339                std::task::Poll::Ready(Err(e)) => return std::task::Poll::Ready(Err(e)),
340                std::task::Poll::Pending => return std::task::Poll::Pending,
341            };
342
343            let unfilled = buf.initialize_unfilled();
344            let fd = self.pty.async_fd.get_ref().as_raw_fd();
345
346            // SAFETY: fd is valid, unfilled is a valid slice
347            let result = unsafe { libc::read(fd, unfilled.as_mut_ptr() as *mut _, unfilled.len()) };
348
349            if result < 0 {
350                let err = io::Error::last_os_error();
351                if err.kind() == io::ErrorKind::WouldBlock {
352                    guard.clear_ready();
353                    continue;
354                }
355                return std::task::Poll::Ready(Err(err));
356            }
357
358            buf.advance(result as usize);
359            return std::task::Poll::Ready(Ok(()));
360        }
361    }
362}
363
364/// Async writer for PTY master.
365///
366/// Implements `AsyncWrite` for use with tokio I/O utilities.
367pub struct PtyWriter<'a> {
368    pty: &'a PtyMaster,
369}
370
371impl<'a> PtyWriter<'a> {
372    /// Create a new async writer for the PTY.
373    pub fn new(pty: &'a PtyMaster) -> Self {
374        Self { pty }
375    }
376}
377
378impl AsyncWrite for PtyWriter<'_> {
379    fn poll_write(
380        self: std::pin::Pin<&mut Self>,
381        cx: &mut std::task::Context<'_>,
382        buf: &[u8],
383    ) -> std::task::Poll<io::Result<usize>> {
384        loop {
385            let mut guard = match self.pty.async_fd.poll_write_ready(cx) {
386                std::task::Poll::Ready(Ok(guard)) => guard,
387                std::task::Poll::Ready(Err(e)) => return std::task::Poll::Ready(Err(e)),
388                std::task::Poll::Pending => return std::task::Poll::Pending,
389            };
390
391            let fd = self.pty.async_fd.get_ref().as_raw_fd();
392
393            // SAFETY: fd is valid, buf is a valid slice
394            let result = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) };
395
396            if result < 0 {
397                let err = io::Error::last_os_error();
398                if err.kind() == io::ErrorKind::WouldBlock {
399                    guard.clear_ready();
400                    continue;
401                }
402                return std::task::Poll::Ready(Err(err));
403            }
404
405            return std::task::Poll::Ready(Ok(result as usize));
406        }
407    }
408
409    fn poll_flush(
410        self: std::pin::Pin<&mut Self>,
411        _cx: &mut std::task::Context<'_>,
412    ) -> std::task::Poll<io::Result<()>> {
413        // PTY doesn't need explicit flushing
414        std::task::Poll::Ready(Ok(()))
415    }
416
417    fn poll_shutdown(
418        self: std::pin::Pin<&mut Self>,
419        _cx: &mut std::task::Context<'_>,
420    ) -> std::task::Poll<io::Result<()>> {
421        // PTY shutdown is handled by dropping
422        std::task::Poll::Ready(Ok(()))
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_pty_config_default() {
432        let config = PtyConfig::default();
433
434        assert_eq!(config.term, DEFAULT_TERM);
435        assert_eq!(config.col_width, DEFAULT_COLS);
436        assert_eq!(config.row_height, DEFAULT_ROWS);
437        assert_eq!(config.pix_width, 0);
438        assert_eq!(config.pix_height, 0);
439    }
440
441    #[test]
442    fn test_pty_config_new() {
443        let config = PtyConfig::new("vt100".to_string(), 132, 50, 1024, 768);
444
445        assert_eq!(config.term, "vt100");
446        assert_eq!(config.col_width, 132);
447        assert_eq!(config.row_height, 50);
448        assert_eq!(config.pix_width, 1024);
449        assert_eq!(config.pix_height, 768);
450    }
451
452    #[test]
453    fn test_pty_config_winsize() {
454        let config = PtyConfig::new("xterm".to_string(), 80, 24, 640, 480);
455        let winsize = config.winsize();
456
457        assert_eq!(winsize.ws_col, 80);
458        assert_eq!(winsize.ws_row, 24);
459        assert_eq!(winsize.ws_xpixel, 640);
460        assert_eq!(winsize.ws_ypixel, 480);
461    }
462
463    #[test]
464    fn test_pty_config_winsize_overflow_clamping() {
465        // Test that values exceeding u16::MAX are clamped
466        let config = PtyConfig::new("xterm".to_string(), 100_000, 100_000, 100_000, 100_000);
467        let winsize = config.winsize();
468
469        assert_eq!(winsize.ws_col, u16::MAX);
470        assert_eq!(winsize.ws_row, u16::MAX);
471        assert_eq!(winsize.ws_xpixel, u16::MAX);
472        assert_eq!(winsize.ws_ypixel, u16::MAX);
473    }
474
475    #[tokio::test]
476    async fn test_pty_master_open() {
477        let config = PtyConfig::default();
478        let result = PtyMaster::open(config);
479
480        // PTY creation should succeed on Unix systems
481        assert!(result.is_ok(), "Failed to open PTY: {:?}", result.err());
482
483        let pty = result.unwrap();
484        assert!(pty.slave_path().exists());
485        assert!(pty.as_raw_fd() >= 0);
486    }
487
488    #[tokio::test]
489    async fn test_pty_master_resize() {
490        let config = PtyConfig::default();
491        let mut pty = PtyMaster::open(config).expect("Failed to open PTY");
492
493        // Resize should succeed
494        assert!(pty.resize(120, 40).is_ok());
495        assert_eq!(pty.config().col_width, 120);
496        assert_eq!(pty.config().row_height, 40);
497    }
498
499    #[tokio::test]
500    async fn test_pty_master_read_write() {
501        use std::fs::OpenOptions;
502
503        let config = PtyConfig::default();
504        let pty = PtyMaster::open(config).expect("Failed to open PTY");
505
506        // Open the slave side to prevent EIO errors when writing to master
507        // Without a slave connection, writes to the master may fail
508        let slave_path = pty.slave_path();
509        let _slave = OpenOptions::new()
510            .read(true)
511            .write(true)
512            .open(slave_path)
513            .expect("Failed to open PTY slave");
514
515        // Write some data
516        let test_data = b"hello\n";
517        let write_result = pty.write(test_data).await;
518        assert!(
519            write_result.is_ok(),
520            "Write failed: {:?}",
521            write_result.err()
522        );
523
524        // Note: Reading requires something on the other end (slave) to echo
525        // This is tested more thoroughly in integration tests
526    }
527
528    #[tokio::test]
529    async fn test_pty_master_debug() {
530        let config = PtyConfig::default();
531        let pty = PtyMaster::open(config).expect("Failed to open PTY");
532
533        let debug = format!("{:?}", pty);
534        assert!(debug.contains("PtyMaster"));
535        assert!(debug.contains("config"));
536        assert!(debug.contains("slave_path"));
537    }
538
539    #[test]
540    fn test_default_constants() {
541        assert_eq!(DEFAULT_TERM, "xterm-256color");
542        assert_eq!(DEFAULT_COLS, 80);
543        assert_eq!(DEFAULT_ROWS, 24);
544    }
545
546    #[test]
547    fn test_pty_config_clone() {
548        let config = PtyConfig::new("vt220".to_string(), 100, 30, 500, 400);
549        let cloned = config.clone();
550
551        assert_eq!(config.term, cloned.term);
552        assert_eq!(config.col_width, cloned.col_width);
553        assert_eq!(config.row_height, cloned.row_height);
554        assert_eq!(config.pix_width, cloned.pix_width);
555        assert_eq!(config.pix_height, cloned.pix_height);
556    }
557
558    #[test]
559    fn test_pty_config_debug() {
560        let config = PtyConfig::new("screen".to_string(), 200, 60, 1920, 1080);
561        let debug_str = format!("{:?}", config);
562
563        assert!(debug_str.contains("PtyConfig"));
564        assert!(debug_str.contains("screen"));
565        assert!(debug_str.contains("200"));
566        assert!(debug_str.contains("60"));
567    }
568
569    #[test]
570    fn test_winsize_boundary_values() {
571        // Test with zero values
572        let config = PtyConfig::new("xterm".to_string(), 0, 0, 0, 0);
573        let winsize = config.winsize();
574        assert_eq!(winsize.ws_col, 0);
575        assert_eq!(winsize.ws_row, 0);
576        assert_eq!(winsize.ws_xpixel, 0);
577        assert_eq!(winsize.ws_ypixel, 0);
578
579        // Test with max u16 values
580        let config = PtyConfig::new(
581            "xterm".to_string(),
582            u16::MAX as u32,
583            u16::MAX as u32,
584            u16::MAX as u32,
585            u16::MAX as u32,
586        );
587        let winsize = config.winsize();
588        assert_eq!(winsize.ws_col, u16::MAX);
589        assert_eq!(winsize.ws_row, u16::MAX);
590        assert_eq!(winsize.ws_xpixel, u16::MAX);
591        assert_eq!(winsize.ws_ypixel, u16::MAX);
592    }
593
594    #[tokio::test]
595    async fn test_pty_master_fd_validity() {
596        let config = PtyConfig::default();
597        let pty = PtyMaster::open(config).expect("Failed to open PTY");
598
599        // File descriptor should be non-negative
600        let fd = pty.as_raw_fd();
601        assert!(
602            fd >= 0,
603            "PTY file descriptor should be valid (non-negative)"
604        );
605    }
606
607    #[tokio::test]
608    async fn test_pty_master_slave_path_format() {
609        let config = PtyConfig::default();
610        let pty = PtyMaster::open(config).expect("Failed to open PTY");
611
612        let slave_path = pty.slave_path();
613
614        // Slave path should be a PTY device path
615        let path_str = slave_path.to_string_lossy();
616        assert!(
617            path_str.starts_with("/dev/pts/") || path_str.starts_with("/dev/tty"),
618            "Slave path should be a PTY device: {}",
619            path_str
620        );
621    }
622
623    #[tokio::test]
624    async fn test_pty_master_config_accessor() {
625        let config = PtyConfig::new("linux".to_string(), 132, 43, 1024, 768);
626        let pty = PtyMaster::open(config).expect("Failed to open PTY");
627
628        let retrieved_config = pty.config();
629        assert_eq!(retrieved_config.term, "linux");
630        assert_eq!(retrieved_config.col_width, 132);
631        assert_eq!(retrieved_config.row_height, 43);
632    }
633
634    #[tokio::test]
635    async fn test_pty_master_multiple_resizes() {
636        let config = PtyConfig::default();
637        let mut pty = PtyMaster::open(config).expect("Failed to open PTY");
638
639        // Resize multiple times
640        assert!(pty.resize(100, 30).is_ok());
641        assert_eq!(pty.config().col_width, 100);
642        assert_eq!(pty.config().row_height, 30);
643
644        assert!(pty.resize(200, 50).is_ok());
645        assert_eq!(pty.config().col_width, 200);
646        assert_eq!(pty.config().row_height, 50);
647
648        // Resize back to original
649        assert!(pty.resize(80, 24).is_ok());
650        assert_eq!(pty.config().col_width, 80);
651        assert_eq!(pty.config().row_height, 24);
652    }
653
654    #[test]
655    fn test_pty_reader_new() {
656        // This test verifies the PtyReader can be created
657        // Full testing requires a runtime context
658    }
659
660    #[test]
661    fn test_pty_writer_new() {
662        // This test verifies the PtyWriter can be created
663        // Full testing requires a runtime context
664    }
665}