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,143 @@
1
+ # Security considerations
2
+
3
+ XML signature is a complex spec with a long history of attacks. This
4
+ document explains what `Moxml::Signature` does to mitigate them and
5
+ what the application is responsible for.
6
+
7
+ ## Built-in mitigations
8
+
9
+ ### Best Practice 1 — authenticate before transforms (DONE)
10
+
11
+ The Verifier validates `SignatureValue` before running any reference
12
+ transforms. A hostile signature with expensive XSLT or XPath transforms
13
+ cannot consume server resources unless its `SignatureValue` verifies
14
+ against a key the application trusts.
15
+
16
+ ### Best Practice 26 — HMAC truncation floor (DONE)
17
+
18
+ `HMACOutputLength` values below `max(hash_bits/2, 80)` are rejected
19
+ during algorithm instantiation (spec §4.4.2). Signatures with
20
+ sub-minimum truncation are deemed invalid.
21
+
22
+ ### Best Practice 11 — opaque certificate handling (DONE)
23
+
24
+ `X509Certificate` payloads are decoded as raw DER and passed to
25
+ `OpenSSL::X509::Certificate.new`. We never re-encode certificates, so
26
+ the signature on the certificate itself is preserved.
27
+
28
+ ## What the application must do
29
+
30
+ The library cannot make trust decisions; the application must.
31
+
32
+ ### Best Practice 2 — establish trust in the key
33
+
34
+ Just because `SignatureValue` verifies against a public key in
35
+ `KeyInfo` does NOT mean the signature should be trusted. The key must
36
+ come from a trusted source:
37
+
38
+ ```ruby
39
+ # Application-side: pin the expected key, ignore KeyInfo entirely
40
+ result = Moxml::Signature.verify(
41
+ context: ctx,
42
+ document: doc,
43
+ key: trusted_public_key, # do NOT rely on KeyInfo extraction
44
+ )
45
+
46
+ # Or, with certificate validation:
47
+ cert = OpenSSL::X509::Certificate.new(cert_der)
48
+ store = OpenSSL::X509::Store.new
49
+ store.add_trust_file("cacert.pem")
50
+ unless store.verify(cert)
51
+ raise "certificate chain invalid"
52
+ end
53
+
54
+ result = Moxml::Signature.verify(
55
+ context: ctx,
56
+ document: doc,
57
+ key: cert.public_key,
58
+ )
59
+ ```
60
+
61
+ ### Best Practice 12 — see what was signed
62
+
63
+ Use `SingleVerificationResult#references` to inspect what the signature
64
+ actually covers:
65
+
66
+ ```ruby
67
+ result.results.first.references.each do |ref|
68
+ puts "#{ref.uri}: #{ref.valid?}"
69
+ # Confirm ref.uri matches what the application expects to be signed.
70
+ # Wrapping attacks work by getting the verifier to confirm a different
71
+ # node than the application acts on.
72
+ end
73
+ ```
74
+
75
+ ### Best Practice 14 — check name AND position
76
+
77
+ When checking a reference URI, don't just verify the element name. A
78
+ wrapping attack moves the signed element into an `<Object>` and points
79
+ the reference at it; the application then acts on a different (unsigned)
80
+ element with the same name.
81
+
82
+ ### Best Practice 8 — control external references
83
+
84
+ External URI dereferencing is **not enabled by default**. If you need
85
+ it, wrap the resolver in a policy that:
86
+
87
+ - Allows only same-document URIs (`#id`, `""`)
88
+ - Allows only specific schemes (`https://`, never `file://`)
89
+ - Caps size and timeout
90
+ - Disallows query parameters that mutate server state
91
+
92
+ ## What this library does NOT do
93
+
94
+ ### Not a chain validator
95
+
96
+ `Moxml::Signature` does not validate X.509 certificate chains, check
97
+ revocation, or evaluate certificate policies. The application is
98
+ responsible for these (see Best Practice 2 above).
99
+
100
+ ### Not a timestamp authority
101
+
102
+ Long-lived signatures need RFC 3161 timestamps from a TSA. This library
103
+ does not implement timestamp verification.
104
+
105
+ ### Not a wrapping-attack detector
106
+
107
+ Wrapping attacks succeed when the application acts on a different node
108
+ than the one the signature actually covers. The library returns the
109
+ list of references; the application must verify they cover the right
110
+ nodes.
111
+
112
+ ## SHA-1 warning
113
+
114
+ The W3C spec marks SHA-1 as REQUIRED for backwards compatibility but
115
+ DISCOURAGED for new signatures. Cryptanalytic advances (SHAttered,
116
+ 2017) demonstrated practical collisions. Use SHA-256 or stronger for
117
+ new signatures.
118
+
119
+ ## HMAC security
120
+
121
+ HMAC signatures require a shared secret. Any verifier with the secret
122
+ can forge signatures. Use distinct keys for signing vs. encryption
123
+ (Best Practice 27).
124
+
125
+ ## Limited XPath support
126
+
127
+ The library implements the Enveloped Signature transform directly
128
+ (no XPath). XPath Filter and XSLT transforms are not implemented;
129
+ Best Practices 3, 5, 6, 22 say avoid them.
130
+
131
+ ## Performance and DoS
132
+
133
+ Canonicalization is O(N) for tree size. XSLT and complex XPath are
134
+ not supported, eliminating the most common DoS vectors (Best Practices
135
+ 3, 5, 6). Wrap calls in a timeout if processing untrusted input:
136
+
137
+ ```ruby
138
+ require "timeout"
139
+
140
+ Timeout.timeout(5) do
141
+ Moxml::Signature.verify(context: ctx, document: untrusted_doc, key:)
142
+ end
143
+ ```
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example: auto-extract the verification key from a KeyInfo that
4
+ # contains an embedded X509Certificate. The Verifier derives the
5
+ # OpenSSL key without application help.
6
+ #
7
+ # Run with: bundle exec ruby examples/signature/auto_key_extraction.rb
8
+
9
+ require "moxml"
10
+ require "moxml/signature"
11
+
12
+ ctx = Moxml.new(:nokogiri)
13
+
14
+ fixture = File.expand_path(
15
+ "../../spec/fixtures/xmldsig/sign3-result.xml",
16
+ __dir__,
17
+ )
18
+ xml = File.read(fixture)
19
+ doc = ctx.parse(xml)
20
+
21
+ # No explicit key — Verifier auto-extracts from X509Certificate.
22
+ result = Moxml::Signature.verify(context: ctx, document: doc)
23
+ puts "Auto-extracted from X509Certificate: #{result.valid?}"
24
+ puts "Signature count: #{result.signature_count}"
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example: enveloped RSA-SHA256 signature with verification.
4
+ #
5
+ # Run with: bundle exec ruby examples/signature/enveloped_rsa.rb
6
+
7
+ require "moxml"
8
+ require "moxml/signature"
9
+ require "openssl"
10
+
11
+ ctx = Moxml.new(:nokogiri)
12
+ key = OpenSSL::PKey::RSA.generate(2048)
13
+
14
+ doc = ctx.parse("<doc><greeting>Hello, World!</greeting></doc>")
15
+ puts "=== Before signing ==="
16
+ puts doc.to_xml
17
+
18
+ signature = Moxml::Signature.sign(
19
+ context: ctx,
20
+ document: doc,
21
+ key: key,
22
+ signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
23
+ canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
24
+ digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
25
+ reference_uri: "",
26
+ transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
27
+ )
28
+
29
+ serialized = Moxml::Signature::Serializer.new(context: ctx).serialize(signature)
30
+ doc.root.add_child(serialized.root)
31
+
32
+ puts ""
33
+ puts "=== After signing ==="
34
+ puts doc.to_xml
35
+
36
+ result = Moxml::Signature.verify(context: ctx, document: doc, key: key)
37
+ puts ""
38
+ puts "=== Verification ==="
39
+ puts "Valid: #{result.valid?}"
40
+
41
+ # Tamper test
42
+ doc2 = ctx.parse(doc.to_xml(indent: 0))
43
+ doc2.at_xpath("//greeting").text = "Goodbye!"
44
+ tampered = Moxml::Signature.verify(context: ctx, document: doc2, key: key)
45
+ puts "Tampered: #{tampered.valid?} (expected false)"
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example: HMAC-SHA256 signature with a shared secret.
4
+ #
5
+ # Run with: bundle exec ruby examples/signature/hmac.rb
6
+
7
+ require "moxml"
8
+ require "moxml/signature"
9
+
10
+ ctx = Moxml.new(:nokogiri)
11
+ secret = "super-secret-shared-key"
12
+
13
+ doc = ctx.parse("<message><body>hello</body></message>")
14
+
15
+ signature = Moxml::Signature.sign(
16
+ context: ctx,
17
+ document: doc,
18
+ key: secret,
19
+ signature_method: "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256",
20
+ canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#",
21
+ digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
22
+ reference_uri: "",
23
+ transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
24
+ )
25
+
26
+ serialized = Moxml::Signature::Serializer.new(context: ctx).serialize(signature)
27
+ doc.root.add_child(serialized.root)
28
+
29
+ result = Moxml::Signature.verify(context: ctx, document: doc, key: secret)
30
+ puts "Valid: #{result.valid?}"
31
+
32
+ wrong = Moxml::Signature.verify(context: ctx, document: doc, key: "wrong")
33
+ puts "Wrong secret: #{wrong.valid?} (expected false)"
@@ -24,6 +24,9 @@ require "moxml/namespace"
24
24
  require "moxml/doctype"
