Skip to main content

hm_config/
creds.rs

1//! File-backed credential store at `~/.config/hm/credentials.toml`.
2//!
3//! Replaces the OS keyring as the sole backend. The file is written with
4//! mode 0o600 (parent dir 0o700) via [`hm_util::os::fs::blocking::write_atomic_restricted`].
5//! Keyed by `(service, account)` to match the host-fn ABI plugins use.
6
7use 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/// Read a credential for `(service, account)`. Returns `None` when the
47/// file is missing, unreadable, or the entry is absent.
48#[must_use]
49pub fn get(service: &str, account: &str) -> Option<String> {
50    load().entries.get(service)?.get(account).cloned()
51}
52
53/// Write a credential. Silently no-ops on I/O failure so plugin callers
54/// match the prior keyring-backed best-effort semantics.
55pub 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
64/// Credential `service` name for the cloud bearer token (account = API base URL).
65pub const CLOUD_SERVICE: &str = "harmont-cloud";
66
67/// Resolve the cloud bearer token for `api_base`.
68///
69/// Priority: `HM_API_TOKEN` env (non-empty) first, then the stored
70/// credential keyed by `(CLOUD_SERVICE, api_base)`. Returns `None` when
71/// neither is present, so the caller can produce a clear "not logged in" error.
72#[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
82/// Persist the cloud bearer token for `api_base`.
83///
84/// Silently no-ops on I/O failure (matches the best-effort semantics of
85/// the underlying [`set`] call).
86pub fn set_cloud_token(api_base: &str, token: &str) {
87    set(CLOUD_SERVICE, api_base, token);
88}
89
90/// Remove any stored cloud bearer token for `api_base`.
91///
92/// Silently no-ops if the entry is absent or the write fails.
93pub fn forget_cloud_token(api_base: &str) {
94    delete(CLOUD_SERVICE, api_base);
95}
96
97/// Remove a credential. Silently no-ops if the entry is absent or the
98/// underlying write fails.
99pub 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        // SAFETY: tests are single-threaded for env mutation by Cargo.
120        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}