use crate::errors::{UpdateError, UpdateVersionParseError};
use camino::{Utf8Path, Utf8PathBuf};
use mukti_metadata::{MuktiProject, MuktiReleasesJson, ReleaseLocation, ReleaseVersionData};
use self_update::{ArchiveKind, Compression, Download, Extract};
use semver::{Version, VersionReq};
use std::{
fs,
io::{self, BufWriter},
str::FromStr,
};
use target_spec::Platform;
#[derive(Clone, Debug)]
pub struct MuktiBackend {
pub url: String,
pub package_name: String,
}
impl MuktiBackend {
pub fn fetch_releases(&self, current_version: Version) -> Result<NextestReleases, UpdateError> {
log::info!(target: "nextest-runner::update", "checking for self-updates");
let mut releases_buf: Vec<u8> = Vec::new();
Download::from_url(&self.url)
.download_to(&mut releases_buf)
.map_err(UpdateError::SelfUpdate)?;
let mut releases_json: MuktiReleasesJson =
serde_json::from_slice(&releases_buf).map_err(UpdateError::ReleaseMetadataDe)?;
let project = match releases_json.projects.remove(&self.package_name) {
Some(project) => project,
None => {
return Err(UpdateError::MuktiProjectNotFound {
not_found: self.package_name.clone(),
known: releases_json.projects.keys().cloned().collect(),
})
}
};
NextestReleases::new(&self.package_name, project, current_version)
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct NextestReleases {
pub package_name: String,
pub project: MuktiProject,
pub current_version: Version,
pub bin_install_path: Utf8PathBuf,
}
impl NextestReleases {
fn new(
package_name: &str,
project: MuktiProject,
current_version: Version,
) -> Result<Self, UpdateError> {
let bin_install_path = std::env::current_exe()
.and_then(|exe| {
Utf8PathBuf::try_from(exe)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
})
.map_err(UpdateError::CurrentExe)?;
Ok(Self {
package_name: package_name.to_owned(),
project,
current_version,
bin_install_path,
})
}
pub fn check<'a>(
&'a self,
version: &UpdateVersion,
force: bool,
bin_path_in_archive: &'a Utf8Path,
) -> Result<CheckStatus<'a>, UpdateError> {
let (version, version_data) = self.get_version_data(version)?;
log::debug!(
target: "nextest-runner::update",
"current version is {}, update version is {version}",
self.current_version,
);
if &self.current_version == version && !force {
return Ok(CheckStatus::AlreadyOnRequested(version.clone()));
}
if &self.current_version > version && !force {
return Ok(CheckStatus::DowngradeNotAllowed {
current_version: self.current_version.clone(),
requested: version.clone(),
});
}
let triple = self.target_triple();
log::debug!(target: "nextest-runner::update", "target triple: {triple}");
let location = version_data
.locations
.iter()
.find(|&data| data.format == TAR_GZ_SUFFIX && data.target == triple)
.ok_or_else(|| {
let known_triples = version_data
.locations
.iter()
.filter_map(|data| (data.format == TAR_GZ_SUFFIX).then(|| data.target.clone()))
.collect();
UpdateError::NoTargetData {
version: version.clone(),
triple,
known_triples,
}
})?;
Ok(CheckStatus::Success(MuktiUpdateContext {
context: self,
version: version.clone(),
location,
bin_path_in_archive,
}))
}
fn get_version_data(
&self,
version: &UpdateVersion,
) -> Result<(&Version, &ReleaseVersionData), UpdateError> {
match version {
UpdateVersion::Exact(version) => {
self.project.get_version_data(version).ok_or_else(|| {
let known = self
.project
.all_versions()
.map(|(v, release_data)| (v.clone(), release_data.status))
.collect();
UpdateError::VersionNotFound {
version: version.clone(),
known,
}
})
}
UpdateVersion::Req(req) => self
.project
.get_latest_matching(req)
.ok_or_else(|| UpdateError::NoMatchForVersionReq { req: req.clone() }),
}
}
fn target_triple(&self) -> String {
let current = Platform::current().expect("current platform could not be detected");
let triple_str = current.triple_str();
if triple_str.ends_with("-apple-darwin") {
"universal-apple-darwin".to_owned()
} else {
triple_str.to_owned()
}
}
}
#[derive(Clone, Debug)]
pub enum CheckStatus<'a> {
AlreadyOnRequested(Version),
DowngradeNotAllowed {
current_version: Version,
requested: Version,
},
Success(MuktiUpdateContext<'a>),
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct MuktiUpdateContext<'a> {
pub context: &'a NextestReleases,
pub version: Version,
pub location: &'a ReleaseLocation,
pub bin_path_in_archive: &'a Utf8Path,
}
impl<'a> MuktiUpdateContext<'a> {
pub fn do_update(&self) -> Result<(), UpdateError> {
let tmp_dir_parent = self.context.bin_install_path.parent().ok_or_else(|| {
UpdateError::CurrentExe(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"parent directory of current exe `{}` could not be determined",
self.context.bin_install_path
),
))
})?;
let tmp_backup_dir_prefix = format!("__{}_backup", self.context.package_name);
let tmp_backup_filename = tmp_backup_dir_prefix.clone();
if cfg!(windows) {
let _ = cleanup_backup_temp_directories(
tmp_dir_parent,
&tmp_backup_dir_prefix,
&tmp_backup_filename,
);
}
let tmp_archive_dir_prefix = format!("{}_download", self.context.package_name);
let tmp_archive_dir = tempfile::Builder::new()
.prefix(&tmp_archive_dir_prefix)
.tempdir_in(&tmp_dir_parent)
.map_err(|error| UpdateError::TempDirCreate {
location: tmp_dir_parent.to_owned(),
error,
})?;
let tmp_dir_path: &Utf8Path =
tmp_archive_dir
.path()
.try_into()
.map_err(|error| UpdateError::TempDirCreate {
location: tmp_dir_parent.to_owned(),
error: io::Error::new(io::ErrorKind::InvalidData, error),
})?;
let tmp_archive_path =
tmp_dir_path.join(format!("{}.{TAR_GZ_SUFFIX}", self.context.package_name));
let tmp_archive = fs::File::create(&tmp_archive_path).map_err(|error| {
UpdateError::TempArchiveCreate {
archive_path: tmp_archive_path.clone(),
error,
}
})?;
let mut tmp_archive_buf = BufWriter::new(tmp_archive);
let mut download = Download::from_url(&self.location.url);
let mut headers = http::header::HeaderMap::new();
headers.insert(
http::header::ACCEPT,
"application/octet-stream".parse().unwrap(),
);
download.set_headers(headers);
download.show_progress(true);
download
.download_to(&mut tmp_archive_buf)
.map_err(UpdateError::SelfUpdate)?;
log::debug!(target: "nextest-runner::update", "downloaded to {tmp_archive_path}");
let tmp_archive =
tmp_archive_buf
.into_inner()
.map_err(|error| UpdateError::TempArchiveWrite {
archive_path: tmp_archive_path.clone(),
error: error.into_error(),
})?;
tmp_archive
.sync_all()
.map_err(|error| UpdateError::TempArchiveWrite {
archive_path: tmp_archive_path.clone(),
error,
})?;
std::mem::drop(tmp_archive);
Extract::from_source(tmp_archive_path.as_std_path())
.archive(ArchiveKind::Tar(Some(Compression::Gz)))
.extract_file(tmp_archive_dir.path(), self.bin_path_in_archive)
.map_err(UpdateError::SelfUpdate)?;
let new_exe = tmp_dir_path.join(self.bin_path_in_archive);
log::debug!(target: "nextest-runner::update", "extracted to {new_exe}, replacing existing binary");
let tmp_backup_dir = tempfile::Builder::new()
.prefix(&tmp_backup_dir_prefix)
.tempdir_in(&tmp_dir_parent)
.map_err(|error| UpdateError::TempDirCreate {
location: tmp_dir_parent.to_owned(),
error,
})?;
let tmp_backup_dir_path: &Utf8Path =
tmp_backup_dir
.path()
.try_into()
.map_err(|error| UpdateError::TempDirCreate {
location: tmp_dir_parent.to_owned(),
error: io::Error::new(io::ErrorKind::InvalidData, error),
})?;
let tmp_file_path = tmp_backup_dir_path.join(&tmp_backup_filename);
Move::from_source(&new_exe)
.replace_using_temp(&tmp_file_path)
.to_dest(&self.context.bin_install_path)?;
Ok(())
}
}
#[derive(Debug)]
struct Move<'a> {
source: &'a Utf8Path,
temp: Option<&'a Utf8Path>,
}
impl<'a> Move<'a> {
pub fn from_source(source: &'a Utf8Path) -> Move<'a> {
Self { source, temp: None }
}
pub fn replace_using_temp(&mut self, temp: &'a Utf8Path) -> &mut Self {
self.temp = Some(temp);
self
}
pub fn to_dest(&self, dest: &Utf8Path) -> Result<(), UpdateError> {
match self.temp {
None => Self::fs_rename(self.source, dest),
Some(temp) => {
if dest.exists() {
Self::fs_rename(dest, temp)?;
if let Err(e) = Self::fs_rename(self.source, dest) {
Self::fs_rename(temp, dest)?;
return Err(e);
}
} else {
Self::fs_rename(self.source, dest)?;
}
Ok(())
}
}
}
fn fs_rename(source: &Utf8Path, dest: &Utf8Path) -> Result<(), UpdateError> {
fs::rename(source, dest).map_err(|error| UpdateError::FsRename {
source: source.to_owned(),
dest: dest.to_owned(),
error,
})
}
}
fn cleanup_backup_temp_directories(
tmp_dir_parent: &Utf8Path,
tmp_dir_prefix: &str,
expected_tmp_filename: &str,
) -> io::Result<()> {
for entry in fs::read_dir(tmp_dir_parent)? {
let entry = entry?;
let tmp_dir_name = if let Ok(tmp_dir_name) = entry.file_name().into_string() {
tmp_dir_name
} else {
continue;
};
let is_expected_tmp_file = |tmp_file_entry: std::io::Result<fs::DirEntry>| {
tmp_file_entry
.ok()
.filter(|e| e.file_name() == expected_tmp_filename)
.is_some()
};
if tmp_dir_name.starts_with(tmp_dir_prefix)
&& fs::read_dir(entry.path())?.all(is_expected_tmp_file)
{
fs::remove_dir_all(entry.path())?;
}
}
Ok(())
}
const TAR_GZ_SUFFIX: &str = "tar.gz";
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum UpdateVersion {
Exact(Version),
Req(VersionReq),
}
impl FromStr for UpdateVersion {
type Err = UpdateVersionParseError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
if input == "latest" {
return Ok(UpdateVersion::Req(VersionReq::STAR));
}
let first = input
.chars()
.next()
.ok_or(UpdateVersionParseError::EmptyString)?;
let is_req = "<>=^~".contains(first) || input.contains('*');
if is_req {
match input.parse::<VersionReq>() {
Ok(v) => Ok(Self::Req(v)),
Err(error) => Err(UpdateVersionParseError::InvalidVersionReq {
input: input.to_owned(),
error,
}),
}
} else {
match input.parse::<Version>() {
Ok(v) => Ok(Self::Exact(v)),
Err(error) => Err(UpdateVersionParseError::InvalidVersion {
input: input.to_owned(),
error,
}),
}
}
}
}