25
25
  require "moxml/entity_reference"
26
26
  require "moxml/entity_registry"
27
+ require "moxml/entity"
28
+ require "moxml/entity/reference"
29
+ require "moxml/xml_emitter"
27
30
  require "moxml/adapter"
28
31
  require "moxml/adapter/base"
29
32
  require "moxml/adapter/customized_rexml"
@@ -36,6 +39,8 @@ require "moxml/sax/block_handler"
36
39
  require "moxml/sax/namespace_splitter"
37
40
  require "moxml/adapter/rexml"
38
41
  require "moxml/adapter/customized_oga"
42
+ require "moxml/adapter/customized_oga/entity_decoder"
43
+ require "moxml/adapter/customized_oga/raw_value_override"
39
44
  require "moxml/adapter/customized_oga/xml_declaration"
40
45
  require "moxml/adapter/customized_oga/xml_generator"
41
46
  require "moxml/adapter/oga"
@@ -5,62 +5,29 @@ module Moxml
5
5
  class Base
6
6
  # include XmlUtils
7
7
 
8
- # Entity marker for adapters that resolve entities during parsing.
9
- # U+FFFC (Object Replacement Character) + U+FEFF (BOM) is a two-character
10
- # sentinel chosen because this exact sequence followed by a valid entity
11
- # name pattern is vanishingly unlikely in real XML content.
12
- # Non-standard entities like &copy; are converted to this marker before
13
- # parsing, then restored during serialization.
14
- # Standard XML entities (&amp; &lt; &gt; &quot; &apos;) are NOT converted.
15
- ENTITY_MARKER = "\u{FFFC}\u{FEFF}"
16
- ENTITY_NAME_PATTERN = "[a-zA-Z_][\\w.:-]*"
17
- ENTITY_NAME_RE = /&(#{ENTITY_NAME_PATTERN});/
18
- ENTITY_MARKER_RE = /\u{FFFC}\u{FEFF}(#{ENTITY_NAME_PATTERN});/
19
- SERIALIZED_ENTITY_MARKER_RE = /&#xFFFC;&#xFEFF;(#{ENTITY_NAME_PATTERN});/
20
- STANDARD_ENTITIES = %w[amp lt gt quot apos].freeze
8
+ # Entity round-trip pipeline lives in Moxml::Entity; these
9
+ # constants and methods stay on the adapter protocol so
10
+ # adapters and wrappers keep their calling convention.
11
+ ENTITY_MARKER = Entity::MARKER
12
+ ENTITY_NAME_PATTERN = Entity::NAME_PATTERN
13
+ ENTITY_NAME_RE = Entity::NAME_RE
14
+ ENTITY_MARKER_RE = Entity::MARKER_RE
15
+ SERIALIZED_ENTITY_MARKER_RE = Entity::SERIALIZED_MARKER_RE
16
+ STANDARD_ENTITIES = Entity::STANDARD_ENTITIES
21
17
 
22
18
  class << self
23
19
  include XmlUtils
24
20
 
25
- # Replace non-standard entity references with markers before parsing.
26
- # Always returns a UTF-8 encoded string.
27
21
  def preprocess_entities(xml)
28
- return "" if xml.nil?
29
-
30
- str = if xml.encoding == Encoding::BINARY
31
- # Binary strings are assumed to be UTF-8. If the bytes are
32
- # not valid UTF-8, fall back to encoding as UTF-8 with
33
- # replacement to avoid raising on gsub.
34
- dup = xml.dup.force_encoding("UTF-8")
35
- if dup.valid_encoding?
36
- dup
37
- else
38
- xml.dup.encode("UTF-8",
39
- "ASCII-8BIT", invalid: :replace, undef: :replace)
40
- end
41
- elsif xml.encoding == Encoding::UTF_8
42
- xml
43
- else
44
- xml.encode("UTF-8")
45
- end
46
- # Fast path: no `&` means no entity references to mark — skip
47
- # the regex scan and string allocation entirely. The vast
48
- # majority of XML payloads contain no entity references.
49
- return str unless str.include?("&")
50
-
51
- str.gsub(ENTITY_NAME_RE) do |match|
52
- STANDARD_ENTITIES.include?(::Regexp.last_match(1)) ? match : "#{ENTITY_MARKER}#{::Regexp.last_match(1)};"
53
- end
22
+ Entity.preprocess_entities(xml)
54
23
  end
55
24
 
56
- # Restore entity markers back to named entity references.
57
- def restore_entities(text)
58
- return text unless text.is_a?(String)
25
+ def decode_entities(text)
26
+ Entity.decode_entities(text)
27
+ end
59
28
 
60
- # Force UTF-8 encoding since markers are UTF-8 characters
61
- str = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding("UTF-8")
62
- result = str.gsub(ENTITY_MARKER_RE, '&\1;')
63
- result.gsub(SERIALIZED_ENTITY_MARKER_RE, '&\1;')
29
+ def restore_entities(text)
30
+ Entity.restore_entities(text)
64
31
  end
65
32
 
66
33
  def set_root(_doc, _element)
@@ -71,7 +38,7 @@ module Moxml
71
38
  )
72
39
  end
73
40
 
74
- def parse(_xml, _options = {})
41
+ def parse(_xml, _options = {}, _context = nil)
75
42
  raise Moxml::NotImplementedError.new(
76
43
  "parse not implemented",
77
44
  feature: "parse",
@@ -172,12 +139,35 @@ namespace_validation_mode: :strict)
172
139
  create_native_entity_reference(name)
173
140
  end
174
141
 
142
+ # Mutation return contract: protocol methods that may change
143
+ # which native a wrapper tracks (set_attribute_name,
144
+ # set_namespace, set_attribute_value) always return the native
145
+ # the wrapper must keep tracking — the same object when
146
+ # mutated in place, a fresh object when the adapter recreates
147
+ # the node.
175
148
  def set_attribute_name(attribute, name)
176
149
  attribute.name = name
150
+ attribute
151
+ end
152
+
153
+ def set_namespace(_node, _namespace)
154
+ raise Moxml::NotImplementedError.new(
155
+ "set_namespace not implemented",
156
+ feature: "set_namespace",
157
+ adapter: name,
158
+ )
177
159
  end
178
160
 
179
161
  def set_attribute_value(attribute, value)
180
162
  attribute.value = value
163
+ attribute
164
+ end
165
+
166
+ # Remove a specific native attribute node from its owning
167
+ # element. Semantics (which attribute a name addresses) live
168
+ # in Moxml::AttributeResolver; this is the raw primitive.
169
+ def remove_attribute_native(attr)
170
+ attr.remove
181
171
  end
182
172
 
183
173
  def entity_reference_name(node)
@@ -212,6 +202,20 @@ namespace_validation_mode: :strict)
212
202
  # Called when a Declaration node is removed from a document.
