moxml 0.1.25 → 0.2.0

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.
Files changed (181) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +9 -0
  3. data/.github/workflows/windows.yml +62 -0
  4. data/Gemfile +3 -1
  5. data/README.adoc +133 -6
  6. data/docs/signature/algorithms.md +108 -0
  7. data/docs/signature/architecture.md +148 -0
  8. data/docs/signature/c14n.md +110 -0
  9. data/docs/signature/examples.md +109 -0
  10. data/docs/signature/flows.md +132 -0
  11. data/docs/signature/quick-reference.md +115 -0
  12. data/docs/signature/security.md +143 -0
  13. data/examples/signature/auto_key_extraction.rb +24 -0
  14. data/examples/signature/enveloped_rsa.rb +45 -0
  15. data/examples/signature/hmac.rb +33 -0
  16. data/lib/compat/opal/moxml_boot.rb +5 -0
  17. data/lib/moxml/adapter/base.rb +53 -49
  18. data/lib/moxml/adapter/customized_leptris/declaration.rb +32 -0
  19. data/lib/moxml/adapter/customized_leptris/doctype.rb +31 -0
  20. data/lib/moxml/adapter/customized_leptris/entity_reference.rb +12 -0
  21. data/lib/moxml/adapter/customized_leptris/text_segment.rb +26 -0
  22. data/lib/moxml/adapter/customized_leptris.rb +16 -0
  23. data/lib/moxml/adapter/customized_libxml/cdata.rb +3 -12
  24. data/lib/moxml/adapter/customized_libxml/comment.rb +2 -9
  25. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -10
  26. data/lib/moxml/adapter/customized_libxml/node.rb +8 -0
  27. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +2 -10
  28. data/lib/moxml/adapter/customized_libxml/text.rb +2 -9
  29. data/lib/moxml/adapter/customized_oga/entity_decoder.rb +37 -0
  30. data/lib/moxml/adapter/customized_oga/raw_value_override.rb +52 -0
  31. data/lib/moxml/adapter/customized_oga/xml_generator.rb +4 -34
  32. data/lib/moxml/adapter/customized_oga.rb +2 -0
  33. data/lib/moxml/adapter/customized_ox/entity_reference.rb +1 -17
  34. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +1 -11
  35. data/lib/moxml/adapter/headed_ox.rb +9 -128
  36. data/lib/moxml/adapter/leptris.rb +965 -0
  37. data/lib/moxml/adapter/libxml.rb +131 -99
  38. data/lib/moxml/adapter/nokogiri.rb +29 -8
  39. data/lib/moxml/adapter/oga.rb +91 -54
  40. data/lib/moxml/adapter/ox.rb +93 -137
  41. data/lib/moxml/adapter/rexml.rb +61 -50
  42. data/lib/moxml/adapter.rb +2 -1
  43. data/lib/moxml/attribute.rb +8 -4
  44. data/lib/moxml/attribute_resolver.rb +189 -0
  45. data/lib/moxml/c14n/attribute_handler.rb +71 -0
  46. data/lib/moxml/c14n/character_encoder.rb +29 -0
  47. data/lib/moxml/c14n/data_model.rb +145 -0
  48. data/lib/moxml/c14n/exclusive.rb +188 -0
  49. data/lib/moxml/c14n/inclusive_10.rb +42 -0
  50. data/lib/moxml/c14n/inclusive_11.rb +21 -0
  51. data/lib/moxml/c14n/namespace_context.rb +68 -0
  52. data/lib/moxml/c14n/namespace_handler.rb +71 -0
  53. data/lib/moxml/c14n/node.rb +42 -0
  54. data/lib/moxml/c14n/nodes/attribute_node.rb +50 -0
  55. data/lib/moxml/c14n/nodes/comment_node.rb +29 -0
  56. data/lib/moxml/c14n/nodes/element_node.rb +62 -0
  57. data/lib/moxml/c14n/nodes/namespace_node.rb +41 -0
  58. data/lib/moxml/c14n/nodes/processing_instruction_node.rb +30 -0
  59. data/lib/moxml/c14n/nodes/root_node.rb +22 -0
  60. data/lib/moxml/c14n/nodes/text_node.rb +29 -0
  61. data/lib/moxml/c14n/nodes.rb +17 -0
  62. data/lib/moxml/c14n/processor.rb +118 -0
  63. data/lib/moxml/c14n/writer.rb +121 -0
  64. data/lib/moxml/c14n/xml_base_handler.rb +140 -0
  65. data/lib/moxml/c14n.rb +96 -0
  66. data/lib/moxml/config.rb +29 -3
  67. data/lib/moxml/context.rb +12 -0
  68. data/lib/moxml/element.rb +29 -12
  69. data/lib/moxml/entity/reference.rb +28 -0
  70. data/lib/moxml/entity.rb +125 -0
  71. data/lib/moxml/node.rb +90 -1
  72. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  73. data/lib/moxml/signature/algorithms/base64_transform.rb +40 -0
  74. data/lib/moxml/signature/algorithms/canonicalization_base.rb +87 -0
  75. data/lib/moxml/signature/algorithms/digest_base.rb +51 -0
  76. data/lib/moxml/signature/algorithms/dsa_sha.rb +107 -0
  77. data/lib/moxml/signature/algorithms/ecdsa_sha.rb +125 -0
  78. data/lib/moxml/signature/algorithms/enveloped_signature_transform.rb +79 -0
  79. data/lib/moxml/signature/algorithms/exc_c14n_10.rb +23 -0
  80. data/lib/moxml/signature/algorithms/hmac_sha.rb +117 -0
  81. data/lib/moxml/signature/algorithms/inclusive_c14n_10.rb +21 -0
  82. data/lib/moxml/signature/algorithms/inclusive_c14n_11.rb +23 -0
  83. data/lib/moxml/signature/algorithms/rsa_pkcs1_sha.rb +56 -0
  84. data/lib/moxml/signature/algorithms/sha1.rb +13 -0
  85. data/lib/moxml/signature/algorithms/sha224.rb +13 -0
  86. data/lib/moxml/signature/algorithms/sha256.rb +13 -0
  87. data/lib/moxml/signature/algorithms/sha384.rb +13 -0
  88. data/lib/moxml/signature/algorithms/sha512.rb +13 -0
  89. data/lib/moxml/signature/algorithms/signature_method_base.rb +58 -0
  90. data/lib/moxml/signature/algorithms/transform_base.rb +40 -0
  91. data/lib/moxml/signature/algorithms.rb +113 -0
  92. data/lib/moxml/signature/errors.rb +99 -0
  93. data/lib/moxml/signature/key_extractor.rb +172 -0
  94. data/lib/moxml/signature/model/algorithm_method.rb +18 -0
  95. data/lib/moxml/signature/model/digest_method.rb +15 -0
  96. data/lib/moxml/signature/model/key/dsa_key_value.rb +28 -0
  97. data/lib/moxml/signature/model/key/ec_key_value.rb +22 -0
  98. data/lib/moxml/signature/model/key/rsa_key_value.rb +19 -0
  99. data/lib/moxml/signature/model/key/x509_data.rb +32 -0
  100. data/lib/moxml/signature/model/key/x509_digest.rb +19 -0
  101. data/lib/moxml/signature/model/key/x509_issuer_serial.rb +19 -0
  102. data/lib/moxml/signature/model/key_info.rb +21 -0
  103. data/lib/moxml/signature/model/key_value.rb +20 -0
  104. data/lib/moxml/signature/model/object_element.rb +18 -0
  105. data/lib/moxml/signature/model/reference.rb +22 -0
  106. data/lib/moxml/signature/model/signature.rb +21 -0
  107. data/lib/moxml/signature/model/signature_value.rb +16 -0
  108. data/lib/moxml/signature/model/signed_info.rb +20 -0
  109. data/lib/moxml/signature/model/transform.rb +16 -0
  110. data/lib/moxml/signature/model/transforms.rb +36 -0
  111. data/lib/moxml/signature/model.rb +35 -0
  112. data/lib/moxml/signature/parser.rb +270 -0
  113. data/lib/moxml/signature/reference_resolver.rb +79 -0
  114. data/lib/moxml/signature/reference_result.rb +22 -0
  115. data/lib/moxml/signature/serializer.rb +163 -0
  116. data/lib/moxml/signature/signer.rb +73 -0
  117. data/lib/moxml/signature/single_verification_result.rb +31 -0
  118. data/lib/moxml/signature/transform_pipeline.rb +106 -0
  119. data/lib/moxml/signature/verification_result.rb +27 -0
  120. data/lib/moxml/signature/verifier.rb +129 -0
  121. data/lib/moxml/signature.rb +94 -0
  122. data/lib/moxml/version.rb +1 -1
  123. data/lib/moxml/xml_emitter.rb +71 -0
  124. data/lib/moxml/xpath/compiler.rb +60 -7
  125. data/lib/moxml/xpath/parser.rb +22 -15
  126. data/lib/moxml.rb +5 -0
  127. data/reference-docs/w3c-xmldsig-bestpractices.md +216 -0
  128. data/reference-docs/w3c-xmldsig-core.md +400 -0
  129. data/spec/examples/readme_examples_spec.rb +0 -4
  130. data/spec/examples/xpath_examples_spec.rb +0 -11
  131. data/spec/fixtures/xmldsig/keys/rsa_private.pem +28 -0
  132. data/spec/fixtures/xmldsig/keys/rsa_public.pem +9 -0
  133. data/spec/fixtures/xmldsig/keys/rsa_ref.pem +15 -0
  134. data/spec/fixtures/xmldsig/keys/rsa_ref.pub +6 -0
  135. data/spec/fixtures/xmldsig/sign2-doc.xml +6 -0
  136. data/spec/fixtures/xmldsig/sign2-result.xml +25 -0
  137. data/spec/fixtures/xmldsig/sign3-result.xml +39 -0
  138. data/spec/integration/all_adapters_spec.rb +17 -0
  139. data/spec/integration/sax_parity_spec.rb +58 -0
  140. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  141. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  142. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  143. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  144. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +0 -8
  145. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  146. data/spec/moxml/adapter/ox_spec.rb +20 -6
  147. data/spec/moxml/adapter/platform_spec.rb +15 -2
  148. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  149. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  150. data/spec/moxml/c14n/api_spec.rb +117 -0
  151. data/spec/moxml/c14n/c14n_spec.rb +88 -0
  152. data/spec/moxml/c14n/comments_pis_spec.rb +65 -0
  153. data/spec/moxml/c14n/inclusive10_spec.rb +60 -0
  154. data/spec/moxml/c14n/namespace_edge_cases_spec.rb +88 -0
  155. data/spec/moxml/c14n/xml_attributes_spec.rb +54 -0
  156. data/spec/moxml/doctype_spec.rb +1 -1
  157. data/spec/moxml/entity_spec.rb +76 -0
  158. data/spec/moxml/node_spec.rb +104 -0
  159. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  160. data/spec/moxml/signature/algorithms/base64_transform_spec.rb +26 -0
  161. data/spec/moxml/signature/algorithms/digest_base_spec.rb +72 -0
  162. data/spec/moxml/signature/algorithms/dsa_sha_spec.rb +46 -0
  163. data/spec/moxml/signature/algorithms/ecdsa_sha_spec.rb +72 -0
  164. data/spec/moxml/signature/algorithms/enveloped_signature_transform_spec.rb +40 -0
  165. data/spec/moxml/signature/algorithms/hmac_sha_spec.rb +71 -0
  166. data/spec/moxml/signature/algorithms/rsa_pkcs1_sha_spec.rb +55 -0
  167. data/spec/moxml/signature/algorithms_spec.rb +76 -0
  168. data/spec/moxml/signature/cross_verify_spec.rb +64 -0
  169. data/spec/moxml/signature/edge_cases_spec.rb +284 -0
  170. data/spec/moxml/signature/fixtures_spec.rb +80 -0
  171. data/spec/moxml/signature/key_extractor_spec.rb +96 -0
  172. data/spec/moxml/signature/model_spec.rb +54 -0
  173. data/spec/moxml/signature/round_trip_spec.rb +113 -0
  174. data/spec/moxml/xml_emitter_spec.rb +64 -0
  175. data/spec/moxml/xpath/axes_spec.rb +72 -0
  176. data/spec/moxml/xpath/parser_spec.rb +6 -0
  177. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  178. data/spec/performance/benchmark_spec.rb +13 -6
  179. data/spec/performance/thread_safety_spec.rb +0 -4
  180. data/spec/spec_helper.rb +5 -1
  181. metadata +130 -2
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Pins cross-adapter parity for entity-decoding in attribute values and
6
+ # text content, across SAX, DOM, and build-then-serialize-then-reparse.
7
+ # Previously the four adapters diverged: nokogiri/libxml SAX failed to
8
+ # resolve "&#NN;"; rexml SAX delivered raw escaped strings; oga DOM
9
+ # and SAX double-decoded.
10
+
11
+ # Namespace-isolated container for the test data and the SAX capture
12
+ # handler. Wrapping these in a module rather than declaring them as
13
+ # top-level constants inside `RSpec.describe` keeps them out of the
14
+ # global namespace and lets the spec reference them without tripping
15
+ # the RSpec/* cops that forbid local-variable use inside examples.
16
+ module SaxEntityParityFixtures
17
+ class CaptureHandler < Moxml::SAX::ElementHandler
18
+ attr_reader :first_attrs, :text
19
+
20
+ def initialize
21
+ super
22
+ @first_attrs = nil
23
+ @text = +""
24
+ end
25
+
26
+ def on_start_element(_name, attrs = {}, _namespaces = {})
27
+ @first_attrs = attrs.dup if @first_attrs.nil?
28
+ end
29
+
30
+ def on_characters(chunk)
31
+ @text << chunk
32
+ end
33
+ end
34
+
35
+ ADAPTERS = %i[nokogiri ox oga rexml libxml headed_ox].freeze
36
+
37
+ # Each row: [input XML, decoded attribute value, expected attribute
38
+ # value as it appears in the serialized XML]. The serialized form
39
+ # asserts what every adapter must emit byte-for-byte for the attribute.
40
+ ATTRIBUTE_CASES = {
41
+ "wrapped amp + decimal ref" => ['<doc x="&amp;#38;"/>', "&#38;", "&amp;#38;"],
42
+ "wrapped amp + hex ref" => ['<doc x="&amp;#x26;"/>', "&#x26;", "&amp;#x26;"],
43
+ "wrapped amp + non-std ref" => ['<doc x="&amp;copy;"/>', "&copy;", "&amp;copy;"],
44
+ "wrapped amp + lt" => ['<doc x="&amp;lt;"/>', "&lt;", "&amp;lt;"],
45
+ "wrapped amp + amp" => ['<doc x="&amp;amp;"/>', "&amp;", "&amp;amp;"],
46
+ "two amps" => ['<doc x="&amp;&amp;"/>', "&&", "&amp;&amp;"],
47
+ "plain amp" => ['<doc x="&amp;"/>', "&", "&amp;"],
48
+ "plain decimal ref" => ['<doc x="&#38;"/>', "&", "&amp;"],
49
+ "plain hex ref" => ['<doc x="&#x26;"/>', "&", "&amp;"],
50
+ "high codepoint" => ['<doc x="&#169;"/>', "©", "©"],
51
+ "no entities" => ['<doc x="plain"/>', "plain", "plain"],
52
+ }.freeze
53
+
54
+ # Each row: [input XML, decoded text, expected text as it appears in
55
+ # serialized XML].
56
+ TEXT_CASES = {
57
+ "text wrapped amp + decimal" => ["<doc>&amp;#38;</doc>", "&#38;", "&amp;#38;"],
58
+ "text wrapped amp + non-std" => ["<doc>&amp;copy;</doc>", "&copy;", "&amp;copy;"],
59
+ "text plain amp" => ["<doc>&amp;</doc>", "&", "&amp;"],
60
+ }.freeze
61
+
62
+ # XML 1.0 §2.6 — entity references inside PI content are NOT resolved.
63
+ # Every adapter must surface the literal source text. The libxml
64
+ # adapter previously decoded these five entities via a gsub chain in
65
+ # processing_instruction_content; pin each one explicitly.
66
+ PI_ENTITY_CASES = {
67
+ "&amp;" => "data &amp; more",
68
+ "&lt;" => "data &lt; more",
69
+ "&gt;" => "data &gt; more",
70
+ "&quot;" => "data &quot; more",
71
+ "&apos;" => "data &apos; more",
72
+ }.freeze
73
+
74
+ # Per-adapter overrides for the rebuild-path serialized form. Oga's
75
+ # set_attribute calls preprocess_entities which marks non-standard
76
+ # named entities so they survive serialization as "&name;" rather than
77
+ # being escaped to "&amp;name;". This is the entity-preservation
78
+ # feature documented in spec/moxml/adapter/entity_restoration_spec.rb
79
+ # and spec/moxml/adapter/oga_spec.rb — not a bug.
80
+ REBUILD_SERIALIZED_OVERRIDES = {
81
+ oga: {
82
+ "wrapped amp + non-std ref" => "&copy;",
83
+ },
84
+ }.freeze
85
+ end
86
+
87
+ RSpec.describe "SAX/DOM entity parity" do
88
+ SaxEntityParityFixtures::ADAPTERS.each do |adapter|
89
+ context "with #{adapter} adapter" do
90
+ let(:ctx) { Moxml.new(adapter) }
91
+
92
+ SaxEntityParityFixtures::ATTRIBUTE_CASES.each do |label, (xml, expected, expected_serialized)|
93
+ it "decodes attribute via SAX: #{label}" do
94
+ handler = SaxEntityParityFixtures::CaptureHandler.new
95
+ ctx.sax_parse(xml, handler)
96
+ expect(handler.first_attrs["x"]).to eq(expected)
97
+ end
98
+
99
+ it "decodes attribute via DOM: #{label}" do
100
+ expect(ctx.parse(xml).root["x"]).to eq(expected)
101
+ end
102
+
103
+ it "serializes attribute to expected XML form: #{label}" do
104
+ # End-to-end: parse → DOM access → set on fresh doc → serialize
105
+ # → assert exact serialized form. This pins what the consumer
106
+ # sees on the wire, not just what the parser delivers in memory.
107
+ parsed_value = ctx.parse(xml).root["x"]
108
+
109
+ doc2 = ctx.create_document
110
+ el = doc2.create_element("doc")
111
+ el["x"] = parsed_value
112
+ doc2.add_child(el)
113
+
114
+ serialized = doc2.to_xml.sub(/\A<\?[^>]*\?>\s*/, "")
115
+ expected_form = SaxEntityParityFixtures::REBUILD_SERIALIZED_OVERRIDES.dig(adapter, label) || expected_serialized
116
+ expect(serialized).to include(%(x="#{expected_form}"))
117
+ end
118
+
119
+ it "round-trips attribute through build+serialize+reparse: #{label}" do
120
+ parsed_value = ctx.parse(xml).root["x"]
121
+
122
+ doc2 = ctx.create_document
123
+ el = doc2.create_element("doc")
124
+ el["x"] = parsed_value
125
+ doc2.add_child(el)
126
+
127
+ serialized = doc2.to_xml.sub(/\A<\?[^>]*\?>/, "")
128
+ expect(ctx.parse(serialized).root["x"]).to eq(parsed_value)
129
+ end
130
+
131
+ it "re-serializes parsed document idempotently: #{label}" do
132
+ # Parse → serialize → re-parse → re-serialize → assert the two
133
+ # serializations match. This catches any non-idempotent
134
+ # mutations the adapter applies during the round-trip.
135
+ first = ctx.parse(xml).to_xml.sub(/\A<\?[^>]*\?>\s*/, "")
136
+ second = ctx.parse(first).to_xml.sub(/\A<\?[^>]*\?>\s*/, "")
137
+ expect(second).to eq(first)
138
+ end
139
+ end
140
+
141
+ SaxEntityParityFixtures::TEXT_CASES.each do |label, (xml, expected, expected_serialized)|
142
+ it "decodes text via SAX: #{label}" do
143
+ handler = SaxEntityParityFixtures::CaptureHandler.new
144
+ ctx.sax_parse(xml, handler)
145
+ expect(handler.text).to eq(expected)
146
+ end
147
+
148
+ it "decodes text via DOM: #{label}" do
149
+ expect(ctx.parse(xml).root.text).to eq(expected)
150
+ end
151
+
152
+ it "serializes text to expected XML form: #{label}" do
153
+ serialized = ctx.parse(xml).to_xml.sub(/\A<\?[^>]*\?>\s*/, "").strip
154
+ expect(serialized).to include(">#{expected_serialized}<")
155
+ end
156
+ end
157
+
158
+ it "does not double-escape '&' in a programmatically-built text node" do
159
+ # Pins the libxml customized Text#to_xml fix: an authored "&"
160
+ # must serialize to "&amp;" exactly once, never "&amp;amp;".
161
+ # Regression target for adapter/customized_libxml/text.rb where
162
+ # the previous implementation used #content (decoded text) and
163
+ # lost the "&" → "&amp;" escape, and earlier variants risked
164
+ # double-escaping on round-trip.
165
+ doc = ctx.create_document
166
+ el = doc.create_element("doc")
167
+ el.add_child("a & b")
168
+ doc.add_child(el)
169
+
170
+ serialized = doc.to_xml.sub(/\A<\?[^>]*\?>\s*/, "").strip
171
+ expect(serialized).to include(">a &amp; b<")
172
+ expect(serialized).not_to include("&amp;amp;")
173
+
174
+ # And the round-trip must preserve the original text exactly.
175
+ reparsed_text = ctx.parse(serialized).root.text
176
+ expect(reparsed_text).to eq("a & b")
177
+ end
178
+
179
+ it "mutates in-tree text node content without double-escaping '&'" do
180
+ # Pins the libxml fix for set_text_content on attached text
181
+ # nodes: libxml-ruby's #content= setter pre-escapes the input,
182
+ # and #to_s escapes again on serialization. The adapter must
183
+ # replace the in-tree node with a fresh raw-storage node to
184
+ # avoid silent double-escape data corruption.
185
+ doc = ctx.create_document
186
+ el = doc.create_element("doc")
187
+ text = doc.create_text("placeholder")
188
+ el.add_child(text)
189
+ doc.add_child(el)
190
+
191
+ text.content = "a & b"
192
+ expect(text.content).to eq("a & b")
193
+
194
+ serialized = doc.to_xml.sub(/\A<\?[^>]*\?>\s*/, "").strip
195
+ expect(serialized).to include(">a &amp; b<")
196
+ expect(serialized).not_to include("&amp;amp;")
197
+ expect(ctx.parse(serialized).root.text).to eq("a & b")
198
+ end
199
+
200
+ it "mutates in-tree PI content verbatim without escaping" do
201
+ # Pins the libxml fix for set_processing_instruction_content
202
+ # on attached PI nodes: libxml-ruby's #content= setter pre-
203
+ # escapes quotes and ampersands, but XML 1.0 §2.6 specifies
204
+ # PI content is verbatim — entity references are not resolved
205
+ # and no escaping is required on serialization.
206
+ doc = ctx.create_document
207
+ el = doc.create_element("doc")
208
+ pi = doc.create_processing_instruction("target", "placeholder")
209
+ el.add_child(pi)
210
+ doc.add_child(el)
211
+
212
+ pi.content = 'a " & b'
213
+ expect(pi.content.strip).to eq('a " & b')
214
+
215
+ serialized = doc.to_xml.sub(/\A<\?[^>]*\?>\s*/, "").strip
216
+ expect(serialized).to include('a " & b?>')
217
+ expect(serialized).not_to include("&quot;")
218
+ expect(serialized).not_to include("&amp;")
219
+ reparsed = ctx.parse(serialized).root.children.find do |c|
220
+ c.is_a?(Moxml::ProcessingInstruction)
221
+ end
222
+ expect(reparsed.content.strip).to eq('a " & b')
223
+ end
224
+
225
+ it "mutates unparented text/PI content before adoption into the tree" do
226
+ # Pins the swap_native_in_place early-return when parent is nil:
227
+ # set_text_content / set_processing_instruction_content called
228
+ # before the node is added to any element must still replace the
229
+ # wrapper's @native with a fresh raw-storage node, so the value
230
+ # survives correctly once the node is later attached and serialized.
231
+ doc = ctx.create_document
232
+ el = doc.create_element("doc")
233
+
234
+ text = doc.create_text("placeholder")
235
+ text.content = "a & b"
236
+ pi = doc.create_processing_instruction("target", "placeholder")
237
+ pi.content = 'a " & b'
238
+
239
+ el.add_child(text)
240
+ el.add_child(pi)
241
+ doc.add_child(el)
242
+
243
+ serialized = doc.to_xml.sub(/\A<\?[^>]*\?>\s*/, "").strip
244
+ expect(serialized).to include(">a &amp; b<")
245
+ expect(serialized).to include('<?target a " & b?>')
246
+ expect(serialized).not_to include("&amp;amp;")
247
+ end
248
+
249
+ # Regressions around &amp; appearing inside contexts that Oga's
250
+ # source-level preprocessor must not touch (CDATA, comments,
251
+ # DOCTYPE) — verified across adapters to ensure parity holds.
252
+ it "preserves literal &amp; inside CDATA" do
253
+ doc = ctx.parse("<doc><![CDATA[a &amp; b]]></doc>")
254
+ expect(doc.root.text).to eq("a &amp; b")
255
+ end
256
+
257
+ it "preserves literal &amp; inside comment" do
258
+ doc = ctx.parse("<doc><!-- a &amp; b --></doc>")
259
+ comment = doc.root.children.find { |c| c.is_a?(Moxml::Comment) }
260
+ expect(comment.content.strip).to eq("a &amp; b")
261
+ end
262
+
263
+ it "preserves literal U+FFFC U+FFFC user data in attribute" do
264
+ xml = "<doc x=\"\u{FFFC}\u{FFFC} test\"/>"
265
+ expect(ctx.parse(xml).root["x"]).to eq("\u{FFFC}\u{FFFC} test")
266
+ end
267
+
268
+ it "preserves literal U+FDD0 U+FDD0 user data in attribute" do
269
+ # Single noncharacters near the Oga marker codepoints — must not
270
+ # collide with the internal DEFAULT_AMP_MARKER sentinel.
271
+ xml = "<doc x=\"\u{FDD0}\u{FDD0} test\"/>"
272
+ expect(ctx.parse(xml).root["x"]).to eq("\u{FDD0}\u{FDD0} test")
273
+ end
274
+
275
+ it "preserves the exact Oga DEFAULT_AMP_MARKER sequence in attribute (no amp)" do
276
+ # User data matching the default Oga marker codepoints with NO
277
+ # &amp; in the input — restore must not run, so the sequence
278
+ # survives intact.
279
+ xml = "<doc x=\"\u{FDD0}\u{FDD1}\u{FDD2} plain\"/>"
280
+ expect(ctx.parse(xml).root["x"]).to eq("\u{FDD0}\u{FDD1}\u{FDD2} plain")
281
+ end
282
+
283
+ it "preserves the exact Oga DEFAULT_AMP_MARKER sequence in attribute (with amp)" do
284
+ # User data matching the default Oga marker codepoints alongside
285
+ # an &amp; that the preprocessor would otherwise substitute.
286
+ # The adapter must pick a marker not present in user data so
287
+ # restore doesn't corrupt the original sequence.
288
+ xml = "<doc x=\"\u{FDD0}\u{FDD1}\u{FDD2} &amp; foo\"/>"
289
+ expect(ctx.parse(xml).root["x"]).to eq("\u{FDD0}\u{FDD1}\u{FDD2} & foo")
290
+ end
291
+
292
+ it "preserves the exact Oga DEFAULT_AMP_MARKER sequence in text" do
293
+ xml = "<doc>\u{FDD0}\u{FDD1}\u{FDD2} &amp; foo</doc>"
294
+ expect(ctx.parse(xml).root.text).to eq("\u{FDD0}\u{FDD1}\u{FDD2} & foo")
295
+ end
296
+
297
+ it "handles DOCTYPE with '[' in quoted system ID" do
298
+ # System ID containing a literal "[" before the internal subset
299
+ # opener — quote-aware DOCTYPE terminator must not pick "]>"
300
+ # based on the bracket inside the quoted string.
301
+ xml = %(<!DOCTYPE root SYSTEM "has[bracket"><root x="&amp;#38;"/>)
302
+ expect(ctx.parse(xml).root["x"]).to eq("&#38;")
303
+ end
304
+
305
+ it "handles bare DOCTYPE with '>' in quoted system ID" do
306
+ # Bare DOCTYPE (no internal subset) — the terminator is ">".
307
+ # A literal ">" inside the quoted SYSTEM id must not be treated
308
+ # as the end of the declaration. For the Oga adapter this also
309
+ # exercises find_block_terminator's quote-aware ">" scan; if
310
+ # the bare-DOCTYPE end was misidentified, the preprocessor
311
+ # would rewrite "&amp;" inside the remaining quoted ID region
312
+ # to DEFAULT_AMP_MARKER and leak it into the serialized doctype.
313
+ xml = %(<!DOCTYPE root SYSTEM "a>b&amp;c"><root x="&amp;#38;"/>)
314
+ expect(ctx.parse(xml).root["x"]).to eq("&#38;")
315
+ end
316
+
317
+ it "handles DOCTYPE with ']>' inside a quoted entity value" do
318
+ # Quote-aware DOCTYPE terminator must skip past a literal "]>"
319
+ # appearing inside a quoted ExternalID / ENTITY value so the
320
+ # internal subset terminator is matched correctly. For the Oga
321
+ # adapter this also exercises the source-level DOCTYPE skipper;
322
+ # native-DOCTYPE parsers must reach the same result.
323
+ xml = '<!DOCTYPE root [ <!ENTITY foo "]>"> ]><root x="&amp;#38;"/>'
324
+ expect(ctx.parse(xml).root["x"]).to eq("&#38;")
325
+ end
326
+
327
+ SaxEntityParityFixtures::PI_ENTITY_CASES.each do |entity_label, payload|
328
+ it "preserves literal #{entity_label} inside processing instruction" do
329
+ doc = ctx.parse("<doc><?target #{payload}?></doc>")
330
+ pi = doc.root.children.find { |c| c.is_a?(Moxml::ProcessingInstruction) }
331
+ expect(pi.content.strip).to eq(payload)
332
+ end
333
+ end
334
+ end
335
+ end
336
+
337
+ # Cross-adapter equivalence: feed the same XML into every adapter and
338
+ # assert all of them emit the same serialized attribute form. Catches
339
+ # any regression where one adapter starts to deviate from the rest.
340
+ describe "cross-adapter serialization equivalence" do
341
+ def serialize_via(adapter, xml)
342
+ ctx = Moxml.new(adapter)
343
+ ctx.parse(xml).to_xml.sub(/\A<\?[^>]*\?>\s*/, "").strip
344
+ end
345
+
346
+ SaxEntityParityFixtures::ATTRIBUTE_CASES.each do |label, (xml, _expected, expected_serialized)|
347
+ it "every adapter emits the same attribute form: #{label}" do
348
+ results = SaxEntityParityFixtures::ADAPTERS.to_h { |a| [a, serialize_via(a, xml)] }
349
+ results.each do |a, out|
350
+ expect(out).to(
351
+ include(%(x="#{expected_serialized}")),
352
+ "#{a} produced #{out.inspect}",
353
+ )
354
+ end
355
+ end
356
+ end
357
+ end
358
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+ require "base64"
6
+
7
+ RSpec.describe Moxml::Signature::Algorithms::Base64Transform do
8
+ let(:ctx) { Moxml.new(:nokogiri) }
9
+ let(:transform) { described_class.new(context: ctx) }
10
+
11
+ it "decodes base64 octet input" do
12
+ encoded = Base64.strict_encode64("hello world")
13
+ expect(transform.transform(encoded)).to eq("hello world")
14
+ end
15
+
16
+ it "strips whitespace before decoding" do
17
+ encoded = Base64.strict_encode64("hello world").chars.each_slice(4).map(&:join).join("\n")
18
+ expect(transform.transform(encoded)).to eq("hello world")
19
+ end
20
+
21
+ it "raises TransformError on invalid base64" do
22
+ expect do
23
+ transform.transform("!!!not base64!!!")
24
+ end.to raise_error(Moxml::Signature::TransformError)
25
+ end
26
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+ require "openssl"
6
+ require "base64"
7
+
8
+ RSpec.describe Moxml::Signature::Algorithms::DigestBase do
9
+ describe "SHA-256" do
10
+ let(:digest) { Moxml::Signature::Algorithms::SHA256.new }
11
+
12
+ it "computes raw bytes matching OpenSSL" do
13
+ data = "hello"
14
+ expect(digest.digest(data)).to eq(OpenSSL::Digest::SHA256.digest(data))
15
+ end
16
+
17
+ it "computes base64-encoded digest matching OpenSSL" do
18
+ data = "hello"
19
+ expected = Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(data))
20
+ expect(digest.digest_base64(data)).to eq(expected)
21
+ end
22
+
23
+ it "matches the FIPS 180-3 test vector for empty string" do
24
+ # SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
25
+ expected_hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
26
+ expect(digest.digest("").unpack1("H*")).to eq(expected_hex)
27
+ end
28
+
29
+ it "matches the FIPS 180-3 test vector for 'abc'" do
30
+ expected_hex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
31
+ expect(digest.digest("abc").unpack1("H*")).to eq(expected_hex)
32
+ end
33
+ end
34
+
35
+ describe "SHA-1" do
36
+ it "matches the FIPS test vector for 'abc'" do
37
+ digest = Moxml::Signature::Algorithms::SHA1.new
38
+ expected_hex = "a9993e364706816aba3e25717850c26c9cd0d89d"
39
+ expect(digest.digest("abc").unpack1("H*")).to eq(expected_hex)
40
+ end
41
+
42
+ it "is registered under its W3C URI" do
43
+ klass = Moxml::Signature::Algorithms.lookup(
44
+ :digest, "http://www.w3.org/2000/09/xmldsig#sha1"
45
+ )
46
+ expect(klass).to eq(Moxml::Signature::Algorithms::SHA1)
47
+ end
48
+ end
49
+
50
+ describe "all five SHA digests" do
51
+ expected_uris = {
52
+ "http://www.w3.org/2000/09/xmldsig#sha1" =>
53
+ [Moxml::Signature::Algorithms::SHA1, 20],
54
+ "http://www.w3.org/2001/04/xmldsig-more#sha224" =>
55
+ [Moxml::Signature::Algorithms::SHA224, 28],
56
+ "http://www.w3.org/2001/04/xmlenc#sha256" =>
57
+ [Moxml::Signature::Algorithms::SHA256, 32],
58
+ "http://www.w3.org/2001/04/xmldsig-more#sha384" =>
59
+ [Moxml::Signature::Algorithms::SHA384, 48],
60
+ "http://www.w3.org/2001/04/xmlenc#sha512" =>
61
+ [Moxml::Signature::Algorithms::SHA512, 64],
62
+ }
63
+
64
+ expected_uris.each do |uri, (klass, byte_length)|
65
+ it "#{uri} resolves to #{klass.name.split('::').last} with #{byte_length}-byte output" do
66
+ instance = klass.new
67
+ result = instance.digest("test")
68
+ expect(result.bytesize).to eq(byte_length)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+ require "openssl"
6
+
7
+ RSpec.describe Moxml::Signature::Algorithms::DsaSha do
8
+ let(:key) { OpenSSL::PKey::DSA.generate(2048) }
9
+ let(:data) { "the quick brown fox jumps over the lazy dog" }
10
+
11
+ it "round-trips DSA-SHA1" do
12
+ algo = described_class.new(
13
+ identifier_uri: "http://www.w3.org/2000/09/xmldsig#dsa-sha1",
14
+ )
15
+ sig = algo.sign(data, key)
16
+ # DSA q for 2048-bit DSA is 256 bits → 32-byte halves → 64-byte raw sig.
17
+ expect(sig.bytesize).to eq(64)
18
+ expect(algo.verify(data, key, sig)).to be true
19
+ end
20
+
21
+ it "round-trips DSA-SHA256" do
22
+ algo = described_class.new(
23
+ identifier_uri: "http://www.w3.org/2009/xmldsig11#dsa-sha256",
24
+ )
25
+ sig = algo.sign(data, key)
26
+ expect(sig.bytesize).to eq(64)
27
+ expect(algo.verify(data, key, sig)).to be true
28
+ end
29
+
30
+ it "rejects tampered payload" do
31
+ algo = described_class.new(
32
+ identifier_uri: "http://www.w3.org/2009/xmldsig11#dsa-sha256",
33
+ )
34
+ sig = algo.sign(data, key)
35
+ expect(algo.verify("#{data}!", key, sig)).to be false
36
+ end
37
+
38
+ it "rejects non-DSA keys" do
39
+ algo = described_class.new(
40
+ identifier_uri: "http://www.w3.org/2009/xmldsig11#dsa-sha256",
41
+ )
42
+ expect do
43
+ algo.sign(data, OpenSSL::PKey::RSA.generate(2048))
44
+ end.to raise_error(Moxml::Signature::SignatureKeyError)
45
+ end
46
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+ require "openssl"
6
+
7
+ RSpec.describe Moxml::Signature::Algorithms::EcdsaSha do
8
+ let(:key) { OpenSSL::PKey::EC.generate("prime256v1") }
9
+ let(:data) { "the quick brown fox jumps over the lazy dog" }
10
+
11
+ %w[
12
+ http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1
13
+ http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha224
14
+ http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256
15
+ http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384
16
+ http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512
17
+ ].each do |uri|
18
+ it "#{uri} round-trips sign/verify on P-256" do
19
+ algo = described_class.new(identifier_uri: uri)
20
+ sig = algo.sign(data, key)
21
+ # P-256: 32-byte r ‖ 32-byte s = 64 bytes
22
+ expect(sig.bytesize).to eq(64)
23
+ expect(algo.verify(data, key, sig)).to be true
24
+ end
25
+ end
26
+
27
+ it "rejects tampered payload" do
28
+ algo = described_class.new(
29
+ identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
30
+ )
31
+ sig = algo.sign(data, key)
32
+ expect(algo.verify("#{data}!", key, sig)).to be false
33
+ end
34
+
35
+ it "rejects verification with a different key" do
36
+ other = OpenSSL::PKey::EC.generate("prime256v1")
37
+ algo = described_class.new(
38
+ identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
39
+ )
40
+ sig = algo.sign(data, key)
41
+ expect(algo.verify(data, other, sig)).to be false
42
+ end
43
+
44
+ it "supports P-384 with 48-byte coordinates" do
45
+ p384 = OpenSSL::PKey::EC.generate("secp384r1")
46
+ algo = described_class.new(
47
+ identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
48
+ )
49
+ sig = algo.sign(data, p384)
50
+ expect(sig.bytesize).to eq(96)
51
+ expect(algo.verify(data, p384, sig)).to be true
52
+ end
53
+
54
+ it "supports P-521 with 66-byte coordinates" do
55
+ p521 = OpenSSL::PKey::EC.generate("secp521r1")
56
+ algo = described_class.new(
57
+ identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512",
58
+ )
59
+ sig = algo.sign(data, p521)
60
+ expect(sig.bytesize).to eq(132)
61
+ expect(algo.verify(data, p521, sig)).to be true
62
+ end
63
+
64
+ it "rejects non-EC keys" do
65
+ algo = described_class.new(
66
+ identifier_uri: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
67
+ )
68
+ expect do
69
+ algo.sign(data, OpenSSL::PKey::RSA.generate(2048))
70
+ end.to raise_error(Moxml::Signature::SignatureKeyError)
71
+ end
72
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/signature"
5
+
6
+ RSpec.describe Moxml::Signature::Algorithms::EnvelopedSignatureTransform do
7
+ let(:ctx) { Moxml.new(:nokogiri) }
8
+
9
+ it "is a no-op when signature_element is nil (signing case)" do
10
+ doc = ctx.parse("<root><child/></root>")
11
+ root = doc.root
12
+ transform = described_class.new(context: ctx, signature_element: nil)
13
+ expect(transform.transform(root)).to equal(root)
14
+ end
15
+
16
+ it "removes the containing signature from a copy (verification case)" do
17
+ xml = <<~XML.strip
18
+ <root>
19
+ <payload>important</payload>
20
+ <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
21
+ <ds:SignedInfo/>
22
+ </ds:Signature>
23
+ </root>
24
+ XML
25
+ doc = ctx.parse(xml)
26
+ sig_elem = doc.at_xpath("//ds:Signature",
27
+ "ds" => "http://www.w3.org/2000/09/xmldsig#")
28
+
29
+ transform = described_class.new(context: ctx, signature_element: sig_elem)
30
+ result = transform.transform(doc.root)
31
+
32
+ expect(doc.at_xpath("//ds:Signature",
33
+ "ds" => "http://www.w3.org/2000/09/xmldsig#")).not_to be_nil
34
+
35
+ c14n = Moxml::C14n::Exclusive.new
36
+ canonical = c14n.canonicalize(result)
37
+ expect(canonical).not_to include("Signature")
38
+ expect(canonical).to include("important")
39
+ end
40
+ end