harmont_cli/
creds_store.rs1use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::path::PathBuf;
11
12#[derive(Debug, Default, Serialize, Deserialize)]
13struct CredentialFile {
14 #[serde(default)]
15 entries: BTreeMap<String, BTreeMap<String, String>>,
16}
17
18fn path() -> Result<PathBuf> {
19 Ok(crate::config::user_config_dir()?.join("credentials.toml"))
20}
21
22fn load() -> CredentialFile {
23 let Ok(p) = path() else {
24 return CredentialFile::default();
25 };
26 let Ok(contents) = std::fs::read_to_string(&p) else {
27 return CredentialFile::default();
28 };
29 toml::from_str(&contents).unwrap_or_default()
30}
31
32fn save(file: &CredentialFile) -> Result<()> {
33 let p = path()?;
34 let serialized = toml::to_string_pretty(file).context("serializing credentials")?;
35 hm_util::os::fs::blocking::write_atomic_restricted(&p, serialized.as_bytes(), 0o600, 0o700)
36 .with_context(|| format!("writing {}", p.display()))?;
37 Ok(())
38}
39
40#[must_use]
43pub fn get(service: &str, account: &str) -> Option<String> {
44 load().entries.get(service)?.get(account).cloned()
45}
46
47pub fn set(service: &str, account: &str, secret: &str) {
50 let mut f = load();
51 f.entries
52 .entry(service.to_string())
53 .or_default()
54 .insert(account.to_string(), secret.to_string());
55 let _ = save(&f);
56}
57
58pub fn delete(service: &str, account: &str) {
61 let mut f = load();
62 let now_empty = f.entries.get_mut(service).is_some_and(|svc| {
63 svc.remove(account);
64 svc.is_empty()
65 });
66 if now_empty {
67 f.entries.remove(service);
68 }
69 let _ = save(&f);
70}
71
72#[cfg(test)]
73#[allow(clippy::unwrap_used, unsafe_code)]
74mod tests {
75 use super::*;
76
77 fn with_home<F: FnOnce()>(f: F) {
78 let tmp = tempfile::tempdir().unwrap();
79 let prev = std::env::var_os("HOME");
80 unsafe {
82 std::env::set_var("HOME", tmp.path());
83 }
84 f();
85 unsafe {
86 if let Some(v) = prev {
87 std::env::set_var("HOME", v);
88 } else {
89 std::env::remove_var("HOME");
90 }
91 }
92 }
93
94 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
95 async fn round_trip() {
96 with_home(|| {
97 assert_eq!(get("svc", "acct"), None);
98 set("svc", "acct", "shh");
99 assert_eq!(get("svc", "acct").as_deref(), Some("shh"));
100 delete("svc", "acct");
101 assert_eq!(get("svc", "acct"), None);
102 });
103 }
104}