Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@
"ext-pcre": "*",
"ext-spl": "*",

"simplesamlphp/assert": "~1.9.0",
"simplesamlphp/composer-xmlprovider-installer": "~1.1.0"
"simplesamlphp/assert": "~1.9.0"
},
"require-dev": {
"simplesamlphp/simplesamlphp-test-framework": "~1.10.1"
Expand All @@ -56,8 +55,23 @@
"allow-plugins": {
"composer/package-versions-deprecated": true,
"dealerdirect/phpcodesniffer-composer-installer": true,
"phpstan/extension-installer": true,
"simplesamlphp/composer-xmlprovider-installer": true
"phpstan/extension-installer": true
}
},
"scripts": {
"pre-commit": [
"vendor/bin/phpcs -p",
"vendor/bin/composer-require-checker check --config-file=tools/composer-require-checker.json composer.json",
"vendor/bin/phpstan analyze -c phpstan.neon",
"vendor/bin/phpstan analyze -c phpstan-dev.neon",
"vendor/bin/composer-unused",
"vendor/bin/phpunit --no-coverage --testdox"
],
"tests": [
"vendor/bin/phpunit --no-coverage"
],
"propose-fix": [
"vendor/bin/phpcs --report=diff"
]
}
}
65 changes: 65 additions & 0 deletions src/XPath/XPath.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace SimpleSAML\XPath;

use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use SimpleSAML\XML\Assert\Assert;
Expand All @@ -21,6 +22,12 @@ class XPath
/**
* Get an instance of DOMXPath associated with a DOMNode
*
* - Reuses a cached DOMXPath per document.
* - Registers core XML-related namespaces: 'xml' and 'xs'.
* - Enriches the XPath with all prefixed xmlns declarations found on the
* current node and its ancestors (up to the document element), so
* custom prefixes declared anywhere up the tree can be used in queries.
*
* @param \DOMNode $node The associated node
* @return \DOMXPath
*/
Expand All @@ -42,10 +49,68 @@ public static function getXPath(DOMNode $node): DOMXPath
$xpCache->registerNamespace('xml', C_XML::NS_XML);
$xpCache->registerNamespace('xs', C_XS::NS_XS);

// Enrich with ancestor-declared prefixes for this document context.
self::registerAncestorNamespaces($xpCache, $node);

return $xpCache;
}


/**
* Walk from the given node up to the document element, registering all prefixed xmlns declarations.
*
* Safety:
* - Only attributes in the XMLNS namespace (http://www.w3.org/2000/xmlns/).
* - Skip default xmlns (localName === 'xmlns') because XPath requires prefixes.
* - Skip empty URIs.
* - Do not override core 'xml' and 'xs' prefixes (already bound).
* - Nearest binding wins during this pass (prefixes are added once).
*
* @param \DOMXPath $xp
* @param \DOMNode $node
*/
private static function registerAncestorNamespaces(DOMXPath $xp, DOMNode $node): void
{
$xmlnsNs = 'http://www.w3.org/2000/xmlns/';

// Avoid re-binding while walking upwards.
$registered = [
'xml' => true,
'xs' => true,
];

// Start from the nearest element (or documentElement if a DOMDocument is passed).
$current = $node instanceof DOMDocument
? $node->documentElement
: ($node instanceof DOMElement ? $node : $node->parentNode);

while ($current instanceof DOMElement) {
if ($current->hasAttributes()) {
foreach ($current->attributes as $attr) {
if ($attr->namespaceURI !== $xmlnsNs) {
continue;
}
$prefix = $attr->localName; // e.g., 'slate' for xmlns:slate, 'xmlns' for default
$uri = (string) $attr->nodeValue;

if (
$prefix === null || $prefix === '' ||
$prefix === 'xmlns' || $uri === '' ||
isset($registered[$prefix])
) {
continue;
}

$xp->registerNamespace($prefix, $uri);
$registered[$prefix] = true;
}
}

$current = $current->parentNode instanceof DOMElement ? $current->parentNode : null;
}
}


