Skip to main content

rusty_vipe/
tty.rs

1//! Cross-platform controlling-terminal reattachment.
2//!
3//! AD-003/AD-004/AD-005/AD-016 (per HINT-001 — implement before pipeline.rs):
4//! - Open the controlling terminal (`/dev/tty` on Unix, `CONIN$`/`CONOUT$`
5//!   on Windows) so the spawned editor's stdin/stdout/stderr point at the
6//!   real terminal — not the pipeline's pipe stdin/stdout.
7//! - Preserve the process's original stdout sink via `dup(2)` (Unix) /
8//!   `DuplicateHandle` (Windows) so the post-edit bytes have a route back
9//!   to the downstream consumer.
10//! - On open failure (no controlling terminal — CI, cron, headless Docker),
11//!   return `Error::NoControllingTty` so the caller can fail fast (FR-015).
12
13use crate::Error;
14
15/// File handles for the controlling terminal that the spawned editor child
16/// will inherit. On Unix these wrap the file descriptors for `/dev/tty`
17/// opened twice (read + write); on Windows they wrap `CONIN$` and `CONOUT$`.
18#[derive(Debug)]
19pub struct TtyHandles {
20    pub tty_in: std::fs::File,
21    pub tty_out: std::fs::File,
22}
23
24/// Snapshot of the original stdout sink, preserved BEFORE the editor's
25/// reattachment so post-edit bytes can route back to the pipeline.
26#[derive(Debug)]
27pub struct PreservedStdout {
28    inner: std::fs::File,
29}
30
31impl PreservedStdout {
32    /// Borrow the preserved stdout as a `Write` for the final write-back.
33    pub fn as_writer(&mut self) -> &mut std::fs::File {
34        &mut self.inner
35    }
36}
37
38/// Open the controlling terminal for read+write. Unix uses `/dev/tty`;
39/// Windows uses `CONIN$`/`CONOUT$` via `CreateFileW`.
40///
41/// Honors the documented test-only fault-injection env var
42/// `RUSTY_VIPE_TEST_FAIL_TTY=1` which forces `NoControllingTty` even when a
43/// real terminal is available. Used by `tests/no_tty.rs` integration tests
44/// because reliably stripping the controlling-TTY from a child process is
45/// platform-specific (Unix needs `setsid` + double-fork; Windows needs
46/// `CREATE_NO_WINDOW` + detached process group). The env-var bypass keeps
47/// the test surface portable without sacrificing FR-015 coverage.
48pub fn open_controlling_tty() -> Result<TtyHandles, Error> {
49    if test_fail_tty_enabled() {
50        return Err(Error::NoControllingTty);
51    }
52    #[cfg(unix)]
53    {
54        unix::open_tty()
55    }
56    #[cfg(windows)]
57    {
58        windows_impl::open_tty()
59    }
60}
61
62/// Returns true iff `RUSTY_VIPE_TEST_FAIL_TTY` is set to a truthy value. Test
63/// fault-injection only — never enable in production.
64fn test_fail_tty_enabled() -> bool {
65    match std::env::var_os("RUSTY_VIPE_TEST_FAIL_TTY") {
66        Some(v) => match v.to_str() {
67            Some(s) => matches!(
68                s.trim().to_ascii_lowercase().as_str(),
69                "1" | "true" | "yes" | "on"
70            ),
71            None => false,
72        },
73        None => false,
74    }
75}
76
77/// Preserve the process's current stdout sink via platform-specific
78/// duplication, so reopening stdout to the TTY does not clobber the path
79/// back to the pipeline.
80pub fn preserve_stdout() -> Result<PreservedStdout, Error> {
81    #[cfg(unix)]
82    {
83        unix::preserve_stdout()
84    }
85    #[cfg(windows)]
86    {
87        windows_impl::preserve_stdout()
88    }
89}
90
91#[cfg(unix)]
92mod unix {
93    use super::{Error, PreservedStdout, TtyHandles};
94    use std::os::fd::{FromRawFd, OwnedFd};
95    use std::os::unix::io::AsRawFd;
96
97    pub fn open_tty() -> Result<TtyHandles, Error> {
98        let tty_in = std::fs::OpenOptions::new()
99            .read(true)
100            .open("/dev/tty")
101            .map_err(|_| Error::NoControllingTty)?;
102        let tty_out = std::fs::OpenOptions::new()
103            .write(true)
104            .open("/dev/tty")
105            .map_err(|_| Error::NoControllingTty)?;
106        Ok(TtyHandles { tty_in, tty_out })
107    }
108
109    pub fn preserve_stdout() -> Result<PreservedStdout, Error> {
110        let stdout = std::io::stdout();
111        let raw_fd = stdout.as_raw_fd();
112        // dup(2) — returns the lowest available fd pointing at the same file
113        // description. Per HINT-002, NOT dup2 (which targets a specific fd
114        // and could clobber).
115        // SAFETY: libc::dup is a thin syscall wrapper; raw_fd is valid for
116        // the lifetime of the process stdout.
117        let new_fd = unsafe { libc::dup(raw_fd) };
118        if new_fd < 0 {
119            return Err(Error::Io(std::io::Error::last_os_error()));
120        }
121        // SAFETY: dup returned a fresh owned fd we now own; converting to
122        // OwnedFd → File transfers that ownership.
123        let owned: OwnedFd = unsafe { OwnedFd::from_raw_fd(new_fd) };
124        Ok(PreservedStdout {
125            inner: std::fs::File::from(owned),
126        })
127    }
128}
129
130#[cfg(windows)]
131mod windows_impl {
132    use super::{Error, PreservedStdout, TtyHandles};
133    use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
134    use windows_sys::Win32::Foundation::{
135        DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, INVALID_HANDLE_VALUE,
136    };
137    use windows_sys::Win32::Storage::FileSystem::{
138        CreateFileW, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ, FILE_SHARE_WRITE,
139        OPEN_EXISTING,
140    };
141    use windows_sys::Win32::System::Threading::GetCurrentProcess;
142
143    fn wide(s: &str) -> Vec<u16> {
144        s.encode_utf16().chain(std::iter::once(0)).collect()
145    }
146
147    fn create_file(name: &str, access: u32) -> Result<HANDLE, Error> {
148        let wide_name = wide(name);
149        // SAFETY: CreateFileW is FFI; wide_name is null-terminated UTF-16,
150        // and the other parameters are well-known constants.
151        let handle = unsafe {
152            CreateFileW(
153                wide_name.as_ptr(),
154                access,
155                FILE_SHARE_READ | FILE_SHARE_WRITE,
156                std::ptr::null(),
157                OPEN_EXISTING,
158                0,
159                std::ptr::null_mut(),
160            )
161        };
162        if handle == INVALID_HANDLE_VALUE || handle.is_null() {
163            return Err(Error::NoControllingTty);
164        }
165        Ok(handle)
166    }
167
168    pub fn open_tty() -> Result<TtyHandles, Error> {
169        let conin = create_file("CONIN$", FILE_GENERIC_READ)?;
170        let conout = create_file("CONOUT$", FILE_GENERIC_WRITE)?;
171        // SAFETY: handles came from CreateFileW; we take ownership.
172        let owned_in: OwnedHandle = unsafe { OwnedHandle::from_raw_handle(conin as _) };
173        let owned_out: OwnedHandle = unsafe { OwnedHandle::from_raw_handle(conout as _) };
174        Ok(TtyHandles {
175            tty_in: std::fs::File::from(owned_in),
176            tty_out: std::fs::File::from(owned_out),
177        })
178    }
179
180    pub fn preserve_stdout() -> Result<PreservedStdout, Error> {
181        let stdout = std::io::stdout();
182        let raw = stdout.as_raw_handle();
183        let mut duplicate: HANDLE = std::ptr::null_mut();
184        // SAFETY: DuplicateHandle FFI; current-process handle is the
185        // process pseudo-handle; we duplicate the source HANDLE into a new
186        // owned slot.
187        let ok = unsafe {
188            DuplicateHandle(
189                GetCurrentProcess(),
190                raw as _,
191                GetCurrentProcess(),
192                &mut duplicate,
193                0,
194                0,
195                DUPLICATE_SAME_ACCESS,
196            )
197        };
198        if ok == 0 {
199            return Err(Error::Io(std::io::Error::last_os_error()));
200        }
201        // SAFETY: DuplicateHandle gave us a fresh owned HANDLE.
202        let owned: OwnedHandle = unsafe { OwnedHandle::from_raw_handle(duplicate as _) };
203        Ok(PreservedStdout {
204            inner: std::fs::File::from(owned),
205        })
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn open_controlling_tty_returns_no_tty_or_handles() {
215        // In a CI environment (no PTY) this returns Err(NoControllingTty).
216        // On a developer machine with an interactive shell it returns Ok.
217        // Either is acceptable — we just verify the function exists and
218        // doesn't panic.
219        match open_controlling_tty() {
220            Ok(_handles) => {}
221            Err(Error::NoControllingTty) => {}
222            Err(other) => panic!("unexpected error: {other:?}"),
223        }
224    }
225
226    #[test]
227    fn preserve_stdout_succeeds_in_normal_process() {
228        // In any normal test process, stdout is a valid descriptor/handle —
229        // duplication should succeed.
230        let _preserved = preserve_stdout().expect("preserve_stdout should succeed");
231    }
232}