syd 3.58.0

rock-solid application kernel
Documentation
//
// Syd: rock-solid application kernel
// src/getpass.rs: Read passwords from controlling terminal
//
// Copyright (c) 2026 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

//! Read passwords from controlling terminal

use std::{
    io::Read,
    os::fd::{AsFd, BorrowedFd},
};

use libc::{ECHO, ECHONL, ICANON, ISIG, VMIN, VTIME};
use nix::{
    errno::Errno,
    fcntl::OFlag,
    sys::signal::{raise, Signal},
};

use crate::{
    compat::{tcgetattr2, ResolveFlag, Termios2},
    cookie::{safe_read, safe_tcsetattr},
    err::err2no,
    io::write_all,
    lookup::safe_open_abs,
    zeroize::{Zeroize, Zeroizing},
};

const DEV_TTY: &str = "/dev/tty";

const BACKSPACE: u8 = 0x08;
const DEL: u8 = 0x7F;
const CTRL_C: u8 = 0x03;
const CTRL_D: u8 = 0x04;
const CTRL_U: u8 = 0x15;
const CTRL_W: u8 = 0x17;
const ESC: u8 = 0x1B;

/// Prompt on `/dev/tty` and read a password back from it with echo disabled.
pub fn prompt_password(prompt: &str) -> Result<Zeroizing<Vec<u8>>, Errno> {
    read_prompt(DEV_TTY, prompt)
}

/// Read a password from `reader` without any terminal interaction.
pub fn read_password<R: Read>(mut reader: R) -> Result<Zeroizing<Vec<u8>>, Errno> {
    read_secret(|| read_byte_io(&mut reader), || Ok(()))
}

struct RawGuard<'a> {
    fd: BorrowedFd<'a>,
    orig: Termios2,
}

impl<'a> RawGuard<'a> {
    fn new(fd: BorrowedFd<'a>, orig: Termios2) -> Self {
        RawGuard { fd, orig }
    }
}

impl Drop for RawGuard<'_> {
    fn drop(&mut self) {
        let _ = safe_tcsetattr(self.fd, self.orig);
    }
}

fn read_prompt(path: &str, prompt: &str) -> Result<Zeroizing<Vec<u8>>, Errno> {
    let tty = safe_open_abs(path, OFlag::O_RDWR | OFlag::O_NOCTTY, ResolveFlag::empty())?;
    write_all(tty.as_fd(), prompt.as_bytes())?;

    let orig = tcgetattr2(tty.as_fd())?;
    enable_raw_mode(tty.as_fd(), &orig)?;
    let _guard = RawGuard::new(tty.as_fd(), orig);

    read_secret(|| read_byte_fd(&tty), || write_all(tty.as_fd(), b"\n"))
}

fn enable_raw_mode(fd: BorrowedFd<'_>, orig: &Termios2) -> Result<(), Errno> {
    let mut raw = *orig;
    raw.c_lflag &= !(ECHO | ICANON | ECHONL | ISIG);
    raw.c_cc[VMIN] = 1;
    raw.c_cc[VTIME] = 0;
    safe_tcsetattr(fd, raw)
}

fn read_secret(
    mut next: impl FnMut() -> Result<Option<u8>, Errno>,
    mut newline: impl FnMut() -> Result<(), Errno>,
) -> Result<Zeroizing<Vec<u8>>, Errno> {
    let mut password: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::new());

    while let Some(byte) = next()? {
        match byte {
            // LF / CR: submit line.
            b'\n' | b'\r' => {
                newline()?;
                break;
            }
            // Backspace / DEL: Drop last character.
            DEL | BACKSPACE => pop_char(&mut password),
            // Ctrl-U: Clear and wipe whole line.
            CTRL_U => password.zeroize(),
            // Ctrl-W: Clear back to (and including) the last space.
            CTRL_W => clear_last_word(&mut password),
            // Ctrl-C: Emit a newline, raise SIGINT, and report interruption.
            CTRL_C => {
                newline()?;
                raise(Signal::SIGINT)?;
                return Err(Errno::EINTR);
            }
            // Ctrl-D: End-of-file only when line is empty.
            CTRL_D => {
                if password.is_empty() {
                    return Err(Errno::ENODATA);
                }
            }
            // ESC: Consume and discard a CSI/SS3 escape sequence.
            ESC => {
                let byte = match next()? {
                    Some(byte) => byte,
                    None => break,
                };
                if byte == b'[' || byte == b'O' {
                    // Read until final byte in the 0x40..=0x7E range.
                    while let Some(byte) = next()? {
                        if (0x40..=0x7E).contains(&byte) {
                            break;
                        }
                    }
                }
            }
            byte if byte >= 0x20 => {
                password.try_reserve(1).or(Err(Errno::ENOMEM))?;
                password.push(byte);
            }
            // Discard unrecognized control bytes.
            _ => {}
        }
    }

    Ok(password)
}

