1use parking_lot::RwLock;
13use std::collections::{BTreeSet, HashMap};
14use std::path::PathBuf;
15use std::sync::Arc;
16use thiserror::Error;
17
18pub const SERVICE_NAME: &str = "flow-studio";
22
23#[derive(Debug, Error)]
24pub enum CredentialError {
25 #[error("credential not found for service '{service}' / account '{account}'")]
26 NotFound { service: String, account: String },
27 #[error("keyring error: {0}")]
28 Keyring(String),
29 #[error("invalid credential identifier: {0}")]
30 Invalid(String),
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum CredentialKind {
38 CloudAiKey,
40 ZoweProfile,
42 Service,
45}
46
47impl CredentialKind {
48 pub fn account_for(self, key: &str) -> String {
49 match self {
50 CredentialKind::CloudAiKey => format!("cloud-ai:{key}"),
51 CredentialKind::ZoweProfile => format!("zowe:{key}"),
52 CredentialKind::Service => format!("service:{key}"),
53 }
54 }
55}
56
57pub trait CredentialStore: Send + Sync {
59 fn set(&self, account: &str, secret: &str) -> Result<(), CredentialError>;
60 fn get(&self, account: &str) -> Result<String, CredentialError>;
61 fn delete(&self, account: &str) -> Result<(), CredentialError>;
62 fn exists(&self, account: &str) -> bool {
65 self.get(account).is_ok()
66 }
67}
68
69struct CredentialIndex {
80 path: Option<PathBuf>,
81 cache: RwLock<BTreeSet<String>>,
82}
83
84impl CredentialIndex {
85 fn open(path: Option<PathBuf>) -> Self {
86 let cache = match path.as_ref() {
87 Some(p) if p.exists() => std::fs::read_to_string(p)
88 .ok()
89 .and_then(|s| serde_json::from_str::<BTreeSet<String>>(&s).ok())
90 .unwrap_or_default(),
91 _ => BTreeSet::new(),
92 };
93 Self {
94 path,
95 cache: RwLock::new(cache),
96 }
97 }
98
99 fn contains(&self, account: &str) -> bool {
100 self.cache.read().contains(account)
101 }
102
103 fn insert(&self, account: &str) {
104 let mut guard = self.cache.write();
105 if guard.insert(account.to_string()) {
106 self.persist(&guard);
107 }
108 }
109
110 fn remove(&self, account: &str) {
111 let mut guard = self.cache.write();
112 if guard.remove(account) {
113 self.persist(&guard);
114 }
115 }
116
117 fn persist(&self, contents: &BTreeSet<String>) {
118 let Some(path) = self.path.as_ref() else {
119 return;
120 };
121 if let Some(parent) = path.parent() {
122 let _ = std::fs::create_dir_all(parent);
123 }
124 if let Ok(body) = serde_json::to_string_pretty(contents) {
125 let _ = std::fs::write(path, body);
126 }
127 }
128}
129
130pub struct KeyringCredentialStore {
134 service: String,
135 index: CredentialIndex,
136}
137
138impl KeyringCredentialStore {
139 pub fn new() -> Self {
140 Self {
141 service: SERVICE_NAME.to_string(),
142 index: CredentialIndex::open(None),
143 }
144 }
145
146 pub fn with_index(index_path: PathBuf) -> Self {
149 Self {
150 service: SERVICE_NAME.to_string(),
151 index: CredentialIndex::open(Some(index_path)),
152 }
153 }
154
155 pub fn with_service(service: impl Into<String>) -> Self {
156 Self {
157 service: service.into(),
158 index: CredentialIndex::open(None),
159 }
160 }
161
162 fn entry(&self, account: &str) -> Result<keyring::Entry, CredentialError> {
163 keyring::Entry::new(&self.service, account)
164 .map_err(|e| CredentialError::Keyring(e.to_string()))
165 }
166}
167
168impl Default for KeyringCredentialStore {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174impl CredentialStore for KeyringCredentialStore {
175 fn set(&self, account: &str, secret: &str) -> Result<(), CredentialError> {
176 if account.is_empty() {
177 return Err(CredentialError::Invalid("account is empty".into()));
178 }
179 self.entry(account)?
180 .set_password(secret)
181 .map_err(|e| CredentialError::Keyring(e.to_string()))?;
182 self.index.insert(account);
183 Ok(())
184 }
185
186 fn get(&self, account: &str) -> Result<String, CredentialError> {
187 self.entry(account)?.get_password().map_err(|e| match e {
188 keyring::Error::NoEntry => {
189 self.index.remove(account);
192 CredentialError::NotFound {
193 service: self.service.clone(),
194 account: account.to_string(),
195 }
196 }
197 other => CredentialError::Keyring(other.to_string()),
198 })
199 }
200
201 fn delete(&self, account: &str) -> Result<(), CredentialError> {
202 let result = self.entry(account)?.delete_credential().map_err(|e| match e {
203 keyring::Error::NoEntry => CredentialError::NotFound {
204 service: self.service.clone(),
205 account: account.to_string(),
206 },
207 other => CredentialError::Keyring(other.to_string()),
208 });
209 self.index.remove(account);
212 result
213 }
214
215 fn exists(&self, account: &str) -> bool {
221 self.index.contains(account)
222 }
223}
224
225#[derive(Default)]
227pub struct InMemoryCredentialStore {
228 inner: RwLock<HashMap<String, String>>,
229}
230
231impl InMemoryCredentialStore {
232 pub fn new() -> Self {
233 Self::default()
234 }
235}
236
237impl CredentialStore for InMemoryCredentialStore {
238 fn set(&self, account: &str, secret: &str) -> Result<(), CredentialError> {
239 if account.is_empty() {
240 return Err(CredentialError::Invalid("account is empty".into()));
241 }
242 self.inner
243 .write()
244 .insert(account.to_string(), secret.to_string());
245 Ok(())
246 }
247
248 fn get(&self, account: &str) -> Result<String, CredentialError> {
249 self.inner
250 .read()
251 .get(account)
252 .cloned()
253 .ok_or_else(|| CredentialError::NotFound {
254 service: SERVICE_NAME.into(),
255 account: account.to_string(),
256 })
257 }
258
259 fn delete(&self, account: &str) -> Result<(), CredentialError> {
260 if self.inner.write().remove(account).is_some() {
261 Ok(())
262 } else {
263 Err(CredentialError::NotFound {
264 service: SERVICE_NAME.into(),
265 account: account.to_string(),
266 })
267 }
268 }
269}
270
271pub trait CredentialResolver: Send + Sync {
276 fn resolve_cloud_ai_key(
279 &self,
280 provider: &str,
281 env_var: &str,
282 ) -> Result<String, CredentialError>;
283}
284
285pub struct EnvFallbackResolver {
286 pub store: Arc<dyn CredentialStore>,
287}
288
289impl EnvFallbackResolver {
290 pub fn new(store: Arc<dyn CredentialStore>) -> Self {
291 Self { store }
292 }
293}
294
295impl CredentialResolver for EnvFallbackResolver {
296 fn resolve_cloud_ai_key(
297 &self,
298 provider: &str,
299 env_var: &str,
300 ) -> Result<String, CredentialError> {
301 let account = CredentialKind::CloudAiKey.account_for(provider);
302 match self.store.get(&account) {
303 Ok(secret) => Ok(secret),
304 Err(CredentialError::NotFound { .. }) => match std::env::var(env_var) {
305 Ok(v) if !v.is_empty() => Ok(v),
306 _ => Err(CredentialError::NotFound {
307 service: SERVICE_NAME.into(),
308 account,
309 }),
310 },
311 Err(other) => Err(other),
312 }
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn account_naming_is_kind_prefixed() {
322 assert_eq!(
323 CredentialKind::CloudAiKey.account_for("claude"),
324 "cloud-ai:claude"
325 );
326 assert_eq!(
327 CredentialKind::ZoweProfile.account_for("PRD01"),
328 "zowe:PRD01"
329 );
330 }
331
332 #[test]
333 fn in_memory_set_get_round_trip() {
334 let store = InMemoryCredentialStore::new();
335 store.set("cloud-ai:claude", "sk-test").unwrap();
336 assert_eq!(store.get("cloud-ai:claude").unwrap(), "sk-test");
337 assert!(store.exists("cloud-ai:claude"));
338 }
339
340 #[test]
341 fn in_memory_get_missing_is_not_found() {
342 let store = InMemoryCredentialStore::new();
343 assert!(matches!(
344 store.get("cloud-ai:claude"),
345 Err(CredentialError::NotFound { .. })
346 ));
347 }
348
349 #[test]
350 fn in_memory_delete_removes() {
351 let store = InMemoryCredentialStore::new();
352 store.set("cloud-ai:openai", "sk-test").unwrap();
353 store.delete("cloud-ai:openai").unwrap();
354 assert!(!store.exists("cloud-ai:openai"));
355 }
356
357 #[test]
358 fn in_memory_set_empty_account_is_invalid() {
359 let store = InMemoryCredentialStore::new();
360 assert!(matches!(
361 store.set("", "x"),
362 Err(CredentialError::Invalid(_))
363 ));
364 }
365
366 #[test]
367 fn resolver_prefers_keyring_over_env() {
368 let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
369 store.set("cloud-ai:claude", "from-keyring").unwrap();
370 std::env::set_var("FLOW_TEST_RESOLVER_ENV", "from-env");
372 let resolver = EnvFallbackResolver::new(store);
373 let key = resolver
374 .resolve_cloud_ai_key("claude", "FLOW_TEST_RESOLVER_ENV")
375 .unwrap();
376 assert_eq!(key, "from-keyring");
377 std::env::remove_var("FLOW_TEST_RESOLVER_ENV");
378 }
379
380 #[test]
381 fn resolver_falls_back_to_env_when_keyring_empty() {
382 let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
383 std::env::set_var("FLOW_TEST_RESOLVER_FALLBACK", "from-env");
384 let resolver = EnvFallbackResolver::new(store);
385 let key = resolver
386 .resolve_cloud_ai_key("openai", "FLOW_TEST_RESOLVER_FALLBACK")
387 .unwrap();
388 assert_eq!(key, "from-env");
389 std::env::remove_var("FLOW_TEST_RESOLVER_FALLBACK");
390 }
391
392 #[test]
393 fn resolver_returns_not_found_when_neither_present() {
394 let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
395 let resolver = EnvFallbackResolver::new(store);
396 assert!(matches!(
397 resolver.resolve_cloud_ai_key("gemini", "FLOW_TEST_RESOLVER_MISSING_ENV"),
398 Err(CredentialError::NotFound { .. })
399 ));
400 }
401}