1use anyhow::Result;
2use std::path::PathBuf;
3
4use super::models::{AuthStatus, AuthToken, ImportAction, ImportPlan};
5
6fn 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, }
16}
17
18#[derive(Debug, Clone)]
20pub struct AuthStore {
21 token: Option<AuthToken>,
22 storage_path: Option<PathBuf>,
23}
24
25impl AuthStore {
26 pub fn new() -> Self {
28 Self {
29 token: None,
30 storage_path: None,
31 }
32 }
33
34 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 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 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 pub fn with_default_storage() -> Result<Self> {
72 Self::with_storage(Self::default_storage_path()?)
73 }
74
75 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 let should_write = if path.exists() {
84 match std::fs::read_to_string(path) {
85 Ok(existing_json) => {
86 !json_content_equal(&existing_json, &new_json)
88 }
89 Err(_) => true, }
91 } else {
92 true };
94
95 if should_write {
96 std::fs::write(path, new_json)?;
97 }
98 } else {
99 if path.exists() {
101 std::fs::remove_file(path)?;
102 }
103 }
104 }
105 Ok(())
106 }
107
108 pub fn status(&self) -> AuthStatus {
110 match &self.token {
111 Some(token) => {
112 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 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 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 let json = serde_json::to_string(token)?;
150 Ok(base64::encode(json))
151 }
152
153 pub fn import(&mut self, data: &str) -> Result<()> {
155 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 pub fn import_with_plan(&mut self, data: &str, dry_run: bool) -> Result<ImportPlan> {
172 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 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 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 !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 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 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 pub fn set_token(&mut self, token: AuthToken) {
231 self.token = Some(token);
232 let _ = self.save_to_storage(); }
234
235 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
247mod base64 {
250 use anyhow::Result;
251
252 pub fn encode(data: String) -> String {
253 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 let exported = store.export().unwrap();
332 assert!(!exported.is_empty());
333
334 let mut new_store = AuthStore::new();
336 new_store.import(&exported).unwrap();
337
338 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 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 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 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 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 let mut store = AuthStore::with_storage(test_path.clone()).unwrap();
461 store.set_token(token.clone());
462
463 let metadata1 = std::fs::metadata(&test_path).unwrap();
465 let mtime1 = metadata1.modified().unwrap();
466
467 std::thread::sleep(std::time::Duration::from_millis(100));
469
470 store.set_token(token);
472
473 let metadata2 = std::fs::metadata(&test_path).unwrap();
475 let mtime2 = metadata2.modified().unwrap();
476
477 assert_eq!(
479 mtime1, mtime2,
480 "File should not be rewritten when content is identical"
481 );
482
483 std::fs::remove_dir_all(&test_dir).ok();
485 }
486
487 #[test]
488 fn test_stable_writes_different_content() {
489 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 let mut store = AuthStore::with_storage(test_path.clone()).unwrap();
510 store.set_token(token1);
511
512 let metadata1 = std::fs::metadata(&test_path).unwrap();
514 let mtime1 = metadata1.modified().unwrap();
515
516 std::thread::sleep(std::time::Duration::from_millis(100));
518
519 store.set_token(token2);
521
522 let metadata2 = std::fs::metadata(&test_path).unwrap();
524 let mtime2 = metadata2.modified().unwrap();
525
526 assert_ne!(
528 mtime1, mtime2,
529 "File should be rewritten when content changes"
530 );
531
532 std::fs::remove_dir_all(&test_dir).ok();
534 }
535
536 #[test]
537 fn test_default_storage_path_with_xdg_data_home() {
538 let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
540
541 let original = std::env::var("XDG_DATA_HOME").ok();
543
544 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 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 let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
565
566 let original = std::env::var("XDG_DATA_HOME").ok();
568
569 std::env::remove_var("XDG_DATA_HOME");
571
572 let path = AuthStore::default_storage_path();
573
574 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 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}