Skip to main content

busbar_sf_auth/
storage.rs

1//! Token storage for persisting credentials.
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6use crate::error::{Error, ErrorKind, Result};
7use crate::oauth::TokenResponse;
8
9/// Trait for token storage implementations.
10pub trait TokenStorage: Send + Sync {
11    /// Save a token.
12    fn save(&self, key: &str, token: &TokenResponse) -> Result<()>;
13
14    /// Load a token.
15    fn load(&self, key: &str) -> Result<Option<TokenResponse>>;
16
17    /// Delete a token.
18    fn delete(&self, key: &str) -> Result<()>;
19
20    /// Check if a token exists.
21    fn exists(&self, key: &str) -> Result<bool>;
22
23    /// List all stored token keys.
24    fn list(&self) -> Result<Vec<String>>;
25}
26
27/// File-based token storage.
28#[derive(Debug, Clone)]
29pub struct FileTokenStorage {
30    base_path: PathBuf,
31}
32
33impl FileTokenStorage {
34    /// Create a new file token storage with the default path.
35    ///
36    /// Default path: `~/.sf-api/tokens/`
37    pub fn new() -> Result<Self> {
38        let base_path = default_token_dir()?;
39        Ok(Self { base_path })
40    }
41
42    /// Create a new file token storage with a custom path.
43    pub fn with_path(path: impl AsRef<Path>) -> Self {
44        Self {
45            base_path: path.as_ref().to_path_buf(),
46        }
47    }
48
49    /// Get the token file path for a key.
50    fn token_path(&self, key: &str) -> PathBuf {
51        // Sanitize the key to create a safe filename
52        let safe_key = key
53            .chars()
54            .map(|c| {
55                if c.is_alphanumeric() || c == '-' || c == '_' {
56                    c
57                } else {
58                    '_'
59                }
60            })
61            .collect::<String>();
62
63        self.base_path.join(format!("{}.json", safe_key))
64    }
65
66    /// Ensure the base directory exists.
67    fn ensure_dir(&self) -> Result<()> {
68        if !self.base_path.exists() {
69            std::fs::create_dir_all(&self.base_path)?;
70        }
71        Ok(())
72    }
73}
74
75impl Default for FileTokenStorage {
76    fn default() -> Self {
77        Self::new().expect("Failed to create default token storage")
78    }
79}
80
81impl TokenStorage for FileTokenStorage {
82    fn save(&self, key: &str, token: &TokenResponse) -> Result<()> {
83        self.ensure_dir()?;
84
85        let path = self.token_path(key);
86        let stored = StoredToken {
87            token: token.clone(),
88            stored_at: chrono::Utc::now(),
89        };
90
91        let json = serde_json::to_string_pretty(&stored)?;
92        std::fs::write(&path, json)?;
93
94        // Set restrictive permissions on Unix
95        #[cfg(unix)]
96        {
97            use std::os::unix::fs::PermissionsExt;
98            let perms = std::fs::Permissions::from_mode(0o600);
99            std::fs::set_permissions(&path, perms)?;
100        }
101
102        Ok(())
103    }
104
105    fn load(&self, key: &str) -> Result<Option<TokenResponse>> {
106        let path = self.token_path(key);
107
108        if !path.exists() {
109            return Ok(None);
110        }
111
112        let json = std::fs::read_to_string(&path)?;
113        let stored: StoredToken = serde_json::from_str(&json)?;
114
115        Ok(Some(stored.token))
116    }
117
118    fn delete(&self, key: &str) -> Result<()> {
119        let path = self.token_path(key);
120
121        if path.exists() {
122            std::fs::remove_file(&path)?;
123        }
124
125        Ok(())
126    }
127
128    fn exists(&self, key: &str) -> Result<bool> {
129        Ok(self.token_path(key).exists())
130    }
131
132    fn list(&self) -> Result<Vec<String>> {
133        if !self.base_path.exists() {
134            return Ok(Vec::new());
135        }
136
137        let mut keys = Vec::new();
138        for entry in std::fs::read_dir(&self.base_path)? {
139            let entry = entry?;
140            let path = entry.path();
141
142            if path.extension().map(|e| e == "json").unwrap_or(false) {
143                if let Some(stem) = path.file_stem() {
144                    keys.push(stem.to_string_lossy().to_string());
145                }
146            }
147        }
148
149        Ok(keys)
150    }
151}
152
153/// Token with storage metadata.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155struct StoredToken {
156    token: TokenResponse,
157    stored_at: chrono::DateTime<chrono::Utc>,
158}
159
160/// Get the default token storage directory.
161pub fn default_token_dir() -> Result<PathBuf> {
162    let home = dirs::home_dir().ok_or_else(|| {
163        Error::new(ErrorKind::Config(
164            "Could not find home directory".to_string(),
165        ))
166    })?;
167
168    Ok(home.join(".sf-api").join("tokens"))
169}
170
171/// Get the default token storage path.
172#[allow(dead_code)]
173pub fn default_token_path(key: &str) -> Result<PathBuf> {
174    let dir = default_token_dir()?;
175    Ok(dir.join(format!("{}.json", key)))
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use tempfile::TempDir;
182
183    fn test_token() -> TokenResponse {
184        TokenResponse {
185            access_token: "test_access".to_string(),
186            refresh_token: Some("test_refresh".to_string()),
187            instance_url: "https://test.salesforce.com".to_string(),
188            id: None,
189            token_type: Some("Bearer".to_string()),
190            scope: None,
191            signature: None,
192            issued_at: None,
193        }
194    }
195
196    #[test]
197    fn test_file_storage_save_load() {
198        let temp_dir = TempDir::new().unwrap();
199        let storage = FileTokenStorage::with_path(temp_dir.path());
200
201        let token = test_token();
202        storage.save("test_org", &token).unwrap();
203
204        let loaded = storage.load("test_org").unwrap().unwrap();
205        assert_eq!(loaded.access_token, "test_access");
206        assert_eq!(loaded.refresh_token, Some("test_refresh".to_string()));
207    }
208
209    #[test]
210    fn test_file_storage_exists() {
211        let temp_dir = TempDir::new().unwrap();
212        let storage = FileTokenStorage::with_path(temp_dir.path());
213
214        assert!(!storage.exists("missing").unwrap());
215
216        storage.save("exists", &test_token()).unwrap();
217        assert!(storage.exists("exists").unwrap());
218    }
219
220    #[test]
221    fn test_file_storage_delete() {
222        let temp_dir = TempDir::new().unwrap();
223        let storage = FileTokenStorage::with_path(temp_dir.path());
224
225        storage.save("to_delete", &test_token()).unwrap();
226        assert!(storage.exists("to_delete").unwrap());
227
228        storage.delete("to_delete").unwrap();
229        assert!(!storage.exists("to_delete").unwrap());
230    }
231
232    #[test]
233    fn test_file_storage_list() {
234        let temp_dir = TempDir::new().unwrap();
235        let storage = FileTokenStorage::with_path(temp_dir.path());
236
237        storage.save("org1", &test_token()).unwrap();
238        storage.save("org2", &test_token()).unwrap();
239
240        let keys = storage.list().unwrap();
241        assert_eq!(keys.len(), 2);
242        assert!(keys.contains(&"org1".to_string()));
243        assert!(keys.contains(&"org2".to_string()));
244    }
245
246    #[test]
247    fn test_key_sanitization() {
248        let temp_dir = TempDir::new().unwrap();
249        let storage = FileTokenStorage::with_path(temp_dir.path());
250
251        // Keys with special characters should be sanitized
252        storage.save("user@example.com", &test_token()).unwrap();
253
254        let path = storage.token_path("user@example.com");
255        assert!(path
256            .file_name()
257            .unwrap()
258            .to_str()
259            .unwrap()
260            .contains("user_example_com"));
261    }
262}