use std::io::{self, BufRead, Read, Write};
use cryptovault::MAX_PLAINTEXT_LEN;
use zeroize::Zeroizing;
use crate::vault::{create_passphrase, PassphrasePrompt, SecretStore, TtyPrompt, VaultError};
const CONFIRM_SUFFIX: &str = "? [Y/n] ";
const EMPTY_VAULT_LINE: &str = "(vault empty)";
fn enforce_value_ceiling(len: usize) -> Result<(), VaultError> {
if len > MAX_PLAINTEXT_LEN {
Err(VaultError::ValueTooLarge(MAX_PLAINTEXT_LEN))
} else {
Ok(())
}
}
fn strip_trailing_newline(s: String) -> String {
if let Some(stripped) = s.strip_suffix("\r\n") {
stripped.to_string()
} else if let Some(stripped) = s.strip_suffix('\n') {
stripped.to_string()
} else {
s
}
}
fn is_affirmative(line: &str) -> bool {
line == "Y"
}
fn read_bounded_value(reader: impl Read) -> Result<Zeroizing<String>, VaultError> {
let mut buf = Vec::new();
reader
.take(MAX_PLAINTEXT_LEN as u64 + 1)
.read_to_end(&mut buf)
.map_err(|e| VaultError::Io(e.to_string()))?;
let decoded = String::from_utf8(buf)
.map_err(|_| VaultError::Io("piped value is not valid UTF-8".to_string()))?;
let text = strip_trailing_newline(decoded);
enforce_value_ceiling(text.len())?;
Ok(Zeroizing::new(text))
}
#[derive(clap::Subcommand, Debug)]
pub enum VaultCmd {
Ls,
Set {
name: String,
#[arg(long)]
show: bool,
#[arg(short, long)]
force: bool,
},
Rm {
name: String,
#[arg(short, long)]
force: bool,
},
Passwd {
#[arg(long)]
show: bool,
},
}
pub trait VaultIo: PassphrasePrompt {
fn read_value(&mut self, prompt_msg: &str, show: bool)
-> Result<Zeroizing<String>, VaultError>;
fn confirm(&mut self, msg: &str) -> Result<bool, VaultError>;
fn println(&mut self, line: &str);
}
pub struct TtyIo {
prompt: TtyPrompt,
}
impl TtyIo {
pub fn new() -> Self {
Self { prompt: TtyPrompt }
}
}
impl Default for TtyIo {
fn default() -> Self {
Self::new()
}
}
impl PassphrasePrompt for TtyIo {
fn is_interactive(&self) -> bool {
self.prompt.is_interactive()
}
fn read_passphrase(&mut self, msg: &str, show: bool) -> Result<Zeroizing<String>, VaultError> {
self.prompt.read_passphrase(msg, show)
}
}
impl VaultIo for TtyIo {
fn read_value(
&mut self,
prompt_msg: &str,
show: bool,
) -> Result<Zeroizing<String>, VaultError> {
if self.is_interactive() {
let value = if show {
eprint!("{prompt_msg}");
io::stderr().flush().ok();
let mut line = String::new();
io::stdin()
.lock()
.take(MAX_PLAINTEXT_LEN as u64 + 1)
.read_line(&mut line)
.map_err(|e| VaultError::Io(e.to_string()))?;
strip_trailing_newline(line)
} else {
rpassword::prompt_password(prompt_msg).map_err(|e| VaultError::Io(e.to_string()))?
};
enforce_value_ceiling(value.len())?;
Ok(Zeroizing::new(value))
} else {
read_bounded_value(io::stdin())
}
}
fn confirm(&mut self, msg: &str) -> Result<bool, VaultError> {
eprint!("{msg}");
io::stderr().flush().ok();
let mut line = String::new();
io::stdin()
.read_line(&mut line)
.map_err(|e| VaultError::Io(e.to_string()))?;
Ok(is_affirmative(&strip_trailing_newline(line)))
}
fn println(&mut self, line: &str) {
println!("{line}");
}
}
pub fn run_vault_cmd(
cmd: VaultCmd,
store: &mut dyn SecretStore,
io: &mut dyn VaultIo,
rekey: &mut dyn FnMut(&str) -> Result<(), VaultError>,
) -> Result<(), VaultError> {
match cmd {
VaultCmd::Ls => run_ls(store, io),
VaultCmd::Set { name, show, force } => run_set(&name, show, force, store, io),
VaultCmd::Rm { name, force } => run_rm(&name, force, store, io),
VaultCmd::Passwd { show } => run_passwd(show, io, rekey),
}
}
fn run_ls(store: &mut dyn SecretStore, io: &mut dyn VaultIo) -> Result<(), VaultError> {
let entries = store.list()?;
if entries.is_empty() {
io.println(EMPTY_VAULT_LINE);
return Ok(());
}
let width = entries
.iter()
.map(|e| e.name.chars().count())
.max()
.unwrap_or(0);
for entry in &entries {
io.println(&format!(
"{name:width$} · {created} · {updated}",
name = entry.name,
created = entry.created_at,
updated = entry.updated_at,
width = width
));
}
Ok(())
}
fn run_set(
name: &str,
show: bool,
force: bool,
store: &mut dyn SecretStore,
io: &mut dyn VaultIo,
) -> Result<(), VaultError> {
if store.contains(name)? {
confirm_destructive(
io,
force,
&format!("overwrite existing secret '{name}'{CONFIRM_SUFFIX}"),
)?;
}
let value = io.read_value(&format!("value for '{name}': "), show)?;
store.set(name, value.as_str())?;
io.println(&format!("secret '{name}' stored"));
Ok(())
}
fn run_rm(
name: &str,
force: bool,
store: &mut dyn SecretStore,
io: &mut dyn VaultIo,
) -> Result<(), VaultError> {
confirm_destructive(
io,
force,
&format!("remove secret '{name}'{CONFIRM_SUFFIX}"),
)?;
store.remove(name)?;
io.println(&format!("secret '{name}' removed"));
Ok(())
}
fn run_passwd(
show: bool,
io: &mut dyn VaultIo,
rekey: &mut dyn FnMut(&str) -> Result<(), VaultError>,
) -> Result<(), VaultError> {
if !io.is_interactive() {
return Err(VaultError::PassphraseUnavailable);
}
let new_passphrase = create_passphrase(io as &mut dyn PassphrasePrompt, show)?;
rekey(new_passphrase.as_str())?;
io.println("passphrase changed");
Ok(())
}
fn confirm_destructive(io: &mut dyn VaultIo, force: bool, prompt: &str) -> Result<(), VaultError> {
if force {
return Ok(());
}
if !io.is_interactive() {
io.println("destructive operation requires -f in non-interactive mode");
return Err(VaultError::Aborted);
}
if io.confirm(prompt)? {
Ok(())
} else {
Err(VaultError::Aborted)
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use super::*;
use crate::vault::{wire, MaskedDek};
struct FakeIo {
interactive: bool,
confirm_answers: Vec<String>,
confirm_reads: usize,
value_answers: Vec<String>,
value_reads: usize,
passphrase_answers: Vec<String>,
passphrase_reads: usize,
stdin_reader: Option<Box<dyn Read>>,
output: Vec<String>,
calls: Vec<String>,
last_show: Option<bool>,
}
impl FakeIo {
fn interactive(value_answers: Vec<String>) -> Self {
Self {
interactive: true,
confirm_answers: Vec::new(),
confirm_reads: 0,
value_answers,
value_reads: 0,
passphrase_answers: Vec::new(),
passphrase_reads: 0,
stdin_reader: None,
output: Vec::new(),
calls: Vec::new(),
last_show: None,
}
}
fn non_interactive_with_value(value: &str) -> Self {
let mut io = Self::interactive(vec![value.to_string()]);
io.interactive = false;
io
}
fn with_stdin_reader(reader: impl Read + 'static) -> Self {
let mut io = Self::interactive(Vec::new());
io.interactive = false;
io.stdin_reader = Some(Box::new(reader));
io
}
fn with_confirm_answers(mut self, answers: Vec<&str>) -> Self {
self.confirm_answers = answers.into_iter().map(str::to_string).collect();
self
}
fn with_passphrase_answers(mut self, answers: Vec<&str>) -> Self {
self.passphrase_answers = answers.into_iter().map(str::to_string).collect();
self
}
}
impl PassphrasePrompt for FakeIo {
fn is_interactive(&self) -> bool {
self.interactive
}
fn read_passphrase(
&mut self,
msg: &str,
_show: bool,
) -> Result<Zeroizing<String>, VaultError> {
self.calls.push(format!("read_passphrase:{msg}"));
let idx = self.passphrase_reads;
self.passphrase_reads += 1;
Ok(Zeroizing::new(
self.passphrase_answers
.get(idx)
.cloned()
.unwrap_or_default(),
))
}
}
impl VaultIo for FakeIo {
fn read_value(
&mut self,
prompt_msg: &str,
show: bool,
) -> Result<Zeroizing<String>, VaultError> {
self.calls.push(format!("read_value:{prompt_msg}"));
self.last_show = Some(if self.interactive { show } else { false });
if let Some(reader) = self.stdin_reader.take() {
return read_bounded_value(reader);
}
let idx = self.value_reads;
self.value_reads += 1;
let value = self.value_answers.get(idx).cloned().unwrap_or_default();
enforce_value_ceiling(value.len())?;
Ok(Zeroizing::new(value))
}
fn confirm(&mut self, msg: &str) -> Result<bool, VaultError> {
self.calls.push(format!("confirm:{msg}"));
let idx = self.confirm_reads;
self.confirm_reads += 1;
let raw = self.confirm_answers.get(idx).cloned().unwrap_or_default();
Ok(is_affirmative(&raw))
}
fn println(&mut self, line: &str) {
self.output.push(line.to_string());
}
}
fn fixture_store() -> impl SecretStore {
let conn = rusqlite::Connection::open_in_memory().expect("mem db");
let dek = MaskedDek::new(Zeroizing::new(vec![7u8; 32])).expect("32B dek");
wire(Arc::new(Mutex::new(conn)), dek).expect("wire")
}
fn no_rekey() -> impl FnMut(&str) -> Result<(), VaultError> {
|_| panic!("rekey should not be called")
}
#[test]
fn test_rm_requires_uppercase_y_exactly() {
for (answer, proceeds) in [
("y", false),
("", false),
("n", false),
("YES", false),
("Y", true),
] {
let mut store = fixture_store();
store.set("K", "v").expect("seed");
let mut io = FakeIo::interactive(Vec::new()).with_confirm_answers(vec![answer]);
let cmd = VaultCmd::Rm {
name: "K".to_string(),
force: false,
};
let result = run_vault_cmd(cmd, &mut store, &mut io, &mut no_rekey());
if proceeds {
assert!(result.is_ok(), "answer {answer:?} should proceed");
assert!(matches!(store.get("K"), Err(VaultError::SecretNotFound(_))));
} else {
assert!(
matches!(result, Err(VaultError::Aborted)),
"answer {answer:?} should abort"
);
assert!(store.get("K").is_ok(), "secret should remain after abort");
}
}
}
#[test]
fn test_set_rejects_oversized_stdin_without_buffering_all() {
let mut io = FakeIo::with_stdin_reader(std::io::repeat(b'a'));
let r = io.read_value("value: ", false);
assert!(matches!(r, Err(VaultError::ValueTooLarge(_))));
}
#[test]
fn test_set_rejects_non_utf8_piped_value_instead_of_silently_corrupting() {
let mut io = FakeIo::with_stdin_reader(&[0xff, 0xfe, 0x00, 0x80][..]);
let r = io.read_value("value: ", false);
assert!(matches!(r, Err(VaultError::Io(_))));
}
#[test]
fn test_set_on_existing_name_asks_before_reading_the_value() {
let mut store = fixture_store();
store.set("K", "old").expect("seed");
let mut io =
FakeIo::interactive(vec!["new-value".to_string()]).with_confirm_answers(vec!["Y"]);
run_vault_cmd(
VaultCmd::Set {
name: "K".to_string(),
show: false,
force: false,
},
&mut store,
&mut io,
&mut no_rekey(),
)
.expect("set ok");
let confirm_idx = io
.calls
.iter()
.position(|c| c.starts_with("confirm"))
.expect("confirm called");
let read_idx = io
.calls
.iter()
.position(|c| c.starts_with("read_value"))
.expect("read_value called");
assert!(
confirm_idx < read_idx,
"must confirm BEFORE reading the value"
);
assert_eq!(store.get("K").expect("get").as_str(), "new-value");
}
#[test]
fn test_force_skips_confirmation_for_set_and_rm() {
let mut store = fixture_store();
store.set("K", "v1").expect("seed");
let mut io = FakeIo::interactive(vec!["v2".to_string()]);
run_vault_cmd(
VaultCmd::Set {
name: "K".to_string(),
show: false,
force: true,
},
&mut store,
&mut io,
&mut no_rekey(),
)
.expect("set ok");
assert!(!io.calls.iter().any(|c| c.starts_with("confirm")));
assert_eq!(store.get("K").expect("get").as_str(), "v2");
let mut io2 = FakeIo::interactive(Vec::new());
run_vault_cmd(
VaultCmd::Rm {
name: "K".to_string(),
force: true,
},
&mut store,
&mut io2,
&mut no_rekey(),
)
.expect("rm ok");
assert!(!io2.calls.iter().any(|c| c.starts_with("confirm")));
assert!(matches!(store.get("K"), Err(VaultError::SecretNotFound(_))));
}
#[test]
fn test_ls_prints_names_and_dates_never_values() {
let mut store = fixture_store();
store
.set("OPENAI_API_KEY", "sk-super-secret-VALUE")
.expect("seed");
let mut io = FakeIo::interactive(Vec::new());
run_vault_cmd(VaultCmd::Ls, &mut store, &mut io, &mut no_rekey()).expect("ls ok");
assert_eq!(io.output.len(), 1);
assert!(io.output[0].contains("OPENAI_API_KEY"));
assert!(!io
.output
.iter()
.any(|l| l.contains("sk-super-secret-VALUE")));
let mut empty_store = fixture_store();
let mut io2 = FakeIo::interactive(Vec::new());
run_vault_cmd(VaultCmd::Ls, &mut empty_store, &mut io2, &mut no_rekey()).expect("ls ok");
assert_eq!(io2.output, vec![EMPTY_VAULT_LINE.to_string()]);
}
#[test]
fn test_set_show_echoes_input_and_is_ignored_without_tty() {
let mut store = fixture_store();
let mut io = FakeIo::interactive(vec!["v".to_string()]);
run_vault_cmd(
VaultCmd::Set {
name: "K".to_string(),
show: true,
force: false,
},
&mut store,
&mut io,
&mut no_rekey(),
)
.expect("set ok");
assert_eq!(io.last_show, Some(true));
let mut store2 = fixture_store();
let mut io2 = FakeIo::non_interactive_with_value("v2");
run_vault_cmd(
VaultCmd::Set {
name: "K2".to_string(),
show: true,
force: true,
},
&mut store2,
&mut io2,
&mut no_rekey(),
)
.expect("set ok");
assert_eq!(io2.last_show, Some(false));
}
#[test]
fn test_no_subcommand_exposes_a_stored_value() {
use clap::CommandFactory;
#[derive(clap::Parser)]
struct Probe {
#[command(subcommand)]
cmd: VaultCmd,
}
let names: Vec<String> = Probe::command()
.get_subcommands()
.map(|c| c.get_name().to_string())
.collect();
for banned in ["get", "cat", "show", "reveal", "export"] {
assert!(
!names.iter().any(|n| n == banned),
"forbidden subcommand: {banned}"
);
}
}
#[test]
fn test_passwd_double_entry_and_strength_gate_apply() {
let mut store = fixture_store();
let mut io = FakeIo::interactive(Vec::new()).with_passphrase_answers(vec!["short"]);
let mut rekey_calls = 0usize;
let mut rekey = |_p: &str| -> Result<(), VaultError> {
rekey_calls += 1;
Ok(())
};
let result = run_vault_cmd(
VaultCmd::Passwd { show: false },
&mut store,
&mut io,
&mut rekey,
);
assert!(matches!(result, Err(VaultError::WeakPassphrase(_))));
assert_eq!(rekey_calls, 0);
let mut io2 = FakeIo::interactive(Vec::new()).with_passphrase_answers(vec![
"correct horse battery staple",
"correct horse battery staple",
]);
let mut received: Option<String> = None;
let mut rekey2 = |p: &str| -> Result<(), VaultError> {
received = Some(p.to_string());
Ok(())
};
run_vault_cmd(
VaultCmd::Passwd { show: false },
&mut store,
&mut io2,
&mut rekey2,
)
.expect("passwd ok");
assert_eq!(received.as_deref(), Some("correct horse battery staple"));
}
#[test]
fn test_passwd_without_tty_fails_closed_without_rekey() {
let mut store = fixture_store();
let mut io = FakeIo::non_interactive_with_value("ignored");
let result = run_vault_cmd(
VaultCmd::Passwd { show: false },
&mut store,
&mut io,
&mut no_rekey(),
);
assert!(matches!(result, Err(VaultError::PassphraseUnavailable)));
}
#[test]
fn test_non_tty_destructive_without_force_aborts_without_reading_confirm() {
let mut store = fixture_store();
store.set("K", "v").expect("seed");
let mut io = FakeIo::non_interactive_with_value("ignored");
let result = run_vault_cmd(
VaultCmd::Rm {
name: "K".to_string(),
force: false,
},
&mut store,
&mut io,
&mut no_rekey(),
);
assert!(matches!(result, Err(VaultError::Aborted)));
assert!(
!io.calls.iter().any(|c| c.starts_with("confirm")),
"must never attempt confirm() without a TTY"
);
assert!(store.get("K").is_ok(), "secret must survive an aborted rm");
}
}