Skip to main content

ironflow_cli/
confirm.rs

1//! Interactive confirmation for destructive commands, and secret input.
2//!
3//! Destructive commands (`delete`) ask for an explicit `y` before acting.
4//! `--yes` skips the prompt. When stdin is not a terminal and `--yes` was not
5//! passed, the command fails instead of prompting: a prompt nobody can answer
6//! would hang a CI job forever, and silently proceeding would make a forgotten
7//! `--yes` destroy data without a trace.
8
9use std::io::{BufRead, IsTerminal, Read, Write};
10
11use anyhow::{Result, bail};
12
13/// Ask for confirmation on stdin unless `assume_yes` is set.
14///
15/// # Errors
16///
17/// Returns an error when the answer is not affirmative, when stdin is not a
18/// terminal, or when reading stdin fails.
19///
20/// # Examples
21///
22/// ```
23/// use ironflow_cli::confirm::confirm;
24///
25/// # fn example() -> anyhow::Result<()> {
26/// confirm("Delete secret 'db/password'?", true)?;
27/// # Ok(())
28/// # }
29/// ```
30pub fn confirm(prompt: &str, assume_yes: bool) -> Result<()> {
31    let stdin = std::io::stdin();
32    confirm_with(prompt, assume_yes, stdin.is_terminal(), &mut stdin.lock())
33}
34
35/// Confirmation logic decoupled from the process' real stdin, for testing.
36///
37/// # Errors
38///
39/// Returns an error when `interactive` is false without `assume_yes`, when the
40/// answer is not affirmative, or when reading fails.
41fn confirm_with<R: BufRead>(
42    prompt: &str,
43    assume_yes: bool,
44    interactive: bool,
45    reader: &mut R,
46) -> Result<()> {
47    if assume_yes {
48        return Ok(());
49    }
50
51    if !interactive {
52        bail!("refusing to prompt on a non-interactive stdin; pass --yes to confirm");
53    }
54
55    let mut stderr = std::io::stderr();
56    write!(stderr, "{prompt} [y/N] ")?;
57    stderr.flush()?;
58
59    let mut answer = String::new();
60    reader.read_line(&mut answer)?;
61
62    match answer.trim().to_ascii_lowercase().as_str() {
63        "y" | "yes" => Ok(()),
64        _ => bail!("aborted"),
65    }
66}
67
68/// Resolve a sensitive value from an argument, falling back to stdin.
69///
70/// Passing the value as an argument leaks it into the shell history and into
71/// `ps` output, so omitting it reads the value from stdin instead. Only the
72/// trailing newline is stripped: a value may legitimately contain inner
73/// newlines or leading whitespace.
74///
75/// # Errors
76///
77/// Returns an error when stdin cannot be read, or when the resolved value is
78/// empty.
79///
80/// # Examples
81///
82/// ```
83/// use ironflow_cli::confirm::resolve_secret_value;
84///
85/// # fn example() -> anyhow::Result<()> {
86/// let value = resolve_secret_value(Some("hunter2"), "value")?;
87/// assert_eq!(value, "hunter2");
88/// # Ok(())
89/// # }
90/// ```
91pub fn resolve_secret_value(argument: Option<&str>, label: &str) -> Result<String> {
92    match argument {
93        Some(value) => reject_empty(value.to_string(), label),
94        None => {
95            let mut buffer = String::new();
96            std::io::stdin().read_to_string(&mut buffer)?;
97            reject_empty(strip_trailing_newline(&buffer).to_string(), label)
98        }
99    }
100}
101
102/// Drop a single trailing `\n` or `\r\n`.
103fn strip_trailing_newline(raw: &str) -> &str {
104    raw.strip_suffix('\n')
105        .map_or(raw, |s| s.strip_suffix('\r').unwrap_or(s))
106}
107
108/// Reject an empty value before it reaches the API.
109fn reject_empty(value: String, label: &str) -> Result<String> {
110    if value.is_empty() {
111        bail!("{label} must not be empty");
112    }
113    Ok(value)
114}
115
116#[cfg(test)]
117mod tests {
118    use std::io::Cursor;
119
120    use super::*;
121
122    fn answer(input: &str) -> Result<()> {
123        let mut reader = Cursor::new(input.as_bytes().to_vec());
124        confirm_with("Delete?", false, true, &mut reader)
125    }
126
127    #[test]
128    fn assume_yes_skips_the_prompt() {
129        let mut reader = Cursor::new(Vec::new());
130        assert!(confirm_with("Delete?", true, false, &mut reader).is_ok());
131    }
132
133    #[test]
134    fn non_interactive_stdin_is_refused() {
135        let mut reader = Cursor::new(b"y\n".to_vec());
136        let err = confirm_with("Delete?", false, false, &mut reader).unwrap_err();
137        assert!(err.to_string().contains("--yes"), "{err}");
138    }
139
140    #[test]
141    fn affirmative_answers_are_accepted() {
142        assert!(answer("y\n").is_ok());
143        assert!(answer("Y\n").is_ok());
144        assert!(answer("yes\n").is_ok());
145        assert!(answer("  YES  \n").is_ok());
146    }
147
148    #[test]
149    fn other_answers_abort() {
150        for input in ["n\n", "no\n", "\n", "", "maybe\n", "yep\n"] {
151            let err = answer(input).unwrap_err();
152            assert_eq!(err.to_string(), "aborted", "input {input:?}");
153        }
154    }
155
156    #[test]
157    fn argument_value_is_used_verbatim() {
158        assert_eq!(
159            resolve_secret_value(Some(" spaced "), "value").unwrap(),
160            " spaced "
161        );
162    }
163
164    #[test]
165    fn empty_argument_is_rejected() {
166        let err = resolve_secret_value(Some(""), "value").unwrap_err();
167        assert!(err.to_string().contains("must not be empty"), "{err}");
168    }
169
170    #[test]
171    fn only_the_trailing_newline_is_stripped() {
172        assert_eq!(strip_trailing_newline("secret\n"), "secret");
173        assert_eq!(strip_trailing_newline("secret\r\n"), "secret");
174        assert_eq!(strip_trailing_newline("secret"), "secret");
175        assert_eq!(strip_trailing_newline("line1\nline2\n"), "line1\nline2");
176        assert_eq!(strip_trailing_newline("secret\n\n"), "secret\n");
177        assert_eq!(strip_trailing_newline("  padded  "), "  padded  ");
178    }
179}