Skip to main content

shell_tunnel/pty/
native.rs

1//! Native PTY implementation using portable-pty.
2
3use portable_pty::{native_pty_system, CommandBuilder, PtySize as NativePtySize};
4use std::io::{Read, Write};
5
6use super::{PtyHandle, PtySize};
7use crate::error::ShellTunnelError;
8use crate::Result;
9
10/// Get the default shell for the current platform.
11pub fn default_shell() -> &'static str {
12    #[cfg(unix)]
13    {
14        std::env::var("SHELL")
15            .ok()
16            .map(|s| Box::leak(s.into_boxed_str()) as &'static str)
17            .unwrap_or("/bin/sh")
18    }
19    #[cfg(windows)]
20    {
21        "powershell.exe"
22    }
23}
24
25/// Wrapper around the native PTY system.
26pub struct NativePty {
27    pty_system: Box<dyn portable_pty::PtySystem + Send>,
28}
29
30impl NativePty {
31    /// Create a new NativePty instance.
32    pub fn new() -> Self {
33        Self {
34            pty_system: native_pty_system(),
35        }
36    }
37
38    /// Spawn a shell process in a new PTY.
39    ///
40    /// # Arguments
41    ///
42    /// * `shell` - The shell command to execute (e.g., "bash", "powershell.exe").
43    /// * `size` - The initial size of the PTY.
44    ///
45    /// # Returns
46    ///
47    /// A `PtyHandle` containing the reader, writer, and process ID.
48    pub fn spawn(
49        &self,
50        shell: &str,
51        size: PtySize,
52    ) -> Result<PtyHandle<Box<dyn Read + Send>, Box<dyn Write + Send>>> {
53        let native_size = NativePtySize {
54            rows: size.rows,
55            cols: size.cols,
56            pixel_width: 0,
57            pixel_height: 0,
58        };
59
60        let pair = self
61            .pty_system
62            .openpty(native_size)
63            .map_err(|e| ShellTunnelError::Pty(e.to_string()))?;
64
65        let cmd = CommandBuilder::new(shell);
66
67        // Note: We don't use -l flag as it can cause issues with some shells
68        // The environment is inherited from the parent process
69
70        let child = pair
71            .slave
72            .spawn_command(cmd)
73            .map_err(|e| ShellTunnelError::Pty(e.to_string()))?;
74
75        let pid = child.process_id().unwrap_or(0);
76
77        let reader = pair
78            .master
79            .try_clone_reader()
80            .map_err(|e| ShellTunnelError::Pty(e.to_string()))?;
81
82        let writer = pair
83            .master
84            .take_writer()
85            .map_err(|e| ShellTunnelError::Pty(e.to_string()))?;
86
87        Ok(PtyHandle::new(
88            reader,
89            writer,
90            pid,
91            Box::new((pair.master, child)),
92        ))
93    }
94
95    /// Spawn a shell process with the default shell.
96    pub fn spawn_default(
97        &self,
98        size: PtySize,
99    ) -> Result<PtyHandle<Box<dyn Read + Send>, Box<dyn Write + Send>>> {
100        self.spawn(default_shell(), size)
101    }
102
103    /// Spawn a shell process with optional working directory.
104    ///
105    /// This is a convenience method for command execution that spawns
106    /// a shell with default size and optionally sets the working directory.
107    pub fn spawn_shell(&mut self, working_dir: Option<&std::path::Path>) -> Result<SpawnedShell> {
108        let native_size = NativePtySize {
109            rows: 24,
110            cols: 80,
111            pixel_width: 0,
112            pixel_height: 0,
113        };
114
115        let pair = self
116            .pty_system
117            .openpty(native_size)
118            .map_err(|e| ShellTunnelError::Pty(e.to_string()))?;
119
120        let mut cmd = CommandBuilder::new(default_shell());
121
122        if let Some(dir) = working_dir {
123            cmd.cwd(dir);
124        }
125
126        let child = pair
127            .slave
128            .spawn_command(cmd)
129            .map_err(|e| ShellTunnelError::Pty(e.to_string()))?;
130
131        Ok(SpawnedShell {
132            master: pair.master,
133            child,
134            reader: None,
135            writer: None,
136        })
137    }
138
139    /// Spawn a command directly (non-interactive).
140    ///
141    /// This runs a single command and exits when done.
142    /// The command is executed via `sh -c "command"` (Unix) or `cmd /c command` (Windows).
143    pub fn spawn_command(
144        &mut self,
145        command_line: &str,
146        working_dir: Option<&std::path::Path>,
147    ) -> Result<SpawnedShell> {
148        let native_size = NativePtySize {
149            rows: 24,
150            cols: 80,
151            pixel_width: 0,
152            pixel_height: 0,
153        };
154
155        let pair = self
156            .pty_system
157            .openpty(native_size)
158            .map_err(|e| ShellTunnelError::Pty(e.to_string()))?;
159
160        #[cfg(unix)]
161        let mut cmd = {
162            let mut c = CommandBuilder::new("/bin/sh");
163            c.arg("-c");
164            c.arg(command_line);
165            c
166        };
167
168        #[cfg(windows)]
169        let mut cmd = {
170            let mut c = CommandBuilder::new("cmd.exe");
171            c.arg("/c");
172            c.arg(command_line);
173            c
174        };
175
176        if let Some(dir) = working_dir {
177            cmd.cwd(dir);
178        }
179
180        let child = pair
181            .slave
182            .spawn_command(cmd)
183            .map_err(|e| ShellTunnelError::Pty(e.to_string()))?;
184
185        Ok(SpawnedShell {
186            master: pair.master,
187            child,
188            reader: None,
189            writer: None,
190        })
191    }
192}
193
194/// A spawned shell process with PTY.
195pub struct SpawnedShell {
196    master: Box<dyn portable_pty::MasterPty + Send>,
197    child: Box<dyn portable_pty::Child + Send + Sync>,
198    reader: Option<Box<dyn Read + Send>>,
199    writer: Option<Box<dyn Write + Send>>,
200}
201
202impl SpawnedShell {
203    /// Take the writer (can only be called once).
204    pub fn take_writer(&mut self) -> Result<Box<dyn Write + Send>> {
205        if let Some(writer) = self.writer.take() {
206            return Ok(writer);
207        }
208        self.master
209            .take_writer()
210            .map_err(|e| ShellTunnelError::Pty(e.to_string()))
211    }
212
213    /// Take the reader (can only be called once).
214    pub fn take_reader(&mut self) -> Result<Box<dyn Read + Send>> {
215        if let Some(reader) = self.reader.take() {
216            return Ok(reader);
217        }
218        self.master
219            .try_clone_reader()
220            .map_err(|e| ShellTunnelError::Pty(e.to_string()))
221    }
222
223    /// Try to wait for the child process without blocking.
224    pub fn try_wait(&mut self) -> std::io::Result<Option<portable_pty::ExitStatus>> {
225        self.child.try_wait()
226    }
227
228    /// Wait for the child process to exit.
229    pub fn wait(&mut self) -> std::io::Result<portable_pty::ExitStatus> {
230        self.child.wait()
231    }
232}
233
234impl Default for NativePty {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_default_shell() {
246        let shell = default_shell();
247        assert!(!shell.is_empty());
248
249        #[cfg(unix)]
250        {
251            // Should be a valid path or command
252            assert!(shell.starts_with('/') || !shell.contains('/'));
253        }
254
255        #[cfg(windows)]
256        {
257            assert!(shell.ends_with(".exe"));
258        }
259    }
260
261    #[test]
262    fn test_spawn_shell() {
263        let pty = NativePty::new();
264        let handle = pty.spawn_default(PtySize::default());
265
266        assert!(handle.is_ok(), "Failed to spawn shell: {:?}", handle.err());
267
268        let handle = handle.unwrap();
269        assert!(handle.pid > 0, "PID should be positive");
270    }
271
272    // Note: This test is ignored by default because PTY read operations
273    // can block indefinitely on some platforms (especially Windows).
274    // Run with: cargo test -- --ignored
275    #[test]
276    #[ignore]
277    fn test_write_and_read() {
278        use std::time::Duration;
279
280        let pty = NativePty::new();
281        let mut handle = pty.spawn_default(PtySize::default()).unwrap();
282
283        // Write a simple echo command followed by exit
284        #[cfg(unix)]
285        let cmd = "echo SHELL_TUNNEL_TEST_OUTPUT; exit\n";
286        #[cfg(windows)]
287        let cmd = "echo SHELL_TUNNEL_TEST_OUTPUT\r\nexit\r\n";
288
289        handle.writer.write_all(cmd.as_bytes()).unwrap();
290        handle.writer.flush().unwrap();
291
292        // Read with a simple timeout approach
293        let mut output = Vec::new();
294        let mut buf = [0u8; 1024];
295        let deadline = std::time::Instant::now() + Duration::from_secs(5);
296
297        while std::time::Instant::now() < deadline {
298            match handle.reader.read(&mut buf) {
299                Ok(0) => break,
300                Ok(n) => {
301                    output.extend_from_slice(&buf[..n]);
302                    if String::from_utf8_lossy(&output).contains("SHELL_TUNNEL_TEST_OUTPUT") {
303                        break;
304                    }
305                }
306                Err(e) => {
307                    #[cfg(unix)]
308                    if e.raw_os_error() == Some(libc::EIO) {
309                        break;
310                    }
311                    if e.kind() == std::io::ErrorKind::WouldBlock {
312                        std::thread::sleep(Duration::from_millis(10));
313                        continue;
314                    }
315                    break;
316                }
317            }
318        }
319
320        assert!(!output.is_empty(), "Should have received some output");
321    }
322
323    #[test]
324    fn test_custom_size() {
325        let pty = NativePty::new();
326        let size = PtySize::new(40, 120);
327        let handle = pty.spawn_default(size);
328
329        assert!(handle.is_ok());
330    }
331
332    #[test]
333    #[cfg(unix)]
334    fn test_spawn_specific_shell() {
335        let pty = NativePty::new();
336        let handle = pty.spawn("/bin/sh", PtySize::default());
337
338        assert!(handle.is_ok());
339    }
340
341    #[test]
342    #[cfg(windows)]
343    fn test_spawn_cmd() {
344        let pty = NativePty::new();
345        let handle = pty.spawn("cmd.exe", PtySize::default());
346
347        assert!(handle.is_ok());
348    }
349}