diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-07-26 00:18:54 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-07-26 00:18:54 +0900 |
| commit | ed0da7fcc3056ce5a822ac0de63ccc2dc799006e (patch) | |
| tree | 8689fb2797c3d9ced9b89a54753ad5d780c32cd5 /crates | |
| parent | f642329554cf18734e671dc4758bc045de489999 (diff) | |
| download | php-shirabe-ed0da7fcc3056ce5a822ac0de63ccc2dc799006e.tar.gz php-shirabe-ed0da7fcc3056ce5a822ac0de63ccc2dc799006e.tar.zst php-shirabe-ed0da7fcc3056ce5a822ac0de63ccc2dc799006e.zip | |
feat(diagnose-command): query the real PHP runtime in one RPC call
diagnose used to read hardcoded shim stubs, so it described a fictional
runtime: OPENSSL_VERSION_NUMBER was always 0 and tripped the TLSv1.1/1.2
check, PHP_BINARY and OPENSSL_VERSION_TEXT were empty, and the extension,
function and ini probes answered from a fixed table.
The PHP worker gained a `diagnose` entry that returns every fact the
command needs as one PHP array, cached in a OnceLock so the several call
sites share a single round trip. Reading it back needed array support in
the serialize() parser, which in turn lets get_loaded_extensions and
get_all_ini_files return real lists instead of comma-joined strings.
Also fixes the openssl_version message, which dropped strstr()'s
before_needle argument during the port, and check_connectivity's
allow_url_fopen test, which did not follow PHP string truthiness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/shirabe-php-rpc/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/php/worker.php | 95 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/src/lib.rs | 452 | ||||
| -rw-r--r-- | crates/shirabe-php-shim/src/string.rs | 10 | ||||
| -rw-r--r-- | crates/shirabe/src/command/diagnose_command.rs | 119 | ||||
| -rw-r--r-- | crates/shirabe/tests/command/diagnose_command_test.rs | 11 |
6 files changed, 547 insertions, 141 deletions
diff --git a/crates/shirabe-php-rpc/Cargo.toml b/crates/shirabe-php-rpc/Cargo.toml index 093b10e5..0015dba4 100644 --- a/crates/shirabe-php-rpc/Cargo.toml +++ b/crates/shirabe-php-rpc/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true shirabe-external-packages.workspace = true shirabe-php-shim.workspace = true anyhow.workspace = true +indexmap.workspace = true tempfile.workspace = true [lints] diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php index 060373d5..1e5b726d 100644 --- a/crates/shirabe-php-rpc/php/worker.php +++ b/crates/shirabe-php-rpc/php/worker.php @@ -6,20 +6,53 @@ $client = @stream_socket_client('unix://' . $argv[1], $errno, $errstr); if ($client === false) { exit(1); } +// Port of Composer\XdebugHandler\XdebugHandler::setXdebugDetails(), which the diagnose payload +// reports as `xdebug_active`. +$xdebug_active = static function (): bool { + if (!extension_loaded('xdebug')) { + return false; + } + + $version = phpversion('xdebug'); + $version = $version !== false ? $version : 'unknown'; + + if (version_compare($version, '3.1', '>=')) { + $modes = xdebug_info('mode'); + return (count($modes) === 0 ? 'off' : implode(',', $modes)) !== 'off'; + } + + $ini_mode = ini_get('xdebug.mode'); + if ($ini_mode === false) { + return true; + } + + $env_mode = (string) getenv('XDEBUG_MODE'); + if ($env_mode !== '') { + $mode = $env_mode; + } else { + $mode = $ini_mode !== '' ? $ini_mode : 'off'; + } + + if (preg_match('/^,+$/', str_replace(' ', '', $mode)) === 1) { + $mode = 'off'; + } + + return $mode !== 'off'; +}; $dispatch = [ 'defined' => static fn($name) => defined($name), 'constant' => static fn($name) => defined($name) ? constant($name) : null, 'inet_pton' => static fn($arg) => @inet_pton($arg), 'curl_version' => static fn($arg) => function_exists('curl_version') ? (curl_version()['version'] ?? null) : null, 'phpversion' => static fn($name) => phpversion($name), - 'get_loaded_extensions' => static fn($arg) => implode(',', get_loaded_extensions()), + 'get_loaded_extensions' => static fn($arg) => get_loaded_extensions(), 'get_all_ini_files' => static function ($arg) { $paths = [(string) php_ini_loaded_file()]; $scanned = php_ini_scanned_files(); if ($scanned !== false) { $paths = array_merge($paths, array_map('trim', explode(',', $scanned))); } - return implode(',', $paths); + return $paths; }, 'extension_info' => static function ($name) { if (!extension_loaded($name)) { @@ -30,10 +63,62 @@ $dispatch = [ $re->info(); return (string) ob_get_clean(); }, - 'phpinfo' => static function ($what) { + 'diagnose' => static function ($arg) use ($xdebug_active) { + $extensions = []; + foreach ([ + 'apcu', + 'curl', + 'filter', + 'hash', + 'iconv', + 'ionCube Loader', + 'mbstring', + 'openssl', + 'Phar', + 'uopz', + 'zip', + 'zlib', + ] as $extension) { + $extensions[$extension] = extension_loaded($extension); + } + + $functions = []; + foreach (['disk_free_space', 'json_decode', 'proc_open'] as $function) { + $functions[$function] = function_exists($function); + } + + $ini = []; + foreach ([ + 'allow_url_fopen', + 'apc.enable_cli', + 'uopz.disable', + 'uopz.exit', + 'xdebug.profiler_enabled', + ] as $setting) { + $value = ini_get($setting); + $ini[$setting] = $value === false ? null : $value; + } + ob_start(); - phpinfo((int) $what); - return (string) ob_get_clean(); + phpinfo(INFO_GENERAL); + $phpinfo = (string) ob_get_clean(); + + return [ + 'php_version' => PHP_VERSION, + 'php_version_id' => PHP_VERSION_ID, + 'php_binary' => defined('PHP_BINARY') ? PHP_BINARY : null, + 'openssl_version_text' => defined('OPENSSL_VERSION_TEXT') ? OPENSSL_VERSION_TEXT : null, + 'openssl_version_number' => defined('OPENSSL_VERSION_NUMBER') ? OPENSSL_VERSION_NUMBER : 0, + 'has_hhvm_version' => defined('HHVM_VERSION'), + 'has_php_windows_version_build' => defined('PHP_WINDOWS_VERSION_BUILD'), + 'xdebug_active' => $xdebug_active(), + 'ioncube_loader_iversion' => extension_loaded('ionCube Loader') ? ioncube_loader_iversion() : 0, + 'ioncube_loader_version' => extension_loaded('ionCube Loader') ? ioncube_loader_version() : '', + 'phpinfo_general' => $phpinfo, + 'extensions' => $extensions, + 'functions' => $functions, + 'ini' => $ini, + ]; }, ]; $read_exact = static function ($conn, int $len): ?string { diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index dde91d3c..ab39c251 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -1,11 +1,12 @@ //! Rust-to-PHP RPC over a Unix domain socket. See `docs/dev/php-rpc.md`. use anyhow::Context as _; +use indexmap::IndexMap; use shirabe_external_packages::symfony::process::PhpExecutableFinder; use shirabe_php_shim::PhpMixed; use std::io::{Read as _, Write as _}; use std::os::unix::net::{UnixListener, UnixStream}; -use std::sync::{LazyLock, Mutex}; +use std::sync::{LazyLock, Mutex, OnceLock}; use std::time::{Duration, Instant}; /// PHP `\PHP_VERSION`. @@ -59,11 +60,152 @@ pub fn get_extension_info(name: &str) -> String { } } -/// PHP `phpinfo($what)` output, captured via `ob_start()`/`ob_get_clean()`. -pub fn get_phpinfo(what: i64) -> String { - match call("phpinfo", &what.to_string()) { - PhpMixed::String(s) => s, - other => panic!("PHP RPC: `phpinfo` did not return a string: {other:?}"), +/// Everything the `diagnose` command needs to know about the PHP runtime, fetched in a single +/// round trip because the command would otherwise probe the same runtime dozens of times. +#[derive(Debug)] +pub struct Diagnostics { + pub php_version: String, + pub php_version_id: i64, + /// `None` when `PHP_BINARY` is undefined. + pub php_binary: Option<String>, + /// `None` when `OPENSSL_VERSION_TEXT` is undefined. + pub openssl_version_text: Option<String>, + /// `0` when `OPENSSL_VERSION_NUMBER` is undefined. + pub openssl_version_number: i64, + pub has_hhvm_version: bool, + pub has_php_windows_version_build: bool, + /// `Composer\XdebugHandler\XdebugHandler::isXdebugActive()`. + pub xdebug_active: bool, + /// `0` when the ionCube loader is not loaded. + pub ioncube_loader_iversion: i64, + /// Empty when the ionCube loader is not loaded. + pub ioncube_loader_version: String, + /// `phpinfo(INFO_GENERAL)` output, captured via `ob_start()`/`ob_get_clean()`. + pub phpinfo_general: String, + extensions: IndexMap<String, bool>, + functions: IndexMap<String, bool>, + ini_settings: IndexMap<String, Option<String>>, +} + +impl Diagnostics { + /// PHP `extension_loaded($name)`. Only the extensions the worker probes can be asked about; + /// any other name is a bug in the caller, not a missing extension. + pub fn extension_loaded(&self, name: &str) -> bool { + *self.extensions.get(name).unwrap_or_else(|| { + panic!("PHP RPC: extension `{name}` is not probed by the diagnose payload") + }) + } + + /// PHP `function_exists($name)`. See [`Diagnostics::extension_loaded`] for the fixed probe set. + pub fn function_exists(&self, name: &str) -> bool { + *self.functions.get(name).unwrap_or_else(|| { + panic!("PHP RPC: function `{name}` is not probed by the diagnose payload") + }) + } + + /// PHP `ini_get($option)`, with PHP's `false` (no such setting) mapped to `None`. See + /// [`Diagnostics::extension_loaded`] for the fixed probe set. + pub fn ini_get(&self, option: &str) -> Option<&str> { + self.ini_settings + .get(option) + .unwrap_or_else(|| { + panic!("PHP RPC: ini setting `{option}` is not probed by the diagnose payload") + }) + .as_deref() + } +} + +static DIAGNOSTICS: OnceLock<Diagnostics> = OnceLock::new(); + +/// PHP runtime information for the `diagnose` command. The worker is queried once per process; +/// subsequent calls reuse the cached payload. +pub fn get_diagnostics() -> &'static Diagnostics { + DIAGNOSTICS.get_or_init(|| { + let payload = call("diagnose", ""); + let payload = payload + .as_array() + .unwrap_or_else(|| panic!("PHP RPC: `diagnose` did not return an array: {payload:?}")); + + Diagnostics { + php_version: string_field(payload, "php_version"), + php_version_id: int_field(payload, "php_version_id"), + php_binary: nullable_string_field(payload, "php_binary"), + openssl_version_text: nullable_string_field(payload, "openssl_version_text"), + openssl_version_number: int_field(payload, "openssl_version_number"), + has_hhvm_version: bool_field(payload, "has_hhvm_version"), + has_php_windows_version_build: bool_field(payload, "has_php_windows_version_build"), + xdebug_active: bool_field(payload, "xdebug_active"), + ioncube_loader_iversion: int_field(payload, "ioncube_loader_iversion"), + ioncube_loader_version: string_field(payload, "ioncube_loader_version"), + phpinfo_general: string_field(payload, "phpinfo_general"), + extensions: map_field(payload, "extensions") + .iter() + .map(|(name, value)| (name.clone(), as_bool(value, name))) + .collect(), + functions: map_field(payload, "functions") + .iter() + .map(|(name, value)| (name.clone(), as_bool(value, name))) + .collect(), + ini_settings: map_field(payload, "ini") + .iter() + .map(|(name, value)| (name.clone(), as_nullable_string(value, name))) + .collect(), + } + }) +} + +fn field<'a>(payload: &'a IndexMap<String, PhpMixed>, key: &str) -> &'a PhpMixed { + payload + .get(key) + .unwrap_or_else(|| panic!("PHP RPC: `diagnose` payload has no `{key}` entry")) +} + +fn string_field(payload: &IndexMap<String, PhpMixed>, key: &str) -> String { + match field(payload, key) { + PhpMixed::String(s) => s.clone(), + other => panic!("PHP RPC: `diagnose` payload entry `{key}` is not a string: {other:?}"), + } +} + +fn nullable_string_field(payload: &IndexMap<String, PhpMixed>, key: &str) -> Option<String> { + as_nullable_string(field(payload, key), key) +} + +fn int_field(payload: &IndexMap<String, PhpMixed>, key: &str) -> i64 { + match field(payload, key) { + PhpMixed::Int(n) => *n, + other => panic!("PHP RPC: `diagnose` payload entry `{key}` is not an int: {other:?}"), + } +} + +fn bool_field(payload: &IndexMap<String, PhpMixed>, key: &str) -> bool { + as_bool(field(payload, key), key) +} + +fn map_field<'a>( + payload: &'a IndexMap<String, PhpMixed>, + key: &str, +) -> &'a IndexMap<String, PhpMixed> { + match field(payload, key) { + PhpMixed::Array(map) => map, + other => panic!("PHP RPC: `diagnose` payload entry `{key}` is not an array: {other:?}"), + } +} + +fn as_bool(value: &PhpMixed, key: &str) -> bool { + match value { + PhpMixed::Bool(b) => *b, + other => panic!("PHP RPC: `diagnose` payload entry `{key}` is not a bool: {other:?}"), + } +} + +fn as_nullable_string(value: &PhpMixed, key: &str) -> Option<String> { + match value { + PhpMixed::String(s) => Some(s.clone()), + PhpMixed::Null => None, + other => { + panic!("PHP RPC: `diagnose` payload entry `{key}` is not a string or null: {other:?}") + } } } @@ -77,26 +219,28 @@ pub fn phpversion(extension: &str) -> Option<String> { } /// PHP `get_loaded_extensions()`. -/// -/// Extension names are joined with `,` on the PHP side and split back here; real -/// extension names never contain a comma. pub fn get_loaded_extensions() -> Vec<String> { - match call("get_loaded_extensions", "") { - PhpMixed::String(s) if s.is_empty() => Vec::new(), - PhpMixed::String(s) => s.split(',').map(|s| s.to_string()).collect(), - other => panic!("PHP RPC: `get_loaded_extensions` did not return a string: {other:?}"), - } + string_list(call("get_loaded_extensions", ""), "get_loaded_extensions") } /// `Composer\XdebugHandler\XdebugHandler::getAllIniFiles()` (minus the `self::$name` branch, /// which is unreachable since this port never constructs an XdebugHandler): `[(string) /// php_ini_loaded_file()]` merged with the trimmed, comma-split `php_ini_scanned_files()` list -/// when scanning is active. Paths are joined with `,` on the PHP side and split back here; real -/// ini paths never contain a comma (same assumption `get_loaded_extensions` makes). +/// when scanning is active. pub fn get_all_ini_files() -> Vec<String> { - match call("get_all_ini_files", "") { - PhpMixed::String(s) => s.split(',').map(|s| s.to_string()).collect(), - other => panic!("PHP RPC: `get_all_ini_files` did not return a string: {other:?}"), + string_list(call("get_all_ini_files", ""), "get_all_ini_files") +} + +fn string_list(value: PhpMixed, name: &str) -> Vec<String> { + match value { + PhpMixed::List(items) => items + .into_iter() + .map(|item| match item { + PhpMixed::String(s) => s, + other => panic!("PHP RPC: `{name}` returned a non-string element: {other:?}"), + }) + .collect(), + other => panic!("PHP RPC: `{name}` did not return a list: {other:?}"), } } @@ -151,7 +295,7 @@ fn call(name: &str, arg: &str) -> PhpMixed { let payload = guard .request(name, arg) .unwrap_or_else(|e| panic!("PHP RPC: request `{name}` failed: {e:#}")); - parse_serialized_scalar(&payload).unwrap_or_else(|| { + parse_serialized_value(&payload).unwrap_or_else(|| { panic!("PHP RPC: request `{name}` returned an unparseable payload: {payload:?}") }) } @@ -214,41 +358,108 @@ fn read_frame(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> { Ok(payload) } -/// Parse the `s:<len>:"<bytes>";` form; only strings are needed here, so other forms are rejected. -fn parse_serialized_string(payload: &[u8]) -> Option<String> { - let rest = payload.strip_prefix(b"s:")?; - let colon = rest.iter().position(|&b| b == b':')?; - let len: usize = std::str::from_utf8(&rest[..colon]).ok()?.parse().ok()?; - let after = rest.get(colon + 1..)?; - let bytes = after.strip_prefix(b"\"")?.get(..len)?; - Some(String::from_utf8_lossy(bytes).into_owned()) +/// Parse a whole `serialize()` payload, rejecting trailing garbage. +fn parse_serialized_value(payload: &[u8]) -> Option<PhpMixed> { + let mut pos = 0; + let value = parse_value(payload, &mut pos)?; + (pos == payload.len()).then_some(value) } -/// Parse any of PHP's scalar/null `serialize()` forms: `N;`, `b:0/1;`, `i:<n>;`, `d:<f>;`, -/// `s:<len>:"<bytes>";`. -fn parse_serialized_scalar(payload: &[u8]) -> Option<PhpMixed> { - if payload == b"N;" { - return Some(PhpMixed::Null); - } - if let Some(rest) = payload.strip_prefix(b"b:") { - return match rest.strip_suffix(b";")? { +/// Parse one `serialize()` value starting at `pos`, advancing it past the value: `N;`, `b:0/1;`, +/// `i:<n>;`, `d:<f>;`, `s:<len>:"<bytes>";`, `a:<count>:{<key><value>...}`. +fn parse_value(payload: &[u8], pos: &mut usize) -> Option<PhpMixed> { + let tag = payload.get(*pos..*pos + 2)?; + *pos += 2; + match tag { + b"N;" => Some(PhpMixed::Null), + b"b:" => match take_until(payload, pos, b';')? { b"0" => Some(PhpMixed::Bool(false)), b"1" => Some(PhpMixed::Bool(true)), _ => None, - }; + }, + b"i:" => parse_int(take_until(payload, pos, b';')?).map(PhpMixed::Int), + b"d:" => std::str::from_utf8(take_until(payload, pos, b';')?) + .ok()? + .parse() + .ok() + .map(PhpMixed::Float), + b"s:" => parse_string_body(payload, pos).map(PhpMixed::String), + b"a:" => parse_array_body(payload, pos), + _ => None, } - if let Some(rest) = payload.strip_prefix(b"i:") { - let s = std::str::from_utf8(rest.strip_suffix(b";")?).ok()?; - return s.parse().ok().map(PhpMixed::Int); +} + +/// Parse the `<len>:"<bytes>";` tail of a serialized string. +fn parse_string_body(payload: &[u8], pos: &mut usize) -> Option<String> { + let len: usize = std::str::from_utf8(take_until(payload, pos, b':')?) + .ok()? + .parse() + .ok()?; + if payload.get(*pos) != Some(&b'"') { + return None; } - if let Some(rest) = payload.strip_prefix(b"d:") { - let s = std::str::from_utf8(rest.strip_suffix(b";")?).ok()?; - return s.parse().ok().map(PhpMixed::Float); + *pos += 1; + let bytes = payload.get(*pos..*pos + len)?; + *pos += len; + if payload.get(*pos..*pos + 2) != Some(b"\";") { + return None; } - if payload.starts_with(b"s:") { - return parse_serialized_string(payload).map(PhpMixed::String); + *pos += 2; + Some(String::from_utf8_lossy(bytes).into_owned()) +} + +/// Parse the `<count>:{<key><value>...}` tail of a serialized array. An array whose keys are +/// exactly `0..count` maps to `PhpMixed::List`, matching how PHP renders such an array as a JSON +/// list; anything else maps to `PhpMixed::Array` with the keys stringified. +fn parse_array_body(payload: &[u8], pos: &mut usize) -> Option<PhpMixed> { + let count: usize = std::str::from_utf8(take_until(payload, pos, b':')?) + .ok()? + .parse() + .ok()?; + if payload.get(*pos) != Some(&b'{') { + return None; } - None + *pos += 1; + + let mut entries: IndexMap<String, PhpMixed> = IndexMap::new(); + let mut is_list = true; + for index in 0..count { + let key = match parse_value(payload, pos)? { + PhpMixed::Int(n) => { + is_list &= n == index as i64; + n.to_string() + } + PhpMixed::String(s) => { + is_list = false; + s + } + _ => return None, + }; + entries.insert(key, parse_value(payload, pos)?); + } + + if payload.get(*pos) != Some(&b'}') { + return None; + } + *pos += 1; + + Some(if is_list { + PhpMixed::List(entries.into_values().collect()) + } else { + PhpMixed::Array(entries) + }) +} + +/// Return the bytes from `pos` up to the next `terminator`, advancing `pos` past it. +fn take_until<'a>(payload: &'a [u8], pos: &mut usize, terminator: u8) -> Option<&'a [u8]> { + let end = *pos + payload.get(*pos..)?.iter().position(|&b| b == terminator)?; + let bytes = &payload[*pos..end]; + *pos = end + 1; + Some(bytes) +} + +fn parse_int(bytes: &[u8]) -> Option<i64> { + std::str::from_utf8(bytes).ok()?.parse().ok() } #[cfg(test)] @@ -258,34 +469,35 @@ mod tests { #[test] fn parses_string_scalar() { assert_eq!( - parse_serialized_string(b"s:5:\"8.5.7\";").as_deref(), - Some("8.5.7"), + parse_serialized_value(b"s:5:\"8.5.7\";"), + Some(PhpMixed::String("8.5.7".to_string())), ); } #[test] fn parses_empty_string() { - assert_eq!(parse_serialized_string(b"s:0:\"\";").as_deref(), Some("")); + assert_eq!( + parse_serialized_value(b"s:0:\"\";"), + Some(PhpMixed::String(String::new())), + ); } #[test] fn parses_string_with_embedded_quote() { assert_eq!( - parse_serialized_string(b"s:3:\"a\"b\";").as_deref(), - Some("a\"b"), + parse_serialized_value(b"s:3:\"a\"b\";"), + Some(PhpMixed::String("a\"b".to_string())), ); } #[test] - fn rejects_non_string_scalars() { - assert_eq!(parse_serialized_string(b"i:42;"), None); - assert_eq!(parse_serialized_string(b"N;"), None); - assert_eq!(parse_serialized_string(b"b:1;"), None); + fn rejects_truncated_string() { + assert_eq!(parse_serialized_value(b"s:5:\"ab\";"), None); } #[test] - fn rejects_truncated_string() { - assert_eq!(parse_serialized_string(b"s:5:\"ab\";"), None); + fn rejects_trailing_garbage() { + assert_eq!(parse_serialized_value(b"i:42;i:43;"), None); } #[test] @@ -306,51 +518,141 @@ mod tests { #[test] fn rejects_non_numeric_length() { - assert_eq!(parse_serialized_string(b"s:x:\"ab\";"), None); + assert_eq!(parse_serialized_value(b"s:x:\"ab\";"), None); } #[test] fn parses_scalar_null() { - assert_eq!(parse_serialized_scalar(b"N;"), Some(PhpMixed::Null)); + assert_eq!(parse_serialized_value(b"N;"), Some(PhpMixed::Null)); } #[test] fn parses_scalar_bool() { - assert_eq!( - parse_serialized_scalar(b"b:0;"), - Some(PhpMixed::Bool(false)) - ); - assert_eq!(parse_serialized_scalar(b"b:1;"), Some(PhpMixed::Bool(true))); + assert_eq!(parse_serialized_value(b"b:0;"), Some(PhpMixed::Bool(false))); + assert_eq!(parse_serialized_value(b"b:1;"), Some(PhpMixed::Bool(true))); } #[test] fn parses_scalar_int() { - assert_eq!(parse_serialized_scalar(b"i:8;"), Some(PhpMixed::Int(8))); - assert_eq!(parse_serialized_scalar(b"i:-1;"), Some(PhpMixed::Int(-1))); + assert_eq!(parse_serialized_value(b"i:8;"), Some(PhpMixed::Int(8))); + assert_eq!(parse_serialized_value(b"i:-1;"), Some(PhpMixed::Int(-1))); } #[test] fn parses_scalar_float() { assert_eq!( - parse_serialized_scalar(b"d:1.5;"), + parse_serialized_value(b"d:1.5;"), Some(PhpMixed::Float(1.5)) ); } #[test] - fn parses_scalar_string() { + fn rejects_malformed_scalar() { + assert_eq!(parse_serialized_value(b"b:2;"), None); + assert_eq!(parse_serialized_value(b"i:x;"), None); + assert_eq!(parse_serialized_value(b"d:x;"), None); + assert_eq!(parse_serialized_value(b"garbage"), None); + } + + #[test] + fn parses_list_array() { + assert_eq!( + parse_serialized_value(b"a:2:{i:0;s:1:\"a\";i:1;i:7;}"), + Some(PhpMixed::List(vec![ + PhpMixed::String("a".to_string()), + PhpMixed::Int(7), + ])), + ); + } + + #[test] + fn parses_empty_array_as_list() { assert_eq!( - parse_serialized_scalar(b"s:5:\"8.5.7\";"), - Some(PhpMixed::String("8.5.7".to_string())), + parse_serialized_value(b"a:0:{}"), + Some(PhpMixed::List(vec![])) ); } #[test] - fn rejects_malformed_scalar() { - assert_eq!(parse_serialized_scalar(b"b:2;"), None); - assert_eq!(parse_serialized_scalar(b"i:x;"), None); - assert_eq!(parse_serialized_scalar(b"d:x;"), None); - assert_eq!(parse_serialized_scalar(b"garbage"), None); + fn parses_keyed_array() { + let expected: IndexMap<String, PhpMixed> = [ + ("zip".to_string(), PhpMixed::Bool(true)), + ("apcu".to_string(), PhpMixed::Null), + ] + .into_iter() + .collect(); + assert_eq!( + parse_serialized_value(b"a:2:{s:3:\"zip\";b:1;s:4:\"apcu\";N;}"), + Some(PhpMixed::Array(expected)), + ); + } + + #[test] + fn parses_nested_array() { + let inner: IndexMap<String, PhpMixed> = [("curl".to_string(), PhpMixed::Bool(false))] + .into_iter() + .collect(); + let expected: IndexMap<String, PhpMixed> = [ + ("extensions".to_string(), PhpMixed::Array(inner)), + ("php_version_id".to_string(), PhpMixed::Int(80500)), + ] + .into_iter() + .collect(); + assert_eq!( + parse_serialized_value( + b"a:2:{s:10:\"extensions\";a:1:{s:4:\"curl\";b:0;}s:14:\"php_version_id\";i:80500;}" + ), + Some(PhpMixed::Array(expected)), + ); + } + + #[test] + fn rejects_malformed_array() { + // Count larger than the number of entries. + assert_eq!(parse_serialized_value(b"a:2:{i:0;i:1;}"), None); + // Missing closing brace. + assert_eq!(parse_serialized_value(b"a:1:{i:0;i:1;"), None); + // Non-scalar key. + assert_eq!(parse_serialized_value(b"a:1:{N;i:1;}"), None); + } + + #[test] + fn queries_string_lists_when_php_available() { + if PhpExecutableFinder::new().find(false).is_none() { + // No PHP in this environment; the worker cannot start. + return; + } + + let extensions = get_loaded_extensions(); + assert!( + extensions.iter().any(|extension| extension == "Core"), + "expected the Core extension among {extensions:?}", + ); + + // XdebugHandler::getAllIniFiles() always yields at least one entry, which is the empty + // string when no php.ini is loaded. + assert!(!get_all_ini_files().is_empty()); + } + + #[test] + fn queries_diagnostics_when_php_available() { + if PhpExecutableFinder::new().find(false).is_none() { + // No PHP in this environment; the worker cannot start. + return; + } + + let diagnostics = get_diagnostics(); + assert_eq!(diagnostics.php_version, get_php_version()); + assert!(diagnostics.php_version_id >= 70205); + let php_binary = get_php_binary(); + assert_eq!(diagnostics.php_binary.as_deref(), Some(php_binary.as_str())); + assert!(diagnostics.function_exists("json_decode")); + assert!(!diagnostics.extension_loaded("ionCube Loader")); + assert!( + diagnostics.phpinfo_general.contains("PHP Version"), + "expected phpinfo(INFO_GENERAL) output, got: {}", + diagnostics.phpinfo_general, + ); } #[test] diff --git a/crates/shirabe-php-shim/src/string.rs b/crates/shirabe-php-shim/src/string.rs index ba45bc8b..dc566bec 100644 --- a/crates/shirabe-php-shim/src/string.rs +++ b/crates/shirabe-php-shim/src/string.rs @@ -380,6 +380,16 @@ pub fn strstr(haystack: &str, needle: &str) -> Option<String> { haystack.find(needle).map(|i| haystack[i..].to_string()) } +pub fn strstr3(haystack: &str, needle: &str, before_needle: bool) -> Option<String> { + haystack.find(needle).map(|i| { + if before_needle { + haystack[..i].to_string() + } else { + haystack[i..].to_string() + } + }) +} + /// PHP's default trim character mask: " \t\n\r\0\x0B". const PHP_TRIM_DEFAULT_CHARS: &[u8] = b" \t\n\r\0\x0B"; diff --git a/crates/shirabe/src/command/diagnose_command.rs b/crates/shirabe/src/command/diagnose_command.rs index 9d0ee6f4..a34d4e96 100644 --- a/crates/shirabe/src/command/diagnose_command.rs +++ b/crates/shirabe/src/command/diagnose_command.rs @@ -34,18 +34,14 @@ use crate::util::http::ProxyManager; use crate::util::http::RequestProxy; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; -use shirabe_external_packages::composer::xdebug_handler::XdebugHandler; use shirabe_external_packages::symfony::console::command::command::Command; use shirabe_external_packages::symfony::console::input::InputInterface; use shirabe_external_packages::symfony::console::output::OutputInterface; use shirabe_external_packages::symfony::process::ExecutableFinder; use shirabe_php_shim::{ - INFO_GENERAL, InvalidArgumentException, OPENSSL_VERSION_NUMBER, OPENSSL_VERSION_TEXT, - PHP_BINARY, PHP_EOL, PHP_VERSION, PHP_VERSION_ID, PHP_WINDOWS_VERSION_BUILD, PhpMixed, defined, - disk_free_space, extension_loaded, file_exists, filter_var_boolean, function_exists, - get_class_err, hash, implode, ini_get, ioncube_loader_iversion, ioncube_loader_version, - is_array, is_string, php_regex, rtrim, str_contains, str_replace, str_starts_with, strpos, - strstr, strtolower, trim, version_compare, + InvalidArgumentException, PHP_EOL, PhpMixed, disk_free_space, file_exists, filter_var_boolean, + get_class_err, hash, implode, is_array, is_string, php_regex, rtrim, str_contains, str_replace, + str_starts_with, strpos, strstr, strstr3, strtolower, trim, version_compare, }; #[derive(Debug)] @@ -200,19 +196,20 @@ impl Command for DiagnoseCommand { io.write(&format!("PHP version: <comment>{}</comment>", php_version)); - if defined("PHP_BINARY") { + let diagnostics = shirabe_php_rpc::get_diagnostics(); + + if let Some(php_binary) = &diagnostics.php_binary { io.write(&format!( "PHP binary path: <comment>{}</comment>", - PHP_BINARY + php_binary )); } io.write(&format!( "OpenSSL version: {}", - if defined("OPENSSL_VERSION_TEXT") { - format!("<comment>{}</comment>", OPENSSL_VERSION_TEXT) - } else { - "<error>missing</error>".to_string() + match &diagnostics.openssl_version_text { + Some(text) => format!("<comment>{}</comment>", text), + None => "<error>missing</error>".to_string(), } )); io.write(&format!("curl version: {}", self.get_curl_version())); @@ -238,7 +235,7 @@ impl Command for DiagnoseCommand { io.write(&format!( "zip: {}, {}, {}{}", - if extension_loaded("zip") { + if diagnostics.extension_loaded("zip") { "<comment>extension present</comment>" } else { "<comment>extension not loaded</comment>" @@ -253,7 +250,7 @@ impl Command for DiagnoseCommand { } else { "<comment>7-Zip not available</comment>".to_string() }, - if (has_system_7zip || has_system_unzip) && !function_exists("proc_open") { + if (has_system_7zip || has_system_unzip) && !diagnostics.function_exists("proc_open") { ", <warning>proc_open is disabled or not present, unzip/7-z will not be usable</warning>" } else { "" @@ -505,7 +502,7 @@ impl DiagnoseCommand { } fn check_git(&self) -> String { - if !function_exists("proc_open") { + if !shirabe_php_rpc::get_diagnostics().function_exists("proc_open") { return "<comment>proc_open is not available, git cannot be used</comment>".to_string(); } @@ -834,7 +831,7 @@ impl DiagnoseCommand { } fn check_disk_space(&self, config: &Config) -> PhpMixed { - if !function_exists("disk_free_space") { + if !shirabe_php_rpc::get_diagnostics().function_exists("disk_free_space") { return PhpMixed::Bool(true); } @@ -1046,7 +1043,7 @@ impl DiagnoseCommand { } fn get_curl_version(&self) -> String { - if extension_loaded("curl") { + if shirabe_php_rpc::get_diagnostics().extension_loaded("curl") { if !HttpDownloader::is_curl_enabled() { return "<error>disabled via disable_functions, using php streams fallback, which reduces performance</error>".to_string(); } @@ -1124,68 +1121,76 @@ impl DiagnoseCommand { 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 diagnostics = shirabe_php_rpc::get_diagnostics(); + let mut errors: IndexMap<String, PhpMixed> = IndexMap::new(); let mut warnings: IndexMap<String, PhpMixed> = IndexMap::new(); - if !function_exists("json_decode") { + if !diagnostics.function_exists("json_decode") { errors.insert("json".to_string(), PhpMixed::Bool(true)); } - if !extension_loaded("Phar") { + if !diagnostics.extension_loaded("Phar") { errors.insert("phar".to_string(), PhpMixed::Bool(true)); } - if !extension_loaded("filter") { + if !diagnostics.extension_loaded("filter") { errors.insert("filter".to_string(), PhpMixed::Bool(true)); } - if !extension_loaded("hash") { + if !diagnostics.extension_loaded("hash") { errors.insert("hash".to_string(), PhpMixed::Bool(true)); } - if !extension_loaded("iconv") && !extension_loaded("mbstring") { + if !diagnostics.extension_loaded("iconv") && !diagnostics.extension_loaded("mbstring") { errors.insert("iconv_mbstring".to_string(), PhpMixed::Bool(true)); } - if !filter_var_boolean(ini_get("allow_url_fopen").as_deref().unwrap_or("")) { + if !filter_var_boolean(diagnostics.ini_get("allow_url_fopen").unwrap_or("")) { errors.insert("allow_url_fopen".to_string(), PhpMixed::Bool(true)); } - if extension_loaded("ionCube Loader") && ioncube_loader_iversion() < 40009 { + if diagnostics.extension_loaded("ionCube Loader") + && diagnostics.ioncube_loader_iversion < 40009 + { errors.insert( "ioncube".to_string(), - PhpMixed::String(ioncube_loader_version()), + PhpMixed::String(diagnostics.ioncube_loader_version.clone()), ); } - if PHP_VERSION_ID < 70205 { - errors.insert("php".to_string(), PhpMixed::String(PHP_VERSION.to_string())); + if diagnostics.php_version_id < 70205 { + errors.insert( + "php".to_string(), + PhpMixed::String(diagnostics.php_version.clone()), + ); } - if !extension_loaded("openssl") { + if !diagnostics.extension_loaded("openssl") { errors.insert("openssl".to_string(), PhpMixed::Bool(true)); } - if extension_loaded("openssl") && OPENSSL_VERSION_NUMBER < 0x1000100f { + if diagnostics.extension_loaded("openssl") + && diagnostics.openssl_version_number < 0x1000100f + { warnings.insert("openssl_version".to_string(), PhpMixed::Bool(true)); } - if !defined("HHVM_VERSION") - && !extension_loaded("apcu") - && filter_var_boolean(ini_get("apc.enable_cli").as_deref().unwrap_or("")) + if !diagnostics.has_hhvm_version + && !diagnostics.extension_loaded("apcu") + && filter_var_boolean(diagnostics.ini_get("apc.enable_cli").unwrap_or("")) { warnings.insert("apc_cli".to_string(), PhpMixed::Bool(true)); } - if !extension_loaded("zlib") { + if !diagnostics.extension_loaded("zlib") { warnings.insert("zlib".to_string(), PhpMixed::Bool(true)); } - let phpinfo_str = shirabe_php_rpc::get_phpinfo(INFO_GENERAL); let mut phpinfo_match: IndexMap<CaptureKey, String> = IndexMap::new(); if Preg::is_match3( php_regex!("{Configure Command(?: *</td><td class=\"v\">| *=> *)(.*?)(?:</td>|$)}m"), - &phpinfo_str, + &diagnostics.phpinfo_general, Some(&mut phpinfo_match), ) { let configure = phpinfo_match @@ -1203,27 +1208,29 @@ impl DiagnoseCommand { } } - if filter_var_boolean(ini_get("xdebug.profiler_enabled").as_deref().unwrap_or("")) { + if filter_var_boolean(diagnostics.ini_get("xdebug.profiler_enabled").unwrap_or("")) { warnings.insert("xdebug_profile".to_string(), PhpMixed::Bool(true)); - } else if XdebugHandler::is_xdebug_active() { + } else if diagnostics.xdebug_active { + // PHP: XdebugHandler::isXdebugActive(). As with IniHelper::get_all, the port of that + // method in shirabe_external_packages cannot reach the PHP RPC bridge (the dependency + // would cycle), so the real runtime is queried through the diagnose payload instead. 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", "<"))) + if diagnostics.has_php_windows_version_build + && (version_compare(&diagnostics.php_version, "7.2.23", "<") + || (version_compare(&diagnostics.php_version, "7.3.0", ">=") + && version_compare(&diagnostics.php_version, "7.3.10", "<"))) { - let _ = PHP_WINDOWS_VERSION_BUILD; warnings.insert( "onedrive".to_string(), - PhpMixed::String(PHP_VERSION.to_string()), + PhpMixed::String(diagnostics.php_version.clone()), ); } - if extension_loaded("uopz") - && !(filter_var_boolean(ini_get("uopz.disable").as_deref().unwrap_or("")) - || filter_var_boolean(ini_get("uopz.exit").as_deref().unwrap_or(""))) + if diagnostics.extension_loaded("uopz") + && !(filter_var_boolean(diagnostics.ini_get("uopz.disable").unwrap_or("")) + || filter_var_boolean(diagnostics.ini_get("uopz.exit").unwrap_or(""))) { warnings.insert("uopz".to_string(), PhpMixed::Bool(true)); } @@ -1325,13 +1332,16 @@ impl DiagnoseCommand { ), "openssl_version" => { // Attempt to parse version number out, fallback to whole string value. + let openssl_version_text = + diagnostics.openssl_version_text.clone().unwrap_or_default(); let openssl_trimmed = trim( - &strstr(OPENSSL_VERSION_TEXT, " ").unwrap_or_default(), + &strstr(&openssl_version_text, " ").unwrap_or_default(), Some(" \t\n\r\0\u{0B}"), ); - let mut openssl_version = strstr(&openssl_trimmed, " ").unwrap_or_default(); + let mut openssl_version = + strstr3(&openssl_trimmed, " ", true).unwrap_or_default(); if openssl_version.is_empty() { - openssl_version = OPENSSL_VERSION_TEXT.to_string(); + openssl_version = openssl_version_text; } format!( @@ -1401,12 +1411,9 @@ impl DiagnoseCommand { /// Check if allow_url_fopen is ON fn check_connectivity(&self) -> PhpMixed { - if !ini_get("allow_url_fopen") - .as_deref() - .and_then(|s| s.parse::<bool>().ok()) - .unwrap_or(false) - && ini_get("allow_url_fopen").as_deref() != Some("1") - { + // PHP: if (!ini_get('allow_url_fopen')) — a missing setting, "" and "0" are all falsey. + let allow_url_fopen = shirabe_php_rpc::get_diagnostics().ini_get("allow_url_fopen"); + if !allow_url_fopen.is_some_and(|value| !value.is_empty() && value != "0") { return PhpMixed::String( "<info>SKIP</> <comment>Because allow_url_fopen is missing.</>".to_string(), ); diff --git a/crates/shirabe/tests/command/diagnose_command_test.rs b/crates/shirabe/tests/command/diagnose_command_test.rs index f1bb2ef5..71f3aa07 100644 --- a/crates/shirabe/tests/command/diagnose_command_test.rs +++ b/crates/shirabe/tests/command/diagnose_command_test.rs @@ -46,11 +46,12 @@ Checking github.com rate limit: " #[test] #[serial] -#[ignore = "shirabe_php_shim::OPENSSL_VERSION_NUMBER is a hardcoded stub (0), which always trips \ - check_platform's `< 0x1000100f` TLSv1.1/1.2 support check regardless of the real \ - linked OpenSSL, forcing a non-zero exit code; diagnose also checks live http/https \ - connectivity to packagist and the github.com rate limit (as the PHP original does), \ - so the test additionally requires real network access"] +#[ignore = "check_composer_audit locates Composer's own vendor/composer/installed.json through a \ + literal relative path standing in for PHP's __DIR__, so it is never found from the \ + temporary working directory this test runs in; diagnose then reports a warning and \ + exits non-zero. diagnose also checks live http/https connectivity to packagist and \ + the github.com rate limit (as the PHP original does), so the test additionally \ + requires real network access"] fn test_cmd_success() { let tear_down = init_temp_composer( Some(&serde_json::json!({ |
