moxml 0.5.91 → 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: 6b290a2b707e7aec576eabf853e5e8434cb070369477d5ebc41735cadf8a0582
4
- data.tar.gz: 1dd022ba5bd37967027aecaee876750134fb65086d595671c42f6dbebcdc3f8e
3
+ metadata.gz: 92d24efac79874e02cbfa26e20fc8135e2a714020a727896cdf2a460fa7d0508
4
+ data.tar.gz: 91f18637388f6983c2c75c606e95e93f15919b3099fbdc0c8e59ebf9aca93d5e
5
5
  SHA512:
6
- metadata.gz: 1ecefd8a5388a448cc018b53b00ae11a5c91786f7641d40da11a5bcb46c957031da1a44d7b00bdb0fc086246750bb6a74af267f7cada75380ca5514a44d897b3
7
- data.tar.gz: d344520c29067fb740f3372c2c2ea16791f9d9e88a7c7faea7e033943852af9a2e40e7e4404e0a7eb9dc9dd223c41d0ff68d2cdab1538f1f1d62c5347ab0fac1
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? ==
@@ -1140,9 +1140,14 @@ module Moxml
1140
1140
  (prefix = node.prefix || prefix_part(node.name))
1141
1141
  resolved = resolve_prefix_ns(node.element, prefix)
1142
1142
  return resolved if resolved
1143
+ # The xml prefix is reserved (Namespaces in XML 1.0 §2.10):
1144
+ # bound to its URI on every element, never in
1145
+ # namespace_definitions, so the scope walk always misses.
1146
+ return implicit_xml_namespace(node) if prefix == "xml"
1143
1147
  end
1144
1148
 
1145
1149
  ns = node.namespace
1150
+ return implicit_xml_namespace(node) if reserved_xml_uri?(ns)
1146
1151
  return ns unless ns.nil?
1147
1152
 
1148
1153
  # Set-side namespaces (created attributes, renamed elements)
@@ -1174,6 +1179,30 @@ module Moxml
1174
1179
  nil
1175
1180
  end
1176
1181
 
1182
+ # The binding reports an attribute's namespace as a bare URI
1183
+ # String; when no prefix can be recovered from the qualified
1184
+ # name, the reserved xml binding must still answer for xml:*
1185
+ # attributes.
1186
+ RESERVED_XML_URI = "http://www.w3.org/XML/1998/namespace"
1187
+
1188
+ def reserved_xml_uri?(namespace)
1189
+ case namespace
1190
+ when ::Leptris::XML::Namespace
1191
+ namespace.prefix.to_s.empty? && namespace.href == RESERVED_XML_URI
1192
+ when String
1193
+ namespace == RESERVED_XML_URI
1194
+ else
1195
+ false
1196
+ end
1197
+ end
1198
+
1199
+ def implicit_xml_namespace(node)
1200
+ owner = node.is_a?(::Leptris::XML::Attr) ? node.element : node
1201
+ return nil unless owner.is_a?(::Leptris::XML::Element)
1202
+
1203
+ ::Leptris::XML::Namespace.new(owner, RESERVED_XML_URI, prefix: "xml")
1204
+ end
1205
+
1177
1206
  def prefix_part(name)
1178
1207
  name.include?(":") ? name.split(":", 2)[0] : nil
1179
1208
  end
@@ -1609,7 +1638,13 @@ module Moxml
1609
1638
  end
1610
1639
 
1611
1640
  def attribute_element(attr)
1612
- 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
1613
1648
  end
1614
1649
 
1615
1650
  def attribute_name(attr)
@@ -1889,7 +1924,14 @@ module Moxml
1889
1924
  end
1890
1925
 
1891
1926
  def comment_content(node)
1892
- return NN_CONTENT.bind_call(node) if NATIVE_READ_LAYER && node.is_a?(NN)
1927
+ if NATIVE_READ_LAYER && node.is_a?(NN)
1928
+ content = NN_CONTENT.bind_call(node)
1929
+ # NativeNode#content answers nil for comment-kind nodes
1930
+ # (leptris-ruby#313); the shared-pointer bridge has it.
1931
+ return to_binding(node).content if content.nil?
1932
+
1933
+ return content
1934
+ end
1893
1935
 
1894
1936
  node.content
1895
1937
  end