/**
* Do an XPath query on an XML node.
*
Expand Down
1 change: 0 additions & 1 deletion tests/XML/ExtendableAttributesTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ public function getAttributeNamespace(): array|string
public function testEmptyNamespaceArrayThrowsAnException(): void
{
$this->expectException(AssertionFailedException::class);
// @phpstan-ignore expr.resultUnused
new class ([]) extends ExtendableAttributesElement {
/**
* @return array<int, string>|string
Expand Down
2 changes: 0 additions & 2 deletions tests/XML/ExtendableElementTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ public static function setUpBeforeClass(): void
public function testIllegalNamespaceComboThrowsAnException(): void
{
$this->expectException(AssertionFailedException::class);
// @phpstan-ignore expr.resultUnused
new class ([]) extends ExtendableElement {
/**
* @return array<int, string>|string
Expand All @@ -104,7 +103,6 @@ public function getElementNamespace(): array|string
public function testEmptyNamespaceArrayThrowsAnException(): void
{
$this->expectException(AssertionFailedException::class);
// @phpstan-ignore expr.resultUnused
new class ([]) extends ExtendableElement {
/**
* @return array<int, string>|string
Expand Down
108 changes: 108 additions & 0 deletions tests/XPath/XPathTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\Test\XPath;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use SimpleSAML\XPath\XPath;

/**
* Tests for the SimpleSAML\XPath\XPath helper.
*/
#[CoversClass(XPath::class)]
final class XPathTest extends TestCase
{
public function testGetXPathCachesPerDocumentAndRegistersCoreNamespaces(): void
{
// Doc A with an xml:space attribute to validate 'xml' prefix usage works
$docA = new \DOMDocument();
$docA->loadXML(<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<root xml:space="preserve" xmlns:xml="http://www.w3.org/XML/1998/namespace">
<child>value</child>
</root>
XML);

// Doc B is different
$docB = new \DOMDocument();
$docB->loadXML(<<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<another><node/></another>
XML);

$xpA1 = XPath::getXPath($docA);
$xpA2 = XPath::getXPath($docA);
$xpB = XPath::getXPath($docB);

// Cached instance reused per same document
$this->assertSame($xpA1, $xpA2);
// Different document => different DOMXPath instance
$this->assertNotSame($xpA1, $xpB);

// 'xml' prefix registered: query should be valid and return xml:space attribute
$rootA = $docA->documentElement;
$this->assertInstanceOf(\DOMElement::class, $rootA);
$attrs = XPath::xpQuery($rootA, '@xml:space', $xpA1);
$this->assertCount(1, $attrs);
$this->assertSame('preserve', $attrs[0]->nodeValue);
}


public function testAncestorNamespaceRegistrationAllowsCustomPrefixes(): void
{
// Custom namespace declared on the root; query from a descendant node
$xml = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<r xmlns:foo="https://example.org/foo">
<a>
<b>
<foo:item>ok</foo:item>
</b>
</a>
</r>
XML;
$doc = new \DOMDocument();
$doc->loadXML($xml);

// Use a deep context node to ensure ancestor-walk picks up xmlns:foo from root
$context = $doc->getElementsByTagName('b')->item(0);
$this->assertInstanceOf(\DOMElement::class, $context);

$xp = XPath::getXPath($context);

$nodes = XPath::xpQuery($context, 'foo:item', $xp);
$this->assertCount(1, $nodes);
$this->assertSame('ok', $nodes[0]->textContent);
}


public function testXpQueryThrowsOnMalformedExpression(): void
{
$doc = new \DOMDocument();
$doc->loadXML('<root><x/></root>');
$xp = XPath::getXPath($doc);

// If xpQuery throws a specific exception, put that class here instead of \Throwable.
$this->expectException(\Throwable::class);
// Keep message assertion resilient to libxml version differences.
$this->expectExceptionMessageMatches('/(XPath|expression).*invalid|malformed|error/i');

// Malformed XPath: missing closing bracket
$root = $doc->documentElement;
$this->assertInstanceOf(\DOMElement::class, $root);

// Avoid emitting a PHP warning; let xpQuery surface it as an exception.
\libxml_use_internal_errors(true);
try {
XPath::xpQuery($root, '//*[', $xp);
} finally {
$errors = \libxml_get_errors();
self::assertCount(1, $errors);
self::assertEquals("Invalid expression\n", $errors[0]->message);
\libxml_clear_errors();
\libxml_use_internal_errors(false);
}
}
}
Loading