Issues (218)

src/XmlDeserializationVisitor.php (11 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
namespace JMS\Serializer;
6
7
use JMS\Serializer\Exception\InvalidArgumentException;
8
use JMS\Serializer\Exception\LogicException;
9
use JMS\Serializer\Exception\NotAcceptableException;
10
use JMS\Serializer\Exception\RuntimeException;
11
use JMS\Serializer\Exception\XmlErrorException;
12
use JMS\Serializer\Metadata\ClassMetadata;
13
use JMS\Serializer\Metadata\PropertyMetadata;
14
use JMS\Serializer\Visitor\DeserializationVisitorInterface;
15
16
final class XmlDeserializationVisitor extends AbstractVisitor implements NullAwareVisitorInterface, DeserializationVisitorInterface
17
{
18
    /**
19
     * @var \SplStack
20
     */
21
    private $objectStack;
22
23
    /**
24
     * @var \SplStack
25
     */
26 72
    private $metadataStack;
27
28
    /**
29 72
     * @var \SplStack
30 72
     */
31 72
    private $objectMetadataStack;
32 72
33 72
    /**
34 72
     * @var object|null
35
     */
36 71
    private $currentObject;
37
38 71
    /**
39
     * @var ClassMetadata|PropertyMetadata|null
40 71
     */
41 71
    private $currentMetadata;
42
43 71
    /**
44
     * @var bool
45 71
     */
46 2
    private $disableExternalEntities;
47 2
48 2
    /**
49 2
     * @var string[]
50 2
     */
51
    private $doctypeAllowList;
52
    /**
53
     * @var int
54
     */
55 69
    private $options;
56
57 69
    public function __construct(
58 69
        bool $disableExternalEntities = true,
59
        array $doctypeAllowList = [],
60 69
        int $options = 0
61 1
    ) {
62
        $this->objectStack = new \SplStack();
63
        $this->metadataStack = new \SplStack();
64 68
        $this->objectMetadataStack = new \SplStack();
65
        $this->disableExternalEntities = $disableExternalEntities;
66
        $this->doctypeAllowList = $doctypeAllowList;
67 71
        $this->options = $options;
68
    }
69 71
70
    /**
71
     * {@inheritdoc}
72 13
     */
73
    public function prepare($data)
74
    {
75 13
        $data = $this->emptyStringToSpaceCharacter($data);
76
77 25
        $previous = libxml_use_internal_errors(true);
78
        libxml_clear_errors();
79 25
80
        $previousEntityLoaderState = null;
81
        if (\LIBXML_VERSION < 20900) {
82 8
            // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated
83
            $previousEntityLoaderState = libxml_disable_entity_loader($this->disableExternalEntities);
84 8
        }
85
86 8
        if (false !== stripos($data, '<!doctype')) {
87 4
            $internalSubset = $this->getDomDocumentTypeEntitySubset($data);
88 5
            if (!in_array($internalSubset, $this->doctypeAllowList, true)) {
89 5
                throw new InvalidArgumentException(sprintf(
90
                    'The document type "%s" is not allowed. If it is safe, you may add it to the allowlist configuration.',
91
                    $internalSubset,
92
                ));
93
            }
94
        }
95 8
96
        $doc = simplexml_load_string($data, 'SimpleXMLElement', $this->options);
97 8
98
        libxml_use_internal_errors($previous);
99
100 10
        if (\LIBXML_VERSION < 20900) {
101
            // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated
102 10
            libxml_disable_entity_loader($previousEntityLoaderState);
0 ignored issues
show
It seems like $previousEntityLoaderState can also be of type null; however, parameter $disable of libxml_disable_entity_loader() does only seem to accept boolean, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

102
            libxml_disable_entity_loader(/** @scrutinizer ignore-type */ $previousEntityLoaderState);
Loading history...
103
        }
104
105 18
        if (false === $doc) {
106
            throw new XmlErrorException(libxml_get_last_error());
107
        }
108 18
109 2
        return $doc;
110
    }
111
112 2
    /**
113
     * @param mixed $data
114 2
     */
115
    private function emptyStringToSpaceCharacter($data): string
116 2
    {
117 2
        return '' === $data ? ' ' : (string) $data;
118 2
    }
119 2
120
    /**
121
     * {@inheritdoc}
122 2
     */
123
    public function visitNull($data, array $type)
124
    {
125 18
        return null;
126 18
    }
127
128 18
    /**
129 13
     * {@inheritdoc}
130 13
     */
131 13
    public function visitString($data, array $type): string
132 10
    {
133 10
        $this->assertValueCanBeCastToString($data);
134 1
135
        return (string) $data;
136
    }
137
138
    /**
139 18
     * {@inheritdoc}
140 5
     */
141 5
    public function visitBoolean($data, array $type): bool
142 5
    {
143
        $this->assertValueCanBeCastToString($data);
144 14
145
        $data = (string) $data;
146
147 18
        if ('true' === $data || '1' === $data) {
148 4
            return true;
149
        } elseif ('false' === $data || '0' === $data) {
150
            return false;
151 18
        } else {
152 18
            throw new RuntimeException(sprintf('Could not convert data to boolean. Expected "true", "false", "1" or "0", but got %s.', json_encode($data)));
153
        }
154
    }
155 18
156 18
    /**
157
     * {@inheritdoc}
158 18
     */
159 18
    public function visitInteger($data, array $type): int
160
    {
161
        $this->assertValueCanBeCastToInt($data);
162 18
163
        return (int) $data;
164 4
    }
165 4
166
    /**
167
     * {@inheritdoc}
168
     */
169 4
    public function visitDouble($data, array $type): float
170 4
    {
171
        $this->assertValueCanCastToFloat($data);
172 4
173 4
        return (float) $data;
174 4
    }
175 4
176
    /**
177
     * {@inheritdoc}
178
     */
179 4
    public function visitArray($data, array $type): array
180 4
    {
181
        // handle key-value-pairs
182
        if (null !== $this->currentMetadata && $this->currentMetadata->xmlKeyValuePairs) {
0 ignored issues
show
The property xmlKeyValuePairs does not seem to exist on JMS\Serializer\Metadata\ClassMetadata.
Loading history...
183 4
            if (2 !== count($type['params'])) {
184
                throw new RuntimeException('The array type must be specified as "array<K,V>" for Key-Value-Pairs.');
185
            }
186
187
            $this->revertCurrentMetadata();
188
189
            [$keyType, $entryType] = $type['params'];
190 8
191
            $result = [];
192
            foreach ($data as $key => $v) {
193
                $k = $this->navigator->accept($key, $keyType);
0 ignored issues
show
The method accept() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

193
                /** @scrutinizer ignore-call */ 
194
                $k = $this->navigator->accept($key, $keyType);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
194 8
                $result[$k] = $this->navigator->accept($v, $entryType);
195 1
            }
196
197
            return $result;
198 7
        }
199 1
200
        $entryName = null !== $this->currentMetadata && $this->currentMetadata->xmlEntryName ? $this->currentMetadata->xmlEntryName : 'entry';
0 ignored issues
show
The property xmlEntryName does not seem to exist on JMS\Serializer\Metadata\ClassMetadata.
Loading history...
201
        $namespace = null !== $this->currentMetadata && $this->currentMetadata->xmlEntryNamespace ? $this->currentMetadata->xmlEntryNamespace : null;
0 ignored issues
show
The property xmlEntryNamespace does not exist on JMS\Serializer\Metadata\ClassMetadata. Did you mean xmlNamespace?
Loading history...
202 6
203 1
        if (null === $namespace && $this->objectMetadataStack->count()) {
204
            $classMetadata = $this->objectMetadataStack->top();
205 5
            $namespace = $classMetadata->xmlNamespaces[''] ?? $namespace;
206 4
            if (null === $namespace) {
207
                $namespaces = $data->getDocNamespaces();
208
                if (isset($namespaces[''])) {
209 1
                    $namespace = $namespaces[''];
210 1
                }
211 1
            }
212 1
        }
213
214
        if (null !== $namespace) {
215
            $prefix = uniqid('ns-');
216
            $data->registerXPathNamespace($prefix, $namespace);
217 34
            $nodes = $data->xpath(sprintf('%s:%s', $prefix, $entryName));
218
        } else {
219 34
            $nodes = $data->xpath($entryName);
220 34
        }
221 34
222
        if (null === $nodes || !\count($nodes)) {
223 30
            return [];
224
        }
225 30
226
        switch (\count($type['params'])) {
227 30
            case 0:
228
                throw new RuntimeException(sprintf('The array type must be specified either as "array<T>", or "array<K,V>".'));
229
230
            case 1:
231 30
                $result = [];
232
233 6
                foreach ($nodes as $v) {
234 6
                    $result[] = $this->navigator->accept($v, $type['params'][0]);
235 6
                }
236
237
                return $result;
238
239
            case 2:
240
                if (null === $this->currentMetadata) {
241 30
                    throw new RuntimeException('Maps are not supported on top-level without metadata.');
242 7
                }
243
244
                [$keyType, $entryType] = $type['params'];
245 28
                $result = [];
246 7
247 7
                $nodes = $data->children($namespace)->$entryName;
248 6
                foreach ($nodes as $v) {
249
                    $attrs = $v->attributes();
250
                    if (!isset($attrs[$this->currentMetadata->xmlKeyAttribute])) {
0 ignored issues
show
The property xmlKeyAttribute does not exist on JMS\Serializer\Metadata\ClassMetadata. Did you mean xmlAttribute?
Loading history...
251 7
                        throw new RuntimeException(sprintf('The key attribute "%s" must be set for each entry of the map.', $this->currentMetadata->xmlKeyAttribute));
252 7
                    }
253 7
254 7
                    $k = $this->navigator->accept($attrs[$this->currentMetadata->xmlKeyAttribute], $keyType);
255
                    $result[$k] = $this->navigator->accept($v, $entryType);
256
                }
257 27
258 4
                return $result;
259 4
260 4
            default:
261
                throw new LogicException(sprintf('The array type does not support more than 2 parameters, but got %s.', json_encode($type['params'])));
262
        }
263
    }
264 24
265
    /**
266 24
     * {@inheritdoc}
267 2
     */
268 2
    public function visitDiscriminatorMapProperty($data, ClassMetadata $metadata): string
269 2
    {
270
        switch (true) {
271 22
            // Check XML attribute without namespace for discriminatorFieldName
272
            case $metadata->xmlDiscriminatorAttribute && null === $metadata->xmlDiscriminatorNamespace && isset($data->attributes()->{$metadata->discriminatorFieldName}):
273 24
                return (string) $data->attributes()->{$metadata->discriminatorFieldName};
274 2
275
            // Check XML attribute with namespace for discriminatorFieldName
276 24
            case $metadata->xmlDiscriminatorAttribute && null !== $metadata->xmlDiscriminatorNamespace && isset($data->attributes($metadata->xmlDiscriminatorNamespace)->{$metadata->discriminatorFieldName}):
277
                return (string) $data->attributes($metadata->xmlDiscriminatorNamespace)->{$metadata->discriminatorFieldName};
278
279 26
            // Check XML element with namespace for discriminatorFieldName
280 2
            case !$metadata->xmlDiscriminatorAttribute && null !== $metadata->xmlDiscriminatorNamespace && isset($data->children($metadata->xmlDiscriminatorNamespace)->{$metadata->discriminatorFieldName}):
281
                return (string) $data->children($metadata->xmlDiscriminatorNamespace)->{$metadata->discriminatorFieldName};
282
283 26
            // Check XML element for discriminatorFieldName
284
            case isset($data->{$metadata->discriminatorFieldName}):
285
                return (string) $data->{$metadata->discriminatorFieldName};
286
287
            default:
288
                throw new LogicException(sprintf(
289
                    'The discriminator field name "%s" for base-class "%s" was not found in input data.',
290
                    $metadata->discriminatorFieldName,
291
                    $metadata->name,
292 34
                ));
293
        }
294 34
    }
295 34
296 34
    public function startVisitingObject(ClassMetadata $metadata, object $object, array $type): void
297
    {
298 34
        $this->setCurrentObject($object);
299
        $this->objectMetadataStack->push($metadata);
300
    }
301 34
302
    /**
303 34
     * {@inheritdoc}
304 34
     */
305 34
    public function visitProperty(PropertyMetadata $metadata, $data)
306
    {
307
        $name = $metadata->serializedName;
308
309
        if (true === $metadata->inline) {
310
            if (!$metadata->type) {
311
                throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);
312 34
            }
313
314 34
            return $this->navigator->accept($data, $metadata->type);
0 ignored issues
show
$metadata->type of type JMS\Serializer\Metadata\TypeArray is incompatible with the type array|null expected by parameter $type of JMS\Serializer\GraphNavigatorInterface::accept(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

314
            return $this->navigator->accept($data, /** @scrutinizer ignore-type */ $metadata->type);
Loading history...
315
        }
316
317 9
        if ($metadata->xmlAttribute) {
318
            $attributes = $data->attributes($metadata->xmlNamespace);
319 9
            if (isset($attributes[$name])) {
320 9
                if (!$metadata->type) {
321 9
                    throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);
322
                }
323
324
                return $this->navigator->accept($attributes[$name], $metadata->type);
325
            }
326
327
            throw new NotAcceptableException();
328 9
        }
329
330 9
        if (0 === strpos($name, '@')) {
0 ignored issues
show
It seems like $name can also be of type null; however, parameter $haystack of strpos() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

330
        if (0 === strpos(/** @scrutinizer ignore-type */ $name, '@')) {
Loading history...
331
            $attributeName = substr($name, 1);
0 ignored issues
show
It seems like $name can also be of type null; however, parameter $string of substr() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

331
            $attributeName = substr(/** @scrutinizer ignore-type */ $name, 1);
Loading history...
332
            $attributes = $data->attributes($metadata->xmlNamespace);
333 67
334
            if (isset($attributes[$attributeName])) {
335 67
                if (!$metadata->type) {
336
                    throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);
337
                }
338
339
                return $this->navigator->accept($attributes[$attributeName], $metadata->type);
340
            }
341
342
            throw new NotAcceptableException(sprintf('Attribute "%s" (derived from serializedName "%s") in namespace "%s" not found for property %s::$%s. XML: %s', $attributeName, $name, $metadata->xmlNamespace ?? '[none]', $metadata->class, $metadata->name, $data->asXML()));
343
        }
344 2
345
        if (false !== strpos($name, '/@')) {
346 2
            [$elementName, $attributeName] = explode('/@', $name, 2);
0 ignored issues
show
It seems like $name can also be of type null; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

346
            [$elementName, $attributeName] = explode('/@', /** @scrutinizer ignore-type */ $name, 2);
Loading history...
347 2
348
            $childDataNode = null;
349 2
            if ('' === $metadata->xmlNamespace) {
350 2
                // Element explicitly in NO namespace
351 2
                $xpathQuery = "./*[local-name()='" . $elementName . "' and (namespace-uri()='' or not(namespace-uri()))]";
352
                $matchingNodes = $data->xpath($xpathQuery);
353 2
                if (!empty($matchingNodes)) {
354 2
                    $childDataNode = $matchingNodes[0];
355
                }
356 2
            } elseif ($metadata->xmlNamespace) {
357
                // Element in a specific namespace URI
358 2
                $childrenInNs = $data->children($metadata->xmlNamespace);
359 2
                if (isset($childrenInNs->$elementName)) {
360 2
                    $childDataNode = $childrenInNs->$elementName;
361 2
                }
362
            } else {
363 2
                // xmlNamespace is null: element in default namespace (or no namespace if no default is active)
364
                $childrenInDefaultOrNoNs = $data->children(null);
365
                if (isset($childrenInDefaultOrNoNs->$elementName)) {
366
                    $childDataNode = $childrenInDefaultOrNoNs->$elementName;
367
                }
368
            }
369
370
            if (!$childDataNode || !$childDataNode->getName()) {
371 69
                if (null === $metadata->xmlNamespace) {
372
                    $ns = '[default/none]';
373 69
                } else {
374
                    $ns = '' === $metadata->xmlNamespace ? '[none]' : $metadata->xmlNamespace;
375
                }
376
377 69
                throw new NotAcceptableException(sprintf('Child element "%s" for attribute access not found (element namespace: %s). Property %s::$%s. XML: %s', $elementName, $ns, $metadata->class, $metadata->name, $data->asXML()));
378
            }
379 2
380
            $attributeTargetNs = $metadata->xmlNamespace && '' !== $metadata->xmlNamespace ? $metadata->xmlNamespace : null;
381
            $attributes = $childDataNode->attributes($attributeTargetNs);
382 69
383 69
            if (isset($attributes[$attributeName])) {
384 69
                if (!$metadata->type) {
385
                    throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);
386 14
                }
387
388
                return $this->navigator->accept($attributes[$attributeName], $metadata->type);
389
            }
390 57
391
            throw new NotAcceptableException(sprintf('Attribute "%s" on element "%s" not found (attribute namespace: %s). Property %s::$%s. XML: %s', $attributeName, $elementName, $attributeTargetNs ?? '[none]', $metadata->class, $metadata->name, $data->asXML()));
392
        }
393
394
        if ($metadata->xmlValue) {
395
            if (!$metadata->type) {
396
                throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);
397
            }
398
399
            return $this->navigator->accept($data, $metadata->type);
400
        }
