Skip to main content

term/
term.rs

1/// Errors returned by this crate.
2#[derive(thiserror::Error, Debug)]
3pub enum Error {
4    #[error("IO error: {0}")]
5    Io(#[from] std::io::Error),
6}
7
8/// Reads a password from stdin without echoing it to the terminal.
9/// The returned string does not include the trailing newline.
10/// Returns an error if the input is not valid UTF-8.
11/// Mimics the behaviour of Go's `golang.org/x/term.ReadPassword`.
12pub fn read_password() -> Result<String, Error> {
13    platform::read_password()
14}
15
16/// Reads a line from stdin with echo enabled (normal terminal input).
17/// The returned string does not include the trailing newline.
18/// Returns an error if the input is not valid UTF-8.
19pub fn read_line() -> Result<String, Error> {
20    platform::read_line()
21}
22
23// ── Unix ─────────────────────────────────────────────────────────────────────
24
25#[cfg(unix)]
26mod platform {
27    use std::io::Read;
28
29    use libc::{ECHO, ICANON, ICRNL, ISIG, STDIN_FILENO, TCSANOW, tcgetattr, tcsetattr, termios};
30
31    /// RAII guard that restores the saved termios state when dropped.
32    struct TermiosGuard {
33        saved: termios,
34    }
35
36    impl Drop for TermiosGuard {
37        fn drop(&mut self) {
38            // Ignore errors: we are in a destructor and cannot propagate them.
39            unsafe { tcsetattr(STDIN_FILENO, TCSANOW, &self.saved) };
40        }
41    }
42
43    pub(super) fn read_password() -> Result<String, super::Error> {
44        // Attempt to read the current terminal attributes.
45        let mut saved: termios = unsafe { std::mem::zeroed() };
46        let is_tty = unsafe { tcgetattr(STDIN_FILENO, &mut saved) } == 0;
47
48        // Disable echo for the duration of the read; the guard restores it.
49        let _guard = if is_tty {
50            let mut raw = saved;
51            // Clear ECHO — keep ICANON and ISIG so the kernel still delivers
52            // a full line on Enter and honours Ctrl-C / Ctrl-D.  Set ICRNL so
53            // a bare carriage-return is mapped to a newline (matches Go).
54            raw.c_lflag &= !(ECHO as libc::tcflag_t);
55            raw.c_lflag |= (ICANON | ISIG) as libc::tcflag_t;
56            raw.c_iflag |= ICRNL as libc::tcflag_t;
57            // SAFETY: fd is STDIN_FILENO, raw is a valid termios.
58            unsafe { tcsetattr(STDIN_FILENO, TCSANOW, &raw) };
59            Some(TermiosGuard {
60                saved,
61            })
62        } else {
63            None
64        };
65
66        let buf = read_line_inner()?;
67        String::from_utf8(buf).map_err(|e| super::Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
68    }
69
70    fn read_line_inner() -> Result<Vec<u8>, super::Error> {
71        let mut buf = Vec::new();
72        let stdin = std::io::stdin();
73        for byte in stdin.lock().bytes() {
74            let b = byte?;
75            if b == b'\n' || b == b'\r' {
76                break;
77            }
78            buf.push(b);
79        }
80        Ok(buf)
81    }
82
83    pub(super) fn read_line() -> Result<String, super::Error> {
84        let buf = read_line_inner()?;
85        String::from_utf8(buf).map_err(|e| super::Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
86    }
87}
88
89// ── Fallback (non-Unix) ──────────────────────────────────────────────────────
90
91#[cfg(not(unix))]
92mod platform {
93    use std::io::BufRead;
94
95    pub(super) fn read_password() -> Result<String, super::Error> {
96        let mut line = String::new();
97        std::io::stdin().lock().read_line(&mut line)?;
98        if line.ends_with('\n') {
99            line.pop();
100            if line.ends_with('\r') {
101                line.pop();
102            }
103        }
104        Ok(line)
105    }
106
107    pub(super) fn read_line() -> Result<String, super::Error> {
108        let mut line = String::new();
109        std::io::stdin().lock().read_line(&mut line)?;
110        if line.ends_with('\n') {
111            line.pop();
112            if line.ends_with('\r') {
113                line.pop();
114            }
115        }
116        Ok(line)
117    }
118}
119
120// ── Tests ────────────────────────────────────────────────────────────────────
121
122#[cfg(test)]
123mod tests {
124    // Integration tests for read_password() require an interactive terminal
125    // and cannot run in a CI pipeline.  We instead test the internal helpers
126    // that are available on every platform.
127
128    /// Verify that a Vec of bytes produced by the platform helper does not
129    /// contain a trailing newline or carriage-return.
130    #[test]
131    fn password_bytes_strip_newline() {
132        let mut raw = b"secret\n".to_vec();
133        if raw.ends_with(b"\n") {
134            raw.pop();
135            if raw.ends_with(b"\r") {
136                raw.pop();
137            }
138        }
139        assert_eq!(raw, b"secret");
140    }
141
142    #[test]
143    fn password_bytes_strip_crlf() {
144        let mut raw = b"secret\r\n".to_vec();
145        if raw.ends_with(b"\n") {
146            raw.pop();
147            if raw.ends_with(b"\r") {
148                raw.pop();
149            }
150        }
151        assert_eq!(raw, b"secret");
152    }
153
154    #[test]
155    fn password_bytes_no_newline() {
156        let raw = b"secret".to_vec();
157        assert_eq!(raw, b"secret");
158    }
159
160    /// Verify that read_line returns an error for non-UTF-8 input.
161    #[test]
162    fn read_line_non_utf8() {
163        let raw = b"\xff\xfe".to_vec();
164        match String::from_utf8(raw) {
165            Ok(_) => panic!("expected 'string from bytes' to be invalid"),
166            Err(_) => {} // expected
167        }
168    }
169
170    #[test]
171    fn read_line_strip_newline() {
172        let mut s = "hello\n".to_string();
173        if s.ends_with('\n') {
174            s.pop();
175            if s.ends_with('\r') {
176                s.pop();
177            }
178        }
179        assert_eq!(s, "hello");
180    }
181
182    #[test]
183    fn read_line_strip_crlf() {
184        let mut s = "hello\r\n".to_string();
185        if s.ends_with('\n') {
186            s.pop();
187            if s.ends_with('\r') {
188                s.pop();
189            }
190        }
191        assert_eq!(s, "hello");
192    }
193
194    #[test]
195    fn read_line_no_newline() {
196        let s = "hello".to_string();
197        assert_eq!(s, "hello");
198    }
199}