@@ -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)
@@ -50,6 +50,35 @@ module Moxml
50
50
  true
51
51
  end
52
52
 
53
+ # libxml2-native inclusive C14N: delegated for the default
54
+ # shape (Inclusive 1.0, no comments, no InclusiveNamespaces
55
+ # prefix list) when the probe below proves libxml2's output
56
+ # byte-identical to the Ruby reference (ported from canon).
57
+ # Every other combination keeps the Ruby engine.
58
+ # Lazy, not load-time: the probe exercises the wrapper layer
59
+ # (Document/C14n), which is circular while THIS adapter file
60
+ # is still loading.
61
+ def native_inclusive10(native)
62
+ return nil unless native_c14n_byte_safe?
63
+
64
+ native.canonicalize(::Nokogiri::XML::XML_C14N_1_0)
65
+ end
66
+
67
+ def native_c14n_byte_safe?
68
+ return @native_c14n_byte_safe unless @native_c14n_byte_safe.nil?
69
+
70
+ @native_c14n_byte_safe = begin
71
+ probe_xml = %(<?xml version="1.0"?><doc xmlns:p="urn:p" xmlns="urn:d" b="2" a="1"><e p:x="v" z="w">t &amp; u</e><!-- c --></doc>)
72
+ native_doc = ::Nokogiri::XML(probe_xml) { |c| c.strict.nonet }
73
+ native = native_doc.root.canonicalize(::Nokogiri::XML::XML_C14N_1_0)
74
+ wrapper = Wrappers::Document.new(native_doc, Context.new(:nokogiri))
75
+ reference = Moxml::C14n::Inclusive10.new.canonicalize(wrapper.root)
76
+ native == reference
77
+ rescue StandardError
78
+ false
79
+ end
80
+ end
81
+
53
82
  def set_root(doc, element)
54
83
  doc.root = element
55
84
  end
@@ -278,6 +278,9 @@ module Moxml
278
278
  result = []
279
279
  element.attributes.each_attribute do |attr|
280
280
  next if attr.prefix.to_s.start_with?("xmlns")
281
+ # REXML yields the default declaration with an empty prefix
282
+ # and name "xmlns"; treat it like any other declaration.
283
+ next if attr.name == "xmlns" && attr.prefix.to_s.empty?
281
284
 
282
285
  result << attr
283
286
  end
@@ -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.
@@ -33,45 +105,64 @@ module Moxml
33
105
  root = Nodes::RootNode.new
34
106
 
35
107
  if document.root
36
- root.add_child(build_element_node(document.root))
37
- # Iterate ALL document children — not just the root element.
38
- # This captures PIs and comments that appear outside the
39
- # document element, which are part of the canonical form.
108
+ # Document-level children (PIs and comments outside the
109
+ # document element) are part of the canonical form and must
110
+ # keep document order — a leading PI canonicalizes before the
111
+ # root element (spec §2.1; the native engines emit it there
112
+ # too). The document element is the first element child: some
113
+ # adapters mint fresh wrappers per children call, so wrapper
114
+ # identity cannot locate it.
115
+ seen_root = false
116
+ idx = 0
40
117
  document.children.each do |child|
41
- next if child.equal?(document.root)
42
- next if child.is_a?(::Moxml::Element)
118
+ if child.is_a?(::Moxml::Element)
119
+ next if seen_root
120
+
121
+ seen_root = true
122
+ end
43
123
 
44
- built = build_node(child)
45
- 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
46
129
  end
130
+ root.add_child(build_element_node(document.root, [idx])) unless seen_root
47
131
  end
48
132
 
49
133
  root
50
134
  end
51
135
 
52
- def self.build_node(moxml_node)
136
+ def self.build_node(moxml_node, path)
53
137
  case moxml_node
54
- when ::Moxml::Element then build_element_node(moxml_node)
55
- when ::Moxml::Text then build_text_node(moxml_node)
56
- when ::Moxml::Comment then build_comment_node(moxml_node)
57
- when ::Moxml::ProcessingInstruction then build_pi_node(moxml_node)
138
+ when ::Moxml::Element then build_element_node(moxml_node, path)
139
+ # CDATA sections are character data to canonicalization: their
140
+ # content is emitted as escaped text (spec §3.1 "text nodes").
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)
58
144
  end
59
145
  end
60
146
 
