#![doc = include_str!("../Documentation.md")]
#![forbid(unsafe_code)]
use kcode_k1_peering::K1Peering;
use kcode_k1_txn_ordering::K1TxnOrdering;
use kcode_k1_vault::{K1Vault, SecretString};
use std::ffi::OsString;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use zeroize::Zeroize;
const USAGE: &str =
"Usage: kcode-k1-daemon [set-secrets <name> | remove-secrets <name> | list-secrets]";
const FAILURE: &str = "kcode-k1-daemon: secrets command failed";
const EMPTY: &str = "kcode-k1-daemon: no secrets configured";
#[derive(Debug, Eq, PartialEq)]
enum Mode {
Daemon,
Admin(Command),
}
#[derive(Debug, Eq, PartialEq)]
enum Command {
Set(String),
Remove(String),
List,
}
#[derive(Debug, Eq, PartialEq)]
enum Outcome {
Set(String),
Removed(String),
Absent(String),
Names(Vec<String>),
}
trait HiddenPrompt {
fn read_hidden(&mut self, prompt: &str) -> Result<String, ()>;
}
struct TerminalPrompt;
impl HiddenPrompt for TerminalPrompt {
fn read_hidden(&mut self, prompt: &str) -> Result<String, ()> {
rpassword::prompt_password(prompt).map_err(|_| ())
}
}
struct StatePaths {
ordering: PathBuf,
peering: PathBuf,
vault: PathBuf,
}
pub fn run(k1_root: PathBuf) -> ExitCode {
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
let mode = match parse(args) {
Ok(mode) => mode,
Err(()) => {
let stderr = io::stderr();
return finish_invalid(&mut stderr.lock());
}
};
let Mode::Admin(command) = mode else {
return kcode_k1_daemon_lib::run(k1_root);
};
let mut prompt = TerminalPrompt;
let result = execute(&k1_root, command, &mut prompt);
let stdout = io::stdout();
let stderr = io::stderr();
finish_admin(result, &mut stdout.lock(), &mut stderr.lock())
}
fn parse(args: Vec<OsString>) -> Result<Mode, ()> {
let mut args = args.into_iter();
let Some(command) = args.next() else {
return Ok(Mode::Daemon);
};
let command = command.into_string().map_err(|_| ())?;
match command.as_str() {
"list-secrets" if args.next().is_none() => Ok(Mode::Admin(Command::List)),
"set-secrets" | "remove-secrets" => {
let name = args.next().ok_or(())?.into_string().map_err(|_| ())?;
if args.next().is_some() {
return Err(());
}
if command == "set-secrets" {
Ok(Mode::Admin(Command::Set(name)))
} else {
Ok(Mode::Admin(Command::Remove(name)))
}
}
_ => Err(()),
}
}
fn state_paths(root: &Path) -> StatePaths {
let state = root.join("state");
StatePaths {
ordering: state.join("ordering"),
peering: state.join("peering"),
vault: state.join("vault"),
}
}
fn prompt_passphrase(prompt: &mut impl HiddenPrompt) -> Result<SecretString, ()> {
let mut text = prompt.read_hidden("Unlock K1 vault: ")?;
if text.is_empty() {
text.zeroize();
return Err(());
}
Ok(SecretString::from(text))
}
fn prompt_value(prompt: &mut impl HiddenPrompt, name: &str) -> Result<SecretString, ()> {
let mut value = prompt.read_hidden(&format!("Value for {name}: "))?;
let mut confirmation = match prompt.read_hidden("Confirm secret value: ") {
Ok(confirmation) => confirmation,
Err(()) => {
value.zeroize();
return Err(());
}
};
if value.is_empty() || confirmation.is_empty() || value != confirmation {
value.zeroize();
confirmation.zeroize();
return Err(());
}
let protected = SecretString::from(value);
confirmation.zeroize();
Ok(protected)
}
fn open_vault(root: &Path, passphrase: SecretString) -> Result<K1Vault, ()> {
let paths = state_paths(root);
let ordering = Arc::new(K1TxnOrdering::open(&paths.ordering).map_err(|_| ())?);
let peering = Arc::new(K1Peering::open(&paths.peering, Arc::clone(&ordering)).map_err(|_| ())?);
K1Vault::open(&paths.vault, passphrase, ordering, peering).map_err(|_| ())
}
fn execute(root: &Path, command: Command, prompt: &mut impl HiddenPrompt) -> Result<Outcome, ()> {
let passphrase = prompt_passphrase(prompt)?;
match command {
Command::Set(name) => {
let value = prompt_value(prompt, &name)?;
let vault = open_vault(root, passphrase)?;
vault.set(&name, value).map_err(|_| ())?;
Ok(Outcome::Set(name))
}
Command::Remove(name) => {
let vault = open_vault(root, passphrase)?;
match vault.remove(&name).map_err(|_| ())? {
Some(_) => Ok(Outcome::Removed(name)),
None => Ok(Outcome::Absent(name)),
}
}
Command::List => {
let vault = open_vault(root, passphrase)?;
vault.names().map(Outcome::Names).map_err(|_| ())
}
}
}
fn render(outcome: Outcome) -> String {
match outcome {
Outcome::Set(name) => format!("kcode-k1-daemon: secrets set for {name}"),
Outcome::Removed(name) => format!("kcode-k1-daemon: secrets removed for {name}"),
Outcome::Absent(name) => format!("kcode-k1-daemon: secrets not configured for {name}"),
Outcome::Names(names) if names.is_empty() => EMPTY.to_owned(),
Outcome::Names(names) => names.join("\n"),
}
}
fn write_line(output: &mut impl Write, text: &str) -> Result<(), ()> {
output.write_all(text.as_bytes()).map_err(|_| ())?;
output.write_all(b"\n").map_err(|_| ())?;
output.flush().map_err(|_| ())
}
fn finish_admin(
result: Result<Outcome, ()>,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ExitCode {
if let Ok(outcome) = result
&& write_line(stdout, &render(outcome)).is_ok()
{
return ExitCode::SUCCESS;
}
let _ = write_line(stderr, FAILURE);
ExitCode::from(1)
}
fn finish_invalid(stderr: &mut impl Write) -> ExitCode {
if write_line(stderr, USAGE).is_err() {
let _ = write_line(stderr, FAILURE);
}
ExitCode::from(1)
}
#[cfg(test)]
mod tests {
use super::*;
use kcode_k1_vault::ExposeSecret;
use std::collections::VecDeque;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
const PASSWORD: &str = "conspicuous-fake-password-never-real";
const WRONG_PASSWORD: &str = "conspicuous-wrong-fake-password-never-real";
const VALUE: &str = "conspicuous-fake-value-never-real";
const REPLACEMENT: &str = "conspicuous-fake-replacement-never-real";
const NAME: &str = "fake-local-name";
static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
struct FakePrompt {
answers: VecDeque<Result<String, ()>>,
seen: Vec<String>,
}
impl FakePrompt {
fn new(answers: &[&str]) -> Self {
Self {
answers: answers.iter().map(|text| Ok((*text).to_owned())).collect(),
seen: Vec::new(),
}
}
}
impl HiddenPrompt for FakePrompt {
fn read_hidden(&mut self, prompt: &str) -> Result<String, ()> {
self.seen.push(prompt.to_owned());
self.answers.pop_front().unwrap_or(Err(()))
}
}
struct TempRoot(PathBuf);
impl TempRoot {
fn new(label: &str) -> Self {
let sequence = NEXT_ROOT.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"kcode-k1-daemon-cli-{}-{sequence}-{label}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
Self(root)
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn command(root: &Path, command: Command, answers: &[&str]) -> Outcome {
execute(root, command, &mut FakePrompt::new(answers)).unwrap()
}
fn inspect_vault(root: &Path) -> K1Vault {
open_vault(root, SecretString::from(PASSWORD)).unwrap()
}
fn tip(root: &Path) -> Option<kcode_k1_txn_ordering::TxId> {
K1TxnOrdering::open(&state_paths(root).ordering)
.unwrap()
.tip()
}
#[test]
fn grammar_is_exact_and_daemon_mode_is_only_no_arguments() {
assert_eq!(parse(vec![]), Ok(Mode::Daemon));
assert_eq!(
parse(vec![OsString::from("list-secrets")]),
Ok(Mode::Admin(Command::List))
);
assert_eq!(
parse(vec!["set-secrets".into(), NAME.into()]),
Ok(Mode::Admin(Command::Set(NAME.to_owned())))
);
assert_eq!(
parse(vec!["remove-secrets".into(), NAME.into()]),
Ok(Mode::Admin(Command::Remove(NAME.to_owned())))
);
for args in [
vec![""],
vec!["set-secrets"],
vec!["remove-secrets"],
vec!["list-secrets", NAME],
vec!["set-secrets", NAME, "extra"],
vec!["remove-secrets", NAME, "extra"],
vec!["secrets", "set", NAME],
vec!["--help"],
vec!["unknown"],
] {
assert!(parse(args.into_iter().map(OsString::from).collect()).is_err());
}
let _: fn(PathBuf) -> ExitCode = run;
}
#[cfg(unix)]
#[test]
fn non_utf8_command_and_name_are_invalid() {
use std::os::unix::ffi::OsStringExt;
let invalid = OsString::from_vec(vec![0xff]);
assert!(parse(vec![invalid.clone()]).is_err());
assert!(parse(vec!["set-secrets".into(), invalid]).is_err());
}
#[test]
fn set_replaces_list_sorts_and_remove_absence_submits_nothing() {
let root = TempRoot::new("lifecycle");
let mut prompts = FakePrompt::new(&[PASSWORD, VALUE, VALUE]);
assert_eq!(
execute(&root.0, Command::Set(NAME.to_owned()), &mut prompts),
Ok(Outcome::Set(NAME.to_owned()))
);
assert_eq!(
prompts.seen,
[
"Unlock K1 vault: ",
"Value for fake-local-name: ",
"Confirm secret value: "
]
);
command(
&root.0,
Command::Set(NAME.to_owned()),
&[PASSWORD, REPLACEMENT, REPLACEMENT],
);
command(
&root.0,
Command::Set("another-name".to_owned()),
&[
PASSWORD,
"conspicuous-other-fake-value",
"conspicuous-other-fake-value",
],
);
let vault = inspect_vault(&root.0);
assert_eq!(
vault.secret(NAME).unwrap().unwrap().expose_secret(),
REPLACEMENT
);
drop(vault);
let listed = command(&root.0, Command::List, &[PASSWORD]);
assert_eq!(
listed,
Outcome::Names(vec!["another-name".to_owned(), NAME.to_owned()])
);
assert_eq!(render(listed), format!("another-name\n{NAME}"));
let before_remove = tip(&root.0);
assert_eq!(
command(&root.0, Command::Remove(NAME.to_owned()), &[PASSWORD]),
Outcome::Removed(NAME.to_owned())
);
let after_remove = tip(&root.0);
assert_ne!(after_remove, before_remove);
assert_eq!(
command(&root.0, Command::Remove(NAME.to_owned()), &[PASSWORD]),
Outcome::Absent(NAME.to_owned())
);
assert_eq!(tip(&root.0), after_remove);
}
#[test]
fn populated_vault_rejects_wrong_password_without_changing_state() {
let root = TempRoot::new("wrong-password");
command(
&root.0,
Command::Set(NAME.to_owned()),
&[PASSWORD, VALUE, VALUE],
);
let before = tip(&root.0);
assert!(
execute(
&root.0,
Command::List,
&mut FakePrompt::new(&[WRONG_PASSWORD])
)
.is_err()
);
assert_eq!(tip(&root.0), before);
assert_eq!(
inspect_vault(&root.0)
.secret(NAME)
.unwrap()
.unwrap()
.expose_secret(),
VALUE
);
}
#[test]
fn rejected_set_values_precede_any_transaction() {
let empty = TempRoot::new("empty-value");
assert!(
execute(
&empty.0,
Command::Set(NAME.to_owned()),
&mut FakePrompt::new(&[PASSWORD, "", ""])
)
.is_err()
);
assert!(!empty.0.join("state").exists());
assert_eq!(tip(&empty.0), None);
let mismatch = TempRoot::new("mismatch");
assert!(
execute(
&mismatch.0,
Command::Set(NAME.to_owned()),
&mut FakePrompt::new(&[PASSWORD, VALUE, REPLACEMENT])
)
.is_err()
);
assert!(!mismatch.0.join("state").exists());
assert_eq!(tip(&mismatch.0), None);
}
#[test]
fn paths_rendering_and_output_failures_are_fixed_and_secret_free() {
let root = Path::new("trusted-k1-root");
let paths = state_paths(root);
assert_eq!(paths.ordering, root.join("state/ordering"));
assert_eq!(paths.peering, root.join("state/peering"));
assert_eq!(paths.vault, root.join("state/vault"));
let rendered = [
render(Outcome::Set(NAME.to_owned())),
render(Outcome::Removed(NAME.to_owned())),
render(Outcome::Absent(NAME.to_owned())),
render(Outcome::Names(vec![NAME.to_owned()])),
render(Outcome::Names(Vec::new())),
USAGE.to_owned(),
FAILURE.to_owned(),
];
for text in rendered {
assert!(!text.contains(PASSWORD));
assert!(!text.contains(VALUE));
}
let mut stdout = Vec::new();
let mut stderr = Vec::new();
finish_admin(Err(()), &mut stdout, &mut stderr);
assert!(stdout.is_empty());
assert_eq!(stderr, format!("{FAILURE}\n").as_bytes());
let mut usage = Vec::new();
finish_invalid(&mut usage);
assert_eq!(usage, format!("{USAGE}\n").as_bytes());
let mut stderr = Vec::new();
finish_admin(
Ok(Outcome::Set(NAME.to_owned())),
&mut FailingWriter,
&mut stderr,
);
assert_eq!(stderr, format!("{FAILURE}\n").as_bytes());
}
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::other("conspicuous fake write failure"))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::other("conspicuous fake flush failure"))
}
}
}