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<()> = std::sync::Mutex::new(());
232
233 pub(crate) struct EnvGuard {
239 _lock: std::sync::MutexGuard<'static, ()>,
240 snapshot: Vec<(&'static str, Option<String>)>,
241 }
242
243 impl EnvGuard {
244 pub(crate) fn take() -> Self {
245 let lock = AUTH_ENV_MUTEX
246 .lock()
247 .unwrap_or_else(std::sync::PoisonError::into_inner);
248 let keys = [
249 "HOME",
250 PROFILE_ENV_VAR,
251 ATLASSIAN_INSTANCE_URL,
252 ATLASSIAN_INSTANCE_OVERRIDE_ENV,
253 ATLASSIAN_EMAIL,
254 ATLASSIAN_API_TOKEN,
255 ];
256 let snapshot = keys
257 .into_iter()
258 .map(|k| (k, std::env::var(k).ok()))
259 .collect();
260 Self {
261 _lock: lock,
262 snapshot,
263 }
264 }
265
266 pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
273 let dir = {
274 std::fs::create_dir_all("tmp").ok();
275 tempfile::TempDir::new_in("tmp").unwrap()
276 };
277 std::env::set_var("HOME", dir.path());
278 std::env::remove_var(PROFILE_ENV_VAR);
279 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
280 std::env::remove_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV);
281 std::env::remove_var(ATLASSIAN_EMAIL);
282 std::env::remove_var(ATLASSIAN_API_TOKEN);
283 dir
284 }
285
286 pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
291 let dir = {
292 std::fs::create_dir_all("tmp").ok();
293 tempfile::TempDir::new_in("tmp").unwrap()
294 };
295 std::env::set_var("HOME", dir.path());
296 std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
297 std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
298 std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
299 dir
300 }
301 }
302
303 impl Drop for EnvGuard {
304 fn drop(&mut self) {
305 for (k, v) in &self.snapshot {
306 match v {
307 Some(val) => std::env::set_var(k, val),
308 None => std::env::remove_var(k),
309 }
310 }
311 }
312 }
313}
314
315#[cfg(test)]
316#[allow(clippy::unwrap_used, clippy::expect_used)]
317mod tests {
318 use std::fs;
319
320 use super::*;
321
322 #[test]
323 fn save_and_read_credentials() {
324 let temp_dir = {
325 std::fs::create_dir_all("tmp").ok();
326 tempfile::TempDir::new_in("tmp").unwrap()
327 };
328 let settings_path = temp_dir.path().join("settings.json");
329
330 let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
332 fs::write(&settings_path, existing).unwrap();
333
334 let content = fs::read_to_string(&settings_path).unwrap();
336 let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
337 val["env"]["ATLASSIAN_INSTANCE_URL"] =
338 serde_json::Value::String("https://test.atlassian.net".to_string());
339 val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
340 val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
341 let formatted = serde_json::to_string_pretty(&val).unwrap();
342 fs::write(&settings_path, formatted).unwrap();
343
344 let content = fs::read_to_string(&settings_path).unwrap();
346 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
347 assert_eq!(val["env"]["SOME_KEY"], "value");
348 assert_eq!(
349 val["env"]["ATLASSIAN_INSTANCE_URL"],
350 "https://test.atlassian.net"
351 );
352 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
353 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
354 }
355
356 #[test]
357 fn load_credentials_normalizes_trailing_slash() {
358 let url = "https://env.atlassian.net/";
360 let normalized = url.trim_end_matches('/').to_string();
361 assert_eq!(normalized, "https://env.atlassian.net");
362 }
363
364 #[test]
365 fn constant_key_names() {
366 assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
367 assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
368 assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
369 }
370
371 #[test]
372 fn credentials_struct_clone_and_debug() {
373 let creds = AtlassianCredentials {
374 instance_url: "https://org.atlassian.net".to_string(),
375 email: "user@test.com".to_string(),
376 api_token: "super-sekret-api-token-value".into(),
377 };
378 let cloned = creds.clone();
379 assert_eq!(cloned.instance_url, creds.instance_url);
380 assert_eq!(cloned.email, creds.email);
381 assert_eq!(cloned.api_token, creds.api_token);
382 let debug = format!("{creds:?}");
384 assert!(debug.contains("AtlassianCredentials"));
385 assert!(
386 !debug.contains("super-sekret-api-token-value"),
387 "leaked token: {debug}"
388 );
389 assert!(debug.contains("api_token: <redacted>"));
390 }
391
392 use super::test_util::EnvGuard;
393
394 fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
395 let dir = {
396 std::fs::create_dir_all("tmp").ok();
397 tempfile::TempDir::new_in("tmp").unwrap()
398 };
399 std::env::set_var("HOME", dir.path());
400 std::env::remove_var(crate::utils::settings::PROFILE_ENV_VAR);
401 std::env::remove_var(ATLASSIAN_INSTANCE_URL);
402 std::env::remove_var(ATLASSIAN_EMAIL);
403 std::env::remove_var(ATLASSIAN_API_TOKEN);
404 dir
405 }
406
407 #[test]
408 fn status_reports_all_false_when_nothing_configured() {
409 let guard = EnvGuard::take();
410 let _dir = with_empty_home(&guard);
411
412 let status = status();
413 assert_eq!(status.scopes.len(), 1);
414 let scope = &status.scopes[0];
415 assert_eq!(scope.name, "default");
416 assert!(!scope.has_email);
417 assert!(!scope.has_token);
418 assert_eq!(scope.instance_url, None);
419 }
420
421 #[test]
422 fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
423 let guard = EnvGuard::take();
424 let dir = with_empty_home(&guard);
425 let omni_dir = dir.path().join(".omni-dev");
426 fs::create_dir_all(&omni_dir).unwrap();
427 fs::write(
428 omni_dir.join("settings.json"),
429 r#"{"env":{
430 "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
431 "ATLASSIAN_EMAIL":"person@example.com",
432 "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
433 }}"#,
434 )
435 .unwrap();
436
437 let status = status();
438 assert_eq!(status.scopes.len(), 1);
439 let scope = &status.scopes[0];
440 assert!(scope.has_email);
441 assert!(scope.has_token);
442 assert_eq!(
443 scope.instance_url.as_deref(),
444 Some("https://status.atlassian.net")
445 );
446
447 let yaml = serde_yaml::to_string(&status).unwrap();
448 assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
449 assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
450 }
451
452 #[test]
453 fn status_returns_instance_url_from_env_without_trailing_slash() {
454 let guard = EnvGuard::take();
455 let _dir = with_empty_home(&guard);
456 std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
457
458 let status = status();
459 let scope = &status.scopes[0];
460 assert_eq!(
461 scope.instance_url.as_deref(),
462 Some("https://env.atlassian.net")
463 );
464 assert!(!scope.has_email);
465 assert!(!scope.has_token);
466 }
467
468 #[test]
473 fn save_credentials_resolves_default_settings_path() {
474 let guard = EnvGuard::take();
475 let dir = with_empty_home(&guard);
476
477 let creds = AtlassianCredentials {
478 instance_url: "https://wrapper.atlassian.net".to_string(),
479 email: "wrapper@example.com".to_string(),
480 api_token: "wrapper-token".into(),
481 };
482 save_credentials(&creds).unwrap();
483
484 let settings_path = dir.path().join(".omni-dev").join("settings.json");
485 let val: serde_json::Value =
486 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
487 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "wrapper@example.com");
488 }
489
490 #[test]
494 fn remove_credentials_resolves_default_settings_path() {
495 let guard = EnvGuard::take();
496 let dir = with_empty_home(&guard);
497
498 let creds = AtlassianCredentials {
499 instance_url: "https://wrapper.atlassian.net".to_string(),
500 email: "wrapper@example.com".to_string(),
501 api_token: "wrapper-token".into(),
502 };
503 save_credentials(&creds).unwrap();
504
505 assert!(remove_credentials().unwrap());
507
508 let settings_path = dir.path().join(".omni-dev").join("settings.json");
509 let val: serde_json::Value =
510 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
511 assert!(val["env"].get(ATLASSIAN_EMAIL).is_none());
512 assert!(val["env"].get(ATLASSIAN_API_TOKEN).is_none());
513
514 assert!(!remove_credentials().unwrap());
516 }
517
518 #[test]
522 fn save_credentials_creates_and_preserves() {
523 {
525 let temp_dir = {
526 std::fs::create_dir_all("tmp").ok();
527 tempfile::TempDir::new_in("tmp").unwrap()
528 };
529 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
530
531 let creds = AtlassianCredentials {
532 instance_url: "https://save.atlassian.net".to_string(),
533 email: "save@example.com".to_string(),
534 api_token: "save-token".into(),
535 };
536 save_credentials_to(&settings_path, None, &creds).unwrap();
537
538 assert!(settings_path.exists());
539 let content = fs::read_to_string(&settings_path).unwrap();
540 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
541 assert_eq!(
542 val["env"]["ATLASSIAN_INSTANCE_URL"],
543 "https://save.atlassian.net"
544 );
545 assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
546 assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
547
548 #[cfg(unix)]
550 {
551 use std::os::unix::fs::PermissionsExt;
552 let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
553 assert_eq!(mode & 0o777, 0o600);
554 }
555 }
556
557 {
559 let temp_dir = {
560 std::fs::create_dir_all("tmp").ok();
561 tempfile::TempDir::new_in("tmp").unwrap()
562 };
563 let omni_dir = temp_dir.path().join(".omni-dev");
564 fs::create_dir_all(&omni_dir).unwrap();
565 let settings_path = omni_dir.join("settings.json");
566 fs::write(
567 &settings_path,
568 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
569 )
570 .unwrap();
571
572 let creds = AtlassianCredentials {
573 instance_url: "https://org.atlassian.net".to_string(),
574 email: "user@test.com".to_string(),
575 api_token: "token".into(),
576 };
577 save_credentials_to(&settings_path, None, &creds).unwrap();
578
579 let content = fs::read_to_string(&settings_path).unwrap();
580 let val: serde_json::Value = serde_json::from_str(&content).unwrap();
581 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
582 assert_eq!(val["extra"], true);
583 assert_eq!(
584 val["env"]["ATLASSIAN_INSTANCE_URL"],
585 "https://org.atlassian.net"
586 );
587 }
588 }
589
590 #[test]
594 fn save_credentials_to_profile_writes_into_profile_env() {
595 let temp_dir = {
596 std::fs::create_dir_all("tmp").ok();
597 tempfile::TempDir::new_in("tmp").unwrap()
598 };
599 let omni_dir = temp_dir.path().join(".omni-dev");
600 fs::create_dir_all(&omni_dir).unwrap();
601 let settings_path = omni_dir.join("settings.json");
602 fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
603
604 let creds = AtlassianCredentials {
605 instance_url: "https://work.atlassian.net".to_string(),
606 email: "work@example.com".to_string(),
607 api_token: "work-token".into(),
608 };
609 save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
610
611 let val: serde_json::Value =
612 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
613 assert_eq!(
614 val["profiles"]["work"]["env"]["ATLASSIAN_EMAIL"],
615 "work@example.com"
616 );
617 assert_eq!(
618 val["profiles"]["work"]["env"]["ATLASSIAN_INSTANCE_URL"],
619 "https://work.atlassian.net"
620 );
621 assert!(val["env"].get("ATLASSIAN_EMAIL").is_none());
622 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
623 }
624
625 #[test]
626 fn load_credentials_with_instance_override_supplies_instance_url() {
627 let guard = EnvGuard::take();
631 let _dir = with_empty_home(&guard);
632 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
633 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
634
635 let creds =
636 load_credentials_with_instance(Some("https://override.atlassian.net/")).unwrap();
637 assert_eq!(creds.instance_url, "https://override.atlassian.net");
638 assert_eq!(creds.email, "person@example.com");
639 assert_eq!(creds.api_token.expose_secret(), "token");
640 }
641
642 #[test]
643 fn load_credentials_with_instance_none_requires_env_instance() {
644 let guard = EnvGuard::take();
647 let _dir = with_empty_home(&guard);
648 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
649 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
650
651 assert!(load_credentials_with_instance(None).is_err());
652 }
653
654 #[test]
655 fn load_credentials_honours_instance_override_env() {
656 let guard = EnvGuard::take();
660 let _dir = with_empty_home(&guard);
661 std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
662 std::env::set_var(ATLASSIAN_API_TOKEN, "token");
663
664 std::env::set_var(ATLASSIAN_INSTANCE_OVERRIDE_ENV, " ");
666 assert!(load_credentials().is_err());
667
668 std::env::set_var(
670 ATLASSIAN_INSTANCE_OVERRIDE_ENV,
671 "https://flag.atlassian.net/",
672 );
673 let creds = load_credentials().unwrap();
674 assert_eq!(creds.instance_url, "https://flag.atlassian.net");
675 assert_eq!(creds.email, "person@example.com");
676 }
677}