Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
52 changes: 52 additions & 0 deletions ext/dom/JitDomXPathEvaluate.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ private static function isBoolEvaluateExpr(string $expr): bool
if (preg_match('~^(true|false|boolean\(|not\()~i', $expr)) {
return true;
}
// XPath 1.0 `or` / `and` (#32050) — not NCName fragments (`ancestor-or-self`).
if (self::hasTopLevelOrAnd($expr)) {
return true;
}
// Top-level comparison only — skip [=<>] inside () / [] (#21148).
$depth = 0;
$quote = null;
Expand Down Expand Up @@ -119,6 +123,54 @@ private static function isBoolEvaluateExpr(string $expr): bool
return false;
}

private static function hasTopLevelOrAnd(string $expr): bool
{
foreach (['or', 'and'] as $word) {
$depth = 0;
$quote = null;
$len = \strlen($expr);
$wordLen = \strlen($word);
for ($i = 0; $i < $len; ++$i) {
$ch = $expr[$i];
if (null !== $quote) {
if ($ch === $quote) {
$quote = null;
}
continue;
}
if ('"' === $ch || "'" === $ch) {
$quote = $ch;
continue;
}
if ('(' === $ch || '[' === $ch) {
++$depth;
continue;
}
if (')' === $ch || ']' === $ch) {
--$depth;
continue;
}
if (0 !== $depth) {
continue;
}
if ($i + $wordLen > $len || 0 !== substr_compare($expr, $word, $i, $wordLen)) {
continue;
}
$beforeOk = 0 === $i || !preg_match('/[\w.-]/', $expr[$i - 1]);
$afterOk = $i + $wordLen >= $len || !preg_match('/[\w.-]/', $expr[$i + $wordLen]);
if ($beforeOk && $afterOk) {
$left = trim(substr($expr, 0, $i));
$right = trim(substr($expr, $i + $wordLen));
if ('' !== $left && '' !== $right) {
return true;
}
}
}
}

return false;
}

