moxml 0.5.92 → 0.5.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e9550ec966450192384ac9f4059a182a52107fac47420010517fe2fff7c8d1c
4
- data.tar.gz: ca3e1a10e2a0669991391ce82909360a66fd17cbd98e454aff99f5d1f1bcb4d4
3
+ metadata.gz: 92d24efac79874e02cbfa26e20fc8135e2a714020a727896cdf2a460fa7d0508
4
+ data.tar.gz: 91f18637388f6983c2c75c606e95e93f15919b3099fbdc0c8e59ebf9aca93d5e
5
5
  SHA512:
6
- metadata.gz: b5de2cfb1875e9947b25cabd166f2f1e2c3ec6994d9ea8376a3b42ae5afd7bfe450ae3fed1306e25748cfdedc6223cf46848c5202e1d4cf898d6df2ac04f2abd
7
- data.tar.gz: 1f5922e2968fdc796df5a183bbd1d876b2c66618eef068c08005c528cf735df0b193b7143f856abe381aa2ec37224c6302359b7f188eb6c3342b680a729c0475
6
+ metadata.gz: 91cd7d93def6717a7eedf675e871ff7d05d3dfb5378f8ddbe8f839daa68d0d1dbe0e1bdf6bdb939704bb03f673950c9faa858265321e2a209fbcb2042224a3cd
7
+ data.tar.gz: c973d0559eb5fcc42f45dfb824758cdbec8758ec0dc378a4ce4034d918b3b50fcdada1120cd9f18b47c39a42055d4cd810225588dbcf598346c920a2a510e0c7
@@ -184,6 +184,12 @@ module Moxml
184
184
  one == other
185
185
  end
186
186
 
187
+ # Owner element native for an attribute native, where the
188
+ # engine exposes it (nil elsewhere).
189
+ def attribute_element(_attribute)
190
+ nil
191
+ end
192
+
187
193
  # Backends without a native subtree digest answer nil —
188
194
  # the wrapper contract Node#digest gates on (issue #173).
189
195
  def digest(*)
@@ -26,12 +26,17 @@ module Moxml
26
26
  @native = fresh
27
27
  end
28
28
 
29
- # Compare wrappers based on their native nodes
29
+ # Compare wrappers based on their native nodes. Either side
30
+ # may arrive double-wrapped (a Customized node stored as
31
+ # another wrapper's native); LibXML's eql? raises on the
32
+ # class mismatch, so unwrap both before comparing.
30
33
  def ==(other)
31
34
  return false unless other
32
35
 
33
- other_native = other.is_a?(self.class) ? other.native : other
34
- @native == other_native
36
+ mine = @native.is_a?(CustomizedLibxml::Node) ? @native.native : @native
37
+ theirs = other.is_a?(self.class) ? other.native : other
38
+ theirs = theirs.native while theirs.is_a?(CustomizedLibxml::Node)
39
+ mine == theirs
35
40
  end
36
41
 
37
42
  alias eql? ==
@@ -1638,7 +1638,13 @@ module Moxml
1638
1638
  end
1639
1639
 
1640
1640
  def attribute_element(attr)
1641
- attr.element
1641
+ # XPath results mint ResultAttr (parent-backed); document
1642
+ # attributes are binding Attr (element-backed).
1643
+ if attr.is_a?(::Leptris::XML::ResultAttr)
1644
+ attr.parent
1645
+ else
1646
+ attr.element
1647
+ end
1642
1648
  end
1643
1649
 
1644
1650
  def attribute_name(attr)
@@ -421,7 +421,10 @@ module Moxml
421
421
  end
422
422
 
423
423
  def attribute_element(attr)
424
- attr&.parent
424
+ # Customized nodes wrap raw LibXML nodes one level deep;
425
+ # callers of this face expect the raw native.
426
+ parent = attr&.parent
427
+ parent.is_a?(::Moxml::Adapter::CustomizedLibxml::Node) ? parent.native : parent
425
428
  end
426
429
 
427
430
  def attribute_namespace(attr)
@@ -19,13 +19,85 @@ module Moxml
19
19
  end
20
20
 
21
21
  def self.from_node(moxml_node)
22
+ @source_map = {}
22
23
  return build_from_document(moxml_node) if moxml_node.is_a?(::Moxml::Document)
23
24
 
24
25
  root = Nodes::RootNode.new
25
- root.add_child(build_node(moxml_node))
26
+ built = build_node(moxml_node, [0])
27
+ root.add_child(built) if built
26
28
  root
27
29
  end
28
30
 
