Skip to main content

raps_kernel/
storage.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Token storage abstraction supporting both file-based and OS keychain storage
5
6use anyhow::{Context, Result};
7use std::path::PathBuf;
8
9use crate::types::StoredToken;
10
11/// Storage backend type
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum StorageBackend {
14    /// File-based storage (DEPRECATED - use only as fallback)
15    File,
16    /// OS keychain storage (Windows Credential Manager, macOS Keychain, Linux Secret Service) - DEFAULT
17    Keychain,
18}
19
20impl StorageBackend {
21    /// Determine storage backend from profile configuration or environment
22    /// Defaults to Keychain for security, falls back to File only if explicitly disabled
23    pub fn from_env() -> Self {
24        // First check profile configuration
25        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        // Fall back to environment variable for backward compatibility
35        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            // Default to keychain for security
49            StorageBackend::Keychain
50        }
51    }
52}
53
54/// Helper function to check if keychain is disabled in profile configuration
55fn is_keychain_disabled_in_profile() -> bool {
56    // Avoid circular dependency by checking the profile file directly
57    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    // Parse JSON to check use_keychain setting
73    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
84/// Token storage abstraction
85pub struct TokenStorage {
86    backend: StorageBackend,
87    service_name: String,
88    username: String,
89}
90
91impl TokenStorage {
92    /// Create a new token storage instance
93    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    /// Get the file path for file-based storage
102    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    /// Save token using the configured backend
110    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    /// Load token using the configured backend
118    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    /// Delete token using the configured backend
126    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    /// Save token to file (INSECURE - logs warning)
134    ///
135    /// Uses synchronous I/O intentionally: token files are ~200 bytes and written
136    /// at most once per CLI invocation. The sub-microsecond I/O cost is far less
137    /// than the overhead of `spawn_blocking` thread-pool scheduling.
138    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        // Add a warning to the file itself
146        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        // Set restrictive permissions on Unix-like systems
158        #[cfg(unix)]
159        {
160            use std::os::unix::fs::PermissionsExt;
161            let mut perms = std::fs::metadata(&path)?.permissions();
162            perms.set_mode(0o600); // Read/write for owner only
163            std::fs::set_permissions(&path, perms)?;
164        }
165
166        Ok(())
167    }
168
169    /// Load token from file
170    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        // Try to parse as our new format with warning field
183        if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&contents) {
184            // Extract the token fields, ignoring the _warning field
185            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        // Fall back to parsing as the old format
205        let token: StoredToken =
206            serde_json::from_str(&contents).context("Failed to parse token file")?;
207        Ok(Some(token))
208    }
209
210    /// Delete token file
211    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    /// Save token to OS keychain
220    fn save_keychain(&self, token: &StoredToken) -> Result<()> {
221        // Serialize token to JSON
222        let json = serde_json::to_string(token).context("Failed to serialize token")?;
223
224        // Store in keychain
225        let entry = match keyring::Entry::new(&self.service_name, &self.username) {
226            Ok(e) => e,
227            Err(e) => {
228                // If keyring is not available, fall back to file storage
229                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                // If keychain save fails, fall back to file storage
238                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    /// Load token from OS keychain
245    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                // If not in keychain, it might be in the file fallback
262                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    /// Delete token from OS keychain
272    fn delete_keychain(&self) -> Result<()> {
273        let entry = match keyring::Entry::new(&self.service_name, &self.username) {
274            Ok(e) => e,
275            Err(_) => {
276                // If keyring is not available, try deleting file storage
277                return self.delete_file();
278            }
279        };
280
281        match entry.delete_password() {
282            Ok(()) => {
283                // Also delete file storage if it exists (for migration)
284                self.delete_file().ok();
285                Ok(())
286            }
287            Err(keyring::Error::NoEntry) => {
288                // Already deleted, also try file storage
289                self.delete_file()
290            }
291            Err(e) => {
292                // If keychain delete fails, try file storage
293                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    /// Get the current backend being used
300    #[allow(dead_code)]
301    pub fn backend(&self) -> StorageBackend {
302        self.backend
303    }
304
305    /// Migrate tokens from file storage to keychain storage
306    #[allow(dead_code)]
307    pub fn migrate_to_keychain() -> Result<()> {
308        println!("Migrating tokens from file storage to secure keychain storage...");
309
310        // First, try to load from file storage
311        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        // Save to keychain
321        let keychain_storage = TokenStorage::new(StorageBackend::Keychain);
322        keychain_storage.save(&token)?;
323        println!("Token successfully migrated to keychain storage.");
324
325        // Delete the file storage
326        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    // ==================== StorageBackend Tests ====================
339    // Note: StorageBackend::from_env() tests are skipped because they depend on
340    // user's profile configuration (use_keychain setting) which takes precedence
341    // over environment variables. We test the enum values and TokenStorage directly.
342
343    #[test]
344    fn test_storage_backend_enum_values() {
345        // Verify enum variants exist and can be created
346        let file = StorageBackend::File;
347        let keychain = StorageBackend::Keychain;
348        assert_ne!(file, keychain);
349    }
350
351    // ==================== TokenStorage Tests ====================
352
353    #[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    // ==================== StorageBackend Equality Tests ====================
385
386    #[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}