Skip to main content

rust_expect/backend/
pty.rs

1//! PTY backend for local process spawning.
2//!
3//! This module provides the PTY backend that uses the rust-pty crate
4//! to spawn local processes with pseudo-terminal support.
5
6use std::io;
7use std::pin::Pin;
8use std::task::{Context, Poll};
9
10use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
11
12use crate::backend::ChildExit;
13use crate::config::SessionConfig;
14use crate::error::{ExpectError, Result, SpawnError};
15use crate::types::ProcessExitStatus;
16
17/// A PTY-based transport for local process communication.
18pub struct PtyTransport {
19    /// The PTY reader half.
20    reader: Box<dyn AsyncRead + Unpin + Send>,
21    /// The PTY writer half.
22    writer: Box<dyn AsyncWrite + Unpin + Send>,
23    /// Process ID.
24    pid: Option<u32>,
25}
26
27impl PtyTransport {
28    /// Create a new PTY transport from reader and writer.
29    pub fn new<R, W>(reader: R, writer: W) -> Self
30    where
31        R: AsyncRead + Unpin + Send + 'static,
32        W: AsyncWrite + Unpin + Send + 'static,
33    {
34        Self {
35            reader: Box::new(reader),
36            writer: Box::new(writer),
37            pid: None,
38        }
39    }
40
41    /// Set the process ID.
42    pub const fn set_pid(&mut self, pid: u32) {
43        self.pid = Some(pid);
44    }
45
46    /// Get the process ID.
47    #[must_use]
48    pub const fn pid(&self) -> Option<u32> {
49        self.pid
50    }
51}
52
53impl AsyncRead for PtyTransport {
54    fn poll_read(
55        mut self: Pin<&mut Self>,
56        cx: &mut Context<'_>,
57        buf: &mut ReadBuf<'_>,
58    ) -> Poll<io::Result<()>> {
59        Pin::new(&mut self.reader).poll_read(cx, buf)
60    }
61}
62
63impl AsyncWrite for PtyTransport {
64    fn poll_write(
65        mut self: Pin<&mut Self>,
66        cx: &mut Context<'_>,
67        buf: &[u8],
68    ) -> Poll<io::Result<usize>> {
69        Pin::new(&mut self.writer).poll_write(cx, buf)
70    }
71
72    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
73        Pin::new(&mut self.writer).poll_flush(cx)
74    }
75
76    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
77        Pin::new(&mut self.writer).poll_shutdown(cx)
78    }
79}
80
81/// Configuration for PTY spawning.
82#[derive(Debug, Clone)]
83#[non_exhaustive]
84pub struct PtyConfig {
85    /// Terminal dimensions (cols, rows).
86    pub dimensions: (u16, u16),
87    /// Whether to use a login shell.
88    pub login_shell: bool,
89    /// Environment variable handling.
90    pub env_mode: EnvMode,
91    /// Environment variables to apply per `env_mode` (overlay for `Extend`,
92    /// the full set for `Clear`, ignored for `Inherit`).
93    pub env: std::collections::HashMap<String, String>,
94    /// Working directory for the spawned child. `None` inherits the parent's
95    /// current directory.
96    pub working_directory: Option<std::path::PathBuf>,
97}
98
99impl Default for PtyConfig {
100    fn default() -> Self {
101        Self {
102            dimensions: (80, 24),
103            login_shell: false,
104            env_mode: EnvMode::Inherit,
105            env: std::collections::HashMap::new(),
106            working_directory: None,
107        }
108    }
109}
110
111impl From<&SessionConfig> for PtyConfig {
112    fn from(config: &SessionConfig) -> Self {
113        Self {
114            dimensions: config.dimensions,
115            login_shell: false,
116            env_mode: match (config.inherit_env, config.env.is_empty()) {
117                (false, _) => EnvMode::Clear,
118                (true, true) => EnvMode::Inherit,
119                (true, false) => EnvMode::Extend,
120            },
121            env: config.env.clone(),
122            working_directory: config.working_dir.clone(),
123        }
124    }
125}
126
127/// Environment variable handling mode.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum EnvMode {
130    /// Inherit all environment variables from parent.
131    Inherit,
132    /// Clear environment and only use specified variables.
133    Clear,
134    /// Inherit and extend with specified variables.
135    Extend,
136}
137
138/// Spawner for PTY sessions.
139pub struct PtySpawner {
140    config: PtyConfig,
141}
142
143impl PtySpawner {
144    /// Create a new PTY spawner with default configuration.
145    #[must_use]
146    pub fn new() -> Self {
147        Self {
148            config: PtyConfig::default(),
149        }
150    }
151
152    /// Create a new PTY spawner with custom configuration.
153    #[must_use]
154    pub const fn with_config(config: PtyConfig) -> Self {
155        Self { config }
156    }
157
158    /// Set the terminal dimensions.
159    pub const fn set_dimensions(&mut self, cols: u16, rows: u16) {
160        self.config.dimensions = (cols, rows);
161    }
162
163    /// Spawn a command.
164    ///
165    /// The Unix implementation spawns via `tokio::process::Command` (through
166    /// rust-pty's `UnixPtySystem`); the only work between fork and exec is the
167    /// async-signal-safe `setsid` + `TIOCSCTTY` in rust-pty's `pre_exec` hook,
168    /// so it is safe under a multi-threaded Tokio runtime (the default
169    /// `#[tokio::main]`). Environment and working-directory setup happen in the
170    /// parent before spawning.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if PTY allocation or process spawning fails.
175    #[cfg(unix)]
176    pub async fn spawn(&self, command: &str, args: &[String]) -> Result<PtyHandle> {
177        use rust_pty::{PtySystem, UnixPtySystem};
178
179        // Preserve the `InvalidWorkingDir` contract: rust-pty surfaces a missing
180        // working directory as a generic spawn failure, so validate it up front
181        // for a clear, specific error.
182        if let Some(dir) = &self.config.working_directory
183            && !dir.is_dir()
184        {
185            return Err(ExpectError::Spawn(SpawnError::InvalidWorkingDir {
186                path: dir.display().to_string(),
187            }));
188        }
189
190        // Build env per env_mode (mirrors the Windows branch):
191        // - Inherit (no overrides): env: None (rust-pty inherits the parent env).
192        // - Inherit/Extend (with overrides): parent env + overrides (ours win).
193        // - Clear: only our overrides (parent env discarded).
194        let built_env: Option<std::collections::HashMap<std::ffi::OsString, std::ffi::OsString>> =
195            match self.config.env_mode {
196                EnvMode::Inherit if self.config.env.is_empty() => None,
197                EnvMode::Inherit | EnvMode::Extend => {
198                    let mut m: std::collections::HashMap<_, _> = std::env::vars_os().collect();
199                    for (k, v) in &self.config.env {
200                        m.insert(std::ffi::OsString::from(k), std::ffi::OsString::from(v));
201                    }
202                    Some(m)
203                }
204                EnvMode::Clear => Some(
205                    self.config
206                        .env
207                        .iter()
208                        .map(|(k, v)| (std::ffi::OsString::from(k), std::ffi::OsString::from(v)))
209                        .collect(),
210                ),
211            };
212
213        let pty_config = rust_pty::PtyConfig {
214            window_size: self.config.dimensions,
215            env: match self.config.env_mode {
216                EnvMode::Clear if self.config.env.is_empty() => {
217                    Some(std::collections::HashMap::new())
218                }
219                _ => built_env,
220            },
221            working_directory: self.config.working_directory.clone(),
222            ..Default::default()
223        };
224
225        let (master, child) =
226            UnixPtySystem::spawn(command, args.iter().map(String::as_str), &pty_config)
227                .await
228                .map_err(|e| {
229                    ExpectError::Spawn(SpawnError::PtyAllocation {
230                        reason: format!("Unix PTY spawn failed: {e}"),
231                    })
232                })?;
233
234        Ok(PtyHandle {
235            master,
236            child,
237            dimensions: self.config.dimensions,
238        })
239    }
240
241    /// Spawn a command on Windows using `ConPTY`.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if:
246    /// - `ConPTY` is not available (Windows version too old)
247    /// - PTY allocation fails
248    /// - Process spawning fails
249    #[cfg(windows)]
250    pub async fn spawn(&self, command: &str, args: &[String]) -> Result<WindowsPtyHandle> {
251        use rust_pty::{PtySystem, WindowsPtySystem};
252
253        // Build env per env_mode:
254        // - Inherit: env: None (rust-pty inherits parent env), but if we also
255        //   have overrides, we need to inherit + overlay → build a full map.
256        // - Clear:   env: Some(our overrides) — parent env discarded.
257        // - Extend:  env: Some(parent + our overrides), parent first so ours win.
258        let built_env: Option<std::collections::HashMap<std::ffi::OsString, std::ffi::OsString>> =
259            match self.config.env_mode {
260                EnvMode::Inherit if self.config.env.is_empty() => None,
261                EnvMode::Inherit | EnvMode::Extend => {
262                    let mut m: std::collections::HashMap<_, _> = std::env::vars_os().collect();
263                    for (k, v) in &self.config.env {
264                        m.insert(std::ffi::OsString::from(k), std::ffi::OsString::from(v));
265                    }
266                    Some(m)
267                }
268                EnvMode::Clear => Some(
269                    self.config
270                        .env
271                        .iter()
272                        .map(|(k, v)| (std::ffi::OsString::from(k), std::ffi::OsString::from(v)))
273                        .collect(),
274                ),
275            };
276
277        // Create configuration for rust-pty
278        let pty_config = rust_pty::PtyConfig {
279            window_size: self.config.dimensions,
280            env: match self.config.env_mode {
281                EnvMode::Clear if self.config.env.is_empty() => {
282                    Some(std::collections::HashMap::new())
283                }
284                _ => built_env,
285            },
286            working_directory: self.config.working_directory.clone(),
287            ..Default::default()
288        };
289
290        // Spawn using rust-pty's Windows implementation
291        let (master, child) = WindowsPtySystem::spawn(
292            command,
293            args.iter().map(std::string::String::as_str),
294            &pty_config,
295        )
296        .await
297        .map_err(|e| {
298            ExpectError::Spawn(SpawnError::PtyAllocation {
299                reason: format!("Windows ConPTY spawn failed: {e}"),
300            })
301        })?;
302
303        Ok(WindowsPtyHandle {
304            master,
305            child,
306            dimensions: self.config.dimensions,
307        })
308    }
309}
310
311impl Default for PtySpawner {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317/// Handle to a spawned PTY process (Unix).
318#[cfg(unix)]
319#[derive(Debug)]
320pub struct PtyHandle {
321    /// The PTY master from rust-pty.
322    pub(crate) master: rust_pty::UnixPtyMaster,
323    /// The child process handle.
324    pub(crate) child: rust_pty::UnixPtyChild,
325    /// Terminal dimensions (cols, rows).
326    dimensions: (u16, u16),
327}
328
329/// Handle to a spawned PTY process (Windows).
330#[cfg(windows)]
331pub struct WindowsPtyHandle {
332    /// The PTY master from rust-pty.
333    pub(crate) master: rust_pty::WindowsPtyMaster,
334    /// The child process handle.
335    pub(crate) child: rust_pty::WindowsPtyChild,
336    /// Terminal dimensions (cols, rows).
337    dimensions: (u16, u16),
338}
339
340#[cfg(windows)]
341impl std::fmt::Debug for WindowsPtyHandle {
342    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343        f.debug_struct("WindowsPtyHandle")
344            .field("dimensions", &self.dimensions)
345            .finish_non_exhaustive()
346    }
347}
348
349#[cfg(unix)]
350impl PtyHandle {
351    /// Get the process ID.
352    #[must_use]
353    pub const fn pid(&self) -> u32 {
354        self.child.pid()
355    }
356
357    /// Get the terminal dimensions.
358    #[must_use]
359    pub const fn dimensions(&self) -> (u16, u16) {
360        self.dimensions
361    }
362
363    /// Resize the terminal.
364    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
365        use rust_pty::{PtyMaster, WindowSize};
366        self.master
367            .resize(WindowSize::new(cols, rows))
368            .map_err(|e| ExpectError::Io(io::Error::other(format!("resize failed: {e}"))))?;
369        self.dimensions = (cols, rows);
370        Ok(())
371    }
372
373    // NB: no `signal`/`kill` here. The unguarded low-level signal path was
374    // removed for the PID-reuse guard (S1); signal a child through
375    // `Session`/`SyncSession`, whose `AsyncPty::signal` performs the
376    // authoritative reap-before-kill check.
377}
378
379#[cfg(windows)]
380impl WindowsPtyHandle {
381    /// Get the process ID.
382    #[must_use]
383    pub const fn pid(&self) -> u32 {
384        self.child.pid()
385    }
386
387    /// Get the terminal dimensions.
388    #[must_use]
389    pub const fn dimensions(&self) -> (u16, u16) {
390        self.dimensions
391    }
392
393    /// Resize the terminal.
394    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
395        use rust_pty::{PtyMaster, WindowSize};
396        let size = WindowSize::new(cols, rows);
397        self.master
398            .resize(size)
399            .map_err(|e| ExpectError::Io(io::Error::other(format!("resize failed: {e}"))))?;
400        self.dimensions = (cols, rows);
401        Ok(())
402    }
403
404    /// Check if the child process is still running.
405    #[must_use]
406    pub fn is_running(&self) -> bool {
407        self.child.is_running()
408    }
409
410    /// Kill the process.
411    pub fn kill(&mut self) -> Result<()> {
412        self.child
413            .kill()
414            .map_err(|e| ExpectError::Io(io::Error::other(format!("kill failed: {e}"))))
415    }
416}
417
418/// Async wrapper around a PTY file descriptor for use with Tokio.
419///
420/// This provides `AsyncRead` and `AsyncWrite` implementations that
421/// integrate with the Tokio runtime.
422#[cfg(unix)]
423pub struct AsyncPty {
424    /// The underlying Unix PTY master from rust-pty.
425    master: rust_pty::UnixPtyMaster,
426    /// The child process handle.
427    child: rust_pty::UnixPtyChild,
428    /// Process ID.
429    pid: u32,
430    /// Terminal dimensions.
431    dimensions: (u16, u16),
432}
433
434#[cfg(unix)]
435impl AsyncPty {
436    /// Create a new async PTY wrapper from a `PtyHandle`.
437    ///
438    /// Takes ownership of the `PtyHandle`'s file descriptor.
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if the `AsyncFd` cannot be created.
443    pub fn from_handle(handle: PtyHandle) -> io::Result<Self> {
444        let pid = handle.child.pid();
445        let dimensions = handle.dimensions;
446        Ok(Self {
447            master: handle.master,
448            child: handle.child,
449            pid,
450            dimensions,
451        })
452    }
453
454    /// Non-blocking reap of the child process.
455    ///
456    /// Returns `Some(status)` once the child has exited (rust-pty caches the
457    /// status), or `None` while it is still running or its status cannot be
458    /// determined.
459    pub fn try_wait(&mut self) -> Option<ProcessExitStatus> {
460        match self.child.try_wait() {
461            Ok(Some(rust_pty::ExitStatus::Exited(code))) => Some(ProcessExitStatus::Exited(code)),
462            Ok(Some(rust_pty::ExitStatus::Signaled(sig))) => Some(ProcessExitStatus::Signaled(sig)),
463            Ok(None) | Err(_) => None,
464        }
465    }
466
467    /// Check whether the child process is still running.
468    ///
469    /// Non-blocking: reaps through tokio's child handle, so it reports the truth
470    /// immediately after the child exits. Mirrors `WindowsAsyncPty::is_running`.
471    pub fn is_running(&mut self) -> bool {
472        self.try_wait().is_none()
473    }
474
475    /// Get the process ID.
476    #[must_use]
477    pub const fn pid(&self) -> u32 {
478        self.pid
479    }
480
481    /// Get the terminal dimensions.
482    #[must_use]
483    pub const fn dimensions(&self) -> (u16, u16) {
484        self.dimensions
485    }
486
487    /// Resize the terminal.
488    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
489        use rust_pty::{PtyMaster, WindowSize};
490        self.master
491            .resize(WindowSize::new(cols, rows))
492            .map_err(|e| ExpectError::Io(io::Error::other(format!("resize failed: {e}"))))?;
493        self.dimensions = (cols, rows);
494        Ok(())
495    }
496
497    /// Send a signal to the child process.
498    ///
499    /// Guards against PID reuse (S1): if the child has already exited (and
500    /// possibly been reaped, freeing its PID for the OS to recycle), this
501    /// returns [`ExpectError::SessionClosed`] rather than risk `libc::kill`
502    /// landing on an unrelated process. A raw `ESRCH` from `kill` maps to the
503    /// same. Other delivery failures (e.g. `EPERM`) surface unchanged as
504    /// [`ExpectError::Io`]. A raw `libc::kill` is used (rather than
505    /// `rust_pty::PtyChild::signal`) to preserve arbitrary-signal support and
506    /// keep the authoritative guard at this layer.
507    #[allow(unsafe_code)]
508    pub fn signal(&mut self, signal: i32) -> Result<()> {
509        // Authoritative pre-kill reap check via tokio's child handle.
510        if self.try_wait().is_some() {
511            return Err(ExpectError::SessionClosed);
512        }
513        // SAFETY: pid is a valid process ID from the spawned child.
514        let result = unsafe { libc::kill(self.pid as i32, signal) };
515        if result == 0 {
516            Ok(())
517        } else {
518            let err = io::Error::last_os_error();
519            // Child exited between the guard and the kill: treat as already
520            // closed rather than a raw error.
521            if err.raw_os_error() == Some(libc::ESRCH) {
522                Err(ExpectError::SessionClosed)
523            } else {
524                Err(ExpectError::Io(err))
525            }
526        }
527    }
528
529    /// Kill the child process.
530    pub fn kill(&mut self) -> Result<()> {
531        self.signal(libc::SIGKILL)
532    }
533}
534
535#[cfg(unix)]
536impl AsyncRead for AsyncPty {
537    fn poll_read(
538        mut self: Pin<&mut Self>,
539        cx: &mut Context<'_>,
540        buf: &mut ReadBuf<'_>,
541    ) -> Poll<io::Result<()>> {
542        Pin::new(&mut self.master).poll_read(cx, buf)
543    }
544}
545
546#[cfg(unix)]
547impl AsyncWrite for AsyncPty {
548    fn poll_write(
549        mut self: Pin<&mut Self>,
550        cx: &mut Context<'_>,
551        buf: &[u8],
552    ) -> Poll<io::Result<usize>> {
553        // A dead child's PTY master buffers writes; surface exit as BrokenPipe.
554        if matches!(self.child.try_wait(), Ok(Some(_))) {
555            return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
556        }
557        Pin::new(&mut self.master).poll_write(cx, buf)
558    }
559
560    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
561        Pin::new(&mut self.master).poll_flush(cx)
562    }
563
564    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
565        Pin::new(&mut self.master).poll_shutdown(cx)
566    }
567}
568
569#[cfg(unix)]
570impl std::fmt::Debug for AsyncPty {
571    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572        f.debug_struct("AsyncPty")
573            .field("pid", &self.pid)
574            .field("dimensions", &self.dimensions)
575            .finish_non_exhaustive()
576    }
577}
578
579#[cfg(unix)]
580impl ChildExit for AsyncPty {
581    fn try_exit_status(&mut self) -> Option<ProcessExitStatus> {
582        self.try_wait()
583    }
584}
585
586/// Async wrapper around Windows `ConPTY` for use with Tokio.
587///
588/// This wraps the rust-pty `WindowsPtyMaster` and provides the same interface
589/// as the Unix `AsyncPty` for consistent cross-platform Session usage.
590#[cfg(windows)]
591pub struct WindowsAsyncPty {
592    /// The underlying Windows PTY master.
593    master: rust_pty::WindowsPtyMaster,
594    /// The child process handle.
595    child: rust_pty::WindowsPtyChild,
596    /// Process ID.
597    pid: u32,
598    /// Terminal dimensions.
599    dimensions: (u16, u16),
600}
601
602#[cfg(windows)]
603impl WindowsAsyncPty {
604    /// Create a new Windows async PTY wrapper from a `WindowsPtyHandle`.
605    ///
606    /// Takes ownership of the handle.
607    #[must_use]
608    pub fn from_handle(handle: WindowsPtyHandle) -> Self {
609        let pid = handle.child.pid();
610        let dimensions = handle.dimensions;
611        Self {
612            master: handle.master,
613            child: handle.child,
614            pid,
615            dimensions,
616        }
617    }
618
619    /// Get the process ID.
620    #[must_use]
621    pub const fn pid(&self) -> u32 {
622        self.pid
623    }
624
625    /// Get the terminal dimensions.
626    #[must_use]
627    pub const fn dimensions(&self) -> (u16, u16) {
628        self.dimensions
629    }
630
631    /// Resize the terminal.
632    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
633        use rust_pty::{PtyMaster, WindowSize};
634        let size = WindowSize::new(cols, rows);
635        self.master
636            .resize(size)
637            .map_err(|e| ExpectError::Io(io::Error::other(format!("resize failed: {e}"))))?;
638        self.dimensions = (cols, rows);
639        Ok(())
640    }
641
642    /// Check if the child process is still running.
643    #[must_use]
644    pub fn is_running(&self) -> bool {
645        self.child.is_running()
646    }
647
648    /// Kill the child process.
649    pub fn kill(&mut self) -> Result<()> {
650        self.child
651            .kill()
652            .map_err(|e| ExpectError::Io(io::Error::other(format!("kill failed: {e}"))))
653    }
654}
655
656#[cfg(windows)]
657impl ChildExit for WindowsAsyncPty {
658    fn try_exit_status(&mut self) -> Option<ProcessExitStatus> {
659        // WindowsPtyChild::try_wait peeks GetExitCodeProcess without blocking and
660        // returns the real status once the child has exited. The exit watcher
661        // installed by rust-pty guarantees EOF is delivered to readers, so by the
662        // time Session::wait reaches here the child has typically already exited.
663        match self.child.try_wait() {
664            Ok(Some(rust_pty::ExitStatus::Exited(code))) => Some(ProcessExitStatus::Exited(code)),
665            // Windows reports every exit as `Terminated(exit_code)`; the code is the real exit code.
666            Ok(Some(rust_pty::ExitStatus::Terminated(code))) => {
667                Some(ProcessExitStatus::Exited(code as i32))
668            }
669            // Still running, or status unrecoverable.
670            Ok(None) | Err(_) => None,
671        }
672    }
673}
674
675#[cfg(windows)]
676impl AsyncRead for WindowsAsyncPty {
677    fn poll_read(
678        mut self: Pin<&mut Self>,
679        cx: &mut Context<'_>,
680        buf: &mut ReadBuf<'_>,
681    ) -> Poll<io::Result<()>> {
682        // Delegate to the underlying WindowsPtyMaster which implements AsyncRead
683        Pin::new(&mut self.master).poll_read(cx, buf)
684    }
685}
686
687#[cfg(windows)]
688impl AsyncWrite for WindowsAsyncPty {
689    fn poll_write(
690        mut self: Pin<&mut Self>,
691        cx: &mut Context<'_>,
692        buf: &[u8],
693    ) -> Poll<io::Result<usize>> {
694        // Mirror the Unix guard: a write after the ConPTY child exits must surface closure.
695        if matches!(self.child.try_wait(), Ok(Some(_))) {
696            return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
697        }
698        Pin::new(&mut self.master).poll_write(cx, buf)
699    }
700
701    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
702        Pin::new(&mut self.master).poll_flush(cx)
703    }
704
705    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
706        Pin::new(&mut self.master).poll_shutdown(cx)
707    }
708}
709
710#[cfg(windows)]
711impl std::fmt::Debug for WindowsAsyncPty {
712    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
713        f.debug_struct("WindowsAsyncPty")
714            .field("pid", &self.pid)
715            .field("dimensions", &self.dimensions)
716            .finish_non_exhaustive()
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    #[test]
725    fn pty_config_default() {
726        let config = PtyConfig::default();
727        assert_eq!(config.dimensions.0, 80);
728        assert_eq!(config.dimensions.1, 24);
729        assert_eq!(config.env_mode, EnvMode::Inherit);
730    }
731
732    #[test]
733    fn pty_config_from_session() {
734        let session_config = SessionConfig {
735            dimensions: (120, 40),
736            ..Default::default()
737        };
738
739        let pty_config = PtyConfig::from(&session_config);
740        assert_eq!(pty_config.dimensions.0, 120);
741        assert_eq!(pty_config.dimensions.1, 40);
742    }
743
744    #[cfg(unix)]
745    #[tokio::test]
746    async fn spawn_rejects_null_byte_in_command() {
747        let spawner = PtySpawner::new();
748        let result = spawner.spawn("test\0command", &[]).await;
749
750        assert!(result.is_err());
751        let err = result.unwrap_err();
752        let err_str = err.to_string();
753        assert!(
754            err_str.contains("nul byte"),
755            "Expected error about a nul byte, got: {err_str}"
756        );
757    }
758
759    #[cfg(unix)]
760    #[tokio::test]
761    async fn spawn_rejects_null_byte_in_args() {
762        let spawner = PtySpawner::new();
763        let result = spawner
764            .spawn("/bin/echo", &["hello\0world".to_string()])
765            .await;
766
767        assert!(result.is_err());
768        let err = result.unwrap_err();
769        let err_str = err.to_string();
770        assert!(
771            err_str.contains("nul byte"),
772            "Expected error about a nul byte, got: {err_str}"
773        );
774    }
775}