31
+ # Position keys for subset selection: adapters mint fresh
32
+ # wrappers per traversal, so XPath-matched wrappers cannot be
33
+ # keyed by object identity. Both sides instead compute the same
34
+ # child-index path — the build walks dm children in order, the
35
+ # match side climbs parents counting buildable siblings (the
36
+ # same types build_node answers non-nil for).
37
+ class << self
38
+ attr_reader :source_map
39
+ end
40
+
41
+ BUILDABLE_TYPES = [::Moxml::Element, ::Moxml::Text, ::Moxml::Cdata,
42
+ ::Moxml::Comment,
43
+ ::Moxml::ProcessingInstruction].freeze
44
+
45
+ def self.buildable?(node)
46
+ BUILDABLE_TYPES.any? { |type| node.is_a?(type) }
47
+ end
48
+
49
+ # Path key of a matched wrapper relative to the source the data
50
+ # model was built from; nil when the wrapper is outside it.
51
+ # Attributes extend their owner's path with [:attribute, index].
52
+ # The adapter comes from the caller: the Attribute contract
53
+ # keeps its own adapter hop protected.
54
+ def self.path_key_for(wrapper, source, adapter)
55
+ case wrapper
56
+ when ::Moxml::Attribute
57
+ owner = wrapper.parent
58
+ unless owner.is_a?(::Moxml::Element)
59
+ # XPath results are re-wrapped from natives: attribute
60
+ # wrappers carry no minted parent. The adapter answers
61
+ # the owner element instead.
62
+ owner_native = adapter.attribute_element(wrapper.native)
63
+ owner = owner_native && ::Moxml::Node.wrap(owner_native, wrapper.context)
64
+ end
65
+ return nil unless owner.is_a?(::Moxml::Element)
66
+
67
+ owner_path = node_path(owner, source)
68
+ return nil unless owner_path
69
+
70
+ idx = owner.attributes.index { |a| a == wrapper }
71
+ return nil unless idx
72
+
73
+ owner_path + [:attribute, idx]
74
+ when *BUILDABLE_TYPES
75
+ node_path(wrapper, source)
76
+ end
77
+ end
78
+
79
+ def self.node_path(node, source)
80
+ parts = []
81
+ cur = node
82
+ loop do
83
+ if !source.is_a?(::Moxml::Document) && cur.equal?(source)
84
+ parts.unshift(0)
85
+ return parts
86
+ end
87
+
88
+ parent = cur.parent
89
+ return nil unless parent
90
+
91
+ idx = parent.children.select { |c| buildable?(c) }.index { |c| c == cur }
92
+ return nil unless idx
93
+
94
+ parts.unshift(idx)
95
+ return parts if source.is_a?(::Moxml::Document) && parent.is_a?(::Moxml::Document)
96
+
97
+ cur = parent
98
+ end
99
+ end
100
+
29
101
  # Build from a Moxml::Document. Matches canon's Nokogiri path:
30
102
  # the root element is added first, then all other document-level
31
103
  # children (PIs, comments) in document order.
@@ -41,6 +113,7 @@ module Moxml
41
113
  # adapters mint fresh wrappers per children call, so wrapper
42
114
  # identity cannot locate it.
43
115
  seen_root = false
116
+ idx = 0
44
117
  document.children.each do |child|
45
118
  if child.is_a?(::Moxml::Element)
46
119
  next if seen_root
@@ -48,40 +121,48 @@ module Moxml
48
121
  seen_root = true
49
122
  end
50
123
 
51
- built = build_node(child)
52
- root.add_child(built) if built
124
+ built = build_node(child, [idx])
125
+ next unless built
126
+
127
+ root.add_child(built)
128
+ idx += 1
53
129
  end
54
- root.add_child(build_element_node(document.root)) unless seen_root
130
+ root.add_child(build_element_node(document.root, [idx])) unless seen_root
55
131
  end
56
132
 
57
133
  root
58
134
  end
59
135
 
60
- def self.build_node(moxml_node)
136
+ def self.build_node(moxml_node, path)
61
137
  case moxml_node
62
- when ::Moxml::Element then build_element_node(moxml_node)
138
+ when ::Moxml::Element then build_element_node(moxml_node, path)
63
139
  # CDATA sections are character data to canonicalization: their
64
140
  # content is emitted as escaped text (spec §3.1 "text nodes").
