1#![doc = include_str!("../Documentation.md")]
2#![forbid(unsafe_code)]
3
4use kcode_k1_peering::K1Peering;
5use kcode_k1_txn_ordering::K1TxnOrdering;
6use kcode_k1_vault::{K1Vault, SecretString};
7use std::ffi::OsString;
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10use std::process::ExitCode;
11use std::sync::Arc;
12use zeroize::Zeroize;
13
14const USAGE: &str =
15 "Usage: kcode-k1-daemon [set-secrets <name> | remove-secrets <name> | list-secrets]";
16const FAILURE: &str = "kcode-k1-daemon: secrets command failed";
17const EMPTY: &str = "kcode-k1-daemon: no secrets configured";
18
19#[derive(Debug, Eq, PartialEq)]
20enum Mode {
21 Daemon,
22 Admin(Command),
23}
24
25#[derive(Debug, Eq, PartialEq)]
26enum Command {
27 Set(String),
28 Remove(String),
29 List,
30}
31
32#[derive(Debug, Eq, PartialEq)]
33enum Outcome {
34 Set(String),
35 Removed(String),
36 Absent(String),
37 Names(Vec<String>),
38}
39
40trait HiddenPrompt {
41 fn read_hidden(&mut self, prompt: &str) -> Result<String, ()>;
42}
43
44struct TerminalPrompt;
45
46impl HiddenPrompt for TerminalPrompt {
47 fn read_hidden(&mut self, prompt: &str) -> Result<String, ()> {
48 rpassword::prompt_password(prompt).map_err(|_| ())
49 }
50}
51
52struct StatePaths {
53 ordering: PathBuf,
54 peering: PathBuf,
55 vault: PathBuf,
56}
57
58pub fn run(k1_root: PathBuf) -> ExitCode {
60 let args: Vec<OsString> = std::env::args_os().skip(1).collect();
61 let mode = match parse(args) {
62 Ok(mode) => mode,
63 Err(()) => {
64 let stderr = io::stderr();
65 return finish_invalid(&mut stderr.lock());
66 }
67 };
68 let Mode::Admin(command) = mode else {
69 return kcode_k1_daemon_lib::run(k1_root);
70 };
71 let mut prompt = TerminalPrompt;
72 let result = execute(&k1_root, command, &mut prompt);
73 let stdout = io::stdout();
74 let stderr = io::stderr();
75 finish_admin(result, &mut stdout.lock(), &mut stderr.lock())
76}
77
78fn parse(args: Vec<OsString>) -> Result<Mode, ()> {
79 let mut args = args.into_iter();
80 let Some(command) = args.next() else {
81 return Ok(Mode::Daemon);
82 };
83 let command = command.into_string().map_err(|_| ())?;
84 match command.as_str() {
85 "list-secrets" if args.next().is_none() => Ok(Mode::Admin(Command::List)),
86 "set-secrets" | "remove-secrets" => {
87 let name = args.next().ok_or(())?.into_string().map_err(|_| ())?;
88 if args.next().is_some() {
89 return Err(());
90 }
91 if command == "set-secrets" {
92 Ok(Mode::Admin(Command::Set(name)))
93 } else {
94 Ok(Mode::Admin(Command::Remove(name)))
95 }
96 }
97 _ => Err(()),
98 }
99}
100
101fn state_paths(root: &Path) -> StatePaths {
102 let state = root.join("state");
103 StatePaths {
104 ordering: state.join("ordering"),
105 peering: state.join("peering"),
106 vault: state.join("vault"),
107 }
108}
109
110fn prompt_passphrase(prompt: &mut impl HiddenPrompt) -> Result<SecretString, ()> {
111 let mut text = prompt.read_hidden("Unlock K1 vault: ")?;
112 if text.is_empty() {
113 text.zeroize();
114 return Err(());
115 }
116 Ok(SecretString::from(text))
117}
118
119fn prompt_value(prompt: &mut impl HiddenPrompt, name: &str) -> Result<SecretString, ()> {
120 let mut value = prompt.read_hidden(&format!("Value for {name}: "))?;
121 let mut confirmation = match prompt.read_hidden("Confirm secret value: ") {
122 Ok(confirmation) => confirmation,
123 Err(()) => {
124 value.zeroize();
125 return Err(());
126 }
127 };
128 if value.is_empty() || confirmation.is_empty() || value != confirmation {
129 value.zeroize();
130 confirmation.zeroize();
131 return Err(());
132 }
133 let protected = SecretString::from(value);
134 confirmation.zeroize();
135 Ok(protected)
136}
137
138fn open_vault(root: &Path, passphrase: SecretString) -> Result<K1Vault, ()> {
139 let paths = state_paths(root);
140 let ordering = Arc::new(K1TxnOrdering::open(&paths.ordering).map_err(|_| ())?);
141 let peering = Arc::new(K1Peering::open(&paths.peering, Arc::clone(&ordering)).map_err(|_| ())?);
142 K1Vault::open(&paths.vault, passphrase, ordering, peering).map_err(|_| ())
143}
144
145fn execute(root: &Path, command: Command, prompt: &mut impl HiddenPrompt) -> Result<Outcome, ()> {
146 let passphrase = prompt_passphrase(prompt)?;
147 match command {
148 Command::Set(name) => {
149 let value = prompt_value(prompt, &name)?;
150 let vault = open_vault(root, passphrase)?;
151 vault.set(&name, value).map_err(|_| ())?;
152 Ok(Outcome::Set(name))
153 }
154 Command::Remove(name) => {
155 let vault = open_vault(root, passphrase)?;
156 match vault.remove(&name).map_err(|_| ())? {
157 Some(_) => Ok(Outcome::Removed(name)),
158 None => Ok(Outcome::Absent(name)),
159 }
160 }
161 Command::List => {
162 let vault = open_vault(root, passphrase)?;
163 vault.names().map(Outcome::Names).map_err(|_| ())
164 }
165 }
166}
167
168fn render(outcome: Outcome) -> String {
169 match outcome {
170 Outcome::Set(name) => format!("kcode-k1-daemon: secrets set for {name}"),
171 Outcome::Removed(name) => format!("kcode-k1-daemon: secrets removed for {name}"),
172 Outcome::Absent(name) => format!("kcode-k1-daemon: secrets not configured for {name}"),
173 Outcome::Names(names) if names.is_empty() => EMPTY.to_owned(),
174 Outcome::Names(names) => names.join("\n"),
175 }
176}
177
178fn write_line(output: &mut impl Write, text: &str) -> Result<(), ()> {
179 output.write_all(text.as_bytes()).map_err(|_| ())?;
180 output.write_all(b"\n").map_err(|_| ())?;
181 output.flush().map_err(|_| ())
182}
183
184fn finish_admin(
185 result: Result<Outcome, ()>,
186 stdout: &mut impl Write,
187 stderr: &mut impl Write,
188) -> ExitCode {
189 if let Ok(outcome) = result
190 && write_line(stdout, &render(outcome)).is_ok()
191 {
192 return ExitCode::SUCCESS;
193 }
194 let _ = write_line(stderr, FAILURE);
195 ExitCode::from(1)
196}
197
198fn finish_invalid(stderr: &mut impl Write) -> ExitCode {
199 if write_line(stderr, USAGE).is_err() {
200 let _ = write_line(stderr, FAILURE);
201 }
202 ExitCode::from(1)
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use kcode_k1_vault::ExposeSecret;
209 use std::collections::VecDeque;
210 use std::fs;
211 use std::sync::atomic::{AtomicU64, Ordering};
212
213 const PASSWORD: &str = "conspicuous-fake-password-never-real";
214 const WRONG_PASSWORD: &str = "conspicuous-wrong-fake-password-never-real";
215 const VALUE: &str = "conspicuous-fake-value-never-real";
216 const REPLACEMENT: &str = "conspicuous-fake-replacement-never-real";
217 const NAME: &str = "fake-local-name";
218 static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
219
220 struct FakePrompt {
221 answers: VecDeque<Result<String, ()>>,
222 seen: Vec<String>,
223 }
224
225 impl FakePrompt {
226 fn new(answers: &[&str]) -> Self {
227 Self {
228 answers: answers.iter().map(|text| Ok((*text).to_owned())).collect(),
229 seen: Vec::new(),
230 }
231 }
232 }
233
234 impl HiddenPrompt for FakePrompt {
235 fn read_hidden(&mut self, prompt: &str) -> Result<String, ()> {
236 self.seen.push(prompt.to_owned());
237 self.answers.pop_front().unwrap_or(Err(()))
238 }
239 }
240
241 struct TempRoot(PathBuf);
242
243 impl TempRoot {
244 fn new(label: &str) -> Self {
245 let sequence = NEXT_ROOT.fetch_add(1, Ordering::Relaxed);
246 let root = std::env::temp_dir().join(format!(
247 "kcode-k1-daemon-cli-{}-{sequence}-{label}",
248 std::process::id()
249 ));
250 let _ = fs::remove_dir_all(&root);
251 Self(root)
252 }
253 }
254
255 impl Drop for TempRoot {
256 fn drop(&mut self) {
257 let _ = fs::remove_dir_all(&self.0);
258 }
259 }
260
261 fn command(root: &Path, command: Command, answers: &[&str]) -> Outcome {
262 execute(root, command, &mut FakePrompt::new(answers)).unwrap()
263 }
264
265 fn inspect_vault(root: &Path) -> K1Vault {
266 open_vault(root, SecretString::from(PASSWORD)).unwrap()
267 }
268
269 fn tip(root: &Path) -> Option<kcode_k1_txn_ordering::TxId> {
270 K1TxnOrdering::open(&state_paths(root).ordering)
271 .unwrap()
272 .tip()
273 }
274
275 #[test]
276 fn grammar_is_exact_and_daemon_mode_is_only_no_arguments() {
277 assert_eq!(parse(vec![]), Ok(Mode::Daemon));
278 assert_eq!(
279 parse(vec![OsString::from("list-secrets")]),
280 Ok(Mode::Admin(Command::List))
281 );
282 assert_eq!(
283 parse(vec!["set-secrets".into(), NAME.into()]),
284 Ok(Mode::Admin(Command::Set(NAME.to_owned())))
285 );
286 assert_eq!(
287 parse(vec!["remove-secrets".into(), NAME.into()]),
288 Ok(Mode::Admin(Command::Remove(NAME.to_owned())))
289 );
290 for args in [
291 vec![""],
292 vec!["set-secrets"],
293 vec!["remove-secrets"],
294 vec!["list-secrets", NAME],
295 vec!["set-secrets", NAME, "extra"],
296 vec!["remove-secrets", NAME, "extra"],
297 vec!["secrets", "set", NAME],
298 vec!["--help"],
299 vec!["unknown"],
300 ] {
301 assert!(parse(args.into_iter().map(OsString::from).collect()).is_err());
302 }
303 let _: fn(PathBuf) -> ExitCode = run;
304 }
305
306 #[cfg(unix)]
307 #[test]
308 fn non_utf8_command_and_name_are_invalid() {
309 use std::os::unix::ffi::OsStringExt;
310 let invalid = OsString::from_vec(vec![0xff]);
311 assert!(parse(vec![invalid.clone()]).is_err());
312 assert!(parse(vec!["set-secrets".into(), invalid]).is_err());
313 }
314
315 #[test]
316 fn set_replaces_list_sorts_and_remove_absence_submits_nothing() {
317 let root = TempRoot::new("lifecycle");
318 let mut prompts = FakePrompt::new(&[PASSWORD, VALUE, VALUE]);
319 assert_eq!(
320 execute(&root.0, Command::Set(NAME.to_owned()), &mut prompts),
321 Ok(Outcome::Set(NAME.to_owned()))
322 );
323 assert_eq!(
324 prompts.seen,
325 [
326 "Unlock K1 vault: ",
327 "Value for fake-local-name: ",
328 "Confirm secret value: "
329 ]
330 );
331 command(
332 &root.0,
333 Command::Set(NAME.to_owned()),
334 &[PASSWORD, REPLACEMENT, REPLACEMENT],
335 );
336 command(
337 &root.0,
338 Command::Set("another-name".to_owned()),
339 &[
340 PASSWORD,
341 "conspicuous-other-fake-value",
342 "conspicuous-other-fake-value",
343 ],
344 );
345 let vault = inspect_vault(&root.0);
346 assert_eq!(
347 vault.secret(NAME).unwrap().unwrap().expose_secret(),
348 REPLACEMENT
349 );
350 drop(vault);
351 let listed = command(&root.0, Command::List, &[PASSWORD]);
352 assert_eq!(
353 listed,
354 Outcome::Names(vec!["another-name".to_owned(), NAME.to_owned()])
355 );
356 assert_eq!(render(listed), format!("another-name\n{NAME}"));
357 let before_remove = tip(&root.0);
358 assert_eq!(
359 command(&root.0, Command::Remove(NAME.to_owned()), &[PASSWORD]),
360 Outcome::Removed(NAME.to_owned())
361 );
362 let after_remove = tip(&root.0);
363 assert_ne!(after_remove, before_remove);
364 assert_eq!(
365 command(&root.0, Command::Remove(NAME.to_owned()), &[PASSWORD]),
366 Outcome::Absent(NAME.to_owned())
367 );
368 assert_eq!(tip(&root.0), after_remove);
369 }
370
371 #[test]
372 fn populated_vault_rejects_wrong_password_without_changing_state() {
373 let root = TempRoot::new("wrong-password");
374 command(
375 &root.0,
376 Command::Set(NAME.to_owned()),
377 &[PASSWORD, VALUE, VALUE],
378 );
379 let before = tip(&root.0);
380 assert!(
381 execute(
382 &root.0,
383 Command::List,
384 &mut FakePrompt::new(&[WRONG_PASSWORD])
385 )
386 .is_err()
387 );
388 assert_eq!(tip(&root.0), before);
389 assert_eq!(
390 inspect_vault(&root.0)
391 .secret(NAME)
392 .unwrap()
393 .unwrap()
394 .expose_secret(),
395 VALUE
396 );
397 }
398
399 #[test]
400 fn rejected_set_values_precede_any_transaction() {
401 let empty = TempRoot::new("empty-value");
402 assert!(
403 execute(
404 &empty.0,
405 Command::Set(NAME.to_owned()),
406 &mut FakePrompt::new(&[PASSWORD, "", ""])
407 )
408 .is_err()
409 );
410 assert!(!empty.0.join("state").exists());
411 assert_eq!(tip(&empty.0), None);
412 let mismatch = TempRoot::new("mismatch");
413 assert!(
414 execute(
415 &mismatch.0,
416 Command::Set(NAME.to_owned()),
417 &mut FakePrompt::new(&[PASSWORD, VALUE, REPLACEMENT])
418 )
419 .is_err()
420 );
421 assert!(!mismatch.0.join("state").exists());
422 assert_eq!(tip(&mismatch.0), None);
423 }
424
425 #[test]
426 fn paths_rendering_and_output_failures_are_fixed_and_secret_free() {
427 let root = Path::new("trusted-k1-root");
428 let paths = state_paths(root);
429 assert_eq!(paths.ordering, root.join("state/ordering"));
430 assert_eq!(paths.peering, root.join("state/peering"));
431 assert_eq!(paths.vault, root.join("state/vault"));
432 let rendered = [
433 render(Outcome::Set(NAME.to_owned())),
434 render(Outcome::Removed(NAME.to_owned())),
435 render(Outcome::Absent(NAME.to_owned())),
436 render(Outcome::Names(vec![NAME.to_owned()])),
437 render(Outcome::Names(Vec::new())),
438 USAGE.to_owned(),
439 FAILURE.to_owned(),
440 ];
441 for text in rendered {
442 assert!(!text.contains(PASSWORD));
443 assert!(!text.contains(VALUE));
444 }
445 let mut stdout = Vec::new();
446 let mut stderr = Vec::new();
447 finish_admin(Err(()), &mut stdout, &mut stderr);
448 assert!(stdout.is_empty());
449 assert_eq!(stderr, format!("{FAILURE}\n").as_bytes());
450 let mut usage = Vec::new();
451 finish_invalid(&mut usage);
452 assert_eq!(usage, format!("{USAGE}\n").as_bytes());
453 let mut stderr = Vec::new();
454 finish_admin(
455 Ok(Outcome::Set(NAME.to_owned())),
456 &mut FailingWriter,
457 &mut stderr,
458 );
459 assert_eq!(stderr, format!("{FAILURE}\n").as_bytes());
460 }
461
462 struct FailingWriter;
463
464 impl Write for FailingWriter {
465 fn write(&mut self, _: &[u8]) -> io::Result<usize> {
466 Err(io::Error::other("conspicuous fake write failure"))
467 }
468
469 fn flush(&mut self) -> io::Result<()> {
470 Err(io::Error::other("conspicuous fake flush failure"))
471 }
472 }
473}