use std::path::PathBuf;
use std::time::Duration;
use crate::errors::*;
use crate::get_target;
use crate::http_client::HeaderMap;
use crate::http_client::header;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum AuthScheme {
#[default]
Token,
#[cfg_attr(not(feature = "gitlab"), allow(dead_code))]
Bearer,
}
impl AuthScheme {
fn prefix(self) -> &'static str {
match self {
AuthScheme::Token => "token",
AuthScheme::Bearer => "Bearer",
}
}
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
#[derive(Debug)]
pub(crate) struct NonSemverTagError {
tag: String,
source: Box<dyn std::error::Error + Send + Sync>,
}
impl std::fmt::Display for NonSemverTagError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"release tag `{}` is not a semver version: {}",
self.tag, self.source
)
}
}
impl std::error::Error for NonSemverTagError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&*self.source)
}
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn name_tag_in_semver_error(tag: &str, err: Error) -> Error {
match err {
Error::SemVer(inner) => Error::SemVer(Box::new(NonSemverTagError {
tag: tag.to_owned(),
source: inner,
})),
other => other,
}
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn strip_tag_prefix(tag: &str, prefix: Option<&str>) -> Option<String> {
match prefix {
None => Some(tag.trim_start_matches('v').to_owned()),
Some(p) => tag
.strip_prefix(p)
.map(|rest| rest.trim_start_matches('v').to_owned()),
}
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn tag_prefix_mismatch_error(tag: &str, prefix: &str) -> Error {
Error::SemVer(Box::new(crate::errors::MessageError(format!(
"release tag `{tag}` does not start with the configured tag_prefix `{prefix}`"
))))
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn first_env_token(candidates: &[(&str, Option<String>)]) -> Option<String> {
for (name, value) in candidates {
let Some(value) = value else { continue };
let value = value.trim();
if value.is_empty() {
continue;
}
log::debug!("self_update: using the auth token from ${name}");
return Some(value.to_owned());
}
None
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn env_token_value(name: &str, raw: Option<std::ffi::OsString>) -> Option<String> {
match raw?.into_string() {
Ok(value) => Some(value),
Err(_) => {
log::warn!(
"self_update: ignoring ${name}: its value is not valid UTF-8, so it cannot be used \
as an auth token. The request proceeds as if it were unset."
);
None
}
}
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn token_from_env(names: &[&str]) -> Option<String> {
let candidates = names
.iter()
.map(|name| (*name, env_token_value(name, std::env::var_os(name))))
.collect::<Vec<_>>();
first_env_token(&candidates)
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn is_blank_token(token: Option<&str>) -> bool {
token.is_none_or(|t| t.trim().is_empty())
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn fill_env_token_if_unset_with(
slot: &mut Option<String>,
resolve: impl FnOnce() -> Option<String>,
) -> bool {
if !is_blank_token(slot.as_deref()) {
return false;
}
match resolve() {
Some(token) => {
*slot = Some(token);
true
}
None => false,
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn fill_env_token_if_unset(slot: &mut Option<String>, resolved: Option<String>) -> bool {
fill_env_token_if_unset_with(slot, || resolved)
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn host_of(url: &str) -> Option<String> {
url.parse::<http::Uri>().ok()?.host().map(|h| {
h.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase()
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) enum EnvTokenDecision {
Sent,
WarnedAndSent,
Withheld,
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
fn host_is_acknowledged(host: &str, auth_hosts: &[String], canonical_host: Option<&str>) -> bool {
canonical_host.is_some_and(|canonical| canonical.eq_ignore_ascii_case(host))
|| auth_hosts.iter().any(|h| h.eq_ignore_ascii_case(host))
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn env_token_host_decision(
env_sourced: bool,
auth_base_host: Option<&str>,
auth_hosts: &[String],
canonical_host: Option<&str>,
) -> EnvTokenDecision {
if !env_sourced {
return EnvTokenDecision::Sent;
}
let Some(host) = auth_base_host else {
return EnvTokenDecision::Sent;
};
if host_is_acknowledged(host, auth_hosts, canonical_host) {
return EnvTokenDecision::Sent;
}
match canonical_host {
Some(canonical) => {
log::warn!(
"self_update: the auth token resolved from the environment will be sent to `{host}`, \
which is not `{canonical}`. The environment variables are conventions of the \
backend's own service, so a token meant for `{canonical}` may be exposed to a \
different host. Set the token explicitly with auth_token(..), or acknowledge this \
host with allow_auth_host(..), if it is intended."
);
EnvTokenDecision::WarnedAndSent
}
None => {
log::warn!(
"self_update: withholding the auth token resolved from the environment: `{host}` was \
not explicitly acknowledged. This backend has no canonical host to compare an \
env-sourced token against, so -- rather than silently binding an ambient credential \
to whatever host the application happens to be pointed at -- the token is not \
attached and the request proceeds anonymously. Set the token explicitly with \
auth_token(..), or acknowledge this host with allow_auth_host(..), to send it."
);
EnvTokenDecision::Withheld
}
}
}
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub(crate) fn set_explicit_auth_token(
slot: &mut Option<String>,
env_sourced: &mut bool,
value: impl Into<String>,
) {
*slot = Some(value.into());
*env_sourced = false;
}
#[cfg(feature = "progress-bar")]
use crate::{DEFAULT_PROGRESS_CHARS, DEFAULT_PROGRESS_TEMPLATE};
#[derive(Clone)]
pub(crate) struct RequestConfig {
pub(crate) timeout: Option<Duration>,
pub(crate) headers: HeaderMap,
pub(crate) retries: u32,
pub(crate) retry_base_delay: Duration,
pub(crate) retry_max_delay: Duration,
pub(crate) auth_scheme: AuthScheme,
pub(crate) auth_token: Option<String>,
pub(crate) client: Option<std::sync::Arc<dyn crate::http_client::HttpClient>>,
#[cfg(feature = "async")]
pub(crate) async_client: Option<std::sync::Arc<dyn crate::http_client::AsyncHttpClient>>,
pub(crate) header_error: Option<String>,
pub(crate) root_certificates: Vec<crate::tls::Certificate>,
pub(crate) cert_error: Option<String>,
pub(crate) proxy: Option<String>,
pub(crate) proxy_error: Option<String>,
pub(crate) auth_base_host: Option<String>,
pub(crate) auth_hosts: Vec<String>,
pub(crate) allow_insecure_auth: bool,
}
pub(crate) const DEFAULT_RETRY_BASE_DELAY: Duration = Duration::from_millis(100);
pub(crate) const DEFAULT_RETRY_MAX_DELAY: Duration = Duration::from_millis(3200);
impl Default for RequestConfig {
fn default() -> Self {
Self {
timeout: None,
headers: HeaderMap::new(),
retries: 0,
retry_base_delay: DEFAULT_RETRY_BASE_DELAY,
retry_max_delay: DEFAULT_RETRY_MAX_DELAY,
auth_scheme: AuthScheme::default(),
auth_token: None,
client: None,
#[cfg(feature = "async")]
async_client: None,
header_error: None,
root_certificates: Vec::new(),
cert_error: None,
proxy: None,
proxy_error: None,
auth_base_host: None,
auth_hosts: Vec::new(),
allow_insecure_auth: false,
}
}
}
impl std::fmt::Debug for RequestConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
timeout,
headers,
retries,
retry_base_delay,
retry_max_delay,
auth_scheme,
auth_token,
client,
#[cfg(feature = "async")]
async_client,
header_error,
root_certificates,
cert_error,
proxy,
proxy_error,
auth_base_host,
auth_hosts,
allow_insecure_auth,
} = self;
let mut s = f.debug_struct("RequestConfig");
s.field("timeout", timeout)
.field("headers", headers)
.field("retries", retries)
.field("retry_base_delay", retry_base_delay)
.field("retry_max_delay", retry_max_delay)
.field("auth_scheme", auth_scheme)
.field("auth_token", &auth_token.as_ref().map(|_| "<token>"))
.field("client", &client.as_ref().map(|_| "<http_client>"));
#[cfg(feature = "async")]
s.field(
"async_client",
&async_client.as_ref().map(|_| "<async_http_client>"),
);
s.field("header_error", header_error)
.field(
"root_certificates",
&format_args!("<{} root_certificates>", root_certificates.len()),
)
.field("cert_error", cert_error)
.field(
"proxy",
&proxy.as_deref().map(crate::errors::redact_proxy_url),
)
.field("proxy_error", proxy_error)
.field("auth_base_host", auth_base_host)
.field("auth_hosts", auth_hosts)
.field("allow_insecure_auth", allow_insecure_auth)
.finish()
}
}
fn header_name_is_credential_bearing(name: &str) -> bool {
matches!(name, "authorization" | "private-token" | "cookie") || name.ends_with("-token")
}
impl RequestConfig {
pub(crate) fn insert_header<N, V>(&mut self, name: N, value: V)
where
N: ::core::convert::TryInto<crate::http_client::header::HeaderName>,
V: ::core::convert::TryInto<crate::http_client::header::HeaderValue>,
{
let name = match name.try_into() {
Ok(n) => n,
Err(_) => {
if self.header_error.is_none() {
self.header_error =
Some("invalid HTTP header name passed to `request_header`".to_string());
}
return;
}
};
let mut value = match value.try_into() {
Ok(v) => v,
Err(_) => {
if self.header_error.is_none() {
self.header_error =
Some("invalid HTTP header value passed to `request_header`".to_string());
}
return;
}
};
if header_name_is_credential_bearing(name.as_str()) {
value.set_sensitive(true);
}
self.headers.insert(name, value);
}
pub(crate) fn apply_auth(&self, url: &str, headers: &mut HeaderMap) -> Result<()> {
if self.headers.contains_key(header::AUTHORIZATION) {
return Ok(());
}
let Some(token) = self.auth_token.as_deref() else {
return Ok(());
};
if is_blank_token(Some(token)) {
return Ok(());
}
if !self.auth_allowed_for(url) {
log::warn!(
"self_update: not attaching the auth token to {url}: its host is not the configured \
API host and is not in the allow_auth_host set (or the scheme is not https). The \
request proceeds without authorization."
);
return Ok(());
}
let mut value = format!("{} {}", self.auth_scheme.prefix(), token)
.parse::<header::HeaderValue>()
.map_err(|err| Error::InvalidAuthToken {
source: Box::new(err),
})?;
value.set_sensitive(true);
headers.insert(header::AUTHORIZATION, value);
Ok(())
}
pub(crate) fn auth_allowed_for(&self, url: &str) -> bool {
let uri = match url.parse::<http::Uri>() {
Ok(u) => u,
Err(_) => return false,
};
let host = match uri.host() {
Some(h) => h
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase(),
None => return false,
};
let host_matches = self
.auth_base_host
.as_deref()
.is_some_and(|b| b.eq_ignore_ascii_case(&host))
|| self
.auth_hosts
.iter()
.any(|h| h.eq_ignore_ascii_case(&host));
if !host_matches {
return false;
}
let is_loopback = host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false);
uri.scheme_str() == Some("https") || is_loopback || self.allow_insecure_auth
}
pub(crate) fn build_client(&mut self) {
let config = crate::http_client::ClientConfig {
certs: &self.root_certificates,
proxy: self.proxy.as_deref(),
};
if config.is_empty() {
return;
}
let mut record = |e: crate::http_client::ClientConfigError| {
let (slot, message) = match e {
crate::http_client::ClientConfigError::Proxy(e) => (&mut self.proxy_error, e),
crate::http_client::ClientConfigError::Other(e)
if self.root_certificates.is_empty() =>
{
(&mut self.proxy_error, e)
}
crate::http_client::ClientConfigError::Other(e) => (&mut self.cert_error, e),
};
if slot.is_none() {
*slot = Some(message.to_string());
}
};
if self.client.is_none() {
match crate::http_client::build_configured_client(config) {
Ok(c) => self.client = Some(c),
Err(e) => record(e),
}
}
#[cfg(feature = "async")]
if self.async_client.is_none() {
match crate::http_client::build_configured_async_client(config) {
Ok(c) => self.async_client = Some(c),
Err(e) => record(e),
}
}
}
pub(crate) fn check(&self) -> Result<()> {
if let Some(msg) = &self.header_error {
return Err(Error::InvalidHeader {
source: Box::new(crate::errors::MessageError(msg.clone())),
});
}
if let Some(msg) = &self.cert_error {
return Err(Error::InvalidCertificate {
source: Box::new(crate::errors::MessageError(msg.clone())),
});
}
if let Some(msg) = &self.proxy_error {
return Err(Error::InvalidProxy {
source: Box::new(crate::errors::MessageError(msg.clone())),
});
}
Ok(())
}
}
#[derive(Clone)]
pub(crate) struct CommonBuilderConfig {
pub request: RequestConfig,
pub target: Option<String>,
pub asset_identifier: Option<String>,
pub bin_name: Option<String>,
pub bin_install_path: Option<PathBuf>,
pub check_install_path_writable: bool,
pub bin_path_in_archive: Option<String>,
pub(crate) bin_path_in_archive_auto: bool,
pub bundle_path_in_archive: Option<String>,
pub bundle_install_path: Option<PathBuf>,
pub show_download_progress: bool,
pub show_output: bool,
pub no_confirm: bool,
pub show_release_notes: bool,
pub update_strategy: crate::update::UpdateStrategy,
pub tag_prefix: Option<String>,
pub current_version: Option<String>,
pub release_tag: Option<String>,
#[cfg(feature = "progress-bar")]
pub progress_template: String,
#[cfg(feature = "progress-bar")]
pub progress_chars: String,
pub auth_token: Option<String>,
pub auth_token_from_env: bool,
pub auth_scheme: AuthScheme,
pub progress_callback: Option<crate::ProgressCallback>,
pub verify: Option<crate::VerifyCallback>,
pub verify_archive: Option<crate::VerifyCallback>,
pub asset_matcher: Option<crate::AssetMatcher>,
#[cfg(feature = "checksums")]
pub checksum: Option<crate::Checksum>,
#[cfg(feature = "checksums")]
pub checksum_from_asset: Option<String>,
#[cfg(feature = "checksums")]
pub verify_release_digest: bool,
#[cfg(feature = "signatures")]
pub verifying_keys: Vec<[u8; zipsign_api::PUBLIC_KEY_LENGTH]>,
}
impl std::fmt::Debug for CommonBuilderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
request,
target,
asset_identifier,
bin_name,
bin_install_path,
check_install_path_writable,
bin_path_in_archive,
bin_path_in_archive_auto,
bundle_path_in_archive,
bundle_install_path,
show_download_progress,
show_output,
no_confirm,
show_release_notes,
update_strategy,
tag_prefix,
current_version,
release_tag,
#[cfg(feature = "progress-bar")]
progress_template,
#[cfg(feature = "progress-bar")]
progress_chars,
auth_token,
auth_token_from_env,
auth_scheme,
progress_callback,
verify,
verify_archive,
asset_matcher,
#[cfg(feature = "checksums")]
checksum,
#[cfg(feature = "checksums")]
checksum_from_asset,
#[cfg(feature = "checksums")]
verify_release_digest,
#[cfg(feature = "signatures")]
verifying_keys,
} = self;
let mut s = f.debug_struct("CommonBuilderConfig");
s.field("request", request)
.field("target", target)
.field("asset_identifier", asset_identifier)
.field("bin_name", bin_name)
.field("bin_install_path", bin_install_path)
.field("check_install_path_writable", check_install_path_writable)
.field("bin_path_in_archive", bin_path_in_archive)
.field("bin_path_in_archive_auto", bin_path_in_archive_auto)
.field("bundle_path_in_archive", bundle_path_in_archive)
.field("bundle_install_path", bundle_install_path)
.field("show_download_progress", show_download_progress)
.field("show_output", show_output)
.field("no_confirm", no_confirm)
.field("show_release_notes", show_release_notes)
.field("update_strategy", update_strategy)
.field("tag_prefix", tag_prefix)
.field("current_version", current_version)
.field("release_tag", release_tag);
#[cfg(feature = "progress-bar")]
s.field("progress_template", progress_template)
.field("progress_chars", progress_chars);
s.field("auth_token", &auth_token.as_ref().map(|_| "<token>"))
.field("auth_token_from_env", auth_token_from_env)
.field("auth_scheme", auth_scheme)
.field("progress_callback", progress_callback)
.field("verify", verify)
.field("verify_archive", verify_archive)
.field("asset_matcher", asset_matcher);
#[cfg(feature = "checksums")]
s.field("checksum", checksum)
.field("checksum_from_asset", checksum_from_asset)
.field("verify_release_digest", verify_release_digest);
#[cfg(feature = "signatures")]
s.field("verifying_keys", verifying_keys);
s.finish()
}
}
impl Default for CommonBuilderConfig {
fn default() -> Self {
Self {
request: RequestConfig::default(),
target: None,
asset_identifier: None,
bin_name: None,
bin_install_path: None,
check_install_path_writable: false,
bin_path_in_archive: None,
bin_path_in_archive_auto: false,
bundle_path_in_archive: None,
bundle_install_path: None,
show_download_progress: false,
show_output: true,
no_confirm: false,
show_release_notes: false,
update_strategy: crate::update::UpdateStrategy::default(),
tag_prefix: None,
current_version: None,
release_tag: None,
#[cfg(feature = "progress-bar")]
progress_template: DEFAULT_PROGRESS_TEMPLATE.to_string(),
#[cfg(feature = "progress-bar")]
progress_chars: DEFAULT_PROGRESS_CHARS.to_string(),
auth_token: None,
auth_token_from_env: false,
auth_scheme: AuthScheme::default(),
progress_callback: None,
verify: None,
verify_archive: None,
asset_matcher: None,
#[cfg(feature = "checksums")]
checksum: None,
#[cfg(feature = "checksums")]
checksum_from_asset: None,
#[cfg(feature = "checksums")]
verify_release_digest: true,
#[cfg(feature = "signatures")]
verifying_keys: vec![],
}
}
}
impl CommonBuilderConfig {
pub(crate) fn build(&self) -> Result<CommonConfig> {
let (bundle_path_in_archive, bundle_install_path) = self.resolve_bundle_mode()?;
let mut request = self.request.clone();
request.auth_scheme = self.auth_scheme;
request.auth_token = self.auth_token.clone();
request.build_client();
request.check()?;
Ok(CommonConfig {
request,
target: self
.target
.clone()
.unwrap_or_else(|| get_target().to_owned()),
asset_identifier: self.asset_identifier.clone(),
current_version: self.current_version.clone().ok_or(Error::MissingField {
field: "current_version",
})?,
release_tag: self.release_tag.clone(),
tag_prefix: self.tag_prefix.clone(),
bin_name: self
.bin_name
.clone()
.ok_or(Error::MissingField { field: "bin_name" })?,
bin_install_path: match &self.bin_install_path {
Some(p) => p.clone(),
None => std::env::current_exe()?,
},
check_install_path_writable: self.check_install_path_writable,
bin_path_in_archive: self
.bin_path_in_archive
.clone()
.ok_or(Error::MissingField {
field: "bin_path_in_archive",
})?,
bundle_path_in_archive,
bundle_install_path,
show_download_progress: self.show_download_progress,
show_output: self.show_output,
no_confirm: self.no_confirm,
show_release_notes: self.show_release_notes,
update_strategy: self.update_strategy,
#[cfg(feature = "progress-bar")]
progress_template: self.progress_template.clone(),
#[cfg(feature = "progress-bar")]
progress_chars: self.progress_chars.clone(),
progress_callback: self.progress_callback.clone(),
verify: self.verify.clone(),
verify_archive: self.verify_archive.clone(),
asset_matcher: self.asset_matcher.clone(),
#[cfg(feature = "checksums")]
checksum: self.checksum.clone(),
#[cfg(feature = "checksums")]
checksum_from_asset: self.checksum_from_asset.clone(),
#[cfg(feature = "checksums")]
verify_release_digest: self.verify_release_digest,
#[cfg(feature = "signatures")]
verifying_keys: self.verifying_keys.clone(),
})
}
fn resolve_bundle_mode(&self) -> Result<(Option<String>, Option<PathBuf>)> {
let Some(path_in_archive) = self.bundle_path_in_archive.clone() else {
if self.bundle_install_path.is_some() {
return Err(Error::MissingField {
field: "bundle_path_in_archive",
});
}
return Ok((None, None));
};
if self.bin_install_path.is_some() {
return Err(Error::ConflictingConfig {
field: "bundle_path_in_archive",
conflict: "bin_install_path",
});
}
if self.bin_path_in_archive.is_some() && !self.bin_path_in_archive_auto {
return Err(Error::ConflictingConfig {
field: "bundle_path_in_archive",
conflict: "bin_path_in_archive",
});
}
let install_path = match &self.bundle_install_path {
Some(p) => p.clone(),
None => crate::update::default_bundle_install_path()?,
};
Ok((Some(path_in_archive), Some(install_path)))
}
}
#[derive(Debug)]
pub(crate) struct CommonConfig {
pub request: RequestConfig,
pub target: String,
pub asset_identifier: Option<String>,
pub current_version: String,
pub release_tag: Option<String>,
#[cfg_attr(
not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "gitee"
)),
allow(dead_code)
)]
pub tag_prefix: Option<String>,
pub bin_name: String,
pub bin_install_path: PathBuf,
pub check_install_path_writable: bool,
pub bin_path_in_archive: String,
pub bundle_path_in_archive: Option<String>,
pub bundle_install_path: Option<PathBuf>,
pub show_download_progress: bool,
pub show_output: bool,
pub no_confirm: bool,
pub show_release_notes: bool,
pub update_strategy: crate::update::UpdateStrategy,
#[cfg(feature = "progress-bar")]
pub progress_template: String,
#[cfg(feature = "progress-bar")]
pub progress_chars: String,
pub progress_callback: Option<crate::ProgressCallback>,
pub verify: Option<crate::VerifyCallback>,
pub verify_archive: Option<crate::VerifyCallback>,
pub asset_matcher: Option<crate::AssetMatcher>,
#[cfg(feature = "checksums")]
pub checksum: Option<crate::Checksum>,
#[cfg(feature = "checksums")]
pub checksum_from_asset: Option<String>,
#[cfg(feature = "checksums")]
pub verify_release_digest: bool,
#[cfg(feature = "signatures")]
pub verifying_keys: Vec<[u8; zipsign_api::PUBLIC_KEY_LENGTH]>,
}
#[cfg(test)]
mod tests {
use super::{CommonBuilderConfig, RequestConfig};
use std::path::PathBuf;
use std::time::Duration;
#[cfg(feature = "async")]
const BAD_PEM_CERT: &[u8] =
b"-----BEGIN CERTIFICATE-----\nbm90IGEgdmFsaWQgY2VydA==\n-----END CERTIFICATE-----\n";
fn candidates<'a>(pairs: &[(&'a str, Option<&str>)]) -> Vec<(&'a str, Option<String>)> {
pairs
.iter()
.map(|(name, value)| (*name, value.map(str::to_owned)))
.collect()
}
#[test]
fn first_env_token_takes_the_first_present_value() {
let got = super::first_env_token(&candidates(&[
("GH_TOKEN", Some("first")),
("GITHUB_TOKEN", Some("second")),
]));
assert_eq!(
got.as_deref(),
Some("first"),
"the earlier variable must win over a later one"
);
}
#[test]
fn first_env_token_skips_empty_and_whitespace_values() {
let got = super::first_env_token(&candidates(&[
("GITHUB_TOKEN", Some("")),
("GH_TOKEN", Some(" ")),
("OTHER_TOKEN", Some("real")),
]));
assert_eq!(
got.as_deref(),
Some("real"),
"empty and whitespace-only values must be skipped"
);
}
#[test]
fn first_env_token_trims_surrounding_whitespace() {
let got = super::first_env_token(&candidates(&[("GITHUB_TOKEN", Some(" ghp_abc\n"))]));
assert_eq!(got.as_deref(), Some("ghp_abc"));
}
#[test]
fn first_env_token_returns_none_when_nothing_is_set() {
assert_eq!(
super::first_env_token(&candidates(&[
("GITHUB_TOKEN", None),
("GH_TOKEN", Some(" ")),
])),
None,
"no present, non-empty candidate must resolve to None"
);
}
#[test]
fn fill_env_token_if_unset_keeps_an_explicit_token() {
let mut slot = Some("explicit".to_string());
let filled = super::fill_env_token_if_unset(&mut slot, Some("from-env".to_string()));
assert_eq!(
slot.as_deref(),
Some("explicit"),
"an explicitly-set token must survive a resolved env token"
);
assert!(
!filled,
"nothing was taken from the environment, so the token is not env-sourced"
);
}
#[test]
fn fill_env_token_if_unset_fills_an_empty_slot() {
let mut slot = None;
let filled = super::fill_env_token_if_unset(&mut slot, Some("from-env".to_string()));
assert_eq!(slot.as_deref(), Some("from-env"));
assert!(
filled,
"filling an empty slot must report an env-sourced token"
);
}
#[test]
fn fill_env_token_if_unset_keeps_an_existing_token_when_env_resolves_to_none() {
let mut slot = Some("explicit".to_string());
let filled = super::fill_env_token_if_unset(&mut slot, None);
assert_eq!(
slot.as_deref(),
Some("explicit"),
"an empty environment must leave an explicitly-set token in place"
);
assert!(!filled);
}
#[test]
fn fill_env_token_if_unset_leaves_an_empty_slot_empty() {
let mut slot = None;
let filled = super::fill_env_token_if_unset(&mut slot, None);
assert_eq!(slot, None);
assert!(!filled);
}
#[cfg(any(unix, windows))]
fn non_utf8_os_string() -> std::ffi::OsString {
#[cfg(unix)]
{
std::os::unix::ffi::OsStringExt::from_vec(vec![b'g', b'h', b'p', 0x80])
}
#[cfg(windows)]
{
std::os::windows::ffi::OsStringExt::from_wide(&[0x0067, 0x0068, 0xD800])
}
}
#[cfg(any(unix, windows))]
#[test]
fn env_token_value_treats_a_non_utf8_value_as_unset() {
assert_eq!(
super::env_token_value("GH_TOKEN", Some(non_utf8_os_string())),
None,
"a non-UTF-8 value must resolve to None"
);
assert_eq!(
super::env_token_value("GH_TOKEN", Some(std::ffi::OsString::from(" ghp_abc "))),
Some(" ghp_abc ".to_string())
);
assert_eq!(super::env_token_value("GH_TOKEN", None), None);
}
#[cfg(any(unix, windows))]
#[test]
fn a_non_utf8_candidate_falls_through_to_the_next_variable() {
let candidates = vec![
(
"GH_TOKEN",
super::env_token_value("GH_TOKEN", Some(non_utf8_os_string())),
),
(
"GITHUB_TOKEN",
super::env_token_value("GITHUB_TOKEN", Some(std::ffi::OsString::from("real"))),
),
];
assert_eq!(super::first_env_token(&candidates).as_deref(), Some("real"));
}
use super::EnvTokenDecision;
const NO_EXTRA_HOSTS: &[String] = &[];
#[test]
fn sends_silently_when_an_env_token_targets_the_canonical_host() {
assert_eq!(
super::env_token_host_decision(
true,
Some("api.github.com"),
NO_EXTRA_HOSTS,
Some("api.github.com")
),
EnvTokenDecision::Sent,
"the canonical host must not warn"
);
assert_eq!(
super::env_token_host_decision(
true,
Some("API.GitHub.com"),
NO_EXTRA_HOSTS,
Some("api.github.com")
),
EnvTokenDecision::Sent
);
}
#[test]
fn warns_and_sends_when_an_env_token_targets_an_unacknowledged_custom_host() {
assert_eq!(
super::env_token_host_decision(
true,
Some("evil.example.com"),
NO_EXTRA_HOSTS,
Some("api.github.com")
),
EnvTokenDecision::WarnedAndSent,
"an env-sourced token bound to an unacknowledged host must warn but still be sent"
);
}
#[test]
fn sends_silently_when_the_host_is_acknowledged_via_allow_auth_host() {
let auth_hosts = ["Evil.Example.com".to_string()];
assert_eq!(
super::env_token_host_decision(
true,
Some("evil.example.com"),
&auth_hosts,
Some("api.github.com")
),
EnvTokenDecision::Sent,
"a host present in allow_auth_host must not warn, even though it is not canonical"
);
assert_eq!(
super::env_token_host_decision(
true,
Some("other.example.com"),
&auth_hosts,
Some("api.github.com")
),
EnvTokenDecision::WarnedAndSent
);
}
#[test]
fn no_action_for_an_explicitly_set_token_on_a_custom_host() {
assert_eq!(
super::env_token_host_decision(
false,
Some("github.mycorp.com"),
NO_EXTRA_HOSTS,
Some("api.github.com")
),
EnvTokenDecision::Sent,
"an explicitly-set token must never warn or be withheld"
);
}
#[test]
fn withholds_for_a_backend_without_a_canonical_host_and_no_acknowledgement() {
assert_eq!(
super::env_token_host_decision(true, Some("gitea.example.com"), NO_EXTRA_HOSTS, None),
EnvTokenDecision::Withheld
);
}
#[test]
fn sends_for_a_backend_without_a_canonical_host_once_the_host_is_acknowledged() {
let auth_hosts = ["gitea.example.com".to_string()];
assert_eq!(
super::env_token_host_decision(true, Some("gitea.example.com"), &auth_hosts, None),
EnvTokenDecision::Sent
);
}
#[test]
fn sends_silently_when_there_is_no_host_at_all() {
assert_eq!(
super::env_token_host_decision(true, None, NO_EXTRA_HOSTS, Some("api.github.com")),
EnvTokenDecision::Sent
);
assert_eq!(
super::env_token_host_decision(true, None, NO_EXTRA_HOSTS, None),
EnvTokenDecision::Sent
);
}
#[test]
fn is_blank_token_treats_none_and_whitespace_as_blank() {
assert!(super::is_blank_token(None));
assert!(super::is_blank_token(Some("")));
assert!(super::is_blank_token(Some(" \n\t")));
assert!(!super::is_blank_token(Some("ghp_abc")));
assert!(!super::is_blank_token(Some(" ghp_abc ")));
}
#[test]
fn fill_env_token_if_unset_fills_over_a_blank_explicit_token() {
let mut slot = Some("".to_string());
let filled = super::fill_env_token_if_unset(&mut slot, Some("from-env".to_string()));
assert!(
filled,
"a blank explicit token must not block the env fallback"
);
assert_eq!(slot.as_deref(), Some("from-env"));
let mut slot = Some(" ".to_string());
let filled = super::fill_env_token_if_unset(&mut slot, Some("from-env".to_string()));
assert!(
filled,
"an all-whitespace explicit token must not block the env fallback"
);
assert_eq!(slot.as_deref(), Some("from-env"));
}
#[test]
fn fill_env_token_if_unset_leaves_a_blank_token_blank_when_the_env_resolves_to_none() {
let mut slot = Some("".to_string());
let filled = super::fill_env_token_if_unset(&mut slot, None);
assert!(!filled);
assert_eq!(slot.as_deref(), Some(""));
}
#[test]
fn apply_auth_treats_a_blank_token_as_unset() {
for blank in ["", " ", "\n\t"] {
let req = RequestConfig {
auth_token: Some(blank.to_string()),
auth_base_host: Some("api.github.com".to_string()),
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("https://api.github.com/repos/o/r/releases", &mut headers)
.expect("a blank token must not fail encoding");
assert!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.is_none(),
"a blank token ({blank:?}) must not produce an Authorization header"
);
}
let req = RequestConfig {
auth_token: Some("real".to_string()),
auth_base_host: Some("api.github.com".to_string()),
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("https://api.github.com/repos/o/r/releases", &mut headers)
.unwrap();
assert!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.is_some()
);
}
#[test]
fn fill_env_token_if_unset_with_does_not_call_the_resolver_when_the_slot_is_filled() {
let mut slot = Some("explicit".to_string());
let mut called = false;
let filled = super::fill_env_token_if_unset_with(&mut slot, || {
called = true;
Some("from-env".to_string())
});
assert!(!filled);
assert!(
!called,
"the resolver must not run when the slot already holds a real token"
);
assert_eq!(slot.as_deref(), Some("explicit"));
}
#[test]
fn fill_env_token_if_unset_with_calls_the_resolver_when_the_slot_is_empty() {
let mut slot = None;
let mut called = false;
let filled = super::fill_env_token_if_unset_with(&mut slot, || {
called = true;
Some("from-env".to_string())
});
assert!(filled);
assert!(called);
assert_eq!(slot.as_deref(), Some("from-env"));
}
#[test]
fn fill_env_token_if_unset_with_calls_the_resolver_when_the_slot_is_blank() {
let mut slot = Some(" ".to_string());
let mut called = false;
let filled = super::fill_env_token_if_unset_with(&mut slot, || {
called = true;
Some("from-env".to_string())
});
assert!(filled);
assert!(called);
assert_eq!(slot.as_deref(), Some("from-env"));
}
#[test]
fn set_explicit_auth_token_sets_the_value_and_clears_env_sourced() {
let mut slot = None;
let mut env_sourced = true;
super::set_explicit_auth_token(&mut slot, &mut env_sourced, "explicit");
assert_eq!(slot.as_deref(), Some("explicit"));
assert!(
!env_sourced,
"an explicit token must clear the env-sourced flag"
);
}
#[test]
fn set_explicit_auth_token_with_a_blank_value_overwrites_an_env_sourced_token() {
let mut slot = Some("from-env".to_string());
let mut env_sourced = true;
super::set_explicit_auth_token(&mut slot, &mut env_sourced, " ");
assert_eq!(
slot.as_deref(),
Some(" "),
"an explicit blank value overwrites the slot rather than being ignored"
);
assert!(
!env_sourced,
"the token is no longer env-sourced once an explicit setter ran, blank or not"
);
assert!(
super::is_blank_token(slot.as_deref()),
"and the resulting slot is blank, i.e. no token is configured at all"
);
}
#[test]
fn debug_redacts_the_auth_token_but_keeps_other_fields() {
let cfg = CommonBuilderConfig {
auth_token: Some("ghp_supersecret".to_string()),
bin_name: Some("app".to_string()),
..Default::default()
};
let rendered = format!("{cfg:?}");
assert!(
!rendered.contains("ghp_supersecret"),
"the token value must never appear in Debug output, got: {rendered}"
);
assert!(
rendered.contains("<token>"),
"the token must render as the redaction marker, got: {rendered}"
);
assert!(
rendered.contains("\"app\""),
"non-secret fields must still be rendered, got: {rendered}"
);
assert!(format!("{:?}", CommonBuilderConfig::default()).contains("auth_token: None"));
}
#[test]
fn debug_renders_every_field() {
let rendered = format!("{:?}", CommonBuilderConfig::default());
let mut fields = vec![
"request",
"target",
"asset_identifier",
"bin_name",
"bin_install_path",
"check_install_path_writable",
"bin_path_in_archive",
"bin_path_in_archive_auto",
"bundle_path_in_archive",
"bundle_install_path",
"show_download_progress",
"show_output",
"no_confirm",
"show_release_notes",
"update_strategy",
"tag_prefix",
"current_version",
"release_tag",
"auth_token",
"auth_token_from_env",
"auth_scheme",
"progress_callback",
"verify",
"verify_archive",
"asset_matcher",
];
#[cfg(feature = "progress-bar")]
fields.extend(["progress_template", "progress_chars"]);
#[cfg(feature = "checksums")]
fields.extend(["checksum", "checksum_from_asset", "verify_release_digest"]);
#[cfg(feature = "signatures")]
fields.push("verifying_keys");
for field in fields {
assert!(
rendered.contains(&format!("{field}:")),
"the hand-written Debug dropped `{field}`, got: {rendered}"
);
}
}
#[test]
fn request_config_debug_renders_every_field() {
let req = RequestConfig {
auth_token: Some("ghp_supersecret".to_string()),
auth_base_host: Some("api.github.com".to_string()),
auth_hosts: vec!["cdn.example.com".to_string()],
allow_insecure_auth: true,
proxy: Some("http://corpuser:hunter2@proxy.corp:8080".to_string()),
..Default::default()
};
let rendered = format!("{req:?}");
let mut fields = vec![
"timeout",
"headers",
"retries",
"retry_base_delay",
"retry_max_delay",
"auth_scheme",
"auth_token",
"client",
"header_error",
"root_certificates",
"cert_error",
"proxy",
"proxy_error",
"auth_base_host",
"auth_hosts",
"allow_insecure_auth",
];
if cfg!(feature = "async") {
fields.push("async_client");
}
for field in fields {
assert!(
rendered.contains(&format!(" {field}:")),
"the hand-written Debug dropped `{field}`, got: {rendered}"
);
}
assert!(
rendered.contains("api.github.com")
&& rendered.contains("cdn.example.com")
&& rendered.contains("allow_insecure_auth: true"),
"the auth-host gate must be readable from the dump, got: {rendered}"
);
assert!(
!rendered.contains("hunter2"),
"the proxy password must never appear in Debug output, got: {rendered}"
);
assert!(
rendered.contains("http://corpuser:REDACTED@proxy.corp:8080"),
"the proxy must render with only its password redacted, got: {rendered}"
);
assert!(
!rendered.contains("ghp_supersecret"),
"the token value must never appear in Debug output, got: {rendered}"
);
assert!(
rendered.contains("auth_token: Some(\"<token>\")"),
"the token must still render as the redaction marker, got: {rendered}"
);
assert!(
format!("{:?}", RequestConfig::default()).contains("auth_token: None"),
"an unset token must render as None"
);
}
#[test]
fn request_config_debug_redacts_a_user_supplied_authorization_header() {
let mut req = RequestConfig::default();
req.insert_header("Authorization", "Bearer user-supplied-secret");
let rendered = format!("{req:?}");
assert!(
!rendered.contains("user-supplied-secret"),
"a user-supplied Authorization header must never appear in Debug output, got: {rendered}"
);
assert!(
rendered.contains("Sensitive"),
"a redacted header renders as `Sensitive` in http's HeaderValue Debug, got: {rendered}"
);
}
#[test]
fn insert_header_marks_every_credential_shaped_header_name_sensitive() {
for name in [
"PRIVATE-TOKEN",
"Cookie",
"X-Upstream-Token",
"authorization",
] {
let mut req = RequestConfig::default();
req.insert_header(name, "super-secret-value");
let rendered = format!("{req:?}");
assert!(
!rendered.contains("super-secret-value"),
"`{name}` must be redacted in Debug output, got: {rendered}"
);
}
let mut req = RequestConfig::default();
req.insert_header("X-Request-Id", "not-a-secret");
assert!(
format!("{req:?}").contains("not-a-secret"),
"a non-credential header must not be redacted"
);
}
#[test]
fn insert_header_marks_a_credential_sensitive_without_altering_its_value() {
let mut req = RequestConfig::default();
req.insert_header("Authorization", "Bearer user-supplied-secret");
req.insert_header("X-Request-Id", "not-a-secret");
let auth = req
.headers
.get(crate::http_client::header::AUTHORIZATION)
.expect("the user-supplied header must still be stored");
assert_eq!(
auth.to_str().unwrap(),
"Bearer user-supplied-secret",
"the value sent on the wire must be exactly what the application passed"
);
assert!(
auth.is_sensitive(),
"a credential-bearing header must be flagged sensitive, which is what keeps it out of \
Debug output and the transports' own header logging"
);
assert!(
!req.headers
.get("x-request-id")
.expect("the ordinary header must be stored")
.is_sensitive(),
"a non-credential header must not be marked sensitive"
);
}
#[test]
fn request_config_debug_pairs_each_field_with_its_own_value() {
let req = RequestConfig {
timeout: Some(Duration::from_secs(11)),
retries: 7,
retry_base_delay: Duration::from_millis(13),
retry_max_delay: Duration::from_millis(17),
header_error: Some("header-error-marker".to_string()),
cert_error: Some("cert-error-marker".to_string()),
proxy: Some("http://proxy-marker.example.test:8080".to_string()),
proxy_error: Some("proxy-error-marker".to_string()),
auth_base_host: Some("base.example.test".to_string()),
auth_hosts: vec!["extra.example.test".to_string()],
allow_insecure_auth: true,
..Default::default()
};
let rendered = format!("{req:?}");
for (field, value) in [
("timeout", "Some(11s)"),
("retries", "7"),
("retry_base_delay", "13ms"),
("retry_max_delay", "17ms"),
("header_error", "Some(\"header-error-marker\")"),
("cert_error", "Some(\"cert-error-marker\")"),
("proxy", "Some(\"http://proxy-marker.example.test:8080\")"),
("proxy_error", "Some(\"proxy-error-marker\")"),
("auth_base_host", "Some(\"base.example.test\")"),
("auth_hosts", "[\"extra.example.test\"]"),
("allow_insecure_auth", "true"),
] {
assert!(
rendered.contains(&format!("{field}: {value}")),
"`{field}` must render its own value (`{value}`), got: {rendered}"
);
}
}
#[test]
fn common_builder_config_debug_pairs_each_field_with_its_own_value() {
let cfg = CommonBuilderConfig {
target: Some("target-marker".to_string()),
asset_identifier: Some("asset-identifier-marker".to_string()),
bin_name: Some("bin-name-marker".to_string()),
bin_install_path: Some(PathBuf::from("/bin-install-path-marker")),
check_install_path_writable: true,
bin_path_in_archive: Some("bin-path-in-archive-marker".to_string()),
bin_path_in_archive_auto: true,
bundle_path_in_archive: Some("bundle-path-in-archive-marker".to_string()),
bundle_install_path: Some(PathBuf::from("/bundle-install-path-marker")),
show_download_progress: true,
show_output: false,
no_confirm: true,
show_release_notes: false,
tag_prefix: Some("tag-prefix-marker".to_string()),
current_version: Some("current-version-marker".to_string()),
release_tag: Some("release-tag-marker".to_string()),
auth_token_from_env: true,
..Default::default()
};
let rendered = format!("{cfg:?}");
for (field, value) in [
("target", "Some(\"target-marker\")"),
("asset_identifier", "Some(\"asset-identifier-marker\")"),
("bin_name", "Some(\"bin-name-marker\")"),
("check_install_path_writable", "true"),
(
"bin_path_in_archive",
"Some(\"bin-path-in-archive-marker\")",
),
("bin_path_in_archive_auto", "true"),
(
"bundle_path_in_archive",
"Some(\"bundle-path-in-archive-marker\")",
),
("show_download_progress", "true"),
("show_output", "false"),
("no_confirm", "true"),
("show_release_notes", "false"),
("tag_prefix", "Some(\"tag-prefix-marker\")"),
("current_version", "Some(\"current-version-marker\")"),
("release_tag", "Some(\"release-tag-marker\")"),
("auth_token_from_env", "true"),
] {
assert!(
rendered.contains(&format!("{field}: {value}")),
"`{field}` must render its own value (`{value}`), got: {rendered}"
);
}
for (field, marker) in [
("bin_install_path", "bin-install-path-marker"),
("bundle_install_path", "bundle-install-path-marker"),
] {
let at = rendered
.find(&format!("{field}: "))
.unwrap_or_else(|| panic!("`{field}` must be rendered, got: {rendered}"));
let tail = &rendered[at..];
let end = tail.find(", ").unwrap_or(tail.len());
assert!(
tail[..end].contains(marker),
"`{field}` must render its own value (`{marker}`), got: {}",
&tail[..end]
);
}
}
#[test]
fn host_of_extracts_a_comparable_host() {
assert_eq!(
super::host_of("https://api.github.com").as_deref(),
Some("api.github.com")
);
assert_eq!(
super::host_of("https://github.mycorp.com:8443/api/v3").as_deref(),
Some("github.mycorp.com"),
"the port and path must not become part of the host"
);
assert_eq!(
super::host_of("https://API.GitHub.COM").as_deref(),
Some("api.github.com")
);
assert_eq!(
super::host_of("https://[::1]:8080/x").as_deref(),
Some("::1")
);
assert_eq!(
super::host_of("https://user:pw@gitlab.com/x").as_deref(),
Some("gitlab.com")
);
}
#[test]
fn host_of_accepts_a_scheme_less_authority() {
assert_eq!(
super::host_of("gitlab.mycorp.com").as_deref(),
Some("gitlab.mycorp.com")
);
assert_eq!(
super::env_token_host_decision(
true,
super::host_of("gitlab.mycorp.com").as_deref(),
NO_EXTRA_HOSTS,
Some("gitlab.com")
),
EnvTokenDecision::WarnedAndSent,
"a scheme-less custom host must still be reported for an env-sourced token"
);
}
#[test]
fn host_of_is_none_without_a_host() {
assert_eq!(super::host_of(""), None);
assert_eq!(super::host_of("/just/a/path"), None);
assert_eq!(
super::env_token_host_decision(true, None, NO_EXTRA_HOSTS, Some("gitlab.com")),
EnvTokenDecision::Sent,
"no parseable host means no token is sent, so there is nothing to warn about"
);
}
#[test]
fn name_tag_in_semver_error_names_tag_and_keeps_source_chain() {
let parse_err = "nightly".parse::<semver::Version>().unwrap_err();
let parse_msg = parse_err.to_string();
let wrapped =
super::name_tag_in_semver_error("nightly", crate::errors::Error::from(parse_err));
let crate::errors::Error::SemVer(inner) = &wrapped else {
panic!("expected Error::SemVer, got {wrapped:?}");
};
assert!(
inner.to_string().contains("`nightly`"),
"the message must name the tag, got: {inner}"
);
let chained = inner
.source()
.expect("the original semver parse error must stay on the chain");
assert_eq!(chained.to_string(), parse_msg);
}
#[test]
fn name_tag_in_semver_error_passes_other_errors_through() {
let err = crate::errors::Error::MissingField { field: "version" };
let out = super::name_tag_in_semver_error("nightly", err);
assert!(
matches!(out, crate::errors::Error::MissingField { field: "version" }),
"non-SemVer errors must pass through unchanged, got {out:?}"
);
}
#[test]
fn insert_header_records_invalid_value_error() {
let mut req = RequestConfig::default();
req.insert_header("x-ok", "bad\nvalue");
assert!(
req.headers.get("x-ok").is_none(),
"an invalid value must not be inserted"
);
let err = req
.check()
.expect_err("invalid value must surface from check()");
match err {
crate::errors::Error::InvalidHeader { source } => {
assert!(
source.to_string().contains("value"),
"value-conversion error should mention the value, got: {}",
source
);
}
other => panic!("expected Error::InvalidHeader, got {:?}", other),
}
}
#[test]
fn insert_header_records_invalid_name_error() {
let mut req = RequestConfig::default();
req.insert_header("inva lid", "ok");
assert!(req.headers.get("inva lid").is_none());
match req
.check()
.expect_err("invalid name must surface from check()")
{
crate::errors::Error::InvalidHeader { source } => {
assert!(source.to_string().contains("name"))
}
other => panic!("expected Error::InvalidHeader, got {:?}", other),
}
}
#[test]
fn insert_header_first_error_wins() {
let mut req = RequestConfig::default();
req.insert_header("bad name", "ok"); req.insert_header("x-ok", "bad\nvalue"); match req.check().expect_err("an error is recorded") {
crate::errors::Error::InvalidHeader { source } => assert!(
source.to_string().contains("name"),
"the first (name) error must win, got: {}",
source
),
other => panic!("expected Error::InvalidHeader, got {:?}", other),
}
}
#[test]
fn insert_header_valid_then_invalid_still_keeps_valid_header() {
let mut req = RequestConfig::default();
req.insert_header("x-good", "value");
req.insert_header("x-bad", "bad\nvalue");
assert_eq!(req.headers.get("x-good").unwrap(), "value");
assert!(req.check().is_err());
}
#[test]
fn check_is_ok_when_no_error_recorded() {
let mut req = RequestConfig::default();
req.insert_header("x-fine", "ok");
assert!(req.check().is_ok());
assert_eq!(req.headers.get("x-fine").unwrap(), "ok");
}
#[test]
fn build_requires_current_version_bin_name_and_archive_path() {
assert!(CommonBuilderConfig::default().build().is_err());
let cfg = CommonBuilderConfig {
current_version: Some("0.1.0".to_string()),
..Default::default()
};
assert!(cfg.build().is_err());
let cfg = CommonBuilderConfig {
current_version: Some("0.1.0".to_string()),
bin_name: Some("app".to_string()),
bin_path_in_archive: Some("app".to_string()),
..Default::default()
};
let built = cfg.build().expect("all required fields present");
assert_eq!(built.current_version, "0.1.0");
assert_eq!(built.bin_name, "app");
}
#[test]
fn build_defaults_and_propagates_update_strategy() {
let base = CommonBuilderConfig {
current_version: Some("0.1.0".to_string()),
bin_name: Some("app".to_string()),
bin_path_in_archive: Some("app".to_string()),
..Default::default()
};
assert_eq!(
base.clone().build().unwrap().update_strategy,
crate::update::UpdateStrategy::Compatible,
"the default update strategy must be Compatible"
);
let latest = CommonBuilderConfig {
update_strategy: crate::update::UpdateStrategy::Latest,
..base
};
assert_eq!(
latest.build().unwrap().update_strategy,
crate::update::UpdateStrategy::Latest,
"an explicit Latest strategy must be carried into the resolved config"
);
}
#[test]
fn build_resolves_target_and_install_path_defaults() {
let base = CommonBuilderConfig {
current_version: Some("0.1.0".to_string()),
bin_name: Some("app".to_string()),
bin_path_in_archive: Some("app".to_string()),
..Default::default()
};
let built = base.clone().build().unwrap();
assert_eq!(built.target.as_str(), crate::get_target());
assert!(!built.bin_install_path.as_os_str().is_empty());
let with_target = CommonBuilderConfig {
target: Some("custom-target".to_string()),
..base
};
assert_eq!(with_target.build().unwrap().target, "custom-target");
}
fn bundle_base() -> CommonBuilderConfig {
CommonBuilderConfig {
current_version: Some("0.1.0".to_string()),
bin_name: Some("app".to_string()),
bin_path_in_archive: Some("app".to_string()),
bin_path_in_archive_auto: true,
..Default::default()
}
}
#[test]
fn build_resolves_bundle_mode_with_an_explicit_install_path() {
let cfg = CommonBuilderConfig {
bundle_path_in_archive: Some("MyApp.app".to_string()),
bundle_install_path: Some(PathBuf::from("/Applications/MyApp.app")),
..bundle_base()
};
let built = cfg
.build()
.expect("an explicit bundle install path must build");
assert_eq!(built.bundle_path_in_archive.as_deref(), Some("MyApp.app"));
assert_eq!(
built.bundle_install_path.as_deref(),
Some(std::path::Path::new("/Applications/MyApp.app"))
);
}
#[test]
fn build_leaves_bundle_fields_none_without_the_setter() {
let built = bundle_base().build().unwrap();
assert!(built.bundle_path_in_archive.is_none());
assert!(built.bundle_install_path.is_none());
}
#[test]
fn build_rejects_bundle_mode_with_an_explicit_bin_install_path() {
let cfg = CommonBuilderConfig {
bundle_path_in_archive: Some("MyApp.app".to_string()),
bundle_install_path: Some(PathBuf::from("/Applications/MyApp.app")),
bin_install_path: Some(PathBuf::from("/usr/local/bin/app")),
..bundle_base()
};
match cfg.build() {
Err(crate::errors::Error::ConflictingConfig { field, conflict }) => {
assert_eq!(field, "bundle_path_in_archive");
assert_eq!(conflict, "bin_install_path");
}
other => panic!("expected ConflictingConfig, got {other:?}"),
}
}
#[test]
fn build_rejects_bundle_mode_only_with_an_explicit_bin_path_in_archive() {
let explicit = CommonBuilderConfig {
bundle_path_in_archive: Some("MyApp.app".to_string()),
bundle_install_path: Some(PathBuf::from("/Applications/MyApp.app")),
bin_path_in_archive: Some("dist/app".to_string()),
bin_path_in_archive_auto: false,
..bundle_base()
};
match explicit.build() {
Err(crate::errors::Error::ConflictingConfig { field, conflict }) => {
assert_eq!(field, "bundle_path_in_archive");
assert_eq!(conflict, "bin_path_in_archive");
}
other => panic!("expected ConflictingConfig, got {other:?}"),
}
let auto = CommonBuilderConfig {
bundle_path_in_archive: Some("MyApp.app".to_string()),
bundle_install_path: Some(PathBuf::from("/Applications/MyApp.app")),
..bundle_base()
};
assert!(
auto.build().is_ok(),
"the auto-derived bin_path_in_archive must not count as a conflict"
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn build_requires_bundle_install_path_off_macos() {
let cfg = CommonBuilderConfig {
bundle_path_in_archive: Some("MyApp.app".to_string()),
..bundle_base()
};
match cfg.build() {
Err(crate::errors::Error::MissingField { field }) => {
assert_eq!(field, "bundle_install_path");
}
other => panic!("expected MissingField, got {other:?}"),
}
}
#[test]
fn build_rejects_a_bundle_install_path_without_the_archive_path() {
let cfg = CommonBuilderConfig {
bundle_install_path: Some(PathBuf::from("/Applications/MyApp.app")),
..bundle_base()
};
match cfg.build() {
Err(crate::errors::Error::MissingField { field }) => {
assert_eq!(field, "bundle_path_in_archive");
}
other => panic!("expected MissingField, got {other:?}"),
}
let built = bundle_base().build().expect("single-file mode must build");
assert!(built.bundle_path_in_archive.is_none());
assert!(built.bundle_install_path.is_none());
}
#[test]
fn build_still_requires_current_version_and_bin_name_in_bundle_mode() {
let no_version = CommonBuilderConfig {
bundle_path_in_archive: Some("MyApp.app".to_string()),
bundle_install_path: Some(PathBuf::from("/Applications/MyApp.app")),
current_version: None,
..bundle_base()
};
match no_version.build() {
Err(crate::errors::Error::MissingField { field }) => {
assert_eq!(field, "current_version");
}
other => panic!("expected MissingField(current_version), got {other:?}"),
}
let no_bin_name = CommonBuilderConfig {
bundle_path_in_archive: Some("MyApp.app".to_string()),
bundle_install_path: Some(PathBuf::from("/Applications/MyApp.app")),
bin_name: None,
bin_path_in_archive: None,
bin_path_in_archive_auto: false,
..bundle_base()
};
match no_bin_name.build() {
Err(crate::errors::Error::MissingField { field }) => {
assert_eq!(field, "bin_name");
}
other => panic!("expected MissingField(bin_name), got {other:?}"),
}
}
#[test]
fn build_reports_the_conflict_before_resolving_the_install_path() {
let cfg = CommonBuilderConfig {
bundle_path_in_archive: Some("MyApp.app".to_string()),
bin_install_path: Some(PathBuf::from("/usr/local/bin/app")),
..bundle_base()
};
match cfg.build() {
Err(crate::errors::Error::ConflictingConfig { field, conflict }) => {
assert_eq!(field, "bundle_path_in_archive");
assert_eq!(conflict, "bin_install_path");
}
other => panic!("expected ConflictingConfig, got {other:?}"),
}
}
#[test]
fn build_error_message_names_the_setter_for_current_version() {
let err = CommonBuilderConfig::default().build().unwrap_err();
match err {
crate::errors::Error::MissingField { field } => {
assert_eq!(
field, "current_version",
"the missing-field error must name `current_version`, got: {}",
field
);
}
other => panic!("expected Error::MissingField, got {:?}", other),
}
}
#[test]
fn build_client_with_no_certs_leaves_client_none() {
let mut req = RequestConfig::default();
req.build_client();
assert!(
req.client.is_none(),
"no certs => build_client must not materialize a client"
);
assert!(req.cert_error.is_none(), "no certs => no cert_error");
}
#[test]
fn build_client_with_injected_client_skips_cert_build() {
struct DummyClient;
impl crate::http_client::HttpClient for DummyClient {
fn get(
&self,
_url: &str,
_headers: &crate::http_client::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> crate::Result<Box<dyn crate::http_client::HttpResponse>> {
unreachable!("not called in this test")
}
}
#[cfg(feature = "async")]
struct DummyAsyncClient;
#[cfg(feature = "async")]
impl crate::http_client::AsyncHttpClient for DummyAsyncClient {
fn get<'a>(
&'a self,
_url: &'a str,
_headers: &'a crate::http_client::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> futures_util::future::BoxFuture<
'a,
crate::Result<Box<dyn crate::http_client::AsyncHttpResponse>>,
> {
unreachable!("not called in this test")
}
}
let mut req = RequestConfig {
client: Some(std::sync::Arc::new(DummyClient)),
#[cfg(feature = "async")]
async_client: Some(std::sync::Arc::new(DummyAsyncClient)),
..Default::default()
};
req.root_certificates
.push(crate::tls::Certificate::from_pem(b"garbage".to_vec()));
req.build_client();
assert!(
req.cert_error.is_none(),
"an injected client must short-circuit the sync cert build (no cert_error)"
);
assert!(req.client.is_some(), "the injected client must be kept");
}
#[cfg(feature = "async")]
#[test]
fn build_client_injected_sync_still_builds_async_from_certs() {
struct DummyClient;
impl crate::http_client::HttpClient for DummyClient {
fn get(
&self,
_url: &str,
_headers: &crate::http_client::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> crate::Result<Box<dyn crate::http_client::HttpResponse>> {
unreachable!("not called in this test")
}
}
let mut req = RequestConfig {
client: Some(std::sync::Arc::new(DummyClient)),
..Default::default()
};
req.root_certificates
.push(crate::tls::Certificate::from_pem(BAD_PEM_CERT.to_vec()));
req.build_client();
assert!(
req.cert_error.is_some(),
"the async slot must attempt the cert-build even when a sync client is injected"
);
assert!(
req.client.is_some(),
"the injected sync client must be kept as-is"
);
}
#[cfg(any(feature = "reqwest", feature = "ureq"))]
#[test]
fn build_client_bad_cert_records_cert_error() {
#[cfg(feature = "reqwest")]
let bad_cert = crate::tls::Certificate::from_pem(
b"-----BEGIN CERTIFICATE-----\nbm90IGEgdmFsaWQgY2VydA==\n-----END CERTIFICATE-----\n"
.to_vec(),
);
#[cfg(all(feature = "ureq", not(feature = "reqwest")))]
let bad_cert = crate::tls::Certificate::from_pem(b"not a pem certificate".to_vec());
let mut req = RequestConfig::default();
req.root_certificates.push(bad_cert);
req.build_client();
assert!(
req.cert_error.is_some(),
"a malformed cert must record a cert_error"
);
assert!(
req.client.is_none(),
"a failed cert build must not leave a client"
);
}
#[test]
fn check_surfaces_cert_error_as_invalid_certificate() {
let req = RequestConfig {
cert_error: Some("boom".to_string()),
..Default::default()
};
match req
.check()
.expect_err("cert_error must surface from check()")
{
crate::errors::Error::InvalidCertificate { source } => {
assert_eq!(source.to_string(), "boom")
}
other => panic!("expected Error::InvalidCertificate, got {:?}", other),
}
}
#[test]
fn check_surfaces_header_error_before_cert_error() {
let req = RequestConfig {
header_error: Some("bad header".to_string()),
cert_error: Some("bad cert".to_string()),
..Default::default()
};
match req.check().expect_err("an error must surface") {
crate::errors::Error::InvalidHeader { .. } => {}
other => panic!("expected Error::InvalidHeader to win, got {:?}", other),
}
}
#[test]
fn build_client_with_a_proxy_materializes_a_client() {
let mut req = RequestConfig {
proxy: Some("http://corpuser:hunter2@proxy.corp:8080".to_string()),
..Default::default()
};
req.build_client();
assert!(
req.client.is_some(),
"a configured proxy must materialize a client"
);
assert!(
req.proxy_error.is_none() && req.cert_error.is_none(),
"a valid proxy must not record an error, got proxy_error={:?} cert_error={:?}",
req.proxy_error,
req.cert_error
);
}
#[cfg(any(feature = "reqwest", feature = "ureq"))]
#[test]
fn build_client_bad_proxy_records_proxy_error_not_cert_error() {
let mut req = RequestConfig {
proxy: Some("http://corpuser:hunter2@ not a proxy url".to_string()),
..Default::default()
};
req.build_client();
let recorded = req
.proxy_error
.as_deref()
.expect("an unparseable proxy URL must record a proxy_error");
assert!(
!recorded.contains("hunter2") && recorded.contains("REDACTED"),
"the recorded proxy error must be redacted, got: {recorded}"
);
assert!(
req.cert_error.is_none(),
"a proxy failure must not be misreported as a certificate failure"
);
assert!(
req.client.is_none(),
"a failed proxy build must not leave a client"
);
}
#[test]
fn check_surfaces_proxy_error_as_invalid_proxy() {
let req = RequestConfig {
proxy_error: Some("boom".to_string()),
..Default::default()
};
match req
.check()
.expect_err("proxy_error must surface from check()")
{
crate::errors::Error::InvalidProxy { source } => {
assert_eq!(source.to_string(), "boom")
}
other => panic!("expected Error::InvalidProxy, got {:?}", other),
}
}
#[test]
fn build_client_with_injected_client_skips_the_proxy_build() {
struct DummyClient;
impl crate::http_client::HttpClient for DummyClient {
fn get(
&self,
_url: &str,
_headers: &crate::http_client::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> crate::Result<Box<dyn crate::http_client::HttpResponse>> {
unreachable!("not called in this test")
}
}
#[cfg(feature = "async")]
struct DummyAsyncClient;
#[cfg(feature = "async")]
impl crate::http_client::AsyncHttpClient for DummyAsyncClient {
fn get<'a>(
&'a self,
_url: &'a str,
_headers: &'a crate::http_client::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> futures_util::future::BoxFuture<
'a,
crate::Result<Box<dyn crate::http_client::AsyncHttpResponse>>,
> {
unreachable!("not called in this test")
}
}
let mut req = RequestConfig {
client: Some(std::sync::Arc::new(DummyClient)),
#[cfg(feature = "async")]
async_client: Some(std::sync::Arc::new(DummyAsyncClient)),
proxy: Some("http://corpuser:hunter2@ not a proxy url".to_string()),
..Default::default()
};
req.build_client();
assert!(
req.proxy_error.is_none(),
"an injected client must short-circuit the proxy build"
);
assert!(req.client.is_some(), "the injected client must be kept");
}
#[test]
fn build_error_message_names_the_setter_for_bin_name() {
let err = CommonBuilderConfig {
current_version: Some("0.1.0".to_string()),
..Default::default()
}
.build()
.unwrap_err();
match err {
crate::errors::Error::MissingField { field } => {
assert_eq!(
field, "bin_name",
"the missing-field error must name `bin_name`, got: {}",
field
);
}
other => panic!("expected Error::MissingField, got {:?}", other),
}
}
#[test]
fn build_error_message_names_the_setter_for_bin_path_in_archive() {
let err = CommonBuilderConfig {
current_version: Some("0.1.0".to_string()),
bin_name: Some("app".to_string()),
..Default::default()
}
.build()
.unwrap_err();
match err {
crate::errors::Error::MissingField { field } => {
assert_eq!(
field, "bin_path_in_archive",
"the missing-field error must name `bin_path_in_archive`, got: {}",
field
);
}
other => panic!("expected Error::MissingField, got {:?}", other),
}
}
#[test]
fn apply_auth_no_token_is_noop() {
let req = RequestConfig::default();
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("https://api.example.com/x", &mut headers)
.unwrap();
assert!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.is_none(),
"apply_auth with no token must not insert an Authorization header"
);
}
#[test]
fn apply_auth_token_scheme_inserts_authorization_header() {
let req = RequestConfig {
auth_token: Some("mytoken".to_string()),
auth_base_host: Some("api.example.com".to_string()),
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("https://api.example.com/x", &mut headers)
.unwrap();
let auth = headers
.get(crate::http_client::header::AUTHORIZATION)
.expect("apply_auth must insert an Authorization header when a token is set");
assert_eq!(
auth, "token mytoken",
"Token scheme must render as 'token <token>'"
);
}
#[test]
fn apply_auth_user_supplied_authorization_header_wins() {
let mut req = RequestConfig {
auth_token: Some("should-not-appear".to_string()),
..Default::default()
};
req.insert_header(
crate::http_client::header::AUTHORIZATION,
"custom my-custom-token",
);
let mut out_headers = crate::http_client::HeaderMap::new();
req.apply_auth("https://api.example.com/x", &mut out_headers)
.unwrap();
assert!(
out_headers
.get(crate::http_client::header::AUTHORIZATION)
.is_none(),
"apply_auth must not insert its token when the user supplied their own Authorization"
);
}
#[test]
fn apply_auth_bearer_scheme_renders_bearer_prefix() {
let req = RequestConfig {
auth_token: Some("mytoken".to_string()),
auth_scheme: super::AuthScheme::Bearer,
auth_base_host: Some("api.example.com".to_string()),
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("https://api.example.com/x", &mut headers)
.unwrap();
let auth = headers
.get(crate::http_client::header::AUTHORIZATION)
.expect("apply_auth must insert an Authorization header");
assert_eq!(
auth, "Bearer mytoken",
"Bearer scheme must render as 'Bearer <token>'"
);
}
#[test]
fn apply_auth_invalid_token_surfaces_invalid_auth_token_error() {
let req = RequestConfig {
auth_token: Some("bad\ntoken".to_string()),
auth_base_host: Some("api.example.com".to_string()),
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
match req.apply_auth("https://api.example.com/x", &mut headers) {
Err(crate::errors::Error::InvalidAuthToken { .. }) => {}
other => panic!(
"expected Error::InvalidAuthToken for a token with a newline, got {:?}",
other
),
}
}
#[test]
fn apply_auth_not_attached_to_cross_origin_url() {
let req = RequestConfig {
auth_token: Some("secret".to_string()),
auth_base_host: Some("api.github.com".to_string()),
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("https://evil.example.com/x.tar.gz", &mut headers)
.unwrap();
assert!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.is_none(),
"the token must not be attached to a cross-origin URL"
);
}
#[test]
fn apply_auth_not_attached_over_plaintext_http() {
let req = RequestConfig {
auth_token: Some("secret".to_string()),
auth_base_host: Some("api.example.com".to_string()),
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("http://api.example.com/x", &mut headers)
.unwrap();
assert!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.is_none(),
"the token must not be sent over plaintext http to a non-loopback host"
);
}
#[test]
fn apply_auth_attached_to_allow_auth_host() {
let req = RequestConfig {
auth_token: Some("secret".to_string()),
auth_base_host: Some("api.example.com".to_string()),
auth_hosts: vec!["cdn.example.com".to_string()],
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("https://cdn.example.com/x", &mut headers)
.unwrap();
assert!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.is_some(),
"an allow_auth_host entry must receive the token"
);
}
#[test]
fn apply_auth_over_http_when_insecure_forwarding_allowed() {
let req = RequestConfig {
auth_token: Some("secret".to_string()),
auth_base_host: Some("internal.example.com".to_string()),
allow_insecure_auth: true,
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("http://internal.example.com/x", &mut headers)
.unwrap();
assert!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.is_some(),
"the escape hatch must allow the token over http to a host-matched request"
);
}
#[test]
fn apply_auth_insecure_flag_still_requires_host_match() {
let req = RequestConfig {
auth_token: Some("secret".to_string()),
auth_base_host: Some("internal.example.com".to_string()),
allow_insecure_auth: true,
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("http://evil.example.com/x", &mut headers)
.unwrap();
assert!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.is_none(),
"the escape hatch must not attach the token to a cross-origin host"
);
}
#[test]
fn apply_auth_attached_to_loopback_over_http() {
let req = RequestConfig {
auth_token: Some("secret".to_string()),
auth_base_host: Some("127.0.0.1".to_string()),
..Default::default()
};
let mut headers = crate::http_client::HeaderMap::new();
req.apply_auth("http://127.0.0.1:8080/x", &mut headers)
.unwrap();
assert!(
headers
.get(crate::http_client::header::AUTHORIZATION)
.is_some(),
"a loopback host matching the base must receive the token over http"
);
}
#[test]
fn build_propagates_auth_token_and_scheme_to_request_config() {
let cfg = CommonBuilderConfig {
current_version: Some("1.0.0".to_string()),
bin_name: Some("mybin".to_string()),
bin_path_in_archive: Some("mybin".to_string()),
auth_token: Some("secrettoken".to_string()),
auth_scheme: super::AuthScheme::Bearer,
..Default::default()
};
let built = cfg.build().expect("valid config must build");
assert_eq!(
built.request.auth_token.as_deref(),
Some("secrettoken"),
"build() must copy auth_token into request.auth_token"
);
assert_eq!(
built.request.auth_scheme,
super::AuthScheme::Bearer,
"build() must copy auth_scheme into request.auth_scheme"
);
}
#[cfg(feature = "async")]
#[test]
fn build_client_injected_async_only_still_builds_sync_from_certs() {
struct DummyAsyncClient;
impl crate::http_client::AsyncHttpClient for DummyAsyncClient {
fn get<'a>(
&'a self,
_url: &'a str,
_headers: &'a crate::http_client::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> futures_util::future::BoxFuture<
'a,
crate::Result<Box<dyn crate::http_client::AsyncHttpResponse>>,
> {
unreachable!("not called in this test")
}
}
let mut req = RequestConfig {
async_client: Some(std::sync::Arc::new(DummyAsyncClient)),
..Default::default()
};
req.root_certificates
.push(crate::tls::Certificate::from_pem(BAD_PEM_CERT.to_vec()));
req.build_client();
assert!(
req.cert_error.is_some(),
"the sync slot must attempt the cert-build even when an async client is injected"
);
assert!(
req.async_client.is_some(),
"the injected async client must be kept as-is"
);
}
#[test]
fn common_builder_config_build_with_injected_clients_skips_cert_error() {
struct DummyClient;
impl crate::http_client::HttpClient for DummyClient {
fn get(
&self,
_url: &str,
_headers: &crate::http_client::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> crate::Result<Box<dyn crate::http_client::HttpResponse>> {
unreachable!("not called in this test")
}
}
#[cfg(feature = "async")]
struct DummyAsyncClient;
#[cfg(feature = "async")]
impl crate::http_client::AsyncHttpClient for DummyAsyncClient {
fn get<'a>(
&'a self,
_url: &'a str,
_headers: &'a crate::http_client::HeaderMap,
_timeout: Option<std::time::Duration>,
) -> futures_util::future::BoxFuture<
'a,
crate::Result<Box<dyn crate::http_client::AsyncHttpResponse>>,
> {
unreachable!("not called in this test")
}
}
let mut builder = CommonBuilderConfig {
current_version: Some("0.1.0".to_string()),
bin_name: Some("app".to_string()),
bin_path_in_archive: Some("app".to_string()),
..Default::default()
};
builder.request.client = Some(std::sync::Arc::new(DummyClient));
#[cfg(feature = "async")]
{
builder.request.async_client = Some(std::sync::Arc::new(DummyAsyncClient));
}
builder
.request
.root_certificates
.push(crate::tls::Certificate::from_pem(b"garbage".to_vec()));
let config = builder
.build()
.expect("injected clients must prevent cert_error from blocking build");
assert!(
config.request.cert_error.is_none(),
"cert_error must be None when all client slots were injected"
);
assert!(
config.request.client.is_some(),
"the injected client must be present in the resolved config"
);
}
}