401
402
        if ($metadata->xmlCollection) {
403
            $enclosingElem = $data;
404
            if (!$metadata->xmlCollectionInline) {
405
                $enclosingElem = $data->children($metadata->xmlNamespace)->$name;
406
            }
407
408
            $this->setCurrentMetadata($metadata);
409
            if (!$metadata->type) {
410
                throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);
411
            }
412
413
            $v = $this->navigator->accept($enclosingElem, $metadata->type);
414
            $this->revertCurrentMetadata();
415
416
            return $v;
417
        }
418
419
        if ($metadata->xmlNamespace) {
420
            $node = $data->children($metadata->xmlNamespace)->$name;
421
            if (!$node->count()) {
422
                throw new NotAcceptableException();
423
            }
424
        } elseif ('' === $metadata->xmlNamespace) {
425
            // See #1087 - element must be like: <element xmlns="" /> - https://www.w3.org/TR/REC-xml-names/#iri-use
426
            // Use of an empty string in a namespace declaration turns it into an "undeclaration".
427
            $nodes = $data->xpath('./' . $name);
428
            if (empty($nodes)) {
429
                throw new NotAcceptableException();
430
            }
431
432
            $node = reset($nodes);
433
        } else {
434
            $namespaces = $data->getDocNamespaces();
435
            if (isset($namespaces[''])) {
436
                $prefix = uniqid('ns-');
437
                $data->registerXPathNamespace($prefix, $namespaces['']);
438
                $nodes = $data->xpath('./' . $prefix . ':' . $name);
439
            } else {
440
                $nodes = $data->xpath('./' . $name);
441
            }
442
443
            if (empty($nodes)) {
444
                throw new NotAcceptableException();
445
            }
446
447
            $node = reset($nodes);
448
        }
