diff --git a/crates/util/keyfork-prompt/examples/test-basic-prompt.rs b/crates/util/keyfork-prompt/examples/test-basic-prompt.rs index 17199ac..9fdce69 100644 --- a/crates/util/keyfork-prompt/examples/test-basic-prompt.rs +++ b/crates/util/keyfork-prompt/examples/test-basic-prompt.rs @@ -1,41 +1,26 @@ #![allow(missing_docs)] -use std::io::{stdin, stdout}; - use keyfork_prompt::{ prompt_validated_wordlist, validators::{mnemonic, Validator}, - Terminal, + default_handler, }; use keyfork_mnemonic::English; fn main() -> Result<(), Box> { - let mut mgr = Terminal::new(stdin(), stdout())?; + let mut handler = default_handler().unwrap(); let transport_validator = mnemonic::MnemonicSetValidator { - word_lengths: [9, 24], - }; - let combine_validator = mnemonic::MnemonicSetValidator { - word_lengths: [24, 48], + word_lengths: [24], }; let mnemonics = prompt_validated_wordlist::( - &mut mgr, - "Enter a 9-word and 24-word mnemonic: ", + &mut *handler, + "Enter a 24-word mnemonic: ", 3, &*transport_validator.to_fn(), )?; - assert_eq!(mnemonics[0].as_bytes().len(), 12); - assert_eq!(mnemonics[1].as_bytes().len(), 32); - - let mnemonics = prompt_validated_wordlist::( - &mut mgr, - "Enter a 24 and 48-word mnemonic: ", - 3, - &*combine_validator.to_fn(), - )?; assert_eq!(mnemonics[0].as_bytes().len(), 32); - assert_eq!(mnemonics[1].as_bytes().len(), 64); Ok(()) } diff --git a/crates/util/keyfork-prompt/src/headless.rs b/crates/util/keyfork-prompt/src/headless.rs new file mode 100644 index 0000000..2f3ce01 --- /dev/null +++ b/crates/util/keyfork-prompt/src/headless.rs @@ -0,0 +1,120 @@ +//! A headless prompt handler. +//! +//! This prompt handler uses the program's standard input and output to read inputs. It is not +//! directly intended to be machine-readable, but can be used for scriptable automation in a +//! fashion similar to a terminal handler. + +use std::io::{IsTerminal, Write}; + +use crate::{BoxResult, Error, Message, PromptHandler, Result}; + +/// A headless prompt handler, usable in situations when a terminal might not be available, or for +/// scripting purposes where manual input from a terminal is not desirable. +pub struct Headless { + stdin: std::io::Stdin, + stderr: std::io::Stderr, +} + +impl Headless { + /// Create a new [`Headless`] prompt handler. + #[allow(clippy::missing_errors_doc, clippy::new_without_default)] + pub fn new() -> Self { + Self { + stdin: std::io::stdin(), + stderr: std::io::stderr(), + } + } +} + +impl PromptHandler for Headless { + fn prompt_input(&mut self, prompt: &str) -> Result { + self.stderr.write_all(prompt.as_bytes())?; + self.stderr.flush()?; + let mut line = String::new(); + self.stdin.read_line(&mut line)?; + Ok(line) + } + + fn prompt_wordlist(&mut self, prompt: &str, _wordlist: &[&str]) -> Result { + self.stderr.write_all(prompt.as_bytes())?; + self.stderr.flush()?; + let mut line = String::new(); + self.stdin.read_line(&mut line)?; + Ok(line) + } + + fn prompt_passphrase(&mut self, prompt: &str) -> Result { + // Temporarily perform an IOCTL to disable printed output. + if self.stdin.is_terminal() { + eprintln!("WARNING: Headless terminal mode may leak passwords!"); + } + self.stderr.write_all(prompt.as_bytes())?; + self.stderr.flush()?; + let mut line = String::new(); + self.stdin.read_line(&mut line)?; + Ok(line) + } + + fn prompt_message(&mut self, prompt: Message) -> Result<()> { + match prompt { + Message::Text(s) => { + self.stderr.write_all(s.as_bytes())?; + self.stderr.flush()?; + } + Message::Data(s) => { + self.stderr.write_all(s.as_bytes())?; + self.stderr.flush()?; + } + } + Ok(()) + } + + fn prompt_validated_wordlist( + &mut self, + prompt: &str, + retries: u8, + _wordlist: &[&str], + validator_fn: &mut dyn FnMut(String) -> BoxResult, + ) -> Result<()> { + let mut line = String::new(); + let mut last_error = String::new(); + for _ in 0..retries { + self.stderr.write_all(prompt.as_bytes())?; + self.stderr.flush()?; + self.stderr.flush()?; + self.stdin.read_line(&mut line)?; + if let Err(e) = validator_fn(std::mem::take(&mut line)) { + last_error = e.to_string(); + self.stderr.write_all(e.to_string().as_bytes())?; + self.stderr.flush()?; + } else { + return Ok(()); + } + } + Err(Error::Validation(retries, last_error)) + } + + fn prompt_validated_passphrase( + &mut self, + prompt: &str, + retries: u8, + validator_fn: &mut dyn FnMut(String) -> BoxResult, + ) -> Result<()> { + let mut line = String::new(); + let mut last_error = String::new(); + for _ in 0..retries { + self.stderr.write_all(prompt.as_bytes())?; + self.stderr.flush()?; + self.stdin.read_line(&mut line)?; + if let Err(e) = validator_fn(std::mem::take(&mut line)) { + last_error = e.to_string(); + self.stderr.write_all(e.to_string().as_bytes())?; + self.stderr.write_all(b"\n")?; + self.stderr.flush()?; + } else { + return Ok(()); + } + } + Err(Error::Validation(retries, last_error)) + } +} diff --git a/crates/util/keyfork-prompt/src/lib.rs b/crates/util/keyfork-prompt/src/lib.rs index 8be0fbb..3fc7e46 100644 --- a/crates/util/keyfork-prompt/src/lib.rs +++ b/crates/util/keyfork-prompt/src/lib.rs @@ -1,13 +1,37 @@ //! Prompt display and interaction management. +use std::io::IsTerminal; + #[cfg(feature = "mnemonic")] use keyfork_mnemonic::Wordlist; -/// +pub mod headless; pub mod terminal; pub mod validators; +pub use headless::Headless; pub use terminal::{default_terminal, DefaultTerminal, Terminal}; +/// An error occurred in the process of loading a default handler. +#[derive(thiserror::Error, Debug)] +pub enum DefaultHandlerError { + /// An invalid handler was loaded. + #[error("An invalid handler was loaded: {handler} ({error})")] + InvalidHandler { + /// The handle that caused an error. + handler: String, + + /// The error that occurred. + error: String, + }, + + /// An unknown handler was requested. + #[error("An unknown handler was requested: {handler}")] + UnknownHandler { + /// The requested, but unknown, handler. + handler: String, + }, +} + /// An error occurred while displaying a prompt. #[derive(thiserror::Error, Debug)] pub enum Error { @@ -157,3 +181,50 @@ pub fn prompt_validated_passphrase( })?; Ok(opt.unwrap()) } + +/// Get a Prompt Handler that is most suitable for the given environment. +/// +/// The following handlers will be used based on the `KEYFORK_PROMPT_TYPE` variable: +/// * `KEYFORK_PROMPT_TYPE=terminal`: [`DefaultTerminal`] +/// * `KEYFORK_PROMPT_TYPE=headless`: [`Headless`] +/// +/// Otherwise, the following heuristics are followed: +/// * [`std::io::IsTerminal::is_terminal`]: [`DefaultTerminal`] +/// * default: [`Headless`] +/// +/// # Errors +/// +/// The function will return an error if a specific handler was requested but could not be +/// constructed. +pub fn default_handler() -> Result, DefaultHandlerError> { + if let Some((_, value)) = std::env::vars().find(|(k, _)| k == "KEYFORK_PROMPT_TYPE") { + match value.as_str() { + "terminal" => match default_terminal() { + Ok(terminal) => return Ok(Box::new(terminal)), + Err(e) => { + return Err(DefaultHandlerError::InvalidHandler { + handler: value, + error: e.to_string(), + }) + } + }, + "headless" => { + return Ok(Box::new(Headless::new())); + } + _ => { + return Err(DefaultHandlerError::UnknownHandler { handler: value }); + } + } + } + + // stdout can be not-a-terminal and we'll just override it anyways, stdin is the + // important one. + if std::io::stdin().is_terminal() { + // because this is a "guessed" handler, let's take the nice route and not error, just skip. + if let Ok(terminal) = default_terminal() { + return Ok(Box::new(terminal)); + } + } + + Ok(Box::new(Headless::new())) +}