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