449
450
        if ($metadata->xmlKeyValuePairs) {
451
            $this->setCurrentMetadata($metadata);
452
        }
453
454
        if (!$metadata->type) {
455
            throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);
456
        }
457
458
        return $this->navigator->accept($node, $metadata->type);
459
    }
460
461
    /**
462
     * {@inheritdoc}
463
     */
464
    public function endVisitingObject(ClassMetadata $metadata, $data, array $type): object
465
    {
466
        $rs = $this->currentObject;
467
        $this->objectMetadataStack->pop();
468
        $this->revertCurrentObject();
469
470
        return $rs;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $rs could return the type null which is incompatible with the type-hinted return object. Consider adding an additional type-check to rule them out.
Loading history...
471
    }
472
473
    public function setCurrentObject(object $object): void
474
    {
475
        $this->objectStack->push($this->currentObject);
476
        $this->currentObject = $object;
477
    }
478
479
    public function getCurrentObject(): ?object
480
    {
481
        return $this->currentObject;
482
    }
483
484
    public function revertCurrentObject(): ?object
485
    {
486
        return $this->currentObject = $this->objectStack->pop();
487
    }
488
489
    public function setCurrentMetadata(PropertyMetadata $metadata): void
490
    {
491
        $this->metadataStack->push($this->currentMetadata);
492
        $this->currentMetadata = $metadata;
493
    }
