1use anyhow::{Context, Result};
7use std::path::PathBuf;
8
9use crate::types::StoredToken;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum StorageBackend {
14 File,
16 Keychain,
18}
19
20impl StorageBackend {
21 pub fn from_env() -> Self {
24 if is_keychain_disabled_in_profile() {
26 tracing::warn!(
27 "File-based token storage enabled in profile. \
28 Tokens will be stored in plaintext. \
29 Consider: raps config set use_keychain true"
30 );
31 return StorageBackend::File;
32 }
33
34 let use_file = std::env::var("RAPS_USE_FILE_STORAGE")
36 .ok()
37 .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes" | "on"))
38 .unwrap_or(false);
39
40 if use_file {
41 tracing::warn!(
42 "File-based token storage via RAPS_USE_FILE_STORAGE. \
43 Tokens will be stored in plaintext. \
44 Remove the env var to use keychain storage."
45 );
46 StorageBackend::File
47 } else {
48 StorageBackend::Keychain
50 }
51 }
52}
53
54fn is_keychain_disabled_in_profile() -> bool {
56 let proj_dirs = match directories::ProjectDirs::from("com", "autodesk", "raps") {
58 Some(dirs) => dirs,
59 None => return false,
60 };
61
62 let profiles_path = proj_dirs.config_dir().join("profiles.json");
63 if !profiles_path.exists() {
64 return false;
65 }
66
67 let content = match std::fs::read_to_string(&profiles_path) {
68 Ok(c) => c,
69 Err(_) => return false,
70 };
71
72 if let Ok(data) = serde_json::from_str::<serde_json::Value>(&content)
74 && let Some(active) = data["active_profile"].as_str()
75 && let Some(profile) = data["profiles"][active].as_object()
76 && let Some(use_keychain) = profile.get("use_keychain")
77 {
78 return use_keychain.as_bool() == Some(false);
79 }
80
81 false
82}
83
84pub struct TokenStorage {
86 backend: StorageBackend,
87 service_name: String,
88 username: String,
89}
90
91impl TokenStorage {
92 pub fn new(backend: StorageBackend) -> Self {
94 Self {
95 backend,
96 service_name: "raps".to_string(),
97 username: "aps_token".to_string(),
98 }
99 }
100
101 fn token_file_path() -> Result<PathBuf> {
103 let dirs = directories::ProjectDirs::from("com", "autodesk", "raps").ok_or_else(|| {
104 anyhow::anyhow!("Failed to determine project directories (no home directory?)")
105 })?;
106 Ok(dirs.config_dir().join("tokens.json"))
107 }
108
109 pub fn save(&self, token: &StoredToken) -> Result<()> {
111 match self.backend {
112 StorageBackend::File => self.save_file(token),
113 StorageBackend::Keychain => self.save_keychain(token),
114 }
115 }
116
117 pub fn load(&self) -> Result<Option<StoredToken>> {
119 match self.backend {
120 StorageBackend::File => self.load_file(),
121 StorageBackend::Keychain => self.load_keychain(),
122 }
123 }
124
125 pub fn delete(&self) -> Result<()> {
127 match self.backend {
128 StorageBackend::File => self.delete_file(),
129 StorageBackend::Keychain => self.delete_keychain(),
130 }
131 }
132
133 fn save_file(&self, token: &StoredToken) -> Result<()> {
139 tracing::warn!("Storing token in plaintext file. Use keychain for better security.");
140 let path = Self::token_file_path()?;
141 if let Some(parent) = path.parent() {
142 std::fs::create_dir_all(parent)?;
143 }
144
145 let json = serde_json::json!({
147 "_warning": "This file contains sensitive authentication tokens in plaintext. Consider using keychain storage.",
148 "access_token": token.access_token,
149 "refresh_token": token.refresh_token,
150 "expires_at": token.expires_at,
151 "scopes": token.scopes,
152 });
153
154 let json_string = serde_json::to_string_pretty(&json)?;
155 std::fs::write(&path, json_string)?;
156
157 #[cfg(unix)]
159 {
160 use std::os::unix::fs::PermissionsExt;
161 let mut perms = std::fs::metadata(&path)?.permissions();
162 perms.set_mode(0o600); std::fs::set_permissions(&path, perms)?;
164 }
165
166 Ok(())
167 }
168
169 fn load_file(&self) -> Result<Option<StoredToken>> {
171 let path = Self::token_file_path()?;
172 if !path.exists() {
173 return Ok(None);
174 }
175
176 tracing::warn!(
177 "Loading token from plaintext file. Consider migrating to keychain storage."
178 );
179
180 let contents = std::fs::read_to_string(&path)?;
181
182 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&contents) {
184 let token = StoredToken {
186 access_token: json_value["access_token"]
187 .as_str()
188 .ok_or_else(|| anyhow::anyhow!("Missing access_token"))?
189 .to_string(),
190 refresh_token: json_value["refresh_token"].as_str().map(|s| s.to_string()),
191 expires_at: json_value["expires_at"].as_i64().unwrap_or(0),
192 scopes: json_value["scopes"]
193 .as_array()
194 .and_then(|arr| {
195 arr.iter()
196 .map(|v| v.as_str().map(|s| s.to_string()))
197 .collect::<Option<Vec<_>>>()
198 })
199 .unwrap_or_default(),
200 };
201 return Ok(Some(token));
202 }
203
204 let token: StoredToken =
206 serde_json::from_str(&contents).context("Failed to parse token file")?;
207 Ok(Some(token))
208 }
209
210 fn delete_file(&self) -> Result<()> {
212 let path = Self::token_file_path()?;
213 if path.exists() {
214 std::fs::remove_file(&path)?;
215 }
216 Ok(())
217 }
218
219 fn save_keychain(&self, token: &StoredToken) -> Result<()> {
221 let json = serde_json::to_string(token).context("Failed to serialize token")?;
223
224 let entry = match keyring::Entry::new(&self.service_name, &self.username) {
226 Ok(e) => e,
227 Err(e) => {
228 tracing::warn!(error = %e, "Keychain not available, using file storage instead. This is normal on headless servers and CI/CD. To silence this, set RAPS_STORAGE_BACKEND=file");
230 return self.save_file(token);
231 }
232 };
233
234 match entry.set_password(&json) {
235 Ok(()) => Ok(()),
236 Err(e) => {
237 tracing::warn!(error = %e, "Keychain save failed, using file storage instead. This is normal on headless servers and CI/CD. To silence this, set RAPS_STORAGE_BACKEND=file");
239 self.save_file(token)
240 }
241 }
242 }
243
244 fn load_keychain(&self) -> Result<Option<StoredToken>> {
246 let entry = match keyring::Entry::new(&self.service_name, &self.username) {
247 Ok(e) => e,
248 Err(e) => {
249 tracing::warn!(error = %e, "Keychain not available, using file storage instead. This is normal on headless servers and CI/CD. To silence this, set RAPS_STORAGE_BACKEND=file");
250 return self.load_file();
251 }
252 };
253
254 match entry.get_password() {
255 Ok(json) => {
256 let token: StoredToken =
257 serde_json::from_str(&json).context("Failed to parse token from keychain")?;
258 Ok(Some(token))
259 }
260 Err(keyring::Error::NoEntry) => {
261 self.load_file()
263 }
264 Err(e) => {
265 tracing::warn!(error = %e, "Keychain load failed, using file storage instead. This is normal on headless servers and CI/CD. To silence this, set RAPS_STORAGE_BACKEND=file");
266 self.load_file()
267 }
268 }
269 }
270
271 fn delete_keychain(&self) -> Result<()> {
273 let entry = match keyring::Entry::new(&self.service_name, &self.username) {
274 Ok(e) => e,
275 Err(_) => {
276 return self.delete_file();
278 }
279 };
280
281 match entry.delete_password() {
282 Ok(()) => {
283 self.delete_file().ok();
285 Ok(())
286 }
287 Err(keyring::Error::NoEntry) => {
288 self.delete_file()
290 }
291 Err(e) => {
292 tracing::warn!(error = %e, "Keychain delete failed, using file storage instead. This is normal on headless servers and CI/CD. To silence this, set RAPS_STORAGE_BACKEND=file");
294 self.delete_file()
295 }
296 }
297 }
298
299 #[allow(dead_code)]
301 pub fn backend(&self) -> StorageBackend {
302 self.backend
303 }
304
305 #[allow(dead_code)]
307 pub fn migrate_to_keychain() -> Result<()> {
308 println!("Migrating tokens from file storage to secure keychain storage...");
309
310 let file_storage = TokenStorage::new(StorageBackend::File);
312 let token = match file_storage.load()? {
313 Some(t) => t,
314 None => {
315 println!("No tokens found in file storage.");
316 return Ok(());
317 }
318 };
319
320 let keychain_storage = TokenStorage::new(StorageBackend::Keychain);
322 keychain_storage.save(&token)?;
323 println!("Token successfully migrated to keychain storage.");
324
325 file_storage.delete_file()?;
327 println!("Removed plaintext token file.");
328
329 println!("Migration complete! Your tokens are now securely stored in the OS keychain.");
330 Ok(())
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
344 fn test_storage_backend_enum_values() {
345 let file = StorageBackend::File;
347 let keychain = StorageBackend::Keychain;
348 assert_ne!(file, keychain);
349 }
350
351 #[test]
354 fn test_token_storage_new_file() {
355 let storage = TokenStorage::new(StorageBackend::File);
356 assert_eq!(storage.backend(), StorageBackend::File);
357 }
358
359 #[test]
360 fn test_token_storage_new_keychain() {
361 let storage = TokenStorage::new(StorageBackend::Keychain);
362 assert_eq!(storage.backend(), StorageBackend::Keychain);
363 }
364
365 #[test]
366 fn test_token_storage_service_name() {
367 let storage = TokenStorage::new(StorageBackend::Keychain);
368 assert_eq!(storage.service_name, "raps");
369 }
370
371 #[test]
372 fn test_token_storage_username() {
373 let storage = TokenStorage::new(StorageBackend::Keychain);
374 assert_eq!(storage.username, "aps_token");
375 }
376
377 #[test]
378 fn test_token_file_path_exists() {
379 let path = TokenStorage::token_file_path().expect("should resolve project dirs");
380 assert!(path.ends_with("tokens.json"));
381 assert!(path.to_string_lossy().contains("raps"));
382 }
383
384 #[test]
387 fn test_storage_backend_equality() {
388 assert_eq!(StorageBackend::File, StorageBackend::File);
389 assert_eq!(StorageBackend::Keychain, StorageBackend::Keychain);
390 assert_ne!(StorageBackend::File, StorageBackend::Keychain);
391 }
392
393 #[test]
394 fn test_storage_backend_clone() {
395 let backend = StorageBackend::File;
396 let cloned = backend;
397 assert_eq!(backend, cloned);
398 }
399
400 #[test]
401 fn test_storage_backend_debug() {
402 let backend = StorageBackend::Keychain;
403 let debug_str = format!("{:?}", backend);
404 assert!(debug_str.contains("Keychain"));
405 }
406}