Skip to main content

harmont_cli/
creds_store.rs

1//! File-backed credential store at `~/.harmont/credentials.toml`.
2//!
3//! Replaces the OS keyring as the sole backend. The file is written with
4//! mode 0o600 (parent dir 0o700) via [`crate::fs_util::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    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    crate::fs_util::write_atomic_restricted(&p, serialized.as_bytes(), 0o600, 0o700)
36        .with_context(|| format!("writing {}", p.display()))?;
37    Ok(())
38}
39
40/// Read a credential for `(service, account)`. Returns `None` when the
41/// file is missing, unreadable, or the entry is absent.
42#[must_use]
43pub fn get(service: &str, account: &str) -> Option<String> {
44    load().entries.get(service)?.get(account).cloned()
45}
46
47/// Write a credential. Silently no-ops on I/O failure so plugin callers
48/// match the prior keyring-backed best-effort semantics.
49pub 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
58/// Remove a credential. Silently no-ops if the entry is absent or the
59/// underlying write fails.
60pub fn delete(service: &str, account: &str) {
61    let mut f = load();
62    let now_empty = if let Some(svc) = f.entries.get_mut(service) {
63        svc.remove(account);
64        svc.is_empty()
65    } else {
66        false
67    };
68    if now_empty {
69        f.entries.remove(service);
70    }
71    let _ = save(&f);
72}
73
74#[cfg(test)]
75#[allow(clippy::unwrap_used)]
76mod tests {
77    use super::*;
78
79    fn with_home<F: FnOnce()>(f: F) {
80        let tmp = tempfile::tempdir().unwrap();
81        let prev = std::env::var_os("HOME");
82        // SAFETY: tests are single-threaded for env mutation by Cargo.
83        unsafe { std::env::set_var("HOME", tmp.path()); }
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    #[test]
95    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}