use crate::{
archive::{ArchiveEntry, SevenZipEntriesIterator, TarEntriesIterator, ZipEntriesIterator},
extension::Extension,
ubi::Download,
};
use anyhow::{anyhow, Context, Result};
use binstall_tar::Archive as TarArchive;
use bzip2::read::BzDecoder;
use flate2::read::GzDecoder;
use log::{debug, info};
use std::{
collections::HashMap,
ffi::OsString,
fmt::Debug,
fs::{self, create_dir_all, File},
io::{Read, Write},
path::{Path, PathBuf},
};
use strum::IntoEnumIterator;
use tempfile::{tempdir, TempDir};
use walkdir::WalkDir;
use xz2::read::XzDecoder;
use zip::ZipArchive;
use zstd::stream::read::Decoder as ZstdDecoder;
#[cfg(target_family = "unix")]
use std::fs::{set_permissions, Permissions};
#[cfg(target_family = "unix")]
use std::os::unix::fs::PermissionsExt;
pub(crate) trait Installer: Debug {
fn install(&self, download: &Download) -> Result<()>;
}
#[derive(Debug)]
pub(crate) struct ExeInstaller {
install_path: PathBuf,
install_path_is_from_rename_exe_to: bool,
exe_file_stem: String,
is_windows: bool,
extensions: Vec<&'static str>,
}
#[derive(Debug)]
pub(crate) struct ArchiveInstaller {
project_name: String,
install_root: PathBuf,
}
impl ExeInstaller {
pub(crate) fn new(
install_path: PathBuf,
install_path_is_from_rename_exe_to: bool,
exe: String,
is_windows: bool,
) -> Self {
let extensions = if is_windows {
Extension::iter()
.filter(super::extension::Extension::is_windows_only)
.map(|e| e.extension())
.collect()
} else {
vec![]
};
ExeInstaller {
install_path,
install_path_is_from_rename_exe_to,
exe_file_stem: exe,
is_windows,
extensions,
}
}
fn extract_executable(&self, downloaded_file: &Path) -> Result<Option<PathBuf>> {
match Extension::from_path(downloaded_file)? {
Some(
Extension::Tar
| Extension::TarBz
| Extension::TarBz2
| Extension::TarGz
| Extension::TarXz
| Extension::TarZst
| Extension::Tbz
| Extension::Tgz
| Extension::Txz
| Extension::Tzst,
) => Ok(Some(self.extract_executable_from_tarball(downloaded_file)?)),
Some(Extension::Bz | Extension::Bz2) => {
self.unbzip(downloaded_file)?;
Ok(None)
}
Some(Extension::Gz) => {
self.ungzip(downloaded_file)?;
Ok(None)
}
Some(Extension::Xz) => {
self.unxz(downloaded_file)?;
Ok(None)
}
Some(Extension::Zst) => {
self.unzstd(downloaded_file)?;
Ok(None)
}
Some(Extension::SevenZip) => {
Ok(Some(self.extract_executable_from_7z(downloaded_file)?))
}
Some(Extension::Zip) => Ok(Some(self.extract_executable_from_zip(downloaded_file)?)),
Some(
Extension::AppImage
| Extension::Bat
| Extension::Exe
| Extension::Jar
| Extension::Phar
| Extension::Py
| Extension::Pyz
| Extension::Sh,
)
| None => Ok(Some(self.copy_executable(downloaded_file)?)),
}
}
fn extract_executable_from_tarball(&self, downloaded_file: &Path) -> Result<PathBuf> {
debug!(
"extracting executable from tarball at {}",
downloaded_file.display(),
);
let mut arch = tar_reader_for(downloaded_file)?;
let entries = arch.entries().with_context(|| {
format!(
"failed to get entries from tarball at {}",
downloaded_file.display()
)
})?;
if let Some(idx) =
self.best_match_from_archive(TarEntriesIterator::new(entries), "tarball")?
{
let mut arch2 = tar_reader_for(downloaded_file)?;
for (i, entry) in arch2
.entries()
.with_context(|| {
format!(
"failed to get entries from tarball at {}",
downloaded_file.display()
)
})?
.enumerate()
{
let mut entry = entry.with_context(|| {
format!(
"failed to read tarball entry at index {i} from {}",
downloaded_file.display()
)
})?;
if i != idx {
continue;
}
let entry_path = entry
.path()
.with_context(|| {
format!(
"failed to get path from tarball entry at index {i} in {}",
downloaded_file.display()
)
})?
.into_owned();
let install_path = self.maybe_munged_install_path(&entry_path)?;
debug!(
"extracting tarball entry named {} to {}",
entry_path.display(),
install_path.display(),
);
self.create_install_dir().with_context(|| {
format!(
"failed to create installation directory for {}",
install_path.display()
)
})?;
entry.unpack(&install_path).with_context(|| {
format!(
"failed to extract executable named {} into {}",
entry_path.display(),
install_path.display()
)
})?;
return Ok(install_path);
}
}
self.could_not_find_archive_matches_error()
}
fn extract_executable_from_7z(&self, downloaded_file: &Path) -> Result<PathBuf> {
debug!(
"extracting executable from 7z file at {}",
downloaded_file.display()
);
let best_match = self.best_match_from_archive(
SevenZipEntriesIterator::new(
sevenz_rust2::ArchiveReader::new(
open_file(downloaded_file)?,
sevenz_rust2::Password::empty(),
)
.with_context(|| {
format!(
"failed to create 7z archive reader for {}",
downloaded_file.display()
)
})?,
),
"sevenzip",
)?;
if let Some(idx) = best_match {
let mut archive = sevenz_rust2::ArchiveReader::new(
open_file(downloaded_file)?,
sevenz_rust2::Password::empty(),
)
.with_context(|| {
format!(
"failed to create 7z archive reader for {}",
downloaded_file.display()
)
})?;
let entry = archive.archive().files[idx].clone();
let path = entry.path().with_context(|| {
format!(
"failed to get path from 7z entry at index {idx} in {}",
downloaded_file.display()
)
})?;
let install_path = self.maybe_munged_install_path(&path)?;
debug!(
"extracting 7z entry named {} to {}",
path.display(),
install_path.display(),
);
let buffer = archive.read_file(entry.name()).with_context(|| {
format!(
"failed to read 7z entry named {} from {}",
entry.name(),
downloaded_file.display()
)
})?;
self.create_install_dir().with_context(|| {
format!(
"failed to create installation directory for {}",
install_path.display()
)
})?;
File::create(&install_path)
.with_context(|| format!("failed to create file at {}", install_path.display()))?
.write_all(&buffer)
.with_context(|| {
format!(
"failed to write extracted content to {}",
install_path.display()
)
})?;
return Ok(install_path);
}
self.could_not_find_archive_matches_error()
}
fn extract_executable_from_zip(&self, downloaded_file: &Path) -> Result<PathBuf> {
debug!(
"extracting executable from zip file at {}",
downloaded_file.display()
);
let mut zip = ZipArchive::new(open_file(downloaded_file)?).with_context(|| {
format!(
"failed to create zip archive reader for {}",
downloaded_file.display()
)
})?;
if let Some(idx) = self.best_match_from_archive(ZipEntriesIterator::new(&mut zip), "zip")? {
let mut zf = zip.by_index(idx).with_context(|| {
format!(
"failed to get zip entry at index {idx} from {}",
downloaded_file.display()
)
})?;
let zf_path = Path::new(zf.name());
let install_path = self.maybe_munged_install_path(zf_path)?;
debug!(
"extracting zip file entry named {} to {}",
zf.name(),
install_path.display(),
);
let mut buffer: Vec<u8> =
Vec::with_capacity(usize::try_from(zf.size()).with_context(|| {
format!(
"failed to convert zip file size to usize for entry {} in {}",
zf.name(),
downloaded_file.display()
)
})?);
zf.read_to_end(&mut buffer).with_context(|| {
format!(
"failed to read zip entry {} from {}",
zf.name(),
downloaded_file.display()
)
})?;
self.create_install_dir().with_context(|| {
format!(
"failed to create installation directory for {}",
install_path.display()
)
})?;
File::create(&install_path)
.with_context(|| format!("failed to create file at {}", install_path.display()))?
.write_all(&buffer)
.with_context(|| {
format!(
"failed to write extracted content to {}",
install_path.display()
)
})?;
return Ok(install_path);
}
self.could_not_find_archive_matches_error()
}
fn best_match_from_archive<'a>(
&self,
archive: impl Iterator<Item = Result<Box<dyn ArchiveEntry + 'a>>>,
archive_type: &'static str,
) -> Result<Option<usize>> {
let mut possible_matches: Vec<usize> = vec![];
for (i, entry) in archive.enumerate() {
let entry = entry
.with_context(|| format!("failed to read {archive_type} entry at index {i}"))?;
if !entry.is_file() {
continue;
}
let path = entry.path().with_context(|| {
format!("failed to get path from {archive_type} entry at index {i}")
})?;
debug!("found {archive_type} entry with path `{}`", path.display());
if let Some(file_name) = path.file_name() {
if let Some(file_name) = file_name.to_str() {
if self.archive_member_is_exact_match(file_name) {
debug!("found {archive_type} file entry with exact match: `{file_name}`");
return Ok(Some(i));
} else if self.archive_member_is_partial_match(file_name) {
if self.is_windows
|| matches!(
entry.is_executable().with_context(|| {
format!(
"failed to check if {archive_type} entry at index {i} is executable"
)
})?,
None | Some(true)
)
{
debug!(
"found {archive_type} file entry with partial match: `{file_name}`"
);
possible_matches.push(i);
}
}
}
}
}
Ok(possible_matches.into_iter().next())
}
fn archive_member_is_exact_match(&self, file_name: &str) -> bool {
if self.extensions.is_empty() {
return file_name == self.exe_file_stem;
}
self.extensions
.iter()
.map(|&ext| format!("{}{}", self.exe_file_stem.to_lowercase(), ext))
.any(|n| n == file_name)
}
fn archive_member_is_partial_match(&self, file_name: &str) -> bool {
if !file_name.starts_with(&self.exe_file_stem) {
return false;
}
if self.extensions.is_empty() {
return true;
}
self.extensions
.iter()
.any(|&ext| file_name.to_lowercase().ends_with(ext))
}
fn could_not_find_archive_matches_error(&self) -> Result<PathBuf> {
let expect_names = if self.extensions.is_empty() {
format!("{}*", self.exe_file_stem)
} else {
self.extensions
.iter()
.map(|ext| format!("{}*{}", self.exe_file_stem, ext))
.collect::<Vec<_>>()
.join(" ")
};
debug!("could not find any entries matching [{expect_names}]");
Err(anyhow!(
"could not find any files matching [{expect_names}] in the downloaded archive file",
))
}
fn unbzip(&self, downloaded_file: &Path) -> Result<()> {
debug!("uncompressing executable from bzip file");
let reader = BzDecoder::new(open_file(downloaded_file)?);
self.write_to_install_path(reader)
}
fn ungzip(&self, downloaded_file: &Path) -> Result<()> {
debug!("uncompressing executable from gzip file");
let reader = GzDecoder::new(open_file(downloaded_file)?);
self.write_to_install_path(reader)
}
fn unxz(&self, downloaded_file: &Path) -> Result<()> {
debug!("uncompressing executable from xz file");
let reader = XzDecoder::new(open_file(downloaded_file)?);
self.write_to_install_path(reader)
}
fn unzstd(&self, downloaded_file: &Path) -> Result<()> {
debug!("uncompressing executable from zstd file");
let reader = ZstdDecoder::new(open_file(downloaded_file)?)?;
self.write_to_install_path(reader)
}
fn write_to_install_path(&self, mut reader: impl Read) -> Result<()> {
self.create_install_dir().with_context(|| {
format!(
"failed to create installation directory for {}",
self.install_path.display()
)
})?;
let mut writer = File::create(&self.install_path)
.with_context(|| format!("Cannot write to {}", self.install_path.display()))?;
std::io::copy(&mut reader, &mut writer).with_context(|| {
format!("failed to copy content to {}", self.install_path.display())
})?;
Ok(())
}
fn copy_executable(&self, exe_file: &Path) -> Result<PathBuf> {
debug!("copying executable to final location");
self.create_install_dir().with_context(|| {
format!(
"failed to create installation directory for {}",
self.install_path.display()
)
})?;
let install_path = self.maybe_munged_install_path(exe_file)?;
std::fs::copy(exe_file, &install_path).with_context(|| {
format!(
"error copying file from {} to {}",
exe_file.display(),
install_path.display()
)
})?;
Ok(install_path)
}
fn create_install_dir(&self) -> Result<()> {
let Some(path) = self.install_path.parent() else {
return Err(anyhow!(
"install path at {} has no parent",
self.install_path.display()
));
};
debug!("creating directory at {}", path.display());
create_dir_all(path)
.with_context(|| format!("could not create a directory at {}", path.display()))
}
fn maybe_munged_install_path<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> {
let mut install_path = self.install_path.clone();
if self.install_path_is_from_rename_exe_to {
debug!("install path was explicitly set, not munging the install path");
return Ok(install_path);
}
if let Some(ext) = Extension::from_path(path.as_ref())? {
if ext.should_preserve_extension_on_install() {
debug!("preserving the {} extension on install", ext.extension());
install_path.set_extension(ext.extension_without_dot());
}
}
Ok(install_path)
}
#[cfg(target_family = "windows")]
fn chmod_executable(_exe: &Path) -> Result<()> {
Ok(())
}
#[cfg(target_family = "unix")]
fn chmod_executable(exe: &Path) -> Result<()> {
match set_permissions(exe, Permissions::from_mode(0o755)) {
Ok(()) => Ok(()),
Err(e) => Err(anyhow::Error::new(e)),
}
}
}
impl Installer for ExeInstaller {
fn install(&self, download: &Download) -> Result<()> {
let exe = self.extract_executable(&download.archive_path)?;
let real_exe = exe.as_deref().unwrap_or(&self.install_path);
Self::chmod_executable(real_exe).with_context(|| {
format!(
"failed to set executable permissions on {}",
real_exe.display()
)
})?;
info!("Installed executable into {}", real_exe.display());
Ok(())
}
}
impl ArchiveInstaller {
pub(crate) fn new(project_name: String, install_path: PathBuf) -> Self {
ArchiveInstaller {
project_name,
install_root: install_path,
}
}
fn extract_entire_archive(&self, downloaded_file: &Path) -> Result<()> {
let td = tempdir().with_context(|| {
format!(
"failed to create temporary directory for extracting {}",
downloaded_file.display()
)
})?;
match Extension::from_path(downloaded_file)? {
Some(
Extension::Tar
| Extension::TarBz
| Extension::TarBz2
| Extension::TarGz
| Extension::TarXz
| Extension::TarZst
| Extension::Tbz
| Extension::Tgz
| Extension::Txz
| Extension::Tzst,
) => Self::extract_entire_tarball(downloaded_file, td.path()).with_context(|| {
format!("failed to extract tarball at {}", downloaded_file.display())
})?,
Some(Extension::SevenZip) => {
Self::extract_entire_7z(downloaded_file, td.path()).with_context(|| {
format!(
"failed to extract 7z archive at {}",
downloaded_file.display()
)
})?;
}
Some(Extension::Zip) => Self::extract_entire_zip(downloaded_file, td.path())
.with_context(|| {
format!(
"failed to extract zip archive at {}",
downloaded_file.display()
)
})?,
_ => {
return Err(anyhow!(
concat!(
"the downloaded release asset, {}, does not appear to be an",
" archive file so we cannot extract all of its contents",
),
downloaded_file.display(),
))
}
}
self.copy_extracted_contents(&td)?;
Ok(())
}
fn extract_entire_tarball(downloaded_file: &Path, into: &Path) -> Result<()> {
debug!(
"extracting entire tarball at {} to {}",
downloaded_file.display(),
into.display()
);
let mut arch = tar_reader_for(downloaded_file)?;
arch.unpack(into)
.with_context(|| format!("failed to unpack tarball to {}", into.display()))?;
Ok(())
}
fn extract_entire_7z(downloaded_file: &Path, into: &Path) -> Result<()> {
debug!(
"extracting entire 7z file at {} to {}",
downloaded_file.display(),
into.display()
);
sevenz_rust2::decompress_file(downloaded_file, into)
.with_context(|| format!("failed to decompress 7z file to {}", into.display()))?;
Ok(())
}
fn extract_entire_zip(downloaded_file: &Path, into: &Path) -> Result<()> {
debug!(
"extracting entire zip file at {} to {}",
downloaded_file.display(),
into.display(),
);
let mut zip = ZipArchive::new(open_file(downloaded_file)?).with_context(|| {
format!(
"failed to create zip archive reader for {}",
downloaded_file.display()
)
})?;
zip.extract(into).with_context(|| {
format!(
"failed to extract zip archive from {} to {}",
downloaded_file.display(),
into.display()
)
})?;
Ok(())
}
fn copy_extracted_contents(&self, td: &TempDir) -> Result<()> {
let copy_from = match self.extracted_contents_top_level_dir(td.path())? {
Some(dir) => dir,
None => td.path().to_path_buf(),
};
debug!(
"copying extracted contents from {} to {}",
copy_from.display(),
self.install_root.display(),
);
for entry in WalkDir::new(©_from).into_iter().filter_map(Result::ok) {
let full_path = entry.path();
let target_path =
self.install_root
.join(full_path.strip_prefix(©_from).with_context(|| {
format!(
"failed to strip prefix {} from path {}",
copy_from.display(),
full_path.display()
)
})?);
if full_path.is_dir() {
debug!("creating directory {}", target_path.display(),);
create_dir_all(&target_path).with_context(|| {
format!("failed to create directory at {}", target_path.display())
})?;
} else {
debug!(
"copying file {} to {}",
full_path.display(),
target_path.display(),
);
fs::copy(full_path, &target_path).with_context(|| {
format!(
"failed to copy file from {} to {}",
full_path.display(),
target_path.display()
)
})?;
}
}
Ok(())
}
fn extracted_contents_top_level_dir(&self, contents_dir: &Path) -> Result<Option<PathBuf>> {
let mut prefixes: HashMap<PathBuf, OsString> = HashMap::new();
debug!(
"checking whether extracted contents at {} share a single-top level dir with the project name `{}`",
contents_dir.display(),
&self.project_name,
);
for entry in fs::read_dir(contents_dir).with_context(|| {
format!(
"could not read {} after unpacking the tarball into this directory",
self.install_root.display(),
)
})? {
let full_path = entry
.with_context(|| {
format!(
"could not read directory entry in {}",
contents_dir.display()
)
})?
.path();
debug!("found entry in temp dir: {}", full_path.display());
if full_path.is_file()
&& full_path
.parent()
.expect("path of entry in temp dir somehow has no parent")
== contents_dir
{
return Ok(None);
}
let Ok(path) = full_path.strip_prefix(contents_dir) else {
return Err(anyhow!(
"temp dir {} contains a path {} that somehow isn't in itself",
contents_dir.display(),
full_path.display(),
));
};
if let Some(prefix) = path.components().next() {
prefixes.insert(full_path.clone(), prefix.as_os_str().to_os_string());
} else {
return Err(anyhow!("directory entry has no path components"));
}
}
if prefixes.len() == 1 {
let (path, prefix) = prefixes.into_iter().next().unwrap();
debug!(
"the extracted archive has a single common prefix: `{}`",
prefix.to_string_lossy(),
);
return Ok(Some(path));
}
debug!("the extracted archive has multiple top-level files or directories");
Ok(None)
}
}
impl Installer for ArchiveInstaller {
fn install(&self, download: &Download) -> Result<()> {
self.extract_entire_archive(&download.archive_path)?;
info!(
"Installed contents of archive file into {}",
self.install_root.display()
);
Ok(())
}
}
fn tar_reader_for(downloaded_file: &Path) -> Result<TarArchive<Box<dyn Read>>> {
let file = open_file(downloaded_file)?;
let ext = downloaded_file.extension();
match ext {
Some(ext) => match ext.to_str() {
Some("tar") => Ok(TarArchive::new(Box::new(file))),
Some("bz" | "tbz" | "bz2" | "tbz2") => {
Ok(TarArchive::new(Box::new(BzDecoder::new(file))))
}
Some("gz" | "tgz") => Ok(TarArchive::new(Box::new(GzDecoder::new(file)))),
Some("xz" | "txz") => Ok(TarArchive::new(Box::new(XzDecoder::new(file)))),
Some("zst" | "tzst") => Ok(TarArchive::new(Box::new(ZstdDecoder::new(file)?))),
Some(e) => Err(anyhow!(
"don't know how to uncompress a tarball with extension = {e}",
)),
None => Err(anyhow!(
"tarball {} has a non-UTF-8 extension",
downloaded_file.display(),
)),
},
None => Ok(TarArchive::new(Box::new(file))),
}
}
fn open_file(path: &Path) -> Result<File> {
File::open(path).with_context(|| format!("Failed to open file at {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[cfg(target_family = "unix")]
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
#[rstest]
#[case("test-data/project.7z", None)]
#[case("test-data/project.AppImage", Some("AppImage"))]
#[case("test-data/project.bat", Some("bat"))]
#[case("test-data/project.bz", None)]
#[case("test-data/project.bz2", None)]
#[case("test-data/project.exe", Some("exe"))]
#[case("test-data/project.gz", None)]
#[case("test-data/project.jar", Some("jar"))]
#[case("test-data/project.phar", Some("phar"))]
#[case("test-data/project.py", Some("py"))]
#[case("test-data/project.pyz", Some("pyz"))]
#[case("test-data/project.sh", Some("sh"))]
#[case("test-data/project.tar", None)]
#[case("test-data/project.tar.bz", None)]
#[case("test-data/project.tar.bz2", None)]
#[case("test-data/project.tar.gz", None)]
#[case("test-data/project.tar.xz", None)]
#[case("test-data/project.tar.zst", None)]
#[case("test-data/project.xz", None)]
#[case("test-data/project.zip", None)]
#[case("test-data/project.zst", None)]
#[case("test-data/project", None)]
#[case("test-data/project-with-partial-before-exact.zip", None)]
#[case("test-data/project-with-partial-match.tar.gz", None)]
#[case("test-data/project-with-partial-match.zip", None)]
fn exe_installer(
#[case] archive_path: &str,
#[case] installed_extension: Option<&str>,
) -> Result<()> {
crate::test_log::init_logging();
let td = tempdir()?;
let path_without_subdir = td.path().to_path_buf();
test_exe_installer(
archive_path,
installed_extension,
path_without_subdir,
false,
)?;
let td = tempdir()?;
let mut path_with_subdir = td.path().to_path_buf();
path_with_subdir.push("subdir");
test_exe_installer(archive_path, installed_extension, path_with_subdir, false)
}
#[rstest]
#[case("test-data/windows-project-exe.7z", "exe")]
#[case("test-data/windows-project-bat.tar.gz", "bat")]
#[case("test-data/windows-project-exe.tar.gz", "exe")]
#[case("test-data/windows-project-bat.zip", "bat")]
#[case("test-data/windows-project-exe.zip", "exe")]
#[case("test-data/windows-project-exe-with-partial-match.tar.gz", "exe")]
#[case("test-data/windows-project-exe-with-partial-match.zip", "exe")]
fn exe_installer_on_windows(#[case] archive_path: &str, #[case] extension: &str) -> Result<()> {
crate::test_log::init_logging();
let td = tempdir()?;
let install_dir = td.path().to_path_buf();
test_exe_installer(archive_path, Some(extension), install_dir, true)
}
fn test_exe_installer(
archive_path: &str,
installed_extension: Option<&str>,
install_dir: PathBuf,
is_windows: bool,
) -> Result<()> {
let mut install_path = install_dir.clone();
install_path.push("project");
run_one_exe_installer_test(
&install_path,
archive_path,
installed_extension,
is_windows,
false,
)?;
let mut install_path = install_dir;
install_path.push("project.foo");
run_one_exe_installer_test(
&install_path,
archive_path,
installed_extension,
is_windows,
true,
)?;
Ok(())
}
fn run_one_exe_installer_test(
install_path: &Path,
archive_path: &str,
installed_extension: Option<&str>,
is_windows: bool,
install_path_is_from_rename_exe_to: bool,
) -> Result<()> {
let exe_file_stem = "project";
let installer = ExeInstaller::new(
install_path.to_path_buf(),
install_path_is_from_rename_exe_to,
exe_file_stem.to_string(),
is_windows,
);
installer.install(&Download {
_temp_dir: tempdir()?,
archive_path: PathBuf::from(archive_path),
})?;
let mut expect_install_path = install_path.to_path_buf();
if !install_path_is_from_rename_exe_to {
if let Some(installed_extension) = installed_extension {
let path = PathBuf::from(format!("foo.{installed_extension}"));
let ext = Extension::from_path(&path).unwrap().unwrap();
if ext.should_preserve_extension_on_install() {
expect_install_path.set_extension(ext.extension_without_dot());
}
}
}
assert!(
fs::exists(&expect_install_path)?,
"{} file exists",
expect_install_path.display()
);
let expect_len = if Path::new(archive_path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pyz"))
{
fs::metadata(archive_path)?.len()
} else {
3
};
let meta = expect_install_path.metadata()?;
assert_eq!(meta.len(), expect_len);
#[cfg(target_family = "unix")]
assert!(meta.permissions().mode() & 0o111 != 0);
Ok(())
}
#[rstest]
#[case("test-data/project.7z")]
#[case("test-data/project.tar")]
#[case("test-data/project.tar.bz")]
#[case("test-data/project.tar.bz2")]
#[case("test-data/project.tar.gz")]
#[case("test-data/project.tar.xz")]
#[case("test-data/project.tar.zst")]
#[case("test-data/project.zip")]
fn archive_installer(#[case] archive_path: &str) -> Result<()> {
crate::test_log::init_logging();
let td = tempdir()?;
let mut path_without_subdir = td.path().to_path_buf();
path_without_subdir.push("project");
let mut path_with_subdir = td.path().to_path_buf();
path_with_subdir.extend(&["subdir", "project"]);
for install_root in [path_without_subdir, path_with_subdir] {
let installer = ArchiveInstaller::new(String::from("project"), install_root.clone());
installer.install(&Download {
_temp_dir: tempdir()?,
archive_path: PathBuf::from(archive_path),
})?;
assert!(install_root.exists());
assert!(install_root.is_dir());
let bin_dir = install_root.join("bin");
assert!(bin_dir.exists());
assert!(bin_dir.is_dir());
let exe = bin_dir.join("project");
assert!(exe.exists());
assert!(exe.is_file());
}
Ok(())
}
#[test_log::test]
fn archive_installer_one_file_in_archive_root() -> Result<()> {
let td = tempdir()?;
let mut path_without_subdir = td.path().to_path_buf();
path_without_subdir.push("project");
let mut path_with_subdir = td.path().to_path_buf();
path_with_subdir.extend(&["subdir", "project"]);
for install_root in [path_without_subdir, path_with_subdir] {
let installer = ArchiveInstaller::new(String::from("project"), install_root.clone());
installer.install(&Download {
_temp_dir: tempdir()?,
archive_path: PathBuf::from("test-data/project-with-one-file.tar.gz"),
})?;
assert!(install_root.exists());
assert!(install_root.is_dir());
let exe = install_root.join("project");
assert!(exe.exists());
assert!(exe.is_file());
}
Ok(())
}
#[test_log::test]
fn archive_installer_no_shared_root_path() -> Result<()> {
let td = tempdir()?;
let mut path_without_subdir = td.path().to_path_buf();
path_without_subdir.push("project");
let mut path_with_subdir = td.path().to_path_buf();
path_with_subdir.extend(&["subdir", "project"]);
for install_root in [path_without_subdir, path_with_subdir] {
let installer = ArchiveInstaller::new(String::from("project"), install_root.clone());
installer.install(&Download {
_temp_dir: tempdir()?,
archive_path: PathBuf::from("test-data/no-shared-root.tar.gz"),
})?;
assert!(install_root.exists());
assert!(install_root.is_dir());
let bin_dir = install_root.join("bin");
assert!(bin_dir.exists());
assert!(bin_dir.is_dir());
let exe = bin_dir.join("project");
assert!(exe.exists());
assert!(exe.is_file());
let readme = install_root.join("README.md");
assert!(readme.exists());
assert!(readme.is_file());
}
Ok(())
}
#[test_log::test]
fn archive_installer_to_existing_tree() -> Result<()> {
let td = tempdir()?;
let mut path_without_subdir = td.path().to_path_buf();
path_without_subdir.push("project");
let mut path_with_subdir = td.path().to_path_buf();
path_with_subdir.extend(&["subdir", "project"]);
{
let install_root = path_without_subdir;
let bin_dir = install_root.join("bin");
create_dir_all(&bin_dir)?;
let share_dir = install_root.join("share");
create_dir_all(&share_dir)?;
let installer = ArchiveInstaller::new(String::from("project"), install_root.clone());
installer.install(&Download {
_temp_dir: tempdir()?,
archive_path: PathBuf::from("test-data/shared-root.tar.gz"),
})?;
assert!(install_root.exists());
assert!(install_root.is_dir());
let exe = bin_dir.join("project");
assert!(exe.exists());
assert!(exe.is_file());
let rsrc = share_dir.join("resources.toml");
assert!(rsrc.exists());
assert!(rsrc.is_file());
}
Ok(())
}
}