Skip to main content

xcom_rs/
auth.rs

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