vtcode_auth/credentials/
storage.rs1use anyhow::{Context, Result, anyhow};
4use base64::Engine;
5use std::fs;
6
7use super::encryption;
8use super::keyring;
9use super::mode::AuthCredentialsStoreMode;
10use super::mode::ResolvedStoreMode;
11use crate::storage_paths::{auth_storage_dir, read_private_file, write_private_file};
12
13pub struct CredentialStorage {
18 service: String,
19 user: String,
20}
21
22impl CredentialStorage {
23 pub(crate) fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
25 Self { service: service.into(), user: user.into() }
26 }
27
28 pub(crate) fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
30 match mode.effective_mode() {
31 ResolvedStoreMode::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 ResolvedStoreMode::File => self.store_file(value),
54 }
55 }
56
57 pub(crate) fn store_exact_with_mode(&self, value: &str, mode: ResolvedStoreMode) -> Result<()> {
65 match mode {
66 ResolvedStoreMode::Keyring => self.store_keyring(value),
67 ResolvedStoreMode::File => self.store_file(value),
68 }
69 }
70
71 pub(crate) fn store_json<T: serde::Serialize>(&self, value: &T, mode: AuthCredentialsStoreMode) -> Result<()> {
73 let serialized = serde_json::to_string(value).context("failed to serialize credential")?;
74 self.store_with_mode(&serialized, mode)
75 }
76
77 pub(crate) fn store_json_exact_with_mode<T: serde::Serialize>(
79 &self,
80 value: &T,
81 mode: ResolvedStoreMode,
82 ) -> Result<()> {
83 let serialized = serde_json::to_string(value).context("failed to serialize credential")?;
84 self.store_exact_with_mode(&serialized, mode)
85 }
86
87 pub fn store(&self, value: &str) -> Result<()> {
89 self.store_with_mode(value, AuthCredentialsStoreMode::Auto)
90 }
91
92 pub(crate) fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
94 match mode.effective_mode() {
95 ResolvedStoreMode::Keyring => match self.load_keyring() {
96 Ok(Some(value)) => Ok(Some(value)),
97 Ok(None) => self.load_file(),
98 Err(err) => {
99 tracing::warn!(
100 "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
101 self.service,
102 self.user,
103 err
104 );
105 self.load_file()
106 }
107 },
108 ResolvedStoreMode::File => self.load_file(),
109 }
110 }
111
112 pub(crate) fn load_exact_with_mode(&self, mode: ResolvedStoreMode) -> Result<Option<String>> {
117 match mode {
118 ResolvedStoreMode::Keyring => self.load_keyring(),
119 ResolvedStoreMode::File => self.load_file(),
120 }
121 }
122
123 pub(crate) fn load_json<T: serde::de::DeserializeOwned>(
125 &self,
126 mode: AuthCredentialsStoreMode,
127 ) -> Result<Option<T>> {
128 let Some(serialized) = self.load_with_mode(mode)? else {
129 return Ok(None);
130 };
131 serde_json::from_str(&serialized)
132 .context("failed to deserialize credential")
133 .map(Some)
134 }
135
136 pub(crate) fn load_json_exact_with_mode<T: serde::de::DeserializeOwned>(
138 &self,
139 mode: ResolvedStoreMode,
140 ) -> Result<Option<T>> {
141 let Some(serialized) = self.load_exact_with_mode(mode)? else {
142 return Ok(None);
143 };
144 serde_json::from_str(&serialized)
145 .context("failed to deserialize credential")
146 .map(Some)
147 }
148
149 pub fn load(&self) -> Result<Option<String>> {
151 self.load_with_mode(AuthCredentialsStoreMode::Auto)
152 }
153
154 pub(crate) fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
156 match mode.effective_mode() {
157 ResolvedStoreMode::Keyring => {
158 let mut errors = Vec::new();
159
160 if let Err(err) = self.clear_keyring() {
161 errors.push(err.to_string());
162 }
163 if let Err(err) = self.clear_file() {
164 errors.push(err.to_string());
165 }
166
167 if errors.is_empty() {
168 Ok(())
169 } else {
170 Err(anyhow!("Failed to clear credential from secure storage: {}", errors.join("; ")))
171 }
172 }
173 ResolvedStoreMode::File => self.clear_file(),
174 }
175 }
176
177 pub(crate) fn clear_exact_with_mode(&self, mode: ResolvedStoreMode) -> Result<()> {
184 match mode {
185 ResolvedStoreMode::Keyring => self.clear_keyring(),
186 ResolvedStoreMode::File => self.clear_file(),
187 }
188 }
189
190 pub fn clear(&self) -> Result<()> {
192 self.clear_with_mode(AuthCredentialsStoreMode::Auto)
193 }
194
195 fn store_keyring(&self, value: &str) -> Result<()> {
200 let entry = keyring::entry(&self.service, &self.user).context("Failed to access OS keyring")?;
201 entry.set_password(value).context("Failed to store credential in OS keyring")?;
202 tracing::debug!("Credential stored in OS keyring for {}/{}", self.service, self.user);
203 Ok(())
204 }
205
206 fn load_keyring(&self) -> Result<Option<String>> {
207 let entry = match keyring::entry(&self.service, &self.user) {
208 Ok(e) => e,
209 Err(_) => return Ok(None),
210 };
211
212 match entry.get_password() {
213 Ok(value) => Ok(Some(value)),
214 Err(keyring_core::Error::NoEntry) => Ok(None),
215 Err(e) => Err(anyhow!("Failed to read from keyring: {e}")),
216 }
217 }
218
219 fn clear_keyring(&self) -> Result<()> {
220 let entry = match keyring::entry(&self.service, &self.user) {
221 Ok(e) => e,
222 Err(_) => return Ok(()),
223 };
224
225 match entry.delete_credential() {
226 Ok(_) => {
227 tracing::debug!("Credential cleared from keyring for {}/{}", self.service, self.user);
228 }
229 Err(keyring_core::Error::NoEntry) => {}
230 Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
231 }
232
233 Ok(())
234 }
235
236 fn store_file(&self, value: &str) -> Result<()> {
237 let path = self.file_path()?;
238 let encrypted = encryption::encrypt(value)?;
239 let payload = serde_json::to_vec_pretty(&encrypted).context("failed to serialize encrypted credential")?;
240 write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
241 Ok(())
242 }
243
244 fn load_file(&self) -> Result<Option<String>> {
245 let path = self.file_path()?;
246 let Some(data) = read_private_file(&path).context("failed to read encrypted credential file")? else {
247 return Ok(None);
248 };
249
250 let encrypted: encryption::EncryptedCredential =
251 serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
252 encryption::decrypt(&encrypted).map(Some)
253 }
254
255 fn clear_file(&self) -> Result<()> {
256 let path = self.file_path()?;
257 match fs::remove_file(path) {
258 Ok(()) => Ok(()),
259 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
260 Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
261 }
262 }
263
264 fn file_path(&self) -> Result<std::path::PathBuf> {
265 use sha2::Digest as _;
266
267 let mut hasher = sha2::Sha256::new();
268 hasher.update(self.service.as_bytes());
269 hasher.update([0]);
270 hasher.update(self.user.as_bytes());
271 let digest = hasher.finalize();
272 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
273
274 Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
275 }
276
277 #[cfg(test)]
278 pub(crate) fn file_path_for_tests(&self) -> Result<std::path::PathBuf> {
279 self.file_path()
280 }
281}