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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rexml/document"
4
4
  require "rexml/xpath"
5
+ require "rexml/streamlistener"
5
6
  require "set" unless RUBY_ENGINE == "opal"
6
7
  require "stringio" if RUBY_ENGINE == "opal"
7
8
 
@@ -60,18 +61,21 @@ module Moxml
60
61
  # @param handler [Moxml::SAX::Handler] Moxml SAX handler
61
62
  # @return [void]
62
63
  def sax_parse(xml, handler)
63
- require "rexml/parsers/sax2parser"
64
+ require "rexml/parsers/streamparser"
64
65
  require "rexml/source"
65
- require "stringio"
66
66
 
67
- bridge = REXMLSAX2Bridge.new(handler)
67
+ bridge = REXMLStreamBridge.new(handler)
68
68
 
69
69
  xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
70
- source = ::REXML::IOSource.new(StringIO.new(xml_string))
71
70
 
72
- parser = ::REXML::Parsers::SAX2Parser.new(source)
73
- parser.listen(bridge)
74
- parser.parse
71
+ # StreamParser (rather than SAX2Parser) is used because
72
+ # SAX2Parser normalizes plain "&#NN;" and "&#NN;" attribute
73
+ # literals to the same raw string, making spec-correct decoding
74
+ # impossible after the fact. StreamParser delivers values in
75
+ # the same decoded form as REXML's DOM.
76
+ handler.on_start_document
77
+ ::REXML::Parsers::StreamParser.new(xml_string, bridge).parse
78
+ handler.on_end_document
75
79
  rescue ::REXML::ParseException => e
76
80
  error = Moxml::ParseError.new(e.message, line: e.line)
77
81
  handler.on_error(error)
@@ -117,15 +121,12 @@ module Moxml
117
121
  def create_native_doctype(name, external_id, system_id)
118
122
  return nil unless name
119
123
 
120
- parts = [name]
121
- if external_id
122
- parts.push("PUBLIC", %("#{external_id}"))
123
- parts << %("#{system_id}") if system_id
124
- elsif system_id
125
- parts.push("SYSTEM", %("#{system_id}"))
126
- end
127
-
128
- ::REXML::DocType.new(parts.join(" "))
124
+ external = if external_id
125
+ system_id ? %(PUBLIC "#{external_id}" "#{system_id}") : %(PUBLIC "#{external_id}")
126
+ elsif system_id
127
+ %(SYSTEM "#{system_id}")
128
+ end
129
+ external ? ::REXML::DocType.new(name.to_s, external) : ::REXML::DocType.new(name.to_s)
129
130
  end
130
131
 
131
132
  def set_root(doc, element)
@@ -250,9 +251,17 @@ module Moxml
250
251
  def attributes(element)
251
252
  return [] unless element.is_a?(::REXML::Element)
252
253
 
253
- # Only return non-namespace attributes
254
- element.attributes.values
255
- .reject { |attr| attr.prefix.to_s.start_with?("xmlns") }
254
+ # Each real Attribute node, not Attributes#values: when two
255
+ # attributes share a local name across namespaces, REXML's
256
+ # values leaks its internal prefix-grouping Hash alongside
257
+ # the Attribute objects (issue #95).
258
+ result = []
259
+ element.attributes.each_attribute do |attr|
260
+ next if attr.prefix.to_s.start_with?("xmlns")
261
+
262
+ result << attr
263
+ end
264
+ result
256
265
  end
257
266
 
258
267
  def attribute_element(attribute)
@@ -271,10 +280,17 @@ module Moxml
271
280
  element = attribute.element
272
281
  element.attributes.delete(old_name)
273
282
  element.attributes << attribute
283
+ attribute
274
284
  end
275
285
 
276
286
  def set_attribute_value(attribute, value)
277
287
  attribute.normalized = value
288
+ attribute
289
+ end
290
+
291
+ def remove_attribute_native(attribute)
292
+ attribute.element.attributes.delete(attribute.expanded_name)
293
+ attribute
278
294
  end
279
295
 
280
296
  def get_attribute(element, name)
@@ -468,9 +484,11 @@ module Moxml
468
484
  element.add_namespace(prefix,
469
485
  ns.value)
470
486
  end
487
+ # Renames the node in place (works for elements and
488
+ # attributes alike); the caller keeps tracking the same
489
+ # native.
471
490
  element.name = "#{prefix}:#{element.name}"
