1use anyhow::{Context, Result, anyhow};
29use serde::{Deserialize, Serialize};
30use std::collections::BTreeMap;
31
32#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[serde(rename_all = "lowercase")]
42pub enum AuthCredentialsStoreMode {
43 Keyring,
47 File,
50 Auto,
52}
53
54impl Default for AuthCredentialsStoreMode {
55 fn default() -> Self {
58 Self::Keyring
59 }
60}
61
62impl AuthCredentialsStoreMode {
63 pub fn effective_mode(self) -> Self {
65 match self {
66 Self::Auto => {
67 if is_keyring_functional() {
69 Self::Keyring
70 } else {
71 tracing::debug!("Keyring not available, falling back to file storage");
72 Self::File
73 }
74 }
75 mode => mode,
76 }
77 }
78}
79
80pub(crate) fn is_keyring_functional() -> bool {
85 let test_user = format!("test_{}", std::process::id());
87 let entry = match keyring::Entry::new("vtcode", &test_user) {
88 Ok(e) => e,
89 Err(_) => return false,
90 };
91
92 if entry.set_password("test").is_err() {
94 return false;
95 }
96
97 let functional = entry.get_password().is_ok();
99
100 let _ = entry.delete_credential();
102
103 functional
104}
105
106pub struct CredentialStorage {
111 service: String,
112 user: String,
113}
114
115impl CredentialStorage {
116 pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
122 Self {
123 service: service.into(),
124 user: user.into(),
125 }
126 }
127
128 pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
134 match mode.effective_mode() {
135 AuthCredentialsStoreMode::Keyring => self.store_keyring(value),
136 AuthCredentialsStoreMode::File => Err(anyhow!(
137 "File storage requires the file_storage feature or custom implementation"
138 )),
139 _ => unreachable!(),
140 }
141 }
142
143 pub fn store(&self, value: &str) -> Result<()> {
145 self.store_keyring(value)
146 }
147
148 fn store_keyring(&self, value: &str) -> Result<()> {
150 let entry = keyring::Entry::new(&self.service, &self.user)
151 .context("Failed to access OS keyring")?;
152
153 entry
154 .set_password(value)
155 .context("Failed to store credential in OS keyring")?;
156
157 tracing::debug!(
158 "Credential stored in OS keyring for {}/{}",
159 self.service,
160 self.user
161 );
162 Ok(())
163 }
164
165 pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
169 match mode.effective_mode() {
170 AuthCredentialsStoreMode::Keyring => self.load_keyring(),
171 AuthCredentialsStoreMode::File => Err(anyhow!(
172 "File storage requires the file_storage feature or custom implementation"
173 )),
174 _ => unreachable!(),
175 }
176 }
177
178 pub fn load(&self) -> Result<Option<String>> {
182 self.load_keyring()
183 }
184
185 fn load_keyring(&self) -> Result<Option<String>> {
187 let entry = match keyring::Entry::new(&self.service, &self.user) {
188 Ok(e) => e,
189 Err(_) => return Ok(None),
190 };
191
192 match entry.get_password() {
193 Ok(value) => Ok(Some(value)),
194 Err(keyring::Error::NoEntry) => Ok(None),
195 Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
196 }
197 }
198
199 pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
201 match mode.effective_mode() {
202 AuthCredentialsStoreMode::Keyring => self.clear_keyring(),
203 AuthCredentialsStoreMode::File => Ok(()), _ => unreachable!(),
205 }
206 }
207
208 pub fn clear(&self) -> Result<()> {
210 self.clear_keyring()
211 }
212
213 fn clear_keyring(&self) -> Result<()> {
215 let entry = match keyring::Entry::new(&self.service, &self.user) {
216 Ok(e) => e,
217 Err(_) => return Ok(()),
218 };
219
220 match entry.delete_credential() {
221 Ok(_) => {
222 tracing::debug!(
223 "Credential cleared from keyring for {}/{}",
224 self.service,
225 self.user
226 );
227 }
228 Err(keyring::Error::NoEntry) => {}
229 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
230 }
231
232 Ok(())
233 }
234}
235
236pub struct CustomApiKeyStorage {
241 storage: CredentialStorage,
242}
243
244impl CustomApiKeyStorage {
245 pub fn new(provider: &str) -> Self {
250 Self {
251 storage: CredentialStorage::new(
252 "vtcode",
253 format!("api_key_{}", provider.to_lowercase()),
254 ),
255 }
256 }
257
258 pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
264 self.storage.store_with_mode(api_key, mode)
265 }
266
267 pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
271 self.storage.load_with_mode(mode)
272 }
273
274 pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
276 self.storage.clear_with_mode(mode)
277 }
278}
279
280pub fn migrate_custom_api_keys_to_keyring(
293 custom_api_keys: &BTreeMap<String, String>,
294 mode: AuthCredentialsStoreMode,
295) -> Result<BTreeMap<String, bool>> {
296 let mut migration_results = BTreeMap::new();
297
298 for (provider, api_key) in custom_api_keys {
299 let storage = CustomApiKeyStorage::new(provider);
300 match storage.store(api_key, mode) {
301 Ok(()) => {
302 tracing::info!(
303 "Migrated API key for provider '{}' to secure storage",
304 provider
305 );
306 migration_results.insert(provider.clone(), true);
307 }
308 Err(e) => {
309 tracing::warn!(
310 "Failed to migrate API key for provider '{}': {}",
311 provider,
312 e
313 );
314 migration_results.insert(provider.clone(), false);
315 }
316 }
317 }
318
319 Ok(migration_results)
320}
321
322pub fn load_custom_api_keys(
333 providers: &[String],
334 mode: AuthCredentialsStoreMode,
335) -> Result<BTreeMap<String, String>> {
336 let mut api_keys = BTreeMap::new();
337
338 for provider in providers {
339 let storage = CustomApiKeyStorage::new(provider);
340 if let Some(key) = storage.load(mode)? {
341 api_keys.insert(provider.clone(), key);
342 }
343 }
344
345 Ok(api_keys)
346}
347
348pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
354 for provider in providers {
355 let storage = CustomApiKeyStorage::new(provider);
356 if let Err(e) = storage.clear(mode) {
357 tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
358 }
359 }
360 Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn test_storage_mode_default_is_keyring() {
369 assert_eq!(
370 AuthCredentialsStoreMode::default(),
371 AuthCredentialsStoreMode::Keyring
372 );
373 }
374
375 #[test]
376 fn test_storage_mode_effective_mode() {
377 assert_eq!(
378 AuthCredentialsStoreMode::Keyring.effective_mode(),
379 AuthCredentialsStoreMode::Keyring
380 );
381 assert_eq!(
382 AuthCredentialsStoreMode::File.effective_mode(),
383 AuthCredentialsStoreMode::File
384 );
385
386 let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
388 assert!(
389 auto_mode == AuthCredentialsStoreMode::Keyring
390 || auto_mode == AuthCredentialsStoreMode::File
391 );
392 }
393
394 #[test]
395 fn test_storage_mode_serialization() {
396 let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
397 assert_eq!(keyring_json, "\"keyring\"");
398
399 let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
400 assert_eq!(file_json, "\"file\"");
401
402 let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
403 assert_eq!(auto_json, "\"auto\"");
404
405 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
407 assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
408
409 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
410 assert_eq!(parsed, AuthCredentialsStoreMode::File);
411
412 let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
413 assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
414 }
415
416 #[test]
417 fn test_credential_storage_new() {
418 let storage = CredentialStorage::new("vtcode", "test_key");
419 assert_eq!(storage.service, "vtcode");
420 assert_eq!(storage.user, "test_key");
421 }
422
423 #[test]
424 fn test_is_keyring_functional_check() {
425 let _functional = is_keyring_functional();
428 }
429}