use regex::Regex;
use std::borrow::Cow;
use std::env::consts::{ARCH, OS};
use std::fs;
use crate::http_client::{self, header};
use crate::{confirm, errors::*, version, Download, Extract, Move, VersionStatus};
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct ReleaseAsset {
pub name: String,
pub download_url: String,
}
impl ReleaseAsset {
pub fn new(name: impl Into<String>, download_url: impl Into<String>) -> Self {
Self {
name: name.into(),
download_url: download_url.into(),
}
}
}
#[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),
}
}
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,
}
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct Release {
pub name: String,
pub version: String,
pub date: String,
pub body: Option<String>,
pub assets: Vec<ReleaseAsset>,
}
impl Release {
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> {
self.assets
.iter()
.find(|asset| {
asset.name.contains(target)
&& if let Some(i) = identifier {
asset.name.contains(i)
} else {
true
}
})
.or_else(|| {
self.assets.iter().find(|asset| {
(asset.name.contains(OS) && asset.name.contains(ARCH))
&& if let Some(i) = identifier {
asset.name.contains(i)
} else {
true
}
})
})
.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_else(|| Error::Config("`version` required".to_string()))?;
Ok(Release {
name: self.name.clone().unwrap_or_else(|| version.clone()),
version,
date: self.date.clone().unwrap_or_default(),
body: self.body.clone(),
assets: self.assets.clone(),
})
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Releases {
releases: Vec<Release>,
current_version: String,
}
impl Releases {
pub(crate) fn new(releases: Vec<Release>, current_version: String) -> Self {
Self {
releases,
current_version,
}
}
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) -> &str {
&self.current_version
}
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> {
for r in &self.releases {
if version::bump_is_greater(&self.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_latest_releases(&self, current_version: &str) -> 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_latest_releases<'a>(
&'a self,
current_version: &'a str,
) -> impl std::future::Future<Output = Result<Vec<Release>>> + Send + 'a;
fn get_release_version<'a>(
&'a self,
ver: &'a str,
) -> impl std::future::Future<Output = Result<Release>> + Send + 'a;
}
#[cfg(feature = "async")]
pub(crate) trait AsyncFetch {
fn get_latest_release_async(
&self,
) -> impl std::future::Future<Output = Result<Releases>> + Send + '_;
fn get_latest_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;
}
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;
fn progress_template(&self) -> &str;
fn progress_chars(&self) -> &str;
fn auth_token(&self) -> Option<&str>;
#[doc(hidden)]
fn request_timeout(&self) -> Option<std::time::Duration>;
#[doc(hidden)]
fn request_headers(&self) -> &http_client::HeaderMap;
#[doc(hidden)]
fn request_client(&self) -> &http_client::ClientOverride;
#[doc(hidden)]
fn progress_callback(&self) -> Option<std::sync::Arc<crate::DynProgressFn>>;
#[doc(hidden)]
fn verify_callback(&self) -> Option<std::sync::Arc<crate::DynVerifyFn>>;
#[doc(hidden)]
fn asset_matcher(&self) -> Option<std::sync::Arc<crate::DynAssetMatcher>> {
None
}
#[doc(hidden)]
#[cfg(feature = "checksums")]
fn verify_checksum(&self) -> Option<&crate::Checksum>;
#[cfg(feature = "signatures")]
fn verify_keys(&self) -> &[crate::VerifyingKey] {
&[]
}
fn api_headers(&self, auth_token: Option<&str>) -> Result<http_client::HeaderMap> {
let mut headers = header::HeaderMap::new();
if let Some(token) = auth_token {
let value = format!("token {}", token).parse().map_err(|_| {
Error::Config(
"the auth token contains characters that are not valid in an HTTP \
header value"
.to_string(),
)
})?;
headers.insert(header::AUTHORIZATION, value);
};
Ok(headers)
}
}
pub trait ReleaseUpdate: UpdateConfig {
fn get_latest_release(&self) -> Result<Releases>;
fn get_latest_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_latest_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| match version::bump_is_greater(&y.version, &x.version) {
Ok(is_greater) => {
if is_greater {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
}
}
Err(_) => std::cmp::Ordering::Greater,
},
);
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)? {
""
} else {
"*NOT* "
};
println(
show_output,
&format!("New release is {}compatible", qualifier),
);
Ok(Some(release))
}
fn resolve_and_confirm<U: UpdateConfig + ?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(|| format_err!(Error::Release, "No asset found for target: `{}`", target))?;
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: {:?}", 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 + ?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());
for (name, value) in u.request_headers() {
headers.insert(name.clone(), value.clone());
}
download.replace_headers(headers);
download.set_client_override(u.request_client().clone());
if let Some(timeout) = u.request_timeout() {
download.timeout(timeout);
}
if let Some(callback) = u.progress_callback() {
download.set_progress_callback_arc(callback);
}
download.show_download_progress(u.show_download_progress());
download.progress_style(u.progress_template(), u.progress_chars());
Ok(download)
}
fn finish_update<U: UpdateConfig + ?Sized>(
u: &U,
release: Release,
tmp_archive_dir: &tempfile::TempDir,
tmp_archive_path: &std::path::Path,
) -> Result<ReleaseStatus> {
let show_output = u.show_output();
#[cfg(feature = "checksums")]
if let Some(checksum) = u.verify_checksum() {
checksum.verify(tmp_archive_path)?;
}
#[cfg(feature = "signatures")]
verify_signature(tmp_archive_path, u.verify_keys())?;
print_flush(show_output, "Extracting archive... ")?;
let bin_path_str = Cow::Borrowed(u.bin_path_in_archive());
fn substitute<'a: 'b, 'b>(str: &'a str, var: &str, val: &str) -> Cow<'b, str> {
let format = format!(r"\{{\{{[[:space:]]*{}[[:space:]]*\}}\}}", var);
Regex::new(&format).unwrap().replace_all(str, val)
}
let bin_path_str = substitute(&bin_path_str, "version", &release.version);
let bin_path_str = substitute(&bin_path_str, "target", u.target());
let bin_path_str = substitute(&bin_path_str, "bin", u.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,
u.bin_install_path(),
u.verify_callback().as_deref(),
)?;
println(show_output, "Done");
Ok(ReleaseStatus::Updated(release))
}
#[cfg(feature = "async")]
pub(crate) async fn update_extended_async<U>(u: &U) -> Result<ReleaseStatus>
where
U: UpdateConfig + AsyncFetch,
{
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_latest_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?;
finish_update(u, release, &tmp_archive_dir, &tmp_archive_path)
}
fn install_binary(
new_exe: &std::path::Path,
bin_install_path: &std::path::Path,
verify: Option<&crate::DynVerifyFn>,
) -> Result<()> {
if let Some(verify) = verify {
if !verify(new_exe) {
bail!(
Error::Update,
"post-update verification rejected the new binary"
)
}
}
let current_exe = std::env::current_exe()?;
if bin_install_path == current_exe.as_path() {
self_replace::self_replace(new_exe)?;
} else {
Move::from_source(new_exe).to_dest(bin_install_path)?;
}
Ok(())
}
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::{choose_latest_release, install_binary, Releases};
use crate::errors::Result;
use crate::update::Release;
use crate::DynVerifyFn;
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_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.as_str()).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).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(), "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).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.as_str())
.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.clone()).collect();
let borrowed: Vec<String> = (&releases).into_iter().map(|r| r.version.clone()).collect();
assert_eq!(borrowed, expected, "&Releases iteration == all() order");
let owned: Vec<String> = releases.into_iter().map(|r| r.version).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.as_str()),
Some("1.2.3"),
"updated_release() borrows the installed release"
);
assert_eq!(
updated.into_updated_release().map(|r| r.version),
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 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 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};
fn accessor_via_release_update_bound<R: ReleaseUpdate + ?Sized>(r: &R) -> (String, String) {
(r.bin_name().to_string(), r.target().to_string())
}
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);
assert_eq!(bin, "app");
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), "app");
}
#[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_latest_releases(&self, _current_version: &str) -> 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::Release("no such tag".into()))
} 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")]
#[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::Release(_))),
"a missing tag must propagate as Error::Release, 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_latest_releases(&self, _c: &str) -> 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| false);
let res = install_binary(&new_exe, &dest, Some(&*reject));
assert!(res.is_err(), "verify=false must abort the install");
assert!(
!dest.exists(),
"nothing is installed when verification fails"
);
assert!(new_exe.exists(), "the extracted binary is left untouched");
}
#[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| true);
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) -> Box<dyn ReleaseUpdate> {
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-flate2"
))]
#[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
);
}
}