diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-23 15:45:33 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-23 15:48:00 +0900 |
| commit | bd6d0186d2c01a3e1d6324ad5a0bcdd71de53098 (patch) | |
| tree | 939eb1dccbfb3341a2f618e734ca23ef84a8e5cc /crates/shirabe/src/downloader | |
| parent | e068a9d644fde6659a88accd55b3f1d0d9d7cf46 (diff) | |
| download | php-shirabe-bd6d0186d2c01a3e1d6324ad5a0bcdd71de53098.tar.gz php-shirabe-bd6d0186d2c01a3e1d6324ad5a0bcdd71de53098.tar.zst php-shirabe-bd6d0186d2c01a3e1d6324ad5a0bcdd71de53098.zip | |
refactor(promise): drop \React\Promise
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe/src/downloader')
| -rw-r--r-- | crates/shirabe/src/downloader/archive_downloader.rs | 175 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/downloader_interface.rs | 7 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/file_downloader.rs | 313 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/fossil_downloader.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/git_downloader.rs | 2 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/gzip_downloader.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/hg_downloader.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/path_downloader.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/perforce_downloader.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/phar_downloader.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/rar_downloader.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/svn_downloader.rs | 2 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/tar_downloader.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/xz_downloader.rs | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/zip_downloader.rs | 166 |
15 files changed, 573 insertions, 101 deletions
diff --git a/crates/shirabe/src/downloader/archive_downloader.rs b/crates/shirabe/src/downloader/archive_downloader.rs index ac898d5..6c8a8f9 100644 --- a/crates/shirabe/src/downloader/archive_downloader.rs +++ b/crates/shirabe/src/downloader/archive_downloader.rs @@ -2,15 +2,17 @@ use anyhow::Result; use indexmap::IndexMap; -use shirabe_external_packages::symfony::component::finder::Finder; +use shirabe_external_packages::symfony::component::finder::{Finder, SplFileInfo}; use shirabe_php_shim::{ - DIRECTORY_SEPARATOR, RuntimeException, bin2hex, file_exists, is_dir, random_bytes, realpath, + DIRECTORY_SEPARATOR, PhpMixed, RuntimeException, basename, bin2hex, file_exists, is_dir, + random_bytes, realpath, }; use crate::dependency_resolver::operation::InstallOperation; use crate::downloader::DownloaderInterface; use crate::downloader::FileDownloader; use crate::package::PackageInterface; +use crate::util::Filesystem; use crate::util::Platform; pub trait ArchiveDownloader { @@ -122,22 +124,79 @@ pub trait ArchiveDownloader { .ensure_directory_exists(&temporary_dir); let file_name = self.inner().get_file_name(package, path); - let _ = file_name; + match self.extract(package, &file_name, &temporary_dir).await { + Err(e) => { + install_cleanup(self.inner_mut(), package, path, &temporary_dir)?; + Err(e) + } + Ok(_) => { + if file_exists(&file_name) { + self.inner().filesystem.borrow().unlink(&file_name)?; + } + + let mut rename_as_one = false; + if !file_exists(path) { + rename_as_one = true; + } else if self.inner().filesystem.borrow().is_dir_empty(path) { + let removed = self + .inner() + .filesystem + .borrow_mut() + .remove_directory_php(path); + match removed { + Ok(true) => { + rename_as_one = true; + } + Ok(false) => {} + Err(e) => { + // ignore error, and simply do not renameAsOne + if e.downcast_ref::<RuntimeException>().is_none() { + return Err(e); + } + } + } + } + + let content_dir = get_folder_content(&temporary_dir); + let single_dir_at_top_level = content_dir.len() == 1 + && content_dir + .first() + .map(|file| is_dir(&file.get_pathname())) + .unwrap_or(false); + + if rename_as_one { + // if the target $path is clear, we can rename the whole package in one go instead of looping over the contents + let extracted_dir = if single_dir_at_top_level { + content_dir.first().unwrap().get_pathname() + } else { + temporary_dir.clone() + }; + self.inner() + .filesystem + .borrow_mut() + .rename(&extracted_dir, path)?; + } else { + // only one dir in the archive, extract its contents out of it + let mut from = temporary_dir.clone(); + if single_dir_at_top_level { + from = content_dir.first().unwrap().get_pathname(); + } - // TODO(phase-c-promise): rewrite extract().then(onFulfilled/onRejected) + renameRecursively chain as an await sequence - let promise = self.extract(package, "", &temporary_dir)?; + rename_recursively(&self.inner().filesystem, package, &from, path)?; + } - // TODO(phase-b): the original PHP chains React promise `.then(onFulfilled, onRejected)` - // callbacks that capture `$this`, `$filesystem`, `$package`, `$path`, `$temporaryDir`, - // `$fileName`, and a recursive `$renameRecursively` closure. PromiseInterface::then in - // Rust expects `FnOnce(Option<PhpMixed>) -> Option<PhpMixed>` and the callbacks here - // need both `&mut self` access and to return another promise. This needs a structural - // rework (likely splitting the trait or adding a `then_boxed_result` adapter), plus a - // way to share `&mut self` with the closure (probably `Rc<RefCell<...>>`). - let _ = (&promise, &temporary_dir, package, path); - todo!( - "ArchiveDownloader::install: rewire .then(onFulfilled, onRejected) chain to match PromiseInterface signature" - ) + self.inner() + .filesystem + .borrow_mut() + .remove_directory_async(&temporary_dir) + .await?; + self.inner_mut() + .remove_cleanup_path(package, &temporary_dir); + self.inner_mut().remove_cleanup_path(package, path); + + Ok(None) + } + } } /// @inheritDoc @@ -145,3 +204,87 @@ pub trait ArchiveDownloader { ": Extracting archive" } } + +fn install_cleanup( + inner: &mut FileDownloader, + package: &dyn PackageInterface, + path: &str, + temporary_dir: &str, +) -> Result<()> { + // remove cache if the file was corrupted + inner.clear_last_cache_write(package); + + // clean up + inner + .filesystem + .borrow_mut() + .remove_directory(temporary_dir)?; + if is_dir(path) && realpath(path) != Some(Platform::get_cwd(false).unwrap_or_default()) { + inner.filesystem.borrow_mut().remove_directory(path)?; + } + inner.remove_cleanup_path(package, temporary_dir); + let realpath = realpath(path); + if let Some(realpath) = realpath { + inner.remove_cleanup_path(package, &realpath); + } + + Ok(()) +} + +/// Returns the folder content, excluding .DS_Store +fn get_folder_content(dir: &str) -> Vec<SplFileInfo> { + let mut finder = Finder::create(); + finder + .ignore_vcs(false) + .ignore_dot_files(false) + .not_name(".DS_Store") + .depth(0) + .r#in(dir); + + finder.iter().collect() +} + +/// Renames (and recursively merges if needed) a folder into another one +/// +/// For custom installers, where packages may share paths, and given Composer 2's parallelism, we need to make sure +/// that the source directory gets merged into the target one if the target exists. Otherwise rename() by default would +/// put the source into the target e.g. src/ => target/src/ (assuming target exists) instead of src/ => target/ +fn rename_recursively( + filesystem: &std::rc::Rc<std::cell::RefCell<Filesystem>>, + package: &dyn PackageInterface, + from: &str, + to: &str, +) -> Result<()> { + let content_dir = get_folder_content(from); + + // move files back out of the temp dir + for file in &content_dir { + let file = file.get_pathname(); + if is_dir(&format!("{}/{}", to, basename(&file))) { + if !is_dir(&file) { + return Err(RuntimeException { + message: format!( + "Installing {} would lead to overwriting the {}/{} directory with a file from the package, invalid operation.", + package, + to, + basename(&file) + ), + code: 0, + } + .into()); + } + rename_recursively( + filesystem, + package, + &file, + &format!("{}/{}", to, basename(&file)), + )?; + } else { + filesystem + .borrow_mut() + .rename(&file, &format!("{}/{}", to, basename(&file)))?; + } + } + + Ok(()) +} diff --git a/crates/shirabe/src/downloader/downloader_interface.rs b/crates/shirabe/src/downloader/downloader_interface.rs index 7b333e6..18026a4 100644 --- a/crates/shirabe/src/downloader/downloader_interface.rs +++ b/crates/shirabe/src/downloader/downloader_interface.rs @@ -3,6 +3,7 @@ use crate::package::PackageInterface; use shirabe_php_shim::PhpMixed; +#[async_trait::async_trait(?Send)] pub trait DownloaderInterface: std::fmt::Debug { fn get_installation_source(&self) -> String; @@ -21,7 +22,7 @@ pub trait DownloaderInterface: std::fmt::Debug { path: &str, prev_package: Option<&dyn PackageInterface>, ) -> anyhow::Result<Option<PhpMixed>> { - self.download(package, path, prev_package, true) + self.download(package, path, prev_package, true).await } async fn prepare( @@ -45,7 +46,7 @@ pub trait DownloaderInterface: std::fmt::Debug { package: &dyn PackageInterface, path: &str, ) -> anyhow::Result<Option<PhpMixed>> { - self.install(package, path, true) + self.install(package, path, true).await } async fn update( @@ -68,7 +69,7 @@ pub trait DownloaderInterface: std::fmt::Debug { package: &dyn PackageInterface, path: &str, ) -> anyhow::Result<Option<PhpMixed>> { - self.remove(package, path, true) + self.remove(package, path, true).await } async fn cleanup( diff --git a/crates/shirabe/src/downloader/file_downloader.rs b/crates/shirabe/src/downloader/file_downloader.rs index 452ce8c..dc8c5a5 100644 --- a/crates/shirabe/src/downloader/file_downloader.rs +++ b/crates/shirabe/src/downloader/file_downloader.rs @@ -6,7 +6,6 @@ use indexmap::IndexMap; use std::sync::{LazyLock, Mutex}; use crate::util::Silencer; -use shirabe_external_packages::react::promise::resolve as react_promise_resolve; use shirabe_php_shim::{ DIRECTORY_SEPARATOR, InvalidArgumentException, PATHINFO_BASENAME, PATHINFO_EXTENSION, PHP_URL_PATH, PhpMixed, RuntimeException, UnexpectedValueException, array_search, array_shift, @@ -49,7 +48,7 @@ pub static DOWNLOAD_METADATA: LazyLock<Mutex<IndexMap<String, PhpMixed>>> = /// @var array<string, array<string>> /// @private /// @internal -pub static RESPONSE_HEADERS: LazyLock<Mutex<IndexMap<String, IndexMap<String, Vec<String>>>>> = +pub static RESPONSE_HEADERS: LazyLock<Mutex<IndexMap<String, Vec<String>>>> = LazyLock::new(|| Mutex::new(IndexMap::new())); /// Base downloader for files @@ -70,7 +69,12 @@ pub struct FileDownloader { /// @var ProcessExecutor pub(crate) process: std::rc::Rc<std::cell::RefCell<ProcessExecutor>>, /// @var array<string, string> Map of package name to cache key - last_cache_writes: IndexMap<String, String>, + /// + /// Behind a Mutex so `download()` can record cache writes through `&self`. `download()` blocks + /// for a while and (once the workers are parallelized) runs from several threads; requiring + /// `&mut self` would force locking the whole FileDownloader for each download's duration. Only + /// this write needs guarding, so it is the only field isolated behind a lock. + last_cache_writes: Mutex<IndexMap<String, String>>, /// @var array<string, string[]> Map of package name to list of paths additional_cleanup_paths: IndexMap<String, Vec<String>>, } @@ -115,7 +119,7 @@ impl FileDownloader { cache, process, filesystem, - last_cache_writes: IndexMap::new(), + last_cache_writes: Mutex::new(IndexMap::new()), additional_cleanup_paths: IndexMap::new(), }; @@ -140,6 +144,7 @@ impl FileDownloader { } } +#[async_trait::async_trait(?Send)] impl DownloaderInterface for FileDownloader { /// @inheritDoc fn get_installation_source(&self) -> String { @@ -199,16 +204,223 @@ impl DownloaderInterface for FileDownloader { .borrow_mut() .ensure_directory_exists(&dir_of_file)?; - // TODO(phase-c-promise): rewrite the accept/reject/retry promise orchestration as an async loop. - // TODO(plugin): inline closures rely on captured $accept/$reject/$urls/$retries. In Rust - // we'd need a struct holding shared state — left as a phase-b refactor. - let _ = (output, &urls, &mut retries, cache_key_generator, &file_name); - let _ = PluginEvents::PRE_FILE_DOWNLOAD; - let _ = PluginEvents::POST_FILE_DOWNLOAD; + // The PHP $download/$accept/$reject closures form a retry loop driven by recursion; here it + // is expressed as a loop. $reject's "return $download()" maps to `continue`, "throw" to + // `return Err`, and the success path runs the verification block and returns the file name. + let _ = cache_key_generator; + loop { + // === $download() === + let url = urls[0].clone(); + // TODO(plugin): dispatch PreFileDownloadEvent and apply its custom cache key / processed url. + urls[0] = url.clone(); - todo!( - "phase-b: orchestrate download/accept/reject closures and call download() returning a PromiseInterface" - ) + let checksum = package.get_dist_sha1_checksum().map(|s| s.to_string()); + let cache_key = url.cache_key.clone(); + + // use from cache if it is present and has a valid checksum or we have no checksum to check against + let mut from_cache = false; + if let Some(cache) = self.cache.clone() { + let checksum_matches = match checksum.as_deref() { + None | Some("") => true, + Some(c) => Some(c) == cache.borrow_mut().sha1(&cache_key).as_deref(), + }; + if checksum_matches && cache.borrow_mut().copy_to(&cache_key, &file_name)? { + from_cache = true; + } + } + + if from_cache { + if output { + self.io.write_error3( + &format!( + " - Loading <info>{}</info> (<comment>{}</comment>) from cache", + package.get_name(), + package.get_full_pretty_version(true, 0) + ), + true, + io_interface::VERY_VERBOSE, + ); + } + // mark the file as having been written in cache even though it is only read from cache, so that if + // the cache is corrupt the archive will be deleted and the next attempt will re-download it + // see https://github.com/composer/composer/issues/10028 + if let Some(cache) = self.cache.as_ref() { + if !cache.borrow().is_read_only() { + self.last_cache_writes + .lock() + .unwrap() + .insert(package.get_name().to_string(), cache_key.clone()); + } + } + } else { + if output { + self.io.write_error(&format!( + " - Downloading <info>{}</info> (<comment>{}</comment>)", + package.get_name(), + package.get_full_pretty_version(true, 0) + )); + } + + let add_copy_result = self + .http_downloader + .borrow_mut() + .add_copy(&url.processed, &file_name, package.get_transport_options()) + .await; + match add_copy_result { + Ok(mut response) => { + // === $accept($response) === + let cache_key = urls[0].cache_key.clone(); + let file_size = match filesize(&file_name) { + Some(size) => PhpMixed::Int(size), + None => PhpMixed::String( + response + .get_header("Content-Length") + .unwrap_or_else(|| "?".to_string()), + ), + }; + DOWNLOAD_METADATA + .lock() + .unwrap() + .insert(package.get_name().to_string(), file_size); + + if Platform::get_env("GITHUB_ACTIONS").is_some() + && Platform::get_env("COMPOSER_TESTS_ARE_RUNNING").is_none() + { + RESPONSE_HEADERS.lock().unwrap().insert( + package.get_name().to_string(), + response.get_headers().clone(), + ); + } + + if let Some(cache) = self.cache.as_ref() { + if !cache.borrow().is_read_only() { + self.last_cache_writes + .lock() + .unwrap() + .insert(package.get_name().to_string(), cache_key.clone()); + cache.borrow_mut().copy_from(&cache_key, &file_name); + } + } + + response.collect(); + } + Err(e) => { + // === $reject($e) === + // clean up + if file_exists(&file_name) { + self.filesystem.borrow().unlink(&file_name)?; + } + self.clear_last_cache_write(package); + + if e.downcast_ref::<IrrecoverableDownloadException>().is_some() { + return Err(e); + } + + if e.downcast_ref::<MaxFileSizeExceededException>().is_some() { + return Err(e); + } + + if let Some(te) = e.downcast_ref::<TransportException>() { + // if we got an http response with a proper code, then requesting again will probably not help, abort + if 0 != te.get_code() + && !in_array( + PhpMixed::Int(te.get_code()), + &PhpMixed::List(vec![ + Box::new(PhpMixed::Int(500)), + Box::new(PhpMixed::Int(502)), + Box::new(PhpMixed::Int(503)), + Box::new(PhpMixed::Int(504)), + ]), + true, + ) + { + retries = 0; + } + + // special error code returned when network is being artificially disabled + if te.get_status_code() == Some(499) { + retries = 0; + urls.clear(); + } + } + + if retries > 0 { + usleep(500000); + retries -= 1; + + continue; + } + + if !urls.is_empty() { + urls.remove(0); + } + if urls.len() > 0 { + let code = e + .downcast_ref::<TransportException>() + .map_or(0, |te| te.get_code()); + if self.io.is_debug() { + self.io.write_error(&format!( + " Failed downloading {}: [{}] {}: {}", + package.get_name(), + get_class(&PhpMixed::Null), + code, + e + )); + self.io.write_error(&format!( + " Trying the next URL for {}", + package.get_name() + )); + } else { + self.io.write_error(&format!( + " Failed downloading {}, trying the next URL ({}: {})", + package.get_name(), + code, + e + )); + } + + retries = 3; + usleep(100000); + + continue; + } + + return Err(e); + } + } + } + + // === $result->then(verify) === + if !file_exists(&file_name) { + return Err(UnexpectedValueException { + message: format!( + "{} could not be saved to {}, make sure the directory is writable and you have internet connectivity", + url.base, file_name + ), + code: 0, + } + .into()); + } + + if let Some(checksum) = checksum.as_deref() { + if !checksum.is_empty() + && hash_file("sha1", &file_name).as_deref() != Some(checksum) + { + return Err(UnexpectedValueException { + message: format!( + "The checksum verification of the file failed (downloaded from {})", + url.base + ), + code: 0, + } + .into()); + } + } + + // TODO(plugin): dispatch PostFileDownloadEvent. + + return Ok(Some(PhpMixed::String(file_name))); + } } /// @inheritDoc @@ -393,70 +605,52 @@ impl ChangeReportInterface for FileDownloader { package: &dyn PackageInterface, path: &str, ) -> Result<Option<String>> { - // TODO(phase-c-promise): get_local_changes drives promises via http_downloader/process wait(); - // converting requires deciding whether ChangeReportInterface::get_local_changes becomes async. Left as-is. // TODO(phase-b): swap self.io to NullIO and restore — needs a take/swap helper let mut null_io = NullIO::new(); null_io.load_configuration(&mut *self.config.borrow_mut())?; - // TODO(phase-b): `e` is captured by both the inner closure (assignment in error handler) - // and the outer block (read after the closure). PHP closures capture by reference (`use (&$e)`); - // emulate via Rc<RefCell> or restructure when proper async/promise types land. - let e: std::cell::RefCell<Option<anyhow::Error>> = std::cell::RefCell::new(None); - let output_cell: std::cell::RefCell<String> = std::cell::RefCell::new(String::new()); let target_dir = Filesystem::trim_trailing_slash(path); - let result: Result<()> = (|| -> Result<()> { + // PHP attaches an onRejected handler to capture the error and drives the promise via + // httpDownloader->wait() / process->wait(); the single-threaded sync bridge block_on's the + // download/install futures, so a rejection surfaces directly as the Err captured below. + let result: Result<String> = (|| -> Result<String> { if is_dir(&format!("{}_compare", target_dir)) { self.filesystem .borrow_mut() .remove_directory(&format!("{}_compare", target_dir))?; } - let promise = - self.download(package, &format!("{}_compare", target_dir), None, false)?; - promise.then_with( - None, - Some(Box::new(|ex: PhpMixed| { - let _ = ex; - PhpMixed::Null - })), - ); - self.http_downloader.borrow_mut().wait()?; - if e.borrow().is_some() { - return Err(e.borrow_mut().take().unwrap()); - } - let promise = self.install(package, &format!("{}_compare", target_dir), false)?; - promise.then_with( - None, - Some(Box::new(|ex: PhpMixed| { - let _ = ex; - PhpMixed::Null - })), - ); - self.process.borrow_mut().wait()?; - if e.borrow().is_some() { - return Err(e.borrow_mut().take().unwrap()); - } + tokio::runtime::Runtime::new() + .unwrap() + .block_on(self.download( + package, + &format!("{}_compare", target_dir), + None, + false, + ))?; + tokio::runtime::Runtime::new() + .unwrap() + .block_on(self.install(package, &format!("{}_compare", target_dir), false))?; let mut comparer = Comparer::new(); comparer.set_source(format!("{}_compare", target_dir)); comparer.set_update(target_dir.clone()); comparer.do_compare(); - *output_cell.borrow_mut() = comparer.get_changed_as_string(true, false); + let output = comparer.get_changed_as_string(true, false); self.filesystem .borrow_mut() .remove_directory(&format!("{}_compare", target_dir))?; - Ok(()) + Ok(output) })(); - if let Err(err) = result { - *e.borrow_mut() = Some(err); - } - let e = e.into_inner(); - let output = output_cell.into_inner(); // TODO(phase-b): restore self.io = prev_io + let (e, output) = match result { + Ok(output) => (None, output), + Err(err) => (Some(err), String::new()), + }; + if let Some(err) = e { if self.io.is_debug() { return Err(err); @@ -499,15 +693,12 @@ impl FileDownloader { .to_string() } - pub(crate) fn clear_last_cache_write(&mut self, package: &dyn PackageInterface) { - if self.cache.is_some() && self.last_cache_writes.contains_key(package.get_name()) { - let key = self - .last_cache_writes - .get(package.get_name()) - .unwrap() - .clone(); + pub(crate) fn clear_last_cache_write(&self, package: &dyn PackageInterface) { + let mut last_cache_writes = self.last_cache_writes.lock().unwrap(); + if self.cache.is_some() && last_cache_writes.contains_key(package.get_name()) { + let key = last_cache_writes.get(package.get_name()).unwrap().clone(); self.cache.as_ref().unwrap().borrow_mut().remove(&key); - self.last_cache_writes.shift_remove(package.get_name()); + last_cache_writes.shift_remove(package.get_name()); } } diff --git a/crates/shirabe/src/downloader/fossil_downloader.rs b/crates/shirabe/src/downloader/fossil_downloader.rs index 25f3a31..1e164b8 100644 --- a/crates/shirabe/src/downloader/fossil_downloader.rs +++ b/crates/shirabe/src/downloader/fossil_downloader.rs @@ -250,6 +250,7 @@ impl FossilDownloader { // TODO(phase-b): wire up VcsDownloader trait properly. FossilDownloader extends VcsDownloader // which implements DownloaderInterface in PHP. Delegating each trait method to todo!() until the // inner VcsDownloaderBase exposes the matching impl surface. +#[async_trait::async_trait(?Send)] impl DownloaderInterface for FossilDownloader { fn get_installation_source(&self) -> String { todo!() diff --git a/crates/shirabe/src/downloader/git_downloader.rs b/crates/shirabe/src/downloader/git_downloader.rs index e43d31b..a5e3638 100644 --- a/crates/shirabe/src/downloader/git_downloader.rs +++ b/crates/shirabe/src/downloader/git_downloader.rs @@ -4,7 +4,6 @@ use crate::io::io_interface; use anyhow::Result; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; -use shirabe_external_packages::react::promise; use shirabe_php_shim::{ PhpMixed, RuntimeException, array_map, basename, dirname, implode, in_array, is_dir, preg_quote, realpath, rtrim, sprintf, strlen, strpos, substr, trim, version_compare, @@ -1359,6 +1358,7 @@ impl DvcsDownloaderInterface for GitDownloader { // TODO(phase-b): GitDownloader extends VcsDownloader which implements DownloaderInterface. // Delegating each trait method to todo!() until the inner VcsDownloaderBase exposes the // matching impl surface. +#[async_trait::async_trait(?Send)] impl crate::downloader::DownloaderInterface for GitDownloader { fn get_installation_source(&self) -> String { todo!() diff --git a/crates/shirabe/src/downloader/gzip_downloader.rs b/crates/shirabe/src/downloader/gzip_downloader.rs index d852a00..fe44fed 100644 --- a/crates/shirabe/src/downloader/gzip_downloader.rs +++ b/crates/shirabe/src/downloader/gzip_downloader.rs @@ -129,6 +129,7 @@ impl GzipDownloader { } } +#[async_trait::async_trait(?Send)] impl crate::downloader::DownloaderInterface for GzipDownloader { fn get_installation_source(&self) -> String { self.inner.get_installation_source() diff --git a/crates/shirabe/src/downloader/hg_downloader.rs b/crates/shirabe/src/downloader/hg_downloader.rs index 56f3b6f..b12e348 100644 --- a/crates/shirabe/src/downloader/hg_downloader.rs +++ b/crates/shirabe/src/downloader/hg_downloader.rs @@ -220,6 +220,7 @@ impl HgDownloader { // TODO(phase-b): wire up VcsDownloader trait properly. HgDownloader extends VcsDownloader which // implements DownloaderInterface in PHP. Delegating each trait method to todo!() until the inner // VcsDownloaderBase exposes the matching impl surface. +#[async_trait::async_trait(?Send)] impl DownloaderInterface for HgDownloader { fn get_installation_source(&self) -> String { todo!() diff --git a/crates/shirabe/src/downloader/path_downloader.rs b/crates/shirabe/src/downloader/path_downloader.rs index f677a50..46090ac 100644 --- a/crates/shirabe/src/downloader/path_downloader.rs +++ b/crates/shirabe/src/downloader/path_downloader.rs @@ -537,6 +537,7 @@ impl VcsCapableDownloaderInterface for PathDownloader { // overrides download/install/remove with &mut self signatures that diverge from the trait. The // trait methods here delegate to the inner FileDownloader; the bespoke overrides on the struct // itself are not yet routed through the trait. +#[async_trait::async_trait(?Send)] impl DownloaderInterface for PathDownloader { fn get_installation_source(&self) -> String { self.inner.get_installation_source() diff --git a/crates/shirabe/src/downloader/perforce_downloader.rs b/crates/shirabe/src/downloader/perforce_downloader.rs index fdb0f0d..b3f7bc9 100644 --- a/crates/shirabe/src/downloader/perforce_downloader.rs +++ b/crates/shirabe/src/downloader/perforce_downloader.rs @@ -159,6 +159,7 @@ impl PerforceDownloader { // TODO(phase-b): wire up VcsDownloader trait properly. PerforceDownloader extends VcsDownloader // which implements DownloaderInterface in PHP. Delegating each trait method to todo!() until the // inner VcsDownloaderBase exposes the matching impl surface. +#[async_trait::async_trait(?Send)] impl DownloaderInterface for PerforceDownloader { fn get_installation_source(&self) -> String { todo!() diff --git a/crates/shirabe/src/downloader/phar_downloader.rs b/crates/shirabe/src/downloader/phar_downloader.rs index 9cfb18f..a598437 100644 --- a/crates/shirabe/src/downloader/phar_downloader.rs +++ b/crates/shirabe/src/downloader/phar_downloader.rs @@ -63,6 +63,7 @@ impl PharDownloader { } } +#[async_trait::async_trait(?Send)] impl DownloaderInterface for PharDownloader { fn get_installation_source(&self) -> String { self.inner.get_installation_source() diff --git a/crates/shirabe/src/downloader/rar_downloader.rs b/crates/shirabe/src/downloader/rar_downloader.rs index c6031d9..f482582 100644 --- a/crates/shirabe/src/downloader/rar_downloader.rs +++ b/crates/shirabe/src/downloader/rar_downloader.rs @@ -143,6 +143,7 @@ impl RarDownloader { } } +#[async_trait::async_trait(?Send)] impl crate::downloader::DownloaderInterface for RarDownloader { fn get_installation_source(&self) -> String { self.inner.get_installation_source() diff --git a/crates/shirabe/src/downloader/svn_downloader.rs b/crates/shirabe/src/downloader/svn_downloader.rs index f3d0fef..162e91c 100644 --- a/crates/shirabe/src/downloader/svn_downloader.rs +++ b/crates/shirabe/src/downloader/svn_downloader.rs @@ -3,7 +3,6 @@ use crate::io::io_interface; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; -use shirabe_external_packages::react::promise; use shirabe_php_shim::{PhpMixed, RuntimeException, is_dir, version_compare}; use crate::config::Config; @@ -432,6 +431,7 @@ impl SvnDownloader { // TODO(phase-b): wire up VcsDownloader trait properly. SvnDownloader extends VcsDownloader which // implements DownloaderInterface in PHP. Delegating each trait method to todo!() until the inner // VcsDownloaderBase exposes the matching impl surface. +#[async_trait::async_trait(?Send)] impl DownloaderInterface for SvnDownloader { fn get_installation_source(&self) -> String { todo!() diff --git a/crates/shirabe/src/downloader/tar_downloader.rs b/crates/shirabe/src/downloader/tar_downloader.rs index c720ccf..09b6f5a 100644 --- a/crates/shirabe/src/downloader/tar_downloader.rs +++ b/crates/shirabe/src/downloader/tar_downloader.rs @@ -58,6 +58,7 @@ impl TarDownloader { } } +#[async_trait::async_trait(?Send)] impl DownloaderInterface for TarDownloader { fn get_installation_source(&self) -> String { self.inner.get_installation_source() diff --git a/crates/shirabe/src/downloader/xz_downloader.rs b/crates/shirabe/src/downloader/xz_downloader.rs index d15287d..ca867df 100644 --- a/crates/shirabe/src/downloader/xz_downloader.rs +++ b/crates/shirabe/src/downloader/xz_downloader.rs @@ -77,6 +77,7 @@ impl XzDownloader { } } +#[async_trait::async_trait(?Send)] impl crate::downloader::DownloaderInterface for XzDownloader { fn get_installation_source(&self) -> String { self.inner.get_installation_source() diff --git a/crates/shirabe/src/downloader/zip_downloader.rs b/crates/shirabe/src/downloader/zip_downloader.rs index 88b3d3a..eb057e0 100644 --- a/crates/shirabe/src/downloader/zip_downloader.rs +++ b/crates/shirabe/src/downloader/zip_downloader.rs @@ -14,7 +14,8 @@ 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, version_compare, + hash_file, is_file, json_encode, random_int, str_contains, str_replace, strlen, substr, + version_compare, }; use std::sync::Mutex; @@ -270,27 +271,153 @@ impl ZipDownloader { } } - // TODO(phase-c-promise): execute_async + .then fallback closure captures &mut self/io; - // recursive promise flattening, not a mechanical await chain. - // TODO(phase-b): full try_fallback closure deferred — PHP captures `$io`, `$self` - // and several locals by reference, conflicting with Rust's borrow checker because - // `extract_with_zip_archive` later needs `&mut self`. Restructure once the - // promise/closure plumbing supports that shape. - let _ = ( - is_last_chance, - file, - path, - executable, - package, - &command, - &self.inner.io, - ); - match self.inner.process.borrow_mut().execute_async(&command, ()) { - Ok(_promise) => todo!("phase-b: chain promise.then with fallback closure"), - Err(_e) => todo!("phase-b: pipe execute_async error into try_fallback"), + 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: &dyn PackageInterface, + executable: &str, + ) -> Result<Option<PhpMixed>> { + 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!(" <warning>{}</warning>", process_error)); + self.inner.io.write_error(" <warning>This most likely is due to a custom installer plugin not handling the returned Promise from the downloader</warning>"); + self.inner.io.write_error(" <warning>See https://github.com/composer/installers/commit/5006d0c28730ade233a8f42ec31ac68fb1c5c9bb for an example fix</warning>"); + } else { + self.inner + .io + .write_error(&format!(" <warning>{}</warning>", 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(" <warning>Additional debug info, please report to https://github.com/composer/composer/issues/11148 if you see this:</warning>"); + 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("")) > 0 { + self.inner.io.write_error(&format!( + "Origin URL: {}", + self.inner + .process_url(package, package.get_dist_url().unwrap_or(""))? + )); + 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: &dyn PackageInterface, @@ -425,6 +552,7 @@ impl ZipDownloader { // 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() |
