1use 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 fn is_writable(&self) -> bool;
19}
20
21pub 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
55pub 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#[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
106pub 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
136pub 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}