Skip to main content

xcom_rs/auth/
storage.rs

1use anyhow::Result;
2use std::path::PathBuf;
3
4use super::models::{AuthStatus, AuthToken, ImportAction, ImportPlan};
5
6/// Compare two JSON strings for equality by parsing and re-serializing
7/// This handles key ordering differences
8fn json_content_equal(json1: &str, json2: &str) -> bool {
9    match (
10        serde_json::from_str::<serde_json::Value>(json1),
11        serde_json::from_str::<serde_json::Value>(json2),
12    ) {
13        (Ok(v1), Ok(v2)) => v1 == v2,
14        _ => false, // If parsing fails, treat as different
15    }
16}
17
18/// In-memory auth store for testing/stub implementation
19#[derive(Debug, Clone)]
20pub struct AuthStore {
21    token: Option<AuthToken>,
22    storage_path: Option<PathBuf>,
23}
24
25impl AuthStore {
26    /// Create a new empty auth store
27    pub fn new() -> Self {
28        Self {
29            token: None,
30            storage_path: None,
31        }
32    }
33
34    /// Create an auth store with persistent storage at the given path
35    pub fn with_storage(path: PathBuf) -> Result<Self> {
36        let mut store = Self {
37            token: None,
38            storage_path: Some(path.clone()),
39        };
40
41        // Try to load existing token from storage
42        if path.exists() {
43            if let Ok(content) = std::fs::read_to_string(&path) {
44                if let Ok(token) = serde_json::from_str::<AuthToken>(&content) {
45                    store.token = Some(token);
46                }
47            }
48        }
49
50        Ok(store)
51    }
52
53    /// Get default storage path: respects XDG_DATA_HOME, falls back to ~/.local/share/xcom-rs/auth.json
54    pub fn default_storage_path() -> Result<PathBuf> {
55        let data_dir = if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") {
56            PathBuf::from(xdg_data).join("xcom-rs")
57        } else {
58            let home = std::env::var("HOME")
59                .or_else(|_| std::env::var("USERPROFILE"))
60                .map_err(|_| anyhow::anyhow!("Could not determine home directory"))?;
61            PathBuf::from(home)
62                .join(".local")
63                .join("share")
64                .join("xcom-rs")
65        };
66        std::fs::create_dir_all(&data_dir)?;
67        Ok(data_dir.join("auth.json"))
68    }
69
70    /// Create an auth store with default storage location
71    pub fn with_default_storage() -> Result<Self> {
72        Self::with_storage(Self::default_storage_path()?)
73    }
74
75    /// Save the current token to persistent storage
76    /// Only writes if the content has changed (prevents unnecessary file modifications)
77    fn save_to_storage(&self) -> Result<()> {
78        if let Some(path) = &self.storage_path {
79            if let Some(token) = &self.token {
80                let new_json = serde_json::to_string_pretty(token)?;
81
82                // Check if file exists and compare content
83                let should_write = if path.exists() {
84                    match std::fs::read_to_string(path) {
85                        Ok(existing_json) => {
86                            // Compare normalized JSON to handle key ordering differences
87                            !json_content_equal(&existing_json, &new_json)
88                        }
89                        Err(_) => true, // If we can't read, write anyway
90                    }
91                } else {
92                    true // File doesn't exist, write it
93                };
94
95                if should_write {
96                    std::fs::write(path, new_json)?;
97                }
98            } else {
99                // If no token, delete the storage file
100                if path.exists() {
101                    std::fs::remove_file(path)?;
102                }
103            }
104        }
105        Ok(())
106    }
107
108    /// Get current authentication status
109    pub fn status(&self) -> AuthStatus {
110        match &self.token {
111            Some(token) => {
112                // Check if token is expired
113                if let Some(expires_at) = token.expires_at {
114                    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
115                        Ok(duration) => {
116                            let now = duration.as_secs() as i64;
117                            if now >= expires_at {
118                                return AuthStatus::unauthenticated(vec![
119                                    "Token expired. Run 'xcom-rs auth login' to re-authenticate"
120                                        .to_string(),
121                                ]);
122                            }
123                        }
124                        Err(_) => {
125                            // System time is before UNIX_EPOCH - treat as unauthenticated
126                            return AuthStatus::unauthenticated(vec![
127                                "System time error. Please check your system clock.".to_string(),
128                            ]);
129                        }
130                    }
131                }
132                AuthStatus::authenticated("bearer".to_string(), token.scopes.clone())
133            }
134            None => AuthStatus::unauthenticated(vec![
135                "Not authenticated. Run 'xcom-rs auth login' to authenticate".to_string(),
136            ]),
137        }
138    }
139
140    /// Export authentication data (returns encrypted in real implementation)
141    pub fn export(&self) -> Result<String> {
142        let token = self
143            .token
144            .as_ref()
145            .ok_or_else(|| anyhow::anyhow!("No authentication data to export"))?;
146
147        // In real implementation, this would encrypt the data
148        // For now, we use base64 encoding as a placeholder
149        let json = serde_json::to_string(token)?;
150        Ok(base64::encode(json))
151    }
152
153    /// Import authentication data (expects encrypted data in real implementation)
154    pub fn import(&mut self, data: &str) -> Result<()> {
155        // In real implementation, this would decrypt the data
156        // For now, we use base64 decoding as a placeholder
157        let json =
158            base64::decode(data).map_err(|e| anyhow::anyhow!("Invalid auth data format: {}", e))?;
159        let json_str = String::from_utf8(json)
160            .map_err(|e| anyhow::anyhow!("Invalid auth data encoding: {}", e))?;
161        let token: AuthToken = serde_json::from_str(&json_str)
162            .map_err(|e| anyhow::anyhow!("Invalid auth data structure: {}", e))?;
163
164        self.token = Some(token);
165        self.save_to_storage()?;
166        Ok(())
167    }
168
169    /// Import authentication data with dry-run support
170    /// Returns an ImportPlan describing what would happen
171    pub fn import_with_plan(&mut self, data: &str, dry_run: bool) -> Result<ImportPlan> {
172        // Validate and parse the data
173        let token = match self.validate_import_data(data) {
174            Ok(token) => token,
175            Err(e) => {
176                return Ok(ImportPlan::fail(e.to_string(), dry_run));
177            }
178        };
179
180        // Determine the action by comparing existing token with new token
181        let action = match &self.token {
182            None => ImportAction::Create,
183            Some(existing_token) => {
184                if existing_token == &token {
185                    ImportAction::Skip
186                } else {
187                    ImportAction::Update
188                }
189            }
190        };
191
192        // If Skip action, return early without saving
193        if action == ImportAction::Skip {
194            return Ok(ImportPlan::skip(
195                "Token is identical to existing token".to_string(),
196                dry_run,
197            ));
198        }
199
200        // If not dry-run, perform the actual import
201        if !dry_run {
202            self.token = Some(token);
203            if let Err(e) = self.save_to_storage() {
204                return Ok(ImportPlan::fail(format!("Failed to save: {}", e), dry_run));
205            }
206        }
207
208        // Return the plan
209        let plan = match action {
210            ImportAction::Create => ImportPlan::create(dry_run),
211            ImportAction::Update => ImportPlan::update(dry_run),
212            _ => unreachable!(),
213        };
214
215        Ok(plan)
216    }
217
218    /// Validate import data and return parsed token
219    fn validate_import_data(&self, data: &str) -> Result<AuthToken> {
220        let json =
221            base64::decode(data).map_err(|e| anyhow::anyhow!("Invalid auth data format: {}", e))?;
222        let json_str = String::from_utf8(json)
223            .map_err(|e| anyhow::anyhow!("Invalid auth data encoding: {}", e))?;
224        let token: AuthToken = serde_json::from_str(&json_str)
225            .map_err(|e| anyhow::anyhow!("Invalid auth data structure: {}", e))?;
226        Ok(token)
227    }
228
229    /// Set a token (for testing)
230    pub fn set_token(&mut self, token: AuthToken) {
231        self.token = Some(token);
232        let _ = self.save_to_storage(); // Ignore errors in test helper
233    }
234
235    /// Check if authenticated
236    pub fn is_authenticated(&self) -> bool {
237        self.status().authenticated
238    }
239}
240
241impl Default for AuthStore {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247// Note: base64 crate is needed - add to Cargo.toml
248// For now, we'll implement a simple base64 encode/decode
249mod base64 {
250    use anyhow::Result;
251
252    pub fn encode(data: String) -> String {
253        // Simple base64 encoding using standard library would require adding dependency
254        // For stub implementation, we'll use a simple reversible encoding
255        format!("STUB_B64_{}", data)
256    }
257
258    pub fn decode(data: &str) -> Result<Vec<u8>> {
259        if let Some(stripped) = data.strip_prefix("STUB_B64_") {
260            Ok(stripped.as_bytes().to_vec())
261        } else {
262            Err(anyhow::anyhow!("Invalid base64 format"))
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_auth_status_unauthenticated() {
273        let status = AuthStatus::unauthenticated(vec!["Login first".to_string()]);
274        assert!(!status.authenticated);
275        assert!(status.auth_mode.is_none());
276        assert!(status.scopes.is_none());
277        assert!(status.next_steps.is_some());
278    }
279
280    #[test]
281    fn test_auth_status_authenticated() {
282        let status = AuthStatus::authenticated(
283            "bearer".to_string(),
284            vec!["read".to_string(), "write".to_string()],
285        );
286        assert!(status.authenticated);
287        assert_eq!(status.auth_mode, Some("bearer".to_string()));
288        assert_eq!(
289            status.scopes,
290            Some(vec!["read".to_string(), "write".to_string()])
291        );
292        assert!(status.next_steps.is_none());
293    }
294
295    #[test]
296    fn test_auth_store_default_unauthenticated() {
297        let store = AuthStore::new();
298        let status = store.status();
299        assert!(!status.authenticated);
300        assert!(status.next_steps.is_some());
301    }
302
303    #[test]
304    fn test_auth_store_with_token() {
305        let mut store = AuthStore::new();
306        let token = AuthToken {
307            access_token: "test_token".to_string(),
308            token_type: "Bearer".to_string(),
309            expires_at: None,
310            scopes: vec!["read".to_string()],
311        };
312        store.set_token(token);
313
314        let status = store.status();
315        assert!(status.authenticated);
316        assert_eq!(status.auth_mode, Some("bearer".to_string()));
317    }
318
319    #[test]
320    fn test_auth_export_import() {
321        let mut store = AuthStore::new();
322        let token = AuthToken {
323            access_token: "test_token".to_string(),
324            token_type: "Bearer".to_string(),
325            expires_at: None,
326            scopes: vec!["read".to_string(), "write".to_string()],
327        };
328        store.set_token(token);
329
330        // Export
331        let exported = store.export().unwrap();
332        assert!(!exported.is_empty());
333
334        // Import into new store
335        let mut new_store = AuthStore::new();
336        new_store.import(&exported).unwrap();
337
338        // Verify it matches
339        let status = new_store.status();
340        assert!(status.authenticated);
341        assert_eq!(
342            status.scopes,
343            Some(vec!["read".to_string(), "write".to_string()])
344        );
345    }
346
347    #[test]
348    fn test_export_without_token() {
349        let store = AuthStore::new();
350        let result = store.export();
351        assert!(result.is_err());
352    }
353
354    #[test]
355    fn test_import_invalid_data() {
356        let mut store = AuthStore::new();
357        let result = store.import("invalid_data");
358        assert!(result.is_err());
359    }
360
361    #[test]
362    fn test_import_plan_create() {
363        let mut store = AuthStore::new();
364        let token = AuthToken {
365            access_token: "test_token".to_string(),
366            token_type: "Bearer".to_string(),
367            expires_at: None,
368            scopes: vec!["read".to_string()],
369        };
370        let exported = base64::encode(serde_json::to_string(&token).unwrap());
371
372        let plan = store.import_with_plan(&exported, true).unwrap();
373        assert_eq!(plan.action, ImportAction::Create);
374        assert!(plan.dry_run);
375        assert!(plan.reason.is_none());
376
377        // Verify no token was set in dry-run
378        assert!(!store.is_authenticated());
379    }
380
381    #[test]
382    fn test_import_plan_update() {
383        let mut store = AuthStore::new();
384        let token1 = AuthToken {
385            access_token: "old_token".to_string(),
386            token_type: "Bearer".to_string(),
387            expires_at: None,
388            scopes: vec!["read".to_string()],
389        };
390        store.set_token(token1);
391
392        let token2 = AuthToken {
393            access_token: "new_token".to_string(),
394            token_type: "Bearer".to_string(),
395            expires_at: None,
396            scopes: vec!["write".to_string()],
397        };
398        let exported = base64::encode(serde_json::to_string(&token2).unwrap());
399
400        let plan = store.import_with_plan(&exported, true).unwrap();
401        assert_eq!(plan.action, ImportAction::Update);
402        assert!(plan.dry_run);
403
404        // Verify old token is still there in dry-run
405        assert_eq!(
406            store.token.as_ref().unwrap().access_token,
407            "old_token".to_string()
408        );
409    }
410
411    #[test]
412    fn test_import_plan_fail() {
413        let mut store = AuthStore::new();
414        let plan = store.import_with_plan("invalid_data", true).unwrap();
415        assert_eq!(plan.action, ImportAction::Fail);
416        assert!(plan.dry_run);
417        assert!(plan.reason.is_some());
418        assert!(plan.reason.unwrap().contains("Invalid"));
419    }
420
421    #[test]
422    fn test_import_plan_actual_import() {
423        let mut store = AuthStore::new();
424        let token = AuthToken {
425            access_token: "test_token".to_string(),
426            token_type: "Bearer".to_string(),
427            expires_at: None,
428            scopes: vec!["read".to_string()],
429        };
430        let exported = base64::encode(serde_json::to_string(&token).unwrap());
431
432        let plan = store.import_with_plan(&exported, false).unwrap();
433        assert_eq!(plan.action, ImportAction::Create);
434        assert!(!plan.dry_run);
435
436        // Verify token was actually set
437        assert!(store.is_authenticated());
438        assert_eq!(
439            store.token.as_ref().unwrap().access_token,
440            "test_token".to_string()
441        );
442    }
443
444    #[test]
445    fn test_stable_writes_same_content() {
446        // Test that writing the same token twice doesn't modify the file
447        let test_dir =
448            std::env::temp_dir().join(format!("auth-stable-test-{}", std::process::id()));
449        std::fs::create_dir_all(&test_dir).unwrap();
450        let test_path = test_dir.join("auth.json");
451
452        let token = AuthToken {
453            access_token: "test_token".to_string(),
454            token_type: "Bearer".to_string(),
455            expires_at: None,
456            scopes: vec!["read".to_string()],
457        };
458
459        // Create store and save token
460        let mut store = AuthStore::with_storage(test_path.clone()).unwrap();
461        store.set_token(token.clone());
462
463        // Get first modification time
464        let metadata1 = std::fs::metadata(&test_path).unwrap();
465        let mtime1 = metadata1.modified().unwrap();
466
467        // Wait to ensure timestamp would change if file was rewritten
468        std::thread::sleep(std::time::Duration::from_millis(100));
469
470        // Save the same token again
471        store.set_token(token);
472
473        // Get second modification time
474        let metadata2 = std::fs::metadata(&test_path).unwrap();
475        let mtime2 = metadata2.modified().unwrap();
476
477        // Timestamps should be identical
478        assert_eq!(
479            mtime1, mtime2,
480            "File should not be rewritten when content is identical"
481        );
482
483        // Cleanup
484        std::fs::remove_dir_all(&test_dir).ok();
485    }
486
487    #[test]
488    fn test_stable_writes_different_content() {
489        // Test that writing different tokens does modify the file
490        let test_dir = std::env::temp_dir().join(format!("auth-diff-test-{}", std::process::id()));
491        std::fs::create_dir_all(&test_dir).unwrap();
492        let test_path = test_dir.join("auth.json");
493
494        let token1 = AuthToken {
495            access_token: "test_token_1".to_string(),
496            token_type: "Bearer".to_string(),
497            expires_at: None,
498            scopes: vec!["read".to_string()],
499        };
500
501        let token2 = AuthToken {
502            access_token: "test_token_2".to_string(),
503            token_type: "Bearer".to_string(),
504            expires_at: None,
505            scopes: vec!["read".to_string()],
506        };
507
508        // Create store and save first token
509        let mut store = AuthStore::with_storage(test_path.clone()).unwrap();
510        store.set_token(token1);
511
512        // Get first modification time
513        let metadata1 = std::fs::metadata(&test_path).unwrap();
514        let mtime1 = metadata1.modified().unwrap();
515
516        // Wait to ensure timestamp would change
517        std::thread::sleep(std::time::Duration::from_millis(100));
518
519        // Save a different token
520        store.set_token(token2);
521
522        // Get second modification time
523        let metadata2 = std::fs::metadata(&test_path).unwrap();
524        let mtime2 = metadata2.modified().unwrap();
525
526        // Timestamps should be different
527        assert_ne!(
528            mtime1, mtime2,
529            "File should be rewritten when content changes"
530        );
531
532        // Cleanup
533        std::fs::remove_dir_all(&test_dir).ok();
534    }
535
536    #[test]
537    fn test_default_storage_path_with_xdg_data_home() {
538        // Use a shared global mutex to prevent parallel test execution from interfering
539        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
540
541        // Save current value
542        let original = std::env::var("XDG_DATA_HOME").ok();
543
544        // Set XDG_DATA_HOME
545        let xdg_path = std::env::temp_dir().join(format!("test-xdg-data-{}", std::process::id()));
546        std::env::set_var("XDG_DATA_HOME", &xdg_path);
547
548        let path = AuthStore::default_storage_path();
549
550        // Restore original value
551        match original {
552            Some(val) => std::env::set_var("XDG_DATA_HOME", val),
553            None => std::env::remove_var("XDG_DATA_HOME"),
554        }
555
556        assert!(path.is_ok());
557        let path = path.unwrap();
558        assert_eq!(path, xdg_path.join("xcom-rs").join("auth.json"));
559    }
560
561    #[test]
562    fn test_default_storage_path_without_xdg() {
563        // Use a shared global mutex to prevent parallel test execution from interfering
564        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
565
566        // Save current value
567        let original = std::env::var("XDG_DATA_HOME").ok();
568
569        // Ensure XDG_DATA_HOME is not set
570        std::env::remove_var("XDG_DATA_HOME");
571
572        let path = AuthStore::default_storage_path();
573
574        // Restore original value
575        if let Some(val) = original {
576            std::env::set_var("XDG_DATA_HOME", val);
577        }
578
579        assert!(path.is_ok());
580        let path = path.unwrap();
581        // Should fall back to ~/.local/share/xcom-rs/auth.json
582        let expected_suffix = std::path::Path::new(".local")
583            .join("share")
584            .join("xcom-rs")
585            .join("auth.json");
586        assert!(path.ends_with(&expected_suffix));
587    }
588}