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
131fn ensure_native_keyring_store() -> keyring_core::Result<()> {
132    if keyring_core::get_default_store().is_some() {
133        return Ok(());
134    }
135
136    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
137    let store = dbus_secret_service_keyring_store::Store::new_with_configuration(
138        &std::collections::HashMap::new(),
139    )?;
140
141    #[cfg(target_os = "macos")]
142    let store = apple_native_keyring_store::keychain::Store::new_with_configuration(
143        &std::collections::HashMap::new(),
144    )?;
145
146    #[cfg(target_os = "windows")]
147    let store = windows_native_keyring_store::Store::new_with_configuration(
148        &std::collections::HashMap::new(),
149    )?;
150
151    #[cfg(not(any(
152        target_os = "linux",
153        target_os = "freebsd",
154        target_os = "macos",
155        target_os = "windows"
156    )))]
157    {
158        return Err(keyring_core::Error::NotSupportedByStore(
159            "VT Code does not have a native keyring store configured for this platform".to_string(),
160        ));
161    }
162
163    keyring_core::set_default_store(store);
164    Ok(())
165}
166
167pub(crate) fn keyring_entry(
168    service: &str,
169    user: &str,
170) -> keyring_core::Result<keyring_core::Entry> {
171    if keyring_core::get_default_store().is_none() {
172        ensure_native_keyring_store()?;
173    }
174
175    keyring_core::Entry::new(service, user)
176}
177
178/// Generic credential storage interface.
179///
180/// Provides methods to store, load, and clear credentials using either
181/// the OS keyring or file-based storage.
182pub struct CredentialStorage {
183    service: String,
184    user: String,
185}
186
187impl CredentialStorage {
188    /// Create a new credential storage handle.
189    ///
190    /// # Arguments
191    /// * `service` - The service name (e.g., "vtcode", "openrouter", "github")
192    /// * `user` - The user/account identifier (e.g., "api_key", "oauth_token")
193    pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
194        Self {
195            service: service.into(),
196            user: user.into(),
197        }
198    }
199
200    /// Store a credential using the specified mode.
201    ///
202    /// # Arguments
203    /// * `value` - The credential value to store
204    /// * `mode` - The storage mode to use
205    pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
206        match mode.effective_mode() {
207            AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
208                Ok(()) => {
209                    let _ = self.clear_file();
210                    Ok(())
211                }
212                Err(err) => {
213                    tracing::warn!(
214                        "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
215                        self.service,
216                        self.user,
217                        err
218                    );
219                    self.store_file(value)
220                        .context("failed to store credential in encrypted file")
221                }
222            },
223            AuthCredentialsStoreMode::File => self.store_file(value),
224            _ => unreachable!(),
225        }
226    }
227
228    /// Store a credential using the default mode (keyring).
229    pub fn store(&self, value: &str) -> Result<()> {
230        self.store_keyring(value)
231    }
232
233    /// Store credential in OS keyring.
234    fn store_keyring(&self, value: &str) -> Result<()> {
235        let entry =
236            keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
237
238        entry
239            .set_password(value)
240            .context("Failed to store credential in OS keyring")?;
241
242        tracing::debug!(
243            "Credential stored in OS keyring for {}/{}",
244            self.service,
245            self.user
246        );
247        Ok(())
248    }
249
250    /// Load a credential using the specified mode.
251    ///
252    /// Returns `None` if no credential exists.
253    pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
254        match mode.effective_mode() {
255            AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
256                Ok(Some(value)) => Ok(Some(value)),
257                Ok(None) => self.load_file(),
258                Err(err) => {
259                    tracing::warn!(
260                        "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
261                        self.service,
262                        self.user,
263                        err
264                    );
265                    self.load_file()
266                }
267            },
268            AuthCredentialsStoreMode::File => self.load_file(),
269            _ => unreachable!(),
270        }
271    }
272
273    /// Load a credential using the default mode (keyring).
274    ///
275    /// Returns `None` if no credential exists.
276    pub fn load(&self) -> Result<Option<String>> {
277        self.load_keyring()
278    }
279
280    /// Load credential from OS keyring.
281    fn load_keyring(&self) -> Result<Option<String>> {
282        let entry = match keyring_entry(&self.service, &self.user) {
283            Ok(e) => e,
284            Err(_) => return Ok(None),
285        };
286
287        match entry.get_password() {
288            Ok(value) => Ok(Some(value)),
289            Err(keyring_core::Error::NoEntry) => Ok(None),
290            Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
291        }
292    }
293
294    /// Clear (delete) a credential using the specified mode.
295    pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
296        match mode.effective_mode() {
297            AuthCredentialsStoreMode::Keyring => {
298                let mut errors = Vec::new();
299
300                if let Err(err) = self.clear_keyring() {
301                    errors.push(err.to_string());
302                }
303                if let Err(err) = self.clear_file() {
304                    errors.push(err.to_string());
305                }
306
307                if errors.is_empty() {
308                    Ok(())
309                } else {
310                    Err(anyhow!(
311                        "Failed to clear credential from secure storage: {}",
312                        errors.join("; ")
313                    ))
314                }
315            }
316            AuthCredentialsStoreMode::File => self.clear_file(),
317            _ => unreachable!(),
318        }
319    }
320
321    /// Clear (delete) a credential using the default mode.
322    pub fn clear(&self) -> Result<()> {
323        self.clear_keyring()
324    }
325
326    /// Clear credential from OS keyring.
327    fn clear_keyring(&self) -> Result<()> {
328        let entry = match keyring_entry(&self.service, &self.user) {
329            Ok(e) => e,
330            Err(_) => return Ok(()),
331        };
332
333        match entry.delete_credential() {
334            Ok(_) => {
335                tracing::debug!(
336                    "Credential cleared from keyring for {}/{}",
337                    self.service,
338                    self.user
339                );
340            }
341            Err(keyring_core::Error::NoEntry) => {}
342            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
343        }
344
345        Ok(())
346    }
347
348    fn store_file(&self, value: &str) -> Result<()> {
349        let path = self.file_path()?;
350        let encrypted = encrypt_credential(value)?;
351        let payload = serde_json::to_vec_pretty(&encrypted)
352            .context("failed to serialize encrypted credential")?;
353        write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
354
355        Ok(())
356    }
357
358    fn load_file(&self) -> Result<Option<String>> {
359        let path = self.file_path()?;
360        let data = match fs::read(&path) {
361            Ok(data) => data,
362            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
363            Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
364        };
365
366        let encrypted: EncryptedCredential =
367            serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
368        decrypt_credential(&encrypted).map(Some)
369    }
370
371    fn clear_file(&self) -> Result<()> {
372        let path = self.file_path()?;
373        match fs::remove_file(path) {
374            Ok(()) => Ok(()),
375            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
376            Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
377        }
378    }
379
380    fn file_path(&self) -> Result<std::path::PathBuf> {
381        use sha2::Digest as _;
382
383        let mut hasher = sha2::Sha256::new();
384        hasher.update(self.service.as_bytes());
385        hasher.update([0]);
386        hasher.update(self.user.as_bytes());
387        let digest = hasher.finalize();
388        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
389
390        Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
391    }
392}
393
394/// Custom API Key storage for provider-specific keys.
395///
396/// Provides secure storage and retrieval of API keys for custom providers
397/// using the OS keyring or encrypted file storage.
398pub struct CustomApiKeyStorage {
399    provider: String,
400    storage: CredentialStorage,
401}
402
403impl CustomApiKeyStorage {
404    /// Create a new custom API key storage for a specific provider.
405    ///
406    /// # Arguments
407    /// * `provider` - The provider identifier (e.g., "openrouter", "anthropic", "custom_provider")
408    pub fn new(provider: &str) -> Self {
409        let normalized_provider = provider.to_lowercase();
410        Self {
411            provider: normalized_provider.clone(),
412            storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
413        }
414    }
415
416    /// Store an API key securely.
417    ///
418    /// # Arguments
419    /// * `api_key` - The API key value to store
420    /// * `mode` - The storage mode to use (defaults to keyring)
421    pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
422        self.storage.store_with_mode(api_key, mode)?;
423        clear_legacy_auth_file_if_matches(&self.provider)?;
424        Ok(())
425    }
426
427    /// Retrieve a stored API key.
428    ///
429    /// Returns `None` if no key is stored.
430    pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
431        if let Some(key) = self.storage.load_with_mode(mode)? {
432            return Ok(Some(key));
433        }
434
435        self.load_legacy_auth_json(mode)
436    }
437
438    /// Clear (delete) a stored API key.
439    pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
440        self.storage.clear_with_mode(mode)?;
441        clear_legacy_auth_file_if_matches(&self.provider)?;
442        Ok(())
443    }
444
445    fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
446        let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
447            return Ok(None);
448        };
449
450        if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
451            tracing::warn!(
452                "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
453                self.provider,
454                err
455            );
456            return Ok(Some(legacy.api_key));
457        }
458
459        clear_legacy_auth_file_if_matches(&self.provider)?;
460        tracing::warn!(
461            "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
462            self.provider
463        );
464        Ok(Some(legacy.api_key))
465    }
466}
467
468fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
469    let key = derive_file_encryption_key()?;
470    let rng = SystemRandom::new();
471    let mut nonce_bytes = [0_u8; NONCE_LEN];
472    rng.fill(&mut nonce_bytes)
473        .map_err(|_| anyhow!("failed to generate credential nonce"))?;
474
475    let mut ciphertext = value.as_bytes().to_vec();
476    key.seal_in_place_append_tag(
477        Nonce::assume_unique_for_key(nonce_bytes),
478        Aad::empty(),
479        &mut ciphertext,
480    )
481    .map_err(|_| anyhow!("failed to encrypt credential"))?;
482
483    Ok(EncryptedCredential {
484        nonce: STANDARD.encode(nonce_bytes),
485        ciphertext: STANDARD.encode(ciphertext),
486        version: ENCRYPTED_CREDENTIAL_VERSION,
487    })
488}
489
490fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
491    if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
492        return Err(anyhow!("unsupported encrypted credential format"));
493    }
494
495    let nonce_bytes = STANDARD
496        .decode(&encrypted.nonce)
497        .context("failed to decode credential nonce")?;
498    let nonce_array: [u8; NONCE_LEN] = nonce_bytes
499        .try_into()
500        .map_err(|_| anyhow!("invalid credential nonce length"))?;
501    let mut ciphertext = STANDARD
502        .decode(&encrypted.ciphertext)
503        .context("failed to decode credential ciphertext")?;
504
505    let key = derive_file_encryption_key()?;
506    let plaintext = key
507        .open_in_place(
508            Nonce::assume_unique_for_key(nonce_array),
509            Aad::empty(),
510            &mut ciphertext,
511        )
512        .map_err(|_| anyhow!("failed to decrypt credential"))?;
513
514    String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
515}
516
517fn derive_file_encryption_key() -> Result<LessSafeKey> {
518    use ring::digest::SHA256;
519    use ring::digest::digest;
520
521    let mut key_material = Vec::new();
522    if let Ok(hostname) = hostname::get() {
523        key_material.extend_from_slice(hostname.as_encoded_bytes());
524    }
525
526    #[cfg(unix)]
527    {
528        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
529    }
530    #[cfg(not(unix))]
531    {
532        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
533            key_material.extend_from_slice(user.as_bytes());
534        }
535    }
536
537    key_material.extend_from_slice(b"vtcode-credentials-v1");
538
539    let hash = digest(&SHA256, &key_material);
540    let key_bytes: &[u8; 32] = hash.as_ref()[..32]
541        .try_into()
542        .context("credential encryption key was too short")?;
543    let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
544        .map_err(|_| anyhow!("invalid credential encryption key"))?;
545    Ok(LessSafeKey::new(unbound))
546}
547
548fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
549    let path = legacy_auth_storage_path()?;
550    let data = match fs::read(&path) {
551        Ok(data) => data,
552        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
553        Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
554    };
555
556    let legacy: LegacyAuthFile =
557        serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
558    let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
559    let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
560    let has_key = !legacy.api_key.trim().is_empty();
561
562    if matches_provider && stores_api_key && has_key {
563        Ok(Some(legacy))
564    } else {
565        Ok(None)
566    }
567}
568
569fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
570    let path = legacy_auth_storage_path()?;
571    let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
572        return Ok(());
573    };
574
575    match fs::remove_file(path) {
576        Ok(()) => Ok(()),
577        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
578        Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
579    }
580}
581
582/// Migrate plain-text API keys from config to secure storage.
583///
584/// This function reads API keys from the provided BTreeMap and stores them
585/// securely using the specified storage mode. After migration, the keys
586/// should be removed from the config file.
587///
588/// # Arguments
589/// * `custom_api_keys` - Map of provider names to API keys (from config)
590/// * `mode` - The storage mode to use
591///
592/// # Returns
593/// A map of providers that were successfully migrated (for tracking purposes)
594pub fn migrate_custom_api_keys_to_keyring(
595    custom_api_keys: &BTreeMap<String, String>,
596    mode: AuthCredentialsStoreMode,
597) -> Result<BTreeMap<String, bool>> {
598    let mut migration_results = BTreeMap::new();
599
600    for (provider, api_key) in custom_api_keys {
601        let storage = CustomApiKeyStorage::new(provider);
602        match storage.store(api_key, mode) {
603            Ok(()) => {
604                tracing::info!(
605                    "Migrated API key for provider '{}' to secure storage",
606                    provider
607                );
608                migration_results.insert(provider.clone(), true);
609            }
610            Err(e) => {
611                tracing::warn!(
612                    "Failed to migrate API key for provider '{}': {}",
613                    provider,
614                    e
615                );
616                migration_results.insert(provider.clone(), false);
617            }
618        }
619    }
620
621    Ok(migration_results)
622}
623
624/// Load all custom API keys from secure storage.
625///
626/// This function retrieves API keys for all providers that have keys stored.
627///
628/// # Arguments
629/// * `providers` - List of provider names to check for stored keys
630/// * `mode` - The storage mode to use
631///
632/// # Returns
633/// A BTreeMap of provider names to their API keys (only includes providers with stored keys)
634pub fn load_custom_api_keys(
635    providers: &[String],
636    mode: AuthCredentialsStoreMode,
637) -> Result<BTreeMap<String, String>> {
638    let mut api_keys = BTreeMap::new();
639
640    for provider in providers {
641        let storage = CustomApiKeyStorage::new(provider);
642        if let Some(key) = storage.load(mode)? {
643            api_keys.insert(provider.clone(), key);
644        }
645    }
646
647    Ok(api_keys)
648}
649
650/// Clear all custom API keys from secure storage.
651///
652/// # Arguments
653/// * `providers` - List of provider names to clear
654/// * `mode` - The storage mode to use
655pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
656    for provider in providers {
657        let storage = CustomApiKeyStorage::new(provider);
658        if let Err(e) = storage.clear(mode) {
659            tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
660        }
661    }
662    Ok(())
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use assert_fs::TempDir;
669    use serial_test::serial;
670
671    struct TestAuthDirGuard {
672        temp_dir: Option<TempDir>,
673        previous: Option<std::path::PathBuf>,
674    }
675
676    impl TestAuthDirGuard {
677        fn new() -> Self {
678            let temp_dir = TempDir::new().expect("create temp auth dir");
679            let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
680                .expect("read auth dir override");
681            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
682                temp_dir.path().to_path_buf(),
683            ))
684            .expect("set auth dir override");
685
686            Self {
687                temp_dir: Some(temp_dir),
688                previous,
689            }
690        }
691    }
692
693    impl Drop for TestAuthDirGuard {
694        fn drop(&mut self) {
695            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
696                .expect("restore auth dir override");
697            if let Some(temp_dir) = self.temp_dir.take() {
698                temp_dir.close().expect("remove temp auth dir");
699            }
700        }
701    }
702
703    #[test]
704    fn test_storage_mode_default_is_keyring() {
705        assert_eq!(
706            AuthCredentialsStoreMode::default(),
707            AuthCredentialsStoreMode::Keyring
708        );
709    }
710
711    #[test]
712    fn test_storage_mode_effective_mode() {
713        assert_eq!(
714            AuthCredentialsStoreMode::Keyring.effective_mode(),
715            AuthCredentialsStoreMode::Keyring
716        );
717        assert_eq!(
718            AuthCredentialsStoreMode::File.effective_mode(),
719            AuthCredentialsStoreMode::File
720        );
721
722        // Auto should resolve to either Keyring or File
723        let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
724        assert!(
725            auto_mode == AuthCredentialsStoreMode::Keyring
726                || auto_mode == AuthCredentialsStoreMode::File
727        );
728    }
729
730    #[test]
731    fn test_storage_mode_serialization() {
732        let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
733        assert_eq!(keyring_json, "\"keyring\"");
734
735        let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
736        assert_eq!(file_json, "\"file\"");
737
738        let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
739        assert_eq!(auto_json, "\"auto\"");
740
741        // Test deserialization
742        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
743        assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
744
745        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
746        assert_eq!(parsed, AuthCredentialsStoreMode::File);
747
748        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
749        assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
750    }
751
752    #[test]
753    fn test_credential_storage_new() {
754        let storage = CredentialStorage::new("vtcode", "test_key");
755        assert_eq!(storage.service, "vtcode");
756        assert_eq!(storage.user, "test_key");
757    }
758
759    #[test]
760    fn test_is_keyring_functional_check() {
761        // This test just verifies the function doesn't panic
762        // The actual result depends on the OS environment
763        let _functional = is_keyring_functional();
764    }
765
766    #[test]
767    #[serial]
768    fn credential_storage_file_mode_round_trips_without_plaintext() {
769        let _guard = TestAuthDirGuard::new();
770        let storage = CredentialStorage::new("vtcode", "test_key");
771
772        storage
773            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
774            .expect("store encrypted credential");
775
776        let loaded = storage
777            .load_with_mode(AuthCredentialsStoreMode::File)
778            .expect("load encrypted credential");
779        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
780
781        let stored = fs::read_to_string(storage.file_path().expect("credential path"))
782            .expect("read encrypted credential file");
783        assert!(!stored.contains("secret_api_key"));
784    }
785
786    #[test]
787    #[serial]
788    fn keyring_mode_load_falls_back_to_encrypted_file() {
789        let _guard = TestAuthDirGuard::new();
790        let storage = CredentialStorage::new("vtcode", "test_key");
791
792        storage
793            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
794            .expect("store encrypted credential");
795
796        let loaded = storage
797            .load_with_mode(AuthCredentialsStoreMode::Keyring)
798            .expect("load credential");
799        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
800    }
801
802    #[test]
803    #[serial]
804    #[cfg(unix)]
805    fn credential_storage_file_mode_uses_private_permissions() {
806        use std::os::unix::fs::PermissionsExt;
807
808        let _guard = TestAuthDirGuard::new();
809        let storage = CredentialStorage::new("vtcode", "test_key");
810
811        storage
812            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
813            .expect("store encrypted credential");
814
815        let metadata = fs::metadata(storage.file_path().expect("credential path"))
816            .expect("read credential metadata");
817        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
818    }
819
820    #[test]
821    #[serial]
822    #[cfg(unix)]
823    fn credential_storage_file_mode_restricts_existing_file_permissions() {
824        use std::os::unix::fs::PermissionsExt;
825
826        let _guard = TestAuthDirGuard::new();
827        let storage = CredentialStorage::new("vtcode", "test_key");
828
829        storage
830            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
831            .expect("store initial credential");
832
833        let path = storage.file_path().expect("credential path");
834        fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
835            .expect("broaden existing credential permissions");
836
837        storage
838            .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
839            .expect("rewrite credential");
840
841        let metadata = fs::metadata(path).expect("read credential metadata");
842        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
843    }
844
845    #[test]
846    #[serial]
847    fn custom_api_key_load_migrates_legacy_auth_json() {
848        let _guard = TestAuthDirGuard::new();
849        let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
850        fs::write(
851            &legacy_path,
852            r#"{
853  "version": 1,
854  "mode": "api_key",
855  "provider": "openai",
856  "api_key": "legacy-secret",
857  "authenticated_at": 1768406185
858}"#,
859        )
860        .expect("write legacy auth file");
861
862        let storage = CustomApiKeyStorage::new("openai");
863        let loaded = storage
864            .load(AuthCredentialsStoreMode::File)
865            .expect("load migrated api key");
866        assert_eq!(loaded.as_deref(), Some("legacy-secret"));
867        assert!(!legacy_path.exists());
868
869        let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
870            .expect("read migrated credential file");
871        assert!(!encrypted.contains("legacy-secret"));
872    }
873}