use crate::client::BugzillaClient;
use crate::client::DetectedServerSettings;
use crate::config::Config;
use crate::error::{BzrError, Result};
use crate::tls::TlsConfig;
use crate::types::ApiMode;
fn persist_detected_settings(
config: &mut Config,
server_name: &str,
settings: &DetectedServerSettings,
persist_auth: bool,
) -> Result<()> {
if let Some(srv_mut) = config.servers.get_mut(server_name) {
if persist_auth {
srv_mut.auth_method = Some(settings.auth_method);
}
if settings.server_version.is_some() {
srv_mut.api_mode = Some(settings.api_mode);
srv_mut.server_version.clone_from(&settings.server_version);
}
config.save()?;
}
Ok(())
}
fn should_offer_tofu(err: &BzrError, tls_config: &TlsConfig) -> bool {
if !tls_uses_default_trust(tls_config) {
return false;
}
matches!(err, BzrError::Http(e) if crate::http::is_tls_cert_error(e))
}
fn tls_uses_default_trust(tls_config: &TlsConfig) -> bool {
!tls_config.insecure && tls_config.ca_cert_path.is_none() && tls_config.pin_sha256.is_none()
}
async fn probe_tls(url: &str, tls_config: &TlsConfig) -> Result<()> {
let client = crate::tls::build_probe_client(tls_config)?;
match client.head(url).send().await {
Ok(_) => Ok(()),
Err(e) => Err(BzrError::Http(e)),
}
}
fn is_pin_mismatch(err: &BzrError) -> bool {
matches!(err, BzrError::Http(e) if {
let chain = crate::error::format_error_chain(e);
chain.contains("PIN_MISMATCH")
})
}
fn is_issuer_changed(err: &BzrError) -> bool {
matches!(err, BzrError::Http(e) if {
let chain = crate::error::format_error_chain(e);
chain.contains("ISSUER_CHANGED")
})
}
fn parse_pin_mismatch_details(chain: &str) -> Option<(String, String)> {
let rest = chain.get(chain.find("PIN_MISMATCH")?..)?;
let got_start = rest.find(", got ")? + ", got ".len();
let after_got = &rest[got_start..];
let issuer_pos = after_got.find(", issuer ")?;
let new_fp = after_got[..issuer_pos].to_string();
let new_issuer = after_got[issuer_pos + ", issuer ".len()..].to_string();
Some((new_fp, new_issuer))
}
fn extract_hostname(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(String::from))
.unwrap_or_else(|| url.to_string())
}
#[cfg_attr(test, mutants::skip)]
async fn handle_tofu(
server_name: &str,
url: &str,
api_key: &str,
email: Option<&str>,
api_override: Option<ApiMode>,
config: &mut Config,
) -> Result<BugzillaClient> {
let hostname = extract_hostname(url);
let (fingerprint, issuer, issuer_der) = crate::tls::tofu::probe_server_cert(url).await?;
let decision = crate::tls::tofu::prompt_tofu(server_name, &hostname, &fingerprint, &issuer)?;
let tls_config = match decision {
Some(true) => {
if let Some(srv) = config.servers.get_mut(server_name) {
srv.tls_pin_sha256 = Some(fingerprint.clone());
srv.tls_pin_issuer = Some(issuer.clone());
srv.tls_pin_issuer_der.clone_from(&issuer_der);
config.save()?;
}
TlsConfig {
pin_sha256: Some(fingerprint),
pin_issuer: Some(issuer),
pin_issuer_der: issuer_der,
server_name: Some(server_name.to_string()),
..Default::default()
}
}
Some(false) => {
TlsConfig {
pin_sha256: Some(fingerprint),
pin_issuer: Some(issuer),
pin_issuer_der: issuer_der,
server_name: Some(server_name.to_string()),
..Default::default()
}
}
None => {
return Err(BzrError::config(
"TLS certificate not trusted. To connect, use one of:\n \
bzr config set-server <NAME> --tls-insecure\n \
bzr config set-server <NAME> --tls-pin-sha256 <PIN>",
));
}
};
detect_and_build_client(
server_name,
url,
api_key,
email,
api_override,
&tls_config,
config,
)
.await
}
#[expect(clippy::too_many_arguments, reason = "private orchestration fn")]
#[cfg_attr(test, mutants::skip)]
async fn handle_pin_rotation(
server_name: &str,
url: &str,
api_key: &str,
email: Option<&str>,
api_override: Option<ApiMode>,
old_pin: &str,
new_fingerprint: &str,
new_issuer: &str,
config: &mut Config,
) -> Result<BugzillaClient> {
let hostname = extract_hostname(url);
let accepted = crate::tls::tofu::prompt_rotation(
server_name,
&hostname,
old_pin,
new_fingerprint,
new_issuer,
)?;
if !accepted {
return Err(BzrError::config(format!(
"certificate rotation rejected for server \"{server_name}\". \
To clear the pin: bzr config set-server {server_name} \
--tls-pin-clear"
)));
}
let existing_issuer_der = config
.servers
.get(server_name)
.and_then(|s| s.tls_pin_issuer_der.clone());
if let Some(srv) = config.servers.get_mut(server_name) {
srv.tls_pin_sha256 = Some(new_fingerprint.to_owned());
srv.tls_pin_issuer = Some(new_issuer.to_owned());
config.save()?;
}
let tls_config = TlsConfig {
pin_sha256: Some(new_fingerprint.to_owned()),
pin_issuer: Some(new_issuer.to_owned()),
pin_issuer_der: existing_issuer_der,
server_name: Some(server_name.to_string()),
..Default::default()
};
detect_and_build_client(
server_name,
url,
api_key,
email,
api_override,
&tls_config,
config,
)
.await
}
async fn detect_and_build_client(
server_name: &str,
url: &str,
api_key: &str,
email: Option<&str>,
api_override: Option<ApiMode>,
tls_config: &crate::tls::TlsConfig,
config: &mut Config,
) -> Result<BugzillaClient> {
let settings = crate::client::detect_server_settings(url, api_key, email, tls_config).await?;
persist_detected_settings(config, server_name, &settings, true)?;
let api_mode = api_override.unwrap_or(settings.api_mode);
BugzillaClient::new(
url,
api_key,
settings.auth_method,
api_mode,
email,
tls_config,
)
}
#[expect(clippy::too_many_arguments, reason = "private orchestration fn")]
async fn classify_and_handle_tls_failure(
err: &BzrError,
server_name: &str,
url: &str,
api_key: &str,
email: Option<&str>,
api_override: Option<ApiMode>,
tls_config: &TlsConfig,
config: &mut Config,
) -> Result<Option<BugzillaClient>> {
if should_offer_tofu(err, tls_config) {
let client = handle_tofu(server_name, url, api_key, email, api_override, config).await?;
return Ok(Some(client));
}
if is_pin_mismatch(err) {
let old_pin = tls_config.pin_sha256.as_deref().unwrap_or("<unknown>");
let chain = match err {
BzrError::Http(re) => crate::error::format_error_chain(re),
_ => String::new(),
};
let (new_fp, new_issuer) = parse_pin_mismatch_details(&chain)
.unwrap_or_else(|| ("<unknown>".to_string(), "<unknown>".to_string()));
let client = handle_pin_rotation(
server_name,
url,
api_key,
email,
api_override,
old_pin,
&new_fp,
&new_issuer,
config,
)
.await?;
return Ok(Some(client));
}
if is_issuer_changed(err) {
return Err(BzrError::config(format!(
"TLS certificate issuer changed for server \"{server_name}\" \
— this could indicate a MITM attack.\n \
If this is expected, clear the pin and re-connect:\n \
bzr config set-server {server_name} --tls-pin-clear"
)));
}
Ok(None)
}
async fn detect_with_tofu_fallback(
server_name: &str,
url: &str,
api_key: &str,
email: Option<&str>,
api_override: Option<ApiMode>,
tls_config: &TlsConfig,
config: &mut Config,
) -> Result<DetectOrClient> {
let err = match crate::client::detect_server_settings(url, api_key, email, tls_config).await {
Ok(settings) => return Ok(DetectOrClient::Settings(settings)),
Err(e) => e,
};
match classify_and_handle_tls_failure(
&err,
server_name,
url,
api_key,
email,
api_override,
tls_config,
config,
)
.await?
{
Some(client) => Ok(DetectOrClient::Client(client)),
None => Err(err),
}
}
enum DetectOrClient {
Settings(DetectedServerSettings),
Client(BugzillaClient),
}
pub async fn connect_and_configure(
server: Option<&str>,
api_override: Option<ApiMode>,
) -> Result<BugzillaClient> {
let mut config = Config::load()?;
let (server_name, srv) = config.resolve_server(server)?;
let tls_config = srv.tls_config(server_name);
let (server_name, url, api_key, email) = (
server_name.to_string(),
srv.url.clone(),
srv.resolve_api_key(server_name)?,
srv.email.clone(),
);
if tls_config.insecure {
tracing::warn!("TLS certificate verification disabled for server '{server_name}'");
}
let (auth, resolved_mode) = match (srv.auth_method, srv.api_mode) {
(Some(method), Some(mode)) => {
if !tls_config.insecure {
if let Err(e) = probe_tls(&url, &tls_config).await {
if let Some(client) = classify_and_handle_tls_failure(
&e,
&server_name,
&url,
&api_key,
email.as_deref(),
api_override,
&tls_config,
&mut config,
)
.await?
{
return Ok(client);
}
}
}
(method, mode)
}
(Some(method), None) => {
tracing::debug!("auth_method cached but api_mode missing; re-detecting");
match detect_with_tofu_fallback(
&server_name,
&url,
&api_key,
email.as_deref(),
api_override,
&tls_config,
&mut config,
)
.await?
{
DetectOrClient::Client(client) => return Ok(client),
DetectOrClient::Settings(settings) => {
persist_detected_settings(&mut config, &server_name, &settings, false)?;
(method, settings.api_mode)
}
}
}
_ => {
match detect_with_tofu_fallback(
&server_name,
&url,
&api_key,
email.as_deref(),
api_override,
&tls_config,
&mut config,
)
.await?
{
DetectOrClient::Client(client) => return Ok(client),
DetectOrClient::Settings(settings) => {
persist_detected_settings(&mut config, &server_name, &settings, true)?;
(settings.auth_method, settings.api_mode)
}
}
}
};
let api_mode = api_override.unwrap_or(resolved_mode);
let client = BugzillaClient::new(
&url,
&api_key,
auth,
api_mode,
email.as_deref(),
&tls_config,
)?;
Ok(client)
}
#[cfg(test)]
#[path = "shared_tests.rs"]
mod tests;