From 5f982837952715f96724aaa989317b29b059328b Mon Sep 17 00:00:00 2001 From: PurHur Date: Fri, 21 Aug 2026 19:21:10 +0000 Subject: [PATCH] Stdlib: SoapClient APACHE_MAP decode to assoc array (#3724) Match php-src to_zval_map so xsi:type Map responses become PHP arrays instead of lists of {key,value} objects. Co-authored-by: Cursor --- ext/soap/VmSoapClient.php | 111 ++++++++++++++++++ .../stdlib/soap_client_apache_map_decode.phpt | 42 +++++++ .../issue_3724_soap_apache_map_decode.php | 35 ++++++ test/unit/SoapApacheMapDecodeTest.php | 75 ++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 test/compliance/cases/stdlib/soap_client_apache_map_decode.phpt create mode 100644 test/repro/issue_3724_soap_apache_map_decode.php create mode 100644 test/unit/SoapApacheMapDecodeTest.php diff --git a/ext/soap/VmSoapClient.php b/ext/soap/VmSoapClient.php index 36ce8a90afb..67c70ca5fa4 100644 --- a/ext/soap/VmSoapClient.php +++ b/ext/soap/VmSoapClient.php @@ -2947,6 +2947,105 @@ private static function encodeSoapAnyXml(mixed $inner): string return self::soapVarRawString($inner); } + /** + * php-src APACHE_MAP / apache Map — xsi:type local name Map (#32222 / to_zval_map). + */ + private static function isApacheMapElement(\DOMElement $el, ?string $hintType): bool + { + [$local, $ns] = self::xsiTypeNameAndNs($el); + if ('Map' === $local) { + return null === $ns || SoapConstants::APACHE_NAMESPACE === $ns; + } + if ('Map' === $hintType) { + return true; + } + + return false; + } + + /** + * php-src to_zval_map — item/key/value children → PHP array (#3724, ext/soap/php_encoding.c). + * + * @param list<\DOMElement> $childElements + * @param array $classmap + * @param list $typemap + * @param array $elementTypes + * @param array> $complexTypeFields + * + * @return array|null + */ + private static function decodeApacheMap( + \DOMElement $el, + array $childElements, + bool $singleElementArrays, + array $classmap, + array $typemap, + array $elementTypes, + array $complexTypeFields + ): ?array { + if (0 === \count($childElements)) { + if ($el->hasAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'nil')) { + return null; + } + + return null; + } + $out = []; + foreach ($childElements as $item) { + $itemLocal = $item->localName ?? $item->nodeName; + if ('item' !== $itemLocal) { + continue; + } + $xmlKey = null; + $xmlValue = null; + foreach ($item->childNodes as $child) { + if (!$child instanceof \DOMElement) { + continue; + } + $ln = $child->localName ?? $child->nodeName; + if ('key' === $ln && null === $xmlKey) { + $xmlKey = $child; + } elseif ('value' === $ln && null === $xmlValue) { + $xmlValue = $child; + } + } + if (null === $xmlKey) { + throw new \SoapFault('Client', "Encoding: Can't decode apache map, missing key"); + } + if (null === $xmlValue) { + throw new \SoapFault('Client', "Encoding: Can't decode apache map, missing value"); + } + $key = self::domElementToValue( + $xmlKey, + $singleElementArrays, + $classmap, + $typemap, + $elementTypes, + null, + $complexTypeFields + ); + $value = self::domElementToValue( + $xmlValue, + $singleElementArrays, + $classmap, + $typemap, + $elementTypes, + null, + $complexTypeFields + ); + if (\is_string($key) || \is_int($key)) { + $out[$key] = $value; + } else { + throw new \SoapFault( + 'Client', + "Encoding: Can't decode apache map, only Strings or Longs are allowed as keys" + ); + } + } + + return $out; + } + /** * php-src to_xml_map — APACHE_MAP item/key/value (#32222). * @@ -3598,6 +3697,18 @@ private static function domElementToValue( $childElements[] = $child; } } + // php-src to_zval_map — APACHE_MAP xsi:type Map → PHP assoc array (#3724). + if (self::isApacheMapElement($el, $hintType)) { + return self::decodeApacheMap( + $el, + $childElements, + $singleElementArrays, + $classmap, + $typemap, + $elementTypes, + $complexTypeFields + ); + } if (0 === \count($childElements)) { $text = $el->textContent; if ('' === \trim($text) && $el->hasAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'nil')) { diff --git a/test/compliance/cases/stdlib/soap_client_apache_map_decode.phpt b/test/compliance/cases/stdlib/soap_client_apache_map_decode.phpt new file mode 100644 index 00000000000..d1b3c2be25e --- /dev/null +++ b/test/compliance/cases/stdlib/soap_client_apache_map_decode.phpt @@ -0,0 +1,42 @@ +--TEST-- +stdlib SoapClient APACHE_MAP response decode to assoc array (#3724, php_encoding.c to_zval_map) +--FILE-- +' + .'' + .'' + .'' + .'kv' + .'7x' + .'' + .''); +$c = new SoapClient(null, [ + 'location' => $resp, + 'uri' => 'http://example.com/echo', + 'exceptions' => true, + 'style' => SOAP_RPC, + 'use' => SOAP_ENCODED, +]); +$out = $c->__soapCall('echo', []); +@unlink($resp); +@rmdir($dir); +echo is_array($out) ? 'array' : gettype($out), "\n"; +echo ($out === ['k' => 'v', 7 => 'x']) ? 'match' : 'mismatch', "\n"; +var_export($out); +echo "\n"; +?> +--EXPECT-- +array +match +array ( + 'k' => 'v', + 7 => 'x', +) diff --git a/test/repro/issue_3724_soap_apache_map_decode.php b/test/repro/issue_3724_soap_apache_map_decode.php new file mode 100644 index 00000000000..9bb5dbec16c --- /dev/null +++ b/test/repro/issue_3724_soap_apache_map_decode.php @@ -0,0 +1,35 @@ +' + .'' + .'' + .'' + .'kv' + .'7x' + .'' + .''; + +$rm = new ReflectionMethod(VmSoapClient::class, 'decodeResponse'); +$rm->setAccessible(true); +$out = $rm->invoke(null, $xml, 'echo'); +var_export($out); +echo "\n"; diff --git a/test/unit/SoapApacheMapDecodeTest.php b/test/unit/SoapApacheMapDecodeTest.php new file mode 100644 index 00000000000..16111850ea8 --- /dev/null +++ b/test/unit/SoapApacheMapDecodeTest.php @@ -0,0 +1,75 @@ +decode($this->envelope( + '' + .'kv' + .'7x' + .'' + )); + $this->assertIsArray($out); + $this->assertSame(['k' => 'v', 7 => 'x'], $out); + } + + public function testEmptyMapIsNull(): void + { + $out = $this->decode($this->envelope( + '' + )); + $this->assertNull($out); + } + + public function testMissingKeyThrowsSoapFault(): void + { + $this->expectException(\SoapFault::class); + $this->expectExceptionMessage("Encoding: Can't decode apache map, missing key"); + $this->decode($this->envelope( + '' + .'v' + .'' + )); + } + + private function envelope(string $bodyInner): string + { + return '' + .'' + .'' + .$bodyInner + .''; + } + + private function decode(string $xml): mixed + { + $rm = new \ReflectionMethod(VmSoapClient::class, 'decodeResponse'); + $rm->setAccessible(true); + + return $rm->invoke(null, $xml, 'echo'); + } +}