diff --git a/ext/xmlrpc/Module.php b/ext/xmlrpc/Module.php index 88393aa567e..ca8295e92ca 100644 --- a/ext/xmlrpc/Module.php +++ b/ext/xmlrpc/Module.php @@ -10,7 +10,7 @@ * xmlrpc extension module entry (php-src ext/xmlrpc/xmlrpc.c; issue #6579, #18503). * * PHP-in-PHP XML-RPC encode/decode — no runtime/*.c growth. Advertise logical {@code xmlrpc} - * extension and xmlrpc_encode()/xmlrpc_decode() when + * extension and xmlrpc_encode()/xmlrpc_decode() plus request/type helpers when * {@see XmlrpcExtensionPolicy::advertisesExtension()} — withheld on reference profile * (Zend 8.2 has no ext/xmlrpc). */ @@ -33,6 +33,11 @@ public function getFunctions(): array return [ new xmlrpc_encode(), new xmlrpc_decode(), + new xmlrpc_encode_request(), + new xmlrpc_decode_request(), + new xmlrpc_is_fault(), + new xmlrpc_get_type(), + new xmlrpc_set_type(), ]; } } diff --git a/ext/xmlrpc/VmXmlrpc.php b/ext/xmlrpc/VmXmlrpc.php index a5be3ba9d6d..462a6598b2e 100644 --- a/ext/xmlrpc/VmXmlrpc.php +++ b/ext/xmlrpc/VmXmlrpc.php @@ -8,12 +8,15 @@ use PHPCompiler\VM\Variable; /** - * XML-RPC encode/decode (php-src ext/xmlrpc/xmlrpc-epi-php.c; #6579). + * XML-RPC encode/decode + request/type helpers (php-src ext/xmlrpc/xmlrpc-epi-php.c; #6579, #22254). * * PHP-in-PHP value serialization — string XML parser (no host DOMDocument; #19048 AOT). */ final class VmXmlrpc { + /** Struct key used by {@see setType()} / {@see getType()} / encode for base64|datetime. */ + public const TYPED_VALUE_KEY = '__xmlrpc_type__'; + private static ?string $lastError = null; public static function getLastError(): string @@ -66,24 +69,271 @@ public static function decode(string $xml) } } + /** + * xmlrpc_encode_request() — methodCall / fault response XML (#22254). + * + * php-src: ext/xmlrpc/xmlrpc-epi-php.c — PHP_FUNCTION(xmlrpc_encode_request) + */ + public static function encodeRequest(?string $method, Variable $params): string + { + self::$lastError = null; + $resolved = $params->resolveIndirect(); + if (null === $method || '' === $method) { + if (Variable::TYPE_ARRAY === $resolved->type && self::isFaultArray($resolved->toArray())) { + return self::encodeFaultResponse($resolved); + } + + return self::encodeMethodResponse($resolved); + } + + $paramValues = self::requestParamList($resolved); + $out = ''."\n" + .''."\n" + .''.self::escapeXml($method).''."\n" + .''."\n"; + foreach ($paramValues as $param) { + $out .= ''.self::encodeValue($param).''."\n"; + } + + return $out.''."\n".''; + } + + /** + * xmlrpc_decode_request() — methodCall/methodResponse → PHP value (#22254). + * + * @param-out string $method + * + * @return mixed|false + */ + public static function decodeRequest(string $xml, ?string &$method) + { + self::$lastError = null; + $method = ''; + $xml = trim($xml); + if ('' === $xml || !str_contains($xml, '<')) { + self::$lastError = 'Invalid XML'; + + return false; + } + + $callInner = self::extractBalancedElementInner($xml, 'methodCall'); + if (null !== $callInner) { + $nameInner = self::extractBalancedElementInner($callInner, 'methodName'); + $method = null !== $nameInner ? trim($nameInner) : ''; + $paramsInner = self::extractBalancedElementInner($callInner, 'params'); + if (null === $paramsInner) { + return []; + } + + return self::decodeParamsList($paramsInner); + } + + $responseInner = self::extractBalancedElementInner($xml, 'methodResponse'); + if (null !== $responseInner) { + $faultInner = self::extractBalancedElementInner($responseInner, 'fault'); + if (null !== $faultInner) { + $valueInner = self::extractBalancedElementInner($faultInner, 'value'); + if (null === $valueInner) { + self::$lastError = 'Invalid XML-RPC fault'; + + return false; + } + + return self::decodeValueString($valueInner); + } + $paramsInner = self::extractBalancedElementInner($responseInner, 'params'); + if (null !== $paramsInner) { + $list = self::decodeParamsList($paramsInner); + + return 1 === \count($list) ? $list[0] : $list; + } + $valueInner = self::extractBalancedElementInner($responseInner, 'value'); + if (null !== $valueInner) { + return self::decodeValueString($valueInner); + } + } + + return self::decode($xml); + } + + /** + * xmlrpc_is_fault() — faultCode + faultString array (#22254). + */ + public static function isFault(Variable $arg): bool + { + $arg = $arg->resolveIndirect(); + if (Variable::TYPE_ARRAY !== $arg->type) { + return false; + } + + return self::isFaultArray($arg->toArray()); + } + + /** + * xmlrpc_get_type() — XML-RPC type name for a PHP value (#22254). + */ + public static function getType(Variable $value): string + { + $value = $value->resolveIndirect(); + if (Variable::TYPE_OBJECT === $value->type) { + $object = $value->toObject(); + if ($object->hasProperty('xmlrpc_type')) { + $typeProp = $object->getProperty('xmlrpc_type')->resolveIndirect(); + if (Variable::TYPE_STRING === $typeProp->type) { + $typed = strtolower($typeProp->toString(null)); + if ('base64' === $typed || 'datetime' === $typed) { + return $typed; + } + } + } + + return 'struct'; + } + if (Variable::TYPE_ARRAY === $value->type) { + $typed = self::typedArrayKind($value->toArray()); + if (null !== $typed) { + return $typed; + } + + return $value->toArray()->isPackedList() ? 'array' : 'struct'; + } + + return match ($value->type) { + Variable::TYPE_NULL => 'null', + Variable::TYPE_BOOLEAN => 'boolean', + Variable::TYPE_INTEGER => 'int', + Variable::TYPE_FLOAT => 'double', + Variable::TYPE_STRING => 'string', + default => 'unknown', + }; + } + + /** + * xmlrpc_set_type() — mark string as base64/datetime for encode (#22254). + * + * Stored as a struct array with {@see TYPED_VALUE_KEY} so by-ref call sites keep + * the payload (stdClass dynamic props were cleared across builtin return). + */ + public static function setType(Variable $valueSlot, string $type): bool + { + $type = strtolower(trim($type)); + if ('datetime.iso8601' === $type) { + $type = 'datetime'; + } + if ('base64' !== $type && 'datetime' !== $type) { + return false; + } + + $resolved = $valueSlot->resolveIndirect(); + $scalar = match ($resolved->type) { + Variable::TYPE_STRING => $resolved->toString(null), + Variable::TYPE_INTEGER => (string) $resolved->toInt(null), + Variable::TYPE_FLOAT => self::formatDouble($resolved->toFloat(null)), + Variable::TYPE_BOOLEAN => $resolved->toBool(null) ? '1' : '0', + Variable::TYPE_NULL => '', + default => (string) $resolved->toString(null), + }; + + $ht = new HashTable(); + $typeVar = new Variable(); + $typeVar->string($type); + $ht->add(self::TYPED_VALUE_KEY, $typeVar); + $scalarVar = new Variable(); + $scalarVar->string($scalar); + $ht->add('scalar', $scalarVar); + if ('datetime' === $type) { + $ts = strtotime($scalar); + $tsVar = new Variable(); + $tsVar->int(false === $ts ? 0 : $ts); + $ht->add('timestamp', $tsVar); + } + $resolved->array($ht); + + return true; + } + private static function encodeValue(Variable $value): string { - switch ($value->type) { - case Variable::TYPE_NULL: - return ''; - case Variable::TYPE_BOOLEAN: - return ''.($value->toBool(null) ? '1' : '0').''; - case Variable::TYPE_INTEGER: - return ''.$value->toInt(null).''; - case Variable::TYPE_FLOAT: - return ''.self::formatDouble($value->toFloat(null)).''; - case Variable::TYPE_STRING: - return ''.self::escapeXml($value->toString(null)).''; - case Variable::TYPE_ARRAY: - return self::encodeArray($value->toArray()); - default: - throw new \Exception('Cannot xmlrpc_encode() value of type '.$value->type); + $value = $value->resolveIndirect(); + if (Variable::TYPE_OBJECT === $value->type) { + $object = $value->toObject(); + if ($object->hasProperty('xmlrpc_type')) { + $typeProp = $object->getProperty('xmlrpc_type')->resolveIndirect(); + if (Variable::TYPE_STRING === $typeProp->type) { + $typed = strtolower($typeProp->toString(null)); + $scalar = ''; + if ($object->hasProperty('scalar')) { + $scalarProp = $object->getProperty('scalar')->resolveIndirect(); + if (Variable::TYPE_STRING === $scalarProp->type) { + $scalar = $scalarProp->toString(null); + } + } + if ('base64' === $typed) { + return ''.base64_encode($scalar).''; + } + if ('datetime' === $typed) { + return ''.self::escapeXml($scalar).''; + } + } + } + + throw new \Exception('Cannot xmlrpc_encode() value of type object'); + } + if (Variable::TYPE_ARRAY === $value->type) { + $table = $value->toArray(); + $typed = self::typedArrayKind($table); + if (null !== $typed) { + $scalar = self::typedArrayScalar($table); + if ('base64' === $typed) { + return ''.base64_encode($scalar).''; + } + + return ''.self::escapeXml($scalar).''; + } + + return self::encodeArray($table); } + + return match ($value->type) { + Variable::TYPE_NULL => '', + Variable::TYPE_BOOLEAN => ''.($value->toBool(null) ? '1' : '0').'', + Variable::TYPE_INTEGER => ''.$value->toInt(null).'', + Variable::TYPE_FLOAT => ''.self::formatDouble($value->toFloat(null)).'', + Variable::TYPE_STRING => ''.self::escapeXml($value->toString(null)).'', + default => throw new \Exception('Cannot xmlrpc_encode() value of type '.$value->type), + }; + } + + private static function typedArrayKind(HashTable $table): ?string + { + foreach (iterator_to_array($table->iterateKeyed(true), false) as [$key, $element]) { + if (self::TYPED_VALUE_KEY !== self::arrayKeyToString($key->resolveIndirect())) { + continue; + } + $element = $element->resolveIndirect(); + if (Variable::TYPE_STRING !== $element->type) { + return null; + } + $typed = strtolower($element->toString(null)); + + return ('base64' === $typed || 'datetime' === $typed) ? $typed : null; + } + + return null; + } + + private static function typedArrayScalar(HashTable $table): string + { + foreach (iterator_to_array($table->iterateKeyed(true), false) as [$key, $element]) { + if ('scalar' !== self::arrayKeyToString($key->resolveIndirect())) { + continue; + } + $element = $element->resolveIndirect(); + + return Variable::TYPE_STRING === $element->type ? $element->toString(null) : ''; + } + + return ''; } private static function encodeArray(HashTable $table): string @@ -108,6 +358,93 @@ private static function encodeArray(HashTable $table): string return $out.''; } + /** + * @return list + */ + private static function requestParamList(Variable $params): array + { + if (Variable::TYPE_ARRAY !== $params->type) { + return [$params]; + } + $table = $params->toArray(); + if (!$table->isPackedList()) { + return [$params]; + } + $out = []; + foreach (iterator_to_array($table->iterateKeyed(true), false) as [, $element]) { + $out[] = $element; + } + + return $out; + } + + private static function encodeMethodResponse(Variable $value): string + { + return ''."\n" + .''."\n" + .''."\n" + .''.self::encodeValue($value).''."\n" + .''."\n" + .''; + } + + private static function encodeFaultResponse(Variable $fault): string + { + return ''."\n" + .''."\n" + .''."\n" + .''.self::encodeValue($fault).''."\n" + .''."\n" + .''; + } + + private static function isFaultArray(HashTable $table): bool + { + $hasCode = false; + $hasString = false; + foreach (iterator_to_array($table->iterateKeyed(true), false) as [$key, $element]) { + $name = strtolower(self::arrayKeyToString($key->resolveIndirect())); + if ('faultcode' === $name) { + $hasCode = true; + } elseif ('faultstring' === $name) { + $hasString = true; + } + } + + return $hasCode && $hasString; + } + + /** + * @return list + */ + private static function decodeParamsList(string $paramsInner): array + { + $out = []; + $pos = 0; + $len = \strlen($paramsInner); + while ($pos < $len) { + if (!preg_match('/]*)?>/i', $paramsInner, $match, PREG_OFFSET_CAPTURE, $pos)) { + break; + } + $sliceStart = $match[0][1]; + $paramInner = self::extractBalancedElementInner(\substr($paramsInner, $sliceStart), 'param'); + if (null === $paramInner) { + break; + } + $valueInner = self::extractBalancedElementInner($paramInner, 'value'); + if (null !== $valueInner) { + $out[] = self::decodeValueString($valueInner); + } + $closePos = stripos($paramsInner, '', $sliceStart); + if (false === $closePos) { + break; + } + $pos = $closePos + 8; + } + + return $out; + } + private static function arrayKeyToString(Variable $key): string { return match ($key->type) { @@ -179,11 +516,11 @@ private static function decodeValueString(string $valueInner) if ('<' !== $valueInner[0]) { return $valueInner; } - if (!preg_match('/^<([a-zA-Z0-9]+)/', $valueInner, $tagMatch)) { + if (!preg_match('/^<([a-zA-Z0-9.]+)/', $valueInner, $tagMatch)) { return $valueInner; } $tag = strtolower($tagMatch[1]); - $typedInner = self::extractBalancedElementInner($valueInner, $tag); + $typedInner = self::extractBalancedElementInner($valueInner, $tagMatch[1]); if (null === $typedInner) { return trim($valueInner); } @@ -195,6 +532,7 @@ private static function decodeValueString(string $valueInner) 'double', 'float' => self::parseDouble($typedInner), 'string' => $typedInner, 'base64' => self::decodeBase64($typedInner), + 'datetime.iso8601' => $typedInner, 'array' => self::decodeArrayString($typedInner), 'struct' => self::decodeStructString($typedInner), default => $typedInner, diff --git a/ext/xmlrpc/xmlrpc_decode_request.php b/ext/xmlrpc/xmlrpc_decode_request.php new file mode 100644 index 00000000000..0c2b01312f7 --- /dev/null +++ b/ext/xmlrpc/xmlrpc_decode_request.php @@ -0,0 +1,57 @@ +calledArgs); + if ($argc < 2 || $argc > 3) { + throw new \ArgumentCountError(\sprintf( + 'xmlrpc_decode_request() expects 2 or 3 arguments, %d given', + $argc + )); + } + if (null === $frame->returnVar) { + return; + } + $xml = VmString::coerceStringBuiltinArg( + $frame->calledArgs[0], + 'xmlrpc_decode_request', + 0, + 'xml' + ); + if (isset($frame->calledArgs[2])) { + VmString::coerceStringBuiltinArg( + $frame->calledArgs[2], + 'xmlrpc_decode_request', + 2, + 'encoding' + ); + } + $method = ''; + $decoded = VmXmlrpc::decodeRequest($xml, $method); + $methodSlot = $frame->calledArgs[1]->resolveIndirect(); + $methodSlot->string($method); + if (false === $decoded) { + $frame->returnVar->bool(false); + + return; + } + $frame->returnVar->copyFrom(VmJson::import($decoded)); + } +} diff --git a/ext/xmlrpc/xmlrpc_encode_request.php b/ext/xmlrpc/xmlrpc_encode_request.php new file mode 100644 index 00000000000..5ff0c9b8306 --- /dev/null +++ b/ext/xmlrpc/xmlrpc_encode_request.php @@ -0,0 +1,45 @@ +calledArgs); + if ($argc < 2 || $argc > 3) { + throw new \ArgumentCountError(\sprintf( + 'xmlrpc_encode_request() expects 2 or 3 arguments, %d given', + $argc + )); + } + if (null === $frame->returnVar) { + return; + } + $methodArg = $frame->calledArgs[0]->resolveIndirect(); + $method = null; + if (Variable::TYPE_NULL !== $methodArg->type) { + $method = VmString::coerceStringBuiltinArg( + $frame->calledArgs[0], + 'xmlrpc_encode_request', + 0, + 'method' + ); + } + // Optional $output_options accepted for signature parity; ignored in v1. + $xml = VmXmlrpc::encodeRequest($method, $frame->calledArgs[1]); + $frame->returnVar->string($xml); + } +} diff --git a/ext/xmlrpc/xmlrpc_get_type.php b/ext/xmlrpc/xmlrpc_get_type.php new file mode 100644 index 00000000000..cfe17cd2755 --- /dev/null +++ b/ext/xmlrpc/xmlrpc_get_type.php @@ -0,0 +1,25 @@ +requireExactArgCount($frame, 'xmlrpc_get_type', 1); + if (null === $frame->returnVar) { + return; + } + $frame->returnVar->string(VmXmlrpc::getType($frame->calledArgs[0])); + } +} diff --git a/ext/xmlrpc/xmlrpc_is_fault.php b/ext/xmlrpc/xmlrpc_is_fault.php new file mode 100644 index 00000000000..0a3a2070a7d --- /dev/null +++ b/ext/xmlrpc/xmlrpc_is_fault.php @@ -0,0 +1,25 @@ +requireExactArgCount($frame, 'xmlrpc_is_fault', 1); + if (null === $frame->returnVar) { + return; + } + $frame->returnVar->bool(VmXmlrpc::isFault($frame->calledArgs[0])); + } +} diff --git a/ext/xmlrpc/xmlrpc_set_type.php b/ext/xmlrpc/xmlrpc_set_type.php new file mode 100644 index 00000000000..e9193fe8cc3 --- /dev/null +++ b/ext/xmlrpc/xmlrpc_set_type.php @@ -0,0 +1,33 @@ +requireExactArgCount($frame, 'xmlrpc_set_type', 2); + $type = VmString::coerceStringBuiltinArg( + $frame->calledArgs[1], + 'xmlrpc_set_type', + 1, + 'type' + ); + $ok = VmXmlrpc::setType($frame->calledArgs[0], $type); + if (null === $frame->returnVar) { + return; + } + $frame->returnVar->bool($ok); + } +} diff --git a/lib/BuiltinByRefParams.php b/lib/BuiltinByRefParams.php index 8fa6ba4e2cf..7cfb3757d18 100644 --- a/lib/BuiltinByRefParams.php +++ b/lib/BuiltinByRefParams.php @@ -257,6 +257,12 @@ public static function forFunction(string $name): array case 'curl_multi_info_read': // php-src ext/curl/multi.c / curl.stub.php — &$queued_messages (#20495) return [1]; + case 'xmlrpc_decode_request': + // php-src ext/xmlrpc — &$method (#22254) + return [1]; + case 'xmlrpc_set_type': + // php-src ext/xmlrpc — &$value (#22254) + return [0]; case 'enchant_dict_quick_check': // php-src ext/enchant/enchant.stub.php — &$suggestions (#20613) return [2]; diff --git a/test/compliance/cases/stdlib/xmlrpc_request_type_helpers.phpt b/test/compliance/cases/stdlib/xmlrpc_request_type_helpers.phpt new file mode 100644 index 00000000000..5be413412cd --- /dev/null +++ b/test/compliance/cases/stdlib/xmlrpc_request_type_helpers.phpt @@ -0,0 +1,52 @@ +--TEST-- +stdlib xmlrpc encode_request/decode_request/is_fault/get_type/set_type (#22254, ext/xmlrpc) +--SKIPIF-- + 1, 'faultString' => 'boom']; +echo 'is_fault=', xmlrpc_is_fault($fault) ? '1' : '0', "\n"; +echo 'not_fault=', xmlrpc_is_fault(['x' => 1]) ? '1' : '0', "\n"; + +echo 'type_int=', xmlrpc_get_type(42), "\n"; +echo 'type_arr=', xmlrpc_get_type([1, 2]), "\n"; +echo 'type_struct=', xmlrpc_get_type(['a' => 1]), "\n"; + +$bin = 'hi'; +echo 'set=', xmlrpc_set_type($bin, 'base64') ? '1' : '0', "\n"; +echo 'type_b64=', xmlrpc_get_type($bin), "\n"; +$enc = xmlrpc_encode($bin); +echo 'b64_xml=', str_contains($enc, '') ? '1' : '0', "\n"; +--EXPECT-- +xmlrpc_encode_request=1 +xmlrpc_decode_request=1 +xmlrpc_is_fault=1 +xmlrpc_get_type=1 +xmlrpc_set_type=1 +method=demo.add +params=ok +is_fault=1 +not_fault=0 +type_int=int +type_arr=array +type_struct=struct +set=1 +type_b64=base64 +b64_xml=1 diff --git a/test/repro/issue_22254_xmlrpc_request_types.php b/test/repro/issue_22254_xmlrpc_request_types.php new file mode 100644 index 00000000000..7ff19bb1b5c --- /dev/null +++ b/test/repro/issue_22254_xmlrpc_request_types.php @@ -0,0 +1,14 @@ + 1, 'faultString' => 'x']) ? '1' : '0', PHP_EOL; diff --git a/test/unit/XmlrpcModuleTest.php b/test/unit/XmlrpcModuleTest.php index e92c8605bde..43d52e25c37 100644 --- a/test/unit/XmlrpcModuleTest.php +++ b/test/unit/XmlrpcModuleTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase; /** - * xmlrpc extension encode/decode (issue #6579, #18503). + * xmlrpc extension encode/decode + request/type helpers (issue #6579, #18503, #22254). * * @group xmlrpc */ @@ -21,7 +21,15 @@ public function test_xmlrpc_withheld_on_reference_profile(): void $runtime = new Runtime(); $ctx = $runtime->vmContext; - foreach (['xmlrpc_encode', 'xmlrpc_decode'] as $fn) { + foreach ([ + 'xmlrpc_encode', + 'xmlrpc_decode', + 'xmlrpc_encode_request', + 'xmlrpc_decode_request', + 'xmlrpc_is_fault', + 'xmlrpc_get_type', + 'xmlrpc_set_type', + ] as $fn) { self::assertFalse(VmReflection::functionExists($ctx, $fn), $fn); } @@ -29,12 +37,13 @@ public function test_xmlrpc_withheld_on_reference_profile(): void parseAndCompile($code, 'xmlrpc_module.php'); ob_start(); $runtime->run($block); - self::assertSame('000', ob_get_clean()); + self::assertSame('0000', ob_get_clean()); } public function test_xmlrpc_registered_and_extension_loaded_on_forward_profile(): void @@ -47,7 +56,15 @@ public function test_xmlrpc_registered_and_extension_loaded_on_forward_profile() $runtime = new Runtime(); $ctx = $runtime->vmContext; - foreach (['xmlrpc_encode', 'xmlrpc_decode'] as $fn) { + foreach ([ + 'xmlrpc_encode', + 'xmlrpc_decode', + 'xmlrpc_encode_request', + 'xmlrpc_decode_request', + 'xmlrpc_is_fault', + 'xmlrpc_get_type', + 'xmlrpc_set_type', + ] as $fn) { self::assertTrue(VmReflection::functionExists($ctx, $fn), $fn); } @@ -55,12 +72,13 @@ public function test_xmlrpc_registered_and_extension_loaded_on_forward_profile() parseAndCompile($code, 'xmlrpc_module.php'); ob_start(); $runtime->run($block); - self::assertSame('111', ob_get_clean()); + self::assertSame('1111', ob_get_clean()); } finally { if (false === $prev) { putenv('PHP_COMPILER_PROFILE'); @@ -96,4 +114,28 @@ public function test_xmlrpc_struct_and_scalar_roundtrip_on_forward_profile(): vo } } } + + public function test_xmlrpc_request_type_helpers_roundtrip_on_forward_profile(): void + { + $prev = getenv('PHP_COMPILER_PROFILE'); + putenv('PHP_COMPILER_PROFILE=8.4'); + try { + $runtime = new Runtime(); + $code = file_get_contents(__DIR__.'/../repro/issue_22254_xmlrpc_request_types.php'); + $block = $runtime->parseAndCompile($code, 'issue_22254_xmlrpc_request_types.php'); + ob_start(); + $runtime->run($block); + $out = ob_get_clean(); + self::assertStringContainsString('xmlrpc_encode_request=true', $out); + self::assertStringContainsString('method=demo.add', $out); + self::assertStringContainsString('params_ok=1', $out); + self::assertStringContainsString('is_fault=1', $out); + } finally { + if (false === $prev) { + putenv('PHP_COMPILER_PROFILE'); + } else { + putenv('PHP_COMPILER_PROFILE='.$prev); + } + } + } }