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 bytes do not include the trailing newline.
10/// Mimics the behaviour of Go's `golang.org/x/term.ReadPassword`.
11pub fn read_password() -> Result<Vec<u8>, Error> {
12    platform::read_password()
13}
14
15// ── Unix ─────────────────────────────────────────────────────────────────────
16
17#[cfg(unix)]
18mod platform {
19    use std::io::Read;
20
21    use libc::{ECHO, ICANON, ICRNL, ISIG, STDIN_FILENO, TCSANOW, tcgetattr, tcsetattr, termios};
22
23    /// RAII guard that restores the saved termios state when dropped.
24    struct TermiosGuard {
25        saved: termios,
26    }
27
28    impl Drop for TermiosGuard {
29        fn drop(&mut self) {
30            // Ignore errors: we are in a destructor and cannot propagate them.
31            unsafe { tcsetattr(STDIN_FILENO, TCSANOW, &self.saved) };
32        }
33    }
34
35    pub(super) fn read_password() -> Result<Vec<u8>, super::Error> {
36        // Attempt to read the current terminal attributes.
37        let mut saved: termios = unsafe { std::mem::zeroed() };
38        let is_tty = unsafe { tcgetattr(STDIN_FILENO, &mut saved) } == 0;
39
40        // Disable echo for the duration of the read; the guard restores it.
41        let _guard = if is_tty {
42            let mut raw = saved;
43            // Clear ECHO — keep ICANON and ISIG so the kernel still delivers
44            // a full line on Enter and honours Ctrl-C / Ctrl-D.  Set ICRNL so
45            // a bare carriage-return is mapped to a newline (matches Go).
46            raw.c_lflag &= !(ECHO as libc::tcflag_t);
47            raw.c_lflag |= (ICANON | ISIG) as libc::tcflag_t;
48            raw.c_iflag |= ICRNL as libc::tcflag_t;
49            // SAFETY: fd is STDIN_FILENO, raw is a valid termios.
50            unsafe { tcsetattr(STDIN_FILENO, TCSANOW, &raw) };
51            Some(TermiosGuard {
52                saved,
53            })
54        } else {
55            None
56        };
57
58        read_line()
59    }
60
61    fn read_line() -> Result<Vec<u8>, super::Error> {
62        let mut buf = Vec::new();
63        let stdin = std::io::stdin();
64        for byte in stdin.lock().bytes() {
65            let b = byte?;
66            if b == b'\n' || b == b'\r' {
67                break;
68            }
69            buf.push(b);
70        }
71        Ok(buf)
72    }
73}
74
75// ── Fallback (non-Unix) ──────────────────────────────────────────────────────
76
77#[cfg(not(unix))]
78mod platform {
79    use std::io::BufRead;
80
81    pub(super) fn read_password() -> Result<Vec<u8>, super::Error> {
82        let mut line = String::new();
83        std::io::stdin().lock().read_line(&mut line)?;
84        if line.ends_with('\n') {
85            line.pop();
86            if line.ends_with('\r') {
87                line.pop();
88            }
89        }
90        Ok(line.into_bytes())
91    }
92}
93
94// ── Tests ────────────────────────────────────────────────────────────────────
95
96#[cfg(test)]
97mod tests {
98    // Integration tests for read_password() require an interactive terminal
99    // and cannot run in a CI pipeline.  We instead test the internal helpers
100    // that are available on every platform.
101
102    /// Verify that a Vec of bytes produced by the platform helper does not
103    /// contain a trailing newline or carriage-return.
104    #[test]
105    fn password_bytes_strip_newline() {
106        let mut raw = b"secret\n".to_vec();
107        if raw.ends_with(b"\n") {
108            raw.pop();
109            if raw.ends_with(b"\r") {
110                raw.pop();
111            }
112        }
113        assert_eq!(raw, b"secret");
114    }
115
116    #[test]
117    fn password_bytes_strip_crlf() {
118        let mut raw = b"secret\r\n".to_vec();
119        if raw.ends_with(b"\n") {
120            raw.pop();
121            if raw.ends_with(b"\r") {
122                raw.pop();
123            }
124        }
125        assert_eq!(raw, b"secret");
126    }
127
128    #[test]
129    fn password_bytes_no_newline() {
130        let raw = b"secret".to_vec();
131        assert_eq!(raw, b"secret");
132    }
133}