//! 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 } }