keyfork-prompt: add Headless

This commit is contained in:
Ryan Heywood 2025-01-04 00:30:41 -05:00
parent 92dde3dcee
commit f8db8702ce
Signed by: ryan
GPG Key ID: 8E401478A3FBEF72
3 changed files with 197 additions and 21 deletions

View File

@ -1,41 +1,26 @@
#![allow(missing_docs)] #![allow(missing_docs)]
use std::io::{stdin, stdout};
use keyfork_prompt::{ use keyfork_prompt::{
prompt_validated_wordlist, prompt_validated_wordlist,
validators::{mnemonic, Validator}, validators::{mnemonic, Validator},
Terminal, default_handler,
}; };
use keyfork_mnemonic::English; use keyfork_mnemonic::English;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut mgr = Terminal::new(stdin(), stdout())?; let mut handler = default_handler().unwrap();
let transport_validator = mnemonic::MnemonicSetValidator { let transport_validator = mnemonic::MnemonicSetValidator {
word_lengths: [9, 24], word_lengths: [24],
};
let combine_validator = mnemonic::MnemonicSetValidator {
word_lengths: [24, 48],
}; };
let mnemonics = prompt_validated_wordlist::<English, _>( let mnemonics = prompt_validated_wordlist::<English, _>(
&mut mgr, &mut *handler,
"Enter a 9-word and 24-word mnemonic: ", "Enter a 24-word mnemonic: ",
3, 3,
&*transport_validator.to_fn(), &*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::<English, _>(
&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[0].as_bytes().len(), 32);
assert_eq!(mnemonics[1].as_bytes().len(), 64);
Ok(()) Ok(())
} }

View File

@ -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<String> {
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<String> {
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<String> {
// 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))
}
}

View File

@ -1,13 +1,37 @@
//! Prompt display and interaction management. //! Prompt display and interaction management.
use std::io::IsTerminal;
#[cfg(feature = "mnemonic")] #[cfg(feature = "mnemonic")]
use keyfork_mnemonic::Wordlist; use keyfork_mnemonic::Wordlist;
/// pub mod headless;
pub mod terminal; pub mod terminal;
pub mod validators; pub mod validators;
pub use headless::Headless;
pub use terminal::{default_terminal, DefaultTerminal, Terminal}; 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. /// An error occurred while displaying a prompt.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum Error {
@ -157,3 +181,50 @@ pub fn prompt_validated_passphrase<V>(
})?; })?;
Ok(opt.unwrap()) 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<Box<dyn PromptHandler>, 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()))
}