65
- when ::Moxml::Text, ::Moxml::Cdata then build_text_node(moxml_node)
66
- when ::Moxml::Comment then build_comment_node(moxml_node)
67
- when ::Moxml::ProcessingInstruction then build_pi_node(moxml_node)
141
+ when ::Moxml::Text, ::Moxml::Cdata then build_text_node(moxml_node, path)
142
+ when ::Moxml::Comment then build_comment_node(moxml_node, path)
143
+ when ::Moxml::ProcessingInstruction then build_pi_node(moxml_node, path)
68
144
  end
69
145
  end
70
146
 
71
- def self.build_element_node(moxml_element)
147
+ def self.build_element_node(moxml_element, path)
72
148
  ns = moxml_element.namespace
73
149
  element = Nodes::ElementNode.new(
74
150
  name: moxml_element.name,
75
151
  namespace_uri: ns&.uri,
76
152
  prefix: ns&.prefix,
77
153
  )
154
+ source_map[path] = element
78
155
 
79
156
  build_namespace_nodes(moxml_element, element)
80
- build_attribute_nodes(moxml_element, element)
157
+ build_attribute_nodes(moxml_element, element, path)
81
158
 
159
+ idx = 0
82
160
  moxml_element.children.each do |child|
83
- built = build_node(child)
84
- element.add_child(built) if built
161
+ built = build_node(child, path + [idx])
162
+ next unless built
163
+
164
+ element.add_child(built)
165
+ idx += 1
85
166
  end
86
167
 
87
168
  element
@@ -101,8 +182,8 @@ module Moxml
101
182
  )
102
183
  end
103
184
 
104
- def self.build_attribute_nodes(moxml_element, element)
105
- moxml_element.attributes.each do |attr|
185
+ def self.build_attribute_nodes(moxml_element, element, path)
186
+ moxml_element.attributes.each_with_index do |attr, idx|
106
187
  ns = attr.namespace
107
188
  # The xml prefix is reserved: any attribute bound to the XML
108
189
  # namespace URI renders (and sorts) as prefix "xml" regardless
@@ -113,30 +194,36 @@ module Moxml
113
194
  prefix = "xml"
114
195
  uri = XML_URI
115
196
  end
