XPath   A
last analyzed

Complexity

Total Complexity 42

Size/Duplication

Total Lines 239
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 80
dl 0
loc 239
rs 9.0399
c 2
b 0
f 0
wmc 42

5 Methods

Rating   Name   Duplication   Size   Complexity  
A getXPath() 0 27 5
D registerSubtreePrefixes() 0 74 19
C registerAncestorNamespaces() 0 45 13
A findElement() 0 10 3
A xpQuery() 0 12 2

How to fix   Complexity   

Complex Class

Complex classes like XPath often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use XPath, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\XPath;
6
7
use DOMDocument;
8
use DOMElement;
9
use DOMNode;
10
use DOMXPath;
11
use RuntimeException;
12
use SimpleSAML\XML\Assert\Assert;
13
use SimpleSAML\XML\Constants as C_XML;
0 ignored issues
show
Bug introduced by
The type SimpleSAML\XML\Constants was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
14
use SimpleSAML\XMLSchema\Constants as C_XS;
0 ignored issues
show
Bug introduced by
The type SimpleSAML\XMLSchema\Constants was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
15
16
/**
17
 * XPath helper functions for the XML library.
18
 *
19
 * @package simplesamlphp/xml-common
20
 */
21
class XPath
22
{
23
    /**
24
     * Search for an element with a certain name among the children of a reference element.
25
     *
26
     * @param \DOMNode $ref The DOMDocument or DOMElement where encrypted data is expected to be found as a child.
27
     * @param string $name The name (possibly prefixed) of the element we are looking for.
28
     *
29
     * @return \DOMElement|false The element we are looking for, or false when not found.
30
     *
31
     * @throws \RuntimeException If no DOM document is available.
32
     */
33
    public static function findElement(DOMNode $ref, string $name): DOMElement|false
34
    {
35
        $doc = $ref instanceof DOMDocument ? $ref : $ref->ownerDocument;
36
        if ($doc === null) {
37
            throw new RuntimeException('Cannot search, no DOMDocument available');
38
        }
39
40
        $nodeset = self::getXPath($doc)->query('./' . $name, $ref);
41
42
        return $nodeset->item(0) ?? false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $nodeset->item(0) ?? false could return the type DOMNode which includes types incompatible with the type-hinted return DOMElement|false. Consider adding an additional type-check to rule them out.
Loading history...
43
    }
44
45
46
    /**
47
     * Get an instance of DOMXPath associated with a DOMNode
48
     *
49
     * - Reuses a cached DOMXPath per document.
50
     * - Registers core XML-related namespaces: 'xml' and 'xs'.
51
     * - Enriches the XPath with all prefixed xmlns declarations found on the
52
     *   current node and its ancestors (up to the document element), so
53
     *   custom prefixes declared anywhere up the tree can be used in queries.
54
     *
55
     * @param \DOMNode $node The associated node
56
     * @param bool $autoregister Whether to auto-register all namespaces used in the document
57
     */
58
    public static function getXPath(DOMNode $node, bool $autoregister = false): DOMXPath
59
    {
60
        static $xpCache = null;
61
62
        if ($node instanceof DOMDocument) {
63
            $doc = $node;
64
        } else {
65
            $doc = $node->ownerDocument;
66
            Assert::notNull($doc);
67
        }
68
69
        if ($xpCache === null || !$xpCache->document->isSameNode($doc)) {
70
            $xpCache = new DOMXPath($doc);
0 ignored issues
show
Bug introduced by
It seems like $doc can also be of type null; however, parameter $document of DOMXPath::__construct() does only seem to accept DOMDocument, 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

70
            $xpCache = new DOMXPath(/** @scrutinizer ignore-type */ $doc);
Loading history...
71
        }
72
73
        $xpCache->registerNamespace('xml', C_XML::NS_XML);
74
        $xpCache->registerNamespace('xs', C_XS::NS_XS);
75
76
        // Enrich with ancestor-declared prefixes for this document context.
77
        $prefixToUri = self::registerAncestorNamespaces($xpCache, $node);
78
79
        if ($autoregister) {
80
            // Single, bounded subtree scan to pick up descendant-only declarations.
81
            self::registerSubtreePrefixes($xpCache, $node, $prefixToUri);
82
        }
83
84
        return $xpCache;
85
    }
86
87
88
    /**
89
     * Walk from the given node up to the document element, registering all prefixed xmlns declarations.
90
     *
91
     * Safety:
92
     * - Only attributes in the XMLNS namespace (http://www.w3.org/2000/xmlns/).
93
     * - Skip default xmlns (localName === 'xmlns') because XPath requires prefixes.
94
     * - Skip empty URIs.
95
     * - Do not override core 'xml' and 'xs' prefixes (already bound).
96
     * - Nearest binding wins during this pass (prefixes are added once).
97
     *
98
     * @param \DOMXPath $xp
99
     * @param \DOMNode  $node
100
     * @return array<string,string> Map of prefix => namespace URI that are bound after this pass
101
     */
102
    private static function registerAncestorNamespaces(DOMXPath $xp, DOMNode $node): array
103
    {
104
        // Track prefix => uri to feed into subtree scan. Seed with core bindings.
105
        $prefixToUri = [
106
            'xml' => C_XML::NS_XML,
107
            'xs'  => C_XS::NS_XS,
108
        ];
109
110
        // Start from the nearest element (or documentElement if a DOMDocument is passed).
111
        $current = $node instanceof DOMDocument
112
            ? $node->documentElement
113
            : ($node instanceof DOMElement ? $node : $node->parentNode);
114
115
        $steps = 0;
116
117
        while ($current instanceof DOMElement) {
118
            if (++$steps > C_XML::UNBOUNDED_LIMIT) {
119
                throw new RuntimeException(__METHOD__ . ': exceeded ancestor traversal limit');
120
            }
121
122
            if ($current->hasAttributes()) {
123
                foreach ($current->attributes as $attr) {
124
                    if ($attr->namespaceURI !== C_XML::NS_XMLNS) {
125
                        continue;
126
                    }
127
                    $prefix = $attr->localName;
128
                    $uri = (string) $attr->nodeValue;
129
130
                    if (
131
                        $prefix === null || $prefix === '' ||
132
                        $prefix === 'xmlns' || $uri === '' ||
133
                        isset($prefixToUri[$prefix])
134
                    ) {
135
                        continue;
136
                    }
137
138
                    $xp->registerNamespace($prefix, $uri);
139
                    $prefixToUri[$prefix] = $uri;
140
                }
141
            }
142
143
            $current = $current->parentNode;
144
        }
145
146
        return $prefixToUri;
147
    }
148
149
150
    /**
151
     * Single-pass subtree scan from the context element to bind prefixes used only on descendants.
152
     * - Never rebind an already-registered prefix (collision-safe).
153
     * - Skips 'xmlns' and empty URIs.
154
     * - Bounded by UNBOUNDED_LIMIT.
155
     *
156
     * @param \DOMXPath $xp
157
     * @param \DOMNode  $node
158
     * @param array<string,string> $prefixToUri
159
     */
160
    private static function registerSubtreePrefixes(DOMXPath $xp, DOMNode $node, array $prefixToUri): void
161
    {
162
        $root = $node instanceof DOMDocument
163
            ? $node->documentElement
164
            : ($node instanceof DOMElement ? $node : $node->parentNode);
165
166
        if (!$root instanceof DOMElement) {
167
            return;
168
        }
169
170
//        $visited = 0;
171
172
        /** @var array<array{0:\DOMElement,1:int}> $queue */
173
        $queue = [[$root, 0]];
174
175
        while ($queue) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $queue of type array<mixed,array> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
176
            /** @var \DOMElement $el */
177
            /** @var int $depth */
178
            [$el, $depth] = array_shift($queue);
179
180
            // Depth guard: cap traversal at UNBOUNDED_LIMIT (root = depth 0).
181
            // Breaking here halts further descent to avoid pathological depth and excessive work,
182
            // which is safer in production than risking runaway traversal or hard failures.
183
            // Trade-off: deeper descendant-only prefixes may remain unregistered, so some
184
            // prefixed XPath queries might fail; overall processing continues gracefully.
185
            if ($depth >= C_XML::UNBOUNDED_LIMIT) {
186
                break;
187
            }
188
189
//            if (++$visited > C_XML::UNBOUNDED_LIMIT) {
190
//                // Safety valve: stop further traversal to avoid unbounded work and noisy exceptions.
191
//                // Returning here halts namespace registration for this subtree, which is safer in
192
//                // production than risking pathological O(n) behavior or a hard failure (e.g. throwing
193
//                // \RuntimeException(__METHOD__ . ': exceeded subtree traversal limit')).
194
//                // Trade-off: some descendant-only prefixes may remain unregistered, so related XPath
195
//                // queries might fail, but overall processing continues gracefully.
196
//                break;
197
//            }
198
199
            // Element prefix
200
            if ($el->prefix && !isset($prefixToUri[$el->prefix])) {
201
                $uri = $el->namespaceURI;
202
                if (is_string($uri) && $uri !== '') {
203
                    $xp->registerNamespace($el->prefix, $uri);
204
                    $prefixToUri[$el->prefix] = $uri;
205
                }
206
            }
207
208
            // Attribute prefixes (excluding xmlns)
209
            if ($el->hasAttributes()) {
210
                foreach ($el->attributes as $attr) {
211
                    if (
212
                        $attr->prefix &&
213
                        $attr->prefix !== 'xmlns' &&
214
                        !isset($prefixToUri[$attr->prefix])
215
                    ) {
216
                        $uri = $attr->namespaceURI;
217
                        if (is_string($uri) && $uri !== '') {
218
                            $xp->registerNamespace($attr->prefix, $uri);
219
                            $prefixToUri[$attr->prefix] = $uri;
220
                        }
221
                    } else {
222
                        // Optional: collision detection (same prefix, different URI)
223
                        // if ($prefixToUri[$pfx] !== $attr->namespaceURI) {
224
                        //     // Default: skip rebind; could log a debug message here.
225
                        // }
226
                    }
227
                }
228
            }
229
230
            // Enqueue children (only DOMElement to keep types precise)
231
            foreach ($el->childNodes as $child) {
232
                if ($child instanceof DOMElement) {
233
                    $queue[] = [$child, $depth + 1];
234
                }
235
            }
236
        }
237
    }
238
239
240
    /**
241
     * Do an XPath query on an XML node.
242
     *
243
     * @param \DOMNode $node  The XML node.
244
     * @param string $query The query.
245
     * @param \DOMXPath $xpCache The DOMXPath object
246
     * @return array<\DOMNode> Array with matching DOM nodes.
247
     */
248
    public static function xpQuery(DOMNode $node, string $query, DOMXPath $xpCache): array
249
    {
250
        $ret = [];
251
252
        $results = $xpCache->query($query, $node);
253
        Assert::notFalse($results, 'Malformed XPath query or invalid contextNode provided.');
254
255
        for ($i = 0; $i < $results->length; $i++) {
256
            $ret[$i] = $results->item($i);
257
        }
258
259
        return $ret;
260
    }
261
}
262