1#![warn(missing_docs)]
18#[cfg(not(unix))]
19compile_error!("nexcore-pty requires a Unix platform (Linux or macOS)");
20
21use std::ffi::CString;
22use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct WinSize {
29 pub rows: u16,
31 pub cols: u16,
33}
34
35pub struct PtyPair {
40 pub master: OwnedFd,
42 pub slave: OwnedFd,
44}
45
46pub struct SpawnConfig<'a> {
48 pub program: &'a str,
50 pub args: &'a [&'a str],
52 pub working_dir: &'a str,
54 pub env: &'a [(String, String)],
56}
57
58#[non_exhaustive]
60#[derive(Debug)]
61pub enum PtyError {
62 OpenPtyFailed(std::io::Error),
64 ForkFailed(std::io::Error),
66 ExecFailed(std::io::Error),
68 ResizeFailed(std::io::Error),
70 SetNonblockFailed(std::io::Error),
72 ProcessError(std::io::Error),
74 InvalidString(std::ffi::NulError),
76}
77
78impl std::fmt::Display for PtyError {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 Self::OpenPtyFailed(e) => write!(f, "openpty failed: {e}"),
82 Self::ForkFailed(e) => write!(f, "fork failed: {e}"),
83 Self::ExecFailed(e) => write!(f, "exec failed in child: {e}"),
84 Self::ResizeFailed(e) => write!(f, "TIOCSWINSZ ioctl failed: {e}"),
85 Self::SetNonblockFailed(e) => write!(f, "fcntl F_SETFL failed: {e}"),
86 Self::ProcessError(e) => write!(f, "process operation failed: {e}"),
87 Self::InvalidString(e) => write!(f, "invalid C string: {e}"),
88 }
89 }
90}
91
92impl std::error::Error for PtyError {
93 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
94 match self {
95 Self::OpenPtyFailed(e)
96 | Self::ForkFailed(e)
97 | Self::ExecFailed(e)
98 | Self::ResizeFailed(e)
99 | Self::SetNonblockFailed(e)
100 | Self::ProcessError(e) => Some(e),
101 Self::InvalidString(e) => Some(e),
102 }
103 }
104}
105
106impl PtyError {
107 #[must_use]
114 pub fn into_io(self) -> std::io::Error {
115 match self {
116 Self::OpenPtyFailed(e)
117 | Self::ForkFailed(e)
118 | Self::ExecFailed(e)
119 | Self::ResizeFailed(e)
120 | Self::SetNonblockFailed(e)
121 | Self::ProcessError(e) => e,
122 Self::InvalidString(e) => std::io::Error::new(std::io::ErrorKind::InvalidInput, e),
123 }
124 }
125}
126
127#[allow(unsafe_code, reason = "FFI boundary for POSIX openpty(3)")]
136pub fn open_pty(size: WinSize) -> Result<PtyPair, PtyError> {
137 let mut master_fd: libc::c_int = -1;
138 let mut slave_fd: libc::c_int = -1;
139
140 let ws = libc::winsize {
141 ws_row: size.rows,
142 ws_col: size.cols,
143 ws_xpixel: 0,
144 ws_ypixel: 0,
145 };
146
147 let ret = unsafe {
152 libc::openpty(
153 &mut master_fd,
154 &mut slave_fd,
155 std::ptr::null_mut(), std::ptr::null(), &ws,
158 )
159 };
160
161 if ret != 0 {
162 return Err(PtyError::OpenPtyFailed(std::io::Error::last_os_error()));
163 }
164
165 let master = unsafe { OwnedFd::from_raw_fd(master_fd) };
169 let slave = unsafe { OwnedFd::from_raw_fd(slave_fd) };
170
171 Ok(PtyPair { master, slave })
172}
173
174#[allow(unsafe_code, reason = "FFI boundary for POSIX fork(2)/execvp(3)")]
189pub fn fork_exec(
190 slave: OwnedFd,
191 master_fd: RawFd,
192 config: &SpawnConfig<'_>,
193) -> Result<u32, PtyError> {
194 let c_program = CString::new(config.program).map_err(PtyError::InvalidString)?;
196
197 let c_args: Vec<CString> = config
198 .args
199 .iter()
200 .map(|a| CString::new(*a))
201 .collect::<Result<Vec<_>, _>>()
202 .map_err(PtyError::InvalidString)?;
203
204 let c_arg_ptrs: Vec<*const libc::c_char> = c_args
205 .iter()
206 .map(|a| a.as_ptr())
207 .chain(std::iter::once(std::ptr::null()))
208 .collect();
209
210 let c_working_dir = CString::new(config.working_dir).map_err(PtyError::InvalidString)?;
211
212 let c_env: Vec<CString> = config
213 .env
214 .iter()
215 .map(|(k, v)| CString::new(format!("{k}={v}")))
216 .collect::<Result<Vec<_>, _>>()
217 .map_err(PtyError::InvalidString)?;
218
219 let mut pipe_fds = [0i32; 2];
223
224 #[cfg(target_os = "linux")]
227 {
228 let ret = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
229 if ret != 0 {
230 return Err(PtyError::ForkFailed(std::io::Error::last_os_error()));
231 }
232 }
233 #[cfg(not(target_os = "linux"))]
234 {
235 let ret = unsafe { libc::pipe(pipe_fds.as_mut_ptr()) };
237 if ret != 0 {
238 return Err(PtyError::ForkFailed(std::io::Error::last_os_error()));
239 }
240 unsafe {
243 libc::fcntl(pipe_fds[0], libc::F_SETFD, libc::FD_CLOEXEC);
244 libc::fcntl(pipe_fds[1], libc::F_SETFD, libc::FD_CLOEXEC);
245 }
246 }
247
248 let pipe_read = pipe_fds[0];
249 let pipe_write = pipe_fds[1];
250 let slave_fd = slave.as_raw_fd();
251
252 let pid = unsafe { libc::fork() };
258
259 if pid < 0 {
260 unsafe {
263 libc::close(pipe_read);
264 libc::close(pipe_write);
265 }
266 drop(slave);
267 return Err(PtyError::ForkFailed(std::io::Error::last_os_error()));
268 }
269
270 if pid == 0 {
271 unsafe {
277 libc::close(pipe_read);
278 libc::close(master_fd);
279 }
280
281 unsafe {
285 libc::setsid();
286 }
287
288 #[cfg(not(target_os = "macos"))]
292 unsafe {
293 libc::ioctl(slave_fd, libc::TIOCSCTTY, 0);
294 }
295 #[cfg(target_os = "macos")]
300 unsafe {
301 libc::ioctl(slave_fd, libc::TIOCSCTTY as libc::c_ulong, 0);
302 }
303
304 unsafe {
307 libc::dup2(slave_fd, libc::STDIN_FILENO);
308 libc::dup2(slave_fd, libc::STDOUT_FILENO);
309 libc::dup2(slave_fd, libc::STDERR_FILENO);
310 }
311
312 if slave_fd > 2 {
314 unsafe {
316 libc::close(slave_fd);
317 }
318 }
319
320 unsafe {
323 libc::chdir(c_working_dir.as_ptr());
324 }
325
326 for c_var in &c_env {
328 unsafe {
330 libc::putenv(c_var.as_ptr() as *mut libc::c_char);
331 }
332 }
333
334 unsafe {
339 libc::execvp(c_program.as_ptr(), c_arg_ptrs.as_ptr());
340 }
341
342 let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(1);
344 let err_bytes = err.to_ne_bytes();
345 unsafe {
348 libc::write(pipe_write, err_bytes.as_ptr().cast(), err_bytes.len());
349 libc::_exit(1);
350 }
351 }
352
353 unsafe {
358 libc::close(pipe_write);
359 }
360
361 drop(slave);
363
364 let mut err_buf = [0u8; 4];
368 let n = unsafe { libc::read(pipe_read, err_buf.as_mut_ptr().cast(), err_buf.len()) };
370
371 unsafe {
373 libc::close(pipe_read);
374 }
375
376 if n > 0 {
377 let errno = i32::from_ne_bytes(err_buf);
379 unsafe {
382 libc::waitpid(pid, std::ptr::null_mut(), 0);
383 }
384 return Err(PtyError::ExecFailed(std::io::Error::from_raw_os_error(
385 errno,
386 )));
387 }
388
389 #[allow(
391 clippy::cast_sign_loss,
392 reason = "pid is positive after successful fork (checked pid < 0 and pid == 0 above)"
393 )]
394 let child_pid = pid as u32;
395
396 Ok(child_pid)
397}
398
399#[allow(unsafe_code, reason = "FFI boundary for POSIX ioctl(2) TIOCSWINSZ")]
404pub fn resize(master: &OwnedFd, size: WinSize) -> Result<(), PtyError> {
405 let ws = libc::winsize {
406 ws_row: size.rows,
407 ws_col: size.cols,
408 ws_xpixel: 0,
409 ws_ypixel: 0,
410 };
411
412 let ret = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
416
417 if ret != 0 {
418 return Err(PtyError::ResizeFailed(std::io::Error::last_os_error()));
419 }
420
421 Ok(())
422}
423
424#[allow(unsafe_code, reason = "FFI boundary for POSIX fcntl(2)")]
428pub fn set_nonblocking(fd: &OwnedFd) -> Result<(), PtyError> {
429 let raw = fd.as_raw_fd();
430
431 let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
433 if flags < 0 {
434 return Err(PtyError::SetNonblockFailed(std::io::Error::last_os_error()));
435 }
436
437 let ret = unsafe { libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK) };
439 if ret != 0 {
440 return Err(PtyError::SetNonblockFailed(std::io::Error::last_os_error()));
441 }
442
443 Ok(())
444}
445
446#[allow(unsafe_code, reason = "FFI boundary for POSIX read(2)")]
451pub fn read_master(master: &OwnedFd, buf: &mut [u8]) -> std::io::Result<usize> {
452 let n = unsafe { libc::read(master.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len()) };
455 if n < 0 {
456 Err(std::io::Error::last_os_error())
457 } else {
458 #[allow(
460 clippy::cast_sign_loss,
461 reason = "n is non-negative (checked n < 0 above)"
462 )]
463 Ok(n as usize)
464 }
465}
466
467#[allow(unsafe_code, reason = "FFI boundary for POSIX write(2)")]
472pub fn write_master(master: &OwnedFd, data: &[u8]) -> std::io::Result<usize> {
473 let n = unsafe { libc::write(master.as_raw_fd(), data.as_ptr().cast(), data.len()) };
476 if n < 0 {
477 Err(std::io::Error::last_os_error())
478 } else {
479 #[allow(
480 clippy::cast_sign_loss,
481 reason = "n is non-negative (checked n < 0 above)"
482 )]
483 Ok(n as usize)
484 }
485}
486
487#[allow(unsafe_code, reason = "FFI boundary for POSIX kill(2)")]
492pub fn signal_process(pid: u32, sig: i32) -> Result<(), PtyError> {
493 #[allow(
496 clippy::cast_possible_wrap,
497 reason = "PID fits in i32 (kernel guarantees PIDs < 2^22)"
498 )]
499 let ret = unsafe { libc::kill(pid as i32, sig) };
500 if ret != 0 {
501 return Err(PtyError::ProcessError(std::io::Error::last_os_error()));
502 }
503 Ok(())
504}
505
506#[allow(unsafe_code, reason = "FFI boundary for POSIX waitpid(2)")]
511pub fn try_wait_pid(pid: u32) -> Result<Option<i32>, PtyError> {
512 let mut status: libc::c_int = 0;
513
514 #[allow(
518 clippy::cast_possible_wrap,
519 reason = "PID fits in i32 (kernel guarantees PIDs < 2^22)"
520 )]
521 let ret = unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) };
522
523 if ret < 0 {
524 return Err(PtyError::ProcessError(std::io::Error::last_os_error()));
525 }
526
527 if ret == 0 {
528 return Ok(None);
530 }
531
532 if libc::WIFEXITED(status) {
535 Ok(Some(libc::WEXITSTATUS(status)))
536 } else if libc::WIFSIGNALED(status) {
537 Ok(Some(-libc::WTERMSIG(status)))
539 } else {
540 Ok(Some(-1))
541 }
542}
543
544#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn open_pty_creates_valid_pair() {
552 let pair = open_pty(WinSize { rows: 24, cols: 80 });
553 assert!(pair.is_ok(), "openpty should succeed");
554 let pair = pair.expect("already checked");
555 assert!(pair.master.as_raw_fd() >= 0);
557 assert!(pair.slave.as_raw_fd() >= 0);
558 assert_ne!(pair.master.as_raw_fd(), pair.slave.as_raw_fd());
559 }
560
561 #[test]
562 fn resize_on_valid_master() {
563 let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
564 let result = resize(
565 &pair.master,
566 WinSize {
567 rows: 40,
568 cols: 120,
569 },
570 );
571 assert!(result.is_ok(), "resize should succeed on valid PTY master");
572 }
573
574 #[test]
575 fn set_nonblocking_on_valid_fd() {
576 let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
577 let result = set_nonblocking(&pair.master);
578 assert!(result.is_ok(), "set_nonblocking should succeed");
579 }
580
581 #[test]
582 fn fork_exec_echo() {
583 let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
584 let master_raw = pair.master.as_raw_fd();
585 let config = SpawnConfig {
586 program: "/bin/echo",
587 args: &["echo", "hello"],
588 working_dir: "/tmp",
589 env: &[],
590 };
591 let result = fork_exec(pair.slave, master_raw, &config);
592 assert!(result.is_ok(), "fork_exec /bin/echo should succeed");
593 let pid = result.expect("already checked");
594 assert!(pid > 0);
595
596 let mut buf = [0u8; 256];
598 let n = read_master(&pair.master, &mut buf);
599 assert!(n.is_ok(), "should read from master");
600
601 let wait = try_wait_pid(pid);
603 assert!(wait.is_ok());
605 }
606
607 #[test]
608 fn fork_exec_nonexistent_fails() {
609 let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
610 let master_raw = pair.master.as_raw_fd();
611 let config = SpawnConfig {
612 program: "/nonexistent/binary/path",
613 args: &["nonexistent"],
614 working_dir: "/tmp",
615 env: &[],
616 };
617 let result = fork_exec(pair.slave, master_raw, &config);
618 assert!(
619 result.is_err(),
620 "fork_exec of nonexistent binary should fail"
621 );
622 if let Err(PtyError::ExecFailed(_)) = result {
623 } else {
625 panic!("Expected ExecFailed, got {:?}", result.err());
626 }
627 }
628
629 #[test]
630 fn signal_and_wait() {
631 let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
632 let master_raw = pair.master.as_raw_fd();
633 let config = SpawnConfig {
634 program: "/bin/sleep",
635 args: &["sleep", "60"],
636 working_dir: "/tmp",
637 env: &[],
638 };
639 let pid = fork_exec(pair.slave, master_raw, &config).expect("fork_exec sleep");
640
641 let status = try_wait_pid(pid).expect("try_wait");
643 assert_eq!(status, None, "sleep should still be running");
644
645 signal_process(pid, libc::SIGKILL).expect("signal_process");
647
648 let mut exited = false;
650 for _ in 0..100 {
651 if let Ok(Some(_)) = try_wait_pid(pid) {
652 exited = true;
653 break;
654 }
655 std::thread::sleep(std::time::Duration::from_millis(10));
656 }
657 assert!(exited, "process should have exited after SIGKILL");
658 }
659
660 #[test]
661 fn write_and_read_master() {
662 let pair = open_pty(WinSize { rows: 24, cols: 80 }).expect("openpty");
663 let master_raw = pair.master.as_raw_fd();
664 let config = SpawnConfig {
665 program: "/bin/cat",
666 args: &["cat"],
667 working_dir: "/tmp",
668 env: &[],
669 };
670 let pid = fork_exec(pair.slave, master_raw, &config).expect("fork_exec cat");
671
672 let written = write_master(&pair.master, b"test\n");
674 assert!(written.is_ok(), "write to master should succeed");
675
676 std::thread::sleep(std::time::Duration::from_millis(100));
678
679 set_nonblocking(&pair.master).expect("nonblock");
681 let mut buf = [0u8; 256];
682 let n = read_master(&pair.master, &mut buf);
683 if let Ok(count) = n {
685 assert!(count > 0, "should have read some output from cat");
686 }
687
688 signal_process(pid, libc::SIGKILL).ok();
690 try_wait_pid(pid).ok();
691 }
692
693 #[test]
694 fn pty_error_display() {
695 let err = PtyError::OpenPtyFailed(std::io::Error::from_raw_os_error(2));
696 let msg = format!("{err}");
697 assert!(msg.contains("openpty failed"));
698 }
699
700 #[test]
701 fn pty_error_into_io() {
702 let err = PtyError::ResizeFailed(std::io::Error::from_raw_os_error(22));
703 let io_err = err.into_io();
704 assert_eq!(io_err.raw_os_error(), Some(22));
705 }
706}