use std::sync::{Arc, Mutex};
use std::time::Duration;
use self_update::Error;
use self_update::backends::custom;
use self_update::http_client::{HeaderMap, HttpClient, HttpResponse};
use self_update::update::{Release, ReleaseAsset, ReleaseSource};
struct RecordingClient {
hits: Arc<Mutex<Vec<String>>>,
}
impl HttpClient for RecordingClient {
fn get(
&self,
url: &str,
_headers: &HeaderMap,
_timeout: Option<Duration>,
) -> self_update::Result<Box<dyn HttpResponse>> {
self.hits.lock().unwrap().push(url.to_string());
Err(Error::transport("recording client: no body served"))
}
}
struct CannedSource {
releases: Vec<Release>,
}
impl CannedSource {
fn one_newer() -> Self {
let asset = ReleaseAsset::new("app-9.9.9.tar.gz", "http://127.0.0.1:9/app-9.9.9.tar.gz");
let release = Release::builder()
.version("9.9.9")
.asset(asset)
.build()
.unwrap();
Self {
releases: vec![release],
}
}
}
impl ReleaseSource for CannedSource {
fn get_releases(&self) -> self_update::Result<Vec<Release>> {
Ok(self.releases.clone())
}
}
#[cfg(unix)]
fn is_write_protected(dir: &std::path::Path) -> bool {
match tempfile::Builder::new()
.prefix(".permcheck")
.tempfile_in(dir)
{
Ok(_) => false,
Err(e) => e.kind() == std::io::ErrorKind::PermissionDenied,
}
}
#[cfg(unix)]
fn make_readonly_dir(parent: &std::path::Path) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let ro = parent.join("ro");
std::fs::create_dir(&ro).unwrap();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap();
ro
}
#[cfg(unix)]
fn restore_writable(dir: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755));
}
#[cfg(unix)]
#[test]
fn preflight_on_unwritable_dir_refuses_before_any_download() {
let tmp = tempfile::tempdir().unwrap();
let ro_dir = make_readonly_dir(tmp.path());
if !is_write_protected(&ro_dir) {
restore_writable(&ro_dir);
eprintln!("skipping: install dir is writable (running as root?)");
return;
}
let install_path = ro_dir.join("app");
let hits = Arc::new(Mutex::new(Vec::new()));
let updater = custom::Update::configure()
.source(CannedSource::one_newer())
.bin_name("app")
.current_version("0.0.1")
.bin_install_path(&install_path)
.check_install_path_writable(true)
.asset_matcher(|assets| assets.first().cloned())
.no_confirm(true)
.show_output(false)
.http_client(Arc::new(RecordingClient { hits: hits.clone() }))
.build()
.unwrap();
let result = updater.update();
restore_writable(&ro_dir);
match result {
Err(Error::InstallPathNotWritable { path, .. }) => {
assert_eq!(
path, install_path,
"the preflight error must name the configured bin_install_path"
);
}
other => panic!("expected Err(InstallPathNotWritable), got {other:?}"),
}
assert!(
hits.lock().unwrap().is_empty(),
"a refused preflight must download NOTHING, but the client saw requests: {:?}",
hits.lock().unwrap()
);
}
#[cfg(unix)]
#[test]
fn preflight_off_unwritable_dir_proceeds_to_download() {
let tmp = tempfile::tempdir().unwrap();
let ro_dir = make_readonly_dir(tmp.path());
let install_path = ro_dir.join("app");
let hits = Arc::new(Mutex::new(Vec::new()));
let updater = custom::Update::configure()
.source(CannedSource::one_newer())
.bin_name("app")
.current_version("0.0.1")
.bin_install_path(&install_path)
.asset_matcher(|assets| assets.first().cloned())
.no_confirm(true)
.show_output(false)
.http_client(Arc::new(RecordingClient { hits: hits.clone() }))
.build()
.unwrap();
let result = updater.update();
restore_writable(&ro_dir);
assert!(
!matches!(result, Err(Error::InstallPathNotWritable { .. })),
"with the preflight off, writability must not be raised before a download is attempted, \
got {result:?}"
);
assert!(
!hits.lock().unwrap().is_empty(),
"with the preflight off, the update must proceed to the download and hit the client"
);
}
#[test]
fn preflight_on_indeterminate_missing_parent_proceeds_to_download() {
let tmp = tempfile::tempdir().unwrap();
let install_path = tmp.path().join("no-such-dir").join("app");
let hits = Arc::new(Mutex::new(Vec::new()));
let updater = custom::Update::configure()
.source(CannedSource::one_newer())
.bin_name("app")
.current_version("0.0.1")
.bin_install_path(&install_path)
.check_install_path_writable(true)
.asset_matcher(|assets| assets.first().cloned())
.no_confirm(true)
.show_output(false)
.http_client(Arc::new(RecordingClient { hits: hits.clone() }))
.build()
.unwrap();
let result = updater.update();
assert!(
!matches!(result, Err(Error::InstallPathNotWritable { .. })),
"an indeterminate (missing parent) preflight must proceed, not refuse, got {result:?}"
);
assert!(
!hits.lock().unwrap().is_empty(),
"an indeterminate preflight must proceed to the download and hit the client"
);
}
#[cfg(feature = "async")]
mod async_parity {
use super::*;
use self_update::futures_util::future::BoxFuture;
use self_update::http_client::{AsyncHttpClient, AsyncHttpResponse};
use self_update::update::AsyncReleaseSource;
struct RecordingAsyncClient {
hits: Arc<Mutex<Vec<String>>>,
}
impl AsyncHttpClient for RecordingAsyncClient {
fn get<'a>(
&'a self,
url: &'a str,
_headers: &'a HeaderMap,
_timeout: Option<Duration>,
) -> BoxFuture<'a, self_update::Result<Box<dyn AsyncHttpResponse>>> {
let hits = self.hits.clone();
let url = url.to_string();
Box::pin(async move {
hits.lock().unwrap().push(url);
Err(Error::transport("recording async client: no body served"))
})
}
}
struct CannedAsyncSource {
releases: Vec<Release>,
}
impl CannedAsyncSource {
fn one_newer() -> Self {
Self {
releases: CannedSource::one_newer().releases,
}
}
}
impl AsyncReleaseSource for CannedAsyncSource {
fn get_releases(
&self,
) -> impl std::future::Future<Output = self_update::Result<Vec<Release>>> + Send + '_
{
let releases = self.releases.clone();
async move { Ok(releases) }
}
}
#[cfg(unix)]
#[tokio::test]
async fn preflight_on_unwritable_dir_refuses_before_any_download_async() {
let tmp = tempfile::tempdir().unwrap();
let ro_dir = make_readonly_dir(tmp.path());
if !is_write_protected(&ro_dir) {
restore_writable(&ro_dir);
eprintln!("skipping: install dir is writable (running as root?)");
return;
}
let install_path = ro_dir.join("app");
let hits = Arc::new(Mutex::new(Vec::new()));
let updater = custom::AsyncUpdate::<CannedAsyncSource>::configure()
.source(CannedAsyncSource::one_newer())
.bin_name("app")
.current_version("0.0.1")
.bin_install_path(&install_path)
.check_install_path_writable(true)
.asset_matcher(|assets| assets.first().cloned())
.no_confirm(true)
.show_output(false)
.http_client_async(Arc::new(RecordingAsyncClient { hits: hits.clone() }))
.build_async()
.unwrap();
let result = updater.update_async().await;
restore_writable(&ro_dir);
match result {
Err(Error::InstallPathNotWritable { path, .. }) => {
assert_eq!(
path, install_path,
"the async preflight error must name the configured bin_install_path"
);
}
other => panic!("expected Err(InstallPathNotWritable), got {other:?}"),
}
assert!(
hits.lock().unwrap().is_empty(),
"a refused async preflight must download NOTHING, but the client saw: {:?}",
hits.lock().unwrap()
);
}
#[tokio::test]
async fn preflight_on_indeterminate_missing_parent_proceeds_to_download_async() {
let tmp = tempfile::tempdir().unwrap();
let install_path = tmp.path().join("no-such-dir").join("app");
let hits = Arc::new(Mutex::new(Vec::new()));
let updater = custom::AsyncUpdate::<CannedAsyncSource>::configure()
.source(CannedAsyncSource::one_newer())
.bin_name("app")
.current_version("0.0.1")
.bin_install_path(&install_path)
.check_install_path_writable(true)
.asset_matcher(|assets| assets.first().cloned())
.no_confirm(true)
.show_output(false)
.http_client_async(Arc::new(RecordingAsyncClient { hits: hits.clone() }))
.build_async()
.unwrap();
let result = updater.update_async().await;
assert!(
!matches!(result, Err(Error::InstallPathNotWritable { .. })),
"an indeterminate async preflight must proceed, not refuse, got {result:?}"
);
assert!(
!hits.lock().unwrap().is_empty(),
"an indeterminate async preflight must proceed to the download and hit the client"
);
}
}