//! ref: composer/src/Composer/Downloader/ZipDownloader.php
use crate::downloader::ArchiveDownloader;
use crate::downloader::DownloaderInterface;
use crate::downloader::FileDownloader;
use crate::io::IOInterface;
use crate::io::IOInterfaceImmutable;
use crate::package::PackageInterfaceHandle;
use crate::util::IniHelper;
use crate::util::Platform;
use anyhow::Result;
use indexmap::IndexMap;
use shirabe_external_packages::composer::pcre::{CaptureKey, Preg};
use shirabe_external_packages::symfony::component::process::ExecutableFinder;
use shirabe_external_packages::symfony::component::process::Process;
use shirabe_php_shim::{
DIRECTORY_SEPARATOR, ErrorException, PhpMixed, RuntimeException, UnexpectedValueException,
ZipArchive, bin2hex, class_exists, file_exists, file_get_contents, filesize, function_exists,
hash_file, is_file, json_encode, random_int, str_contains, str_replace, strlen, substr,
version_compare,
};
use std::sync::Mutex;
static UNZIP_COMMANDS: Mutex>>> = Mutex::new(None);
static HAS_ZIP_ARCHIVE: Mutex > = Mutex::new(None);
static IS_WINDOWS: Mutex > = Mutex::new(None);
#[derive(Debug)]
pub struct ZipDownloader {
inner: FileDownloader,
cleanup_executed: IndexMap,
// @phpstan-ignore property.onlyRead (helper property that is set via reflection for testing purposes)
zip_archive_object: Option,
}
impl ZipDownloader {
pub fn new(
io: std::rc::Rc>,
config: std::rc::Rc>,
http_downloader: std::rc::Rc>,
event_dispatcher: Option<
std::rc::Rc>,
>,
cache: Option>>,
filesystem: std::rc::Rc>,
process: std::rc::Rc>,
) -> Self {
Self {
inner: FileDownloader::new(
io,
config,
http_downloader,
event_dispatcher,
cache,
Some(filesystem),
Some(process),
),
cleanup_executed: IndexMap::new(),
zip_archive_object: None,
}
}
pub async fn download(
&mut self,
package: PackageInterfaceHandle,
path: &str,
prev_package: Option,
output: bool,
) -> Result> {
{
let mut unzip_commands = UNZIP_COMMANDS.lock().unwrap();
if unzip_commands.is_none() {
*unzip_commands = Some(vec![]);
let finder = ExecutableFinder::new();
let commands = unzip_commands.as_mut().unwrap();
if Platform::is_windows() {
if let Some(cmd) =
finder.find("7z", None, &[r"C:\Program Files\7-Zip".to_string()])
{
commands.push(vec![
"7z".to_string(),
cmd,
"x".to_string(),
"-bb0".to_string(),
"-y".to_string(),
"%file%".to_string(),
"-o%path%".to_string(),
]);
}
}
if let Some(cmd) = finder.find("unzip", None, &[]) {
commands.push(vec![
"unzip".to_string(),
cmd,
"-qq".to_string(),
"%file%".to_string(),
"-d".to_string(),
"%path%".to_string(),
]);
}
if !Platform::is_windows() {
if let Some(cmd) = finder.find("7z", None, &[]) {
// 7z linux/macOS support is only used if unzip is not present
commands.push(vec![
"7z".to_string(),
cmd,
"x".to_string(),
"-bb0".to_string(),
"-y".to_string(),
"%file%".to_string(),
"-o%path%".to_string(),
]);
} else if let Some(cmd) = finder.find("7zz", None, &[]) {
// 7zz linux/macOS support is only used if unzip is not present
commands.push(vec![
"7zz".to_string(),
cmd,
"x".to_string(),
"-bb0".to_string(),
"-y".to_string(),
"%file%".to_string(),
"-o%path%".to_string(),
]);
} else if let Some(cmd) = finder.find("7za", None, &[]) {
// 7za linux/macOS support is only used if unzip is not present
commands.push(vec![
"7za".to_string(),
cmd,
"x".to_string(),
"-bb0".to_string(),
"-y".to_string(),
"%file%".to_string(),
"-o%path%".to_string(),
]);
}
}
}
}
let proc_open_missing = !function_exists("proc_open");
if proc_open_missing {
*UNZIP_COMMANDS.lock().unwrap() = Some(vec![]);
}
{
let mut has_zip_archive = HAS_ZIP_ARCHIVE.lock().unwrap();
if has_zip_archive.is_none() {
*has_zip_archive = Some(class_exists("ZipArchive"));
}
}
let has_zip_archive = HAS_ZIP_ARCHIVE.lock().unwrap().unwrap_or(false);
let unzip_commands_empty = UNZIP_COMMANDS
.lock()
.unwrap()
.as_ref()
.map_or(true, |v| v.is_empty());
if !has_zip_archive && unzip_commands_empty {
let ini_message = IniHelper::get_message();
let error = if proc_open_missing {
format!(
"The zip extension is missing and unzip/7z commands cannot be called as proc_open is disabled, skipping.\n{}",
ini_message
)
} else {
format!(
"The zip extension and unzip/7z commands are both missing, skipping.\n{}",
ini_message
)
};
return Err(RuntimeException {
message: error,
code: 0,
}
.into());
}
{
let mut is_windows_guard = IS_WINDOWS.lock().unwrap();
if is_windows_guard.is_none() {
*is_windows_guard = Some(Platform::is_windows());
if !is_windows_guard.unwrap() && unzip_commands_empty {
if proc_open_missing {
self.inner.io.write_error("proc_open is disabled so 'unzip' and '7z' commands cannot be used, zip files are being unpacked using the PHP zip extension. ");
self.inner.io.write_error("This may cause invalid reports of corrupted archives. Besides, any UNIX permissions (e.g. executable) defined in the archives will be lost. ");
self.inner.io.write_error("Enabling proc_open and installing 'unzip' or '7z' (21.01+) may remediate them. ");
} else {
self.inner.io.write_error("As there is no 'unzip' nor '7z' command installed zip files are being unpacked using the PHP zip extension. ");
self.inner.io.write_error("This may cause invalid reports of corrupted archives. Besides, any UNIX permissions (e.g. executable) defined in the archives will be lost. ");
self.inner.io.write_error("Installing 'unzip' or '7z' (21.01+) may remediate them. ");
}
}
}
}
self.inner
.download(package, path, prev_package, output)
.await
}
async fn extract_with_system_unzip(
&mut self,
package: PackageInterfaceHandle,
file: &str,
path: &str,
) -> Result > {
static WARNED_7ZIP_LINUX: Mutex = Mutex::new(false);
let is_last_chance = !HAS_ZIP_ARCHIVE.lock().unwrap().unwrap_or(false);
let unzip_commands_empty = UNZIP_COMMANDS
.lock()
.unwrap()
.as_ref()
.map_or(true, |v| v.is_empty());
if unzip_commands_empty {
return self.extract_with_zip_archive(package, file, path).await;
}
let command_spec = UNZIP_COMMANDS.lock().unwrap().as_ref().unwrap()[0].clone();
let executable = command_spec[0].clone();
let map: IndexMap<&str, String> = [
// normalize separators to backslashes to avoid problems with 7-zip on windows
// see https://github.com/composer/composer/issues/10058
("%file%", file.replace('/', DIRECTORY_SEPARATOR)),
("%path%", path.replace('/', DIRECTORY_SEPARATOR)),
]
.into_iter()
.collect();
let command: Vec = command_spec[1..]
.iter()
.map(|value| {
let mut v = value.clone();
for (from, to) in &map {
v = v.replace(from, to.as_str());
}
v
})
.collect();
if !*WARNED_7ZIP_LINUX.lock().unwrap()
&& !Platform::is_windows()
&& ["7z", "7zz", "7za"].contains(&executable.as_str())
{
*WARNED_7ZIP_LINUX.lock().unwrap() = true;
let mut output = String::new();
if self
.inner
.process
.borrow_mut()
.execute(&[command_spec[1].as_str()], &mut output, None::<&str>)
.unwrap_or(1)
== 0
{
let mut m: IndexMap = IndexMap::new();
if Preg::is_match_strict_groups3(
r"^\s*7-Zip(?:\s\[64\])?\s([0-9.]+)",
&output,
Some(&mut m),
)
.unwrap_or(false)
{
let m1 = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default();
if version_compare(&m1, "21.01", "<") {
self.inner.io.write_error(&format!(
" Unzipping using {} {} may result in incorrect file permissions. Install {} 21.01+ or unzip to ensure you get correct permissions. ",
executable, m1, executable,
));
}
}
}
}
let process_result = self
.inner
.process
.borrow_mut()
.execute_async(&command, ())
.await;
match process_result {
Ok(process) => {
if !process.is_successful() {
if self.cleanup_executed.contains_key(&package.get_name()) {
return Err(RuntimeException {
message: format!(
"Failed to extract {} as the installation was aborted by another package operation.",
package.get_name()
),
code: 0,
}
.into());
}
let output = process.get_error_output();
let output =
str_replace(&format!(", {}.zip or {}.ZIP", file, file), "", &output);
return self
.try_fallback(
RuntimeException {
message: format!(
"Failed to extract {}: ({}) {}\n\n{}",
package.get_name(),
process
.get_exit_code()
.map(|c| c.to_string())
.unwrap_or_default(),
command.join(" "),
output
),
code: 0,
}
.into(),
is_last_chance,
file,
path,
package,
&executable,
)
.await;
}
Ok(None)
}
Err(e) => {
self.try_fallback(e, is_last_chance, file, path, package, &executable)
.await
}
}
}
async fn try_fallback(
&mut self,
process_error: anyhow::Error,
is_last_chance: bool,
file: &str,
path: &str,
package: PackageInterfaceHandle,
executable: &str,
) -> Result> {
if is_last_chance {
return Err(process_error);
}
if str_contains(&process_error.to_string(), "zip bomb") {
return Err(process_error);
}
if !is_file(file) {
self.inner
.io
.write_error(&format!(" {} ", process_error));
self.inner.io.write_error(" This most likely is due to a custom installer plugin not handling the returned Promise from the downloader ");
self.inner.io.write_error(" See https://github.com/composer/installers/commit/5006d0c28730ade233a8f42ec31ac68fb1c5c9bb for an example fix ");
} else {
self.inner
.io
.write_error(&format!(" {} ", process_error));
self.inner.io.write_error(" The archive may contain identical file names with different capitalization (which fails on case insensitive filesystems)");
self.inner.io.write_error(&format!(
" Unzip with {} command failed, falling back to ZipArchive class",
executable
));
// additional debug data to try to figure out GH actions issues https://github.com/composer/composer/issues/11148
if Platform::get_env("GITHUB_ACTIONS").is_some()
&& Platform::get_env("COMPOSER_TESTS_ARE_RUNNING").is_none()
{
self.inner.io.write_error(" Additional debug info, please report to https://github.com/composer/composer/issues/11148 if you see this: ");
self.inner.io.write_error(&format!(
"File size: {}",
filesize(file).map(|s| s.to_string()).unwrap_or_default()
));
self.inner.io.write_error(&format!(
"File SHA1: {}",
hash_file("sha1", file).unwrap_or_default()
));
self.inner.io.write_error(&format!(
"First 100 bytes (hex): {}",
bin2hex(
substr(&file_get_contents(file).unwrap_or_default(), 0, Some(100))
.as_bytes()
)
));
self.inner.io.write_error(&format!(
"Last 100 bytes (hex): {}",
bin2hex(
substr(&file_get_contents(file).unwrap_or_default(), -100, None).as_bytes()
)
));
if strlen(&package.get_dist_url().unwrap_or_default()) > 0 {
self.inner.io.write_error(&format!(
"Origin URL: {}",
self.inner.process_url(
package.clone(),
&package.get_dist_url().unwrap_or_default()
)?
));
let headers = {
let response_headers = crate::downloader::file_downloader::RESPONSE_HEADERS
.lock()
.unwrap();
match response_headers.get(&package.get_name()) {
Some(list) => PhpMixed::List(
list.iter()
.map(|s| Box::new(PhpMixed::String(s.clone())))
.collect(),
),
None => PhpMixed::List(vec![]),
}
};
self.inner.io.write_error(&format!(
"Response Headers: {}",
json_encode(&headers).unwrap_or_default()
));
}
}
}
self.extract_with_zip_archive(package, file, path).await
}
async fn extract_with_zip_archive(
&mut self,
package: PackageInterfaceHandle,
file: &str,
path: &str,
) -> Result > {
let mut zip_archive = self
.zip_archive_object
.take()
.unwrap_or_else(ZipArchive::new);
let result: Result > = (|| {
let retval = if !file_exists(file) || filesize(file).map_or(true, |s| s == 0) {
Err(-1i64)
} else {
zip_archive.open(file, 0)
};
if retval.is_ok() {
let archive_size = filesize(file);
let total_files = zip_archive.count();
if total_files > 0 {
let mut total_size: i64 = 0;
let mut inspect_all = false;
let mut files_to_inspect = total_files.min(5);
let mut i: i64 = 0;
while i < files_to_inspect {
let stat_index = if inspect_all {
i
} else {
random_int(0, total_files - 1)
};
if let Some(stat) = zip_archive.stat_index(stat_index) {
let size = stat.get("size").and_then(|v| v.as_int()).unwrap_or(0);
let comp_size =
stat.get("comp_size").and_then(|v| v.as_int()).unwrap_or(0);
total_size += size;
if !inspect_all && size > comp_size * 200 {
total_size = 0;
inspect_all = true;
i = -1;
files_to_inspect = total_files;
}
}
i += 1;
}
if let Some(archive_sz) = archive_size {
if total_size > archive_sz * 100 && total_size > 50 * 1024 * 1024 {
return Err(RuntimeException {
message: format!(
"Invalid zip file for \"{}\" with compression ratio >99% (possible zip bomb)",
package.get_name(),
),
code: 0,
}.into());
}
}
}
let extract_result = zip_archive.extract_to(path);
if extract_result {
zip_archive.close();
return Ok(None);
}
return Err(RuntimeException {
message: format!(
"There was an error extracting the ZIP file for \"{}\", it is either corrupted or using an invalid format.",
package.get_name(),
),
code: 0,
}.into());
} else {
let code = retval.unwrap_err();
return Err(UnexpectedValueException {
message: self.get_error_message(code, file).trim_end().to_string(),
code,
}
.into());
}
})();
result.map_err(|e| {
if let Some(err) = e.downcast_ref::() {
RuntimeException {
message: format!(
"The archive for \"{}\" may contain identical file names with different capitalization (which fails on case insensitive filesystems): {}",
package.get_name(),
err.message,
),
code: 0,
}.into()
} else {
e
}
})
}
pub(crate) async fn extract(
&mut self,
package: PackageInterfaceHandle,
file: &str,
path: &str,
) -> Result> {
self.extract_with_system_unzip(package, file, path).await
}
pub fn get_error_message(&self, retval: i64, file: &str) -> String {
match retval {
ZipArchive::ER_EXISTS => format!("File '{}' already exists.", file),
ZipArchive::ER_INCONS => format!("Zip archive '{}' is inconsistent.", file),
ZipArchive::ER_INVAL => format!("Invalid argument ({})", file),
ZipArchive::ER_MEMORY => format!("Malloc failure ({})", file),
ZipArchive::ER_NOENT => format!("No such zip file: '{}'", file),
ZipArchive::ER_NOZIP => format!("'{}' is not a zip archive.", file),
ZipArchive::ER_OPEN => format!("Can't open zip file: {}", file),
ZipArchive::ER_READ => format!("Zip read error ({})", file),
ZipArchive::ER_SEEK => format!("Zip seek error ({})", file),
-1 => format!(
"'{}' is a corrupted zip archive (0 bytes), try again.",
file
),
_ => format!(
"'{}' is not a valid zip archive, got error code: {}",
file, retval
),
}
}
}
// TODO(phase-b): ZipDownloader::download is overridden with extra setup (UNZIP_COMMANDS init,
// etc.). The trait method here delegates straight to the inner FileDownloader; the bespoke
// override on the struct itself takes &mut self and is not yet routed through the trait.
#[async_trait::async_trait(?Send)]
impl crate::downloader::DownloaderInterface for ZipDownloader {
fn get_installation_source(&self) -> String {
self.inner.get_installation_source()
}
async fn download(
&self,
package: PackageInterfaceHandle,
path: &str,
prev_package: Option,
output: bool,
) -> Result> {
self.inner
.download(package, path, prev_package, output)
.await
}
async fn prepare(
&self,
r#type: &str,
package: PackageInterfaceHandle,
path: &str,
prev_package: Option,
) -> Result> {
self.inner
.prepare(r#type, package, path, prev_package)
.await
}
async fn install(
&self,
package: PackageInterfaceHandle,
path: &str,
output: bool,
) -> Result > {
self.inner.install(package, path, output).await
}
async fn update(
&self,
initial: PackageInterfaceHandle,
target: PackageInterfaceHandle,
path: &str,
) -> Result > {
self.inner.update(initial, target, path).await
}
async fn remove(
&self,
package: PackageInterfaceHandle,
path: &str,
output: bool,
) -> Result > {
self.inner.remove(package, path, output).await
}
async fn cleanup(
&self,
r#type: &str,
package: PackageInterfaceHandle,
path: &str,
prev_package: Option,
) -> Result> {
self.inner
.cleanup(r#type, package, path, prev_package)
.await
}
}