Skip to main content

kcode_k1_daemon_cli/
lib.rs

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
58/// Runs either the normal K1 daemon or one exact offline secrets command.
59pub 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}