moxml 0.5.92 → 0.5.94

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: e54a93041f955c95affa2211704839bfaf47dafc0908cd73b58012946ffe99f1
4
+ data.tar.gz: 4d28d6376f61ae7441e13075ac5e9433bd18b6c393cf95afe363a639a4b8d251
5
5
  SHA512:
6
- metadata.gz: b5de2cfb1875e9947b25cabd166f2f1e2c3ec6994d9ea8376a3b42ae5afd7bfe450ae3fed1306e25748cfdedc6223cf46848c5202e1d4cf898d6df2ac04f2abd
7
- data.tar.gz: 1f5922e2968fdc796df5a183bbd1d876b2c66618eef068c08005c528cf735df0b193b7143f856abe381aa2ec37224c6302359b7f188eb6c3342b680a729c0475
6
+ metadata.gz: 3e13a34f69a9e91b18d7fc9cef1e30c40ced24c8c73b322459ecba15af568439b002db8eec51097af2188571d026bfae7861646cc33077127c1fbd570b179863
7
+ data.tar.gz: a873c43e7ee3ccffc1997bf103263b7848d8ed17c61e25322a8d550bdf62a46d2f1e9e4a93f6e2832484a6df9d93350fd53d833a0b48ce04e1247583fc447d50
@@ -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)
@@ -1936,7 +1942,14 @@ module Moxml
1936
1942
  end
1937
1943
 
1938
1944
  def processing_instruction_content(node)
1939
- return NN_CONTENT.bind_call(node) if NATIVE_READ_LAYER && node.is_a?(NN)
1945
+ if NATIVE_READ_LAYER && node.is_a?(NN)
1946
+ content = NN_CONTENT.bind_call(node)
1947
+ # NativeNode#content answers nil for PI-kind nodes too
1948
+ # (leptris-ruby#344 covers comments); bridge for the data.
1949
+ return to_binding(node).content if content.nil?
1950
+
1951
+ return content
1952
+ end
1940
1953
 
1941
1954
  node.content
1942
1955
  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)
@@ -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,54 @@ 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. Comments and PIs render
241
+ # only when the expression itself selects them (spec §3: the
242
+ # node-set is literal).
243
+ def self.mark_subset_paths(root_node, paths)
244
+ mark_all(root_node, false)
245
+ paths.each do |path|
246
+ node = lookup_path(root_node, path)
247
+ next unless node
248
+
249
+ if node.is_a?(Nodes::ElementNode)
250
+ node.in_node_set = true
251
+ node.namespace_nodes.each { |ns| ns.in_node_set = true }
252
+ node.attribute_nodes.each { |attr| attr.in_node_set = true }
253
+ node.children.each do |child|
254
+ child.in_node_set = true if child.is_a?(Nodes::TextNode)
255
+ end
256
+ else
257
+ node.in_node_set = true
258
+ end
259
+ end
150
260
  end
151
261
 
152
- def self.mark_node_and_descendants(node)
153
- node.in_node_set = true
154
- node.children.each { |child| mark_node_and_descendants(child) }
262
+ def self.lookup_path(root_node, path)
263
+ cur = root_node
264
+ i = 0
265
+ while i < path.length && path[i].is_a?(Integer)
266
+ cur = cur.children[path[i]]
267
+ return nil unless cur
268
+
269
+ i += 1
270
+ end
271
+ return cur if i >= path.length
272
+ return nil unless path[i] == :attribute
273
+
274
+ cur.attribute_nodes[path[i + 1]]
155
275
  end
156
276
 
157
277
  private_class_method :build_from_document, :build_node,
158
278
  :build_element_node, :build_namespace_nodes,
159
279
  :build_attribute_nodes, :build_text_node,
160
280
  :build_comment_node, :build_pi_node,
161
- :mark_node_and_descendants
281
+ :mark_all, :lookup_path
162
282
  end
163
283
  end
164
284
  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.94"
5
5
  end