494
495
    /**
496
     * @return ClassMetadata|PropertyMetadata|null
497
     */
498
    public function getCurrentMetadata()
499
    {
500
        return $this->currentMetadata;
501
    }
502
503
    /**
504
     * @return ClassMetadata|PropertyMetadata|null
505
     */
506
    public function revertCurrentMetadata()
507
    {
508
        return $this->currentMetadata = $this->metadataStack->pop();
509
    }
510
511
    /**
512
     * {@inheritdoc}
513
     */
514
    public function getResult($data)
515
    {
516
        $this->navigator = null;
517
518
        return $data;
519
    }
520
521
    /**
522
     * Retrieves internalSubset even in bugfixed php versions
523
     */
524
    private function getDomDocumentTypeEntitySubset(string $data): string
525
    {
526
        $startPos = $endPos = stripos($data, '<!doctype');
527
        $braces = 0;
528
        do {
529
            $char = $data[$endPos++];
530
            if ('<' === $char) {
531
                ++$braces;
532
            }
533
534
            if ('>' === $char) {
535
                --$braces;
536
            }
537
        } while ($braces > 0);
538
539
        $internalSubset = substr($data, $startPos, $endPos - $startPos);
540
        $internalSubset = str_replace(["\n", "\r"], '', $internalSubset);
541
        $internalSubset = preg_replace('/\s{2,}/', ' ', $internalSubset);
542
        $internalSubset = str_replace(['[ <!', '> ]>'], ['[<!', '>]>'], $internalSubset);
543
544
        return $internalSubset;
545
    }
546
547
    /**
548
     * {@inheritdoc}
549
     */
550
    public function isNull($value): bool
551
    {
552
        if ($value instanceof \SimpleXMLElement) {
553
            // Workaround for https://bugs.php.net/bug.php?id=75168 and https://github.com/schmittjoh/serializer/issues/817
554
            // If the "name" is empty means that we are on a nonexistent node and subsequent operations on the object will trigger the warning:
555
            // "Node no longer exists"
556
            if ('' === $value->getName()) {
557
                // @todo should be "true", but for collections needs a default collection value. maybe something for the 2.0
558
                return false;
559
            }
560
561
            $xsiAttributes = $value->attributes('http://www.w3.org/2001/XMLSchema-instance');
562
            if (
563
                isset($xsiAttributes['nil'])
564
                && ('true' === (string) $xsiAttributes['nil'] || '1' === (string) $xsiAttributes['nil'])
565
            ) {
566
                return true;
567
            }
568
        }
569
570
        return null === $value;
571
    }
572
}
573