61
- def self.build_element_node(moxml_element)
147
+ def self.build_element_node(moxml_element, path)
62
148
  ns = moxml_element.namespace
63
149
  element = Nodes::ElementNode.new(
64
150
  name: moxml_element.name,
65
151
  namespace_uri: ns&.uri,
66
152
  prefix: ns&.prefix,
67
153
  )
154
+ source_map[path] = element
68
155
 
69
156
  build_namespace_nodes(moxml_element, element)
70
- build_attribute_nodes(moxml_element, element)
157
+ build_attribute_nodes(moxml_element, element, path)
71
158
 
159
+ idx = 0
72
160
  moxml_element.children.each do |child|
73
- built = build_node(child)
74
- 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
75
166
  end
76
167
 
77
168
  element
@@ -91,33 +182,48 @@ module Moxml
91
182
  )
92
183
  end
93
184
 
94
- def self.build_attribute_nodes(moxml_element, element)
95
- 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|
96
187
  ns = attr.namespace
97
- element.add_attribute(
98
- Nodes::AttributeNode.new(
99
- name: attr.name,
100
- value: attr.value,
101
- namespace_uri: ns&.uri,
102
- prefix: ns&.prefix,
103
- ),
188
+ # The xml prefix is reserved: any attribute bound to the XML
189
+ # namespace URI renders (and sorts) as prefix "xml" regardless
190
+ # of how the adapter reports it (spec §2.3 attribute-axis key).
191
+ prefix = ns&.prefix
192
+ uri = ns&.uri
193
+ if prefix == "xml" || uri == XML_URI
194
+ prefix = "xml"
195
+ uri = XML_URI
196
+ end
197
+ node = Nodes::AttributeNode.new(
198
+ name: attr.name,
199
+ value: attr.value,
200
+ namespace_uri: uri,
201
+ prefix: prefix,
104
202
  )
203
+ source_map[path + [:attribute, idx]] = node
204
+ element.add_attribute(node)
105
205
  end
106
206
  end
107
207
 
108
- def self.build_text_node(moxml_text)
109
- 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
110
212
  end
111
213
 
112
- def self.build_comment_node(moxml_comment)
113
- 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
114
218
  end
115
219
 
116
- def self.build_pi_node(moxml_pi)
117
- Nodes::ProcessingInstructionNode.new(
220
+ def self.build_pi_node(moxml_pi, path)
221
+ node = Nodes::ProcessingInstructionNode.new(
118
222
  target: moxml_pi.target || moxml_pi.name,
119
223
  data: moxml_pi.content || "",
120
224
  )
225
+ source_map[path] = node
226
+ node
121
227
  end
122
228
 
123
229
  def self.mark_all(node, value)
@@ -125,21 +231,52 @@ module Moxml
125
231
  node.children.each { |child| mark_all(child, value) }
126
232
  end
127
233
 
128
- def self.mark_subset(root_node, matched)
129
- matched.each { |node| mark_node_and_descendants(node) }
130
- 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
131
258
  end
132
259
 
133
- def self.mark_node_and_descendants(node)
134
- node.in_node_set = true
135
- 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]]
136
273
  end
137
274
 
138
275
  private_class_method :build_from_document, :build_node,
139
276
  :build_element_node, :build_namespace_nodes,
140
277
  :build_attribute_nodes, :build_text_node,
141
278
  :build_comment_node, :build_pi_node,
142
- :mark_node_and_descendants
279
+ :mark_all, :lookup_path
143
280
  end
144
281
  end
145
282
  end
data/lib/moxml/c14n.rb CHANGED
@@ -39,7 +39,7 @@ module Moxml
39
39
  # :exclusive10 Exclusive C14N 1.0 (W3C REC-xml-exc-c14n-20020718)
40
40
  def self.canonicalize(node_or_xml, with_comments: false,
41
41
  algorithm: :inclusive10, inclusive_namespaces: [])