fn first_byte(nread: usize, byte: [u8; 1]) -> Option<u8> {
    (nread != 0).then_some(byte[0])
}

fn read_byte_io<R: Read>(reader: &mut R) -> Result<Option<u8>, Errno> {
    let mut byte = [0u8; 1];
    let nread = reader.read(&mut byte).map_err(|error| err2no(&error))?;
    Ok(first_byte(nread, byte))
}

fn read_byte_fd<Fd: AsFd>(fd: Fd) -> Result<Option<u8>, Errno> {
    let mut byte = [0u8; 1];
    let nread = safe_read(fd, &mut byte)?;
    Ok(first_byte(nread, byte))
}

fn pop_char(buf: &mut Vec<u8>) {
    while let Some(byte) = buf.pop() {
        if byte & 0xC0 != 0x80 {
            break;
        }
    }
}

fn clear_last_word(buf: &mut Vec<u8>) {
    let trimmed = buf
        .iter()
        .rposition(|&byte| byte != b' ')
        .map_or(0, |pos| pos.saturating_add(1));
    let keep = buf[..trimmed]
        .iter()
        .rposition(|&byte| byte == b' ')
        .map_or(0, |pos| pos.saturating_add(1));
    buf[keep..].zeroize();
    buf.truncate(keep);
}

