edgefirst_client/
storage.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4//! Token storage abstraction for EdgeFirst Client.
5//!
6//! This module provides a trait-based abstraction for token persistence,
7//! allowing different storage backends to be used depending on the platform.
8//!
9//! # Storage Implementations
10//!
11//! - [`FileTokenStorage`]: Default file-based storage for desktop platforms
12//! - [`MemoryTokenStorage`]: In-memory storage (no persistence)
13//!
14//! # Custom Storage
15//!
16//! Implement the [`TokenStorage`] trait to create custom storage backends,
17//! such as iOS Keychain or Android EncryptedSharedPreferences.
18//!
19//! # Examples
20//!
21//! ```rust,no_run
22//! use edgefirst_client::{Client, FileTokenStorage, MemoryTokenStorage};
23//! use std::sync::Arc;
24//!
25//! # fn main() -> Result<(), edgefirst_client::Error> {
26//! // Use default file storage (desktop platforms)
27//! let client = Client::new()?;
28//!
29//! // Use memory-only storage (no persistence)
30//! let client = Client::new()?.with_memory_storage();
31//!
32//! // Use custom file path
33//! let storage = FileTokenStorage::with_path("/custom/path/token".into());
34//! let client = Client::new()?.with_storage(Arc::new(storage));
35//! # Ok(())
36//! # }
37//! ```
38
39use directories::ProjectDirs;
40use log::debug;
41use std::{path::PathBuf, sync::RwLock};
42
43/// Error type for token storage operations.
44#[derive(Debug)]
45pub enum StorageError {
46    /// Storage is not available (e.g., cannot determine config directory).
47    NotAvailable(String),
48    /// Failed to read token from storage.
49    ReadError(String),
50    /// Failed to write token to storage.
51    WriteError(String),
52    /// Failed to clear token from storage.
53    ClearError(String),
54}
55
56impl std::fmt::Display for StorageError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            StorageError::NotAvailable(msg) => write!(f, "Token storage not available: {}", msg),
60            StorageError::ReadError(msg) => write!(f, "Failed to read token: {}", msg),
61            StorageError::WriteError(msg) => write!(f, "Failed to write token: {}", msg),
62            StorageError::ClearError(msg) => write!(f, "Failed to clear token: {}", msg),
63        }
64    }
65}
66
67impl std::error::Error for StorageError {}
68
69/// Trait for persistent token storage.
70///
71/// Implement this trait to create custom storage backends for authentication
72/// tokens. The storage must be thread-safe (`Send + Sync`).
73///
74/// # Platform Examples
75///
76/// - **Desktop**: Use [`FileTokenStorage`] to store tokens in the user's config
77///   directory
78/// - **iOS**: Implement using Keychain Services
79/// - **Android**: Implement using EncryptedSharedPreferences
80///
81/// # Example Implementation
82///
83/// ```rust,ignore
84/// use edgefirst_client::{TokenStorage, StorageError};
85///
86/// struct KeychainStorage {
87///     service: String,
88///     account: String,
89/// }
90///
91/// impl TokenStorage for KeychainStorage {
92///     fn store(&self, token: &str) -> Result<(), StorageError> {
93///         // Store in Keychain
94///         Ok(())
95///     }
96///
97///     fn load(&self) -> Result<Option<String>, StorageError> {
98///         // Load from Keychain
99///         Ok(Some("token".to_string()))
100///     }
101///
102///     fn clear(&self) -> Result<(), StorageError> {
103///         // Remove from Keychain
104///         Ok(())
105///     }
106/// }
107/// ```
108pub trait TokenStorage: Send + Sync {
109    /// Store the authentication token.
110    fn store(&self, token: &str) -> Result<(), StorageError>;
111
112    /// Load the stored authentication token.
113    ///
114    /// Returns `Ok(None)` if no token is stored.
115    fn load(&self) -> Result<Option<String>, StorageError>;
116
117    /// Clear the stored authentication token.
118    fn clear(&self) -> Result<(), StorageError>;
119}
120
121/// File-based token storage for desktop platforms.
122///
123/// Stores the authentication token in a file on the local filesystem. By
124/// default, uses the platform-specific config directory
125/// (e.g., `~/.config/EdgeFirst Studio/token` on Linux).
126///
127/// # Examples
128///
129/// ```rust,no_run
130/// use edgefirst_client::FileTokenStorage;
131/// use std::path::PathBuf;
132///
133/// // Use default path
134/// let storage = FileTokenStorage::new().unwrap();
135///
136/// // Use custom path
137/// let storage = FileTokenStorage::with_path(PathBuf::from("/custom/path/token"));
138/// ```
139#[derive(Debug, Clone)]
140pub struct FileTokenStorage {
141    path: PathBuf,
142}
143
144impl FileTokenStorage {
145    /// Create a new `FileTokenStorage` using the default platform config
146    /// directory.
147    ///
148    /// The default path is determined by the `directories` crate:
149    /// - Linux: `~/.config/EdgeFirst Studio/token`
150    /// - macOS: `~/Library/Application
151    ///   Support/ai.EdgeFirst.EdgeFirst-Studio/token`
152    /// - Windows: `C:\Users\<User>\AppData\Roaming\EdgeFirst\EdgeFirst
153    ///   Studio\token`
154    pub fn new() -> Result<Self, StorageError> {
155        let path = ProjectDirs::from("ai", "EdgeFirst", "EdgeFirst Studio")
156            .ok_or_else(|| {
157                StorageError::NotAvailable("Could not determine user config directory".to_string())
158            })?
159            .config_dir()
160            .join("token");
161
162        debug!("FileTokenStorage using default path: {:?}", path);
163        Ok(Self { path })
164    }
165
166    /// Create a new `FileTokenStorage` with a custom file path.
167    pub fn with_path(path: PathBuf) -> Self {
168        debug!("FileTokenStorage using custom path: {:?}", path);
169        Self { path }
170    }
171
172    /// Returns the path where the token is stored.
173    pub fn path(&self) -> &PathBuf {
174        &self.path
175    }
176}
177
178impl TokenStorage for FileTokenStorage {
179    fn store(&self, token: &str) -> Result<(), StorageError> {
180        // Ensure parent directory exists
181        if let Some(parent) = self.path.parent() {
182            std::fs::create_dir_all(parent).map_err(|e| {
183                StorageError::WriteError(format!("Failed to create directory {:?}: {}", parent, e))
184            })?;
185        }
186
187        std::fs::write(&self.path, token).map_err(|e| {
188            StorageError::WriteError(format!("Failed to write token to {:?}: {}", self.path, e))
189        })?;
190
191        debug!("Token stored to {:?}", self.path);
192        Ok(())
193    }
194
195    fn load(&self) -> Result<Option<String>, StorageError> {
196        if !self.path.exists() {
197            debug!("No token file found at {:?}", self.path);
198            return Ok(None);
199        }
200
201        let token = std::fs::read_to_string(&self.path).map_err(|e| {
202            StorageError::ReadError(format!("Failed to read token from {:?}: {}", self.path, e))
203        })?;
204
205        if token.is_empty() {
206            debug!("Token file at {:?} is empty", self.path);
207            return Ok(None);
208        }
209
210        debug!("Token loaded from {:?}", self.path);
211        Ok(Some(token))
212    }
213
214    fn clear(&self) -> Result<(), StorageError> {
215        if self.path.exists() {
216            std::fs::remove_file(&self.path).map_err(|e| {
217                StorageError::ClearError(format!(
218                    "Failed to remove token file {:?}: {}",
219                    self.path, e
220                ))
221            })?;
222            debug!("Token file removed from {:?}", self.path);
223        }
224        Ok(())
225    }
226}
227
228/// In-memory token storage (no persistence).
229///
230/// Stores the authentication token in memory only. The token is lost when the
231/// application exits. This is useful for:
232///
233/// - Testing
234/// - Mobile platforms that use custom secure storage
235/// - Applications that don't need token persistence
236///
237/// # Examples
238///
239/// ```rust
240/// use edgefirst_client::{MemoryTokenStorage, TokenStorage};
241///
242/// let storage = MemoryTokenStorage::new();
243/// storage.store("my-token").unwrap();
244/// assert_eq!(storage.load().unwrap(), Some("my-token".to_string()));
245/// storage.clear().unwrap();
246/// assert_eq!(storage.load().unwrap(), None);
247/// ```
248#[derive(Debug, Default)]
249pub struct MemoryTokenStorage {
250    token: RwLock<Option<String>>,
251}
252
253impl MemoryTokenStorage {
254    /// Create a new `MemoryTokenStorage`.
255    pub fn new() -> Self {
256        Self::default()
257    }
258}
259
260impl TokenStorage for MemoryTokenStorage {
261    fn store(&self, token: &str) -> Result<(), StorageError> {
262        let mut guard = self.token.write().map_err(|e| {
263            StorageError::WriteError(format!("Failed to acquire write lock: {}", e))
264        })?;
265        *guard = Some(token.to_string());
266        Ok(())
267    }
268
269    fn load(&self) -> Result<Option<String>, StorageError> {
270        let guard = self
271            .token
272            .read()
273            .map_err(|e| StorageError::ReadError(format!("Failed to acquire read lock: {}", e)))?;
274        Ok(guard.clone())
275    }
276
277    fn clear(&self) -> Result<(), StorageError> {
278        let mut guard = self.token.write().map_err(|e| {
279            StorageError::ClearError(format!("Failed to acquire write lock: {}", e))
280        })?;
281        *guard = None;
282        Ok(())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use std::sync::Arc;
290    use tempfile::TempDir;
291
292    #[test]
293    fn test_memory_storage_store_load_clear() {
294        let storage = MemoryTokenStorage::new();
295
296        // Initially empty
297        assert_eq!(storage.load().unwrap(), None);
298
299        // Store token
300        storage.store("test-token").unwrap();
301        assert_eq!(storage.load().unwrap(), Some("test-token".to_string()));
302
303        // Clear token
304        storage.clear().unwrap();
305        assert_eq!(storage.load().unwrap(), None);
306    }
307
308    #[test]
309    fn test_memory_storage_overwrite() {
310        let storage = MemoryTokenStorage::new();
311
312        storage.store("token-1").unwrap();
313        assert_eq!(storage.load().unwrap(), Some("token-1".to_string()));
314
315        storage.store("token-2").unwrap();
316        assert_eq!(storage.load().unwrap(), Some("token-2".to_string()));
317    }
318
319    #[test]
320    fn test_memory_storage_thread_safety() {
321        let storage = Arc::new(MemoryTokenStorage::new());
322        let storage_clone = Arc::clone(&storage);
323
324        let handle = std::thread::spawn(move || {
325            storage_clone.store("thread-token").unwrap();
326        });
327
328        handle.join().unwrap();
329        assert_eq!(storage.load().unwrap(), Some("thread-token".to_string()));
330    }
331
332    #[test]
333    fn test_file_storage_store_load_clear() {
334        let temp_dir = TempDir::new().unwrap();
335        let token_path = temp_dir.path().join("token");
336        let storage = FileTokenStorage::with_path(token_path.clone());
337
338        // Initially empty (file doesn't exist)
339        assert_eq!(storage.load().unwrap(), None);
340
341        // Store token
342        storage.store("file-test-token").unwrap();
343        assert!(token_path.exists());
344        assert_eq!(storage.load().unwrap(), Some("file-test-token".to_string()));
345
346        // Clear token
347        storage.clear().unwrap();
348        assert!(!token_path.exists());
349        assert_eq!(storage.load().unwrap(), None);
350    }
351
352    #[test]
353    fn test_file_storage_creates_parent_dirs() {
354        let temp_dir = TempDir::new().unwrap();
355        let token_path = temp_dir.path().join("nested").join("dirs").join("token");
356        let storage = FileTokenStorage::with_path(token_path.clone());
357
358        storage.store("nested-token").unwrap();
359        assert!(token_path.exists());
360        assert_eq!(storage.load().unwrap(), Some("nested-token".to_string()));
361    }
362
363    #[test]
364    fn test_file_storage_overwrite() {
365        let temp_dir = TempDir::new().unwrap();
366        let token_path = temp_dir.path().join("token");
367        let storage = FileTokenStorage::with_path(token_path);
368
369        storage.store("token-1").unwrap();
370        assert_eq!(storage.load().unwrap(), Some("token-1".to_string()));
371
372        storage.store("token-2").unwrap();
373        assert_eq!(storage.load().unwrap(), Some("token-2".to_string()));
374    }
375
376    #[test]
377    fn test_file_storage_clear_nonexistent() {
378        let temp_dir = TempDir::new().unwrap();
379        let token_path = temp_dir.path().join("nonexistent_token");
380        let storage = FileTokenStorage::with_path(token_path);
381
382        // Should not error when clearing nonexistent file
383        assert!(storage.clear().is_ok());
384    }
385
386    #[test]
387    fn test_file_storage_path() {
388        let path = PathBuf::from("/custom/path/token");
389        let storage = FileTokenStorage::with_path(path.clone());
390        assert_eq!(storage.path(), &path);
391    }
392
393    #[test]
394    fn test_storage_error_display() {
395        let err = StorageError::NotAvailable("test".to_string());
396        assert!(err.to_string().contains("test"));
397        assert!(err.to_string().contains("not available"));
398
399        let err = StorageError::ReadError("read failed".to_string());
400        assert!(err.to_string().contains("read failed"));
401
402        let err = StorageError::WriteError("write failed".to_string());
403        assert!(err.to_string().contains("write failed"));
404
405        let err = StorageError::ClearError("clear failed".to_string());
406        assert!(err.to_string().contains("clear failed"));
407    }
408}