Skip to main content

vtcode_auth/credentials/
storage.rs

1//! Generic credential storage that orchestrates the keyring and file backends.
2
3use anyhow::{Context, Result, anyhow};
4use base64::Engine;
5use std::fs;
6
7use super::encryption;
8use super::keyring;
9use super::mode::AuthCredentialsStoreMode;
10use crate::storage_paths::auth_storage_dir;
11use crate::storage_paths::write_private_file;
12
13/// Generic credential storage interface.
14///
15/// Provides methods to store, load, and clear credentials using either
16/// the OS keyring or file-based storage.
17pub struct CredentialStorage {
18    service: String,
19    user: String,
20}
21
22impl CredentialStorage {
23    /// Create a new credential storage handle.
24    pub(crate) fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
25        Self { service: service.into(), user: user.into() }
26    }
27
28    /// Store a credential using the specified mode.
29    pub(crate) fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
30        match mode.effective_mode() {
31            AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
32                Ok(()) => {
33                    if let Err(err) = self.store_file(value) {
34                        tracing::warn!(
35                            "Failed to write encrypted file backup for {}/{}: {}",
36                            self.service,
37                            self.user,
38                            err
39                        );
40                    }
41                    Ok(())
42                }
43                Err(err) => {
44                    tracing::warn!(
45                        "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
46                        self.service,
47                        self.user,
48                        err
49                    );
50                    self.store_file(value).context("failed to store credential in encrypted file")
51                }
52            },
53            AuthCredentialsStoreMode::File => self.store_file(value),
54            AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
55        }
56    }
57
58    /// Store a credential in exactly the selected backend.
59    ///
60    /// Provider-specific storage adapters use this boundary when their public
61    /// API promises that an operation affects only the configured backend.
62    /// Unlike [`Self::store_with_mode`], this method never falls back or writes
63    /// a backup to another backend.
64    pub(crate) fn store_exact_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
65        match mode.effective_mode() {
66            AuthCredentialsStoreMode::Keyring => self.store_keyring(value),
67            AuthCredentialsStoreMode::File => self.store_file(value),
68            AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
69        }
70    }
71
72    /// Store a serializable value using the shared credential backends.
73    pub(crate) fn store_json<T: serde::Serialize>(&self, value: &T, mode: AuthCredentialsStoreMode) -> Result<()> {
74        let serialized = serde_json::to_string(value).context("failed to serialize credential")?;
75        self.store_with_mode(&serialized, mode)
76    }
77
78    /// Store a serializable value in exactly the selected backend.
79    pub(crate) fn store_json_exact_with_mode<T: serde::Serialize>(
80        &self,
81        value: &T,
82        mode: AuthCredentialsStoreMode,
83    ) -> Result<()> {
84        let serialized = serde_json::to_string(value).context("failed to serialize credential")?;
85        self.store_exact_with_mode(&serialized, mode)
86    }
87
88    /// Store a credential using `Auto` mode.
89    pub fn store(&self, value: &str) -> Result<()> {
90        self.store_with_mode(value, AuthCredentialsStoreMode::Auto)
91    }
92
93    /// Load a credential using the specified mode.
94    pub(crate) fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
95        match mode.effective_mode() {
96            AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
97                Ok(Some(value)) => Ok(Some(value)),
98                Ok(None) => self.load_file(),
99                Err(err) => {
100                    tracing::warn!(
101                        "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
102                        self.service,
103                        self.user,
104                        err
105                    );
106                    self.load_file()
107                }
108            },
109            AuthCredentialsStoreMode::File => self.load_file(),
110            AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
111        }
112    }
113
114    /// Load a credential from exactly the selected backend.
115    ///
116    /// This deliberately does not fall back to another backend. Callers that
117    /// want a preferred-backend lookup must compose that policy explicitly.
118    pub(crate) fn load_exact_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
119        match mode.effective_mode() {
120            AuthCredentialsStoreMode::Keyring => self.load_keyring(),
121            AuthCredentialsStoreMode::File => self.load_file(),
122            AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
123        }
124    }
125
126    /// Load and deserialize a value from the shared credential backends.
127    pub(crate) fn load_json<T: serde::de::DeserializeOwned>(
128        &self,
129        mode: AuthCredentialsStoreMode,
130    ) -> Result<Option<T>> {
131        let Some(serialized) = self.load_with_mode(mode)? else {
132            return Ok(None);
133        };
134        serde_json::from_str(&serialized)
135            .context("failed to deserialize credential")
136            .map(Some)
137    }
138
139    /// Load and deserialize a value from exactly the selected backend.
140    pub(crate) fn load_json_exact_with_mode<T: serde::de::DeserializeOwned>(
141        &self,
142        mode: AuthCredentialsStoreMode,
143    ) -> Result<Option<T>> {
144        let Some(serialized) = self.load_exact_with_mode(mode)? else {
145            return Ok(None);
146        };
147        serde_json::from_str(&serialized)
148            .context("failed to deserialize credential")
149            .map(Some)
150    }
151
152    /// Load a credential using `Auto` mode.
153    pub fn load(&self) -> Result<Option<String>> {
154        self.load_with_mode(AuthCredentialsStoreMode::Auto)
155    }
156
157    /// Clear (delete) a credential using the specified mode.
158    pub(crate) fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
159        match mode.effective_mode() {
160            AuthCredentialsStoreMode::Keyring => {
161                let mut errors = Vec::new();
162
163                if let Err(err) = self.clear_keyring() {
164                    errors.push(err.to_string());
165                }
166                if let Err(err) = self.clear_file() {
167                    errors.push(err.to_string());
168                }
169
170                if errors.is_empty() {
171                    Ok(())
172                } else {
173                    Err(anyhow!("Failed to clear credential from secure storage: {}", errors.join("; ")))
174                }
175            }
176            AuthCredentialsStoreMode::File => self.clear_file(),
177            AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
178        }
179    }
180
181    /// Clear a credential from exactly the selected backend.
182    ///
183    /// This is the deletion counterpart to [`Self::store_exact_with_mode`].
184    /// It is intentionally separate from [`Self::clear_with_mode`], whose
185    /// keyring branch also removes the encrypted backup written by the generic
186    /// storage policy.
187    pub(crate) fn clear_exact_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
188        match mode.effective_mode() {
189            AuthCredentialsStoreMode::Keyring => self.clear_keyring(),
190            AuthCredentialsStoreMode::File => self.clear_file(),
191            AuthCredentialsStoreMode::Auto => unreachable!("effective_mode() resolves Auto"),
192        }
193    }
194
195    /// Clear a credential using `Auto` mode.
196    pub fn clear(&self) -> Result<()> {
197        self.clear_with_mode(AuthCredentialsStoreMode::Auto)
198    }
199
200    // ------------------------------------------------------------------
201    // Private backend helpers
202    // ------------------------------------------------------------------
203
204    fn store_keyring(&self, value: &str) -> Result<()> {
205        let entry = keyring::entry(&self.service, &self.user).context("Failed to access OS keyring")?;
206        entry.set_password(value).context("Failed to store credential in OS keyring")?;
207        tracing::debug!("Credential stored in OS keyring for {}/{}", self.service, self.user);
208        Ok(())
209    }
210
211    fn load_keyring(&self) -> Result<Option<String>> {
212        let entry = match keyring::entry(&self.service, &self.user) {
213            Ok(e) => e,
214            Err(_) => return Ok(None),
215        };
216
217        match entry.get_password() {
218            Ok(value) => Ok(Some(value)),
219            Err(keyring_core::Error::NoEntry) => Ok(None),
220            Err(e) => Err(anyhow!("Failed to read from keyring: {e}")),
221        }
222    }
223
224    fn clear_keyring(&self) -> Result<()> {
225        let entry = match keyring::entry(&self.service, &self.user) {
226            Ok(e) => e,
227            Err(_) => return Ok(()),
228        };
229
230        match entry.delete_credential() {
231            Ok(_) => {
232                tracing::debug!("Credential cleared from keyring for {}/{}", self.service, self.user);
233            }
234            Err(keyring_core::Error::NoEntry) => {}
235            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
236        }
237
238        Ok(())
239    }
240
241    fn store_file(&self, value: &str) -> Result<()> {
242        let path = self.file_path()?;
243        let encrypted = encryption::encrypt(value)?;
244        let payload = serde_json::to_vec_pretty(&encrypted).context("failed to serialize encrypted credential")?;
245        write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
246        Ok(())
247    }
248
249    fn load_file(&self) -> Result<Option<String>> {
250        let path = self.file_path()?;
251        let data = match fs::read(&path) {
252            Ok(data) => data,
253            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
254            Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
255        };
256
257        let encrypted: encryption::EncryptedCredential =
258            serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
259        encryption::decrypt(&encrypted).map(Some)
260    }
261
262    fn clear_file(&self) -> Result<()> {
263        let path = self.file_path()?;
264        match fs::remove_file(path) {
265            Ok(()) => Ok(()),
266            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
267            Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
268        }
269    }
270
271    fn file_path(&self) -> Result<std::path::PathBuf> {
272        use sha2::Digest as _;
273
274        let mut hasher = sha2::Sha256::new();
275        hasher.update(self.service.as_bytes());
276        hasher.update([0]);
277        hasher.update(self.user.as_bytes());
278        let digest = hasher.finalize();
279        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
280
281        Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
282    }
283
284    #[cfg(test)]
285    pub(crate) fn file_path_for_tests(&self) -> Result<std::path::PathBuf> {
286        self.file_path()
287    }
288}