@@ -0,0 +1,128 @@
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 renders explicitly selected comments only",
35
+ %(<a><!-- keep --><b><!-- in --><c/></b></a>), "//b | //comment()",
36
+ %(<!-- keep -->\n<b><!-- in --></b>)
37
+ ].freeze
38
+
39
+ COMMENTS_EXCLUDED_CASE = [
40
+ "unselected comments stay out",
41
+ %(<a><!-- keep --><b><!-- in --><c/></b></a>), "//b",
42
+ %(<b></b>)
43
+ ].freeze
44
+
45
+ COMMENTS_PADDING_LOSS = %i[ox headed_ox].freeze
46
+ # rexml's adapter xpath does not return comment()/text() node results
47
+ # (pre-existing capability gap, same family for both axes).
48
+ COMMENT_SELECTION_GAP = %i[rexml].freeze
49
+
50
+ RSpec.describe "Moxml::C14n subset canonicalization" do
51
+ # Node-set semantics follow the enveloped-signature interop
52
+ # (libxml2/xmlsec): a matched element renders with its namespaces,
53
+ # attributes and non-element children; child ELEMENTS need their
54
+ # own match, so an excluded subtree stays excluded under matched
55
+ # ancestors. Inheritable xml:* attributes of omitted ancestors and
56
+ # apex namespace attraction still render (spec §2.3/§2.4/§3).
57
+ describe "byte-identical subset canonical form across adapters" do
58
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
59
+ context "with the #{adapter_name} adapter" do
60
+ let(:ctx) { Moxml.new(adapter_name) }
61
+
62
+ CORPUS.each do |label, xml, xpath, expected|
63
+ next if label == "text selection" && adapter_name == :rexml
64
+
65
+ # rexml's adapter xpath does not return text nodes for
66
+ # text() node tests (pre-existing capability gap).
67
+
68
+ it "canonicalizes #{label}" do
69
+ doc = ctx.parse(xml)
70
+ expect(Moxml::C14n.canonicalize_subset(doc, xpath)).to eq(expected)
71
+ end
72
+ end
73
+
74
+ # ox/headed_ox strip comment content padding at the parse
75
+ # layer (pre-existing, same family as their PI content loss);
76
+ # only the rendered-comment case depends on it. rexml's
77
+ # comment() selection gap also only affects this case.
78
+ unless COMMENTS_PADDING_LOSS.include?(adapter_name) ||
79
+ COMMENT_SELECTION_GAP.include?(adapter_name)
80
+ it COMMENTS_CASE[0] do
81
+ doc = ctx.parse(COMMENTS_CASE[1])
82
+ expect(Moxml::C14n.canonicalize_subset(doc, COMMENTS_CASE[2],
83
+ with_comments: true))
84
+ .to eq(COMMENTS_CASE[3])
85
+ end
86
+ end
87
+
88
+ it COMMENTS_EXCLUDED_CASE[0] do
89
+ doc = ctx.parse(COMMENTS_EXCLUDED_CASE[1])
90
+ expect(Moxml::C14n.canonicalize_subset(doc, COMMENTS_EXCLUDED_CASE[2],
91
+ with_comments: true))
92
+ .to eq(COMMENTS_EXCLUDED_CASE[3])
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ describe "enveloped-signature oracle" do
99
+ it "equals canonicalizing the DOM with the Signature removed" do
100
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
101
+ subset = Moxml::C14n.canonicalize_subset(
102
+ Moxml.new(adapter_name).parse(ENVELOPED_DOC), ENVELOPED_EXCLUDE
103
+ )
104
+
105
+ doc = Moxml.new(adapter_name).parse(ENVELOPED_DOC)
106
+ doc.root.xpath(SIGNATURE_XPATH).to_a[0].remove
107
+ removed = Moxml::C14n.canonicalize(doc.root)
108
+
109
+ expect(subset).to eq(removed)
110
+ end
111
+ end
112
+ end
113
+
114
+ describe "input forms" do
115
+ let(:ctx) { Moxml.new(:leptris) }
116
+
117
+ it "accepts an XML string" do
118
+ expect(Moxml::C14n.canonicalize_subset(%(<a><b>t</b></a>), "//b"))
119
+ .to eq("<b>t</b>")
120
+ end
121
+
122
+ it "accepts an element source, paths relative to it" do
123
+ doc = ctx.parse(%(<a><b><c>t</c></b></a>))
124
+ b = doc.root.xpath("//b").to_a[0]
125
+ expect(Moxml::C14n.canonicalize_subset(b, ".//c")).to eq("<c>t</c>")
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/c14n"
5
+
6
+ # Shapes drawn from the W3C Canonical XML 1.0 REC examples (§2–§3):
7
+ # attribute-value whitespace normalization, character-reference CR
8
+ # preservation vs literal-CR folding, CDATA as character data, empty
9
+ # element form, PI/comment placement, and the §3 document-subset
10
+ # whitespace rules. Expected bytes are the forms all conformant
11
+ # adapters answer identically.
12
+ #
13
+ # Documented parse-layer divergences (skipped rows):
14
+ # - ox/headed_ox expand &#xD; to LF, strip inter-element whitespace
15
+ # and comment padding at parse (out of scope here)
16
+ # - oga keeps the target/content separator space in PI data
17
+ W3C_FULL_FORMS = [
18
+ ["attribute-value whitespace collapses to single spaces",
19
+ "<doc>\n <e attr1=\"v1\"\n attr2 = \"v2\"\n > content </e>\n</doc>",
20
+ "<doc>\n <e attr1=\"v1\" attr2=\"v2\"> content </e>\n</doc>"],
21
+ ["character-reference CR stays &#xD;; literal CR folds to LF",
22
+ %(<doc>a&#xD;b\nc&#xA;d</doc>),
23
+ %(<doc>a&#xD;b\nc\nd</doc>)],
24
+ ["CDATA is character data and escapes",
25
+ %(<doc><![CDATA[a<b]]> &amp; &lt; x</doc>),
26
+ %(<doc>a&lt;b &amp; &lt; x</doc>)],
27
+ ["empty elements render as start and end tags",
28
+ %(<doc><e/><f></f></doc>),
29
+ "<doc><e></e><f></f></doc>"],
30
+ ["PIs render in place; comments drop without with_comments",
31
+ %(<doc><?pi c?><!-- c1 --><e/><!-- c2 --></doc>),
32
+ %(<doc><?pi c?><e></e></doc>)],
33
+ ].freeze
34
+
35
+ W3C_SUBSET_FORM = [
36
+ %(<doc>\n <e1 a="1" b="2"/>\n <e2/>\n <e3/>\n <!-- A small comment -->\n <?pi x?>\n</doc>),
37
+ "//e1 | //e2 | //e3",
38
+ %(<e1 a="1" b="2"></e1><e2></e2><e3></e3>),
39
+ ].freeze
40
+
41
+ W3C_SUBSET_WITH_ROOT = [
42
+ %(<doc>\n <e1 a="1" b="2"/>\n <e2/>\n <e3/>\n <!-- A small comment -->\n <?pi x?>\n</doc>),
43
+ "/doc | //e1 | //e2 | //e3",
44
+ %(<doc>\n <e1 a="1" b="2"></e1>\n <e2></e2>\n <e3></e3>\n \n \n</doc>),
45
+ ].freeze
46
+
47
+ CR_SHAPE = 1
48
+ PI_SHAPE = 4
49
+ WHITESPACE_SHAPES = %i[ox headed_ox].freeze
50
+
51
+ RSpec.describe "Moxml::C14n W3C REC-xml-c14n examples" do
52
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
53
+ context "with the #{adapter_name} adapter" do
54
+ let(:ctx) { Moxml.new(adapter_name) }
55
+
56
+ W3C_FULL_FORMS.each_with_index do |(label, xml, expected), idx|
57
+ # ox/headed_ox strip inter-element whitespace, expand &#xD;
58
+ # and pad comments at parse; oga keeps the PI separator space.
59
+ skip_shapes = { ox: [0, CR_SHAPE, PI_SHAPE],
60
+ headed_ox: [0, CR_SHAPE, PI_SHAPE],
61
+ oga: [PI_SHAPE] }[adapter_name] || []
62
+ next if skip_shapes.include?(idx)
63
+
64
+ it "canonicalizes #{label}" do
65
+ doc = ctx.parse(xml)
66
+ expect(Moxml::C14n.canonicalize_inclusive10(doc)).to eq(expected)
67
+ expect(Moxml::C14n.canonicalize(doc.root)).to eq(expected)
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ describe "W3C §3 document-subset whitespace" do
74
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
75
+ next if WHITESPACE_SHAPES.include?(adapter_name)
76
+
77
+ it "renders selected nodes only, whitespace runs in place (#{adapter_name})" do
78
+ doc = Moxml.new(adapter_name).parse(W3C_SUBSET_FORM[0])
79
+ expect(Moxml::C14n.canonicalize_subset(doc, W3C_SUBSET_FORM[1]))
80
+ .to eq(W3C_SUBSET_FORM[2])
81
+ end
82
+
83
+ it "keeps the root's whitespace runs when the root is selected (#{adapter_name})" do
84
+ doc = Moxml.new(adapter_name).parse(W3C_SUBSET_WITH_ROOT[0])
85
+ expect(Moxml::C14n.canonicalize_subset(doc, W3C_SUBSET_WITH_ROOT[1]))
86
+ .to eq(W3C_SUBSET_WITH_ROOT[2])
87
+ end
88
+ end
89
+ end
90
+
91
+ describe "Ruby reference agrees with the native engines" do
92
+ %i[leptris nokogiri].each do |adapter_name|
93
+ next unless Moxml::Adapter::AVAILABLE_ADAPTERS.include?(adapter_name)
94
+
95
+ it "on every full-document shape (#{adapter_name})" do
96
+ W3C_FULL_FORMS.each do |_label, xml, _expected|
97
+ root = Moxml.new(adapter_name).parse(xml).root
98
+ expect(Moxml::C14n.canonicalize(root))
99
+ .to eq(Moxml::C14n.canonicalize_inclusive10(root))
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
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.94
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-28 00:00:00.000000000 Z
11
+ date: 2026-09-30 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Moxml is a unified XML manipulation library that provides a common API
@@ -463,6 +463,8 @@ 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
467
+ - spec/moxml/c14n/w3c_examples_spec.rb
466
468
  - spec/moxml/c14n/xml_attributes_spec.rb
467
469
  - spec/moxml/cdata_spec.rb
468
470
  - spec/moxml/comment_spec.rb