1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7#[serde(rename_all = "lowercase")]
8pub enum ImportAction {
9 Create,
11 Update,
13 Skip,
15 Fail,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ImportPlan {
22 pub action: ImportAction,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub reason: Option<String>,
27 #[serde(rename = "dryRun")]
29 pub dry_run: bool,
30}
31
32impl ImportPlan {
33 pub fn create(dry_run: bool) -> Self {
35 Self {
36 action: ImportAction::Create,
37 reason: None,
38 dry_run,
39 }
40 }
41
42 pub fn update(dry_run: bool) -> Self {
44 Self {
45 action: ImportAction::Update,
46 reason: None,
47 dry_run,
48 }
49 }
50
51 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 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#[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 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 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#[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
116fn 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, }
126}
127
128#[derive(Debug, Clone)]
130pub struct AuthStore {
131 token: Option<AuthToken>,
132 storage_path: Option<PathBuf>,
133}
134
135impl AuthStore {
136 pub fn new() -> Self {
138 Self {
139 token: None,
140 storage_path: None,
141 }
142 }
143
144 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 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 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 pub fn with_default_storage() -> Result<Self> {
182 Self::with_storage(Self::default_storage_path()?)
183 }
184
185 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 let should_write = if path.exists() {
194 match std::fs::read_to_string(path) {
195 Ok(existing_json) => {
196 !json_content_equal(&existing_json, &new_json)
198 }
199 Err(_) => true, }
201 } else {
202 true };
204
205 if should_write {
206 std::fs::write(path, new_json)?;
207 }
208 } else {
209 if path.exists() {
211 std::fs::remove_file(path)?;
212 }
213 }
214 }
215 Ok(())
216 }
217
218 pub fn status(&self) -> AuthStatus {
220 match &self.token {
221 Some(token) => {
222 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 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 let json = serde_json::to_string(token)?;
253 Ok(base64::encode(json))
254 }
255
256 pub fn import(&mut self, data: &str) -> Result<()> {
258 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 pub fn import_with_plan(&mut self, data: &str, dry_run: bool) -> Result<ImportPlan> {
275 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 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 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 !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 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 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 pub fn set_token(&mut self, token: AuthToken) {
334 self.token = Some(token);
335 let _ = self.save_to_storage(); }
337
338 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
350mod base64 {
353 use anyhow::Result;
354
355 pub fn encode(data: String) -> String {
356 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 let exported = store.export().unwrap();
435 assert!(!exported.is_empty());
436
437 let mut new_store = AuthStore::new();
439 new_store.import(&exported).unwrap();
440
441 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 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 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 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 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 let mut store = AuthStore::with_storage(test_path.clone()).unwrap();
564 store.set_token(token.clone());
565
566 let metadata1 = std::fs::metadata(&test_path).unwrap();
568 let mtime1 = metadata1.modified().unwrap();
569
570 std::thread::sleep(std::time::Duration::from_millis(100));
572
573 store.set_token(token);
575
576 let metadata2 = std::fs::metadata(&test_path).unwrap();
578 let mtime2 = metadata2.modified().unwrap();
579
580 assert_eq!(
582 mtime1, mtime2,
583 "File should not be rewritten when content is identical"
584 );
585
586 std::fs::remove_dir_all(&test_dir).ok();
588 }
589
590 #[test]
591 fn test_stable_writes_different_content() {
592 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 let mut store = AuthStore::with_storage(test_path.clone()).unwrap();
613 store.set_token(token1);
614
615 let metadata1 = std::fs::metadata(&test_path).unwrap();
617 let mtime1 = metadata1.modified().unwrap();
618
619 std::thread::sleep(std::time::Duration::from_millis(100));
621
622 store.set_token(token2);
624
625 let metadata2 = std::fs::metadata(&test_path).unwrap();
627 let mtime2 = metadata2.modified().unwrap();
628
629 assert_ne!(
631 mtime1, mtime2,
632 "File should be rewritten when content changes"
633 );
634
635 std::fs::remove_dir_all(&test_dir).ok();
637 }
638
639 #[test]
640 fn test_default_storage_path_with_xdg_data_home() {
641 let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
643
644 let original = std::env::var("XDG_DATA_HOME").ok();
646
647 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 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 let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
668
669 let original = std::env::var("XDG_DATA_HOME").ok();
671
672 std::env::remove_var("XDG_DATA_HOME");
674
675 let path = AuthStore::default_storage_path();
676
677 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 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}