diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-16 20:41:48 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-16 22:17:14 +0900 |
| commit | f4fc58772b350cbb97ab180cae47b940c18492e5 (patch) | |
| tree | 8869bc02e1115c51c075c27587aeba3d7ee5b530 /crates/shirabe/src/command | |
| parent | cb8b05e33db8518f46ea19ee43bc964b2eddd9b8 (diff) | |
| download | php-shirabe-f4fc58772b350cbb97ab180cae47b940c18492e5.tar.gz php-shirabe-f4fc58772b350cbb97ab180cae47b940c18492e5.tar.zst php-shirabe-f4fc58772b350cbb97ab180cae47b940c18492e5.zip | |
feat(port): port DiagnoseCommand.php
Diffstat (limited to 'crates/shirabe/src/command')
| -rw-r--r-- | crates/shirabe/src/command/diagnose_command.rs | 1138 |
1 files changed, 1138 insertions, 0 deletions
diff --git a/crates/shirabe/src/command/diagnose_command.rs b/crates/shirabe/src/command/diagnose_command.rs index 2760da5..664b242 100644 --- a/crates/shirabe/src/command/diagnose_command.rs +++ b/crates/shirabe/src/command/diagnose_command.rs @@ -1 +1,1139 @@ //! ref: composer/src/Composer/Command/DiagnoseCommand.php + +use indexmap::IndexMap; + +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_external_packages::composer::xdebug_handler::xdebug_handler::XdebugHandler; +use shirabe_external_packages::symfony::component::console::input::input_interface::InputInterface; +use shirabe_external_packages::symfony::component::console::output::output_interface::OutputInterface; +use shirabe_external_packages::symfony::component::process::executable_finder::ExecutableFinder; +use shirabe_php_shim::{ + count, curl_version, defined, disk_free_space, extension_loaded, file_exists, filter_var, + function_exists, get_class, hash, implode, ini_get, ioncube_loader_iversion, + ioncube_loader_version, is_array, is_string, key, max_i64, ob_get_clean, ob_start, phpinfo, + reset, rtrim, sprintf, str_contains, str_replace, str_starts_with, strpos, strstr, strtolower, + trim, version_compare, FILTER_VALIDATE_BOOLEAN, INFO_GENERAL, + InvalidArgumentException, PhpMixed, RuntimeException, + OPENSSL_VERSION_NUMBER, OPENSSL_VERSION_TEXT, PHP_BINARY, PHP_EOL, PHP_VERSION, + PHP_VERSION_ID, PHP_WINDOWS_VERSION_BUILD, + CURL_HTTP_VERSION_2_0, CURL_VERSION_HTTP2, CURL_VERSION_HTTP3, CURL_VERSION_ZSTD, +}; + +use crate::advisory::auditor::Auditor; +use crate::command::base_command::BaseCommand; +use crate::composer::Composer; +use crate::config::Config; +use crate::downloader::transport_exception::TransportException; +use crate::factory::Factory; +use crate::io::buffer_io::BufferIO; +use crate::io::null_io::NullIO; +use crate::json::json_file::JsonFile; +use crate::json::json_validation_exception::JsonValidationException; +use crate::package::complete_package_interface::CompletePackageInterface; +use crate::package::locker::Locker; +use crate::package::root_package::RootPackage; +use crate::package::version::version_parser::VersionParser; +use crate::plugin::command_event::CommandEvent; +use crate::plugin::plugin_events::PluginEvents; +use crate::repository::composer_repository::ComposerRepository; +use crate::repository::filesystem_repository::FilesystemRepository; +use crate::repository::platform_repository::PlatformRepository; +use crate::repository::repository_set::RepositorySet; +use crate::self_update::keys::Keys; +use crate::self_update::versions::Versions; +use crate::util::config_validator::ConfigValidator; +use crate::util::git::Git; +use crate::util::http::proxy_manager::ProxyManager; +use crate::util::http::request_proxy::RequestProxy; +use crate::util::http_downloader::HttpDownloader; +use crate::util::ini_helper::IniHelper; +use crate::util::platform::Platform; +use crate::util::process_executor::ProcessExecutor; + +#[derive(Debug)] +pub struct DiagnoseCommand { + inner: BaseCommand, + pub(crate) http_downloader: Option<HttpDownloader>, + pub(crate) process: Option<ProcessExecutor>, + pub(crate) exit_code: i64, +} + +impl DiagnoseCommand { + pub(crate) fn configure(&mut self) { + self.inner + .set_name("diagnose") + .set_description("Diagnoses the system to identify common errors") + .set_help( + "The <info>diagnose</info> command checks common errors to help debugging problems.\n\n\ + The process exit code will be 1 in case of warnings and 2 for errors.\n\n\ + Read more at https://getcomposer.org/doc/03-cli.md#diagnose", + ); + } + + pub(crate) fn execute(&mut self, input: &dyn InputInterface, output: &dyn OutputInterface) -> anyhow::Result<i64> { + let composer = self.inner.try_composer(); + let io = self.inner.get_io(); + + let config: Config; + if let Some(ref c) = composer { + config = c.get_config().clone(); + + let command_event = CommandEvent::new(PluginEvents::COMMAND, "diagnose", input, output, vec![], IndexMap::new()); + c.get_event_dispatcher().dispatch(command_event.get_name(), &command_event); + self.process = Some( + c.get_loop() + .get_process_executor() + .unwrap_or_else(|| ProcessExecutor::new(Some(io.clone_box()))), + ); + } else { + config = Factory::create_config(None)?; + + self.process = Some(ProcessExecutor::new(Some(io.clone_box()))); + } + + let mut secure_http_wrap: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + let mut config_inner: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + config_inner.insert("secure-http".to_string(), Box::new(PhpMixed::Bool(false))); + secure_http_wrap.insert("config".to_string(), Box::new(PhpMixed::Array(config_inner))); + let mut config = config; + config.merge(PhpMixed::Array(secure_http_wrap), Config::SOURCE_COMMAND); + config.prohibit_url_by_config("http://repo.packagist.org", &NullIO::new()); + + self.http_downloader = Some(Factory::create_http_downloader(io, &config)?); + + if strpos(file!(), "phar:") == Some(0) { + io.write_no_newline("Checking pubkeys: "); + let r = self.check_pub_keys(&config); + self.output_result(r); + + io.write_no_newline("Checking Composer version: "); + let r = self.check_version(&config)?; + self.output_result(r); + } + + io.write(&format!("Composer version: <comment>{}</comment>", Composer::get_version())); + + io.write_no_newline("Checking Composer and its dependencies for vulnerabilities: "); + let r = self.check_composer_audit(&config)?; + self.output_result(r); + + let platform_overrides = config.get("platform").as_array().cloned().unwrap_or_default(); + let platform_repo = PlatformRepository::new(vec![], platform_overrides); + let php_pkg = platform_repo.find_package("php", "*").unwrap(); + let mut php_version = php_pkg.get_pretty_version().to_string(); + if let Some(cp) = php_pkg.as_complete_package_interface() { + if str_contains(&cp.get_description().unwrap_or_default(), "overridden") { + php_version = format!("{} - {}", php_version, cp.get_description().unwrap_or_default()); + } + } + + io.write(&format!("PHP version: <comment>{}</comment>", php_version)); + + if defined("PHP_BINARY") { + io.write(&format!("PHP binary path: <comment>{}</comment>", PHP_BINARY)); + } + + io.write(&format!( + "OpenSSL version: {}", + if defined("OPENSSL_VERSION_TEXT") { + format!("<comment>{}</comment>", OPENSSL_VERSION_TEXT) + } else { + "<error>missing</error>".to_string() + } + )); + io.write(&format!("curl version: {}", self.get_curl_version())); + + let finder = ExecutableFinder::new(); + let has_system_unzip = finder.find("unzip", None, vec![]).is_some(); + let mut bin_7zip = String::new(); + let has_system_7zip = if finder + .find("7z", None, vec!["C:\\Program Files\\7-Zip".to_string()]) + .is_some() + { + bin_7zip = "7z".to_string(); + true + } else if !Platform::is_windows() && finder.find("7zz", None, vec![]).is_some() { + bin_7zip = "7zz".to_string(); + true + } else if !Platform::is_windows() && finder.find("7za", None, vec![]).is_some() { + bin_7zip = "7za".to_string(); + true + } else { + false + }; + + io.write(&format!( + "zip: {}, {}, {}{}", + if extension_loaded("zip") { + "<comment>extension present</comment>" + } else { + "<comment>extension not loaded</comment>" + }, + if has_system_unzip { + "<comment>unzip present</comment>".to_string() + } else { + "<comment>unzip not available</comment>".to_string() + }, + if has_system_7zip { + format!("<comment>7-Zip present ({})</comment>", bin_7zip) + } else { + "<comment>7-Zip not available</comment>".to_string() + }, + if (has_system_7zip || has_system_unzip) && !function_exists("proc_open") { + ", <warning>proc_open is disabled or not present, unzip/7-z will not be usable</warning>" + } else { + "" + } + )); + + if let Some(ref c) = composer { + io.write(&format!( + "Active plugins: {}", + implode(", ", &c.get_plugin_manager().get_registered_plugins()) + )); + + io.write_no_newline("Checking composer.json: "); + let r = self.check_composer_schema()?; + self.output_result(r); + + if c.get_locker().is_locked() { + io.write_no_newline("Checking composer.lock: "); + let r = self.check_composer_lock_schema(c.get_locker())?; + self.output_result(r); + } + } + + io.write_no_newline("Checking platform settings: "); + let r = self.check_platform()?; + self.output_result(r); + + io.write_no_newline("Checking git settings: "); + let r = self.check_git(); + self.output_result(PhpMixed::String(r)); + + io.write_no_newline("Checking http connectivity to packagist: "); + let r = self.check_http("http", &config)?; + self.output_result(r); + + io.write_no_newline("Checking https connectivity to packagist: "); + let r = self.check_http("https", &config)?; + self.output_result(r); + + for repo in config.get_repositories() { + let repo_arr = repo.as_array().cloned().unwrap_or_default(); + if repo_arr.get("type").and_then(|v| v.as_string()) == Some("composer") + && repo_arr.get("url").is_some() + { + let composer_repo = ComposerRepository::new( + PhpMixed::Array(repo_arr.clone()), + self.inner.get_io().clone_box(), + config.clone(), + self.http_downloader.clone().unwrap(), + ); + // PHP: ReflectionMethod($composerRepo, 'getPackagesJsonUrl') + // We surface the same internal call by directly invoking the equivalent method. + // TODO(plugin): support reflection-based access if plugin code requires it. + let url = composer_repo.get_packages_json_url(); + if !str_starts_with(&url, "http") { + continue; + } + if str_starts_with(&url, "https://repo.packagist.org") { + continue; + } + io.write_no_newline(&format!( + "Checking connectivity to {}: ", + repo_arr.get("url").and_then(|v| v.as_string()).unwrap_or("") + )); + let r = self.check_composer_repo(&url, &config)?; + self.output_result(r); + } + } + + let proxy_manager = ProxyManager::get_instance(); + let protos: Vec<&str> = if config.get("disable-tls").as_bool() == Some(true) { + vec!["http"] + } else { + vec!["http", "https"] + }; + let proxy_check_result: Result<(), anyhow::Error> = (|| -> anyhow::Result<()> { + for proto in &protos { + let proxy = proxy_manager.get_proxy_for_request(&format!("{}://repo.packagist.org", proto)); + if !proxy.get_status().is_empty() { + let r#type = if proxy.is_secure() { "HTTPS" } else { "HTTP" }; + io.write_no_newline(&format!("Checking {} proxy with {}: ", r#type, proto)); + let r = self.check_http_proxy(&proxy, proto)?; + self.output_result(r); + } + } + Ok(()) + })(); + if let Err(e) = proxy_check_result { + if let Some(_te) = e.downcast_ref::<TransportException>() { + io.write_no_newline("Checking HTTP proxy: "); + let status = self.check_connectivity_and_composer_network_http_enablement(); + self.output_result(if is_string(&status) { + status + } else { + PhpMixed::String(format!("<error>[{}] {}</error>", get_class(&e), e.to_string())) + }); + } else { + return Err(e); + } + } + + let oauth = config.get("github-oauth").as_array().cloned().unwrap_or_default(); + if count(&oauth) > 0 { + for (domain, token) in &oauth { + io.write_no_newline(&format!("Checking {} oauth access: ", domain)); + let r = self.check_github_oauth(domain, token.as_string().unwrap_or(""))?; + self.output_result(r); + } + } else { + io.write_no_newline("Checking github.com rate limit: "); + match self.get_github_rate_limit("github.com", None) { + Ok(rate) => { + if !is_array(&rate) { + self.output_result(rate); + } else if let Some(arr) = rate.as_array() { + let remaining = arr.get("remaining").and_then(|v| v.as_int()).unwrap_or(0); + let limit = arr.get("limit").and_then(|v| v.as_int()).unwrap_or(0); + if 10 > remaining { + io.write("<warning>WARNING</warning>"); + io.write(&sprintf( + "<comment>GitHub has a rate limit on their API. You currently have <options=bold>%u</options=bold> out of <options=bold>%u</options=bold> requests left.\nSee https://developer.github.com/v3/#rate-limiting and also\n https://getcomposer.org/doc/articles/troubleshooting.md#api-rate-limit-and-oauth-tokens</comment>", + &[remaining.into(), limit.into()], + )); + } else { + self.output_result(PhpMixed::Bool(true)); + } + } + } + Err(e) => { + if let Some(te) = e.downcast_ref::<TransportException>() { + if te.get_code() == 401 { + self.output_result(PhpMixed::String("<comment>The oauth token for github.com seems invalid, run \"composer config --global --unset github-oauth.github.com\" to remove it</comment>".to_string())); + } else { + self.output_result(PhpMixed::String(format!("<error>[{}] {}</error>", get_class(&e), e.to_string()))); + } + } else { + self.output_result(PhpMixed::String(format!("<error>[{}] {}</error>", get_class(&e), e.to_string()))); + } + } + } + } + + io.write_no_newline("Checking disk free space: "); + let r = self.check_disk_space(&config); + self.output_result(r); + + Ok(self.exit_code) + } + + fn check_composer_schema(&self) -> anyhow::Result<PhpMixed> { + let validator = ConfigValidator::new(self.inner.get_io().clone_box()); + let (errors, _, warnings) = validator.validate(&Factory::get_composer_file()); + + if !errors.is_empty() || !warnings.is_empty() { + let mut messages: IndexMap<String, Vec<String>> = IndexMap::new(); + messages.insert("error".to_string(), errors); + messages.insert("warning".to_string(), warnings); + + let mut output = String::new(); + for (style, msgs) in &messages { + for msg in msgs { + output.push_str(&format!("<{}>{}</{}>{}", style, msg, style, PHP_EOL)); + } + } + + return Ok(PhpMixed::String(rtrim(&output, " \t\n\r\0\u{0B}"))); + } + + Ok(PhpMixed::Bool(true)) + } + + fn check_composer_lock_schema(&self, locker: &Locker) -> anyhow::Result<PhpMixed> { + let json = locker.get_json_file(); + + match json.validate_schema(JsonFile::LOCK_SCHEMA) { + Ok(_) => {} + Err(e) => { + if let Some(jve) = e.downcast_ref::<JsonValidationException>() { + let mut output = String::new(); + for error in jve.get_errors() { + output.push_str(&format!("<error>{}</error>{}", error, PHP_EOL)); + } + + return Ok(PhpMixed::String(trim(&output, " \t\n\r\0\u{0B}"))); + } + return Err(e); + } + } + + Ok(PhpMixed::Bool(true)) + } + + fn check_git(&mut self) -> String { + if !function_exists("proc_open") { + return "<comment>proc_open is not available, git cannot be used</comment>".to_string(); + } + + let mut output = String::new(); + self.process.as_mut().unwrap().execute( + &vec!["git".to_string(), "config".to_string(), "color.ui".to_string()], + &mut output, + ); + if strtolower(&trim(&output, " \t\n\r\0\u{0B}")) == "always" { + return "<comment>Your git color.ui setting is set to always, this is known to create issues. Use \"git config --global color.ui true\" to set it correctly.</comment>".to_string(); + } + + let git_version = Git::get_version(self.process.as_ref().unwrap()); + let git_version = match git_version { + Some(v) => v, + None => return "<comment>No git process found</>".to_string(), + }; + + if version_compare("2.24.0", &git_version, ">") { + return format!("<warning>Your git version ({}) is too old and possibly will cause issues. Please upgrade to git 2.24 or above</>", git_version); + } + + format!("<info>OK</> <comment>git version {}</>", git_version) + } + + fn check_http(&mut self, proto: &str, config: &Config) -> anyhow::Result<PhpMixed> { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); + } + + let mut result_list: Vec<Box<PhpMixed>> = vec![]; + let mut tls_warning: Option<String> = None; + if proto == "https" && config.get("disable-tls").as_bool() == Some(true) { + tls_warning = Some("<warning>Composer is configured to disable SSL/TLS protection. This will leave remote HTTPS requests vulnerable to Man-In-The-Middle attacks.</warning>".to_string()); + } + + match self.http_downloader.as_mut().unwrap().get(&format!("{}://repo.packagist.org/packages.json", proto), IndexMap::new()) { + Ok(_) => {} + Err(e) => { + if let Some(te) = e.downcast_ref::<TransportException>() { + let hints = HttpDownloader::get_exception_hints(te); + if !hints.is_empty() && count(&hints) > 0 { + for hint in hints { + result_list.push(Box::new(PhpMixed::String(hint))); + } + } + + result_list.push(Box::new(PhpMixed::String(format!( + "<error>[{}] {}</error>", + get_class(te), + te.get_message() + )))); + } else { + return Err(e); + } + } + } + + if let Some(w) = tls_warning { + result_list.push(Box::new(PhpMixed::String(w))); + } + + if count(&result_list) > 0 { + return Ok(PhpMixed::List(result_list)); + } + + Ok(PhpMixed::Bool(true)) + } + + fn check_composer_repo(&mut self, url: &str, config: &Config) -> anyhow::Result<PhpMixed> { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); + } + + let mut result_list: Vec<Box<PhpMixed>> = vec![]; + let mut tls_warning: Option<String> = None; + if str_starts_with(url, "https://") && config.get("disable-tls").as_bool() == Some(true) { + tls_warning = Some("<warning>Composer is configured to disable SSL/TLS protection. This will leave remote HTTPS requests vulnerable to Man-In-The-Middle attacks.</warning>".to_string()); + } + + match self.http_downloader.as_mut().unwrap().get(url, IndexMap::new()) { + Ok(_) => {} + Err(e) => { + if let Some(te) = e.downcast_ref::<TransportException>() { + let hints = HttpDownloader::get_exception_hints(te); + if !hints.is_empty() && count(&hints) > 0 { + for hint in hints { + result_list.push(Box::new(PhpMixed::String(hint))); + } + } + + result_list.push(Box::new(PhpMixed::String(format!( + "<error>[{}] {}</error>", + get_class(te), + te.get_message() + )))); + } else { + return Err(e); + } + } + } + + if let Some(w) = tls_warning { + result_list.push(Box::new(PhpMixed::String(w))); + } + + if count(&result_list) > 0 { + return Ok(PhpMixed::List(result_list)); + } + + Ok(PhpMixed::Bool(true)) + } + + fn check_http_proxy(&mut self, proxy: &RequestProxy, protocol: &str) -> anyhow::Result<PhpMixed> { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); + } + + let proxy_status = proxy.get_status(); + + if proxy.is_excluded_by_no_proxy() { + return Ok(PhpMixed::String(format!( + "<info>SKIP</> <comment>Because repo.packagist.org is {}</>", + proxy_status + ))); + } + + let json = self + .http_downloader + .as_mut() + .unwrap() + .get(&format!("{}://repo.packagist.org/packages.json", protocol), IndexMap::new())? + .decode_json()?; + if let Some(provider_includes) = json.as_array().and_then(|a| a.get("provider-includes")) { + let mut hash_val = reset(&provider_includes.as_array().cloned().unwrap_or_default()); + hash_val = hash_val.as_array().and_then(|a| a.get("sha256")).map(|v| (**v).clone()).unwrap_or(PhpMixed::Null); + let path = str_replace( + "%hash%", + hash_val.as_string().unwrap_or(""), + &key(&provider_includes.as_array().cloned().unwrap_or_default()).unwrap_or_default(), + ); + let provider = self + .http_downloader + .as_mut() + .unwrap() + .get(&format!("{}://repo.packagist.org/{}", protocol, path), IndexMap::new())? + .get_body(); + + if hash("sha256", &provider) != hash_val.as_string().unwrap_or("") { + return Ok(PhpMixed::String(format!( + "<warning>It seems that your proxy ({}) is modifying {} traffic on the fly</>", + proxy_status, protocol + ))); + } + } + + Ok(PhpMixed::String(format!("<info>OK</> <comment>{}</>", proxy_status))) + } + + fn check_github_oauth(&mut self, domain: &str, token: &str) -> anyhow::Result<PhpMixed> { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); + } + + self.inner.get_io().set_authentication(domain.to_string(), token.to_string(), Some("x-oauth-basic".to_string())); + let url = if domain == "github.com" { + format!("https://api.{}/", domain) + } else { + format!("https://{}/api/v3/", domain) + }; + + let mut opts: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + opts.insert("retry-auth-failure".to_string(), Box::new(PhpMixed::Bool(false))); + + match self.http_downloader.as_mut().unwrap().get(&url, opts) { + Ok(response) => { + let expiration = response.get_header("github-authentication-token-expiration"); + + if expiration.is_none() { + return Ok(PhpMixed::String("<info>OK</> <comment>does not expire</>".to_string())); + } + + Ok(PhpMixed::String(format!( + "<info>OK</> <comment>expires on {}</>", + expiration.unwrap() + ))) + } + Err(e) => { + if let Some(te) = e.downcast_ref::<TransportException>() { + if te.get_code() == 401 { + return Ok(PhpMixed::String(format!( + "<comment>The oauth token for {} seems invalid, run \"composer config --global --unset github-oauth.{}\" to remove it</comment>", + domain, domain + ))); + } + } + Ok(PhpMixed::String(format!("<error>[{}] {}</error>", get_class(&e), e.to_string()))) + } + } + } + + fn get_github_rate_limit(&mut self, domain: &str, token: Option<&str>) -> anyhow::Result<PhpMixed> { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); + } + + if let Some(t) = token { + self.inner.get_io().set_authentication(domain.to_string(), t.to_string(), Some("x-oauth-basic".to_string())); + } + + let url = if domain == "github.com" { + format!("https://api.{}/rate_limit", domain) + } else { + format!("https://{}/api/rate_limit", domain) + }; + let mut opts: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + opts.insert("retry-auth-failure".to_string(), Box::new(PhpMixed::Bool(false))); + let data = self.http_downloader.as_mut().unwrap().get(&url, opts)?.decode_json()?; + + Ok(data + .as_array() + .and_then(|a| a.get("resources")) + .and_then(|v| v.as_array()) + .and_then(|a| a.get("core")) + .map(|v| (**v).clone()) + .unwrap_or(PhpMixed::Null)) + } + + fn check_disk_space(&self, config: &Config) -> PhpMixed { + if !function_exists("disk_free_space") { + return PhpMixed::Bool(true); + } + + let min_space_free = 1024 * 1024; + let home_dir = config.get("home").as_string().unwrap_or("").to_string(); + let vendor_dir = config.get("vendor-dir").as_string().unwrap_or("").to_string(); + let mut dir = home_dir.clone(); + let df_home = disk_free_space(&home_dir); + if df_home.map(|d| d < min_space_free).unwrap_or(false) { + return PhpMixed::String(format!("<error>The disk hosting {} is full</error>", dir)); + } + dir = vendor_dir.clone(); + let df_vendor = disk_free_space(&vendor_dir); + if df_vendor.map(|d| d < min_space_free).unwrap_or(false) { + return PhpMixed::String(format!("<error>The disk hosting {} is full</error>", dir)); + } + + PhpMixed::Bool(true) + } + + fn check_pub_keys(&self, config: &Config) -> PhpMixed { + let home = config.get("home").as_string().unwrap_or("").to_string(); + let mut errors: Vec<Box<PhpMixed>> = vec![]; + let io = self.inner.get_io(); + + if file_exists(&format!("{}/keys.tags.pub", home)) && file_exists(&format!("{}/keys.dev.pub", home)) { + io.write(""); + } + + if file_exists(&format!("{}/keys.tags.pub", home)) { + io.write(&format!( + "Tags Public Key Fingerprint: {}", + Keys::fingerprint(&format!("{}/keys.tags.pub", home)) + )); + } else { + errors.push(Box::new(PhpMixed::String("<error>Missing pubkey for tags verification</error>".to_string()))); + } + + if file_exists(&format!("{}/keys.dev.pub", home)) { + io.write(&format!( + "Dev Public Key Fingerprint: {}", + Keys::fingerprint(&format!("{}/keys.dev.pub", home)) + )); + } else { + errors.push(Box::new(PhpMixed::String("<error>Missing pubkey for dev verification</error>".to_string()))); + } + + if !errors.is_empty() { + errors.push(Box::new(PhpMixed::String("<error>Run composer self-update --update-keys to set them up</error>".to_string()))); + } + + if !errors.is_empty() { + PhpMixed::List(errors) + } else { + PhpMixed::Bool(true) + } + } + + fn check_version(&mut self, config: &Config) -> anyhow::Result<PhpMixed> { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); + } + + let versions_util = Versions::new(config.clone(), self.http_downloader.clone().unwrap()); + let latest = match versions_util.get_latest() { + Ok(l) => l, + Err(e) => return Ok(PhpMixed::String(format!("<error>[{}] {}</error>", get_class(&e), e.to_string()))), + }; + + let latest_version = latest.as_array().and_then(|a| a.get("version")).and_then(|v| v.as_string()).unwrap_or("").to_string(); + if Composer::VERSION != latest_version && Composer::VERSION != "@package_version@" { + return Ok(PhpMixed::String(format!( + "<comment>You are not running the latest {} version, run `composer self-update` to update ({} => {})</comment>", + versions_util.get_channel(), + Composer::VERSION, + latest_version + ))); + } + + Ok(PhpMixed::Bool(true)) + } + + fn check_composer_audit(&mut self, config: &Config) -> anyhow::Result<PhpMixed> { + let result = self.check_connectivity_and_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return Ok(result); + } + + let auditor = Auditor::new(); + let mut repo_set = RepositorySet::new( + "stable".to_string(), + IndexMap::new(), + vec![], + IndexMap::new(), + IndexMap::new(), + IndexMap::new(), + ); + // PHP: __DIR__ . '/../../../vendor/composer/installed.json' + let installed_json = JsonFile::new( + "composer/src/Composer/Command/../../../vendor/composer/installed.json".to_string(), + None, + None, + ); + if !installed_json.exists() { + return Ok(PhpMixed::String("<warning>Could not find Composer's installed.json, this must be a non-standard Composer installation.</>".to_string())); + } + + let local_repo = FilesystemRepository::new(installed_json, false, None); + let version = Composer::get_version(); + let mut packages = local_repo.get_canonical_packages(); + if version != "@package_version@" { + let version_parser = VersionParser::new(); + let normalized_version = version_parser.normalize(&version, None)?; + let root_pkg = RootPackage::new("composer/composer".to_string(), normalized_version, version.clone()); + packages.push(Box::new(root_pkg)); + } + let mut repo_config: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + repo_config.insert("type".to_string(), Box::new(PhpMixed::String("composer".to_string()))); + repo_config.insert("url".to_string(), Box::new(PhpMixed::String("https://packagist.org".to_string()))); + repo_set.add_repository(Box::new(ComposerRepository::new( + PhpMixed::Array(repo_config), + Box::new(NullIO::new()), + config.clone(), + self.http_downloader.clone().unwrap(), + ))); + + let io = BufferIO::new(); + let result = match auditor.audit( + &io, + &repo_set, + &packages, + Auditor::FORMAT_TABLE, + true, + &IndexMap::new(), + Auditor::ABANDONED_IGNORE, + &IndexMap::new(), + false, + &IndexMap::new(), + ) { + Ok(r) => r, + Err(e) => return Ok(PhpMixed::String(format!("<highlight>Failed performing audit: {}</>", e.to_string()))), + }; + + if result > 0 { + return Ok(PhpMixed::String(format!("<highlight>Audit found some issues:</>{}{}", PHP_EOL, io.get_output()))); + } + + Ok(PhpMixed::Bool(true)) + } + + fn get_curl_version(&self) -> String { + if extension_loaded("curl") { + if !HttpDownloader::is_curl_enabled() { + return "<error>disabled via disable_functions, using php streams fallback, which reduces performance</error>".to_string(); + } + + let version = curl_version(); + let version_arr = version.as_array().cloned().unwrap_or_default(); + let libz_version = version_arr.get("libz_version").and_then(|v| v.as_string()).filter(|s| !s.is_empty()).unwrap_or("missing").to_string(); + let brotli_version = version_arr.get("brotli_version").and_then(|v| v.as_string()).filter(|s| !s.is_empty()).unwrap_or("missing").to_string(); + let ssl_version = version_arr.get("ssl_version").and_then(|v| v.as_string()).filter(|s| !s.is_empty()).unwrap_or("missing").to_string(); + let features = version_arr.get("features").and_then(|v| v.as_int()).unwrap_or(0); + let has_zstd = features != 0 && defined("CURL_VERSION_ZSTD") && 0 != (features & CURL_VERSION_ZSTD); + let mut http_versions = "1.0, 1.1".to_string(); + if features != 0 && defined("CURL_VERSION_HTTP2") && defined("CURL_HTTP_VERSION_2_0") && (CURL_VERSION_HTTP2 & features) != 0 { + http_versions.push_str(", 2"); + } + if features != 0 && defined("CURL_VERSION_HTTP3") && (features & CURL_VERSION_HTTP3) != 0 { + http_versions.push_str(", 3"); + } + + let curl_version_str = version_arr.get("version").and_then(|v| v.as_string()).unwrap_or("").to_string(); + return format!( + "<comment>{}</comment> libz <comment>{}</comment> brotli <comment>{}</comment> zstd <comment>{}</comment> ssl <comment>{}</comment> HTTP <comment>{}</comment>", + curl_version_str, + libz_version, + brotli_version, + if has_zstd { "supported" } else { "missing" }, + ssl_version, + http_versions + ); + } + + "<error>missing, using php streams fallback, which reduces performance</error>".to_string() + } + + fn output_result(&mut self, result: PhpMixed) { + let io = self.inner.get_io(); + if result.as_bool() == Some(true) { + io.write("<info>OK</info>"); + + return; + } + + let mut had_error = false; + let mut had_warning = false; + let mut result = result; + // PHP: $result instanceof \Exception → already converted to string at call sites here + if !result.as_bool().unwrap_or(true) && !result.is_string() && !is_array(&result) { + // falsey results should be considered as an error, even if there is nothing to output + had_error = true; + } else { + let result_list: Vec<PhpMixed> = match &result { + PhpMixed::List(l) => l.iter().map(|b| (**b).clone()).collect(), + other => vec![other.clone()], + }; + for message in &result_list { + let s = message.as_string().unwrap_or(""); + if strpos(s, "<error>").is_some() { + had_error = true; + } else if strpos(s, "<warning>").is_some() { + had_warning = true; + } + } + // re-wrap so the final output loop works the same + result = PhpMixed::List(result_list.into_iter().map(Box::new).collect()); + } + + if had_error { + io.write("<error>FAIL</error>"); + self.exit_code = max_i64(self.exit_code, 2); + } else if had_warning { + io.write("<warning>WARNING</warning>"); + self.exit_code = max_i64(self.exit_code, 1); + } + + if !result.as_bool().unwrap_or(false) { + // PHP: if ($result) — falsey skips; this branch matches truthy + } + if let Some(list) = result.as_list() { + for message in list { + io.write(&trim(message.as_string().unwrap_or(""), " \t\n\r\0\u{0B}")); + } + } + } + + fn check_platform(&mut self) -> anyhow::Result<PhpMixed> { + let mut output = String::new(); + let mut display_ini_message = false; + + let mut ini_message = format!("{}{}{}", PHP_EOL, PHP_EOL, IniHelper::get_message()); + ini_message.push_str(&format!("{}If you can not modify the ini file, you can also run `php -d option=value` to modify ini values on the fly. You can use -d multiple times.", PHP_EOL)); + + let mut errors: IndexMap<String, PhpMixed> = IndexMap::new(); + let mut warnings: IndexMap<String, PhpMixed> = IndexMap::new(); + + if !function_exists("json_decode") { + errors.insert("json".to_string(), PhpMixed::Bool(true)); + } + + if !extension_loaded("Phar") { + errors.insert("phar".to_string(), PhpMixed::Bool(true)); + } + + if !extension_loaded("filter") { + errors.insert("filter".to_string(), PhpMixed::Bool(true)); + } + + if !extension_loaded("hash") { + errors.insert("hash".to_string(), PhpMixed::Bool(true)); + } + + if !extension_loaded("iconv") && !extension_loaded("mbstring") { + errors.insert("iconv_mbstring".to_string(), PhpMixed::Bool(true)); + } + + if !filter_var(&ini_get("allow_url_fopen"), FILTER_VALIDATE_BOOLEAN).as_bool().unwrap_or(false) { + errors.insert("allow_url_fopen".to_string(), PhpMixed::Bool(true)); + } + + if extension_loaded("ionCube Loader") && ioncube_loader_iversion() < 40009 { + errors.insert("ioncube".to_string(), PhpMixed::String(ioncube_loader_version())); + } + + if PHP_VERSION_ID < 70205 { + errors.insert("php".to_string(), PhpMixed::String(PHP_VERSION.to_string())); + } + + if !extension_loaded("openssl") { + errors.insert("openssl".to_string(), PhpMixed::Bool(true)); + } + + if extension_loaded("openssl") && OPENSSL_VERSION_NUMBER < 0x1000100f { + warnings.insert("openssl_version".to_string(), PhpMixed::Bool(true)); + } + + if !defined("HHVM_VERSION") + && !extension_loaded("apcu") + && filter_var(&ini_get("apc.enable_cli"), FILTER_VALIDATE_BOOLEAN).as_bool().unwrap_or(false) + { + warnings.insert("apc_cli".to_string(), PhpMixed::Bool(true)); + } + + if !extension_loaded("zlib") { + warnings.insert("zlib".to_string(), PhpMixed::Bool(true)); + } + + ob_start(); + phpinfo(INFO_GENERAL); + let phpinfo_str = ob_get_clean(); + let mut phpinfo_match: Vec<String> = vec![]; + if phpinfo_str.is_some() + && Preg::is_match_strict_groups( + "{Configure Command(?: *</td><td class=\"v\">| *=> *)(.*?)(?:</td>|$)}m", + phpinfo_str.as_ref().unwrap(), + Some(&mut phpinfo_match), + ) + .unwrap_or(false) + { + let configure = &phpinfo_match[1]; + + if str_contains(configure, "--enable-sigchild") { + warnings.insert("sigchild".to_string(), PhpMixed::Bool(true)); + } + + if str_contains(configure, "--with-curlwrappers") { + warnings.insert("curlwrappers".to_string(), PhpMixed::Bool(true)); + } + } + + if filter_var(&ini_get("xdebug.profiler_enabled"), FILTER_VALIDATE_BOOLEAN).as_bool().unwrap_or(false) { + warnings.insert("xdebug_profile".to_string(), PhpMixed::Bool(true)); + } else if XdebugHandler::is_xdebug_active() { + warnings.insert("xdebug_loaded".to_string(), PhpMixed::Bool(true)); + } + + if defined("PHP_WINDOWS_VERSION_BUILD") + && (version_compare(PHP_VERSION, "7.2.23", "<") + || (version_compare(PHP_VERSION, "7.3.0", ">=") + && version_compare(PHP_VERSION, "7.3.10", "<"))) + { + let _ = PHP_WINDOWS_VERSION_BUILD; + warnings.insert("onedrive".to_string(), PhpMixed::String(PHP_VERSION.to_string())); + } + + if extension_loaded("uopz") + && !(filter_var(&ini_get("uopz.disable"), FILTER_VALIDATE_BOOLEAN).as_bool().unwrap_or(false) + || filter_var(&ini_get("uopz.exit"), FILTER_VALIDATE_BOOLEAN).as_bool().unwrap_or(false)) + { + warnings.insert("uopz".to_string(), PhpMixed::Bool(true)); + } + + let mut out_fn = |msg: &str, style: &str, output: &mut String| { + output.push_str(&format!("<{}>{}</{}>{}", style, msg, style, PHP_EOL)); + }; + + if !errors.is_empty() { + for (error, current) in &errors { + let text = match error.as_str() { + "json" => format!("{}The json extension is missing.{}Install it or recompile php without --disable-json", PHP_EOL, PHP_EOL), + "phar" => format!("{}The phar extension is missing.{}Install it or recompile php without --disable-phar", PHP_EOL, PHP_EOL), + "filter" => format!("{}The filter extension is missing.{}Install it or recompile php without --disable-filter", PHP_EOL, PHP_EOL), + "hash" => format!("{}The hash extension is missing.{}Install it or recompile php without --disable-hash", PHP_EOL, PHP_EOL), + "iconv_mbstring" => format!("{}The iconv OR mbstring extension is required and both are missing.{}Install either of them or recompile php without --disable-iconv", PHP_EOL, PHP_EOL), + "php" => format!( + "{}Your PHP ({}) is too old, you must upgrade to PHP 7.2.5 or higher.", + PHP_EOL, + current.as_string().unwrap_or("") + ), + "allow_url_fopen" => { + display_ini_message = true; + format!( + "{}The allow_url_fopen setting is incorrect.{}Add the following to the end of your `php.ini`:{} allow_url_fopen = On", + PHP_EOL, PHP_EOL, PHP_EOL + ) + } + "ioncube" => { + display_ini_message = true; + format!( + "{}Your ionCube Loader extension ({}) is incompatible with Phar files.{}Upgrade to ionCube 4.0.9 or higher or remove this line (path may be different) from your `php.ini` to disable it:{} zend_extension = /usr/lib/php5/20090626+lfs/ioncube_loader_lin_5.3.so", + PHP_EOL, + current.as_string().unwrap_or(""), + PHP_EOL, + PHP_EOL + ) + } + "openssl" => format!( + "{}The openssl extension is missing, which means that secure HTTPS transfers are impossible.{}If possible you should enable it or recompile php with --with-openssl", + PHP_EOL, PHP_EOL + ), + other => { + return Err(InvalidArgumentException { + message: sprintf( + "DiagnoseCommand: Unknown error type \"%s\". Please report at https://github.com/composer/composer/issues/new.", + &[other.to_string().into()], + ), + code: 0, + } + .into()); + } + }; + out_fn(&text, "error", &mut output); + } + + output.push_str(PHP_EOL); + } + + if !warnings.is_empty() { + for (warning, current) in &warnings { + let text = match warning.as_str() { + "apc_cli" => { + display_ini_message = true; + format!( + "The apc.enable_cli setting is incorrect.{}Add the following to the end of your `php.ini`:{} apc.enable_cli = Off", + PHP_EOL, PHP_EOL + ) + } + "zlib" => { + display_ini_message = true; + format!( + "The zlib extension is not loaded, this can slow down Composer a lot.{}If possible, enable it or recompile php with --with-zlib{}", + PHP_EOL, PHP_EOL + ) + } + "sigchild" => format!( + "PHP was compiled with --enable-sigchild which can cause issues on some platforms.{}Recompile it without this flag if possible, see also:{} https://bugs.php.net/bug.php?id=22999", + PHP_EOL, PHP_EOL + ), + "curlwrappers" => format!( + "PHP was compiled with --with-curlwrappers which will cause issues with HTTP authentication and GitHub.{} Recompile it without this flag if possible", + PHP_EOL + ), + "openssl_version" => { + // Attempt to parse version number out, fallback to whole string value. + let openssl_trimmed = trim(&strstr(OPENSSL_VERSION_TEXT, " ", false), " \t\n\r\0\u{0B}"); + let mut openssl_version = strstr(&openssl_trimmed, " ", true); + if openssl_version.is_empty() { + openssl_version = OPENSSL_VERSION_TEXT.to_string(); + } + + format!( + "The OpenSSL library ({}) used by PHP does not support TLSv1.2 or TLSv1.1.{}If possible you should upgrade OpenSSL to version 1.0.1 or above.", + openssl_version, PHP_EOL + ) + } + "xdebug_loaded" => format!( + "The xdebug extension is loaded, this can slow down Composer a little.{} Disabling it when using Composer is recommended.", + PHP_EOL + ), + "xdebug_profile" => { + display_ini_message = true; + format!( + "The xdebug.profiler_enabled setting is enabled, this can slow down Composer a lot.{}Add the following to the end of your `php.ini` to disable it:{} xdebug.profiler_enabled = 0", + PHP_EOL, PHP_EOL + ) + } + "onedrive" => format!( + "The Windows OneDrive folder is not supported on PHP versions below 7.2.23 and 7.3.10.{}Upgrade your PHP ({}) to use this location with Composer.{}", + PHP_EOL, + current.as_string().unwrap_or(""), + PHP_EOL + ), + "uopz" => format!( + "The uopz extension ignores exit calls and may not work with all Composer commands.{}Disabling it when using Composer is recommended.", + PHP_EOL + ), + other => { + return Err(InvalidArgumentException { + message: sprintf( + "DiagnoseCommand: Unknown warning type \"%s\". Please report at https://github.com/composer/composer/issues/new.", + &[other.to_string().into()], + ), + code: 0, + } + .into()); + } + }; + out_fn(&text, "comment", &mut output); + } + } + + if display_ini_message { + out_fn(&ini_message, "comment", &mut output); + } + + let composer_ipresolve = Platform::get_env("COMPOSER_IPRESOLVE").unwrap_or_default(); + if vec!["4".to_string(), "6".to_string()].contains(&composer_ipresolve) { + warnings.insert("ipresolve".to_string(), PhpMixed::Bool(true)); + out_fn( + &format!( + "The COMPOSER_IPRESOLVE env var is set to {} which may result in network failures below.", + Platform::get_env("COMPOSER_IPRESOLVE").unwrap_or_default() + ), + "comment", + &mut output, + ); + } + + Ok(if count(&warnings) == 0 && count(&errors) == 0 { + PhpMixed::Bool(true) + } else { + PhpMixed::String(output) + }) + } + + /// Check if allow_url_fopen is ON + fn check_connectivity(&self) -> PhpMixed { + if !ini_get("allow_url_fopen").parse::<bool>().unwrap_or(false) && ini_get("allow_url_fopen") != "1" { + return PhpMixed::String("<info>SKIP</> <comment>Because allow_url_fopen is missing.</>".to_string()); + } + + PhpMixed::Bool(true) + } + + fn check_connectivity_and_composer_network_http_enablement(&self) -> PhpMixed { + let result = self.check_connectivity(); + if result.as_bool() != Some(true) { + return result; + } + + let result = self.check_composer_network_http_enablement(); + if result.as_bool() != Some(true) { + return result; + } + + PhpMixed::Bool(true) + } + + /// Check if Composer network is enabled for HTTP/S + fn check_composer_network_http_enablement(&self) -> PhpMixed { + if Platform::get_env("COMPOSER_DISABLE_NETWORK").map(|v| !v.is_empty() && v != "0").unwrap_or(false) { + return PhpMixed::String("<info>SKIP</> <comment>Network is disabled by COMPOSER_DISABLE_NETWORK.</>".to_string()); + } + + PhpMixed::Bool(true) + } +} |
