Skip to content

Commit 4988cc7

Browse files
authored
Merge pull request #74 from ioigoume/xpath-ancestor-xmlns-registration
xpath ancestor registration
2 parents 8f56ca3 + 66bbb30 commit 4988cc7

File tree

8 files changed

+548
-3
lines changed

8 files changed

+548
-3
lines changed

composer.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,21 @@
5959
"phpstan/extension-installer": true,
6060
"simplesamlphp/composer-xmlprovider-installer": true
6161
}
62+
},
63+
"scripts": {
64+
"pre-commit": [
65+
"vendor/bin/phpcs -p",
66+
"vendor/bin/composer-require-checker check --config-file=tools/composer-require-checker.json composer.json",
67+
"vendor/bin/phpstan analyze -c phpstan.neon",
68+
"vendor/bin/phpstan analyze -c phpstan-dev.neon",
69+
"vendor/bin/composer-unused --excludePackage=simplesamlphp/composer-xmlprovider-installer",
70+
"vendor/bin/phpunit --no-coverage --testdox"
71+
],
72+
"tests": [
73+
"vendor/bin/phpunit --no-coverage"
74+
],
75+
"propose-fix": [
76+
"vendor/bin/phpcs --report=diff"
77+
]
6278
}
6379
}

src/XML/Constants.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ class Constants
1616
*/
1717
public const NS_XML = 'http://www.w3.org/XML/1998/namespace';
1818

19+
/**
20+
* The namespace for XMLNS declarations.
21+
*/
22+
public const NS_XMLNS = 'http://www.w3.org/2000/xmlns/';
23+
1924
/**
2025
* The maximum amount of child nodes this library is willing to handle.
2126
* By specification, this limit is 150K, but that opens up for denial of service.

src/XPath/XPath.php

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
namespace SimpleSAML\XPath;
66

77
use DOMDocument;
8+
use DOMElement;
89
use DOMNode;
910
use DOMXPath;
11+
use RuntimeException;
1012
use SimpleSAML\XML\Assert\Assert;
1113
use SimpleSAML\XML\Constants as C_XML;
1214
use SimpleSAML\XMLSchema\Constants as C_XS;
@@ -21,6 +23,12 @@ class XPath
2123
/**
2224
* Get an instance of DOMXPath associated with a DOMNode
2325
*
26+
* - Reuses a cached DOMXPath per document.
27+
* - Registers core XML-related namespaces: 'xml' and 'xs'.
28+
* - Enriches the XPath with all prefixed xmlns declarations found on the
29+
* current node and its ancestors (up to the document element), so
30+
* custom prefixes declared anywhere up the tree can be used in queries.
31+
*
2432
* @param \DOMNode $node The associated node
2533
* @return \DOMXPath
2634
*/
@@ -42,10 +50,152 @@ public static function getXPath(DOMNode $node): DOMXPath
4250
$xpCache->registerNamespace('xml', C_XML::NS_XML);
4351
$xpCache->registerNamespace('xs', C_XS::NS_XS);
4452

53+
// Enrich with ancestor-declared prefixes for this document context.
54+
$prefixToUri = self::registerAncestorNamespaces($xpCache, $node);
55+
56+
// Single, bounded subtree scan to pick up descendant-only declarations.
57+
self::registerSubtreePrefixes($xpCache, $node, $prefixToUri);
58+
4559
return $xpCache;
4660
}
4761

4862