472
- owner = element.is_a?(::REXML::Attribute) ? element.element : element
473
- ::REXML::Attribute.new(prefix, ns.value, owner)
491
+ element
474
492
  end
475
493
 
476
494
  def namespace_prefix(node)
@@ -518,11 +536,17 @@ module Moxml
518
536
  end
519
537
 
520
538
  def doctype_external_id(native)
521
- native.public
539
+ # Constructed doctypes keep the identifier as a raw
540
+ # external_id string; parsed ones populate public directly
541
+ native.public ||
542
+ native.external_id&.match(/PUBLIC\s+"([^"]*)"/)&.[](1)
522
543
  end
523
544
 
524
545
  def doctype_system_id(native)
525
- native.system
546
+ return native.system if native.system
547
+
548
+ quoted = native.external_id&.scan(/"([^"]*)"/)
549
+ quoted&.last&.first
526
550
  end
527
551
 
528
552
  # not used at the moment
@@ -642,29 +666,33 @@ module Moxml
642
666
  end
643
667
  end
644
668
 
645
- # Bridge between REXML SAX2 and Moxml SAX
669
+ # Bridge between REXML StreamParser and Moxml SAX
646
670
  #
647
- # Translates REXML::SAX2Parser events to Moxml::SAX::Handler events
671
+ # StreamParser (rather than SAX2Parser) is used because SAX2Parser
672
+ # normalizes plain "&#NN;" and "&amp;#NN;" attribute literals to
673
+ # the same raw string, which makes spec-correct decoding impossible
674
+ # after the fact. StreamParser delivers attribute values in the
675
+ # same decoded form as REXML's DOM.
648
676
  #
649
677
  # @private
650
- class REXMLSAX2Bridge
678
+ class REXMLStreamBridge
679
+ include ::REXML::StreamListener
651
680
  include Moxml::SAX::NamespaceSplitter
652
681
 
653
682
  def initialize(handler)
654
683
  @handler = handler
655
684
  end
656
685
 
657
- # REXML splits element name into uri/localname/qname
658
- def start_element(_uri, _localname, qname, attributes)
686
+ def tag_start(name, attributes)
659
687
  attr_hash, ns_hash = split_attributes_and_namespaces(attributes)
660
- @handler.on_start_element(qname, attr_hash, ns_hash)
688
+ @handler.on_start_element(name, attr_hash, ns_hash)
661
689
  end
662
690
 
663
- def end_element(_uri, _localname, qname)
664
- @handler.on_end_element(qname)
691
+ def tag_end(name)
692
+ @handler.on_end_element(name)
665
693
  end
666
694
 
667
- def characters(text)
695
+ def text(text)
668
696
  @handler.on_characters(text)
669
697
  end
670
698
 
@@ -676,26 +704,9 @@ module Moxml
676
704
  @handler.on_comment(text)
677
705
  end
678
706
 
679
- def processing_instruction(target, data)
707
+ def instruction(target, data)
680
708
  @handler.on_processing_instruction(target, data || "")
681
709
  end
682
-
683
- def start_document
684
- @handler.on_start_document
685
- end
686
-
687
- def end_document
688
- @handler.on_end_document
689
- end
690
-
691
- # REXML calls these but we don't need to handle them
692
- def xmldecl(version, encoding, standalone)
693
- # XML declaration - we don't need to do anything
694
- end
695
-
696
- def progress(position)
697
- # Progress callback - we don't need to do anything
698
- end
699
710
  end
700
711
  end
701
712
  end
data/lib/moxml/adapter.rb CHANGED
@@ -7,8 +7,9 @@ module Moxml
7
7
  autoload :CustomizedOx, "moxml/adapter/customized_ox"
8
8
  autoload :CustomizedRexml, "moxml/adapter/customized_rexml"
9
9
  autoload :CustomizedLibxml, "moxml/adapter/customized_libxml"
10
+ autoload :CustomizedLeptris, "moxml/adapter/customized_leptris"
10
11
 
11
- AVAILABLE_ADAPTERS = %i[nokogiri oga rexml ox headed_ox libxml].freeze
12
+ AVAILABLE_ADAPTERS = %i[nokogiri oga rexml ox headed_ox libxml leptris].freeze
12
13
 
