Skip to main content

vtcode_auth/
credentials.rs

1//! Generic credential storage with OS keyring and file-based backends.
2//!
3//! This module provides a unified interface for storing sensitive credentials
4//! securely using the OS keyring (macOS Keychain, Windows Credential Manager,
5//! Linux Secret Service) with fallback to AES-256-GCM encrypted files.
6//!
7//! ## Usage
8//!
9//! ```rust
10//! use vtcode_auth::{AuthCredentialsStoreMode, CredentialStorage};
11//!
12//! # fn example() -> anyhow::Result<()> {
13//! // Store a credential using the default mode (keyring)
14//! let storage = CredentialStorage::new("my_app", "api_key");
15//! storage.store("secret_api_key")?;
16//!
17//! // Retrieve the credential
18//! if let Some(value) = storage.load()? {
19//!     println!("Found credential: {}", value);
20//! }
21//!
22//! // Delete the credential
23//! storage.clear()?;
24//! # Ok(())
25//! # }
26//! ```
27
28use anyhow::{Context, Result, anyhow};
29use base64::Engine;
30use base64::engine::general_purpose::STANDARD;
31use ring::aead::{self, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
32use ring::rand::{SecureRandom, SystemRandom};
33use serde::{Deserialize, Serialize};
34use std::collections::BTreeMap;
35use std::fs;
36
37use crate::storage_paths::auth_storage_dir;
38use crate::storage_paths::legacy_auth_storage_path;
39use crate::storage_paths::write_private_file;
40
41const ENCRYPTED_CREDENTIAL_VERSION: u8 = 1;
42
43#[derive(Debug, Serialize, Deserialize)]
44struct EncryptedCredential {
45    nonce: String,
46    ciphertext: String,
47    version: u8,
48}
49
50#[derive(Debug, Deserialize)]
51struct LegacyAuthFile {
52    mode: String,
53    provider: String,
54    api_key: String,
55}
56
57/// Preferred storage backend for credentials.
58///
59/// - `Keyring`: Use OS-specific secure storage (macOS Keychain, Windows Credential Manager,
60///   Linux Secret Service). This is the default as it's the most secure option.
61/// - `File`: Use AES-256-GCM encrypted file (requires the `file-storage` feature or
62///   custom implementation)
63/// - `Auto`: Try keyring first, fall back to file if unavailable
64#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(rename_all = "lowercase")]
67pub enum AuthCredentialsStoreMode {
68    /// Use OS-specific keyring service.
69    /// This is the most secure option as credentials are managed by the OS
70    /// and are not accessible to other users or applications.
71    Keyring,
72    /// Persist credentials in an encrypted file.
73    /// The file is encrypted with AES-256-GCM using a machine-derived key.
74    File,
75    /// Use keyring when available; otherwise, fall back to file.
76    Auto,
77}
78
79impl Default for AuthCredentialsStoreMode {
80    /// Default to keyring on all platforms for maximum security.
81    /// Falls back to file-based storage if keyring is unavailable.
82    fn default() -> Self {
83        Self::Keyring
84    }
85}
86
87impl AuthCredentialsStoreMode {
88    /// Get the effective storage mode, resolving Auto to the best available option.
89    pub fn effective_mode(self) -> Self {
90        match self {
91            Self::Auto => {
92                // Check if keyring is functional by attempting to create an entry
93                if is_keyring_functional() {
94                    Self::Keyring
95                } else {
96                    tracing::debug!("Keyring not available, falling back to file storage");
97                    Self::File
98                }
99            }
100            mode => mode,
101        }
102    }
103}
104
105/// Check if the OS keyring is functional by attempting a test operation.
106///
107/// This creates a test entry, verifies it can be written and read, then deletes it.
108/// This is more reliable than just checking if Entry creation succeeds.
109pub(crate) fn is_keyring_functional() -> bool {
110    // Create a test entry with a unique name to avoid conflicts
111    let test_user = format!("test_{}", std::process::id());
112    let entry = match keyring_entry("vtcode", &test_user) {
113        Ok(e) => e,
114        Err(_) => return false,
115    };
116
117    // Try to write a test value
118    if entry.set_password("test").is_err() {
119        return false;
120    }
121
122    // Try to read it back
123    let functional = entry.get_password().is_ok();
124
125    // Clean up - ignore errors during cleanup
126    let _ = entry.delete_credential();
127
128    functional
129}
130
131pub(crate) fn keyring_entry(
132    service: &str,
133    user: &str,
134) -> keyring_core::Result<keyring_core::Entry> {
135    if keyring_core::get_default_store().is_none() {
136        keyring::use_native_store(cfg!(target_os = "linux"))?;
137    }
138
139    keyring_core::Entry::new(service, user)
140}
141
142/// Generic credential storage interface.
143///
144/// Provides methods to store, load, and clear credentials using either
145/// the OS keyring or file-based storage.
146pub struct CredentialStorage {
147    service: String,
148    user: String,
149}
150
151impl CredentialStorage {
152    /// Create a new credential storage handle.
153    ///
154    /// # Arguments
155    /// * `service` - The service name (e.g., "vtcode", "openrouter", "github")
156    /// * `user` - The user/account identifier (e.g., "api_key", "oauth_token")
157    pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
158        Self {
159            service: service.into(),
160            user: user.into(),
161        }
162    }
163
164    /// Store a credential using the specified mode.
165    ///
166    /// # Arguments
167    /// * `value` - The credential value to store
168    /// * `mode` - The storage mode to use
169    pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
170        match mode.effective_mode() {
171            AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
172                Ok(()) => {
173                    let _ = self.clear_file();
174                    Ok(())
175                }
176                Err(err) => {
177                    tracing::warn!(
178                        "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
179                        self.service,
180                        self.user,
181                        err
182                    );
183                    self.store_file(value)
184                        .context("failed to store credential in encrypted file")
185                }
186            },
187            AuthCredentialsStoreMode::File => self.store_file(value),
188            _ => unreachable!(),
189        }
190    }
191
192    /// Store a credential using the default mode (keyring).
193    pub fn store(&self, value: &str) -> Result<()> {
194        self.store_keyring(value)
195    }
196
197    /// Store credential in OS keyring.
198    fn store_keyring(&self, value: &str) -> Result<()> {
199        let entry =
200            keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
201
202        entry
203            .set_password(value)
204            .context("Failed to store credential in OS keyring")?;
205
206        tracing::debug!(
207            "Credential stored in OS keyring for {}/{}",
208            self.service,
209            self.user
210        );
211        Ok(())
212    }
213
214    /// Load a credential using the specified mode.
215    ///
216    /// Returns `None` if no credential exists.
217    pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
218        match mode.effective_mode() {
219            AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
220                Ok(Some(value)) => Ok(Some(value)),
221                Ok(None) => self.load_file(),
222                Err(err) => {
223                    tracing::warn!(
224                        "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
225                        self.service,
226                        self.user,
227                        err
228                    );
229                    self.load_file()
230                }
231            },
232            AuthCredentialsStoreMode::File => self.load_file(),
233            _ => unreachable!(),
234        }
235    }
236
237    /// Load a credential using the default mode (keyring).
238    ///
239    /// Returns `None` if no credential exists.
240    pub fn load(&self) -> Result<Option<String>> {
241        self.load_keyring()
242    }
243
244    /// Load credential from OS keyring.
245    fn load_keyring(&self) -> Result<Option<String>> {
246        let entry = match keyring_entry(&self.service, &self.user) {
247            Ok(e) => e,
248            Err(_) => return Ok(None),
249        };
250
251        match entry.get_password() {
252            Ok(value) => Ok(Some(value)),
253            Err(keyring_core::Error::NoEntry) => Ok(None),
254            Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
255        }
256    }
257
258    /// Clear (delete) a credential using the specified mode.
259    pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
260        match mode.effective_mode() {
261            AuthCredentialsStoreMode::Keyring => {
262                let mut errors = Vec::new();
263
264                if let Err(err) = self.clear_keyring() {
265                    errors.push(err.to_string());
266                }
267                if let Err(err) = self.clear_file() {
268                    errors.push(err.to_string());
269                }
270
271                if errors.is_empty() {
272                    Ok(())
273                } else {
274                    Err(anyhow!(
275                        "Failed to clear credential from secure storage: {}",
276                        errors.join("; ")
277                    ))
278                }
279            }
280            AuthCredentialsStoreMode::File => self.clear_file(),
281            _ => unreachable!(),
282        }
283    }
284
285    /// Clear (delete) a credential using the default mode.
286    pub fn clear(&self) -> Result<()> {
287        self.clear_keyring()
288    }
289
290    /// Clear credential from OS keyring.
291    fn clear_keyring(&self) -> Result<()> {
292        let entry = match keyring_entry(&self.service, &self.user) {
293            Ok(e) => e,
294            Err(_) => return Ok(()),
295        };
296
297        match entry.delete_credential() {
298            Ok(_) => {
299                tracing::debug!(
300                    "Credential cleared from keyring for {}/{}",
301                    self.service,
302                    self.user
303                );
304            }
305            Err(keyring_core::Error::NoEntry) => {}
306            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
307        }
308
309        Ok(())
310    }
311
312    fn store_file(&self, value: &str) -> Result<()> {
313        let path = self.file_path()?;
314        let encrypted = encrypt_credential(value)?;
315        let payload = serde_json::to_vec_pretty(&encrypted)
316            .context("failed to serialize encrypted credential")?;
317        write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
318
319        Ok(())
320    }
321
322    fn load_file(&self) -> Result<Option<String>> {
323        let path = self.file_path()?;
324        let data = match fs::read(&path) {
325            Ok(data) => data,
326            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
327            Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
328        };
329
330        let encrypted: EncryptedCredential =
331            serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
332        decrypt_credential(&encrypted).map(Some)
333    }
334
335    fn clear_file(&self) -> Result<()> {
336        let path = self.file_path()?;
337        match fs::remove_file(path) {
338            Ok(()) => Ok(()),
339            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
340            Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
341        }
342    }
343
344    fn file_path(&self) -> Result<std::path::PathBuf> {
345        use sha2::Digest as _;
346
347        let mut hasher = sha2::Sha256::new();
348        hasher.update(self.service.as_bytes());
349        hasher.update([0]);
350        hasher.update(self.user.as_bytes());
351        let digest = hasher.finalize();
352        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
353
354        Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
355    }
356}
357
358/// Custom API Key storage for provider-specific keys.
359///
360/// Provides secure storage and retrieval of API keys for custom providers
361/// using the OS keyring or encrypted file storage.
362pub struct CustomApiKeyStorage {
363    provider: String,
364    storage: CredentialStorage,
365}
366
367impl CustomApiKeyStorage {
368    /// Create a new custom API key storage for a specific provider.
369    ///
370    /// # Arguments
371    /// * `provider` - The provider identifier (e.g., "openrouter", "anthropic", "custom_provider")
372    pub fn new(provider: &str) -> Self {
373        let normalized_provider = provider.to_lowercase();
374        Self {
375            provider: normalized_provider.clone(),
376            storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
377        }
378    }
379
380    /// Store an API key securely.
381    ///
382    /// # Arguments
383    /// * `api_key` - The API key value to store
384    /// * `mode` - The storage mode to use (defaults to keyring)
385    pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
386        self.storage.store_with_mode(api_key, mode)?;
387        clear_legacy_auth_file_if_matches(&self.provider)?;
388        Ok(())
389    }
390
391    /// Retrieve a stored API key.
392    ///
393    /// Returns `None` if no key is stored.
394    pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
395        if let Some(key) = self.storage.load_with_mode(mode)? {
396            return Ok(Some(key));
397        }
398
399        self.load_legacy_auth_json(mode)
400    }
401
402    /// Clear (delete) a stored API key.
403    pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
404        self.storage.clear_with_mode(mode)?;
405        clear_legacy_auth_file_if_matches(&self.provider)?;
406        Ok(())
407    }
408
409    fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
410        let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
411            return Ok(None);
412        };
413
414        if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
415            tracing::warn!(
416                "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
417                self.provider,
418                err
419            );
420            return Ok(Some(legacy.api_key));
421        }
422
423        clear_legacy_auth_file_if_matches(&self.provider)?;
424        tracing::warn!(
425            "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
426            self.provider
427        );
428        Ok(Some(legacy.api_key))
429    }
430}
431
432fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
433    let key = derive_file_encryption_key()?;
434    let rng = SystemRandom::new();
435    let mut nonce_bytes = [0_u8; NONCE_LEN];
436    rng.fill(&mut nonce_bytes)
437        .map_err(|_| anyhow!("failed to generate credential nonce"))?;
438
439    let mut ciphertext = value.as_bytes().to_vec();
440    key.seal_in_place_append_tag(
441        Nonce::assume_unique_for_key(nonce_bytes),
442        Aad::empty(),
443        &mut ciphertext,
444    )
445    .map_err(|_| anyhow!("failed to encrypt credential"))?;
446
447    Ok(EncryptedCredential {
448        nonce: STANDARD.encode(nonce_bytes),
449        ciphertext: STANDARD.encode(ciphertext),
450        version: ENCRYPTED_CREDENTIAL_VERSION,
451    })
452}
453
454fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
455    if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
456        return Err(anyhow!("unsupported encrypted credential format"));
457    }
458
459    let nonce_bytes = STANDARD
460        .decode(&encrypted.nonce)
461        .context("failed to decode credential nonce")?;
462    let nonce_array: [u8; NONCE_LEN] = nonce_bytes
463        .try_into()
464        .map_err(|_| anyhow!("invalid credential nonce length"))?;
465    let mut ciphertext = STANDARD
466        .decode(&encrypted.ciphertext)
467        .context("failed to decode credential ciphertext")?;
468
469    let key = derive_file_encryption_key()?;
470    let plaintext = key
471        .open_in_place(
472            Nonce::assume_unique_for_key(nonce_array),
473            Aad::empty(),
474            &mut ciphertext,
475        )
476        .map_err(|_| anyhow!("failed to decrypt credential"))?;
477
478    String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
479}
480
481fn derive_file_encryption_key() -> Result<LessSafeKey> {
482    use ring::digest::SHA256;
483    use ring::digest::digest;
484
485    let mut key_material = Vec::new();
486    if let Ok(hostname) = hostname::get() {
487        key_material.extend_from_slice(hostname.as_encoded_bytes());
488    }
489
490    #[cfg(unix)]
491    {
492        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
493    }
494    #[cfg(not(unix))]
495    {
496        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
497            key_material.extend_from_slice(user.as_bytes());
498        }
499    }
500
501    key_material.extend_from_slice(b"vtcode-credentials-v1");
502
503    let hash = digest(&SHA256, &key_material);
504    let key_bytes: &[u8; 32] = hash.as_ref()[..32]
505        .try_into()
506        .context("credential encryption key was too short")?;
507    let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
508        .map_err(|_| anyhow!("invalid credential encryption key"))?;
509    Ok(LessSafeKey::new(unbound))
510}
511
512fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
513    let path = legacy_auth_storage_path()?;
514    let data = match fs::read(&path) {
515        Ok(data) => data,
516        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
517        Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
518    };
519
520    let legacy: LegacyAuthFile =
521        serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
522    let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
523    let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
524    let has_key = !legacy.api_key.trim().is_empty();
525
526    if matches_provider && stores_api_key && has_key {
527        Ok(Some(legacy))
528    } else {
529        Ok(None)
530    }
531}
532
533fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
534    let path = legacy_auth_storage_path()?;
535    let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
536        return Ok(());
537    };
538
539    match fs::remove_file(path) {
540        Ok(()) => Ok(()),
541        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
542        Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
543    }
544}
545
546/// Migrate plain-text API keys from config to secure storage.
547///
548/// This function reads API keys from the provided BTreeMap and stores them
549/// securely using the specified storage mode. After migration, the keys
550/// should be removed from the config file.
551///
552/// # Arguments
553/// * `custom_api_keys` - Map of provider names to API keys (from config)
554/// * `mode` - The storage mode to use
555///
556/// # Returns
557/// A map of providers that were successfully migrated (for tracking purposes)
558pub fn migrate_custom_api_keys_to_keyring(
559    custom_api_keys: &BTreeMap<String, String>,
560    mode: AuthCredentialsStoreMode,
561) -> Result<BTreeMap<String, bool>> {
562    let mut migration_results = BTreeMap::new();
563
564    for (provider, api_key) in custom_api_keys {
565        let storage = CustomApiKeyStorage::new(provider);
566        match storage.store(api_key, mode) {
567            Ok(()) => {
568                tracing::info!(
569                    "Migrated API key for provider '{}' to secure storage",
570                    provider
571                );
572                migration_results.insert(provider.clone(), true);
573            }
574            Err(e) => {
575                tracing::warn!(
576                    "Failed to migrate API key for provider '{}': {}",
577                    provider,
578                    e
579                );
580                migration_results.insert(provider.clone(), false);
581            }
582        }
583    }
584
585    Ok(migration_results)
586}
587
588/// Load all custom API keys from secure storage.
589///
590/// This function retrieves API keys for all providers that have keys stored.
591///
592/// # Arguments
593/// * `providers` - List of provider names to check for stored keys
594/// * `mode` - The storage mode to use
595///
596/// # Returns
597/// A BTreeMap of provider names to their API keys (only includes providers with stored keys)
598pub fn load_custom_api_keys(
599    providers: &[String],
600    mode: AuthCredentialsStoreMode,
601) -> Result<BTreeMap<String, String>> {
602    let mut api_keys = BTreeMap::new();
603
604    for provider in providers {
605        let storage = CustomApiKeyStorage::new(provider);
606        if let Some(key) = storage.load(mode)? {
607            api_keys.insert(provider.clone(), key);
608        }
609    }
610
611    Ok(api_keys)
612}
613
614/// Clear all custom API keys from secure storage.
615///
616/// # Arguments
617/// * `providers` - List of provider names to clear
618/// * `mode` - The storage mode to use
619pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
620    for provider in providers {
621        let storage = CustomApiKeyStorage::new(provider);
622        if let Err(e) = storage.clear(mode) {
623            tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
624        }
625    }
626    Ok(())
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use assert_fs::TempDir;
633    use serial_test::serial;
634
635    struct TestAuthDirGuard {
636        temp_dir: Option<TempDir>,
637        previous: Option<std::path::PathBuf>,
638    }
639
640    impl TestAuthDirGuard {
641        fn new() -> Self {
642            let temp_dir = TempDir::new().expect("create temp auth dir");
643            let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
644                .expect("read auth dir override");
645            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
646                temp_dir.path().to_path_buf(),
647            ))
648            .expect("set auth dir override");
649
650            Self {
651                temp_dir: Some(temp_dir),
652                previous,
653            }
654        }
655    }
656
657    impl Drop for TestAuthDirGuard {
658        fn drop(&mut self) {
659            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
660                .expect("restore auth dir override");
661            if let Some(temp_dir) = self.temp_dir.take() {
662                temp_dir.close().expect("remove temp auth dir");
663            }
664        }
665    }
666
667    #[test]
668    fn test_storage_mode_default_is_keyring() {
669        assert_eq!(
670            AuthCredentialsStoreMode::default(),
671            AuthCredentialsStoreMode::Keyring
672        );
673    }
674
675    #[test]
676    fn test_storage_mode_effective_mode() {
677        assert_eq!(
678            AuthCredentialsStoreMode::Keyring.effective_mode(),
679            AuthCredentialsStoreMode::Keyring
680        );
681        assert_eq!(
682            AuthCredentialsStoreMode::File.effective_mode(),
683            AuthCredentialsStoreMode::File
684        );
685
686        // Auto should resolve to either Keyring or File
687        let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
688        assert!(
689            auto_mode == AuthCredentialsStoreMode::Keyring
690                || auto_mode == AuthCredentialsStoreMode::File
691        );
692    }
693
694    #[test]
695    fn test_storage_mode_serialization() {
696        let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
697        assert_eq!(keyring_json, "\"keyring\"");
698
699        let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
700        assert_eq!(file_json, "\"file\"");
701
702        let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
703        assert_eq!(auto_json, "\"auto\"");
704
705        // Test deserialization
706        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
707        assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
708
709        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
710        assert_eq!(parsed, AuthCredentialsStoreMode::File);
711
712        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
713        assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
714    }
715
716    #[test]
717    fn test_credential_storage_new() {
718        let storage = CredentialStorage::new("vtcode", "test_key");
719        assert_eq!(storage.service, "vtcode");
720        assert_eq!(storage.user, "test_key");
721    }
722
723    #[test]
724    fn test_is_keyring_functional_check() {
725        // This test just verifies the function doesn't panic
726        // The actual result depends on the OS environment
727        let _functional = is_keyring_functional();
728    }
729
730    #[test]
731    #[serial]
732    fn credential_storage_file_mode_round_trips_without_plaintext() {
733        let _guard = TestAuthDirGuard::new();
734        let storage = CredentialStorage::new("vtcode", "test_key");
735
736        storage
737            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
738            .expect("store encrypted credential");
739
740        let loaded = storage
741            .load_with_mode(AuthCredentialsStoreMode::File)
742            .expect("load encrypted credential");
743        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
744
745        let stored = fs::read_to_string(storage.file_path().expect("credential path"))
746            .expect("read encrypted credential file");
747        assert!(!stored.contains("secret_api_key"));
748    }
749
750    #[test]
751    #[serial]
752    fn keyring_mode_load_falls_back_to_encrypted_file() {
753        let _guard = TestAuthDirGuard::new();
754        let storage = CredentialStorage::new("vtcode", "test_key");
755
756        storage
757            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
758            .expect("store encrypted credential");
759
760        let loaded = storage
761            .load_with_mode(AuthCredentialsStoreMode::Keyring)
762            .expect("load credential");
763        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
764    }
765
766    #[test]
767    #[serial]
768    #[cfg(unix)]
769    fn credential_storage_file_mode_uses_private_permissions() {
770        use std::os::unix::fs::PermissionsExt;
771
772        let _guard = TestAuthDirGuard::new();
773        let storage = CredentialStorage::new("vtcode", "test_key");
774
775        storage
776            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
777            .expect("store encrypted credential");
778
779        let metadata = fs::metadata(storage.file_path().expect("credential path"))
780            .expect("read credential metadata");
781        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
782    }
783
784    #[test]
785    #[serial]
786    #[cfg(unix)]
787    fn credential_storage_file_mode_restricts_existing_file_permissions() {
788        use std::os::unix::fs::PermissionsExt;
789
790        let _guard = TestAuthDirGuard::new();
791        let storage = CredentialStorage::new("vtcode", "test_key");
792
793        storage
794            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
795            .expect("store initial credential");
796
797        let path = storage.file_path().expect("credential path");
798        fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
799            .expect("broaden existing credential permissions");
800
801        storage
802            .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
803            .expect("rewrite credential");
804
805        let metadata = fs::metadata(path).expect("read credential metadata");
806        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
807    }
808
809    #[test]
810    #[serial]
811    fn custom_api_key_load_migrates_legacy_auth_json() {
812        let _guard = TestAuthDirGuard::new();
813        let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
814        fs::write(
815            &legacy_path,
816            r#"{
817  "version": 1,
818  "mode": "api_key",
819  "provider": "openai",
820  "api_key": "legacy-secret",
821  "authenticated_at": 1768406185
822}"#,
823        )
824        .expect("write legacy auth file");
825
826        let storage = CustomApiKeyStorage::new("openai");
827        let loaded = storage
828            .load(AuthCredentialsStoreMode::File)
829            .expect("load migrated api key");
830        assert_eq!(loaded.as_deref(), Some("legacy-secret"));
831        assert!(!legacy_path.exists());
832
833        let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
834            .expect("read migrated credential file");
835        assert!(!encrypted.contains("legacy-secret"));
836    }
837}