use anyhow::Result;
use serde::Serialize;
use crate::atlassian::error::AtlassianError;
use crate::utils::env::SystemEnv;
use crate::utils::secret::Secret;
use crate::utils::settings::{active_profile_from, Settings};
pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
#[derive(Debug, Clone)]
pub struct AtlassianCredentials {
pub instance_url: String,
pub email: String,
pub api_token: Secret,
}
pub fn load_credentials() -> Result<AtlassianCredentials> {
load_credentials_with_instance(None)
}
pub fn load_credentials_with_instance(
instance_override: Option<&str>,
) -> Result<AtlassianCredentials> {
let settings = Settings::load().unwrap_or_default();
let instance_url = match instance_override {
Some(url) => url.to_string(),
None => settings
.get_env_var(ATLASSIAN_INSTANCE_URL)
.ok_or(AtlassianError::CredentialsNotFound)?,
};
let email = settings
.get_env_var(ATLASSIAN_EMAIL)
.ok_or(AtlassianError::CredentialsNotFound)?;
let api_token = settings
.get_env_var(ATLASSIAN_API_TOKEN)
.ok_or(AtlassianError::CredentialsNotFound)?;
let instance_url = instance_url.trim_end_matches('/').to_string();
Ok(AtlassianCredentials {
instance_url,
email,
api_token: api_token.into(),
})
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct AtlassianScopeStatus {
pub name: String,
pub has_email: bool,
pub has_token: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct AuthStatus {
pub scopes: Vec<AtlassianScopeStatus>,
}
pub fn status() -> AuthStatus {
let settings = Settings::load().unwrap_or_default();
let instance_url = settings
.get_env_var(ATLASSIAN_INSTANCE_URL)
.map(|v| v.trim_end_matches('/').to_string());
let has_email = settings.get_env_var(ATLASSIAN_EMAIL).is_some();
let has_token = settings.get_env_var(ATLASSIAN_API_TOKEN).is_some();
AuthStatus {
scopes: vec![AtlassianScopeStatus {
name: "default".to_string(),
has_email,
has_token,
instance_url,
}],
}
}
pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
save_credentials_to(
&Settings::get_settings_path()?,
active_profile_from(&SystemEnv).as_deref(),
credentials,
)
}
pub(crate) fn save_credentials_to(
settings_path: &std::path::Path,
profile: Option<&str>,
credentials: &AtlassianCredentials,
) -> Result<()> {
Settings::upsert_env_vars_in(
settings_path,
profile,
&[
(ATLASSIAN_INSTANCE_URL, credentials.instance_url.as_str()),
(ATLASSIAN_EMAIL, credentials.email.as_str()),
(ATLASSIAN_API_TOKEN, credentials.api_token.expose_secret()),
],
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
pub(crate) mod test_util {
use super::{ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_URL};
use crate::utils::settings::PROFILE_ENV_VAR;
pub(crate) static AUTH_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
snapshot: Vec<(&'static str, Option<String>)>,
}
impl EnvGuard {
pub(crate) fn take() -> Self {
let lock = AUTH_ENV_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let keys = [
"HOME",
PROFILE_ENV_VAR,
ATLASSIAN_INSTANCE_URL,
ATLASSIAN_EMAIL,
ATLASSIAN_API_TOKEN,
];
let snapshot = keys
.into_iter()
.map(|k| (k, std::env::var(k).ok()))
.collect();
Self {
_lock: lock,
snapshot,
}
}
pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
let dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
std::env::set_var("HOME", dir.path());
std::env::remove_var(PROFILE_ENV_VAR);
std::env::remove_var(ATLASSIAN_INSTANCE_URL);
std::env::remove_var(ATLASSIAN_EMAIL);
std::env::remove_var(ATLASSIAN_API_TOKEN);
dir
}
pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
let dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
std::env::set_var("HOME", dir.path());
std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
dir
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (k, v) in &self.snapshot {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::fs;
use super::*;
#[test]
fn save_and_read_credentials() {
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let settings_path = temp_dir.path().join("settings.json");
let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
fs::write(&settings_path, existing).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
val["env"]["ATLASSIAN_INSTANCE_URL"] =
serde_json::Value::String("https://test.atlassian.net".to_string());
val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
let formatted = serde_json::to_string_pretty(&val).unwrap();
fs::write(&settings_path, formatted).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let val: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(val["env"]["SOME_KEY"], "value");
assert_eq!(
val["env"]["ATLASSIAN_INSTANCE_URL"],
"https://test.atlassian.net"
);
assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
}
#[test]
fn load_credentials_normalizes_trailing_slash() {
let url = "https://env.atlassian.net/";
let normalized = url.trim_end_matches('/').to_string();
assert_eq!(normalized, "https://env.atlassian.net");
}
#[test]
fn constant_key_names() {
assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
}
#[test]
fn credentials_struct_clone_and_debug() {
let creds = AtlassianCredentials {
instance_url: "https://org.atlassian.net".to_string(),
email: "user@test.com".to_string(),
api_token: "super-sekret-api-token-value".into(),
};
let cloned = creds.clone();
assert_eq!(cloned.instance_url, creds.instance_url);
assert_eq!(cloned.email, creds.email);
assert_eq!(cloned.api_token, creds.api_token);
let debug = format!("{creds:?}");
assert!(debug.contains("AtlassianCredentials"));
assert!(
!debug.contains("super-sekret-api-token-value"),
"leaked token: {debug}"
);
assert!(debug.contains("api_token: <redacted>"));
}
use super::test_util::EnvGuard;
fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
let dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
std::env::set_var("HOME", dir.path());
std::env::remove_var(crate::utils::settings::PROFILE_ENV_VAR);
std::env::remove_var(ATLASSIAN_INSTANCE_URL);
std::env::remove_var(ATLASSIAN_EMAIL);
std::env::remove_var(ATLASSIAN_API_TOKEN);
dir
}
#[test]
fn status_reports_all_false_when_nothing_configured() {
let guard = EnvGuard::take();
let _dir = with_empty_home(&guard);
let status = status();
assert_eq!(status.scopes.len(), 1);
let scope = &status.scopes[0];
assert_eq!(scope.name, "default");
assert!(!scope.has_email);
assert!(!scope.has_token);
assert_eq!(scope.instance_url, None);
}
#[test]
fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
let guard = EnvGuard::take();
let dir = with_empty_home(&guard);
let omni_dir = dir.path().join(".omni-dev");
fs::create_dir_all(&omni_dir).unwrap();
fs::write(
omni_dir.join("settings.json"),
r#"{"env":{
"ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
"ATLASSIAN_EMAIL":"person@example.com",
"ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
}}"#,
)
.unwrap();
let status = status();
assert_eq!(status.scopes.len(), 1);
let scope = &status.scopes[0];
assert!(scope.has_email);
assert!(scope.has_token);
assert_eq!(
scope.instance_url.as_deref(),
Some("https://status.atlassian.net")
);
let yaml = serde_yaml::to_string(&status).unwrap();
assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
}
#[test]
fn status_returns_instance_url_from_env_without_trailing_slash() {
let guard = EnvGuard::take();
let _dir = with_empty_home(&guard);
std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
let status = status();
let scope = &status.scopes[0];
assert_eq!(
scope.instance_url.as_deref(),
Some("https://env.atlassian.net")
);
assert!(!scope.has_email);
assert!(!scope.has_token);
}
#[test]
fn save_credentials_resolves_default_settings_path() {
let guard = EnvGuard::take();
let dir = with_empty_home(&guard);
let creds = AtlassianCredentials {
instance_url: "https://wrapper.atlassian.net".to_string(),
email: "wrapper@example.com".to_string(),
api_token: "wrapper-token".into(),
};
save_credentials(&creds).unwrap();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "wrapper@example.com");
}
#[test]
fn save_credentials_creates_and_preserves() {
{
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
let creds = AtlassianCredentials {
instance_url: "https://save.atlassian.net".to_string(),
email: "save@example.com".to_string(),
api_token: "save-token".into(),
};
save_credentials_to(&settings_path, None, &creds).unwrap();
assert!(settings_path.exists());
let content = fs::read_to_string(&settings_path).unwrap();
let val: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
val["env"]["ATLASSIAN_INSTANCE_URL"],
"https://save.atlassian.net"
);
assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
{
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let omni_dir = temp_dir.path().join(".omni-dev");
fs::create_dir_all(&omni_dir).unwrap();
let settings_path = omni_dir.join("settings.json");
fs::write(
&settings_path,
r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
)
.unwrap();
let creds = AtlassianCredentials {
instance_url: "https://org.atlassian.net".to_string(),
email: "user@test.com".to_string(),
api_token: "token".into(),
};
save_credentials_to(&settings_path, None, &creds).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let val: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
assert_eq!(val["extra"], true);
assert_eq!(
val["env"]["ATLASSIAN_INSTANCE_URL"],
"https://org.atlassian.net"
);
}
}
#[test]
fn save_credentials_to_profile_writes_into_profile_env() {
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
tempfile::TempDir::new_in("tmp").unwrap()
};
let omni_dir = temp_dir.path().join(".omni-dev");
fs::create_dir_all(&omni_dir).unwrap();
let settings_path = omni_dir.join("settings.json");
fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
let creds = AtlassianCredentials {
instance_url: "https://work.atlassian.net".to_string(),
email: "work@example.com".to_string(),
api_token: "work-token".into(),
};
save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
let val: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
assert_eq!(
val["profiles"]["work"]["env"]["ATLASSIAN_EMAIL"],
"work@example.com"
);
assert_eq!(
val["profiles"]["work"]["env"]["ATLASSIAN_INSTANCE_URL"],
"https://work.atlassian.net"
);
assert!(val["env"].get("ATLASSIAN_EMAIL").is_none());
assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
}
#[test]
fn load_credentials_with_instance_override_supplies_instance_url() {
let guard = EnvGuard::take();
let _dir = with_empty_home(&guard);
std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
std::env::set_var(ATLASSIAN_API_TOKEN, "token");
let creds =
load_credentials_with_instance(Some("https://override.atlassian.net/")).unwrap();
assert_eq!(creds.instance_url, "https://override.atlassian.net");
assert_eq!(creds.email, "person@example.com");
assert_eq!(creds.api_token.expose_secret(), "token");
}
#[test]
fn load_credentials_with_instance_none_requires_env_instance() {
let guard = EnvGuard::take();
let _dir = with_empty_home(&guard);
std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
std::env::set_var(ATLASSIAN_API_TOKEN, "token");
assert!(load_credentials_with_instance(None).is_err());
}
}