63+
/**
64+
* Walk from the given node up to the document element, registering all prefixed xmlns declarations.
65+
*
66+
* Safety:
67+
* - Only attributes in the XMLNS namespace (http://www.w3.org/2000/xmlns/).
68+
* - Skip default xmlns (localName === 'xmlns') because XPath requires prefixes.
69+
* - Skip empty URIs.
70+
* - Do not override core 'xml' and 'xs' prefixes (already bound).
71+
* - Nearest binding wins during this pass (prefixes are added once).
72+
*
73+
* @param \DOMXPath $xp
74+
* @param \DOMNode $node
75+
* @return array<string,string> Map of prefix => namespace URI that are bound after this pass
76+
*/
77+
private static function registerAncestorNamespaces(DOMXPath $xp, DOMNode $node): array
78+
{
79+
// Track prefix => uri to feed into subtree scan. Seed with core bindings.
80+
$prefixToUri = [
81+
'xml' => C_XML::NS_XML,
82+
'xs' => C_XS::NS_XS,
83+
];
84+
85+
// Start from the nearest element (or documentElement if a DOMDocument is passed).
86+
$current = $node instanceof DOMDocument
87+
? $node->documentElement
88+
: ($node instanceof DOMElement ? $node : $node->parentNode);
89+
90+
$steps = 0;
91+
92+
while ($current instanceof DOMElement) {
93+
if (++$steps > C_XML::UNBOUNDED_LIMIT) {
94+
throw new RuntimeException(__METHOD__ . ': exceeded ancestor traversal limit');
95+
}
96+
97+
if ($current->hasAttributes()) {
98+
foreach ($current->attributes as $attr) {
99+
if ($attr->namespaceURI !== C_XML::NS_XMLNS) {
100+
continue;
101+
}
102+
$prefix = $attr->localName;
103+
$uri = (string) $attr->nodeValue;
104+
105+
if (
106+
$prefix === null || $prefix === '' ||
107+
$prefix === 'xmlns' || $uri === '' ||
108+
isset($prefixToUri[$prefix])
109+
) {
110+
continue;
111+
}
112+
113+
$xp->registerNamespace($prefix, $uri);
114+
$prefixToUri[$prefix] = $uri;
115+
}
116+
}
117+
118+
$current = $current->parentNode;
119+
}
120+
121+
return $prefixToUri;
122+
}
123+
124+
125+
/**
126+
* Single-pass subtree scan from the context element to bind prefixes used only on descendants.
127+
* - Never rebind an already-registered prefix (collision-safe).
128+
* - Skips 'xmlns' and empty URIs.
129+
* - Bounded by UNBOUNDED_LIMIT.
130+
*
131+
* @param \DOMXPath $xp
132+
* @param \DOMNode $node
133+
* @param array<string,string> $prefixToUri
134+
*/
135+
private static function registerSubtreePrefixes(DOMXPath $xp, DOMNode $node, array $prefixToUri): void
136+
{
137+
$root = $node instanceof DOMDocument
138+
? $node->documentElement
139+
: ($node instanceof DOMElement ? $node : $node->parentNode);
140+
141+
if (!$root instanceof DOMElement) {
142+
return;
143+
}
144+
145+
$visited = 0;
146+
147+
/** @var array<\DOMElement> $queue */
148+
$queue = [$root];
149+
150+
while ($queue) {
151+
/** @var \DOMElement $el */
152+
$el = array_shift($queue);
153+
154+
if (++$visited > C_XML::UNBOUNDED_LIMIT) {
155+
throw new \RuntimeException(__METHOD__ . ': exceeded subtree traversal limit');
156+
}
157+
158+
// Element prefix
159+
if ($el->prefix && !isset($prefixToUri[$el->prefix])) {
160+
$uri = $el->namespaceURI;
161+
if (is_string($uri) && $uri !== '') {
162+
$xp->registerNamespace($el->prefix, $uri);
163+
$prefixToUri[$el->prefix] = $uri;
164+
}
165+
}
166+
167+
// Attribute prefixes (excluding xmlns)
168+
if ($el->hasAttributes()) {
169+
foreach ($el->attributes as $attr) {
170+
if (
171+
$attr->prefix &&
172+
$attr->prefix !== 'xmlns' &&
173+
!isset($prefixToUri[$attr->prefix])
174+
) {
175+
$uri = $attr->namespaceURI;
176+
if (is_string($uri) && $uri !== '') {
177+
$xp->registerNamespace($attr->prefix, $uri);
178+
$prefixToUri[$attr->prefix] = $uri;
179+
}
180+
} else {
181+
// Optional: collision detection (same prefix, different URI)
182+
// if ($prefixToUri[$pfx] !== $attr->namespaceURI) {
183+
// // Default: skip rebind; could log a debug message here.
184+
// }
185+
}
186+
}
187+
}
188+
189+
// Enqueue children (only DOMElement to keep types precise)
190+
foreach ($el->childNodes as $child) {
191+
if ($child instanceof DOMElement) {
192+
$queue[] = $child;
193+
}
194+
}
195+
}
196+
}
197+
198+
49199
/**
50200
* Do an XPath query on an XML node.
51201
*

tests/XML/ExtendableAttributesTraitTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ public function getAttributeNamespace(): array|string
8888
public function testEmptyNamespaceArrayThrowsAnException(): void
8989
{
9090
$this->expectException(AssertionFailedException::class);
91-
// @phpstan-ignore expr.resultUnused
9291
new class ([]) extends ExtendableAttributesElement {
9392
/**
9493
* @return array<int, string>|string

tests/XML/ExtendableElementTraitTest.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ public static function setUpBeforeClass(): void
8585
public function testIllegalNamespaceComboThrowsAnException(): void
8686
{
8787
$this->expectException(AssertionFailedException::class);
88-
// @phpstan-ignore expr.resultUnused
8988
new class ([]) extends ExtendableElement {
9089
/**
9190
* @return array<int, string>|string
@@ -104,7 +103,6 @@ public function getElementNamespace(): array|string
104103
public function testEmptyNamespaceArrayThrowsAnException(): void
105104
{
106105
$this->expectException(AssertionFailedException::class);
107-
// @phpstan-ignore expr.resultUnused
108106
new class ([]) extends ExtendableElement {
109107
/**
110108
* @return array<int, string>|string

0 commit comments

Comments
 (0)