//! ref: composer/src/Composer/Downloader/ZipDownloader.php use crate::downloader::archive_downloader::ArchiveDownloader; use crate::downloader::file_downloader::FileDownloader; use crate::package::package_interface::PackageInterface; use crate::util::ini_helper::IniHelper; use crate::util::platform::Platform; use anyhow::Result; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::preg::Preg; use shirabe_external_packages::react::promise::promise_interface::PromiseInterface; use shirabe_external_packages::symfony::component::process::executable_finder::ExecutableFinder; use shirabe_external_packages::symfony::component::process::process::Process; use shirabe_php_shim::{ DIRECTORY_SEPARATOR, ErrorException, RuntimeException, UnexpectedValueException, ZipArchive, bin2hex, class_exists, file_exists, file_get_contents, filesize, function_exists, hash_file, is_file, json_encode, random_int, 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 download( &mut self, package: &dyn PackageInterface, path: &str, prev_package: Option<&dyn PackageInterface>, 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"]) { 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.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.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.inner.io.write_error("Enabling proc_open and installing 'unzip' or '7z' (21.01+) may remediate them."); } else { self.inner.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.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.inner.io.write_error("Installing 'unzip' or '7z' (21.01+) may remediate them."); } } } } self.inner .inner .download(package, path, prev_package, output) } fn extract_with_system_unzip( &mut self, package: &dyn PackageInterface, 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); } 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 .inner .process .execute(&[command_spec[1].as_str()], &mut output) == 0 { if let Some(m) = Preg::is_match_strict_groups(r"^\s*7-Zip(?:\s\[64\])?\s([0-9.]+)", &output) { if version_compare(&m[1], "21.01", "<") { self.inner.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, m[1], executable, )); } } } } let io = &self.inner.inner.io; let try_fallback = |process_error: anyhow::Error| -> Result> { if is_last_chance { return Err(process_error); } if process_error.to_string().contains("zip bomb") { return Err(process_error); } if !is_file(file) { io.write_error(&format!(" {}", process_error)); io.write_error(" This most likely is due to a custom installer plugin not handling the returned Promise from the downloader"); io.write_error(" See https://github.com/composer/installers/commit/5006d0c28730ade233a8f42ec31ac68fb1c5c9bb for an example fix"); } else { io.write_error(&format!(" {}", process_error)); io.write_error(" The archive may contain identical file names with different capitalization (which fails on case insensitive filesystems)"); io.write_error(&format!( " Unzip with {} command failed, falling back to ZipArchive class", executable )); if Platform::get_env("GITHUB_ACTIONS").is_some() && Platform::get_env("COMPOSER_TESTS_ARE_RUNNING").is_none() { io.write_error(" Additional debug info, please report to https://github.com/composer/composer/issues/11148 if you see this:"); io.write_error(&format!("File size: {}", filesize(file).unwrap_or(0))); io.write_error(&format!( "File SHA1: {}", hash_file("sha1", file).unwrap_or_default() )); let content = file_get_contents(file).unwrap_or_default(); let bytes = content.as_bytes(); io.write_error(&format!( "First 100 bytes (hex): {}", bin2hex(&bytes[..bytes.len().min(100)]) )); let len = bytes.len(); io.write_error(&format!( "Last 100 bytes (hex): {}", bin2hex(&bytes[len.saturating_sub(100)..]) )); if package.get_dist_url().map_or(false, |u| !u.is_empty()) { io.write_error(&format!( "Origin URL: {}", self.inner .inner .process_url(package, &package.get_dist_url().unwrap_or_default()) )); let headers = FileDownloader::response_headers.lock().unwrap(); io.write_error(&format!( "Response Headers: {}", json_encode(&shirabe_php_shim::PhpMixed::Null) .unwrap_or_else(|| "[]".to_string()) )); } } } self.extract_with_zip_archive(package, file, path) }; match self.inner.inner.process.execute_async(&command) { Ok(promise) => Ok(promise.then( Box::new(move |process: Process| -> Result<()> { if !process.is_successful() { if self.inner.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 mut output = process.get_error_output(); output = output.replace(&format!(", {}.zip or {}.ZIP", file, file), ""); return try_fallback(RuntimeException { message: format!( "Failed to extract {}: ({}) {}\n\n{}", package.get_name(), process.get_exit_code().unwrap_or(0), command.join(" "), output, ), code: 0, }.into()); } Ok(()) }), None, )), Err(e) => try_fallback(e), } } fn extract_with_zip_archive( &mut self, package: &dyn PackageInterface, 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(shirabe_external_packages::react::promise::resolve(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) fn extract( &mut self, package: &dyn PackageInterface, file: &str, path: &str, ) -> Result> { self.extract_with_system_unzip(package, file, path) } 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 ), } } }