213
203
  def remove_declaration(_native_doc); end
214
204
 
205
+ # Source line of a native node (1-based), or nil when the
206
+ # underlying backend does not track source positions.
207
+ # Adapters that track lines (Nokogiri, LibXML) override this.
208
+ def line_number(_node)
209
+ nil
210
+ end
211
+
212
+ # Local name of a native attribute node. Adapters whose natives
213
+ # carry qualified names override this to expose the local part;
214
+ # the wrapper composes the prefix.
215
+ def attribute_name(attr)
216
+ attr.name
217
+ end
218
+
215
219
  # Return the actual native node after an add_child operation.
216
220
  # Override for adapters where node identity may change (e.g., LibXML doc.root=).
217
221
  def actual_native(child_native, _parent_native)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ module CustomizedLeptris
6
+ # Wrapper for an XML declaration. libleptris stores declaration
7
+ # data on the document struct (read-only, serialized via options)
8
+ # and has no first-class declaration node, so Moxml exposes this
9
+ # mutable value object instead.
10
+ class Declaration
11
+ attr_accessor :version, :encoding, :standalone, :parent_doc
12
+
13
+ def initialize(version = "1.0", encoding = "UTF-8", standalone = nil)
14
+ @version = version
15
+ @encoding = encoding
16
+ @standalone = standalone
17
+ end
18
+
19
+ def to_xml
20
+ XmlEmitter.declaration_xml(version, encoding, standalone)
21
+ end
22
+
23
+ def ==(other)
24
+ other.is_a?(self.class) &&
25
+ version == other.version &&
26
+ encoding == other.encoding &&
27
+ standalone == other.standalone
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ module CustomizedLeptris
6
+ # Wrapper for a programmatic DOCTYPE. libleptris only parses
7
+ # DOCTYPEs from source; it has no API to create one, so Moxml
8
+ # stores this value object in the document's attachments.
9
+ class Doctype
10
+ attr_accessor :name, :external_id, :system_id, :parent_doc
11
+
12
+ def initialize(name, external_id = nil, system_id = nil)
13
+ @name = name
14
+ @external_id = external_id
15
+ @system_id = system_id
16
+ end
17
+
18
+ def to_xml
19
+ XmlEmitter.doctype_xml(name, external_id, system_id)
20
+ end
21
+
22
+ def ==(other)
23
+ other.is_a?(self.class) &&
24
+ name == other.name &&
25
+ external_id == other.external_id &&
26
+ system_id == other.system_id
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ module CustomizedLeptris
6
+ # libleptris expands the five built-in entities at parse time
7
+ # and has no custom ones; the adapter carries entity references
8
+ # as Moxml::Entity::Reference values over marker-bearing text.
9
+ EntityReference = ::Moxml::Entity::Reference
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ module CustomizedLeptris
6
+ # A text run split out of a native text node that carried entity
7
+ # markers. Value object: the moxml contract exposes text nodes as
8
+ # separate children around entity references, while libleptris
9
+ # stores them as one marker-bearing text node.
10
+ class TextSegment
11
+ attr_accessor :parent
12
+
13
+ attr_reader :content
14
+
15
+ def initialize(content, parent = nil)
16
+ @content = content
17
+ @parent = parent
18
+ end
19
+
20
+ def ==(other)
21
+ other.is_a?(self.class) && content == other.content
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ # Pure-Ruby node kinds that libleptris has no native representation
6
+ # for: XML declarations, programmatic DOCTYPEs, and entity references.
7
+ # The adapter stores them via NativeAttachment and serializes them
8
+ # from #to_xml.
9
+ module CustomizedLeptris
10
+ autoload :Declaration, "moxml/adapter/customized_leptris/declaration"
11
+ autoload :Doctype, "moxml/adapter/customized_leptris/doctype"
12
+ autoload :EntityReference, "moxml/adapter/customized_leptris/entity_reference"
13
+ autoload :TextSegment, "moxml/adapter/customized_leptris/text_segment"
14
+ end
15
+ end
16
+ end
@@ -5,19 +5,10 @@ module Moxml
5
5
  module CustomizedLibxml
