From 364d6a2f4fc781d769f6be3237ababaa6cd909d9 Mon Sep 17 00:00:00 2001
From: sjh9714 <163989462+sjh9714@users.noreply.github.com>
Date: Sun, 30 Aug 2026 10:26:31 +0900
Subject: [PATCH 1/2] Reject underscored XPath function names
---
lib/rexml/functions.rb | 5 ++---
lib/rexml/parsers/xpathparser.rb | 1 +
test/functions/test_base.rb | 12 +++++++-----
test/parser/test_xpath.rb | 7 +++++++
test/xpath/test_base.rb | 8 ++++----
5 files changed, 21 insertions(+), 12 deletions(-)
diff --git a/lib/rexml/functions.rb b/lib/rexml/functions.rb
index 16177263..daabd807 100644
--- a/lib/rexml/functions.rb
+++ b/lib/rexml/functions.rb
@@ -4,9 +4,8 @@ module REXML
# (1) the first argument will always be a list of nodes from which to
# filter. In the case of context methods (such as position), the function
# should return an array with a value for each child in the array.
- # (2) all method calls from XML will have "-" replaced with "_".
- # Therefore, in XML, "local-name()" is identical (and actually becomes)
- # "local_name()"
+ # (2) all valid method calls from XML will have "-" replaced with "_"
+ # after parsing. For example, "local-name()" calls #local_name.
class FunctionsClass # :nodoc:
@@available_functions = {}
diff --git a/lib/rexml/parsers/xpathparser.rb b/lib/rexml/parsers/xpathparser.rb
index 7cca0975..ec7e12bc 100644
--- a/lib/rexml/parsers/xpathparser.rb
+++ b/lib/rexml/parsers/xpathparser.rb
@@ -653,6 +653,7 @@ def PrimaryExpr path, parsed
#arry << @variables[ varname ]
when /^(\w[-\w]*)(?:\()/
fname = $1
+ return path if fname.include?("_")
tmp = $'
return path if fname =~ NT
path = tmp
diff --git a/test/functions/test_base.rb b/test/functions/test_base.rb
index b63f3d5a..2128c469 100644
--- a/test/functions/test_base.rb
+++ b/test/functions/test_base.rb
@@ -164,8 +164,8 @@ def test_name
def test_local_name
d = REXML::Document.new("")
- assert_equal 2, d.root.elements.to_a('*[local_name() = "b"]').size
- assert_equal 2, d.elements.to_a('//*[local_name() = "b"]').size
+ assert_equal 2, d.root.elements.to_a('*[local-name() = "b"]').size
+ assert_equal 2, d.elements.to_a('//*[local-name() = "b"]').size
end
def test_substring2
@@ -242,7 +242,7 @@ def test_ticket_60
def test_normalize_space
source = ""
doc = REXML::Document.new(source)
- predicate = "string(.)=normalize_space('\nCOMMENT \n A \n\n ')"
+ predicate = "string(.)=normalize-space('\nCOMMENT \n A \n\n ')"
m = REXML::XPath.match(doc, "//comment()[#{predicate}]")
assert_equal( [REXML::Comment.new("COMMENT A")], m )
end
@@ -290,9 +290,11 @@ def test_string_nil_without_context
{"n" => nil}))
end
- def test_unregistered_method
+ def test_unregistered_method_with_underscore
doc = Document.new("")
- assert_nil(XPath::first(doc.root, "to_s()"))
+ assert_raise(REXML::ParseException) do
+ XPath::first(doc.root, "to_s()")
+ end
end
def test_nonexistent_function
diff --git a/test/parser/test_xpath.rb b/test/parser/test_xpath.rb
index bb20c1fd..0dd50430 100644
--- a/test/parser/test_xpath.rb
+++ b/test/parser/test_xpath.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: false
require "test/unit"
+require "rexml/parseexception"
require "rexml/parsers/xpathparser"
module REXMLTests
@@ -66,6 +67,12 @@ def test_function
abbreviate("string-length(a/b[last()])"))
end
+ def test_function_with_underscore
+ assert_raise(REXML::ParseException) do
+ abbreviate("local_name(*)")
+ end
+ end
+
def test_descendant_or_self_only
assert_equal("//",
abbreviate("/descendant-or-self::node()/"))
diff --git a/test/xpath/test_base.rb b/test/xpath/test_base.rb
index f0f53219..c2105c27 100644
--- a/test/xpath/test_base.rb
+++ b/test/xpath/test_base.rb
@@ -419,10 +419,10 @@ def test_namespaces_2
EOF
doc = Document.new source
- res = XPath::first(doc, "//*[local_name()='bar']")
+ res = XPath::first(doc, "//*[local-name()='bar']")
assert res, "looking for //*[name()='bar']"
assert_equal 'this', res.namespace
- res = XPath::first(doc.root, "*[namespace_uri()='that']")
+ res = XPath::first(doc.root, "*[namespace-uri()='that']")
assert_equal 'that bar', res.text
end
@@ -879,8 +879,8 @@ def test_name
def test_local_name
d = REXML::Document.new("")
- assert_equal 2, d.root.elements.to_a('*[local_name() = "b"]').size
- assert_equal 2, d.elements.to_a('//*[local_name() = "b"]').size
+ assert_equal 2, d.root.elements.to_a('*[local-name() = "b"]').size
+ assert_equal 2, d.elements.to_a('//*[local-name() = "b"]').size
end
def test_comparisons
From cc8ee2a48240cc1d41e5fecb623cf6170693b027 Mon Sep 17 00:00:00 2001
From: sjh9714 <163989462+sjh9714@users.noreply.github.com>
Date: Sun, 30 Aug 2026 20:45:11 +0900
Subject: [PATCH 2/2] Preserve underscored XPath functions by default
---
lib/rexml/functions.rb | 5 +++--
lib/rexml/parsers/xpathparser.rb | 6 +++++-
lib/rexml/xpath_parser.rb | 2 +-
test/functions/test_base.rb | 13 +++++++++----
test/parser/test_xpath.rb | 8 +++++++-
test/xpath/test_base.rb | 8 ++++----
6 files changed, 29 insertions(+), 13 deletions(-)
diff --git a/lib/rexml/functions.rb b/lib/rexml/functions.rb
index daabd807..16177263 100644
--- a/lib/rexml/functions.rb
+++ b/lib/rexml/functions.rb
@@ -4,8 +4,9 @@ module REXML
# (1) the first argument will always be a list of nodes from which to
# filter. In the case of context methods (such as position), the function
# should return an array with a value for each child in the array.
- # (2) all valid method calls from XML will have "-" replaced with "_"
- # after parsing. For example, "local-name()" calls #local_name.
+ # (2) all method calls from XML will have "-" replaced with "_".
+ # Therefore, in XML, "local-name()" is identical (and actually becomes)
+ # "local_name()"
class FunctionsClass # :nodoc:
@@available_functions = {}
diff --git a/lib/rexml/parsers/xpathparser.rb b/lib/rexml/parsers/xpathparser.rb
index ec7e12bc..38516158 100644
--- a/lib/rexml/parsers/xpathparser.rb
+++ b/lib/rexml/parsers/xpathparser.rb
@@ -13,6 +13,10 @@ class XPathParser # :nodoc:
include XMLTokens
LITERAL = /^'([^']*)'|^"([^"]*)"/u
+ def initialize(strict: false)
+ @strict = strict
+ end
+
def namespaces=( namespaces )
Functions::namespace_context = namespaces
@namespaces = namespaces
@@ -653,7 +657,7 @@ def PrimaryExpr path, parsed
#arry << @variables[ varname ]
when /^(\w[-\w]*)(?:\()/
fname = $1
- return path if fname.include?("_")
+ return path if @strict && fname.include?("_")
tmp = $'
return path if fname =~ NT
path = tmp
diff --git a/lib/rexml/xpath_parser.rb b/lib/rexml/xpath_parser.rb
index 761b5281..2e6deb58 100644
--- a/lib/rexml/xpath_parser.rb
+++ b/lib/rexml/xpath_parser.rb
@@ -60,7 +60,7 @@ class XPathParser
def initialize(strict: false)
@debug = DEBUG
- @parser = REXML::Parsers::XPathParser.new
+ @parser = REXML::Parsers::XPathParser.new(strict: strict)
@namespaces = nil
@variables = {}
@functions = FunctionsClass.new
diff --git a/test/functions/test_base.rb b/test/functions/test_base.rb
index 2128c469..5f6c8110 100644
--- a/test/functions/test_base.rb
+++ b/test/functions/test_base.rb
@@ -164,8 +164,8 @@ def test_name
def test_local_name
d = REXML::Document.new("")
- assert_equal 2, d.root.elements.to_a('*[local-name() = "b"]').size
- assert_equal 2, d.elements.to_a('//*[local-name() = "b"]').size
+ assert_equal 2, d.root.elements.to_a('*[local_name() = "b"]').size
+ assert_equal 2, d.elements.to_a('//*[local_name() = "b"]').size
end
def test_substring2
@@ -242,7 +242,7 @@ def test_ticket_60
def test_normalize_space
source = ""
doc = REXML::Document.new(source)
- predicate = "string(.)=normalize-space('\nCOMMENT \n A \n\n ')"
+ predicate = "string(.)=normalize_space('\nCOMMENT \n A \n\n ')"
m = REXML::XPath.match(doc, "//comment()[#{predicate}]")
assert_equal( [REXML::Comment.new("COMMENT A")], m )
end
@@ -291,9 +291,14 @@ def test_string_nil_without_context
end
def test_unregistered_method_with_underscore
+ doc = Document.new("")
+ assert_nil(XPath::first(doc.root, "to_s()"))
+ end
+
+ def test_unregistered_method_with_underscore_in_strict_mode
doc = Document.new("")
assert_raise(REXML::ParseException) do
- XPath::first(doc.root, "to_s()")
+ XPath::first(doc.root, "to_s()", nil, {}, strict: true)
end
end
diff --git a/test/parser/test_xpath.rb b/test/parser/test_xpath.rb
index 0dd50430..a011f619 100644
--- a/test/parser/test_xpath.rb
+++ b/test/parser/test_xpath.rb
@@ -68,8 +68,14 @@ def test_function
end
def test_function_with_underscore
+ assert_equal("local_name(*)",
+ abbreviate("local_name(*)"))
+ end
+
+ def test_function_with_underscore_in_strict_mode
+ parser = REXML::Parsers::XPathParser.new(strict: true)
assert_raise(REXML::ParseException) do
- abbreviate("local_name(*)")
+ parser.abbreviate("local_name(*)")
end
end
diff --git a/test/xpath/test_base.rb b/test/xpath/test_base.rb
index c2105c27..f0f53219 100644
--- a/test/xpath/test_base.rb
+++ b/test/xpath/test_base.rb
@@ -419,10 +419,10 @@ def test_namespaces_2
EOF
doc = Document.new source
- res = XPath::first(doc, "//*[local-name()='bar']")
+ res = XPath::first(doc, "//*[local_name()='bar']")
assert res, "looking for //*[name()='bar']"
assert_equal 'this', res.namespace
- res = XPath::first(doc.root, "*[namespace-uri()='that']")
+ res = XPath::first(doc.root, "*[namespace_uri()='that']")
assert_equal 'that bar', res.text
end
@@ -879,8 +879,8 @@ def test_name
def test_local_name
d = REXML::Document.new("")
- assert_equal 2, d.root.elements.to_a('*[local-name() = "b"]').size
- assert_equal 2, d.elements.to_a('//*[local-name() = "b"]').size
+ assert_equal 2, d.root.elements.to_a('*[local_name() = "b"]').size
+ assert_equal 2, d.elements.to_a('//*[local_name() = "b"]').size
end
def test_comparisons