1use 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 let dir = hm_util::dirs::hm_config_dir().context("could not determine config directory")?;
20 Ok(dir.join("credentials.toml"))
21}
22
23fn load() -> CredentialFile {
24 let Ok(p) = path() else {
25 return CredentialFile::default();
26 };
27 let Ok(contents) = std::fs::read_to_string(&p) else {
28 return CredentialFile::default();
29 };
30 toml::from_str(&contents).unwrap_or_default()
31}
32
33fn save(file: &CredentialFile) -> Result<()> {
34 let p = path()?;
35 let serialized = toml::to_string_pretty(file).context("serializing credentials")?;
36 hm_util::os::fs::blocking::write_atomic_restricted(
37 &p,
38 serialized.as_bytes(),
39 hm_util::os::fs::FileMode(0o600),
40 hm_util::os::fs::DirMode(0o700),
41 )
42 .with_context(|| format!("writing {}", p.display()))?;
43 Ok(())
44}
45
46#[must_use]
49pub fn get(service: &str, account: &str) -> Option<String> {
50 load().entries.get(service)?.get(account).cloned()
51}
52
53pub fn set(service: &str, account: &str, secret: &str) {
56 let mut f = load();
57 f.entries
58 .entry(service.to_string())
59 .or_default()
60 .insert(account.to_string(), secret.to_string());
61 let _ = save(&f);
62}
63
64pub const CLOUD_SERVICE: &str = "harmont-cloud";
66
67#[must_use]
73pub fn cloud_token(api_base: &str) -> Option<String> {
74 if let Ok(t) = std::env::var("HM_API_TOKEN")
75 && !t.is_empty()
76 {
77 return Some(t);
78 }
79 get(CLOUD_SERVICE, api_base)
80}
81
82pub fn set_cloud_token(api_base: &str, token: &str) {
87 set(CLOUD_SERVICE, api_base, token);
88}
89
90pub fn forget_cloud_token(api_base: &str) {
94 delete(CLOUD_SERVICE, api_base);
95}
96
97pub fn delete(service: &str, account: &str) {
100 let mut f = load();
101 let now_empty = f.entries.get_mut(service).is_some_and(|svc| {
102 svc.remove(account);
103 svc.is_empty()
104 });
105 if now_empty {
106 f.entries.remove(service);
107 }
108 let _ = save(&f);
109}
110
111#[cfg(test)]
112#[allow(clippy::unwrap_used, unsafe_code)]
113mod tests {
114 use super::*;
115
116 fn with_home<F: FnOnce()>(f: F) {
117 let tmp = tempfile::tempdir().unwrap();
118 let prev = std::env::var_os("HOME");
119 unsafe {
121 std::env::set_var("HOME", tmp.path());
122 }
123 f();
124 unsafe {
125 if let Some(v) = prev {
126 std::env::set_var("HOME", v);
127 } else {
128 std::env::remove_var("HOME");
129 }
130 }
131 }
132
133 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
134 async fn round_trip() {
135 with_home(|| {
136 assert_eq!(get("svc", "acct"), None);
137 set("svc", "acct", "shh");
138 assert_eq!(get("svc", "acct").as_deref(), Some("shh"));
139 delete("svc", "acct");
140 assert_eq!(get("svc", "acct"), None);
141 });
142 }
143}