Skip to main content

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/edgefirststudio/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. Note that
149    /// `directories` derives the Linux path from the application name alone
150    /// (lowercased, spaces stripped) and always appends a `config`
151    /// subdirectory on Windows — this is not simply "server/organization/app"
152    /// concatenated the same way on every platform:
153    /// - Linux: `~/.config/edgefirststudio/token`
154    /// - macOS: `~/Library/Application
155    ///   Support/ai.EdgeFirst.EdgeFirst-Studio/token`
156    /// - Windows: `C:\Users\<User>\AppData\Roaming\EdgeFirst\EdgeFirst
157    ///   Studio\config\token`
158    pub fn new() -> Result<Self, StorageError> {
159        let path = ProjectDirs::from("ai", "EdgeFirst", "EdgeFirst Studio")
160            .ok_or_else(|| {
161                StorageError::NotAvailable("Could not determine user config directory".to_string())
162            })?
163            .config_dir()
164            .join("token");
165
166        debug!("FileTokenStorage using default path: {:?}", path);
167        Ok(Self { path })
168    }
169
170    /// Create a new `FileTokenStorage` with a custom file path.
171    pub fn with_path(path: PathBuf) -> Self {
172        debug!("FileTokenStorage using custom path: {:?}", path);
173        Self { path }
174    }
175
176    /// Returns the path where the token is stored.
177    pub fn path(&self) -> &PathBuf {
178        &self.path
179    }
180}
181
182impl TokenStorage for FileTokenStorage {
183    fn store(&self, token: &str) -> Result<(), StorageError> {
184        // Ensure parent directory exists
185        if let Some(parent) = self.path.parent() {
186            std::fs::create_dir_all(parent).map_err(|e| {
187                StorageError::WriteError(format!("Failed to create directory {:?}: {}", parent, e))
188            })?;
189        }
190
191        std::fs::write(&self.path, token).map_err(|e| {
192            StorageError::WriteError(format!("Failed to write token to {:?}: {}", self.path, e))
193        })?;
194
195        debug!("Token stored to {:?}", self.path);
196        Ok(())
197    }
198
199    fn load(&self) -> Result<Option<String>, StorageError> {
200        if !self.path.exists() {
201            debug!("No token file found at {:?}", self.path);
202            return Ok(None);
203        }
204
205        let token = std::fs::read_to_string(&self.path).map_err(|e| {
206            StorageError::ReadError(format!("Failed to read token from {:?}: {}", self.path, e))
207        })?;
208
209        if token.is_empty() {
210            debug!("Token file at {:?} is empty", self.path);
211            return Ok(None);
212        }
213
214        debug!("Token loaded from {:?}", self.path);
215        Ok(Some(token))
216    }
217
218    fn clear(&self) -> Result<(), StorageError> {
219        if self.path.exists() {
220            std::fs::remove_file(&self.path).map_err(|e| {
221                StorageError::ClearError(format!(
222                    "Failed to remove token file {:?}: {}",
223                    self.path, e
224                ))
225            })?;
226            debug!("Token file removed from {:?}", self.path);
227        }
228        Ok(())
229    }
230}
231
232/// In-memory token storage (no persistence).
233///
234/// Stores the authentication token in memory only. The token is lost when the
235/// application exits. This is useful for:
236///
237/// - Testing
238/// - Mobile platforms that use custom secure storage
239/// - Applications that don't need token persistence
240///
241/// # Examples
242///
243/// ```rust
244/// use edgefirst_client::{MemoryTokenStorage, TokenStorage};
245///
246/// let storage = MemoryTokenStorage::new();
247/// storage.store("my-token").unwrap();
248/// assert_eq!(storage.load().unwrap(), Some("my-token".to_string()));
249/// storage.clear().unwrap();
250/// assert_eq!(storage.load().unwrap(), None);
251/// ```
252#[derive(Debug, Default)]
253pub struct MemoryTokenStorage {
254    token: RwLock<Option<String>>,
255}
256
257impl MemoryTokenStorage {
258    /// Create a new `MemoryTokenStorage`.
259    pub fn new() -> Self {
260        Self::default()
261    }
262}
263
264impl TokenStorage for MemoryTokenStorage {
265    fn store(&self, token: &str) -> Result<(), StorageError> {
266        let mut guard = self.token.write().map_err(|e| {
267            StorageError::WriteError(format!("Failed to acquire write lock: {}", e))
268        })?;
269        *guard = Some(token.to_string());
270        Ok(())
271    }
272
273    fn load(&self) -> Result<Option<String>, StorageError> {
274        let guard = self
275            .token
276            .read()
277            .map_err(|e| StorageError::ReadError(format!("Failed to acquire read lock: {}", e)))?;
278        Ok(guard.clone())
279    }
280
281    fn clear(&self) -> Result<(), StorageError> {
282        let mut guard = self.token.write().map_err(|e| {
283            StorageError::ClearError(format!("Failed to acquire write lock: {}", e))
284        })?;
285        *guard = None;
286        Ok(())
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use std::sync::Arc;
294    use tempfile::TempDir;
295
296    #[test]
297    fn test_memory_storage_store_load_clear() {
298        let storage = MemoryTokenStorage::new();
299
300        // Initially empty
301        assert_eq!(storage.load().unwrap(), None);
302
303        // Store token
304        storage.store("test-token").unwrap();
305        assert_eq!(storage.load().unwrap(), Some("test-token".to_string()));
306
307        // Clear token
308        storage.clear().unwrap();
309        assert_eq!(storage.load().unwrap(), None);
310    }
311
312    #[test]
313    fn test_memory_storage_overwrite() {
314        let storage = MemoryTokenStorage::new();
315
316        storage.store("token-1").unwrap();
317        assert_eq!(storage.load().unwrap(), Some("token-1".to_string()));
318
319        storage.store("token-2").unwrap();
320        assert_eq!(storage.load().unwrap(), Some("token-2".to_string()));
321    }
322
323    #[test]
324    fn test_memory_storage_thread_safety() {
325        let storage = Arc::new(MemoryTokenStorage::new());
326        let storage_clone = Arc::clone(&storage);
327
328        let handle = std::thread::spawn(move || {
329            storage_clone.store("thread-token").unwrap();
330        });
331
332        handle.join().unwrap();
333        assert_eq!(storage.load().unwrap(), Some("thread-token".to_string()));
334    }
335
336    #[test]
337    fn test_file_storage_store_load_clear() {
338        let temp_dir = TempDir::new().unwrap();
339        let token_path = temp_dir.path().join("token");
340        let storage = FileTokenStorage::with_path(token_path.clone());
341
342        // Initially empty (file doesn't exist)
343        assert_eq!(storage.load().unwrap(), None);
344
345        // Store token
346        storage.store("file-test-token").unwrap();
347        assert!(token_path.exists());
348        assert_eq!(storage.load().unwrap(), Some("file-test-token".to_string()));
349
350        // Clear token
351        storage.clear().unwrap();
352        assert!(!token_path.exists());
353        assert_eq!(storage.load().unwrap(), None);
354    }
355
356    #[test]
357    fn test_file_storage_creates_parent_dirs() {
358        let temp_dir = TempDir::new().unwrap();
359        let token_path = temp_dir.path().join("nested").join("dirs").join("token");
360        let storage = FileTokenStorage::with_path(token_path.clone());
361
362        storage.store("nested-token").unwrap();
363        assert!(token_path.exists());
364        assert_eq!(storage.load().unwrap(), Some("nested-token".to_string()));
365    }
366
367    #[test]
368    fn test_file_storage_overwrite() {
369        let temp_dir = TempDir::new().unwrap();
370        let token_path = temp_dir.path().join("token");
371        let storage = FileTokenStorage::with_path(token_path);
372
373        storage.store("token-1").unwrap();
374        assert_eq!(storage.load().unwrap(), Some("token-1".to_string()));
375
376        storage.store("token-2").unwrap();
377        assert_eq!(storage.load().unwrap(), Some("token-2".to_string()));
378    }
379
380    #[test]
381    fn test_file_storage_clear_nonexistent() {
382        let temp_dir = TempDir::new().unwrap();
383        let token_path = temp_dir.path().join("nonexistent_token");
384        let storage = FileTokenStorage::with_path(token_path);
385
386        // Should not error when clearing nonexistent file
387        assert!(storage.clear().is_ok());
388    }
389
390    #[test]
391    fn test_file_storage_path() {
392        let path = PathBuf::from("/custom/path/token");
393        let storage = FileTokenStorage::with_path(path.clone());
394        assert_eq!(storage.path(), &path);
395    }
396
397    #[test]
398    fn test_storage_error_display() {
399        let err = StorageError::NotAvailable("test".to_string());
400        assert!(err.to_string().contains("test"));
401        assert!(err.to_string().contains("not available"));
402
403        let err = StorageError::ReadError("read failed".to_string());
404        assert!(err.to_string().contains("read failed"));
405
406        let err = StorageError::WriteError("write failed".to_string());
407        assert!(err.to_string().contains("write failed"));
408
409        let err = StorageError::ClearError("clear failed".to_string());
410        assert!(err.to_string().contains("clear failed"));
411    }
412}