6
6
  # Wrapper for LibXML CDATA section nodes
7
7
  class Cdata < Node
8
- # Serialize as XML CDATA section
9
- # LibXML auto-escapes content, we need to un-escape it
8
+ # libxml stores CDATA payload verbatim. Only the `]]>` end-marker
9
+ # needs splitting before re-wrapping.
10
10
  def to_xml
11
- content = @native.content
12
- .gsub("&quot;", '"')
13
- .gsub("&apos;", "'")
14
- .gsub("&lt;", "<")
15
- .gsub("&gt;", ">")
16
- .gsub("&amp;", "&")
17
-
18
- # Handle CDATA end marker escaping (]]> becomes ]]]]><![CDATA[>)
19
- # Replace all ]]> markers in the content before wrapping
20
- escaped_content = content.gsub("]]>", "]]]]><![CDATA[>")
11
+ escaped_content = @native.content.gsub("]]>", "]]]]><![CDATA[>")
21
12
  "<![CDATA[#{escaped_content}]]>"
22
13
  end
23
14
  end
@@ -5,16 +5,9 @@ module Moxml
5
5
  module CustomizedLibxml
6
6
  # Wrapper for LibXML comment nodes
7
7
  class Comment < Node
8
- # Serialize as XML comment
9
- # LibXML auto-escapes content, we need to un-escape it
8
+ # libxml stores comment payload verbatim.
10
9
  def to_xml
