use regex::Regex;
use std::borrow::Cow;
use std::fs;
use std::sync::{Arc, LazyLock};
use crate::http_client::{self, header};
use crate::{Download, Extract, Move, VersionStatus, confirm, errors::*, version};
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct ReleaseAsset {
pub(crate) name: Arc<str>,
pub(crate) download_url: Arc<str>,
}
impl ReleaseAsset {
pub fn new(name: impl Into<String>, download_url: impl Into<String>) -> Self {
Self {
name: Arc::from(name.into()),
download_url: Arc::from(download_url.into()),
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn download_url(&self) -> &str {
&self.download_url
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum ReleaseStatus {
UpToDate,
Updated(Release),
}
impl ReleaseStatus {
pub fn into_version_status(self, current_version: String) -> VersionStatus {
match self {
ReleaseStatus::UpToDate => VersionStatus::UpToDate(current_version),
ReleaseStatus::Updated(release) => {
VersionStatus::Updated(release.version().to_string())
}
}
}
pub fn is_up_to_date(&self) -> bool {
matches!(*self, ReleaseStatus::UpToDate)
}
pub fn is_updated(&self) -> bool {
!self.is_up_to_date()
}
pub fn updated_release(&self) -> Option<&Release> {
match self {
ReleaseStatus::Updated(release) => Some(release),
ReleaseStatus::UpToDate => None,
}
}
pub fn into_updated_release(self) -> Option<Release> {
match self {
ReleaseStatus::Updated(release) => Some(release),
ReleaseStatus::UpToDate => None,
}
}
pub fn version(&self) -> Option<&str> {
match self {
ReleaseStatus::Updated(release) => Some(release.version()),
ReleaseStatus::UpToDate => None,
}
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct Release {
pub(crate) name: Arc<str>,
pub(crate) version: Arc<str>,
pub(crate) date: Arc<str>,
pub(crate) body: Option<Arc<str>>,
pub(crate) assets: Vec<ReleaseAsset>,
}
impl Release {
pub fn name(&self) -> &str {
&self.name
}
pub fn version(&self) -> &str {
&self.version
}
pub fn date(&self) -> &str {
&self.date
}
pub fn body(&self) -> Option<&str> {
self.body.as_deref()
}
pub fn assets(&self) -> &[ReleaseAsset] {
&self.assets
}
pub fn has_target_asset(&self, target: &str) -> bool {
self.assets.iter().any(|asset| asset.name.contains(target))
}
pub fn asset_for(&self, target: &str, identifier: Option<&str>) -> Option<ReleaseAsset> {
let has_identifier =
|asset: &&ReleaseAsset| identifier.is_none_or(|i| asset.name.contains(i));
self.assets
.iter()
.find(|asset| asset.name.contains(target) && has_identifier(asset))
.or_else(|| {
let (arch, os) = target_arch_os(target);
match (arch, os) {
(Some(arch), Some(os)) => self.assets.iter().find(|asset| {
asset.name.contains(arch)
&& asset.name.contains(os)
&& has_identifier(asset)
}),
_ => None,
}
})
.or_else(|| {
identifier.and_then(|i| self.assets.iter().find(|asset| asset.name.contains(i)))
})
.cloned()
}
pub fn builder() -> ReleaseBuilder {
ReleaseBuilder::default()
}
}
#[derive(Clone, Debug, Default)]
#[must_use]
pub struct ReleaseBuilder {
name: Option<String>,
version: Option<String>,
date: Option<String>,
body: Option<String>,
assets: Vec<ReleaseAsset>,
}
impl ReleaseBuilder {
pub fn version(&mut self, version: impl Into<String>) -> &mut Self {
self.version = Some(version.into());
self
}
pub fn name(&mut self, name: impl Into<String>) -> &mut Self {
self.name = Some(name.into());
self
}
pub fn date(&mut self, date: impl Into<String>) -> &mut Self {
self.date = Some(date.into());
self
}
pub fn body(&mut self, body: impl Into<String>) -> &mut Self {
self.body = Some(body.into());
self
}
pub fn asset(&mut self, asset: ReleaseAsset) -> &mut Self {
self.assets.push(asset);
self
}
pub fn assets(&mut self, assets: impl IntoIterator<Item = ReleaseAsset>) -> &mut Self {
self.assets.extend(assets);
self
}
pub fn build(&self) -> Result<Release> {
let version = self
.version
.clone()
.ok_or(Error::MissingField { field: "version" })?;
Ok(Release {
name: Arc::from(self.name.clone().unwrap_or_else(|| version.clone())),
version: Arc::from(version),
date: Arc::from(self.date.clone().unwrap_or_default()),
body: self.body.clone().map(Arc::from),
assets: self.assets.clone(),
})
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Releases {
releases: Vec<Release>,
current_version: Option<String>,
}
impl Releases {
pub(crate) fn new(releases: Vec<Release>, current_version: String) -> Self {
Self {
releases,
current_version: Some(current_version),
}
}
pub fn from_listing(releases: Vec<Release>) -> Self {
Self {
releases,
current_version: None,
}
}
pub fn from_releases(releases: Vec<Release>, current_version: impl Into<String>) -> Self {
Self {
releases,
current_version: Some(current_version.into()),
}
}
pub fn all(&self) -> &[Release] {
&self.releases
}
pub fn len(&self) -> usize {
self.releases.len()
}
pub fn is_empty(&self) -> bool {
self.releases.is_empty()
}
pub fn current_version(&self) -> Option<&str> {
self.current_version.as_deref()
}
pub fn latest(&self) -> Option<&Release> {
self.releases.first()
}
pub fn into_vec(self) -> Vec<Release> {
self.releases
}
pub fn is_update_available(&self) -> Result<bool> {
let current_version = self
.current_version
.as_deref()
.ok_or(Error::NoCurrentVersion)?;
for r in &self.releases {
if version::bump_is_greater(current_version, r.version())? {
return Ok(true);
}
}
Ok(false)
}
}
impl IntoIterator for Releases {
type Item = Release;
type IntoIter = std::vec::IntoIter<Release>;
fn into_iter(self) -> Self::IntoIter {
self.releases.into_iter()
}
}
impl<'a> IntoIterator for &'a Releases {
type Item = &'a Release;
type IntoIter = std::slice::Iter<'a, Release>;
fn into_iter(self) -> Self::IntoIter {
self.releases.iter()
}
}
pub trait ReleaseSource: Send + Sync {
fn get_latest_release(&self) -> Result<Release>;
fn get_releases(&self) -> Result<Vec<Release>>;
fn get_release_version(&self, ver: &str) -> Result<Release>;
}
#[cfg(feature = "async")]
pub trait AsyncReleaseSource: Send + Sync {
fn get_latest_release(&self) -> impl std::future::Future<Output = Result<Release>> + Send + '_;
fn get_releases(&self) -> impl std::future::Future<Output = Result<Vec<Release>>> + Send + '_;
fn get_release_version<'a>(
&'a self,
ver: &'a str,
) -> impl std::future::Future<Output = Result<Release>> + Send + 'a;
}
#[allow(private_bounds)]
#[cfg(feature = "async")]
pub trait AsyncReleaseUpdate: UpdateConfig + UpdateInternals {
fn get_latest_release_async(
&self,
) -> impl std::future::Future<Output = Result<Releases>> + Send + '_;
fn get_newer_releases_async(
&self,
) -> impl std::future::Future<Output = Result<Releases>> + Send + '_;
fn get_release_version_async<'a>(
&'a self,
ver: &'a str,
) -> impl std::future::Future<Output = Result<Release>> + Send + 'a;
fn update_async(&self) -> impl std::future::Future<Output = Result<VersionStatus>> + Send + '_
where
Self: Sized + Sync,
{
async move {
let current_version = self.current_version().to_string();
self.update_extended_async()
.await
.map(|s| s.into_version_status(current_version))
}
}
fn update_extended_async(
&self,
) -> impl std::future::Future<Output = Result<ReleaseStatus>> + Send + '_
where
Self: Sized + Sync,
{
update_extended_async(self)
}
}
pub(crate) mod sealed {
pub trait Sealed {}
}
pub trait UpdateConfig: sealed::Sealed {
fn current_version(&self) -> &str;
fn target(&self) -> &str;
fn release_tag(&self) -> Option<&str>;
fn asset_identifier(&self) -> Option<&str> {
None
}
fn bin_name(&self) -> &str;
fn bin_install_path(&self) -> &std::path::Path;
fn bin_path_in_archive(&self) -> &str;
fn show_download_progress(&self) -> bool;
fn show_output(&self) -> bool;
fn no_confirm(&self) -> bool;
#[cfg(feature = "progress-bar")]
fn progress_template(&self) -> &str;
#[cfg(feature = "progress-bar")]
fn progress_chars(&self) -> &str;
fn auth_token(&self) -> Option<&str>;
fn api_headers(&self, _auth_token: Option<&str>) -> Result<http_client::HeaderMap> {
Ok(header::HeaderMap::new())
}
}
pub(crate) trait UpdateInternals: sealed::Sealed {
fn request_timeout(&self) -> Option<std::time::Duration>;
fn request_headers(&self) -> &http_client::HeaderMap;
fn request_config(&self) -> &crate::backends::common::RequestConfig;
fn request_client(&self) -> Option<std::sync::Arc<dyn http_client::HttpClient>>;
#[cfg(feature = "async")]
fn request_async_client(&self) -> Option<std::sync::Arc<dyn http_client::AsyncHttpClient>>;
fn progress_callback(&self) -> Option<std::sync::Arc<crate::DynProgressFn>>;
fn verify_callback(&self) -> Option<std::sync::Arc<crate::DynVerifyFn>>;
fn asset_matcher(&self) -> Option<std::sync::Arc<crate::DynAssetMatcher>> {
None
}
#[cfg(feature = "checksums")]
fn verify_checksum(&self) -> Option<&crate::Checksum>;
#[cfg(feature = "signatures")]
fn verifying_keys(&self) -> &[crate::VerifyingKey] {
&[]
}
}
#[allow(private_bounds)]
pub trait ReleaseUpdate: UpdateConfig + UpdateInternals {
fn get_latest_release(&self) -> Result<Releases>;
fn get_newer_releases(&self) -> Result<Releases>;
fn get_release_version(&self, ver: &str) -> Result<Release>;
fn update(&self) -> Result<VersionStatus> {
let current_version = self.current_version().to_string();
self.update_extended()
.map(|s| s.into_version_status(current_version))
}
fn update_extended(&self) -> Result<ReleaseStatus> {
let current_version = self.current_version();
let show_output = self.show_output();
print_check_header(self.target(), current_version, show_output);
let release = match self.release_tag() {
None => {
print_flush(show_output, "Checking latest released version... ")?;
let releases = self.get_newer_releases()?;
match choose_latest_release(releases.into_vec(), current_version, show_output)? {
Some(release) => release,
None => return Ok(ReleaseStatus::UpToDate),
}
}
Some(ref ver) => {
println(show_output, &format!("Looking for tag: {}", ver));
self.get_release_version(ver)?
}
};
let target_asset = resolve_and_confirm(self, &release)?;
let tmp_archive_dir = tempfile::TempDir::new()?;
let tmp_archive_path = tmp_archive_dir.path().join(target_asset.name());
let mut tmp_archive = fs::File::create(&tmp_archive_path)?;
println(show_output, "Downloading...");
build_download(self, &target_asset)?.download_to(&mut tmp_archive)?;
finish_update(self, release, tmp_archive_dir, &tmp_archive_path)
}
}
fn print_check_header(target: &str, current_version: &str, show_output: bool) {
println(show_output, &format!("Checking target-arch... {}", target));
println(
show_output,
&format!("Checking current version... v{}", current_version),
);
}
fn choose_latest_release(
releases: Vec<Release>,
current_version: &str,
show_output: bool,
) -> Result<Option<Release>> {
let mut releases = releases
.into_iter()
.filter(|r| version::bump_is_greater(current_version, r.version()).unwrap_or(false))
.collect::<Vec<_>>();
releases.sort_by(|x, y| version::cmp_releases_newest_first(x.version(), y.version()));
let compatible_releases = releases
.iter()
.filter(|r| version::bump_is_compatible(current_version, r.version()).unwrap_or(false))
.collect::<Vec<_>>();
let release = if let Some(release) = compatible_releases.first() {
println(
show_output,
&format!(
"v{} ({} versions compatible)",
release.version(),
compatible_releases.len()
),
);
(*release).clone()
} else if let Some(release) = releases.first() {
println(
show_output,
&format!(
"v{} ({} versions available)",
release.version(),
releases.len()
),
);
release.clone()
} else {
println(show_output, "up-to-date.");
return Ok(None);
};
println(
show_output,
&format!(
"New release found! v{} --> v{}",
current_version,
release.version()
),
);
let qualifier =
if version::bump_is_compatible(current_version, release.version()).unwrap_or(false) {
""
} else {
"*NOT* "
};
println(
show_output,
&format!("New release is {}compatible", qualifier),
);
Ok(Some(release))
}
#[cfg(test)]
pub(crate) mod testing {
use super::*;
pub(crate) fn choose_latest_release_for_test(
releases: Vec<Release>,
current_version: &str,
) -> Result<Option<Release>> {
choose_latest_release(releases, current_version, false)
}
}
fn target_arch_os(target: &str) -> (Option<&str>, Option<&str>) {
let arch = target.split('-').next().filter(|s| !s.is_empty());
let os = [
"linux", "darwin", "windows", "freebsd", "netbsd", "openbsd", "android", "ios", "wasm",
]
.into_iter()
.find(|os| target.contains(os));
(arch, os)
}
fn is_safe_asset_name(name: &str) -> bool {
if name.contains('\\') || name.contains(':') {
return false;
}
let mut components = std::path::Path::new(name).components();
matches!(
(components.next(), components.next()),
(Some(std::path::Component::Normal(_)), None)
)
}
fn resolve_and_confirm<U: UpdateConfig + UpdateInternals + ?Sized>(
u: &U,
release: &Release,
) -> Result<ReleaseAsset> {
let target = u.target();
let target_asset = match u.asset_matcher() {
Some(matcher) => matcher(&release.assets),
None => release.asset_for(target, u.asset_identifier()),
}
.ok_or_else(|| Error::NoReleaseFound {
target: Some(target.to_string()),
})?;
if !is_safe_asset_name(target_asset.name()) {
return Err(Error::InvalidAssetName {
name: target_asset.name().to_string(),
});
}
let prompt_confirmation = !u.no_confirm();
if u.show_output() || prompt_confirmation {
println!("\n{} release status:", u.bin_name());
println!(" * Current exe: {:?}", u.bin_install_path());
println!(" * New exe release: {:?}", target_asset.name());
println!(
" * New exe download url: {:?}",
crate::errors::redact_url(target_asset.download_url())
);
println!(
"\nThe new release will be downloaded/extracted and the existing binary will be replaced."
);
}
if prompt_confirmation {
confirm("Do you want to continue? [Y/n] ")?;
}
Ok(target_asset)
}
fn build_download<U: UpdateConfig + UpdateInternals + ?Sized>(
u: &U,
target_asset: &ReleaseAsset,
) -> Result<Download> {
let mut download = Download::from_url(target_asset.download_url());
let mut headers = u.api_headers(u.auth_token())?;
headers.insert(header::ACCEPT, "application/octet-stream".parse().unwrap());
u.request_config()
.apply_auth(target_asset.download_url(), &mut headers)?;
let user_auth_allowed = u
.request_config()
.auth_allowed_for(target_asset.download_url());
for (name, value) in u.request_headers() {
if name == header::AUTHORIZATION && !user_auth_allowed {
continue;
}
headers.insert(name.clone(), value.clone());
}
download.replace_headers(headers);
download.set_http_client(
u.request_client(),
#[cfg(feature = "async")]
u.request_async_client(),
);
for cert in &u.request_config().root_certificates {
download.add_root_certificate(cert.clone());
}
if let Some(timeout) = u.request_timeout() {
download.timeout(timeout);
}
{
let request = u.request_config();
download.set_retries(
request.retries,
request.retry_base_delay,
request.retry_max_delay,
);
}
if let Some(callback) = u.progress_callback() {
download.set_progress_callback_arc(callback);
}
download.show_download_progress(u.show_download_progress());
#[cfg(feature = "progress-bar")]
download.progress_style(crate::ProgressStyle::new(
u.progress_template(),
u.progress_chars(),
));
Ok(download)
}
struct FinishCtx {
release: Release,
bin_install_path: std::path::PathBuf,
target: String,
bin_name: String,
bin_path_in_archive: String,
show_output: bool,
verify_callback: Option<std::sync::Arc<crate::DynVerifyFn>>,
#[cfg(feature = "checksums")]
verify_checksum: Option<crate::Checksum>,
#[cfg(feature = "signatures")]
verify_keys: Vec<crate::VerifyingKey>,
}
impl FinishCtx {
fn capture<U: UpdateConfig + UpdateInternals + ?Sized>(u: &U, release: Release) -> Self {
Self {
release,
bin_install_path: u.bin_install_path().to_path_buf(),
target: u.target().to_string(),
bin_name: u.bin_name().to_string(),
bin_path_in_archive: u.bin_path_in_archive().to_string(),
show_output: u.show_output(),
verify_callback: u.verify_callback(),
#[cfg(feature = "checksums")]
verify_checksum: u.verify_checksum().cloned(),
#[cfg(feature = "signatures")]
verify_keys: u.verifying_keys().to_vec(),
}
}
}
fn finish_update<U: UpdateConfig + UpdateInternals + ?Sized>(
u: &U,
release: Release,
tmp_archive_dir: tempfile::TempDir,
tmp_archive_path: &std::path::Path,
) -> Result<ReleaseStatus> {
let ctx = FinishCtx::capture(u, release);
finish_update_owned(ctx, tmp_archive_dir, tmp_archive_path)
}
fn finish_update_owned(
ctx: FinishCtx,
tmp_archive_dir: tempfile::TempDir,
tmp_archive_path: &std::path::Path,
) -> Result<ReleaseStatus> {
let show_output = ctx.show_output;
#[cfg(feature = "checksums")]
if let Some(checksum) = ctx.verify_checksum.as_ref() {
checksum.verify(tmp_archive_path)?;
}
#[cfg(feature = "signatures")]
verify_signature(tmp_archive_path, &ctx.verify_keys)?;
print_flush(show_output, "Extracting archive... ")?;
let bin_path_str = Cow::Borrowed(ctx.bin_path_in_archive.as_str());
static VERSION_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{[[:space:]]*version[[:space:]]*\}\}").unwrap());
static TARGET_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{[[:space:]]*target[[:space:]]*\}\}").unwrap());
static BIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{[[:space:]]*bin[[:space:]]*\}\}").unwrap());
fn substitute<'a: 'b, 'b>(re: &Regex, str: &'a str, val: &str) -> Result<Cow<'b, str>> {
if re.is_match(str) && !is_safe_asset_name(val) {
return Err(Error::InvalidAssetName {
name: val.to_string(),
});
}
Ok(re.replace_all(str, regex::NoExpand(val)))
}
let bin_path_str = substitute(&VERSION_RE, &bin_path_str, ctx.release.version())?;
let bin_path_str = substitute(&TARGET_RE, &bin_path_str, &ctx.target)?;
let bin_path_str = substitute(&BIN_RE, &bin_path_str, &ctx.bin_name)?;
let bin_path_str = bin_path_str.as_ref();
Extract::from_source(tmp_archive_path).extract_file(tmp_archive_dir.path(), bin_path_str)?;
let new_exe = tmp_archive_dir.path().join(bin_path_str);
println(show_output, "Done");
print_flush(show_output, "Replacing binary file... ")?;
install_binary(
&new_exe,
&ctx.bin_install_path,
ctx.verify_callback.as_deref(),
)?;
println(show_output, "Done");
Ok(ReleaseStatus::Updated(ctx.release))
}
#[cfg(feature = "async")]
pub(crate) async fn update_extended_async<U>(u: &U) -> Result<ReleaseStatus>
where
U: AsyncReleaseUpdate + Sync,
{
let current_version = u.current_version();
let show_output = u.show_output();
print_check_header(u.target(), current_version, show_output);
let release = match u.release_tag() {
None => {
print_flush(show_output, "Checking latest released version... ")?;
let releases = u.get_newer_releases_async().await?;
match choose_latest_release(releases.into_vec(), current_version, show_output)? {
Some(release) => release,
None => return Ok(ReleaseStatus::UpToDate),
}
}
Some(ref ver) => {
println(show_output, &format!("Looking for tag: {}", ver));
u.get_release_version_async(ver).await?
}
};
let target_asset = resolve_and_confirm(u, &release)?;
let tmp_archive_dir = tempfile::TempDir::new()?;
let tmp_archive_path = tmp_archive_dir.path().join(target_asset.name());
let mut tmp_archive = fs::File::create(&tmp_archive_path)?;
println(show_output, "Downloading...");
build_download(u, &target_asset)?
.download_to_async(&mut tmp_archive)
.await?;
let ctx = FinishCtx::capture(u, release);
tokio::task::spawn_blocking(move || {
finish_update_owned(ctx, tmp_archive_dir, &tmp_archive_path)
})
.await
.map_err(|e| Error::Internal {
message: "finish-update task failed".to_string(),
source: Some(Box::new(e)),
})?
}
fn install_binary(
new_exe: &std::path::Path,
bin_install_path: &std::path::Path,
verify: Option<&crate::DynVerifyFn>,
) -> Result<()> {
if let Some(verify) = verify {
verify(new_exe).map_err(|e| match e {
Error::VerificationRejected { .. } => e,
other => Error::VerificationRejected {
reason: Some(other.to_string()),
},
})?;
}
let current_exe = std::env::current_exe()?;
if same_file(bin_install_path, ¤t_exe) {
self_replace::self_replace(new_exe)?;
} else {
Move::from_source(new_exe).to_dest(bin_install_path)?;
}
Ok(())
}
fn same_file(a: &std::path::Path, b: &std::path::Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => a == b,
}
}
fn print_flush(show_output: bool, msg: &str) -> Result<()> {
if show_output {
print_flush!("{}", msg);
}
Ok(())
}
fn println(show_output: bool, msg: &str) {
if show_output {
println!("{}", msg);
}
}
#[cfg(feature = "signatures")]
fn verify_signature(
archive_path: &std::path::Path,
keys: &[[u8; zipsign_api::PUBLIC_KEY_LENGTH]],
) -> crate::Result<()> {
if keys.is_empty() {
return Ok(());
}
println!("Verifying downloaded file...");
let archive_kind = crate::detect_archive(archive_path)?;
#[cfg(any(feature = "archive-tar", feature = "archive-zip"))]
{
let context = archive_path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.as_bytes())
.ok_or(Error::SignatureNonUTF8)?;
let keys = keys.iter().copied().map(Ok);
let keys =
zipsign_api::verify::collect_keys(keys).map_err(zipsign_api::ZipsignError::from)?;
let mut exe = std::fs::File::open(archive_path)?;
match archive_kind {
#[cfg(feature = "archive-tar")]
crate::ArchiveKind::Tar(Some(crate::Compression::Gz)) => {
zipsign_api::verify::verify_tar(&mut exe, &keys, Some(context))
.map_err(zipsign_api::ZipsignError::from)?;
return Ok(());
}
#[cfg(feature = "archive-zip")]
crate::ArchiveKind::Zip => {
zipsign_api::verify::verify_zip(&mut exe, &keys, Some(context))
.map_err(zipsign_api::ZipsignError::from)?;
return Ok(());
}
_ => {}
}
}
Err(Error::NoSignatures(archive_kind))
}
#[cfg(test)]
mod tests {
use super::{Releases, choose_latest_release, install_binary};
use crate::Download;
use crate::DynVerifyFn;
use crate::errors::Result;
use crate::update::Release;
fn rel(version: &str) -> Release {
Release::builder().version(version).build().unwrap()
}
#[test]
fn releases_is_update_available_true_when_latest_newer() {
let releases = Releases::new(vec![rel("2.0.0"), rel("1.0.0")], "1.0.0".to_string());
assert!(
releases.is_update_available().unwrap(),
"2.0.0 > 1.0.0 => update available"
);
}
#[test]
fn releases_is_update_available_false_when_latest_not_newer() {
let releases = Releases::new(vec![rel("1.0.0"), rel("0.9.0")], "1.0.0".to_string());
assert!(
!releases.is_update_available().unwrap(),
"1.0.0 not newer than 1.0.0 => no update"
);
}
#[test]
fn releases_is_update_available_false_when_empty() {
let releases = Releases::new(vec![], "1.0.0".to_string());
assert!(
!releases.is_update_available().unwrap(),
"empty Releases => no update available"
);
}
#[test]
fn releases_is_update_available_true_when_newer_not_first() {
let releases = Releases::new(
vec![rel("0.9.0"), rel("1.0.0"), rel("2.0.0")],
"1.0.0".to_string(),
);
assert!(
releases.is_update_available().unwrap(),
"2.0.0 is newer than 1.0.0 even though it is not first => update available"
);
}
#[test]
fn releases_is_update_available_false_when_nothing_newer_unordered() {
let releases = Releases::new(
vec![rel("0.9.0"), rel("1.0.0"), rel("0.5.0")],
"1.0.0".to_string(),
);
assert!(
!releases.is_update_available().unwrap(),
"no release exceeds 1.0.0 => no update available"
);
}
#[test]
fn releases_is_update_available_propagates_semver_error() {
let releases = Releases::new(
vec![rel("0.9.0"), rel("not-a-version"), rel("2.0.0")],
"1.0.0".to_string(),
);
let err = releases
.is_update_available()
.expect_err("an unparseable version reached by the scan must error");
assert!(
matches!(err, crate::errors::Error::SemVer(_)),
"expected Error::SemVer, got {:?}",
err
);
}
#[test]
fn releases_is_update_available_short_circuits_before_semver_error() {
let releases = Releases::new(
vec![rel("2.0.0"), rel("not-a-version")],
"1.0.0".to_string(),
);
assert!(
releases
.is_update_available()
.expect("a found update wins over a later parse error"),
"2.0.0 > 1.0.0 must return true before reaching the bad entry"
);
}
#[test]
fn releases_latest_all_and_into_vec() {
let releases = Releases::new(
vec![rel("2.0.0"), rel("1.5.0"), rel("1.0.0")],
"1.0.0".to_string(),
);
assert_eq!(releases.latest().unwrap().version(), "2.0.0");
let all: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
assert_eq!(all, vec!["2.0.0", "1.5.0", "1.0.0"]);
let v: Vec<String> = releases
.into_vec()
.into_iter()
.map(|r| r.version().to_string())
.collect();
assert_eq!(v, vec!["2.0.0", "1.5.0", "1.0.0"]);
}
#[test]
fn releases_latest_is_none_when_empty() {
let releases = Releases::new(vec![], "1.0.0".to_string());
assert!(releases.latest().is_none());
assert!(releases.all().is_empty());
}
#[test]
fn releases_len_and_is_empty() {
let empty = Releases::new(vec![], "1.0.0".to_string());
assert_eq!(empty.len(), 0);
assert!(empty.is_empty());
let some = Releases::new(vec![rel("2.0.0"), rel("1.0.0")], "1.0.0".to_string());
assert_eq!(some.len(), 2);
assert!(!some.is_empty());
}
#[test]
fn releases_current_version_accessor() {
let releases = Releases::new(vec![rel("2.0.0")], "1.2.3".to_string());
assert_eq!(releases.current_version(), Some("1.2.3"));
}
#[test]
fn releases_into_iterator_owned_in_order() {
let releases = Releases::new(
vec![rel("2.0.0"), rel("1.5.0"), rel("1.0.0")],
"1.0.0".to_string(),
);
let v: Vec<String> = releases
.into_iter()
.map(|r| r.version().to_string())
.collect();
assert_eq!(v, vec!["2.0.0", "1.5.0", "1.0.0"]);
}
#[test]
fn releases_into_iterator_borrowed_in_order() {
let releases = Releases::new(
vec![rel("2.0.0"), rel("1.5.0"), rel("1.0.0")],
"1.0.0".to_string(),
);
let v: Vec<&str> = (&releases).into_iter().map(|r| r.version()).collect();
assert_eq!(v, vec!["2.0.0", "1.5.0", "1.0.0"]);
assert_eq!(releases.len(), 3);
}
#[test]
fn releases_into_iterator_empty_yields_nothing() {
let borrowed = Releases::new(vec![], "1.0.0".to_string());
assert_eq!((&borrowed).into_iter().count(), 0, "&Releases over empty");
assert!(borrowed.is_empty());
let owned = Releases::new(vec![], "1.0.0".to_string());
assert_eq!(owned.into_iter().count(), 0, "owned Releases over empty");
}
#[test]
fn releases_into_iterator_order_matches_all() {
let releases = Releases::new(
vec![rel("3.0.0"), rel("2.1.0"), rel("2.0.0"), rel("1.0.0")],
"1.0.0".to_string(),
);
let expected: Vec<String> = releases
.all()
.iter()
.map(|r| r.version().to_string())
.collect();
let borrowed: Vec<String> = (&releases)
.into_iter()
.map(|r| r.version().to_string())
.collect();
assert_eq!(borrowed, expected, "&Releases iteration == all() order");
let owned: Vec<String> = releases
.into_iter()
.map(|r| r.version().to_string())
.collect();
assert_eq!(owned, expected, "owned iteration == all() order");
}
#[test]
fn release_status_into_version_status_updated() {
let rs = super::ReleaseStatus::Updated(rel("2.0.0"));
let vs = rs.into_version_status("1.0.0".to_string());
assert!(
vs.is_updated(),
"ReleaseStatus::Updated => VersionStatus::Updated"
);
assert_eq!(vs.version(), "2.0.0", "version comes from the release");
}
#[test]
fn release_status_into_version_status_up_to_date() {
let rs = super::ReleaseStatus::UpToDate;
let vs = rs.into_version_status("1.5.0".to_string());
assert!(
vs.is_up_to_date(),
"ReleaseStatus::UpToDate => VersionStatus::UpToDate"
);
assert_eq!(
vs.version(),
"1.5.0",
"version is the current_version passed in"
);
}
#[test]
fn release_status_is_updated_predicate() {
let updated = super::ReleaseStatus::Updated(rel("1.2.3"));
assert!(updated.is_updated(), "Updated => is_updated() true");
assert!(!updated.is_up_to_date());
let up_to_date = super::ReleaseStatus::UpToDate;
assert!(!up_to_date.is_updated(), "UpToDate => is_updated() false");
assert!(up_to_date.is_up_to_date());
}
#[test]
fn release_status_release_accessors() {
let updated = super::ReleaseStatus::Updated(rel("1.2.3"));
assert_eq!(
updated.updated_release().map(|r| r.version()),
Some("1.2.3"),
"updated_release() borrows the installed release"
);
assert_eq!(
updated
.into_updated_release()
.map(|r| r.version().to_string()),
Some("1.2.3".to_string()),
"into_updated_release() yields the installed release"
);
let up_to_date = super::ReleaseStatus::UpToDate;
assert!(
up_to_date.updated_release().is_none(),
"UpToDate => updated_release() None"
);
assert!(
up_to_date.into_updated_release().is_none(),
"UpToDate => into_updated_release() None"
);
}
#[test]
fn asset_for_fallback_uses_configured_target_not_build_host() {
let release = super::Release::builder()
.name("v1")
.version("1.0.0")
.assets([
super::ReleaseAsset::new("app-x86_64-linux", "https://host/linux"),
super::ReleaseAsset::new("app-aarch64-darwin", "https://host/darwin"),
])
.build()
.unwrap();
let chosen = release
.asset_for("aarch64-apple-darwin", None)
.expect("must select the darwin asset for an apple-darwin target");
assert_eq!(
chosen.download_url(),
"https://host/darwin",
"the fallback must use the configured target's arch/os, not the build host's"
);
}
#[test]
fn release_asset_new_argument_order() {
let asset = super::ReleaseAsset::new("my-bin-x86_64.tar.gz", "https://host/dl");
assert_eq!(asset.name(), "my-bin-x86_64.tar.gz");
assert_eq!(asset.download_url(), "https://host/dl");
}
#[test]
fn release_getters_return_builder_set_values() {
let release = super::Release::builder()
.name("My App 1.2.3")
.version("1.2.3")
.date("2024-05-06T00:00:00Z")
.body("release notes here")
.asset(super::ReleaseAsset::new(
"app.tar.gz",
"https://host/app.tar.gz",
))
.build()
.unwrap();
assert_eq!(release.name(), "My App 1.2.3");
assert_eq!(release.version(), "1.2.3");
assert_eq!(release.date(), "2024-05-06T00:00:00Z");
assert_eq!(release.body(), Some("release notes here"));
assert_eq!(release.assets().len(), 1);
assert_eq!(release.assets()[0].name(), "app.tar.gz");
assert_eq!(
release.assets()[0].download_url(),
"https://host/app.tar.gz"
);
}
#[test]
fn release_builder_defaults_name_to_version_and_body_to_none() {
let release = super::Release::builder().version("9.9.9").build().unwrap();
assert_eq!(release.version(), "9.9.9");
assert_eq!(release.name(), "9.9.9", "name defaults to version");
assert_eq!(release.date(), "", "date defaults to empty");
assert_eq!(release.body(), None, "body defaults to None");
assert!(release.assets().is_empty());
}
#[test]
fn release_clone_shares_arc_backing() {
let original = super::Release::builder()
.version("1.2.3")
.asset(super::ReleaseAsset::new(
"app.tar.gz",
"https://host/app.tar.gz",
))
.build()
.unwrap();
let cloned = original.clone();
assert_eq!(original.version(), cloned.version());
assert!(
std::ptr::eq(original.version().as_ptr(), cloned.version().as_ptr()),
"cloning a Release must share the Arc<str> backing, not reallocate"
);
assert!(std::ptr::eq(
original.assets()[0].download_url().as_ptr(),
cloned.assets()[0].download_url().as_ptr()
));
}
#[test]
fn releases_from_releases_builds_a_usable_collection() {
let releases = super::Releases::from_releases(vec![rel("2.0.0"), rel("1.0.0")], "1.0.0");
assert_eq!(releases.latest().unwrap().version(), "2.0.0");
assert_eq!(releases.current_version(), Some("1.0.0"));
assert!(
releases.is_update_available().unwrap(),
"2.0.0 > 1.0.0 via from_releases-built Releases"
);
let up_to_date = super::Releases::from_releases(vec![rel("1.0.0")], "1.0.0");
assert!(!up_to_date.is_update_available().unwrap());
}
#[test]
fn releases_from_listing_has_no_current_version() {
let listing = super::Releases::from_listing(vec![rel("2.0.0"), rel("1.0.0")]);
assert_eq!(listing.current_version(), None);
assert_eq!(listing.latest().unwrap().version(), "2.0.0");
assert!(
matches!(
listing.is_update_available(),
Err(crate::errors::Error::NoCurrentVersion)
),
"a bare listing must error on is_update_available with the distinct NoCurrentVersion \
variant, not the misleading MissingField builder-field error"
);
}
#[test]
fn release_status_version_returns_installed_version_or_none() {
let updated = super::ReleaseStatus::Updated(rel("3.1.4"));
assert_eq!(updated.version(), Some("3.1.4"));
let up_to_date = super::ReleaseStatus::UpToDate;
assert_eq!(
up_to_date.version(),
None,
"UpToDate carries no version => None"
);
}
#[test]
fn releases_from_listing_has_no_current_version_and_precheck_errors() {
let listing = super::Releases::from_listing(vec![rel("2.0.0"), rel("1.0.0")]);
assert_eq!(listing.current_version(), None);
assert_eq!(listing.latest().unwrap().version(), "2.0.0");
assert!(
matches!(
listing.is_update_available(),
Err(crate::errors::Error::NoCurrentVersion)
),
"a listing with no current version must error on is_update_available()"
);
let msg = listing.is_update_available().unwrap_err().to_string();
assert!(
msg.contains("no current_version to compare against"),
"the error must self-describe the bare-listing case, got: {msg}"
);
}
#[test]
fn choose_latest_release_up_to_date_when_nothing_newer() {
assert!(
choose_latest_release(vec![], "1.0.0", false)
.unwrap()
.is_none()
);
let chosen =
choose_latest_release(vec![rel("1.0.0"), rel("0.9.0")], "1.0.0", false).unwrap();
assert!(
chosen.is_none(),
"current/older releases must not be offered as an update"
);
}
#[test]
fn choose_latest_release_prefers_newest_compatible() {
let chosen = choose_latest_release(
vec![rel("1.2.0"), rel("1.1.0"), rel("1.0.0")],
"1.0.0",
false,
)
.unwrap()
.expect("a compatible newer release is chosen");
assert_eq!(chosen.version(), "1.2.0");
}
#[test]
fn choose_latest_release_sorts_out_of_order_candidates() {
let chosen = choose_latest_release(
vec![rel("1.1.0"), rel("1.4.2"), rel("1.0.5"), rel("1.3.0")],
"1.0.0",
false,
)
.unwrap()
.expect("the newest compatible release is chosen regardless of input order");
assert_eq!(chosen.version(), "1.4.2");
let chosen = choose_latest_release(
vec![rel("1.3.0"), rel("1.0.5"), rel("1.4.2"), rel("1.1.0")],
"1.0.0",
false,
)
.unwrap()
.expect("the newest compatible release is chosen regardless of input order");
assert_eq!(chosen.version(), "1.4.2");
}
#[test]
fn choose_latest_release_ignores_unparseable_versions() {
let chosen = choose_latest_release(
vec![
rel("not-a-version"),
rel("1.2.0"),
rel("also-bad"),
rel("1.1.0"),
],
"1.0.0",
false,
)
.unwrap()
.expect("the newest parseable compatible release is chosen");
assert_eq!(chosen.version(), "1.2.0");
assert!(
choose_latest_release(vec![rel("junk"), rel("garbage")], "1.0.0", false)
.unwrap()
.is_none()
);
}
#[test]
fn choose_latest_release_falls_back_to_incompatible_newer() {
let chosen = choose_latest_release(vec![rel("2.0.0")], "1.0.0", false)
.unwrap()
.expect("an incompatible-but-newer release is still offered");
assert_eq!(chosen.version(), "2.0.0");
}
use crate::update::{ReleaseUpdate, UpdateConfig, UpdateInternals};
fn accessor_via_release_update_bound<R: ReleaseUpdate + ?Sized>(r: &R) -> (String, String) {
(r.bin_name().to_string(), r.target().to_string())
}
fn internal_accessor_via_update_internals_bound<U: UpdateInternals + ?Sized>(u: &U) -> usize {
u.request_headers().len()
}
fn accessor_via_dyn_release_update(r: &dyn ReleaseUpdate) -> String {
r.current_version().to_string()
}
fn accessor_via_update_config_bound<U: UpdateConfig + ?Sized>(u: &U) -> String {
u.bin_name().to_string()
}
#[test]
fn bound_narrowing_helpers_are_exercised() {
let upd = crate::backends::custom::Update::configure()
.source(BoundSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build()
.unwrap();
let (bin, target) = accessor_via_release_update_bound(&upd);
let expected_bin = format!("app{}", std::env::consts::EXE_SUFFIX);
assert_eq!(bin, expected_bin);
assert_eq!(target, "x86_64-unknown-linux-gnu");
assert_eq!(accessor_via_dyn_release_update(&upd), "1.0.0");
assert_eq!(accessor_via_update_config_bound(&upd), expected_bin);
assert_eq!(internal_accessor_via_update_internals_bound(&upd), 0);
}
#[cfg(feature = "async")]
use crate::update::AsyncReleaseUpdate;
#[cfg(feature = "async")]
struct TaggedAsyncSource;
#[cfg(feature = "async")]
impl crate::update::AsyncReleaseSource for TaggedAsyncSource {
async fn get_latest_release(&self) -> Result<Release> {
Release::builder().version("2.0.0").build()
}
async fn get_releases(&self) -> Result<Vec<Release>> {
Ok(vec![Release::builder().version("2.0.0").build()?])
}
async fn get_release_version(&self, ver: &str) -> Result<Release> {
if ver == "9.9.9" {
Err(crate::errors::Error::NoReleaseFound { target: None })
} else {
Release::builder().version(ver).build()
}
}
}
#[cfg(feature = "async")]
#[tokio::test]
async fn public_get_release_version_async_returns_tagged_release() {
let upd = crate::backends::custom::AsyncUpdate::configure()
.source(TaggedAsyncSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build_async()
.unwrap();
let rel = upd.get_release_version_async("1.5.0").await.unwrap();
assert_eq!(rel.version(), "1.5.0");
}
#[cfg(feature = "async")]
async fn fetch_latest_via_bound<U: AsyncReleaseUpdate + Sync>(u: &U) -> Result<String> {
let releases = u.get_latest_release_async().await?;
Ok(releases
.latest()
.map(|r| r.version().to_string())
.unwrap_or_default())
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_release_update_is_usable_as_a_generic_bound() {
let upd = crate::backends::custom::AsyncUpdate::configure()
.source(TaggedAsyncSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build_async()
.unwrap();
let version = fetch_latest_via_bound(&upd).await.unwrap();
assert_eq!(
version, "2.0.0",
"the bounded generic fn drove the async verb"
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn public_get_release_version_async_propagates_missing_tag_error() {
let upd = crate::backends::custom::AsyncUpdate::configure()
.source(TaggedAsyncSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build_async()
.unwrap();
let res = upd.get_release_version_async("9.9.9").await;
assert!(
matches!(res, Err(crate::errors::Error::NoReleaseFound { .. })),
"a missing tag must propagate as Error::NoReleaseFound, got {:?}",
res
);
}
struct BoundSource;
impl crate::update::ReleaseSource for BoundSource {
fn get_latest_release(&self) -> Result<Release> {
Release::builder().version("1.0.0").build()
}
fn get_releases(&self) -> Result<Vec<Release>> {
Ok(vec![Release::builder().version("1.0.0").build()?])
}
fn get_release_version(&self, v: &str) -> Result<Release> {
Release::builder().version(v).build()
}
}
#[test]
fn bin_install_path_returns_a_borrow() {
let upd = crate::backends::custom::Update::configure()
.source(BoundSource)
.bin_name("app")
.bin_install_path("/tmp/app-install-path")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.build()
.unwrap();
let p: &std::path::Path = upd.bin_install_path();
assert_eq!(p, std::path::Path::new("/tmp/app-install-path"));
}
#[test]
fn install_binary_aborts_when_verify_rejects() {
let dir = tempfile::tempdir().unwrap();
let new_exe = dir.path().join("new");
std::fs::write(&new_exe, b"new binary").unwrap();
let dest = dir.path().join("installed");
let reject: Box<DynVerifyFn> = Box::new(|_: &std::path::Path| {
Err(crate::errors::Error::VerificationRejected {
reason: Some("binary did not pass the smoke test".to_string()),
})
});
let res = install_binary(&new_exe, &dest, Some(&*reject));
let err = res.expect_err("a rejecting verify hook must abort the install");
match err {
crate::errors::Error::VerificationRejected { reason } => {
let reason = reason.expect("a rejecting hook must carry a reason");
assert!(
reason.contains("binary did not pass the smoke test"),
"the rejection reason must carry the hook's error message, got: {reason}"
);
}
other => panic!("expected Error::VerificationRejected, got {:?}", other),
}
assert!(
!dest.exists(),
"nothing is installed when verification fails"
);
assert!(new_exe.exists(), "the extracted binary is left untouched");
}
#[test]
fn install_binary_passes_verification_rejected_through_unwrapped() {
let dir = tempfile::tempdir().unwrap();
let new_exe = dir.path().join("new");
std::fs::write(&new_exe, b"new binary").unwrap();
let dest = dir.path().join("installed");
let reject: Box<DynVerifyFn> = Box::new(|_: &std::path::Path| {
Err(crate::errors::Error::verification_rejected("bad signature"))
});
let err = install_binary(&new_exe, &dest, Some(&*reject))
.expect_err("a rejecting verify hook must abort the install");
match err {
crate::errors::Error::VerificationRejected { reason } => {
assert_eq!(
reason.as_deref(),
Some("bad signature"),
"the constructor's reason must pass through unwrapped"
);
}
other => panic!("expected Error::VerificationRejected, got {:?}", other),
}
assert!(!dest.exists());
}
#[test]
fn install_binary_propagates_hook_io_error_as_reason() {
let dir = tempfile::tempdir().unwrap();
let new_exe = dir.path().join("new");
std::fs::write(&new_exe, b"new binary").unwrap();
let dest = dir.path().join("installed");
let io_failing: Box<DynVerifyFn> = Box::new(|_: &std::path::Path| {
Err(crate::errors::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not run new --version",
)))
});
let err = install_binary(&new_exe, &dest, Some(&*io_failing))
.expect_err("a hook IO error must abort the install");
match err {
crate::errors::Error::VerificationRejected { reason } => {
let reason = reason.expect("a hook IO error must carry its message as the reason");
assert!(
reason.contains("could not run new --version"),
"the hook IO error message must propagate as the reason, got: {reason}"
);
}
other => panic!("expected Error::VerificationRejected, got {:?}", other),
}
assert!(!dest.exists());
}
#[test]
fn install_binary_installs_when_verify_accepts() {
let dir = tempfile::tempdir().unwrap();
let new_exe = dir.path().join("new");
std::fs::write(&new_exe, b"new binary").unwrap();
let dest = dir.path().join("installed");
let accept: Box<DynVerifyFn> = Box::new(|_: &std::path::Path| Ok(()));
install_binary(&new_exe, &dest, Some(&*accept)).unwrap();
assert!(
dest.exists(),
"binary is installed when verification passes"
);
assert_eq!(std::fs::read(&dest).unwrap(), b"new binary");
}
#[cfg(feature = "checksums")]
fn update_with_checksum(checksum: crate::Checksum) -> crate::backends::custom::Update {
crate::backends::custom::Update::configure()
.source(BoundSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.verify_checksum(checksum)
.build()
.unwrap()
}
#[cfg(feature = "checksums")]
#[test]
fn finish_update_rejects_a_mismatched_checksum_before_extracting() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("release.tar.gz");
std::fs::write(&archive_path, b"hello").unwrap();
let upd = update_with_checksum(crate::Checksum::Sha256("00".repeat(32)));
let release = Release::builder().version("1.2.3").build().unwrap();
let err = super::finish_update(&upd, release, dir, &archive_path)
.expect_err("a mismatched checksum must abort the update");
let msg = err.to_string();
assert!(
msg.contains("checksum mismatch"),
"expected a checksum-mismatch abort, got: {}",
msg
);
}
#[cfg(all(
feature = "checksums",
feature = "archive-tar",
feature = "compression-tar-gz"
))]
#[test]
fn finish_update_passes_a_matching_checksum_then_proceeds() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("release.tar.gz");
std::fs::write(&archive_path, b"hello").unwrap();
let digest = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
let upd = update_with_checksum(crate::Checksum::Sha256(digest.to_string()));
let release = Release::builder().version("1.2.3").build().unwrap();
let err = super::finish_update(&upd, release, dir, &archive_path)
.expect_err("the bytes are not a real archive, so extraction must fail");
let msg = err.to_string();
assert!(
!msg.contains("checksum mismatch"),
"a matching checksum must pass the gate; the failure should come from extraction, \
got: {}",
msg
);
}
#[cfg(feature = "async")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn async_finish_join_failure_maps_to_internal_with_source() {
use std::error::Error as _;
let mapped: Result<()> = tokio::task::spawn_blocking(|| {
panic!("boom in the finish tail");
})
.await
.map_err(|e| crate::errors::Error::Internal {
message: "finish-update task failed".to_string(),
source: Some(Box::new(e)),
});
let err = mapped.expect_err("a panicking finish tail must fail the join");
match err {
crate::errors::Error::Internal {
ref message,
ref source,
} => {
assert_eq!(message, "finish-update task failed");
assert!(
source.is_some(),
"Internal from a JoinError must carry a non-None source"
);
}
other => panic!("expected Error::Internal, got {:?}", other),
}
assert!(
err.source().is_some(),
"the JoinError must chain through source()"
);
assert_eq!(
err.to_string(),
"InternalError: finish-update task failed",
"Display must render the message without panicking"
);
}
#[cfg(any(feature = "github", feature = "gitlab"))]
fn download_auth_capture_stub() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = captured.clone();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]).into_owned();
let lines: Vec<String> = req.lines().map(|l| l.to_string()).collect();
*sink.lock().unwrap() = lines;
let body = "payload";
let out = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(out.as_bytes());
let _ = stream.flush();
}
});
(base, captured)
}
#[cfg(any(feature = "github", feature = "gitlab"))]
fn captured_download_authorization(lines: &[String]) -> Option<String> {
lines.iter().find_map(|l| {
l.strip_prefix("Authorization: ")
.or_else(|| l.strip_prefix("authorization: "))
.map(|v| v.to_string())
})
}
#[cfg(feature = "github")]
#[test]
fn download_path_applies_github_token_scheme() {
let (base, captured) = download_auth_capture_stub();
let host = crate::backends::common::host_of(&base).unwrap();
let upd = crate::backends::github::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.auth_token("secret")
.allow_auth_host(host)
.build()
.unwrap();
let asset = super::ReleaseAsset::new("app.tar.gz", format!("{base}/app.tar.gz"));
let download = super::build_download(&upd, &asset).unwrap();
let mut out = Vec::new();
download.download_to(&mut out).unwrap();
assert_eq!(out, b"payload", "the download streamed the stub body");
let lines = captured.lock().unwrap().clone();
assert_eq!(
captured_download_authorization(&lines),
Some("token secret".to_string()),
"the download path must send github's derived `token` auth header to an authorized host"
);
}
#[cfg(feature = "github")]
#[test]
fn download_path_drops_auth_for_cross_origin_asset_url() {
let (base, captured) = download_auth_capture_stub();
let upd = crate::backends::github::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.auth_token("secret")
.build()
.unwrap();
let asset = super::ReleaseAsset::new("app.tar.gz", format!("{base}/app.tar.gz"));
let download = super::build_download(&upd, &asset).unwrap();
let mut out = Vec::new();
download.download_to(&mut out).unwrap();
let lines = captured.lock().unwrap().clone();
assert_eq!(
captured_download_authorization(&lines),
None,
"the token must not be sent to a cross-origin asset download URL"
);
}
#[cfg(feature = "github")]
#[test]
fn download_path_honors_user_authorization_override() {
use crate::http_client::header::AUTHORIZATION;
let (base, captured) = download_auth_capture_stub();
let host = crate::backends::common::host_of(&base).unwrap();
let upd = crate::backends::github::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.auth_token("secret")
.allow_auth_host(host)
.request_header(AUTHORIZATION, "Bearer user-override")
.build()
.unwrap();
let asset = super::ReleaseAsset::new("app.tar.gz", format!("{base}/app.tar.gz"));
let download = super::build_download(&upd, &asset).unwrap();
let mut out = Vec::new();
download.download_to(&mut out).unwrap();
let lines = captured.lock().unwrap().clone();
assert_eq!(
captured_download_authorization(&lines),
Some("Bearer user-override".to_string()),
"a user AUTHORIZATION override must win over the backend token, and be forwarded to the \
authorized asset host"
);
}
#[cfg(feature = "github")]
#[test]
fn download_path_drops_user_authorization_for_disallowed_host() {
use crate::http_client::header::AUTHORIZATION;
let (base, captured) = download_auth_capture_stub();
let upd = crate::backends::github::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.request_header(AUTHORIZATION, "Bearer user-secret")
.build()
.unwrap();
let asset = super::ReleaseAsset::new("app.tar.gz", format!("{base}/app.tar.gz"));
let download = super::build_download(&upd, &asset).unwrap();
let mut out = Vec::new();
download.download_to(&mut out).unwrap();
let lines = captured.lock().unwrap().clone();
assert_eq!(
captured_download_authorization(&lines),
None,
"a user Authorization must NOT be forwarded to a disallowed (cross-origin) download host"
);
}
#[cfg(feature = "gitlab")]
#[test]
fn download_path_applies_gitlab_bearer_scheme() {
let (base, captured) = download_auth_capture_stub();
let host = crate::backends::common::host_of(&base).unwrap();
let upd = crate::backends::gitlab::Update::configure()
.repo_owner("o")
.repo_name("r")
.bin_name("app")
.current_version("0.1.0")
.auth_token("secret")
.allow_auth_host(host)
.build()
.unwrap();
let asset = super::ReleaseAsset::new("app.tar.gz", format!("{base}/app.tar.gz"));
let download = super::build_download(&upd, &asset).unwrap();
let mut out = Vec::new();
download.download_to(&mut out).unwrap();
let lines = captured.lock().unwrap().clone();
assert_eq!(
captured_download_authorization(&lines),
Some("Bearer secret".to_string()),
"the download path must send gitlab's derived `Bearer` auth header to an authorized host"
);
}
#[test]
fn build_download_forwards_configured_retry_budget() {
use crate::http_client::header::HeaderMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
struct FlakyClient {
body: Vec<u8>,
fail_times: AtomicU32,
attempts: Arc<AtomicU32>,
}
impl crate::http_client::HttpClient for FlakyClient {
fn get(
&self,
_url: &str,
_headers: &HeaderMap,
_timeout: Option<std::time::Duration>,
) -> Result<Box<dyn crate::http_client::HttpResponse>> {
self.attempts.fetch_add(1, Ordering::SeqCst);
if self.fail_times.load(Ordering::SeqCst) > 0 {
self.fail_times.fetch_sub(1, Ordering::SeqCst);
return Err(crate::errors::Error::HttpStatus {
status: 503,
url: "u".into(),
});
}
Ok(Box::new(CannedResponse {
body: self.body.clone(),
}))
}
}
struct CannedResponse {
body: Vec<u8>,
}
impl crate::http_client::HttpResponse for CannedResponse {
fn headers(&self) -> &HeaderMap {
static EMPTY: std::sync::OnceLock<HeaderMap> = std::sync::OnceLock::new();
EMPTY.get_or_init(HeaderMap::new)
}
fn body(self: Box<Self>) -> Box<dyn std::io::Read> {
Box::new(std::io::Cursor::new(self.body))
}
}
let attempts = Arc::new(AtomicU32::new(0));
let client = Arc::new(FlakyClient {
body: b"after-retry".to_vec(),
fail_times: AtomicU32::new(2),
attempts: attempts.clone(),
});
let upd = crate::backends::custom::Update::configure()
.source(BoundSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.retries(3)
.retry_backoff(
std::time::Duration::from_millis(1),
std::time::Duration::from_millis(2),
)
.http_client(client)
.build()
.unwrap();
let asset = super::ReleaseAsset::new("app.bin", "https://nonroutable.invalid/app.bin");
let download = super::build_download(&upd, &asset).unwrap();
let mut out = Vec::new();
download.download_to(&mut out).unwrap();
assert_eq!(out, b"after-retry", "the download succeeds after retrying");
assert_eq!(
attempts.load(Ordering::SeqCst),
3,
"the configured retry budget (3) must be forwarded to the download: two failures + success"
);
}
#[test]
fn build_download_forwards_certs_to_download() {
use crate::http_client::header::HeaderMap;
use std::sync::Arc;
struct NoopClient;
impl crate::http_client::HttpClient for NoopClient {
fn get(
&self,
_url: &str,
_headers: &HeaderMap,
_timeout: Option<std::time::Duration>,
) -> Result<Box<dyn crate::http_client::HttpResponse>> {
unreachable!("not called in this test")
}
}
#[cfg(feature = "async")]
struct NoopAsyncClient;
#[cfg(feature = "async")]
impl crate::http_client::AsyncHttpClient for NoopAsyncClient {
fn get<'a>(
&'a self,
_url: &'a str,
_headers: &'a HeaderMap,
_timeout: Option<std::time::Duration>,
) -> futures_util::future::BoxFuture<
'a,
Result<Box<dyn crate::http_client::AsyncHttpResponse>>,
> {
unreachable!("not called in this test")
}
}
let mut builder = crate::backends::custom::Update::configure();
builder
.source(BoundSource)
.bin_name("app")
.target("x86_64-unknown-linux-gnu")
.current_version("1.0.0")
.http_client(Arc::new(NoopClient))
.add_root_certificate(crate::Certificate::from_pem(b"pem-bytes".to_vec()))
.add_root_certificate(crate::Certificate::from_der(b"der-bytes".to_vec()));
#[cfg(feature = "async")]
builder.http_client_async(Arc::new(NoopAsyncClient));
let upd = builder.build().unwrap();
let asset = super::ReleaseAsset::new("app.bin", "https://nonroutable.invalid/app.bin");
let download = super::build_download(&upd, &asset).unwrap();
assert_eq!(
download.root_certificates().len(),
2,
"both configured root certificates must be forwarded onto the Download"
);
}
#[test]
fn download_does_not_retry_or_corrupt_after_streaming_begins() {
use crate::http_client::header::{CONTENT_LENGTH, HeaderMap};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
struct FailingMidStream {
prefix: Vec<u8>,
yielded: bool,
}
impl std::io::Read for FailingMidStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if !self.yielded {
self.yielded = true;
let n = self.prefix.len().min(buf.len());
buf[..n].copy_from_slice(&self.prefix[..n]);
return Ok(n);
}
Err(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"connection reset mid-stream",
))
}
}
struct MidStreamResponse {
headers: HeaderMap,
prefix: Vec<u8>,
}
impl crate::http_client::HttpResponse for MidStreamResponse {
fn headers(&self) -> &HeaderMap {
&self.headers
}
fn body(self: Box<Self>) -> Box<dyn std::io::Read> {
Box::new(FailingMidStream {
prefix: self.prefix,
yielded: false,
})
}
}
struct MidStreamClient {
attempts: Arc<AtomicU32>,
}
impl crate::http_client::HttpClient for MidStreamClient {
fn get(
&self,
_url: &str,
_headers: &HeaderMap,
_timeout: Option<std::time::Duration>,
) -> Result<Box<dyn crate::http_client::HttpResponse>> {
self.attempts.fetch_add(1, Ordering::SeqCst);
let mut headers = HeaderMap::new();
headers.insert(CONTENT_LENGTH, "1024".parse().unwrap());
Ok(Box::new(MidStreamResponse {
headers,
prefix: b"PARTIAL".to_vec(),
}))
}
}
let attempts = Arc::new(AtomicU32::new(0));
let client = Arc::new(MidStreamClient {
attempts: attempts.clone(),
});
let mut dl = Download::from_url("https://nonroutable.invalid/asset.bin");
dl.set_http_client(
Some(client),
#[cfg(feature = "async")]
None,
);
dl.set_retries(
5,
std::time::Duration::from_millis(1),
std::time::Duration::from_millis(2),
);
let mut out = Vec::new();
let res = dl.download_to(&mut out);
assert!(
res.is_err(),
"a mid-stream failure must propagate, not be silently retried into a corrupt file"
);
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"the GET must be issued exactly once: streaming-phase failures are NOT re-established"
);
assert_eq!(
out, b"PARTIAL",
"the destination must hold only the single pre-failure prefix, never a duplicated body"
);
}
#[cfg(all(
feature = "signatures",
feature = "archive-tar",
feature = "compression-tar-gz",
))]
fn make_tar_gz() -> Result<Vec<u8>> {
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Cursor;
let mut data = Cursor::new(Vec::<u8>::new());
{
let gz = GzEncoder::new(&mut data, Compression::default());
let mut tar = tar::Builder::new(gz);
let content = b"hello";
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, "hello.txt", content.as_slice())?;
tar.finish()?;
}
Ok(data.into_inner())
}
#[cfg(all(
feature = "signatures",
feature = "archive-tar",
feature = "compression-tar-gz",
))]
fn sign_tar_gz(
unsigned: &[u8],
signing_keys: &[zipsign_api::SigningKey],
) -> Result<tempfile::NamedTempFile> {
use std::io::Cursor;
use tempfile::Builder;
let signed_file = Builder::new().suffix(".tar.gz").tempfile()?;
let signed_path = signed_file.path();
let context = signed_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.as_bytes();
let mut unsigned_cursor = Cursor::new(unsigned);
zipsign_api::sign::copy_and_sign_tar(
&mut unsigned_cursor,
&mut signed_file.as_file(),
signing_keys,
Some(context),
)
.map_err(zipsign_api::ZipsignError::from)?;
Ok(signed_file)
}
#[test]
#[cfg(all(
feature = "signatures",
feature = "archive-tar",
feature = "compression-tar-gz",
))]
fn embedded_key_verification_const_seed_verifies() -> Result<()> {
const KEY_SEED: [u8; 32] = [42u8; 32];
let signing_key = zipsign_api::SigningKey::from_bytes(&KEY_SEED);
let vkey: [u8; zipsign_api::PUBLIC_KEY_LENGTH] = signing_key.verifying_key().to_bytes();
let unsigned = make_tar_gz()?;
let signed_file = sign_tar_gz(&unsigned, &[signing_key])?;
super::verify_signature(signed_file.path(), &[vkey])
}
#[test]
#[cfg(all(
feature = "signatures",
feature = "archive-tar",
feature = "compression-tar-gz",
))]
fn embedded_key_verification_dual_signed_verifies_with_each_key() -> Result<()> {
let key_a = zipsign_api::SigningKey::from_bytes(&[1u8; 32]);
let key_b = zipsign_api::SigningKey::from_bytes(&[2u8; 32]);
let vkey_a: [u8; zipsign_api::PUBLIC_KEY_LENGTH] = key_a.verifying_key().to_bytes();
let vkey_b: [u8; zipsign_api::PUBLIC_KEY_LENGTH] = key_b.verifying_key().to_bytes();
let unsigned = make_tar_gz()?;
let signed_file = sign_tar_gz(&unsigned, &[key_a, key_b])?;
super::verify_signature(signed_file.path(), &[vkey_a])?;
super::verify_signature(signed_file.path(), &[vkey_b])
}
#[test]
#[cfg(all(
feature = "signatures",
feature = "archive-tar",
feature = "compression-tar-gz",
))]
fn embedded_key_verification_wrong_key_returns_error() -> Result<()> {
let key_a = zipsign_api::SigningKey::from_bytes(&[1u8; 32]);
let key_b = zipsign_api::SigningKey::from_bytes(&[2u8; 32]);
let vkey_b: [u8; zipsign_api::PUBLIC_KEY_LENGTH] = key_b.verifying_key().to_bytes();
let unsigned = make_tar_gz()?;
let signed_file = sign_tar_gz(&unsigned, &[key_a])?;
let err = super::verify_signature(signed_file.path(), &[vkey_b]).unwrap_err();
assert!(
matches!(err, crate::errors::Error::Signature(_)),
"expected Signature error, got: {err}"
);
Ok(())
}
#[test]
#[cfg(feature = "signatures")]
fn embedded_key_verification_empty_keys_is_noop() {
let missing = std::path::Path::new("/nonexistent/self_update/never_here.tar.gz");
let res = super::verify_signature(missing, &[]);
assert!(
res.is_ok(),
"empty key set must skip verification and return Ok without touching the file, got: {res:?}"
);
}
#[test]
#[cfg(all(
feature = "signatures",
feature = "archive-tar",
feature = "compression-tar-gz",
))]
fn embedded_key_verification_any_of_verifier_keys_accepts() -> Result<()> {
let signer = zipsign_api::SigningKey::from_bytes(&[7u8; 32]);
let wrong = zipsign_api::SigningKey::from_bytes(&[8u8; 32]);
let vkey_signer: [u8; zipsign_api::PUBLIC_KEY_LENGTH] = signer.verifying_key().to_bytes();
let vkey_wrong: [u8; zipsign_api::PUBLIC_KEY_LENGTH] = wrong.verifying_key().to_bytes();
let unsigned = make_tar_gz()?;
let signed_file = sign_tar_gz(&unsigned, &[signer])?;
super::verify_signature(signed_file.path(), &[vkey_wrong, vkey_signer])?;
super::verify_signature(signed_file.path(), &[vkey_signer, vkey_wrong])
}
#[test]
#[cfg(all(feature = "signatures", feature = "archive-tar"))]
fn embedded_key_verification_unsupported_archive_kind_returns_no_signatures() -> Result<()> {
let vkey = zipsign_api::SigningKey::from_bytes(&[9u8; 32])
.verifying_key()
.to_bytes();
let f = tempfile::Builder::new().suffix(".tar").tempfile()?;
std::fs::write(
f.path(),
b"not really a tar, but the extension decides the kind",
)?;
let err = super::verify_signature(f.path(), &[vkey]).unwrap_err();
assert!(
matches!(err, crate::errors::Error::NoSignatures(_)),
"a bare .tar with non-empty keys must yield NoSignatures, got: {err}"
);
Ok(())
}
#[test]
#[cfg(all(unix, feature = "signatures", feature = "archive-tar"))]
fn embedded_key_verification_non_utf8_filename_returns_non_utf8_error() {
use std::os::unix::ffi::OsStrExt;
let vkey = zipsign_api::SigningKey::from_bytes(&[3u8; 32])
.verifying_key()
.to_bytes();
let name = std::ffi::OsStr::from_bytes(b"\xff\xfe-bad.tar.gz");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(name);
let err = super::verify_signature(&path, &[vkey]).unwrap_err();
assert!(
matches!(err, crate::errors::Error::SignatureNonUTF8),
"a non-UTF-8 archive name must yield SignatureNonUTF8, got: {err}"
);
}
#[cfg(all(feature = "signatures", feature = "archive-zip"))]
fn make_zip() -> Result<Vec<u8>> {
use std::io::Cursor;
use std::io::Write;
let mut buf = Cursor::new(Vec::<u8>::new());
{
let mut zip = zip::ZipWriter::new(&mut buf);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("hello.txt", options)
.expect("start zip file");
zip.write_all(b"hello").expect("write zip file");
zip.finish().expect("finish zip");
}
Ok(buf.into_inner())
}
#[cfg(all(feature = "signatures", feature = "archive-zip"))]
fn sign_zip(
unsigned: &[u8],
signing_keys: &[zipsign_api::SigningKey],
) -> Result<tempfile::NamedTempFile> {
use std::io::Cursor;
let signed_file = tempfile::Builder::new().suffix(".zip").tempfile()?;
let context = signed_file
.path()
.file_name()
.unwrap()
.to_str()
.unwrap()
.as_bytes()
.to_vec();
let mut unsigned_cursor = Cursor::new(unsigned.to_vec());
zipsign_api::sign::copy_and_sign_zip(
&mut unsigned_cursor,
&mut signed_file.as_file(),
signing_keys,
Some(&context),
)
.map_err(zipsign_api::ZipsignError::from)?;
Ok(signed_file)
}
#[test]
#[cfg(all(feature = "signatures", feature = "archive-zip"))]
fn embedded_key_verification_zip_const_seed_verifies() -> Result<()> {
const KEY_SEED: [u8; 32] = [55u8; 32];
let signing_key = zipsign_api::SigningKey::from_bytes(&KEY_SEED);
let vkey: [u8; zipsign_api::PUBLIC_KEY_LENGTH] = signing_key.verifying_key().to_bytes();
let unsigned = make_zip()?;
let signed_file = sign_zip(&unsigned, &[signing_key])?;
super::verify_signature(signed_file.path(), &[vkey])
}
#[test]
#[cfg(all(feature = "signatures", feature = "archive-zip"))]
fn embedded_key_verification_zip_wrong_key_returns_error() -> Result<()> {
let key_a = zipsign_api::SigningKey::from_bytes(&[11u8; 32]);
let key_b = zipsign_api::SigningKey::from_bytes(&[12u8; 32]);
let vkey_b: [u8; zipsign_api::PUBLIC_KEY_LENGTH] = key_b.verifying_key().to_bytes();
let unsigned = make_zip()?;
let signed_file = sign_zip(&unsigned, &[key_a])?;
let err = super::verify_signature(signed_file.path(), &[vkey_b]).unwrap_err();
assert!(
matches!(err, crate::errors::Error::Signature(_)),
"expected Signature error for wrong zip key, got: {err}"
);
Ok(())
}
fn traversal_ctx(bin_path_in_archive: &str, version: &str) -> super::FinishCtx {
super::FinishCtx {
release: rel(version),
bin_install_path: std::path::PathBuf::from("unused"),
target: "x86_64-unknown-linux-gnu".to_string(),
bin_name: "app".to_string(),
bin_path_in_archive: bin_path_in_archive.to_string(),
show_output: false,
verify_callback: None,
#[cfg(feature = "checksums")]
verify_checksum: None,
#[cfg(feature = "signatures")]
verify_keys: vec![],
}
}
#[test]
fn finish_update_rejects_traversal_in_substituted_version() {
let ctx = traversal_ctx("{{ version }}/app", "../evil");
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("archive.tar.gz");
let res = super::finish_update_owned(ctx, dir, &archive);
match res {
Err(super::Error::InvalidAssetName { name }) => {
assert_eq!(name, "../evil", "the offending component must be named");
}
other => panic!("expected InvalidAssetName for a traversal version, got {other:?}"),
}
}
#[test]
fn finish_update_rejects_separator_in_substituted_version() {
let ctx = traversal_ctx("{{ version }}", "sub/evil");
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("archive.tar.gz");
assert!(
matches!(
super::finish_update_owned(ctx, dir, &archive),
Err(super::Error::InvalidAssetName { .. })
),
"a `/` in a substituted component must be rejected"
);
}
#[test]
fn finish_update_does_not_reject_unreferenced_substitution_value() {
let ctx = traversal_ctx("plain-bin", "../evil");
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("archive.tar.gz");
let res = super::finish_update_owned(ctx, dir, &archive);
assert!(
!matches!(res, Err(super::Error::InvalidAssetName { .. })),
"an unreferenced traversal value must not trigger the substitution guard, got {res:?}"
);
}
#[test]
fn config_auth_allowed_for_gates_host_and_scheme() {
let cfg = crate::backends::common::RequestConfig {
auth_base_host: Some("api.example.com".into()),
auth_hosts: vec!["cdn.example.com".into()],
..Default::default()
};
assert!(
cfg.auth_allowed_for("https://api.example.com/asset"),
"the configured API host over https is allowed"
);
assert!(
cfg.auth_allowed_for("https://cdn.example.com/asset"),
"an allow_auth_host entry over https is allowed"
);
assert!(
!cfg.auth_allowed_for("https://evil.example.net/asset"),
"an unlisted host must be rejected"
);
assert!(
!cfg.auth_allowed_for("http://api.example.com/asset"),
"a matching host over plain http (non-loopback) must be rejected"
);
}
#[test]
fn config_auth_allowed_for_loopback_and_insecure_flag() {
let cfg = crate::backends::common::RequestConfig {
auth_hosts: vec!["127.0.0.1".into()],
..Default::default()
};
assert!(
cfg.auth_allowed_for("http://127.0.0.1:8080/asset"),
"a host-matched loopback address is allowed over http"
);
let mut cfg = crate::backends::common::RequestConfig {
auth_base_host: Some("internal.example.com".into()),
..Default::default()
};
assert!(
!cfg.auth_allowed_for("http://internal.example.com/asset"),
"http to a matched non-loopback host is rejected by default"
);
cfg.allow_insecure_auth = true;
assert!(
cfg.auth_allowed_for("http://internal.example.com/asset"),
"allow_insecure_auth lifts the https requirement for a matched host"
);
assert!(
!cfg.auth_allowed_for("http://other.example.com/asset"),
"allow_insecure_auth still requires a host match"
);
}
#[test]
fn asset_name_valid_passes() {
assert!(
super::is_safe_asset_name("my-binary-v1.0.0-linux.tar.gz"),
"ordinary archive name must be accepted"
);
}
#[test]
fn asset_name_dot_dot_slash_is_rejected() {
assert!(
!super::is_safe_asset_name("../evil"),
"../evil must be rejected"
);
}
#[test]
fn asset_name_absolute_unix_path_is_rejected() {
assert!(
!super::is_safe_asset_name("/etc/hosts"),
"/etc/hosts must be rejected"
);
}
#[test]
fn asset_name_dot_dot_alone_is_rejected() {
assert!(
!super::is_safe_asset_name(".."),
".. alone must be rejected"
);
}
#[test]
fn asset_name_dot_alone_is_rejected() {
assert!(!super::is_safe_asset_name("."), ". alone must be rejected");
}
#[test]
fn asset_name_empty_is_rejected() {
assert!(
!super::is_safe_asset_name(""),
"empty name must be rejected"
);
}
#[test]
fn asset_name_with_slash_is_rejected() {
assert!(
!super::is_safe_asset_name("sub/path"),
"name containing / must be rejected"
);
}
#[test]
fn asset_name_with_backslash_is_rejected() {
assert!(
!super::is_safe_asset_name("sub\\path"),
"name containing \\ must be rejected"
);
}
#[test]
fn asset_name_with_windows_drive_prefix_is_rejected() {
assert!(
!super::is_safe_asset_name("C:evil"),
"a Windows drive-relative name must be rejected"
);
assert!(
!super::is_safe_asset_name("C:"),
"a bare drive designator must be rejected"
);
assert!(
!super::is_safe_asset_name("C:\\evil"),
"a drive-absolute name must be rejected"
);
}
#[test]
fn invalid_asset_name_error_display() {
let err = crate::errors::Error::InvalidAssetName {
name: "../evil".to_string(),
};
let shown = err.to_string();
assert!(
shown.starts_with("InvalidAssetNameError: "),
"must carry the expected prefix, got: {shown}"
);
assert!(
shown.contains("../evil"),
"must embed the offending name, got: {shown}"
);
}
#[test]
fn invalid_asset_name_error_http_helpers_are_none() {
let err = crate::errors::Error::InvalidAssetName {
name: "../evil".to_string(),
};
assert_eq!(err.http_status(), None);
assert_eq!(err.url(), None);
}
}