use std::io::{BufRead as _, Write as _};
use crate::Error;
#[derive(Debug, Clone, Default)]
pub struct PinPromptInfo {
pub reason: String,
pub retries_remaining: u32,
pub wants_pin: bool,
}
pub trait PinProvider: std::fmt::Debug {
fn provide_pin(&self, prompt: &PinPromptInfo) -> Result<String, Error>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TtyPinProvider;
impl PinProvider for TtyPinProvider {
fn provide_pin(&self, prompt: &PinPromptInfo) -> Result<String, Error> {
if !prompt.wants_pin {
return Ok(String::new());
}
let label = if prompt.reason.is_empty() {
"PIN".to_owned()
} else {
prompt.reason.clone()
};
let hint = if prompt.retries_remaining > 0 {
format!(
"{label} ({} attempt(s) remaining): ",
prompt.retries_remaining
)
} else {
format!("{label}: ")
};
let mut stderr = std::io::stderr();
stderr
.write_all(hint.as_bytes())
.and_then(|()| stderr.flush())
.map_err(|e| Error::ExternalSignerSpawn(format!("PIN prompt write failed: {e}")))?;
let echo_was_disabled = disable_terminal_echo();
let mut line = String::new();
let read_result = std::io::stdin().lock().read_line(&mut line);
if echo_was_disabled {
restore_terminal_echo();
let _ = stderr.write_all(b"\n");
}
read_result.map_err(|e| Error::ExternalSignerSpawn(format!("PIN read failed: {e}")))?;
Ok(line.trim_end_matches(['\n', '\r']).to_owned())
}
}
#[cfg(unix)]
fn disable_terminal_echo() -> bool {
std::process::Command::new("stty")
.arg("-echo")
.status()
.is_ok_and(|status| status.success())
}
#[cfg(unix)]
fn restore_terminal_echo() {
let _ = std::process::Command::new("stty").arg("echo").status();
}
#[cfg(not(unix))]
fn disable_terminal_echo() -> bool {
false
}
#[cfg(not(unix))]
fn restore_terminal_echo() {}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug)]
struct FakePinProvider {
pin: String,
}
impl PinProvider for FakePinProvider {
fn provide_pin(&self, _prompt: &PinPromptInfo) -> Result<String, Error> {
Ok(self.pin.clone())
}
}
#[test]
fn fake_provider_returns_configured_pin() {
let p = FakePinProvider {
pin: "1234".to_owned(),
};
let info = PinPromptInfo {
reason: "authenticator locked".to_owned(),
retries_remaining: 3,
wants_pin: true,
};
assert_eq!(p.provide_pin(&info).unwrap(), "1234");
}
#[test]
fn pin_prompt_info_defaults_are_zeroed() {
let info = PinPromptInfo::default();
assert_eq!(info.reason, "");
assert_eq!(info.retries_remaining, 0);
assert!(!info.wants_pin);
}
#[derive(Debug)]
struct FailingPinProvider;
impl PinProvider for FailingPinProvider {
fn provide_pin(&self, _prompt: &PinPromptInfo) -> Result<String, Error> {
Err(Error::ExternalSignerFailed(
"user declined PIN entry".into(),
))
}
}
#[test]
fn failing_provider_propagates_error() {
let p = FailingPinProvider;
let err = p.provide_pin(&PinPromptInfo::default()).unwrap_err();
assert!(matches!(err, Error::ExternalSignerFailed(_)));
}
}