116
- element.add_attribute(
117
- Nodes::AttributeNode.new(
118
- name: attr.name,
119
- value: attr.value,
120
- namespace_uri: uri,
121
- prefix: prefix,
122
- ),
197
+ node = Nodes::AttributeNode.new(
198
+ name: attr.name,
199
+ value: attr.value,
200
+ namespace_uri: uri,
201
+ prefix: prefix,
123
202
  )
203
+ source_map[path + [:attribute, idx]] = node
204
+ element.add_attribute(node)
124
205
  end
125
206
  end
126
207
 
127
- def self.build_text_node(moxml_text)
128
- Nodes::TextNode.new(value: moxml_text.content)
208
+ def self.build_text_node(moxml_text, path)
209
+ node = Nodes::TextNode.new(value: moxml_text.content)
210
+ source_map[path] = node
211
+ node
129
212
  end
130
213
 
131
- def self.build_comment_node(moxml_comment)
132
- Nodes::CommentNode.new(value: moxml_comment.content)
214
+ def self.build_comment_node(moxml_comment, path)
215
+ node = Nodes::CommentNode.new(value: moxml_comment.content)
216
+ source_map[path] = node
217
+ node
133
218
  end
134
219
 
135
- def self.build_pi_node(moxml_pi)
136
- Nodes::ProcessingInstructionNode.new(
220
+ def self.build_pi_node(moxml_pi, path)
221
+ node = Nodes::ProcessingInstructionNode.new(
137
222
  target: moxml_pi.target || moxml_pi.name,
138
223
  data: moxml_pi.content || "",
139
224
  )
225
+ source_map[path] = node
226
+ node
140
227
  end
141
228
 
142
229
  def self.mark_all(node, value)
@@ -144,21 +231,52 @@ module Moxml
144
231
  node.children.each { |child| mark_all(child, value) }
145
232
  end
146
233
 
147
- def self.mark_subset(root_node, matched)
148
- matched.each { |node| mark_node_and_descendants(node) }
149
- root_node.in_node_set = true
234
+ # Mark an XPath-selected subset: reset the whole model, then
235
+ # flag each matched node by position path. Node-set semantics
236
+ # follow the enveloped-signature interop (libxml2/xmlsec): an
237
+ # included element renders with its namespaces, attributes and
238
+ # DIRECT character data, but child ELEMENTS render only if
239
+ # matched too — an unmatched Signature subtree stays out even
240
+ # though its ancestors are matched.
241
+ def self.mark_subset_paths(root_node, paths)
242
+ mark_all(root_node, false)
243
+ paths.each do |path|
244
+ node = lookup_path(root_node, path)
245
+ next unless node
246
+
247
+ if node.is_a?(Nodes::ElementNode)
248
+ node.in_node_set = true
249
+ node.namespace_nodes.each { |ns| ns.in_node_set = true }
250
+ node.attribute_nodes.each { |attr| attr.in_node_set = true }
251
+ node.children.each do |child|
252
+ child.in_node_set = true unless child.is_a?(Nodes::ElementNode)
253
+ end
254
+ else
255
+ node.in_node_set = true
256
+ end
257
+ end
150
258
  end
151
259
 
152
- def self.mark_node_and_descendants(node)
153
- node.in_node_set = true
154
- node.children.each { |child| mark_node_and_descendants(child) }
260
+ def self.lookup_path(root_node, path)
261
+ cur = root_node
262
+ i = 0
263
+ while i < path.length && path[i].is_a?(Integer)
264
+ cur = cur.children[path[i]]
265
+ return nil unless cur
266
+
267
+ i += 1
268
+ end
269
+ return cur if i >= path.length
270
+ return nil unless path[i] == :attribute
271
+
272
+ cur.attribute_nodes[path[i + 1]]
155
273
  end
156
274
 
157
275
  private_class_method :build_from_document, :build_node,
158
276
  :build_element_node, :build_namespace_nodes,
159
277
  :build_attribute_nodes, :build_text_node,
160
278
  :build_comment_node, :build_pi_node,
161
- :mark_node_and_descendants
279
+ :mark_all, :lookup_path
162
280
  end
163
281
  end
164
282
  end
data/lib/moxml/c14n.rb CHANGED
@@ -75,6 +75,23 @@ module Moxml
75
75
  Inclusive10.new.canonicalize(node_or_xml, with_comments: with_comments)
76
76
  end
77
77
 
78
+ # Canonicalize an XPath-selected subset of the document (spec §3).
79
+ # The node-set is whatever the expression matches (elements,
80
+ # attributes, text): matched elements render with their
81
+ # namespaces, attributes and descendants; matched attributes
82
+ # render on their owner element alone. Enveloped-signature flows
83
+ # use this to digest the document minus the Signature element
84
+ # without deep-copying.
85
+ def self.canonicalize_subset(node_or_xml, xpath, with_comments: false)
86
+ source = node_or_xml.is_a?(String) ? ::Moxml.parse(node_or_xml) : node_or_xml
87
+ root = DataModel.from_node(source)
88
+ adapter = source.context.config.adapter
89
+ matched = source.xpath(xpath).to_a
90
+ paths = matched.filter_map { |wrapper| DataModel.path_key_for(wrapper, source, adapter) }
91
+ DataModel.mark_subset_paths(root, paths)
92
+ Processor.new(with_comments: with_comments).process(root)
93
+ end
94
+
78
95
  def self.canonicalize_inclusive11(node_or_xml, with_comments: false)
79
96
  Inclusive11.new.canonicalize(node_or_xml, with_comments: with_comments)
80
97
  end
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.92"
4
+ VERSION = "0.5.93"
5
5
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/c14n"
5
+
6
+ SIG = "<Signature xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><SignedInfo>" \
7
+ "<m xmlns=\"urn:m\">x</m></SignedInfo><Object>o</Object></Signature>"
8
+ ENVELOPED_DOC = %(<env><payload id="p1">data &amp; more</payload>#{SIG}<tail/></env>).freeze
9
+ ENVELOPED_EXCLUDE = '//*[not(ancestor-or-self::*[local-name()="Signature"])]'
10
+ SIGNATURE_XPATH = '//*[local-name()="Signature"]'
11
+
12
+ CORPUS = [
13
+ ["apex attraction + text",
14
+ %(<a xml:lang="en"><b id="1"><c>t</c></b><d/></a>), "//b",
15
+ %(<b id="1" xml:lang="en"></b>)],
16
+ ["explicit descendant inclusion",
17
+ %(<a xml:lang="en"><b id="1"><c>t</c></b><d/></a>), "//b | //c",
18
+ %(<b id="1" xml:lang="en"><c>t</c></b>)],
19
+ ["direct character data of a matched element",
20
+ %(<a><b>t</b><d/></a>), "//b", "<b>t</b>"],
21
+ ["unmatched child element excluded",
22
+ %(<a><b><c>t</c></b></a>), "//b", "<b></b>"],
23
+ ["excluded sibling subtree",
24
+ %(<a><b><c/></b><d/></a>), "//*[not(self::d)]",
25
+ %(<a><b><c></c></b></a>)],
26
+ ["attribute-only selection renders nothing",
27
+ %(<a><b id="1"/></a>), "//@id", ""],
28
+ # C14N renders a text-only node-set without its omitted parents.
29
+ ["text selection", %(<a><b>t1</b><b>t2</b></a>), "//b/text()", "t1t2"],
30
+ ["empty result", %(<a><b/></a>), "//missing", ""],
31
+ ].freeze
32
+
33
+ COMMENTS_CASE = [
34
+ "with_comments keeps matched comments only",
35
+ %(<a><!-- keep --><b><!-- in --><c/></b></a>), "//b",
36
+ %(<b><!-- in --></b>)
37
+ ].freeze
38
+
39
+ COMMENTS_PADDING_LOSS = %i[ox headed_ox].freeze
40
+
41
+ RSpec.describe "Moxml::C14n subset canonicalization" do
42
+ # Node-set semantics follow the enveloped-signature interop
43
+ # (libxml2/xmlsec): a matched element renders with its namespaces,
44
+ # attributes and non-element children; child ELEMENTS need their
45
+ # own match, so an excluded subtree stays excluded under matched
46
+ # ancestors. Inheritable xml:* attributes of omitted ancestors and
47
+ # apex namespace attraction still render (spec §2.3/§2.4/§3).
48
+ describe "byte-identical subset canonical form across adapters" do
49
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
50
+ context "with the #{adapter_name} adapter" do
51
+ let(:ctx) { Moxml.new(adapter_name) }
52
+
53
+ CORPUS.each do |label, xml, xpath, expected|
54
+ next if label == "text selection" && adapter_name == :rexml
55
+
56
+ # rexml's adapter xpath does not return text nodes for
57
+ # text() node tests (pre-existing capability gap).
58
+
59
+ it "canonicalizes #{label}" do
60
+ doc = ctx.parse(xml)
61
+ expect(Moxml::C14n.canonicalize_subset(doc, xpath)).to eq(expected)
62
+ end
63
+ end
64
+
65
+ # ox/headed_ox strip comment content padding at the parse
66
+ # layer (pre-existing, same family as their PI content loss).
67
+ unless COMMENTS_PADDING_LOSS.include?(adapter_name)
68
+ it COMMENTS_CASE[0] do
69
+ doc = ctx.parse(COMMENTS_CASE[1])
70
+ expect(Moxml::C14n.canonicalize_subset(doc, COMMENTS_CASE[2],
71
+ with_comments: true))
72
+ .to eq(COMMENTS_CASE[3])
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ describe "enveloped-signature oracle" do
80
+ it "equals canonicalizing the DOM with the Signature removed" do
81
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
82
+ subset = Moxml::C14n.canonicalize_subset(
83
+ Moxml.new(adapter_name).parse(ENVELOPED_DOC), ENVELOPED_EXCLUDE
84
+ )
85
+
86
+ doc = Moxml.new(adapter_name).parse(ENVELOPED_DOC)
87
+ doc.root.xpath(SIGNATURE_XPATH).to_a[0].remove
88
+ removed = Moxml::C14n.canonicalize(doc.root)
89
+
90
+ expect(subset).to eq(removed)
91
+ end
92
+ end
93
+ end
94
+
95
+ describe "input forms" do
96
+ let(:ctx) { Moxml.new(:leptris) }
97
+
98
+ it "accepts an XML string" do
99
+ expect(Moxml::C14n.canonicalize_subset(%(<a><b>t</b></a>), "//b"))
100
+ .to eq("<b>t</b>")
101
+ end
102
+
103
+ it "accepts an element source, paths relative to it" do
104
+ doc = ctx.parse(%(<a><b><c>t</c></b></a>))
105
+ b = doc.root.xpath("//b").to_a[0]
106
+ expect(Moxml::C14n.canonicalize_subset(b, ".//c")).to eq("<c>t</c>")
107
+ end
108
+ end
109
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.92
4
+ version: 0.5.93
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -463,6 +463,7 @@ files:
463
463
  - spec/moxml/c14n/comments_pis_spec.rb
464
464
  - spec/moxml/c14n/inclusive10_spec.rb
465
465
  - spec/moxml/c14n/namespace_edge_cases_spec.rb
466
+ - spec/moxml/c14n/subset_spec.rb
466
467
  - spec/moxml/c14n/xml_attributes_spec.rb
467
468
  - spec/moxml/cdata_spec.rb
468
469
  - spec/moxml/comment_spec.rb