1use 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
17pub struct PtyTransport {
19 reader: Box<dyn AsyncRead + Unpin + Send>,
21 writer: Box<dyn AsyncWrite + Unpin + Send>,
23 pid: Option<u32>,
25}
26
27impl PtyTransport {
28 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 pub const fn set_pid(&mut self, pid: u32) {
43 self.pid = Some(pid);
44 }
45
46 #[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#[derive(Debug, Clone)]
83#[non_exhaustive]
84pub struct PtyConfig {
85 pub dimensions: (u16, u16),
87 pub login_shell: bool,
89 pub env_mode: EnvMode,
91 pub env: std::collections::HashMap<String, String>,
94 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum EnvMode {
130 Inherit,
132 Clear,
134 Extend,
136}
137
138pub struct PtySpawner {
140 config: PtyConfig,
141}
142
143impl PtySpawner {
144 #[must_use]
146 pub fn new() -> Self {
147 Self {
148 config: PtyConfig::default(),
149 }
150 }
151
152 #[must_use]
154 pub const fn with_config(config: PtyConfig) -> Self {
155 Self { config }
156 }
157
158 pub const fn set_dimensions(&mut self, cols: u16, rows: u16) {
160 self.config.dimensions = (cols, rows);
161 }
162
163 #[cfg(unix)]
176 pub async fn spawn(&self, command: &str, args: &[String]) -> Result<PtyHandle> {
177 use rust_pty::{PtySystem, UnixPtySystem};
178
179 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 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 #[cfg(windows)]
250 pub async fn spawn(&self, command: &str, args: &[String]) -> Result<WindowsPtyHandle> {
251 use rust_pty::{PtySystem, WindowsPtySystem};
252
253 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 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 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#[cfg(unix)]
319#[derive(Debug)]
320pub struct PtyHandle {
321 pub(crate) master: rust_pty::UnixPtyMaster,
323 pub(crate) child: rust_pty::UnixPtyChild,
325 dimensions: (u16, u16),
327}
328
329#[cfg(windows)]
331pub struct WindowsPtyHandle {
332 pub(crate) master: rust_pty::WindowsPtyMaster,
334 pub(crate) child: rust_pty::WindowsPtyChild,
336 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 #[must_use]
353 pub const fn pid(&self) -> u32 {
354 self.child.pid()
355 }
356
357 #[must_use]
359 pub const fn dimensions(&self) -> (u16, u16) {
360 self.dimensions
361 }
362
363 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 }
378
379#[cfg(windows)]
380impl WindowsPtyHandle {
381 #[must_use]
383 pub const fn pid(&self) -> u32 {
384 self.child.pid()
385 }
386
387 #[must_use]
389 pub const fn dimensions(&self) -> (u16, u16) {
390 self.dimensions
391 }
392
393 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 #[must_use]
406 pub fn is_running(&self) -> bool {
407 self.child.is_running()
408 }
409
410 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#[cfg(unix)]
423pub struct AsyncPty {
424 master: rust_pty::UnixPtyMaster,
426 child: rust_pty::UnixPtyChild,
428 pid: u32,
430 dimensions: (u16, u16),
432}
433
434#[cfg(unix)]
435impl AsyncPty {
436 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 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 pub fn is_running(&mut self) -> bool {
472 self.try_wait().is_none()
473 }
474
475 #[must_use]
477 pub const fn pid(&self) -> u32 {
478 self.pid
479 }
480
481 #[must_use]
483 pub const fn dimensions(&self) -> (u16, u16) {
484 self.dimensions
485 }
486
487 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 #[allow(unsafe_code)]
508 pub fn signal(&mut self, signal: i32) -> Result<()> {
509 if self.try_wait().is_some() {
511 return Err(ExpectError::SessionClosed);
512 }
513 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 if err.raw_os_error() == Some(libc::ESRCH) {
522 Err(ExpectError::SessionClosed)
523 } else {
524 Err(ExpectError::Io(err))
525 }
526 }
527 }
528
529 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 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#[cfg(windows)]
591pub struct WindowsAsyncPty {
592 master: rust_pty::WindowsPtyMaster,
594 child: rust_pty::WindowsPtyChild,
596 pid: u32,
598 dimensions: (u16, u16),
600}
601
602#[cfg(windows)]
603impl WindowsAsyncPty {
604 #[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 #[must_use]
621 pub const fn pid(&self) -> u32 {
622 self.pid
623 }
624
625 #[must_use]
627 pub const fn dimensions(&self) -> (u16, u16) {
628 self.dimensions
629 }
630
631 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 #[must_use]
644 pub fn is_running(&self) -> bool {
645 self.child.is_running()
646 }
647
648 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 match self.child.try_wait() {
664 Ok(Some(rust_pty::ExitStatus::Exited(code))) => Some(ProcessExitStatus::Exited(code)),
665 Ok(Some(rust_pty::ExitStatus::Terminated(code))) => {
667 Some(ProcessExitStatus::Exited(code as i32))
668 }
669 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 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 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}