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