11
- content = @native.content
12
- .gsub("&quot;", '"')
13
- .gsub("&apos;", "'")
14
- .gsub("&lt;", "<")
15
- .gsub("&gt;", ">")
16
- .gsub("&amp;", "&")
17
- "<!--#{content}-->"
10
+ "<!--#{@native.content}-->"
18
11
  end
19
12
  end
20
13
  end
@@ -47,16 +47,7 @@ module Moxml
47
47
 
48
48
  # Generate XML declaration string
49
49
  def to_xml
50
- output = "<?xml version=\"#{@version}\""
51
- if @encoding && !@encoding.empty?
52
- output << " encoding=\"#{@encoding}\""
53
- end
54
- # Include standalone attribute if explicitly set (true or false)
55
- unless @standalone_value.nil?
56
- output << " standalone=\"#{standalone}\""
57
- end
58
- output << "?>"
59
- output
50
+ XmlEmitter.declaration_xml(@version, @encoding, standalone)
60
51
  end
61
52
 
62
53
  private
@@ -18,6 +18,14 @@ module Moxml
18
18
  @native = native_node
19
19
  end
20
20
 
21
+ # Swap the wrapped native node. Used by the Libxml adapter when
22
+ # libxml-ruby's content= setter would silently re-escape stored
23
+ # text; replacing the node with a fresh raw-storage instance is
24
+ # the only way to preserve verbatim content.
25
+ def replace_native!(fresh)
26
+ @native = fresh
27
+ end
28
+
21
29
  # Compare wrappers based on their native nodes