private static function isDoubleEvaluateExpr(string $expr): bool
{
if (preg_match('~^(number|count|sum)\(~i', $expr)) {
Expand Down
124 changes: 124 additions & 0 deletions ext/dom/JitDomXPathEvaluateUserScript.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ public static function tryInvoke(Context $context, JITVariable ...$args): ?Value
if (null !== $invalid) {
return $invalid;
}
// XPath 1.0 `or` / `and` evaluate() — host-fold when Zend DOM is available (#32050).
if (self::hasTopLevelOrAnd($expression)) {
$host = self::tryHostEvaluateScalar($xml, $expression);
if (null !== $host) {
return self::boxHostScalar($context, $host);
}
}
if (preg_match('~^boolean\((.+)\)$~i', $expression, $boolWrap)) {
$inner = trim($boolWrap[1]);
if (preg_match('~^count\((.+)\)$~i', $inner, $countWrap)) {
Expand Down Expand Up @@ -355,6 +362,117 @@ private static function sumForXPath(string $xml, string $inner): ?float
return $sum;
}

/**
* Host Zend DOMXPath::evaluate() for compile-time scalar folds (#32050).
*
* @return bool|int|float|string|null
*/
private static function tryHostEvaluateScalar(string $xml, string $expression): mixed
{
if (!\extension_loaded('dom') || !\class_exists(\DOMDocument::class, false)) {
return null;
}
set_error_handler(static function (): bool {
return true;
});
try {
$doc = new \DOMDocument();
if (!@$doc->loadXML($xml)) {
restore_error_handler();

return null;
}
$xpath = new \DOMXPath($doc);
foreach (JitDomXPathRegisterUserScript::namespaces() as $prefix => $uri) {
if ('' === $prefix) {
continue;
}
@$xpath->registerNamespace($prefix, $uri);
}
$result = $xpath->evaluate($expression);
} catch (\Throwable) {
restore_error_handler();

return null;
}
restore_error_handler();
if (\is_bool($result) || \is_int($result) || \is_float($result) || \is_string($result)) {
return $result;
}

return null;
}

private static function hasTopLevelOrAnd(string $expression): bool
{
foreach (['or', 'and'] as $word) {
$depth = 0;
$quote = null;
$len = \strlen($expression);
$wordLen = \strlen($word);
for ($i = 0; $i < $len; ++$i) {
$ch = $expression[$i];
if (null !== $quote) {
if ($ch === $quote) {
$quote = null;
}
continue;
}
if ('"' === $ch || "'" === $ch) {
$quote = $ch;
continue;
}
if ('(' === $ch || '[' === $ch) {
++$depth;
continue;
}
if (')' === $ch || ']' === $ch) {
--$depth;
continue;
}
if (0 !== $depth) {
continue;
}
if ($i + $wordLen > $len || 0 !== substr_compare($expression, $word, $i, $wordLen)) {
continue;
}
$beforeOk = 0 === $i || !preg_match('/[\w.-]/', $expression[$i - 1]);
$afterOk = $i + $wordLen >= $len || !preg_match('/[\w.-]/', $expression[$i + $wordLen]);
if ($beforeOk && $afterOk) {
$left = trim(substr($expression, 0, $i));
$right = trim(substr($expression, $i + $wordLen));
if ('' !== $left && '' !== $right) {
return true;
}
}
}
}

return false;
}

private static function boxHostScalar(Context $context, bool|int|float|string $value): Value
{
if (\is_bool($value)) {
$slot = JitValueBox::alloc($context);
JitValueBox::writeBool(
$context,
$slot,
$context->getTypeFromString('int1')->constInt($value ? 1 : 0, false)
);

return JitValueBox::normalizeValuePtr($context, $slot);
}
if (\is_int($value)) {
return self::boxLong($context, $value);
}
if (\is_float($value)) {
return self::boxDouble($context, $value);
}

return self::boxString($context, $value);
}

private static function countForXPath(string $xml, string $inner): ?int
{
// //tag[n] — at most one node (#19456).
Expand All @@ -363,6 +481,12 @@ private static function countForXPath(string $xml, string $inner): ?int

return null === $text ? 0 : 1;
}
if (preg_match('~\s+(?:or|and)\s+|\[not\(~i', $inner)) {
$host = self::tryHostEvaluateScalar($xml, 'count('.$inner.')');
if (\is_int($host) || \is_float($host)) {
return (int) $host;
}
}
if (!preg_match(
'~^//([*\w][\w:-]*)(?:\[@([^\]=]+)=(?:["\']([^"\']*)["\']|([+-]?(?:\d+\.?\d*|\.\d+)))\])?$~',
$inner,
Expand Down
11 changes: 8 additions & 3 deletions ext/dom/JitDomXPathQueryUserScript.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,16 @@ private static function tryHostTreeAxisCompileTime(Context $context, string $xml
{
$trimmed = trim($exprLit);
// Host-fold named axes / `..` (#31773), `//*[last()]` / `[position()…]` (#31923),
// and abbreviated attribute axis `@*` / `@name` (#32003, #32032). Flattened
// descendant `[last()]` is wrong; user-script AOT ABI aborts on these paths.
// abbreviated attribute axis `@*` / `@name` (#32003, #32032), and `or`/`and`/`not(`
// boolean predicates (#32050). Flattened descendant `[last()]` is wrong; user-script
// AOT ABI aborts on these paths.
$positional = (bool) preg_match('~\[(?:last\(\)|position\(\))~i', $trimmed);
$attrAxis = self::isHostFoldAttributeAxis($trimmed);
if (!str_contains($trimmed, '::') && !str_contains($trimmed, '..') && !$positional && !$attrAxis) {
// Boolean `or`/`and`/`not(` — user-script regex only handles [@attr=v] (#32050).
$boolExpr = str_contains($trimmed, ' or ')
|| str_contains($trimmed, ' and ')
|| str_contains($trimmed, '[not(');
if (!str_contains($trimmed, '::') && !str_contains($trimmed, '..') && !$positional && !$attrAxis && !$boolExpr) {
return null;
}
if (!\extension_loaded('dom') || !\class_exists(\DOMDocument::class, false)) {
Expand Down
Loading
Loading