aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--crates/shirabe-php-rpc/Cargo.toml1
-rw-r--r--crates/shirabe-php-rpc/php/worker.php95
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs452
-rw-r--r--crates/shirabe-php-shim/src/string.rs10
-rw-r--r--crates/shirabe/src/command/diagnose_command.rs119
-rw-r--r--crates/shirabe/tests/command/diagnose_command_test.rs11
-rw-r--r--docs/dev/php-rpc.md7
8 files changed, 551 insertions, 145 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 54ff59ce..a61ab57e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2122,6 +2122,7 @@ name = "shirabe-php-rpc"
version = "0.0.1"
dependencies = [
"anyhow",
+ "indexmap",
"shirabe-external-packages",
"shirabe-php-shim",
"tempfile",
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!({
diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md
index 1ec81710..6e0bc40c 100644
--- a/docs/dev/php-rpc.md
+++ b/docs/dev/php-rpc.md
@@ -10,12 +10,12 @@ system PHP as a child process and asks it for runtime information over a Unix do
The crate supports exactly one interaction pattern, and nothing else:
-> Rust calls a named PHP function, passing a single string argument, and receives a single scalar
+> Rust calls a named PHP function, passing a single string argument, and receives a single value
> back.
- Rust to PHP only. PHP never calls back into Rust.
- Exactly one argument, and it must be a string.
-- Scalar return values only (string / int / float / bool / null).
+- Return values are scalars (string / int / float / bool / null) or arrays of them.
- Every failure `panic!`s: a PHP exception (not currently possible — the glue script never lets
one escape), a serialization/deserialization failure, a missing PHP function, a crashed child,
etc. None are handled as recoverable errors. This is not the final design — a future revision
@@ -35,8 +35,7 @@ Reuse the existing `PhpExecutableFinder` class to resolve the PHP binary.
- Request payload: the PHP function name as raw bytes, followed by a `\0` byte and the string
argument (function names are static literals and never contain `\0`, so the first `\0`
unambiguously separates name from argument).
- - Response payload: `serialize()` of the function's return value — any of `N;` (null), `b:0/1;`
- (bool), `i:<n>;` (int), `d:<f>;` (float), or `s:<len>:"<bytes>";` (string).
+ - Response payload: `serialize()` of the function's return value.
The PHP worker is a single read-eval-respond loop: read a framed function name and argument, call
the matching entry in a fixed dispatch table (`defined`, `constant`), send back