#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
    use std::io::{self, Read, Write};

    use memchr::memmem;
    use nix::{
        fcntl::OFlag,
        pty::{grantpt, posix_openpt, ptsname_r, unlockpt, PtyMaster},
        sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal},
    };

    use super::*;

    struct FailReader;
    impl Read for FailReader {
        fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
            Err(io::Error::from_raw_os_error(libc::EIO))
        }
    }

    fn pty() -> (PtyMaster, String) {
        let main = posix_openpt(OFlag::O_RDWR | OFlag::O_NOCTTY).unwrap();
        grantpt(&main).unwrap();
        unlockpt(&main).unwrap();
        let peer = ptsname_r(&main).unwrap();
        (main, peer)
    }

    fn lflag(peer: &str) -> libc::tcflag_t {
        let obs =
            safe_open_abs(peer, OFlag::O_RDWR | OFlag::O_NOCTTY, ResolveFlag::empty()).unwrap();
        tcgetattr2(obs.as_fd()).unwrap().c_lflag
    }

    #[test]
    fn test_read_password_1() {
        assert_eq!(&*read_password(&b"hunter2\n"[..]).unwrap(), b"hunter2");
    }

    #[test]
    fn test_read_password_2() {
        assert_eq!(
            &*read_password(&b"hunter2\rignored"[..]).unwrap(),
            b"hunter2"
        );
    }

    #[test]
    fn test_read_password_3() {
        assert_eq!(&*read_password(&b"hunter2"[..]).unwrap(), b"hunter2");
    }

    #[test]
    fn test_read_password_4() {
        assert_eq!(&*read_password(&b""[..]).unwrap(), b"");
        assert_eq!(&*read_password(&b"\n"[..]).unwrap(), b"");
    }

    #[test]
    fn test_read_password_5() {
        assert_eq!(&*read_password(&b"abX\x7fc\n"[..]).unwrap(), b"abc");
        assert_eq!(&*read_password(&b"abX\x08c\n"[..]).unwrap(), b"abc");
        assert_eq!(&*read_password(&b"\x7f\x08x\n"[..]).unwrap(), b"x");
    }

    #[test]
    fn test_read_password_6() {
        assert_eq!(&*read_password(&b"a\xc3\xa9\x7f\n"[..]).unwrap(), b"a");
    }

    #[test]
    fn test_read_password_7() {
        assert_eq!(
            &*read_password(&b"secret\x15retry\n"[..]).unwrap(),
            b"retry"
        );
    }

    #[test]
    fn test_read_password_8() {
        assert_eq!(&*read_password(&b"foo bar\x17\n"[..]).unwrap(), b"foo ");
        assert_eq!(&*read_password(&b"foobar\x17\n"[..]).unwrap(), b"");
        assert_eq!(&*read_password(&b"foo   \x17\n"[..]).unwrap(), b"");
    }

    #[test]
    fn test_read_password_9() {
        assert_eq!(read_password(&b"\x04"[..]).err(), Some(Errno::ENODATA));
    }

    #[test]
    fn test_read_password_10() {
        assert_eq!(&*read_password(&b"ab\x04cd\n"[..]).unwrap(), b"abcd");
    }

    #[test]
    fn test_read_password_11() {
        assert_eq!(&*read_password(&b"a\x1b[Db\n"[..]).unwrap(), b"ab");
        assert_eq!(&*read_password(&b"a\x1bOPb\n"[..]).unwrap(), b"ab");
        assert_eq!(&*read_password(&b"a\x1bZb\n"[..]).unwrap(), b"ab");
        assert_eq!(&*read_password(&b"a\x1b"[..]).unwrap(), b"a");
        assert_eq!(&*read_password(&b"a\x1b[1;5Db\n"[..]).unwrap(), b"ab");
    }

    #[test]
    fn test_read_password_12() {
        assert_eq!(&*read_password(&b"a\x01\x09b\n"[..]).unwrap(), b"ab");
    }

    #[test]
    fn test_read_password_13() {
        assert_eq!(
            &*read_password("aé€\n".as_bytes()).unwrap(),
            "aé€".as_bytes()
        );
    }

    #[test]
    fn test_read_password_14() {
        assert_eq!(
            &*read_password(&b"\xff\xfe\x80\n"[..]).unwrap(),
            &[0xff, 0xfe, 0x80]
        );
    }

    #[test]
    fn test_read_password_15() {
        assert_eq!(read_password(FailReader).err(), Some(Errno::EIO));
    }

    #[test]
    fn test_read_password_16() {
        let ignore = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty());
        let prev = unsafe { sigaction(Signal::SIGINT, &ignore).unwrap() };
        let result = read_password(&b"ab\x03cd\n"[..]);
        unsafe { sigaction(Signal::SIGINT, &prev).unwrap() };
        assert_eq!(result.err(), Some(Errno::EINTR));
    }

    #[test]
    fn test_read_prompt_1() {
        assert!(read_prompt("/dev/null/nonexistent", "Password: ").is_err());
    }

    #[test]
    fn test_read_prompt_2() {
        let (mut main, peer) = pty();
        let before = lflag(&peer);
        assert_ne!(before & (ECHO | ICANON), 0);

        main.write_all(b"corr\xffct\n").unwrap();

        let pass = read_prompt(&peer, "Password: ").unwrap();
        assert_eq!(&*pass, b"corr\xffct");
        assert_eq!(lflag(&peer), before);

        let mut buf = [0u8; 64];
        let n = main.read(&mut buf).unwrap();
        assert!(memmem::find(&buf[..n], b"Password: ").is_some());
    }

    #[test]
    fn test_raw_guard_1() {
        let (_main, peer) = pty();
        let before = lflag(&peer);
        let fd = safe_open_abs(
            peer.as_str(),
            OFlag::O_RDWR | OFlag::O_NOCTTY,
            ResolveFlag::empty(),
        )
        .unwrap();
        let orig = tcgetattr2(fd.as_fd()).unwrap();

        enable_raw_mode(fd.as_fd(), &orig).unwrap();
        assert_eq!(lflag(&peer) & (ECHO | ICANON), 0);

        drop(RawGuard::new(fd.as_fd(), orig));
        assert_eq!(lflag(&peer), before);
    }

    #[test]
    fn test_prompt_password_1() {
        if safe_open_abs(
            DEV_TTY,
            OFlag::O_RDWR | OFlag::O_NONBLOCK | OFlag::O_NOCTTY,
            ResolveFlag::empty(),
        )
        .is_err()
        {
            assert!(prompt_password("Password: ").is_err());
        }
    }
}