1<?php 2 3declare(strict_types=1); 4 5namespace JMS\Serializer; 6 7use JMS\Serializer\Exception\InvalidArgumentException; 8use JMS\Serializer\Exception\LogicException; 9use JMS\Serializer\Exception\NotAcceptableException; 10use JMS\Serializer\Exception\RuntimeException; 11use JMS\Serializer\Exception\XmlErrorException; 12use JMS\Serializer\Metadata\ClassMetadata; 13use JMS\Serializer\Metadata\PropertyMetadata; 14use JMS\Serializer\Visitor\DeserializationVisitorInterface; 15 16final class XmlDeserializationVisitor extends AbstractVisitor implements NullAwareVisitorInterface, DeserializationVisitorInterface 17{ 18 /** 19 * @var \SplStack 20 */ 21 private $objectStack; 22 23 /** 24 * @var \SplStack 25 */ 26 private $metadataStack; 27 28 /** 29 * @var \SplStack 30 */ 31 private $objectMetadataStack; 32 33 /** 34 * @var object|null 35 */ 36 private $currentObject; 37 38 /** 39 * @var ClassMetadata|PropertyMetadata|null 40 */ 41 private $currentMetadata; 42 43 /** 44 * @var bool 45 */ 46 private $disableExternalEntities = true; 47 48 /** 49 * @var string[] 50 */ 51 private $doctypeWhitelist = []; 52 53 public function __construct( 54 bool $disableExternalEntities = true, 55 array $doctypeWhitelist = [] 56 ) { 57 $this->objectStack = new \SplStack(); 58 $this->metadataStack = new \SplStack(); 59 $this->objectMetadataStack = new \SplStack(); 60 $this->disableExternalEntities = $disableExternalEntities; 61 $this->doctypeWhitelist = $doctypeWhitelist; 62 } 63 64 /** 65 * {@inheritdoc} 66 */ 67 public function prepare($data) 68 { 69 $data = $this->emptyStringToSpaceCharacter($data); 70 71 $previous = libxml_use_internal_errors(true); 72 libxml_clear_errors(); 73 74 $previousEntityLoaderState = libxml_disable_entity_loader($this->disableExternalEntities); 75 76 if (false !== stripos($data, '<!doctype')) { 77 $internalSubset = $this->getDomDocumentTypeEntitySubset($data); 78 if (!in_array($internalSubset, $this->doctypeWhitelist, true)) { 79 throw new InvalidArgumentException(sprintf( 80 'The document type "%s" is not allowed. If it is safe, you may add it to the whitelist configuration.', 81 $internalSubset 82 )); 83 } 84 } 85 86 $doc = simplexml_load_string($data); 87 88 libxml_use_internal_errors($previous); 89 libxml_disable_entity_loader($previousEntityLoaderState); 90 91 if (false === $doc) { 92 throw new XmlErrorException(libxml_get_last_error()); 93 } 94 95 return $doc; 96 } 97 98 /** 99 * @param mixed $data 100 */ 101 private function emptyStringToSpaceCharacter($data): string 102 { 103 return '' === $data ? ' ' : (string) $data; 104 } 105 106 /** 107 * {@inheritdoc} 108 */ 109 public function visitNull($data, array $type): void 110 { 111 } 112 113 /** 114 * {@inheritdoc} 115 */ 116 public function visitString($data, array $type): string 117 { 118 return (string) $data; 119 } 120 /** 121 * {@inheritdoc} 122 */ 123 public function visitBoolean($data, array $type): bool 124 { 125 $data = (string) $data; 126 127 if ('true' === $data || '1' === $data) { 128 return true; 129 } elseif ('false' === $data || '0' === $data) { 130 return false; 131 } else { 132 throw new RuntimeException(sprintf('Could not convert data to boolean. Expected "true", "false", "1" or "0", but got %s.', json_encode($data))); 133 } 134 } 135 /** 136 * {@inheritdoc} 137 */ 138 public function visitInteger($data, array $type): int 139 { 140 return (int) $data; 141 } 142 /** 143 * {@inheritdoc} 144 */ 145 public function visitDouble($data, array $type): float 146 { 147 return (float) $data; 148 } 149 /** 150 * {@inheritdoc} 151 */ 152 public function visitArray($data, array $type): array 153 { 154 // handle key-value-pairs 155 if (null !== $this->currentMetadata && $this->currentMetadata->xmlKeyValuePairs) { 156 if (2 !== count($type['params'])) { 157 throw new RuntimeException('The array type must be specified as "array<K,V>" for Key-Value-Pairs.'); 158 } 159 $this->revertCurrentMetadata(); 160 161 [$keyType, $entryType] = $type['params']; 162 163 $result = []; 164 foreach ($data as $key => $v) { 165 $k = $this->navigator->accept($key, $keyType); 166 $result[$k] = $this->navigator->accept($v, $entryType); 167 } 168 169 return $result; 170 } 171 172 $entryName = null !== $this->currentMetadata && $this->currentMetadata->xmlEntryName ? $this->currentMetadata->xmlEntryName : 'entry'; 173 $namespace = null !== $this->currentMetadata && $this->currentMetadata->xmlEntryNamespace ? $this->currentMetadata->xmlEntryNamespace : null; 174 175 if (null === $namespace && $this->objectMetadataStack->count()) { 176 $classMetadata = $this->objectMetadataStack->top(); 177 $namespace = $classMetadata->xmlNamespaces[''] ?? $namespace; 178 if (null === $namespace) { 179 $namespaces = $data->getDocNamespaces(); 180 if (isset($namespaces[''])) { 181 $namespace = $namespaces['']; 182 } 183 } 184 } 185 186 if (null !== $namespace) { 187 $prefix = uniqid('ns-'); 188 $data->registerXPathNamespace($prefix, $namespace); 189 $nodes = $data->xpath(sprintf('%s:%s', $prefix, $entryName)); 190 } else { 191 $nodes = $data->xpath($entryName); 192 } 193 194 if (!\count($nodes)) { 195 return []; 196 } 197 198 switch (\count($type['params'])) { 199 case 0: 200 throw new RuntimeException(sprintf('The array type must be specified either as "array<T>", or "array<K,V>".')); 201 202 case 1: 203 $result = []; 204 205 foreach ($nodes as $v) { 206 $result[] = $this->navigator->accept($v, $type['params'][0]); 207 } 208 209 return $result; 210 211 case 2: 212 if (null === $this->currentMetadata) { 213 throw new RuntimeException('Maps are not supported on top-level without metadata.'); 214 } 215 216 [$keyType, $entryType] = $type['params']; 217 $result = []; 218 219 $nodes = $data->children($namespace)->$entryName; 220 foreach ($nodes as $v) { 221 $attrs = $v->attributes(); 222 if (!isset($attrs[$this->currentMetadata->xmlKeyAttribute])) { 223 throw new RuntimeException(sprintf('The key attribute "%s" must be set for each entry of the map.', $this->currentMetadata->xmlKeyAttribute)); 224 } 225 226 $k = $this->navigator->accept($attrs[$this->currentMetadata->xmlKeyAttribute], $keyType); 227 $result[$k] = $this->navigator->accept($v, $entryType); 228 } 229 230 return $result; 231 232 default: 233 throw new LogicException(sprintf('The array type does not support more than 2 parameters, but got %s.', json_encode($type['params']))); 234 } 235 } 236 /** 237 * {@inheritdoc} 238 */ 239 public function visitDiscriminatorMapProperty($data, ClassMetadata $metadata): string 240 { 241 switch (true) { 242 // Check XML attribute without namespace for discriminatorFieldName 243 case $metadata->xmlDiscriminatorAttribute && null === $metadata->xmlDiscriminatorNamespace && isset($data->attributes()->{$metadata->discriminatorFieldName}): 244 return (string) $data->attributes()->{$metadata->discriminatorFieldName}; 245 246 // Check XML attribute with namespace for discriminatorFieldName 247 case $metadata->xmlDiscriminatorAttribute && null !== $metadata->xmlDiscriminatorNamespace && isset($data->attributes($metadata->xmlDiscriminatorNamespace)->{$metadata->discriminatorFieldName}): 248 return (string) $data->attributes($metadata->xmlDiscriminatorNamespace)->{$metadata->discriminatorFieldName}; 249 250 // Check XML element with namespace for discriminatorFieldName 251 case !$metadata->xmlDiscriminatorAttribute && null !== $metadata->xmlDiscriminatorNamespace && isset($data->children($metadata->xmlDiscriminatorNamespace)->{$metadata->discriminatorFieldName}): 252 return (string) $data->children($metadata->xmlDiscriminatorNamespace)->{$metadata->discriminatorFieldName}; 253 // Check XML element for discriminatorFieldName 254 case isset($data->{$metadata->discriminatorFieldName}): 255 return (string) $data->{$metadata->discriminatorFieldName}; 256 257 default: 258 throw new LogicException(sprintf( 259 'The discriminator field name "%s" for base-class "%s" was not found in input data.', 260 $metadata->discriminatorFieldName, 261 $metadata->name 262 )); 263 } 264 } 265 266 public function startVisitingObject(ClassMetadata $metadata, object $object, array $type): void 267 { 268 $this->setCurrentObject($object); 269 $this->objectMetadataStack->push($metadata); 270 } 271 /** 272 * {@inheritdoc} 273 */ 274 public function visitProperty(PropertyMetadata $metadata, $data) 275 { 276 $name = $metadata->serializedName; 277 278 if (!$metadata->type) { 279 throw new RuntimeException(sprintf('You must define a type for %s::$%s.', $metadata->class, $metadata->name)); 280 } 281 if (true === $metadata->inline) { 282 return $this->navigator->accept($data, $metadata->type); 283 } 284 if ($metadata->xmlAttribute) { 285 $attributes = $data->attributes($metadata->xmlNamespace); 286 if (isset($attributes[$name])) { 287 return $this->navigator->accept($attributes[$name], $metadata->type); 288 } 289 290 throw new NotAcceptableException(); 291 } 292 293 if ($metadata->xmlValue) { 294 return $this->navigator->accept($data, $metadata->type); 295 } 296 297 if ($metadata->xmlCollection) { 298 $enclosingElem = $data; 299 if (!$metadata->xmlCollectionInline) { 300 $enclosingElem = $data->children($metadata->xmlNamespace)->$name; 301 } 302 303 $this->setCurrentMetadata($metadata); 304 $v = $this->navigator->accept($enclosingElem, $metadata->type); 305 $this->revertCurrentMetadata(); 306 return $v; 307 } 308 309 if ($metadata->xmlNamespace) { 310 $node = $data->children($metadata->xmlNamespace)->$name; 311 if (!$node->count()) { 312 throw new NotAcceptableException(); 313 } 314 } else { 315 $namespaces = $data->getDocNamespaces(); 316 if (isset($namespaces[''])) { 317 $prefix = uniqid('ns-'); 318 $data->registerXPathNamespace($prefix, $namespaces['']); 319 $nodes = $data->xpath('./' . $prefix . ':' . $name); 320 } else { 321 $nodes = $data->xpath('./' . $name); 322 } 323 if (empty($nodes)) { 324 throw new NotAcceptableException(); 325 } 326 $node = reset($nodes); 327 } 328 329 if ($metadata->xmlKeyValuePairs) { 330 $this->setCurrentMetadata($metadata); 331 } 332 333 return $this->navigator->accept($node, $metadata->type); 334 } 335 336 /** 337 * {@inheritdoc} 338 */ 339 public function endVisitingObject(ClassMetadata $metadata, $data, array $type): object 340 { 341 $rs = $this->currentObject; 342 $this->objectMetadataStack->pop(); 343 $this->revertCurrentObject(); 344 345 return $rs; 346 } 347 348 public function setCurrentObject(object $object): void 349 { 350 $this->objectStack->push($this->currentObject); 351 $this->currentObject = $object; 352 } 353 354 public function getCurrentObject(): ?object 355 { 356 return $this->currentObject; 357 } 358 359 public function revertCurrentObject(): ?object 360 { 361 return $this->currentObject = $this->objectStack->pop(); 362 } 363 364 public function setCurrentMetadata(PropertyMetadata $metadata): void 365 { 366 $this->metadataStack->push($this->currentMetadata); 367 $this->currentMetadata = $metadata; 368 } 369 370 /** 371 * @return ClassMetadata|PropertyMetadata|null 372 */ 373 public function getCurrentMetadata() 374 { 375 return $this->currentMetadata; 376 } 377 378 /** 379 * @return ClassMetadata|PropertyMetadata|null 380 */ 381 public function revertCurrentMetadata() 382 { 383 return $this->currentMetadata = $this->metadataStack->pop(); 384 } 385 386 /** 387 * {@inheritdoc} 388 */ 389 public function getResult($data) 390 { 391 return $data; 392 } 393 394 /** 395 * Retrieves internalSubset even in bugfixed php versions 396 */ 397 private function getDomDocumentTypeEntitySubset(string $data): string 398 { 399 $startPos = $endPos = stripos($data, '<!doctype'); 400 $braces = 0; 401 do { 402 $char = $data[$endPos++]; 403 if ('<' === $char) { 404 ++$braces; 405 } 406 if ('>' === $char) { 407 --$braces; 408 } 409 } while ($braces > 0); 410 411 $internalSubset = substr($data, $startPos, $endPos - $startPos); 412 $internalSubset = str_replace(["\n", "\r"], '', $internalSubset); 413 $internalSubset = preg_replace('/\s{2,}/', ' ', $internalSubset); 414 $internalSubset = str_replace(['[ <!', '> ]>'], ['[<!', '>]>'], $internalSubset); 415 416 return $internalSubset; 417 } 418 419 /** 420 * {@inheritdoc} 421 */ 422 public function isNull($value): bool 423 { 424 if ($value instanceof \SimpleXMLElement) { 425 // Workaround for https://bugs.php.net/bug.php?id=75168 and https://github.com/schmittjoh/serializer/issues/817 426 // If the "name" is empty means that we are on an not-existent node and subsequent operations on the object will trigger the warning: 427 // "Node no longer exists" 428 if ('' === $value->getName()) { 429 // @todo should be "true", but for collections needs a default collection value. maybe something for the 2.0 430 return false; 431 } 432 433 $xsiAttributes = $value->attributes('http://www.w3.org/2001/XMLSchema-instance'); 434 if (isset($xsiAttributes['nil']) 435 && ('true' === (string) $xsiAttributes['nil'] || '1' === (string) $xsiAttributes['nil']) 436 ) { 437 return true; 438 } 439 } 440 441 return null === $value; 442 } 443} 444