13
14
  # Adapters that work under the Opal (JavaScript) runtime.
14
15
  # Oga is pure Ruby and designed with Opal compatibility in mind — it is the
@@ -3,11 +3,14 @@
3
3
  module Moxml
4
4
  class Attribute < Node
5
5
  def name
6
- @native.name
6
+ adapter.attribute_name(@native)
7
7
  end
8
8
 
9
9
  def name=(new_name)
10
- adapter.set_attribute_name(@native, new_name)
10
+ # Mutation return contract (Adapter::Base): returns the native
11
+ # to keep tracking — same object for in-place adapters, fresh
12
+ # object for value-object adapters (leptris).
13
+ @native = adapter.set_attribute_name(@native, new_name)
11
14
  end
12
15
 
13
16
  # Returns the primary identifier for this attribute (its name)
@@ -44,7 +47,8 @@ module Moxml
44
47
  end
45
48
 
46
49
  def namespace=(ns)
47
- adapter.set_namespace(@native, ns&.native)
50
+ # See name= for the mutation return contract.
51
+ @native = adapter.set_namespace(@native, ns&.native)
48
52
  end
49
53
 
50
54
  def element
@@ -53,7 +57,7 @@ module Moxml
53
57
  end
54
58
 
55
59
  def remove
56
- adapter.remove_attribute(adapter.attribute_element(@native), name)
60
+ adapter.remove_attribute_native(@native)
57
61
  if @parent_node.is_a?(Moxml::Element)
58
62
  @parent_node.invalidate_attribute_cache!
