aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/shirabe/src/downloader/file_downloader.rs548
1 files changed, 548 insertions, 0 deletions
diff --git a/crates/shirabe/src/downloader/file_downloader.rs b/crates/shirabe/src/downloader/file_downloader.rs
index 29dbe03..7ddc60a 100644
--- a/crates/shirabe/src/downloader/file_downloader.rs
+++ b/crates/shirabe/src/downloader/file_downloader.rs
@@ -1 +1,549 @@
//! ref: composer/src/Composer/Downloader/FileDownloader.php
+
+use anyhow::Result;
+use indexmap::IndexMap;
+use std::sync::{LazyLock, Mutex};
+
+use shirabe_external_packages::react::promise::promise_interface::PromiseInterface;
+use shirabe_external_packages::react::promise::resolve as react_promise_resolve;
+use shirabe_php_shim::{
+ array_search, array_shift, file_exists, filesize, get_class, hash, hash_file, in_array,
+ is_dir, is_executable, parse_url, pathinfo, realpath, rtrim, spl_object_hash, strlen, strpos,
+ strtr, trim, umask, usleep, InvalidArgumentException, PhpMixed, RuntimeException, Silencer,
+ UnexpectedValueException, DIRECTORY_SEPARATOR, PATHINFO_BASENAME, PATHINFO_EXTENSION,
+ PHP_URL_PATH,
+};
+
+use crate::cache::Cache;
+use crate::config::Config;
+use crate::dependency_resolver::operation::install_operation::InstallOperation;
+use crate::dependency_resolver::operation::uninstall_operation::UninstallOperation;
+use crate::dependency_resolver::operation::update_operation::UpdateOperation;
+use crate::downloader::change_report_interface::ChangeReportInterface;
+use crate::downloader::downloader_interface::DownloaderInterface;
+use crate::downloader::max_file_size_exceeded_exception::MaxFileSizeExceededException;
+use crate::downloader::transport_exception::TransportException;
+use crate::event_dispatcher::event_dispatcher::EventDispatcher;
+use crate::exception::irrecoverable_download_exception::IrrecoverableDownloadException;
+use crate::io::io_interface::IOInterface;
+use crate::io::null_io::NullIO;
+use crate::package::comparer::comparer::Comparer;
+use crate::package::package_interface::PackageInterface;
+use crate::plugin::plugin_events::PluginEvents;
+use crate::plugin::post_file_download_event::PostFileDownloadEvent;
+use crate::plugin::pre_file_download_event::PreFileDownloadEvent;
+use crate::util::filesystem::Filesystem;
+use crate::util::http_downloader::HttpDownloader;
+use crate::util::platform::Platform;
+use crate::util::process_executor::ProcessExecutor;
+use crate::util::url::Url as UrlUtil;
+
+/// @var array<string, int|string>
+/// @private
+/// @internal
+pub static DOWNLOAD_METADATA: LazyLock<Mutex<IndexMap<String, PhpMixed>>> =
+ LazyLock::new(|| Mutex::new(IndexMap::new()));
+
+/// Collects response headers when running on GH Actions
+///
+/// @var array<string, array<string>>
+/// @private
+/// @internal
+pub static RESPONSE_HEADERS: LazyLock<Mutex<IndexMap<String, IndexMap<String, Vec<String>>>>> =
+ LazyLock::new(|| Mutex::new(IndexMap::new()));
+
+/// Base downloader for files
+#[derive(Debug)]
+pub struct FileDownloader {
+ /// @var IOInterface
+ pub(crate) io: Box<dyn IOInterface>,
+ /// @var Config
+ pub(crate) config: Config,
+ /// @var HttpDownloader
+ pub(crate) http_downloader: HttpDownloader,
+ /// @var Filesystem
+ pub(crate) filesystem: Filesystem,
+ /// @var ?Cache
+ pub(crate) cache: Option<Cache>,
+ /// @var ?EventDispatcher
+ pub(crate) event_dispatcher: Option<EventDispatcher>,
+ /// @var ProcessExecutor
+ pub(crate) process: ProcessExecutor,
+ /// @var array<string, string> Map of package name to cache key
+ last_cache_writes: IndexMap<String, String>,
+ /// @var array<string, string[]> Map of package name to list of paths
+ additional_cleanup_paths: IndexMap<String, Vec<String>>,
+}
+
+impl FileDownloader {
+ /// Constructor.
+ pub fn new(
+ io: Box<dyn IOInterface>,
+ config: Config,
+ http_downloader: HttpDownloader,
+ event_dispatcher: Option<EventDispatcher>,
+ cache: Option<Cache>,
+ filesystem: Option<Filesystem>,
+ process: Option<ProcessExecutor>,
+ ) -> Self {
+ let process = process.unwrap_or_else(|| ProcessExecutor::new(Some(Box::new(&*io)), None));
+ let filesystem = filesystem.unwrap_or_else(|| Filesystem::new(Some(process.clone())));
+
+ let mut this = Self {
+ io,
+ config,
+ http_downloader,
+ event_dispatcher,
+ cache,
+ process,
+ filesystem,
+ last_cache_writes: IndexMap::new(),
+ additional_cleanup_paths: IndexMap::new(),
+ };
+
+ if this.cache.is_some() && this.cache.as_ref().unwrap().gc_is_necessary() {
+ // PHP: writeError('Running cache garbage collection', true, IOInterface::VERY_VERBOSE)
+ this.io.write_error("Running cache garbage collection");
+ this.cache.as_mut().unwrap().gc(
+ this.config.get("cache-files-ttl").as_int().unwrap_or(0),
+ this.config
+ .get("cache-files-maxsize")
+ .as_int()
+ .unwrap_or(0),
+ );
+ }
+
+ this
+ }
+}
+
+impl DownloaderInterface for FileDownloader {
+ /// @inheritDoc
+ fn get_installation_source(&self) -> &str {
+ "dist"
+ }
+
+ /// @inheritDoc
+ fn download(
+ &mut self,
+ package: &dyn PackageInterface,
+ path: &str,
+ _prev_package: Option<&dyn PackageInterface>,
+ output: bool,
+ ) -> Result<Box<dyn PromiseInterface>> {
+ if package.get_dist_url().is_none() {
+ return Err(InvalidArgumentException {
+ message: "The given package is missing url information".to_string(),
+ code: 0,
+ }
+ .into());
+ }
+
+ let cache_key_generator = |package: &dyn PackageInterface, key: &str| -> String {
+ let cache_key = hash("sha1", key);
+
+ format!(
+ "{}/{}.{}",
+ package.get_name(),
+ cache_key,
+ package.get_dist_type().unwrap_or("")
+ )
+ };
+
+ let mut retries: i64 = 3;
+ let dist_urls = package.get_dist_urls();
+ // @var array<array{base: non-empty-string, processed: non-empty-string, cacheKey: string}> $urls
+ let mut urls: Vec<UrlEntry> = vec![];
+ for url in dist_urls {
+ let processed_url = self.process_url(package, &url)?;
+ let cache_key = cache_key_generator(package, &processed_url);
+ urls.push(UrlEntry {
+ base: url,
+ processed: processed_url,
+ // we use the complete download url here to avoid conflicting entries
+ // from different packages, which would potentially allow a given package
+ // in a third party repo to pre-populate the cache for the same package in
+ // packagist for example.
+ cache_key,
+ });
+ }
+ debug_assert!(urls.len() > 0);
+
+ let file_name = self.get_file_name(package, path);
+ self.filesystem.ensure_directory_exists(path)?;
+ let dir_of_file = shirabe_php_shim::dirname(&file_name, 1);
+ self.filesystem.ensure_directory_exists(&dir_of_file)?;
+
+ // 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;
+
+ todo!("phase-b: orchestrate download/accept/reject closures and call download() returning a PromiseInterface")
+ }
+
+ /// @inheritDoc
+ fn prepare(
+ &mut self,
+ _r#type: &str,
+ _package: &dyn PackageInterface,
+ _path: &str,
+ _prev_package: Option<&dyn PackageInterface>,
+ ) -> Result<Box<dyn PromiseInterface>> {
+ Ok(react_promise_resolve(PhpMixed::Null))
+ }
+
+ /// @inheritDoc
+ fn cleanup(
+ &mut self,
+ _r#type: &str,
+ package: &dyn PackageInterface,
+ path: &str,
+ _prev_package: Option<&dyn PackageInterface>,
+ ) -> Result<Box<dyn PromiseInterface>> {
+ let file_name = self.get_file_name(package, path);
+ if file_exists(&file_name) {
+ self.filesystem.unlink(&file_name)?;
+ }
+
+ let vendor_dir = self
+ .config
+ .get("vendor-dir")
+ .as_string()
+ .unwrap_or("")
+ .to_string();
+ let first_segment = package
+ .get_pretty_name()
+ .split('/')
+ .next()
+ .unwrap_or("")
+ .to_string();
+ let dirs_to_clean_up: Vec<String> = vec![
+ path.to_string(),
+ format!("{}/{}", vendor_dir, first_segment),
+ format!("{}/composer/", vendor_dir),
+ vendor_dir.clone(),
+ ];
+
+ if let Some(paths) = self.additional_cleanup_paths.get(package.get_name()).cloned() {
+ for path_to_clean in &paths {
+ self.filesystem.remove(path_to_clean)?;
+ }
+ }
+
+ for dir in &dirs_to_clean_up {
+ if is_dir(dir)
+ && self.filesystem.is_dir_empty(dir)?
+ && realpath(dir).as_deref() != Some(&Platform::get_cwd())
+ {
+ self.filesystem.remove_directory_php(dir)?;
+ }
+ }
+
+ Ok(react_promise_resolve(PhpMixed::Null))
+ }
+
+ /// @inheritDoc
+ fn install(
+ &mut self,
+ package: &dyn PackageInterface,
+ path: &str,
+ output: bool,
+ ) -> Result<Box<dyn PromiseInterface>> {
+ if output {
+ self.io
+ .write_error(&format!(" - {}", InstallOperation::format(package)));
+ }
+
+ let vendor_dir = self
+ .config
+ .get("vendor-dir")
+ .as_string()
+ .unwrap_or("")
+ .to_string();
+
+ // clean up the target directory, unless it contains the vendor dir, as the vendor dir contains
+ // the file to be installed. This is the case when installing with create-project in the current directory
+ // but in that case we ensure the directory is empty already in ProjectInstaller so no need to empty it here.
+ if false == {
+ let normalized_vendor = self.filesystem.normalize_path(&vendor_dir);
+ let normalized_path = self
+ .filesystem
+ .normalize_path(&format!("{}{}", path, DIRECTORY_SEPARATOR));
+ strpos(&normalized_vendor, &normalized_path).is_some()
+ } {
+ self.filesystem.empty_directory(path)?;
+ }
+ self.filesystem.ensure_directory_exists(path)?;
+ self.filesystem.rename(
+ &self.get_file_name(package, path),
+ &format!(
+ "{}/{}",
+ path,
+ self.get_dist_path(package, PATHINFO_BASENAME)
+ ),
+ )?;
+
+ // Single files can not have a mode set like files in archives
+ // so we make sure if the file is a binary that it is executable
+ for bin in package.get_binaries() {
+ let bin_path = format!("{}/{}", path, bin);
+ if file_exists(&bin_path) && !is_executable(&bin_path) {
+ Silencer::call_named(
+ "chmod",
+ &[
+ PhpMixed::String(bin_path),
+ PhpMixed::Int((0o777 & !umask()) as i64),
+ ],
+ );
+ }
+ }
+
+ Ok(react_promise_resolve(PhpMixed::Null))
+ }
+
+ /// @inheritDoc
+ fn update(
+ &mut self,
+ initial: &dyn PackageInterface,
+ target: &dyn PackageInterface,
+ path: &str,
+ ) -> Result<Box<dyn PromiseInterface>> {
+ self.io.write_error(&format!(
+ " - {}{}",
+ UpdateOperation::format(initial, target),
+ self.get_install_operation_appendix(target, path)
+ ));
+
+ let _promise = self.remove(initial, path, false)?;
+ // TODO(phase-b): chain `.then(|| self.install(target, path, false))`
+ let _ = (initial, target, path);
+ todo!("phase-b: chain promise.then(|| self.install(target, path, false))")
+ }
+
+ /// @inheritDoc
+ fn remove(
+ &mut self,
+ package: &dyn PackageInterface,
+ path: &str,
+ output: bool,
+ ) -> Result<Box<dyn PromiseInterface>> {
+ if output {
+ self.io
+ .write_error(&format!(" - {}", UninstallOperation::format(package)));
+ }
+ let _promise = self.filesystem.remove_directory_async(path)?;
+
+ // TODO(phase-b): chain `.then(|result| if !result { throw RuntimeException })`
+ let _ = path;
+ todo!(
+ "phase-b: chain promise.then(|result| {{ if !result {{ throw RuntimeException }} }})"
+ )
+ }
+}
+
+impl ChangeReportInterface for FileDownloader {
+ /// @inheritDoc
+ /// @throws \RuntimeException
+ fn get_local_changes(
+ &mut self,
+ package: &dyn PackageInterface,
+ path: &str,
+ ) -> Result<Option<String>> {
+ // 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(&self.config);
+ let mut e: Option<anyhow::Error> = None;
+ let mut output: String = String::new();
+
+ let target_dir = Filesystem::trim_trailing_slash(path);
+ let result: Result<()> = (|| -> Result<()> {
+ if is_dir(&format!("{}_compare", target_dir)) {
+ self.filesystem
+ .remove_directory(&format!("{}_compare", target_dir), false)?;
+ }
+
+ 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.wait()?;
+ if e.is_some() {
+ return Err(e.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.wait()?;
+ if e.is_some() {
+ return Err(e.unwrap());
+ }
+
+ let mut comparer = Comparer::new();
+ comparer.set_source(&format!("{}_compare", target_dir));
+ comparer.set_update(&target_dir);
+ comparer.do_compare();
+ output = comparer.get_changed_as_string(true);
+ self.filesystem
+ .remove_directory(&format!("{}_compare", target_dir), false)?;
+ Ok(())
+ })();
+ if let Err(err) = result {
+ e = Some(err);
+ }
+
+ // TODO(phase-b): restore self.io = prev_io
+
+ if let Some(err) = e {
+ if self.io.is_debug() {
+ return Err(err);
+ }
+
+ return Ok(Some(format!(
+ "Failed to detect changes: [{}] {}",
+ get_class(&PhpMixed::Null),
+ err
+ )));
+ }
+
+ let output = trim(&output, None);
+
+ Ok(if strlen(&output) > 0 { Some(output) } else { None })
+ }
+}
+
+impl FileDownloader {
+ /// @param PATHINFO_EXTENSION|PATHINFO_BASENAME $component
+ fn get_dist_path(&self, package: &dyn PackageInterface, component: i64) -> String {
+ pathinfo(
+ PhpMixed::String(
+ parse_url(
+ &strtr(package.get_dist_url().unwrap_or(""), "\\", "/"),
+ PHP_URL_PATH,
+ )
+ .as_string()
+ .unwrap_or("")
+ .to_string(),
+ ),
+ component,
+ )
+ .as_string()
+ .unwrap_or("")
+ .to_string()
+ }
+
+ fn clear_last_cache_write(&mut self, package: &dyn PackageInterface) {
+ if self.cache.is_some() && self.last_cache_writes.contains_key(package.get_name()) {
+ self.cache
+ .as_ref()
+ .unwrap()
+ .remove(self.last_cache_writes.get(package.get_name()).unwrap());
+ self.last_cache_writes.shift_remove(package.get_name());
+ }
+ }
+
+ fn add_cleanup_path(&mut self, package: &dyn PackageInterface, path: &str) {
+ self.additional_cleanup_paths
+ .entry(package.get_name().to_string())
+ .or_insert_with(Vec::new)
+ .push(path.to_string());
+ }
+
+ fn remove_cleanup_path(&mut self, package: &dyn PackageInterface, path: &str) {
+ if let Some(paths) = self.additional_cleanup_paths.get_mut(package.get_name()) {
+ // PHP: array_search($path, ..., true)
+ let idx = paths.iter().position(|p| p == path);
+ if let Some(i) = idx {
+ paths.remove(i);
+ }
+ let _ = array_search;
+ }
+ }
+
+ /// Gets file name for specific package
+ fn get_file_name(&self, package: &dyn PackageInterface, _path: &str) -> String {
+ let extension = self.get_dist_path(package, PATHINFO_EXTENSION);
+ let extension = if extension.is_empty() {
+ package.get_dist_type().unwrap_or("").to_string()
+ } else {
+ extension
+ };
+
+ rtrim(
+ &format!(
+ "{}/composer/tmp-{}.{}",
+ self.config.get("vendor-dir").as_string().unwrap_or(""),
+ hash(
+ "md5",
+ &format!("{}{}", package, spl_object_hash(&PhpMixed::Null))
+ ),
+ extension
+ ),
+ Some("."),
+ )
+ }
+
+ /// Gets appendix message to add to the "- Upgrading x" string being output on update
+ fn get_install_operation_appendix(
+ &self,
+ _package: &dyn PackageInterface,
+ _path: &str,
+ ) -> String {
+ String::new()
+ }
+
+ /// Process the download url
+ fn process_url(&self, package: &dyn PackageInterface, url: &str) -> Result<String> {
+ if !shirabe_php_shim::extension_loaded("openssl") && Some(0) == strpos(url, "https:") {
+ return Err(RuntimeException {
+ message: "You must enable the openssl extension to download files via https"
+ .to_string(),
+ code: 0,
+ }
+ .into());
+ }
+
+ let mut url = url.to_string();
+ if package.get_dist_reference().is_some() {
+ url = UrlUtil::update_dist_reference(
+ &self.config,
+ &url,
+ package.get_dist_reference().unwrap(),
+ )?;
+ }
+
+ Ok(url)
+ }
+}
+
+#[derive(Debug, Clone)]
+struct UrlEntry {
+ base: String,
+ processed: String,
+ cache_key: String,
+}
+
+// Suppress unused-import warnings for items kept for parity with the PHP source.
+#[allow(dead_code)]
+const _USE_PARITY: () = {
+ let _ = filesize;
+ let _ = hash_file;
+ let _ = in_array;
+ let _ = usleep;
+ let _ = array_shift::<u8>;
+ let _ = UnexpectedValueException {
+ message: String::new(),
+ code: 0,
+ };
+};