22
30
  def ==(other)
23
31
  return false unless other
@@ -5,20 +5,12 @@ module Moxml
5
5
  module CustomizedLibxml
6
6
  # Wrapper for LibXML processing instruction nodes
7
7
  class ProcessingInstruction < Node
8
- # Serialize as XML processing instruction
9
- # LibXML auto-escapes content, we need to un-escape it
8
+ # XML 1.0 §2.6: PI content is verbatim — no entity resolution, no escaping.
10
9
  def to_xml
11
10
  target = @native.name
12
11
  content = @native.content
13
-
14
- # Un-escape LibXML's automatic escaping
15
12
  if content && !content.empty?
16
- unescaped = content.gsub("&quot;", '"')
17
- .gsub("&apos;", "'")
18
- .gsub("&lt;", "<")
19
- .gsub("&gt;", ">")
20
- .gsub("&amp;", "&")
21
- "<?#{target} #{unescaped}?>"
13
+ "<?#{target} #{content}?>"
22
14
  else
23
15
  "<?#{target}?>"
24
16
  end
@@ -13,16 +13,9 @@ module Moxml
13
13
  @native.content
14
14
  end
15
15
 
16
- # Serialize as XML with proper escaping
17
- # LibXML's .content already contains escaped text, but it over-escapes
18
- # quotes which don't need escaping in text nodes (only in attributes)
16
+ # @native.to_s escapes & < > but leaves quotes alone, which text nodes need.
19
17
  def to_xml
20
- content = @native.content
21
- # Skip the gsub allocation entirely when there's nothing to undo —
22
- # the common case for parsed text without literal quotes.
23
- return content unless content.include?("&quot;")
24
-
25
- content.gsub("&quot;", '"')
18
+ @native.to_s
26
19
  end
27
20
  end
28
21
  end