Skip to main content

forgetop_core/
secret.rs

1//! Secret storage for per-connection PATs. Config only stores a key reference; the
2//! actual token lives in the OS keychain (or a `FORGETOP_PAT_*` env var fallback).
3//! Secrets are never written to the config file or committed to source.
4
5use std::collections::HashMap;
6use std::sync::Mutex;
7
8use crate::error::{Error, Result};
9
10pub const SERVICE: &str = "forgetop";
11pub const ENV_PREFIX: &str = "FORGETOP_PAT_";
12
13pub trait SecretStore: Send + Sync {
14    fn get(&self, key: &str) -> Result<Option<String>>;
15    fn set(&self, key: &str, secret: &str) -> Result<()>;
16    fn delete(&self, key: &str) -> Result<()>;
17    /// False for read-only stores (e.g. the env-var fallback).
18    fn is_writable(&self) -> bool;
19}
20
21/// OS keychain via the `keyring` crate (macOS Keychain / Windows DPAPI / libsecret).
22pub struct KeyringSecretStore;
23
24impl SecretStore for KeyringSecretStore {
25    fn get(&self, key: &str) -> Result<Option<String>> {
26        let entry = keyring::Entry::new(SERVICE, key).map_err(kerr)?;
27        match entry.get_password() {
28            Ok(p) => Ok(Some(p)),
29            Err(keyring::Error::NoEntry) => Ok(None),
30            Err(e) => Err(kerr(e)),
31        }
32    }
33
34    fn set(&self, key: &str, secret: &str) -> Result<()> {
35        keyring::Entry::new(SERVICE, key).map_err(kerr)?.set_password(secret).map_err(kerr)
36    }
37
38    fn delete(&self, key: &str) -> Result<()> {
39        let entry = keyring::Entry::new(SERVICE, key).map_err(kerr)?;
40        match entry.delete_credential() {
41            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
42            Err(e) => Err(kerr(e)),
43        }
44    }
45
46    fn is_writable(&self) -> bool {
47        true
48    }
49}
50
51fn kerr(e: keyring::Error) -> Error {
52    Error::Provider(format!("keychain: {e}"))
53}
54
55/// Read-only fallback resolving secrets from `FORGETOP_PAT_{KEY}` env vars.
56pub struct EnvSecretStore;
57
58impl EnvSecretStore {
59    pub fn var_name(key: &str) -> String {
60        let sanitized: String = key
61            .chars()
62            .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' })
63            .collect();
64        format!("{ENV_PREFIX}{sanitized}")
65    }
66}
67
68impl SecretStore for EnvSecretStore {
69    fn get(&self, key: &str) -> Result<Option<String>> {
70        Ok(std::env::var(Self::var_name(key)).ok().filter(|s| !s.is_empty()))
71    }
72    fn set(&self, _key: &str, _secret: &str) -> Result<()> {
73        Err(Error::Config("the environment secret store is read-only".into()))
74    }
75    fn delete(&self, _key: &str) -> Result<()> {
76        Err(Error::Config("the environment secret store is read-only".into()))
77    }
78    fn is_writable(&self) -> bool {
79        false
80    }
81}
82
83/// In-memory store for tests and the Demo provider.
84#[derive(Default)]
85pub struct InMemorySecretStore {
86    secrets: Mutex<HashMap<String, String>>,
87}
88
89impl SecretStore for InMemorySecretStore {
90    fn get(&self, key: &str) -> Result<Option<String>> {
91        Ok(self.secrets.lock().unwrap().get(key).cloned())
92    }
93    fn set(&self, key: &str, secret: &str) -> Result<()> {
94        self.secrets.lock().unwrap().insert(key.to_string(), secret.to_string());
95        Ok(())
96    }
97    fn delete(&self, key: &str) -> Result<()> {
98        self.secrets.lock().unwrap().remove(key);
99        Ok(())
100    }
101    fn is_writable(&self) -> bool {
102        true
103    }
104}
105
106/// Tries a writable primary (keychain), falling back to a read-only secondary (env).
107pub struct FallbackSecretStore {
108    primary: Box<dyn SecretStore>,
109    fallback: Box<dyn SecretStore>,
110}
111
112impl FallbackSecretStore {
113    pub fn new(primary: Box<dyn SecretStore>, fallback: Box<dyn SecretStore>) -> Self {
114        Self { primary, fallback }
115    }
116}
117
118impl SecretStore for FallbackSecretStore {
119    fn get(&self, key: &str) -> Result<Option<String>> {
120        match self.primary.get(key)? {
121            Some(v) => Ok(Some(v)),
122            None => self.fallback.get(key),
123        }
124    }
125    fn set(&self, key: &str, secret: &str) -> Result<()> {
126        self.primary.set(key, secret)
127    }
128    fn delete(&self, key: &str) -> Result<()> {
129        self.primary.delete(key)
130    }
131    fn is_writable(&self) -> bool {
132        self.primary.is_writable()
133    }
134}
135
136/// The default store: OS keychain with an env-var fallback.
137pub fn default_secret_store() -> Box<dyn SecretStore> {
138    Box::new(FallbackSecretStore::new(Box::new(KeyringSecretStore), Box::new(EnvSecretStore)))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn in_memory_roundtrip() {
147        let store = InMemorySecretStore::default();
148        store.set("gh-1", "token").unwrap();
149        assert_eq!(store.get("gh-1").unwrap().as_deref(), Some("token"));
150        store.delete("gh-1").unwrap();
151        assert_eq!(store.get("gh-1").unwrap(), None);
152    }
153
154    #[test]
155    fn env_var_name_is_sanitized() {
156        assert_eq!(EnvSecretStore::var_name("test-conn"), "FORGETOP_PAT_TEST_CONN");
157    }
158
159    #[test]
160    fn fallback_reads_primary_then_env() {
161        let primary = InMemorySecretStore::default();
162        primary.set("in-primary", "p").unwrap();
163        let store = FallbackSecretStore::new(Box::new(primary), Box::new(InMemorySecretStore::default()));
164        assert_eq!(store.get("in-primary").unwrap().as_deref(), Some("p"));
165        assert_eq!(store.get("missing").unwrap(), None);
166    }
167}