1use anyhow::Result;
8use serde::Serialize;
9
10use crate::atlassian::error::AtlassianError;
11use crate::utils::env::SystemEnv;
12use crate::utils::secret::Secret;
13use crate::utils::settings::{active_profile_from, Settings};
14
15pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
17
18pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
20
21pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
23
24pub const ATLASSIAN_INSTANCE_OVERRIDE_ENV: &str = "OMNI_DEV_ATLASSIAN_INSTANCE";
31
32#[derive(Debug, Clone)]
34pub struct AtlassianCredentials {
35 pub instance_url: String,
37
38 pub email: String,
40
41 pub api_token: Secret,
43}
44
45pub fn load_credentials() -> Result<AtlassianCredentials> {
53 let env_override = std::env::var(ATLASSIAN_INSTANCE_OVERRIDE_ENV)
54 .ok()
55 .filter(|s| !s.trim().is_empty());
56 load_credentials_with_instance(env_override.as_deref())
57}
58
59pub fn load_credentials_with_instance(
68 instance_override: Option<&str>,
69) -> Result<AtlassianCredentials> {
70 let settings = Settings::load().unwrap_or_default();
71
72 let instance_url = match instance_override {
73 Some(url) => url.to_string(),
74 None => settings
75 .get_env_var(ATLASSIAN_INSTANCE_URL)
76 .ok_or(AtlassianError::CredentialsNotFound)?,
77 };
78 let email = settings
79 .get_env_var(ATLASSIAN_EMAIL)
80 .ok_or(AtlassianError::CredentialsNotFound)?;
81 let api_token = settings
82 .get_env_var(ATLASSIAN_API_TOKEN)
83 .ok_or(AtlassianError::CredentialsNotFound)?;
84
85 let instance_url = instance_url.trim_end_matches('/').to_string();
87
88 Ok(AtlassianCredentials {
89 instance_url,
90 email,
91 api_token: api_token.into(),
92 })
93}
94
95#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
100pub struct AtlassianScopeStatus {
101 pub name: String,
104 pub has_email: bool,
106 pub has_token: bool,
108 #[serde(skip_serializing_if = "Option::is_none")]
112 pub instance_url: Option<String>,
113}
114
115#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
117pub struct AuthStatus {
118 pub scopes: Vec<AtlassianScopeStatus>,
121}
122
123pub fn status() -> AuthStatus {
131 let settings = Settings::load().unwrap_or_default();
132
133 let instance_url = settings
134 .get_env_var(ATLASSIAN_INSTANCE_URL)
135 .map(|v| v.trim_end_matches('/').to_string());
136 let has_email = settings.get_env_var(ATLASSIAN_EMAIL).is_some();
137 let has_token = settings.get_env_var(ATLASSIAN_API_TOKEN).is_some();
138
139 AuthStatus {
140 scopes: vec![AtlassianScopeStatus {
141 name: "default".to_string(),
142 has_email,
143 has_token,
144 instance_url,
145 }],
146 }
147}
148
149pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
155 save_credentials_to(
156 &Settings::get_settings_path()?,
157 active_profile_from(&SystemEnv).as_deref(),
158 credentials,
159 )
160}
161
162pub(crate) fn save_credentials_to(
168 settings_path: &std::path::Path,
169 profile: Option<&str>,
170 credentials: &AtlassianCredentials,
171) -> Result<()> {
172 Settings::upsert_env_vars_in(
173 settings_path,
174 profile,
175 &[
176 (ATLASSIAN_INSTANCE_URL, credentials.instance_url.as_str()),
177 (ATLASSIAN_EMAIL, credentials.email.as_str()),
178 (ATLASSIAN_API_TOKEN, credentials.api_token.expose_secret()),
179 ],
180 )
181}
182
183pub fn remove_credentials() -> Result<bool> {
191 remove_credentials_at(
192 &Settings::get_settings_path()?,
193 active_profile_from(&SystemEnv).as_deref(),
194 )
195}
196
197pub(crate) fn remove_credentials_at(
204 settings_path: &std::path::Path,
205 profile: Option<&str>,
206) -> Result<bool> {
207 Settings::remove_env_vars_in(
208 settings_path,
209 profile,
210 &[ATLASSIAN_INSTANCE_URL, ATLASSIAN_EMAIL, ATLASSIAN_API_TOKEN],
211 )
212}
213
214#[cfg(test)]
220#[allow(clippy::unwrap_used, clippy::expect_used)]
221pub(crate) mod test_util {
222 use super::{
223 ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_OVERRIDE_ENV,
224 ATLASSIAN_INSTANCE_URL,
225 };
226 use crate::utils::settings::PROFILE_ENV_VAR;
227
228 pub(crate) static AUTH_ENV_MUTEX: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
238
239 pub(crate) struct EnvGuard {
245 _lock: std::sync::MutexGuard<'static, ()>,
246 snapshot: Vec<(&'static str, Option<String>)>,
247 }
248
249 impl EnvGuard {
250 pub(crate) fn take() -> Self {
251 let lock = AUTH_ENV_MUTEX
252 .lock()
253 .unwrap_or_else(std::sync::PoisonError::into_inner);
254 let keys = [
255 "HOME",
256 PROFILE_ENV_VAR,
257 ATLASSIAN_INSTANCE_URL,
258 ATLASSIAN_INSTANCE_OVERRIDE_ENV,
259 ATLASSIAN_EMAIL,
260 ATLASSIAN_API_TOKEN,
261 ];
262 let snapshot = keys
263 .into_iter()
264 .map(|k| (k, std::env::var(k).ok()))
265 .collect();
266 Self {
267 _lock: lock,
268 snapshot,
269 }
270 }
271
272 pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
279 let dir = {
280 std::fs::create_dir_all("tmp").ok();
281 tempfile::TempDir::new_in("tmp").unwrap()
282 };
283 std::env::set_var("HOME", dir.path());
284 std::env::remove_var(PROFILE_ENV_VAR);
285 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
286 std::env::remove_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV);
287 std::env::remove_var(ATLASSIAN_EMAIL);
288 std::env::remove_var(ATLASSIAN_API_TOKEN);
289 dir
290 }
291
292 pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
297 let dir = {
298 std::fs::create_dir_all("tmp").ok();
299 tempfile::TempDir::new_in("tmp").unwrap()
300 };
301 std::env::set_var("HOME", dir.path());
302 std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
303 std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
304 std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
305 dir
306 }
307 }
308
309 impl Drop for EnvGuard {
310 fn drop(&mut self) {
311 for (k, v) in &self.snapshot {
312 match v {
313 Some(val) => std::env::set_var(k, val),
314 None => std::env::remove_var(k),
315 }
316 }
317 }
318 }
319}
320
321#[cfg(test)]
322#[allow(clippy::unwrap_used, clippy::expect_used)]
323mod tests {
324 use std::fs;
325
326 use super::*;
327
328 #[test]
329 fn save_and_read_credentials() {
330 let temp_dir = {
331 std::fs::create_dir_all("tmp").ok();
332 tempfile::TempDir::new_in("tmp").unwrap()
333 };
334 let settings_path = temp_dir.path().join("settings.json");
335
336 let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
338 fs::write(&settings_path, existing).unwrap();
339
340 let content = fs::read_to_string(&settings_path).unwrap();
342 let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
343 val["env"]["ATLASSIAN_INSTANCE_URL"] =
344 serde_json::Value::String("https://test.atlassian.net".to_string());
345 val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
346 val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
347 let formatted = serde_json::to_string_pretty(&val).unwrap();
348 fs::write(&settings_path, formatted).unwrap();
349
350 let content = fs::read_to_string(&settings_path).unwrap();
352 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
353 assert_eq!(val["env"]["SOME_KEY"], "value");
354 assert_eq!(
355 val["env"]["ATLASSIAN_INSTANCE_URL"],
356 "https://test.atlassian.net"
357 );
358 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
359 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
360 }
361
362 #[test]
363 fn load_credentials_normalizes_trailing_slash() {
364 let url = "https://env.atlassian.net/";
366 let normalized = url.trim_end_matches('/').to_string();
367 assert_eq!(normalized, "https://env.atlassian.net");
368 }
369
370 #[test]
371 fn constant_key_names() {
372 assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
373 assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
374 assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
375 }
376
377 #[test]
378 fn credentials_struct_clone_and_debug() {
379 let creds = AtlassianCredentials {
380 instance_url: "https://org.atlassian.net".to_string(),
381 email: "user@test.com".to_string(),
382 api_token: "super-sekret-api-token-value".into(),
383 };
384 let cloned = creds.clone();
385 assert_eq!(cloned.instance_url, creds.instance_url);
386 assert_eq!(cloned.email, creds.email);
387 assert_eq!(cloned.api_token, creds.api_token);
388 let debug = format!("{creds:?}");
390 assert!(debug.contains("AtlassianCredentials"));
391 assert!(
392 !debug.contains("super-sekret-api-token-value"),
393 "leaked token: {debug}"
394 );
395 assert!(debug.contains("api_token: <redacted>"));
396 }
397
398 use super::test_util::EnvGuard;
399
400 fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
401 let dir = {
402 std::fs::create_dir_all("tmp").ok();
403 tempfile::TempDir::new_in("tmp").unwrap()
404 };
405 std::env::set_var("HOME", dir.path());
406 std::env::remove_var(crate::utils::settings::PROFILE_ENV_VAR);
407 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
408 std::env::remove_var(ATLASSIAN_EMAIL);
409 std::env::remove_var(ATLASSIAN_API_TOKEN);
410 dir
411 }
412
413 #[test]
414 fn status_reports_all_false_when_nothing_configured() {
415 let guard = EnvGuard::take();
416 let _dir = with_empty_home(&guard);
417
418 let status = status();
419 assert_eq!(status.scopes.len(), 1);
420 let scope = &status.scopes[0];
421 assert_eq!(scope.name, "default");
422 assert!(!scope.has_email);
423 assert!(!scope.has_token);
424 assert_eq!(scope.instance_url, None);
425 }
426
427 #[test]
428 fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
429 let guard = EnvGuard::take();
430 let dir = with_empty_home(&guard);
431 let omni_dir = dir.path().join(".omni-dev");
432 fs::create_dir_all(&omni_dir).unwrap();
433 fs::write(
434 omni_dir.join("settings.json"),
435 r#"{"env":{
436 "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
437 "ATLASSIAN_EMAIL":"person@example.com",
438 "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
439 }}"#,
440 )
441 .unwrap();
442
443 let status = status();
444 assert_eq!(status.scopes.len(), 1);
445 let scope = &status.scopes[0];
446 assert!(scope.has_email);
447 assert!(scope.has_token);
448 assert_eq!(
449 scope.instance_url.as_deref(),
450 Some("https://status.atlassian.net")
451 );
452
453 let yaml = serde_yaml::to_string(&status).unwrap();
454 assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
455 assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
456 }
457
458 #[test]
459 fn status_returns_instance_url_from_env_without_trailing_slash() {
460 let guard = EnvGuard::take();
461 let _dir = with_empty_home(&guard);
462 std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
463
464 let status = status();
465 let scope = &status.scopes[0];
466 assert_eq!(
467 scope.instance_url.as_deref(),
468 Some("https://env.atlassian.net")
469 );
470 assert!(!scope.has_email);
471 assert!(!scope.has_token);
472 }
473
474 #[test]
479 fn save_credentials_resolves_default_settings_path() {
480 let guard = EnvGuard::take();
481 let dir = with_empty_home(&guard);
482
483 let creds = AtlassianCredentials {
484 instance_url: "https://wrapper.atlassian.net".to_string(),
485 email: "wrapper@example.com".to_string(),
486 api_token: "wrapper-token".into(),
487 };
488 save_credentials(&creds).unwrap();
489
490 let settings_path = dir.path().join(".omni-dev").join("settings.json");
491 let val: serde_json::Value =
492 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
493 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "wrapper@example.com");
494 }
495
496 #[test]
500 fn remove_credentials_resolves_default_settings_path() {
501 let guard = EnvGuard::take();
502 let dir = with_empty_home(&guard);
503
504 let creds = AtlassianCredentials {
505 instance_url: "https://wrapper.atlassian.net".to_string(),
506 email: "wrapper@example.com".to_string(),
507 api_token: "wrapper-token".into(),
508 };
509 save_credentials(&creds).unwrap();
510
511 assert!(remove_credentials().unwrap());
513
514 let settings_path = dir.path().join(".omni-dev").join("settings.json");
515 let val: serde_json::Value =
516 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
517 assert!(val["env"].get(ATLASSIAN_EMAIL).is_none());
518 assert!(val["env"].get(ATLASSIAN_API_TOKEN).is_none());
519
520 assert!(!remove_credentials().unwrap());
522 }
523
524 #[test]
528 fn save_credentials_creates_and_preserves() {
529 {
531 let temp_dir = {
532 std::fs::create_dir_all("tmp").ok();
533 tempfile::TempDir::new_in("tmp").unwrap()
534 };
535 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
536
537 let creds = AtlassianCredentials {
538 instance_url: "https://save.atlassian.net".to_string(),
539 email: "save@example.com".to_string(),
540 api_token: "save-token".into(),
541 };
542 save_credentials_to(&settings_path, None, &creds).unwrap();
543
544 assert!(settings_path.exists());
545 let content = fs::read_to_string(&settings_path).unwrap();
546 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
547 assert_eq!(
548 val["env"]["ATLASSIAN_INSTANCE_URL"],
549 "https://save.atlassian.net"
550 );
551 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
552 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
553
554 #[cfg(unix)]
556 {
557 use std::os::unix::fs::PermissionsExt;
558 let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
559 assert_eq!(mode & 0o777, 0o600);
560 }
561 }
562
563 {
565 let temp_dir = {
566 std::fs::create_dir_all("tmp").ok();
567 tempfile::TempDir::new_in("tmp").unwrap()
568 };
569 let omni_dir = temp_dir.path().join(".omni-dev");
570 fs::create_dir_all(&omni_dir).unwrap();
571 let settings_path = omni_dir.join("settings.json");
572 fs::write(
573 &settings_path,
574 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
575 )
576 .unwrap();
577
578 let creds = AtlassianCredentials {
579 instance_url: "https://org.atlassian.net".to_string(),
580 email: "user@test.com".to_string(),
581 api_token: "token".into(),
582 };
583 save_credentials_to(&settings_path, None, &creds).unwrap();
584
585 let content = fs::read_to_string(&settings_path).unwrap();
586 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
587 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
588 assert_eq!(val["extra"], true);
589 assert_eq!(
590 val["env"]["ATLASSIAN_INSTANCE_URL"],
591 "https://org.atlassian.net"
592 );
593 }
594 }
595
596 #[test]
600 fn save_credentials_to_profile_writes_into_profile_env() {
601 let temp_dir = {
602 std::fs::create_dir_all("tmp").ok();
603 tempfile::TempDir::new_in("tmp").unwrap()
604 };
605 let omni_dir = temp_dir.path().join(".omni-dev");
606 fs::create_dir_all(&omni_dir).unwrap();
607 let settings_path = omni_dir.join("settings.json");
608 fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
609
610 let creds = AtlassianCredentials {
611 instance_url: "https://work.atlassian.net".to_string(),
612 email: "work@example.com".to_string(),
613 api_token: "work-token".into(),
614 };
615 save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
616
617 let val: serde_json::Value =
618 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
619 assert_eq!(
620 val["profiles"]["work"]["env"]["ATLASSIAN_EMAIL"],
621 "work@example.com"
622 );
623 assert_eq!(
624 val["profiles"]["work"]["env"]["ATLASSIAN_INSTANCE_URL"],
625 "https://work.atlassian.net"
626 );
627 assert!(val["env"].get("ATLASSIAN_EMAIL").is_none());
628 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
629 }
630
631 #[test]
632 fn load_credentials_with_instance_override_supplies_instance_url() {
633 let guard = EnvGuard::take();
637 let _dir = with_empty_home(&guard);
638 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
639 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
640
641 let creds =
642 load_credentials_with_instance(Some("https://override.atlassian.net/")).unwrap();
643 assert_eq!(creds.instance_url, "https://override.atlassian.net");
644 assert_eq!(creds.email, "person@example.com");
645 assert_eq!(creds.api_token.expose_secret(), "token");
646 }
647
648 #[test]
649 fn load_credentials_with_instance_none_requires_env_instance() {
650 let guard = EnvGuard::take();
653 let _dir = with_empty_home(&guard);
654 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
655 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
656
657 assert!(load_credentials_with_instance(None).is_err());
658 }
659
660 #[test]
661 fn load_credentials_honours_instance_override_env() {
662 let guard = EnvGuard::take();
666 let _dir = with_empty_home(&guard);
667 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
668 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
669
670 std::env::set_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV, " ");
672 assert!(load_credentials().is_err());
673
674 std::env::set_var(
676 ATLASSIAN_INSTANCE_OVERRIDE_ENV,
677 "https://flag.atlassian.net/",
678 );
679 let creds = load_credentials().unwrap();
680 assert_eq!(creds.instance_url, "https://flag.atlassian.net");
681 assert_eq!(creds.email, "person@example.com");
682 }
683}