use std::ops::Deref;
use std::sync::Arc;
use crate::client::ReqwestGatewayApi;
use crate::config::{self, AuthRef, Config, Credential, Profile, SecretStore};
use crate::error::CoreError;
pub struct Session {
profile: String,
url: url::Url,
credential_present: bool,
api: Arc<ReqwestGatewayApi>,
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session")
.field("profile", &self.profile)
.field("api", &"ReqwestGatewayApi")
.finish()
}
}
impl Session {
pub fn resolve(profile_flag: Option<&str>) -> Result<Self, CoreError> {
let mut config = config::load(&config::config_path())?;
Self::resolve_loaded(&mut config, profile_flag).map(|(session, _)| session)
}
pub fn resolve_loaded(
config: &mut Config,
profile_flag: Option<&str>,
) -> Result<(Self, Profile), CoreError> {
let (name, profile) = resolve_selected(config, profile_flag)?;
let credential = config::resolve_secret(&name, &profile.auth, &locked_secret_chain())?;
let api = ReqwestGatewayApi::new(&profile, Some(credential))?;
Ok((
Self {
profile: name,
url: profile.url.clone(),
credential_present: true,
api: Arc::new(api),
},
profile,
))
}
pub fn resolve_side(config: &mut Config, name: &str) -> Result<Self, CoreError> {
let Some((_resolved, profile)) = config::resolve_selection(config, Some(name))? else {
return Err(CoreError::Internal(
"a named profile selection resolved to nothing".to_string(),
));
};
let credential = config::resolve_secret(name, &profile.auth, &locked_secret_chain())?;
let api = ReqwestGatewayApi::new(&profile, Some(credential))?;
Ok(Self {
profile: name.to_string(),
url: profile.url.clone(),
credential_present: true,
api: Arc::new(api),
})
}
pub fn resolve_degraded(profile_flag: Option<&str>) -> Result<Self, CoreError> {
let mut config = config::load(&config::config_path())?;
let (name, profile) = resolve_selected(&mut config, profile_flag)?;
let credential = resolve_secret_opt(&name, &profile.auth)?;
let credential_present = credential.is_some();
let api = ReqwestGatewayApi::new(&profile, credential)?;
Ok(Self {
profile: name,
url: profile.url.clone(),
credential_present,
api: Arc::new(api),
})
}
pub fn for_url(
url: url::Url,
credential: Option<Credential>,
ssl_verify: bool,
) -> Result<Self, CoreError> {
let profile = Profile {
url,
label: None,
ssl_verify,
auth: AuthRef::default(),
webdev_secret: None,
poll_interval_secs: None,
};
let credential_present = credential.is_some();
let api = ReqwestGatewayApi::new(&profile, credential)?;
Ok(Self {
profile: String::new(),
url: profile.url.clone(),
credential_present,
api: Arc::new(api),
})
}
pub fn profile_name(&self) -> &str {
&self.profile
}
pub fn profile_url(&self) -> &url::Url {
&self.url
}
pub fn credential_present(&self) -> bool {
self.credential_present
}
pub fn api(&self) -> &ReqwestGatewayApi {
&self.api
}
pub fn api_handle(&self) -> Arc<ReqwestGatewayApi> {
Arc::clone(&self.api)
}
}
impl Deref for Session {
type Target = ReqwestGatewayApi;
fn deref(&self) -> &Self::Target {
&self.api
}
}
fn locked_secret_chain() -> Vec<Box<dyn SecretStore>> {
vec![
Box::new(config::EnvStore),
Box::new(config::KeyringStore),
Box::new(config::BasicEnvStore),
]
}
fn resolve_selected(
config: &mut config::Config,
flag: Option<&str>,
) -> Result<(String, Profile), CoreError> {
let overlay_target = flag.map(str::to_string).or_else(|| config.active.clone());
config::apply_env_overlay(config, overlay_target.as_deref());
match config::resolve_selection(config, flag)? {
Some((name, profile)) => Ok((name, profile)),
None => Err(CoreError::NoActiveProfile),
}
}
fn resolve_secret_opt(profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
config::resolve_secret(profile, auth, &locked_secret_chain())
.map(Some)
.or_else(|err| match err {
CoreError::SecretUnavailable { .. } => Ok(None),
other => Err(other),
})
}
#[cfg(test)]
mod tests {
use super::{Session, resolve_selected};
use crate::client::GatewayApi;
use crate::config::ENV_LOCK;
use crate::error::CoreError;
fn info_body() -> serde_json::Value {
serde_json::json!({ "ignitionVersion": "8.3.6 (b2026042713)" })
}
fn headers_debug(request: &wiremock::Request) -> String {
format!("{:?}", request.headers).to_lowercase()
}
fn isolate_config(dir: &tempfile::TempDir, toml: &str) {
let path = dir.path().join("config.toml");
std::fs::write(&path, toml).expect("write config fixture");
unsafe { std::env::set_var("IGNITION_CLI_CONFIG", &path) };
}
fn unset(name: &str) {
unsafe { std::env::remove_var(name) };
}
fn two_profile_toml(url_a: &str, url_b: &str) -> String {
format!(
r#"
active = "a"
[profiles.a]
url = "{url_a}"
[profiles.b]
url = "{url_b}"
"#
)
}
async fn mount_info(server: &wiremock::MockServer, expected: u64) -> wiremock::MockGuard {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/gateway-info"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(info_body()))
.expect(expected)
.mount_as_scoped(server)
.await
}
#[tokio::test]
async fn env_overlay_targets_the_selected_profile() {
let mock_a = wiremock::MockServer::start().await;
let mock_b = wiremock::MockServer::start().await;
let guard_a = mount_info(&mock_a, 1).await;
let guard_b = mount_info(&mock_b, 1).await;
let dir = tempfile::tempdir().expect("tempdir");
let session = {
let _lock = ENV_LOCK.lock().expect("env lock");
isolate_config(
&dir,
&two_profile_toml(mock_b.uri().as_str(), mock_b.uri().as_str()),
);
unsafe { std::env::set_var("IGNITION_URL", mock_a.uri()) };
unsafe { std::env::set_var("IGNITION_TOKEN", "overlay-token") };
let session = Session::resolve(Some("b")).expect("resolve selects b");
unsafe { std::env::remove_var("IGNITION_URL") };
unsafe { std::env::remove_var("IGNITION_TOKEN") };
session
};
assert_eq!(session.profile_name(), "b");
let info = session.gateway_info().await.expect("overlay url answers");
assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
assert_eq!(
guard_a.received_requests().await.len(),
1,
"the env overlay URL took the request"
);
assert_eq!(
guard_b.received_requests().await.len(),
0,
"the profile's own URL stayed untouched while the overlay was set"
);
let session = {
let _lock = ENV_LOCK.lock().expect("env lock");
isolate_config(
&dir,
&two_profile_toml(mock_b.uri().as_str(), mock_b.uri().as_str()),
);
unsafe { std::env::set_var("IGNITION_TOKEN", "overlay-token") };
let session = Session::resolve(Some("b")).expect("resolve again");
unsafe { std::env::remove_var("IGNITION_TOKEN") };
unsafe { std::env::remove_var("IGNITION_CLI_CONFIG") };
session
};
let info = session.gateway_info().await.expect("own url answers");
assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
assert_eq!(
guard_b.received_requests().await.len(),
1,
"without the overlay the selected profile's own URL answers"
);
}
#[test]
fn selection_precedence_flag_over_active_and_errors() {
let _lock = ENV_LOCK.lock().expect("env lock");
let dir = tempfile::tempdir().expect("tempdir");
isolate_config(
&dir,
&two_profile_toml("http://a.example:9088/", "http://b.example:9088/"),
);
unsafe { std::env::set_var("IGNITION_TOKEN", "t") };
let session = Session::resolve(Some("b")).expect("flag selects b");
assert_eq!(session.profile_name(), "b");
let err = Session::resolve(Some("nope")).expect_err("unknown errors");
match &err {
CoreError::ProfileNotFound { name, known } => {
assert_eq!(name, "nope");
assert_eq!(known, &vec!["a".to_string(), "b".to_string()]);
}
other => panic!("wrong error class: {other}"),
}
assert_eq!(err.exit_code(), 3);
isolate_config(&dir, "");
assert!(matches!(
Session::resolve(None).expect_err("no selection errors"),
CoreError::NoActiveProfile
));
assert!(matches!(
Session::resolve_degraded(None).expect_err("no selection errors"),
CoreError::NoActiveProfile
));
isolate_config(
&dir,
&two_profile_toml("http://a.example:9088/", "http://b.example:9088/"),
);
unsafe { std::env::set_var("IGNITION_PROFILE", "b") };
let session = Session::resolve(None).expect("active wins without a flag");
assert_eq!(
session.profile_name(),
"a",
"IGNITION_PROFILE folding belongs to the bin, not the seam"
);
unset("IGNITION_PROFILE");
unset("IGNITION_TOKEN");
unset("IGNITION_CLI_CONFIG");
}
#[tokio::test]
async fn locked_chain_env_first_required_errors_degraded_headerless() {
let mock = wiremock::MockServer::start().await;
let guard = mount_info(&mock, 2).await;
let dir = tempfile::tempdir().expect("tempdir");
let session = {
let _lock = ENV_LOCK.lock().expect("env lock");
isolate_config(
&dir,
&two_profile_toml(mock.uri().as_str(), mock.uri().as_str()),
);
unsafe {
std::env::set_var("IGNITION_TOKEN", "chain-token");
std::env::set_var("IGNITION_USER", "admin");
std::env::set_var("IGNITION_PASSWORD", "pw");
}
let session = Session::resolve(Some("a")).expect("env token resolves");
unsafe {
std::env::remove_var("IGNITION_TOKEN");
std::env::remove_var("IGNITION_USER");
std::env::remove_var("IGNITION_PASSWORD");
}
session
};
let info = session.gateway_info().await.expect("token answers");
assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 1, "one token-carrying request so far");
let headers = headers_debug(&requests[0]);
assert!(
headers.contains("x-ignition-api-token"),
"env token must ride the token header: {headers}"
);
assert!(
!headers.contains("authorization"),
"basic pair must lose to the env token: {headers}"
);
{
let _lock = ENV_LOCK.lock().expect("env lock");
isolate_config(
&dir,
&two_profile_toml(mock.uri().as_str(), mock.uri().as_str()),
);
let err = Session::resolve(Some("a")).expect_err("required mode demands a secret");
assert!(matches!(err, CoreError::SecretUnavailable { .. }));
assert_eq!(err.exit_code(), 3);
assert!(
err.hint().expect("hint").contains("IGNITION_TOKEN"),
"hint names the env path"
);
let degraded = Session::resolve_degraded(Some("a")).expect("degraded tolerates none");
unsafe { std::env::remove_var("IGNITION_CLI_CONFIG") };
degraded
}
.gateway_info()
.await
.map(|info| {
assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
})
.expect("headerless answers");
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 2, "the headerless request arrived");
let headers = headers_debug(&requests[1]);
assert!(
!headers.contains("x-ignition-api-token"),
"degraded mode must be header-less: {headers}"
);
assert!(
!headers.contains("authorization"),
"degraded mode must be header-less: {headers}"
);
}
#[test]
fn resolve_selected_maps_none_to_no_active_profile() {
let _lock = ENV_LOCK.lock().expect("env lock");
let dir = tempfile::tempdir().expect("tempdir");
isolate_config(&dir, "");
let mut config = crate::config::load(&crate::config::config_path()).expect("load");
let err = resolve_selected(&mut config, None).expect_err("none → error");
assert!(matches!(err, CoreError::NoActiveProfile));
unset("IGNITION_CLI_CONFIG");
}
}