use std::io::{BufRead, IsTerminal, Write};
use secrecy::SecretString;
use crate::cli::output::{OutputConfig, OutputFormat};
#[derive(Debug)]
pub enum PromptResult<T> {
Answered(T),
Aborted,
NotATty,
}
pub trait Prompter {
fn ask_url(&mut self, attempt: u8) -> PromptResult<String>;
fn ask_password(&mut self, authority: &str, user: &str) -> PromptResult<SecretString>;
fn ask_username(&mut self, authority: &str) -> PromptResult<String>;
}
pub fn interactive(output: &OutputConfig, yes: bool) -> bool {
std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& output.format == OutputFormat::Human
&& !output.quiet
&& !yes
}
pub const MAX_URL_ATTEMPTS: u8 = 3;
pub struct TerminalPrompter {
pub api_url: String,
}
impl TerminalPrompter {
pub fn new(api_url: impl Into<String>) -> Self {
Self {
api_url: api_url.into(),
}
}
fn read_line() -> Option<String> {
let mut buf = String::new();
let stdin = std::io::stdin();
match stdin.lock().read_line(&mut buf) {
Ok(0) | Err(_) => None,
Ok(_) => Some(buf.trim().to_string()),
}
}
}
impl Prompter for TerminalPrompter {
fn ask_url(&mut self, attempt: u8) -> PromptResult<String> {
if attempt == 1 {
eprintln!(
"Cannot reach {}. Proxy URL (http://, https://, socks5://) or Enter to abort:",
self.api_url
);
} else {
eprintln!(
"That proxy did not work. Proxy URL (http://, https://, socks5://) or Enter to \
abort ({attempt} of {MAX_URL_ATTEMPTS}):"
);
}
let _ = std::io::stderr().flush();
match Self::read_line() {
None => PromptResult::Aborted,
Some(s) if s.is_empty() => PromptResult::Aborted,
Some(s) => PromptResult::Answered(s),
}
}
fn ask_username(&mut self, authority: &str) -> PromptResult<String> {
eprintln!("{authority} requires authentication. Username (leave empty to cancel):");
let _ = std::io::stderr().flush();
match Self::read_line() {
None => PromptResult::Aborted,
Some(s) if s.is_empty() => PromptResult::Aborted,
Some(s) => PromptResult::Answered(s),
}
}
fn ask_password(&mut self, authority: &str, user: &str) -> PromptResult<SecretString> {
eprintln!("Password for {user}@{authority} (not echoed; leave empty to cancel):");
let _ = std::io::stderr().flush();
match rpassword::read_password() {
Err(_) => PromptResult::Aborted,
Ok(p) if p.trim().is_empty() => PromptResult::Aborted,
Ok(p) => PromptResult::Answered(SecretString::from(p)),
}
}
}
pub struct ScriptedPrompter {
urls: std::collections::VecDeque<Option<String>>,
usernames: std::collections::VecDeque<Option<String>>,
passwords: std::collections::VecDeque<Option<String>>,
pub asked: Vec<String>,
}
impl ScriptedPrompter {
pub fn new(urls: Vec<Option<&str>>) -> Self {
Self {
urls: urls.into_iter().map(|u| u.map(str::to_string)).collect(),
usernames: std::collections::VecDeque::new(),
passwords: std::collections::VecDeque::new(),
asked: Vec::new(),
}
}
pub fn with_credentials(
mut self,
usernames: Vec<Option<&str>>,
passwords: Vec<Option<&str>>,
) -> Self {
self.usernames = usernames
.into_iter()
.map(|u| u.map(str::to_string))
.collect();
self.passwords = passwords
.into_iter()
.map(|p| p.map(str::to_string))
.collect();
self
}
}
impl Prompter for ScriptedPrompter {
fn ask_url(&mut self, attempt: u8) -> PromptResult<String> {
self.asked.push(format!("url:{attempt}"));
match self.urls.pop_front() {
Some(Some(u)) => PromptResult::Answered(u),
_ => PromptResult::Aborted,
}
}
fn ask_username(&mut self, authority: &str) -> PromptResult<String> {
self.asked.push(format!("username:{authority}"));
match self.usernames.pop_front() {
Some(Some(u)) => PromptResult::Answered(u),
_ => PromptResult::Aborted,
}
}
fn ask_password(&mut self, authority: &str, user: &str) -> PromptResult<SecretString> {
self.asked.push(format!("password:{user}@{authority}"));
match self.passwords.pop_front() {
Some(Some(p)) => PromptResult::Answered(SecretString::from(p)),
_ => PromptResult::Aborted,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
#[test]
fn a_script_answers_in_order_and_then_aborts() {
let mut p = ScriptedPrompter::new(vec![Some("http://a:1"), Some("http://b:2")]);
assert!(matches!(p.ask_url(1), PromptResult::Answered(u) if u == "http://a:1"));
assert!(matches!(p.ask_url(2), PromptResult::Answered(u) if u == "http://b:2"));
assert!(matches!(p.ask_url(3), PromptResult::Aborted));
assert_eq!(p.asked, vec!["url:1", "url:2", "url:3"]);
}
#[test]
fn an_explicit_none_is_the_abort_answer() {
let mut p = ScriptedPrompter::new(vec![None]);
assert!(matches!(p.ask_url(1), PromptResult::Aborted));
}
#[test]
fn credentials_come_back_in_order_and_are_never_logged() {
let mut p =
ScriptedPrompter::new(vec![]).with_credentials(vec![Some("svc")], vec![Some("s3cr3t")]);
assert!(
matches!(p.ask_username("http://proxy:8080"), PromptResult::Answered(u) if u == "svc")
);
let PromptResult::Answered(pw) = p.ask_password("http://proxy:8080", "svc") else {
panic!("scripted password must be answered");
};
assert_eq!(pw.expose_secret(), "s3cr3t");
assert!(
!p.asked.iter().any(|q| q.contains("s3cr3t")),
"the transcript must never carry the password: {:?}",
p.asked
);
}
fn out(format: OutputFormat, quiet: bool) -> OutputConfig {
OutputConfig {
format,
verbose: false,
debug: false,
quiet,
color: false,
}
}
#[test]
fn yes_forces_headless_semantics() {
assert!(
!interactive(&out(OutputFormat::Human, false), true),
"--yes must suppress prompting whatever the terminal says"
);
}
#[test]
fn json_and_quiet_each_suppress_prompting_on_their_own() {
assert!(!interactive(&out(OutputFormat::Json, false), false));
assert!(!interactive(&out(OutputFormat::Human, true), false));
}
}