42
- if (native = native_inclusive10(node_or_xml, with_comments,
42
+ if (native = native_inclusive10(node_or_xml, algorithm, with_comments,
43
43
  inclusive_namespaces))
44
44
  return native
45
45
  end
@@ -52,13 +52,15 @@ module Moxml
52
52
  end
53
53
 
54
54
  # The leptris engine canonicalizes in C — 23x the Ruby reference.
55
- # Delegated only for the exact default shape (Inclusive 1.0, no
56
- # comments, no InclusiveNamespace prefix list) on wrappers whose
57
- # adapter passed the NATIVE_C14N_BYTE_SAFE load probe; every
55
+ # Delegated only for the exact default shape (Inclusive 1.0
56
+ # algorithm, no comments, no InclusiveNamespace prefix list) on
57
+ # wrappers whose adapter passed the byte-safety probe; every
58
58
  # other combination keeps the Ruby engine (node-set subsets,
59
59
  # xml:base fixup, exclusive, 1.1 stay on the ported
60
60
  # implementation regardless).
61
- def self.native_inclusive10(node_or_xml, with_comments, inclusive_namespaces)
61
+ def self.native_inclusive10(node_or_xml, algorithm, with_comments,
62
+ inclusive_namespaces)
63
+ return nil unless algorithm.nil? || algorithm == :inclusive10
62
64
  return nil if with_comments || !inclusive_namespaces.empty?
63
65
  return nil unless algorithm_shape_inclusive10?(node_or_xml)
64
66
 
@@ -73,6 +75,23 @@ module Moxml
73
75
  Inclusive10.new.canonicalize(node_or_xml, with_comments: with_comments)
74
76
  end
75
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
+
76
95
  def self.canonicalize_inclusive11(node_or_xml, with_comments: false)
77
96
  Inclusive11.new.canonicalize(node_or_xml, with_comments: with_comments)
78
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.91"
4
+ VERSION = "0.5.93"
5
5
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/c14n"
5
+
6
+ CORPUS = {
7
+ "cdata content becomes escaped text" => [
8
+ "<doc><![CDATA[a < b]]>tail</doc>",
9
+ "<doc>a &lt; btail</doc>",
10
+ ],
11
+ "prefixed and unprefixed attributes" => [
12
+ %(<doc xmlns:p="urn:p"><e p:x="1" y="2"/></doc>),
13
+ %(<doc xmlns:p="urn:p"><e y="2" p:x="1"></e></doc>),
14
+ ],
15
+ "reserved xml:* attributes keep the xml prefix" => [
16
+ %(<doc xml:base="http://a/b/" xml:lang="en" xml:space="preserve"><e xml:id="i1"/></doc>),
17
+ %(<doc xml:base="http://a/b/" xml:lang="en" xml:space="preserve"><e xml:id="i1"></e></doc>),
18
+ ],
19
+ "default namespace transitions" => [
20
+ %(<doc xmlns="urn:a"><e xmlns=""/><e xmlns="urn:a"/></doc>),
21
+ %(<doc xmlns="urn:a"><e xmlns=""></e><e></e></doc>),
22
+ ],
23
+ "namespace redeclaration" => [
24
+ %(<doc xmlns:m="urn:m"><m:e xmlns:m="urn:m2"/></doc>),
25
+ %(<doc xmlns:m="urn:m"><m:e xmlns:m="urn:m2"></m:e></doc>),
26
+ ],
27
+ "prefixed namespace ordering" => [
28
+ %(<r xmlns:z="urn:z" xmlns:a="urn:a" b="2" a="1"/>),
29
+ %(<r xmlns:a="urn:a" xmlns:z="urn:z" a="1" b="2"></r>),
30
+ ],
31
+ "entities stay escaped" => [
32
+ "<doc>a &amp; b &lt; c</doc>",
33
+ "<doc>a &amp; b &lt; c</doc>",
34
+ ],
35
+ }.freeze
36
+
37
+ RSpec.describe "Moxml::C14n cross-adapter canonicalization" do
38
+ # Every installed adapter must answer identical canonical bytes: the
39
+ # Ruby reference path (Inclusive10) is shared, but attribute/namespace
40
+ # reporting differs per adapter, and two adapters additionally
41
+ # delegate the default shape to a native engine (leptris in C,
42
+ # nokogiri via libxml2).
43
+ describe "byte-identical canonical form across adapters" do
44
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
45
+ context "with the #{adapter_name} adapter" do
46
+ let(:ctx) { Moxml.new(adapter_name) }
47
+
48
+ CORPUS.each do |label, (input, expected)|
49
+ it "canonicalizes #{label}" do
50
+ root = ctx.parse(input).root
51
+ expect(Moxml::C14n.canonicalize_inclusive10(root)).to eq(expected)
52
+ expect(Moxml::C14n.canonicalize(root)).to eq(expected)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ describe "document-level nodes keep document order" do
60
+ # oga surfaces the XML declaration as a <?xml?> PI and keeps the
61
+ # separator space in PI content; ox/headed_ox drop PI content on
62
+ # parse. Both are pre-existing parse-layer divergences, out of
63
+ # scope here.
64
+ let(:adapters) { Moxml::Adapter::AVAILABLE_ADAPTERS - %i[oga ox headed_ox] }
65
+
66
+ it "canonicalizes a leading PI before the root element" do
67
+ adapters.each do |adapter_name|
68
+ doc = Moxml.new(adapter_name)
69
+ .parse(%(<?xml version="1.0"?><?render mode="x"?><doc><e/></doc>))
70
+ expect(Moxml::C14n.canonicalize(doc))
71
+ .to eq(%(<?render mode="x"?>\n<doc><e></e></doc>))
72
+ end
73
+ end
74
+ end
75
+
76
+ describe "native delegation" do
77
+ adapters_with_native = %i[leptris nokogiri].select do |name|
78
+ Moxml::Adapter::AVAILABLE_ADAPTERS.include?(name)
79
+ end
80
+
81
+ adapters_with_native.each do |adapter_name|
82
+ context "with the #{adapter_name} adapter" do
83
+ let(:adapter_class) do
84
+ Moxml::Adapter.const_get(adapter_name.to_s.split("_").map(&:capitalize).join)
85
+ end
86
+
87
+ it "passes the byte-safety probe" do
88
+ expect(adapter_class.native_c14n_byte_safe?).to be(true)
89
+ end
90
+
91
+ it "engages the native path for the default shape" do
92
+ doc = Moxml.new(adapter_name)
93
+ .parse(%(<doc xmlns="urn:d" a="1"><e>t</e></doc>))
94
+ native = Moxml::C14n.native_inclusive10(doc.root, :inclusive10, false, [])
95
+ expect(native).to be_a(String)
96
+ end
97
+
98
+ it "keeps exclusive on the Ruby engine" do
99
+ doc = Moxml.new(adapter_name)
100
+ .parse(%(<root xmlns:foo='urn:foo'><foo:a/></root>))
101
+ expect(Moxml::C14n.canonicalize(doc.root, algorithm: :exclusive10))
102
+ .to eq(Moxml::C14n.canonicalize_exclusive(doc.root))
103
+ end
104
+
105
+ it "keeps inclusive 1.1 on the Ruby engine" do
106
+ doc = Moxml.new(adapter_name).parse(%(<root a="1"/>))
107
+ expect(Moxml::C14n.canonicalize(doc.root, algorithm: :inclusive11))
108
+ .to eq(Moxml::C14n.canonicalize_inclusive11(doc.root))
109
+ end
110
+
111
+ it "keeps with_comments on the Ruby engine" do
112
+ doc = Moxml.new(adapter_name).parse(%(<doc><!-- c --><e/></doc>))
113
+ expect(Moxml::C14n.canonicalize(doc.root, with_comments: true))
114
+ .to eq(Moxml::C14n.canonicalize_inclusive10(doc.root,
115
+ with_comments: true))
116
+ end
117
+ end
118
+ end
119
+ end
120
+ 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.91
4
+ version: 0.5.93
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -457,11 +457,13 @@ files:
457
457
  - spec/moxml/attribute_resolver_spec.rb
458
458
  - spec/moxml/attribute_spec.rb
459
459
  - spec/moxml/builder_spec.rb
460
+ - spec/moxml/c14n/adapter_delegation_spec.rb
460
461
  - spec/moxml/c14n/api_spec.rb
461
462
  - spec/moxml/c14n/c14n_spec.rb
462
463
  - spec/moxml/c14n/comments_pis_spec.rb
463
464
  - spec/moxml/c14n/inclusive10_spec.rb
464
465
  - spec/moxml/c14n/namespace_edge_cases_spec.rb
466
+ - spec/moxml/c14n/subset_spec.rb
465
467
  - spec/moxml/c14n/xml_attributes_spec.rb
466
468
  - spec/moxml/cdata_spec.rb
467
469
  - spec/moxml/comment_spec.rb