diff options
-rw-r--r-- | .cargo_vcs_info.json | 6 | ||||
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | CHANGELOG | 20 | ||||
-rw-r--r-- | Cargo.lock | 39 | ||||
-rw-r--r-- | Cargo.toml | 41 | ||||
-rw-r--r-- | Cargo.toml.orig | 22 | ||||
-rw-r--r-- | LICENSE.md | 24 | ||||
-rw-r--r-- | README.md | 18 | ||||
-rw-r--r-- | README.tpl | 5 | ||||
-rw-r--r-- | examples/username-password.rs | 10 | ||||
-rw-r--r-- | src/lib.rs | 191 | ||||
-rw-r--r-- | src/sys/mod.rs | 11 | ||||
-rw-r--r-- | src/sys/unix.rs | 142 | ||||
-rw-r--r-- | src/sys/windows.rs | 117 |
14 files changed, 647 insertions, 0 deletions
diff --git a/.cargo_vcs_info.json b/.cargo_vcs_info.json new file mode 100644 index 0000000..81fad5f --- /dev/null +++ b/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "0fbe095a9b44a8da5b42c972b54af660a340acda" + }, + "path_in_vcs": "" +}
\ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..992ae6e --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,20 @@ +# Version 0.2.3 - 2023-08-09 +- [change][patch] Remove use of `IsTerminal` to support rust version 1.66 and up. +- [change][patch] Declare minimum rust version in `Cargo.toml`. + +# Version 0.2.2 - 2023-08-08 +- [fix][minor] Fix printing of newline in `Terminal::read_input_line()`. + +# Version 0.2.1 - 2023-08-04 +- [change][patch] Fix category slug in `Cargo.toml`. + +# Version 0.2.0 - 2023-08-04 +- [rename][major] Rename `TerminalPrompter` to `Terminal`. +- [rename][major] Rename `read_line()` to `read_input_line()`. +- [add][minor] Implement `BufRead` for `Terminal`. +- [change][minor] Try `sterr`, `stdin`, `stdout` and `/dev/tty` in that order on Unix. +- [change][minor] Do not cache the terminal mode, retrieve it every time when needed. +- [add][minor] Add documentation. + +# Version 0.1.0 - 2023-08-03 +- [add][minor] Initial release. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..44de46e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,39 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "terminal-prompt" +version = "0.2.3" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..752f5f4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.66" +name = "terminal-prompt" +version = "0.2.3" +authors = ["Maarten de Vries <maarten@de-vri.es>"] +publish = ["crates-io"] +description = "Tiny library for prompting sensitive or non-sensitive data on the terminal" +documentation = "https://docs.rs/terminal-prompt" +readme = "README.md" +keywords = [ + "terminal", + "console", + "prompt", + "input", + "tty", +] +categories = [ + "command-line-interface", + "os", +] +license = "BSD-2-Clause" +repository = "https://github.com/de-vri-es/terminal-prompt-rs" + +[target."cfg(unix)".dependencies.libc] +version = "0.2.147" + +[target."cfg(windows)".dependencies.winapi] +version = "0.3.9" +features = ["consoleapi"] diff --git a/Cargo.toml.orig b/Cargo.toml.orig new file mode 100644 index 0000000..360a8fc --- /dev/null +++ b/Cargo.toml.orig @@ -0,0 +1,22 @@ +[package] +name = "terminal-prompt" +version = "0.2.3" +description = "Tiny library for prompting sensitive or non-sensitive data on the terminal" +license = "BSD-2-Clause" + +authors = ["Maarten de Vries <maarten@de-vri.es>"] +repository = "https://github.com/de-vri-es/terminal-prompt-rs" +documentation = "https://docs.rs/terminal-prompt" + +keywords = ["terminal", "console", "prompt", "input", "tty"] +categories = ["command-line-interface", "os"] + +publish = ["crates-io"] +edition = "2021" + rust-version = "1.66" + +[target.'cfg(unix)'.dependencies] +libc = "0.2.147" + +[target.'cfg(windows)'.dependencies] +winapi = { version = "0.3.9", features = ["consoleapi"] } diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..df9bcf5 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2023, Maarten de Vries <maarten@de-vri.es> + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f4bb46 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# terminal-prompt + +Tiny library for prompting sensitive or non-sensitive data on the terminal. + +The only dependency is `libc` on Unix and `winapi` on Windows. + +See [`Terminal`] for the API documentation. + +## Example +Read a username and password from the terminal: +```rust +use terminal_prompt::Terminal; +let mut terminal = Terminal::open()?; +let username = terminal.prompt("Username: ")?; +let password = terminal.prompt_sensitive("Password: ")?; +``` + +[`Terminal`]: https://docs.rs/terminal-prompt/latest/terminal_prompt/struct.Terminal.html diff --git a/README.tpl b/README.tpl new file mode 100644 index 0000000..28afaae --- /dev/null +++ b/README.tpl @@ -0,0 +1,5 @@ +# {{crate}} + +{{readme}} + +[`Terminal`]: https://docs.rs/terminal-prompt/latest/terminal_prompt/struct.Terminal.html diff --git a/examples/username-password.rs b/examples/username-password.rs new file mode 100644 index 0000000..93071c9 --- /dev/null +++ b/examples/username-password.rs @@ -0,0 +1,10 @@ +use terminal_prompt::Terminal; + +fn main() -> std::io::Result<()> { + let mut terminal = Terminal::open()?; + let username = terminal.prompt("Username: ")?; + let password = terminal.prompt_sensitive("Password: ")?; + println!("Username: {username}"); + println!("Password: {password}"); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b8d42ee --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,191 @@ +//! Tiny library for prompting sensitive or non-sensitive data on the terminal. +//! +//! The only dependency is `libc` on Unix and `winapi` on Windows. +//! +//! See [`Terminal`] for the API documentation. +//! +//! # Example +//! Read a username and password from the terminal: +//! ```no_run +//! # fn main() -> std::io::Result<()> { +//! use terminal_prompt::Terminal; +//! let mut terminal = Terminal::open()?; +//! let username = terminal.prompt("Username: ")?; +//! let password = terminal.prompt_sensitive("Password: ")?; +//! # Ok(()) +//! # } +//! ``` + +#![warn(missing_docs)] + +use std::io::{BufReader, BufRead, Read, Write}; + +mod sys; + +/// A handle to the terminal associated with the current process. +/// +/// Once opened, you can use [`Self::prompt()`] to read non-sensitive data from the terminal, +/// and [`Self::prompt_sensitive()`] to read sensitive data like passwords. +/// +/// Alternatively, you can manually call [`Self::enable_echo()`] and [`Self::disable_echo()`], and read/write from the terminal directly. +/// The terminal handle implements the standard [`Read`], [`Write`] and [`BufRead`] traits, +/// and it has a [`Self::read_line()`] convenience function that returns a new string. +/// +/// # Terminal modes +/// When opened, the terminal will be put in line editing mode. +/// When dropped, the original mode of the terminal will be restored. +/// Note that the terminal is inherently a global resource, +/// so creating multiple terminal objects and dropping them in a different order can cause the terminal to be left in a different mode. +pub struct Terminal { + /// The underlying terminal. + terminal: BufReader<sys::Terminal>, + + /// The mode of the terminal when we opened it. + initial_mode: sys::TerminalMode, +} + +impl Terminal { + /// Open the terminal associated with the current process. + /// + /// The exact behavior is platform dependent. + /// + /// On Unix platforms, if one of standard I/O streams is a terminal, that terminal is used. + /// First standard error is tried, then standard input and finally standard output. + /// If none of those work, the function tries to open `/dev/tty`. + /// This means that on Unix platforms, the terminal prompt can still work, even when both standard input and standard output are connected to pipes instead of the terminal. + /// + /// On Windows, if both standard input and standard error are connected to a terminal, those streams are used. + /// + /// In all cases, if the function fails to find a terminal for the process, an error is returned. + pub fn open() -> std::io::Result<Self> { + // Open the terminal and retrieve the initial mode. + let terminal = sys::Terminal::open()?; + let initial_mode = terminal.get_terminal_mode()?; + + // Enable line editing mode. + let mut mode = initial_mode; + mode.enable_line_editing(); + terminal.set_terminal_mode(&mode)?; + + Ok(Self { + terminal: BufReader::new(terminal), + initial_mode, + }) + } + + /// Check if the terminal is echoing input. + /// + /// If enabled, any text typed on the terminal will be visible. + pub fn is_echo_enabled(&self) -> std::io::Result<bool> { + let mode = self.terminal.get_ref().get_terminal_mode()?; + Ok(mode.is_echo_enabled()) + } + + /// Disable echoing of terminal input. + /// + /// This will prevent text typed on the terminal from being visible. + /// This can be used to hide passwords while they are being typed. + pub fn disable_echo(&self) -> std::io::Result<()> { + let mut mode = self.terminal.get_ref().get_terminal_mode()?; + mode.disable_echo(); + self.terminal.get_ref().set_terminal_mode(&mode)?; + Ok(()) + } + + /// Enable echoing of terminal input. + /// + /// This will cause any text typed on the terminal to be visible. + pub fn enable_echo(&mut self) -> std::io::Result<()> { + let mut mode = self.terminal.get_ref().get_terminal_mode()?; + mode.enable_echo(); + self.terminal.get_ref().set_terminal_mode(&mode)?; + Ok(()) + } + + /// Read a line of input from the terminal. + /// + /// If echoing is disabled, this will also print a newline character to visually indicate to the user. + /// If this is not desired, use the [`BufRead::read_line()`] function instead. + pub fn read_input_line(&mut self) -> std::io::Result<String> { + let mut buffer = String::new(); + self.terminal.read_line(&mut buffer)?; + + if self.is_echo_enabled().ok() == Some(false) { + writeln!(self).ok(); + } + if buffer.ends_with('\n') { + buffer.pop(); + } + Ok(buffer) + } + + /// Prompt the user on the terminal. + /// + /// This function does not enable or disable echoing and should not normally be used for reading sensitive data like passwords. + /// Consider [`Self::prompt_sensitive()`] instead. + pub fn prompt(&mut self, prompt: impl std::fmt::Display) -> std::io::Result<String> { + write!(self, "{prompt}")?; + self.read_input_line() + } + + /// Prompt the user for sensitive data (like passwords) on the terminal. + /// + /// This function makes sure that echoing is disabled before the prompt is shown. + /// If echoing was enabled, it is re-enabled after the response is read. + /// + /// Use [`Self::prompt()`] to read non-sensitive data. + pub fn prompt_sensitive(&mut self, prompt: impl std::fmt::Display) -> std::io::Result<String> { + let old_mode = self.terminal.get_ref().get_terminal_mode()?; + if old_mode.is_echo_enabled() { + let mut new_mode = old_mode; + new_mode.disable_echo(); + self.terminal.get_ref().set_terminal_mode(&new_mode)?; + } + write!(self, "{prompt}")?; + let line = self.read_input_line(); + if old_mode.is_echo_enabled() { + self.terminal.get_ref().set_terminal_mode(&old_mode).ok(); + } + line + } +} + +impl Drop for Terminal { + fn drop(&mut self) { + self.terminal.get_ref().set_terminal_mode(&self.initial_mode).ok(); + } +} + +impl Read for Terminal { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + self.terminal.read(buf) + } + + fn read_vectored(&mut self, bufs: &mut [std::io::IoSliceMut<'_>]) -> std::io::Result<usize> { + self.terminal.read_vectored(bufs) + } +} + +impl BufRead for Terminal { + fn fill_buf(&mut self) -> std::io::Result<&[u8]> { + self.terminal.fill_buf() + } + + fn consume(&mut self, amt: usize) { + self.terminal.consume(amt) + } +} + +impl Write for Terminal { + fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { + self.terminal.get_mut().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.terminal.get_mut().flush() + } + + fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result<usize> { + self.terminal.get_mut().write_vectored(bufs) + } +} diff --git a/src/sys/mod.rs b/src/sys/mod.rs new file mode 100644 index 0000000..3525e01 --- /dev/null +++ b/src/sys/mod.rs @@ -0,0 +1,11 @@ +#[cfg(unix)] +mod unix; + +#[cfg(unix)] +pub use unix::*; + +#[cfg(windows)] +mod windows; + +#[cfg(windows)] +pub use windows::*; diff --git a/src/sys/unix.rs b/src/sys/unix.rs new file mode 100644 index 0000000..67bc1da --- /dev/null +++ b/src/sys/unix.rs @@ -0,0 +1,142 @@ +use std::fs::File; +use std::io::{Read, Write}; +use std::mem::ManuallyDrop; +use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, RawFd}; + +/// Unix handle to an open terminal. +pub enum Terminal { + /// Non-owning file for one of the standard I/O streams. + Stdio(ManuallyDrop<File>), + + /// Owned file for `/dev/tty`. + File(File), +} + +#[derive(Copy, Clone)] +pub struct TerminalMode { + termios: libc::termios, +} + +impl Terminal { + pub fn open() -> std::io::Result<Self> { + if let Some(terminal) = open_fd_terminal(2) { + Ok(terminal) + } else if let Some(terminal) = open_fd_terminal(0) { + Ok(terminal) + } else if let Some(terminal) = open_fd_terminal(1) { + Ok(terminal) + } else { + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty")?; + if is_terminal(file.as_fd()) { + Ok(Self::File(file)) + } else { + Err(std::io::Error::from_raw_os_error(libc::ENOTTY)) + } + } + } + + pub fn get_terminal_mode(&self) -> std::io::Result<TerminalMode> { + unsafe { + let mut termios = std::mem::zeroed(); + check_ret(libc::tcgetattr(self.as_fd().as_raw_fd(), &mut termios))?; + Ok(TerminalMode { termios }) + } + } + + pub fn set_terminal_mode(&self, mode: &TerminalMode) -> std::io::Result<()> { + unsafe { + check_ret(libc::tcsetattr( + self.as_fd().as_raw_fd(), + libc::TCSANOW, + &mode.termios, + ))?; + Ok(()) + } + } + + fn as_file(&self) -> &File { + match self { + Self::Stdio(io) => io, + Self::File(io) => io, + } + } +} + +fn open_fd_terminal(fd: RawFd) -> Option<Terminal> { + let file = unsafe { ManuallyDrop::new(File::from_raw_fd(fd)) }; + if is_terminal(file.as_fd()) { + Some(Terminal::Stdio(file)) + } else { + None + } +} + +impl TerminalMode { + pub fn enable_line_editing(&mut self) { + self.termios.c_lflag |= libc::ICANON; + } + + pub fn disable_echo(&mut self) { + self.termios.c_lflag &= !libc::ECHO; + self.termios.c_lflag &= !libc::ICANON; + } + + pub fn enable_echo(&mut self) { + self.termios.c_lflag |= libc::ECHO; + self.termios.c_lflag |= !libc::ICANON; + } + + pub fn is_echo_enabled(&self) -> bool { + self.termios.c_lflag & libc::ECHO != 0 + } +} + +fn is_terminal(fd: BorrowedFd) -> bool { + unsafe { + libc::isatty(fd.as_raw_fd()) == 1 + } +} + +fn check_ret(input: i32) -> std::io::Result<()> { + if input == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +impl AsFd for Terminal { + fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { + match self { + Self::Stdio(stdin) => stdin.as_fd(), + Self::File(file) => file.as_fd(), + } + } +} + +impl Read for Terminal { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + self.as_file().read(buf) + } + + fn read_vectored(&mut self, bufs: &mut [std::io::IoSliceMut<'_>]) -> std::io::Result<usize> { + self.as_file().read_vectored(bufs) + } +} + +impl Write for Terminal { + fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { + self.as_file().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.as_file().flush() + } + + fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result<usize> { + self.as_file().write_vectored(bufs) + } +} diff --git a/src/sys/windows.rs b/src/sys/windows.rs new file mode 100644 index 0000000..958f993 --- /dev/null +++ b/src/sys/windows.rs @@ -0,0 +1,117 @@ +use std::io::{Read, Write}; +use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle}; + +use winapi::um::consoleapi::{ + GetConsoleMode, + SetConsoleMode, +}; +use winapi::um::wincon::{ + ENABLE_LINE_INPUT, + ENABLE_ECHO_INPUT, +}; + +use winapi::shared::minwindef::{BOOL, DWORD}; + +pub struct Terminal { + input: std::io::Stdin, + output: std::io::Stderr, +} + +#[derive(Copy, Clone)] +pub struct TerminalMode { + input_mode: DWORD, +} + +impl Terminal { + pub fn open() -> std::io::Result<Self> { + let input = std::io::stdin(); + let output = std::io::stderr(); + if !is_terminal(input.as_handle()) { + return Err(std::io::Error::new(std::io::ErrorKind::Other, "stdin is not a terminal")); + } + if !is_terminal(output.as_handle()) { + return Err(std::io::Error::new(std::io::ErrorKind::Other, "stderr is not a terminal")); + } + Ok(Self { + input, + output, + }) + } + + pub fn get_terminal_mode(&self) -> std::io::Result<TerminalMode> { + unsafe { + let mut input_mode = 0; + check_ret(GetConsoleMode(self.input.as_raw_handle().cast(), &mut input_mode))?; + Ok(TerminalMode { + input_mode, + }) + } + } + + pub fn set_terminal_mode(&self, mode: &TerminalMode) -> std::io::Result<()> { + unsafe { + check_ret(SetConsoleMode( + self.input.as_raw_handle().cast(), + mode.input_mode, + ))?; + Ok(()) + } + } +} + +impl TerminalMode { + pub fn enable_line_editing(&mut self) { + self.input_mode |= ENABLE_LINE_INPUT; + } + + pub fn disable_echo(&mut self) { + self.input_mode &= !ENABLE_ECHO_INPUT; + } + + pub fn enable_echo(&mut self) { + self.input_mode |= ENABLE_ECHO_INPUT; + } + + pub fn is_echo_enabled(&self) -> bool { + self.input_mode & ENABLE_ECHO_INPUT != 0 + } +} + +fn is_terminal(handle: BorrowedHandle) -> bool { + unsafe { + let mut mode = 0; + GetConsoleMode(handle.as_raw_handle().cast(), &mut mode) != 0 + } +} + +fn check_ret(input: BOOL) -> std::io::Result<()> { + if input != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +impl Read for Terminal { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + self.input.read(buf) + } + + fn read_vectored(&mut self, bufs: &mut [std::io::IoSliceMut<'_>]) -> std::io::Result<usize> { + self.input.read_vectored(bufs) + } +} + +impl Write for Terminal { + fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { + self.output.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.output.flush() + } + + fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result<usize> { + self.output.write_vectored(bufs) + } +} |