59
63
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Canonical, adapter-independent attribute-name resolution.
5
+ #
6
+ # XML Namespaces 1.0 semantics, implemented once on the Moxml object
7
+ # model (the unified interface); adapters provide raw access only:
8
+ #
9
+ # - An attribute's expanded name is (namespace URI or none) + local
10
+ # name. Unprefixed attributes are never in a namespace — the
11
+ # default namespace declaration does not apply to attributes
12
+ # (Namespaces 1.0 §5.2).
13
+ # - "prefix:local" resolves the prefix against the element's
14
+ # in-scope declarations and matches by expanded name: an attribute
15
+ # written as q:type matches a lookup as p:type whenever both
16
+ # prefixes are bound to the same URI.
17
+ # - "xml" is prebound to the XML namespace URI; it never needs a
18
+ # declaration (Namespaces 1.0 §4).
19
+ # - "xmlns"/"xmlns:*" are declarations, not attributes: never match.
20
+ # - An undeclared prefix names nothing: nil. No silent prefix-string
21
+ # or bare-name fallback.
22
+ #
23
+ # When several attributes match (only possible in namespace-ill-formed
24
+ # documents), the first in document order wins, matching every
25
+ # adapter's attributes() ordering contract.
26
+ module AttributeResolver
27
+ XML_NAMESPACE_URI = "http://www.w3.org/XML/1998/namespace"
28
+
29
+ module_function
30
+
31
+ # @return [Moxml::Attribute, nil] the matching attribute wrapper
32
+ def resolve(element, name)
33
+ name = name.to_s
34
+ if name.include?(":")
35
+ prefix, local = name.split(":", 2)
36
+ return nil if prefix == "xmlns"
37
+
38
+ uri = prefix_uri(element, prefix)
39
+ return nil if uri.nil?
40
+
41
+ element.attributes.find do |attr|
42
+ local_name(attr.name) == local && attribute_uri(element, attr) == uri
43
+ end
44
+ else
45
+ element.attributes.find do |attr|
46
+ local_name(attr.name) == name && attribute_uri(element, attr).nil?
47
+ end
48
+ end
49
+ end
50
+
51
+ # URI a prefix is bound to for lookups on this element, or nil when
52
+ # the prefix is undeclared.
53
+ def prefix_uri(element, prefix)
54
+ return XML_NAMESPACE_URI if prefix == "xml"
55
+
56
+ element.in_scope_namespaces
57
+ .find { |candidate| candidate.prefix == prefix }&.uri
58
+ end
59
+
60
+ # Assign a value to the attribute named by XML-correct resolution,
61
+ # mirroring #resolve:
62
+ #
63
+ # - "xmlns"/"xmlns:*" are namespace declarations, not attributes:
64
+ # delegated verbatim to the adapter (declaration creation is
65
+ # adapter territory).
66
+ # - A bare name replaces the no-namespace attribute only — a
67
+ # namespaced attribute sharing the local name is left alone.
68
+ # - A prefixed name replaces by expanded name: assigning p:x
69
+ # overwrites an attribute spelled q:x when both prefixes are
70
+ # bound to the same URI.
71
+ #
72
+ # When nothing matches, the attribute is created with the given
73
+ # spelling. A prefix not yet in scope is stored raw — builders
74
+ # legitimately write detached children before attaching them under
75
+ # the declaring ancestor, and reads resolve by expanded name once
76
+ # the prefix is in scope (matching nokogiri/libxml creation
77
+ # behavior; namespace validity is a property of the serialized
78
+ # document).
79
+ #
80
+ # @return [String] the assigned value
81
+ def assign(element, name, value)
82
+ name = name.to_s
83
+ adapter = element.context.config.adapter
84
+ if name == "xmlns" || name.start_with?("xmlns:")
85
+ adapter.set_attribute(element.native, name, value)
86
+ element.invalidate_attribute_cache!
87
+ return value
88
+ end
89
+
90
+ existing = resolve(element, name)
91
+ if existing
92
+ existing.value = value
93
+ return value
94
+ end
95
+
96
+ adapter.set_attribute(element.native, name, value)
97
+ element.invalidate_attribute_cache!
98
+ value
99
+ end
100
+
101
+ # Remove the attribute named by XML-correct resolution (see
102
+ # #assign for the name semantics).
103
+ #
104
+ # @return [Moxml::Attribute, nil] the removed attribute
105
+ def remove(element, name)
106
+ name = name.to_s
107
+ adapter = element.context.config.adapter
108
+ if name == "xmlns" || name.start_with?("xmlns:")
109
+ adapter.remove_attribute(element.native, name)
110
+ element.invalidate_attribute_cache!
111
+ return nil
112
+ end
113
+
114
+ attr = resolve(element, name)
115
+ return nil unless attr
116
+
117
+ adapter.remove_attribute_native(attr.native)
118
+ element.invalidate_attribute_cache!
119
+ attr
120
+ end
121
+
122
+ # XPath 1.0 attribute node test, sharing the resolver's
123
+ # expanded-name semantics:
124
+ #
125
+ # @param prefix [String, Symbol, nil]
126
+ # - String: match by namespace URI resolved against the
127
+ # element's in-scope declarations; the prefix spelling is
128
+ # irrelevant (q:x matches a p:x test when URIs agree)
129
+ # - :any: any namespace, including none (*:local)
130
+ # - nil: bare name — no-namespace attributes only (Namespaces
131
+ # 1.0 §5.2)
132
+ # @param local [String, nil] local name, or nil for any (p:*)
133
+ # @return [Boolean]
134
+ def attribute_test?(element, attr, prefix, local)
135
+ return false if local && local_name(attr.name) != local
136
+
137
+ case prefix
138
+ when :any then true
139
+ when nil then attribute_uri(element, attr).nil?
140
+ else
141
+ uri = prefix_uri(element, prefix)
142
+ !uri.nil? && uri == attribute_uri(element, attr)
143
+ end
144
+ end
145
+
146
+ # Attribute node test with a URI resolved at XPath compile time
147
+ # (the static context's prefix mapping wins over the document's
148
+ # own declarations). See #attribute_test? for local semantics.
149
+ #
150
+ # @return [Boolean]
151
+ def attribute_test_uri?(element, attr, uri, local)
152
+ return false if local && local_name(attr.name) != local
153
+
154
+ attribute_uri(element, attr) == uri
155
+ end
156
+
157
+ # Local part of a wrapper attribute name. Local names cannot
158
+ # contain ':' in namespace-well-formed documents; splitting is for
159
+ # adapters whose natives carry the qualified name (leptris).
160
+ def local_name(name)
161
+ name.include?(":") ? name.split(":", 2)[1] : name
162
+ end
163
+
164
+ # URI of an attribute's namespace, nil for the no-namespace case.
165
+ #
166
+ # Uses the attribute's own namespace when the native binding
167
+ # exposes one with a URI (nokogiri/oga/libxml, and rexml/ox for
168
+ # declared prefixes). Otherwise reconstructs it from the attribute
169
+ # prefix via the element's in-scope declarations — covering
170
+ # adapters that report a prefix without resolving it (rexml/ox for
171
+ # the prebound xml prefix) and adapters that expose neither
172
+ # namespace nor separate prefix (leptris qualified names).
173
+ def attribute_uri(element, attr)
174
+ ns = attr.namespace
175
+ return ns.uri unless ns.nil? || ns.uri.to_s.empty?
176
+
177
+ prefix = ns&.prefix || prefix_part(attr.name)
178
+ return nil if prefix.nil? || prefix.empty?
179
+ return XML_NAMESPACE_URI if prefix == "xml"
180
+
181
+ element.in_scope_namespaces
182
+ .find { |candidate| candidate.prefix == prefix }&.uri
183
+ end
184
+
185
+ def prefix_part(name)
186
+ name.include?(":") ? name.split(":", 2)[0] : nil
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module C14n
5
+ # Attribute axis handler for inclusive C14N.
6
+ # Implements W3C C14N 1.0 §2.4 / 1.1 §2.4 attribute rendering,
7
+ # including resolution of simple inheritable attributes (xml:lang,
8
+ # xml:space) for document subsets.
9
+ class AttributeHandler
10
+ attr_reader :encoder
11
+
12
+ def initialize(encoder)
13
+ @encoder = encoder
14
+ end
15
+
16
+ def process_attributes(element, output, omitted_ancestors = [])
17
+ return unless element.in_node_set?
18
+
19
+ attributes = collect_attributes(element, omitted_ancestors)
20
+ attributes.each do |attr|
21
+ output << " "
22
+ output << attr.qname
23
+ output << '="'
24
+ output << encoder.encode_attribute(attr.value)
25
+ output << '"'
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def collect_attributes(element, omitted_ancestors)
32
+ attributes = element.sorted_attribute_nodes.select(&:in_node_set?)
33
+
34
+ return attributes if omitted_ancestors.empty?
35
+
36
+ inherited = collect_inherited_attributes(element, omitted_ancestors)
37
+ merge_attributes(attributes, inherited)
38
+ end
39
+
40
+ # Walk omitted ancestors to collect simple inheritable attributes
41
+ # not already declared on the element. Per C14N 1.1 §2.4, these
42
+ # are inherited from the nearest ancestor in which they are declared.
43
+ def collect_inherited_attributes(element, omitted_ancestors)
44
+ inherited = []
45
+ seen = Set.new
46
+
47
+ element.attribute_nodes.each do |attr|
48
+ seen.add(attr.name) if attr.simple_inheritable?
49
+ end
50
+
51
+ omitted_ancestors.reverse_each do |ancestor|
52
+ ancestor.attribute_nodes.each do |attr|
53
+ next unless attr.simple_inheritable?
54
+ next if seen.include?(attr.name)
55
+
56
+ inherited << attr
57
+ seen.add(attr.name)
58
+ end
59
+ end
60
+
61
+ inherited
62
+ end
63
+
64
+ def merge_attributes(element_attrs, inherited_attrs)
65
+ (element_attrs + inherited_attrs).sort_by do |attr|
66
+ [attr.namespace_uri.to_s, attr.local_name]
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module C14n
5
+ # Character encoder for C14N output. Handles the entity escaping
6
+ # mandated by W3C C14N 1.0 §2.4 / 1.1 §2.4 for text and attribute values.
7
+ class CharacterEncoder
8
+ TEXT_ESCAPES = {
9
+ "&" => "&amp;",
10
+ "<" => "&lt;",
11
+ ">" => "&gt;",
12
+ "\r" => "&#xD;",
13
+ }.freeze
14
+ ATTRIBUTE_ESCAPES = TEXT_ESCAPES.merge(
15
+ '"' => "&quot;",
16
+ "\t" => "&#x9;",
17
+ "\n" => "&#xA;",
18
+ ).freeze
19
+
20
+ def encode_text(text)
21
+ text.to_s.gsub(/[&<>\r]/, TEXT_ESCAPES)
22
+ end
23
+
24
+ def encode_attribute(value)
25
+ value.to_s.gsub(/[&<"\t\n\r]/, ATTRIBUTE_ESCAPES)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module C14n
5
+ # Builds a C14N data model from a Moxml::Node tree (or XML string).
6
+ #
7
+ # The data model exists because canonicalization needs:
8
+ # - sorted namespace and attribute axes (spec §2.3, §2.4)
9
+ # - in_node_set flags for subset canonicalization (spec §3)
10
+ # - xml:* inheritable attribute resolution
11
+ #
12
+ # Ported from canon (lutaml/canon) — adapted to build directly from
13
+ # Moxml::Node rather than via a separate Nokogiri pass. Matches
14
+ # canon's document-level node iteration (PIs and comments outside
15
+ # the document root element).
16
+ class DataModel
17
+ def self.from_xml(xml_string)
18
+ from_node(::Moxml.parse(xml_string))
19
+ end
20
+
21
+ def self.from_node(moxml_node)
22
+ return build_from_document(moxml_node) if moxml_node.is_a?(::Moxml::Document)
23
+
24
+ root = Nodes::RootNode.new
25
+ root.add_child(build_node(moxml_node))
26
+ root
27
+ end
28
+
29
+ # Build from a Moxml::Document. Matches canon's Nokogiri path:
30
+ # the root element is added first, then all other document-level
31
+ # children (PIs, comments) in document order.
32
+ def self.build_from_document(document)
33
+ root = Nodes::RootNode.new
34
+
35
+ 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.
40
+ document.children.each do |child|
41
+ next if child.equal?(document.root)
42
+ next if child.is_a?(::Moxml::Element)
43
+
44
+ built = build_node(child)
45
+ root.add_child(built) if built
46
+ end
47
+ end
48
+
49
+ root
50
+ end
51
+
52
+ def self.build_node(moxml_node)
53
+ 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)
58
+ end
59
+ end
60
+
61
+ def self.build_element_node(moxml_element)
62
+ ns = moxml_element.namespace
63
+ element = Nodes::ElementNode.new(
64
+ name: moxml_element.name,
65
+ namespace_uri: ns&.uri,
66
+ prefix: ns&.prefix,
67
+ )
68
+
69
+ build_namespace_nodes(moxml_element, element)
70
+ build_attribute_nodes(moxml_element, element)
71
+
72
+ moxml_element.children.each do |child|
73
+ built = build_node(child)
74
+ element.add_child(built) if built
75
+ end
76
+
77
+ element
78
+ end
79
+
80
+ def self.build_namespace_nodes(moxml_element, element)
81
+ moxml_element.in_scope_namespaces.each do |ns|
82
+ element.add_namespace(
83
+ Nodes::NamespaceNode.new(prefix: ns.prefix || "", uri: ns.uri),
84
+ )
85
+ end
86
+
87
+ return if element.namespace_nodes.any? { |n| n.prefix == "xml" }
88
+
89
+ element.add_namespace(
90
+ Nodes::NamespaceNode.new(prefix: "xml", uri: XML_URI),
91
+ )
92
+ end
93
+
94
+ def self.build_attribute_nodes(moxml_element, element)
95
+ moxml_element.attributes.each do |attr|
96
+ 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
+ ),
104
+ )
105
+ end
106
+ end
107
+
108
+ def self.build_text_node(moxml_text)
109
+ Nodes::TextNode.new(value: moxml_text.content)
110
+ end
111
+
112
+ def self.build_comment_node(moxml_comment)
113
+ Nodes::CommentNode.new(value: moxml_comment.content)
114
+ end
115
+
116
+ def self.build_pi_node(moxml_pi)
117
+ Nodes::ProcessingInstructionNode.new(
118
+ target: moxml_pi.target || moxml_pi.name,
119
+ data: moxml_pi.content || "",
120
+ )
121
+ end
122
+
123
+ def self.mark_all(node, value)
124
+ node.in_node_set = value
125
+ node.children.each { |child| mark_all(child, value) }
126
+ end
127
+
128
+ def self.mark_subset(root_node, matched)
129
+ matched.each { |node| mark_node_and_descendants(node) }
130
+ root_node.in_node_set = true
131
+ end
132
+
133
+ def self.mark_node_and_descendants(node)
134
+ node.in_node_set = true
135
+ node.children.each { |child| mark_node_and_descendants(child) }
136
+ end
137
+
138
+ private_class_method :build_from_document, :build_node,
139
+ :build_element_node, :build_namespace_nodes,
140
+ :build_attribute_nodes, :build_text_node,
141
+ :build_comment_node, :build_pi_node,
142
+ :mark_node_and_descendants
143
+ end
144
+ end
145
+ end