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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/c14n"
5
+
6
+ # Edge cases for namespace rendering per W3C C14N 1.0 §2.3 / 1.1 §2.3.
7
+ # These cover the bugs that historically plague C14N implementations.
8
+ RSpec.describe "C14N namespace rendering" do
9
+ let(:ctx) { Moxml.new(:nokogiri) }
10
+
11
+ describe "default namespace" do
12
+ it "renders xmlns= declaration when default ns is set" do
13
+ doc = ctx.parse(%(<root xmlns="urn:foo"/>))
14
+ result = Moxml::C14n.canonicalize(doc.root)
15
+ expect(result).to eq('<root xmlns="urn:foo"></root>')
16
+ end
17
+
18
+ it "renders xmlns='' when transitioning to empty default" do
19
+ doc = ctx.parse(%(<a xmlns="urn:foo"><b xmlns=""><c/></b></a>))
20
+ result = Moxml::C14n.canonicalize(doc.root)
21
+ # <b> has empty default; <a> has urn:foo → xmlns="" appears
22
+ expect(result).to include('xmlns=""')
23
+ end
24
+
25
+ it "does not render xmlns when default ns is empty everywhere" do
26
+ doc = ctx.parse(%(<root><child/></root>))
27
+ result = Moxml::C14n.canonicalize(doc.root)
28
+ expect(result).to eq("<root><child></child></root>")
29
+ end
30
+ end
31
+
32
+ describe "prefix redeclaration" do
33
+ it "renders both declarations when URI changes" do
34
+ doc = ctx.parse(%(<a xmlns:x="urn:1"><b xmlns:x="urn:2"/></a>))
35
+ result = Moxml::C14n.canonicalize(doc.root)
36
+ expect(result.scan("xmlns:x=").length).to eq(2)
37
+ end
38
+
39
+ it "renders only once when URI is unchanged" do
40
+ doc = ctx.parse(%(<a xmlns:x="urn:1"><b xmlns:x="urn:1"/></a>))
41
+ result = Moxml::C14n.canonicalize(doc.root)
42
+ expect(result.scan("xmlns:x=").length).to eq(1)
43
+ end
44
+ end
45
+
46
+ describe "xml namespace" do
47
+ it "does not render xmlns:xml declaration" do
48
+ doc = ctx.parse(%(<root xmlns:xml="http://www.w3.org/XML/1998/namespace"/>))
49
+ result = Moxml::C14n.canonicalize(doc.root)
50
+ expect(result).not_to include("xmlns:xml=")
51
+ end
52
+ end
53
+
54
+ describe "namespace sorting" do
55
+ it "sorts xmlns declarations by prefix" do
56
+ doc = ctx.parse(%(<root xmlns:z="urn:z" xmlns:a="urn:a"/>))
57
+ result = Moxml::C14n.canonicalize(doc.root)
58
+ # a should appear before z
59
+ expect(result.index("xmlns:a=")).to be < result.index("xmlns:z=")
60
+ end
61
+
62
+ it "renders default namespace first" do
63
+ doc = ctx.parse(%(<root xmlns="urn:default" xmlns:a="urn:a"/>))
64
+ result = Moxml::C14n.canonicalize(doc.root)
65
+ # Default (xmlns=) before prefixed
66
+ expect(result.index("xmlns=")).to be < result.index("xmlns:a=")
67
+ end
68
+ end
69
+
70
+ describe "deeply nested inheritance" do
71
+ it "renders each prefix at the level it is first declared" do
72
+ xml = <<~XML.strip
73
+ <root xmlns:a="urn:a">
74
+ <level1 xmlns:b="urn:b">
75
+ <level2 xmlns:c="urn:c">
76
+ <apex/>
77
+ </level2>
78
+ </level1>
79
+ </root>
80
+ XML
81
+ doc = ctx.parse(xml)
82
+ result = Moxml::C14n.canonicalize(doc.root)
83
+ # Inclusive: all in-scope ns render on apex. But we're canonicalizing
84
+ # the whole tree, so each ns renders at its declaration point.
85
+ expect(result.scan("xmlns:").length).to eq(3)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "moxml/c14n"
5
+
6
+ # xml:lang and xml:space are "simple inheritable" attributes per W3C C14N
7
+ # 1.1 §2.4. When an element is in the node-set but an ancestor is not,
8
+ # the canonical form inherits these attributes from the nearest ancestor
9
+ # in which they were declared.
10
+ #
11
+ # These specs exercise the inclusive behavior at full-document
12
+ # canonicalization (where everything is in the set, so inheritance
13
+ # doesn't trigger). For subset scenarios, see TODO.c14n/07.
14
+ RSpec.describe "C14N xml:* inheritable attributes" do
15
+ let(:ctx) { Moxml.new(:nokogiri) }
16
+
17
+ it "renders xml:lang attribute" do
18
+ doc = ctx.parse(%(<root xml:lang="en">hello</root>))
19
+ expect(Moxml::C14n.canonicalize(doc.root))
20
+ .to eq(%(<root xml:lang="en">hello</root>))
21
+ end
22
+
23
+ it "renders xml:space attribute" do
24
+ doc = ctx.parse(%(<root xml:space="preserve">data</root>))
25
+ expect(Moxml::C14n.canonicalize(doc.root))
26
+ .to eq(%(<root xml:space="preserve">data</root>))
27
+ end
28
+
29
+ it "renders xml:lang on descendant that declares its own" do
30
+ doc = ctx.parse(%(<a xml:lang="en"><b xml:lang="fr"/></a>))
31
+ result = Moxml::C14n.canonicalize(doc.root)
32
+ expect(result).to include('<b xml:lang="fr">')
33
+ end
34
+
35
+ it "renders xml:id attribute" do
36
+ doc = ctx.parse(%(<root xml:id="foo"/>))
37
+ expect(Moxml::C14n.canonicalize(doc.root))
38
+ .to eq(%(<root xml:id="foo"></root>))
39
+ end
40
+
41
+ it "renders xml:base attribute" do
42
+ doc = ctx.parse(%(<root xml:base="http://example.com/"/>))
43
+ expect(Moxml::C14n.canonicalize(doc.root))
44
+ .to eq(%(<root xml:base="http://example.com/"></root>))
45
+ end
46
+
47
+ it "sorts xml:* attributes by namespace URI then local name" do
48
+ doc = ctx.parse(%(<root xml:space="default" xml:lang="en" xml:id="x"/>))
49
+ result = Moxml::C14n.canonicalize(doc.root)
50
+ # All in XML namespace; sort by local name: id < lang < space
51
+ expect(result.index("xml:id=")).to be < result.index("xml:lang=")
52
+ expect(result.index("xml:lang=")).to be < result.index("xml:space=")
53
+ end
54
+ end
@@ -48,7 +48,7 @@ RSpec.describe Moxml::Doctype do
48
48
  end
49
49
 
50
50
  describe "parsing" do
51
- %i[nokogiri oga rexml ox].each do |adapter_name|
51
+ %i[nokogiri oga rexml ox libxml].each do |adapter_name|
52
52
  context "with #{adapter_name} adapter" do
53
53
  let(:ctx) { Moxml.new(adapter_name) }
54
54
 
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Moxml::Entity do
6
+ describe ".preprocess_entities" do
7
+ it "marks non-standard entities and leaves the five standard ones" do
8
+ marked = described_class.preprocess_entities("a &copy; b &amp; c &lt;")
9
+ expect(marked).to include("#{described_class::MARKER}copy;")
10
+ expect(marked).to include("&amp;")
11
+ expect(marked).to include("&lt;")
12
+ end
13
+
14
+ it "returns the input untouched when it contains no ampersand" do
15
+ input = "plain text"
16
+ expect(described_class.preprocess_entities(input)).to equal(input)
17
+ end
18
+
19
+ it "always returns UTF-8" do
20
+ binary = "caf\xC3\xA9 &copy;".b
21
+ result = described_class.preprocess_entities(binary)
22
+ expect(result.encoding).to eq(Encoding::UTF_8)
23
+ end
24
+
25
+ it "returns an empty string for nil" do
26
+ expect(described_class.preprocess_entities(nil)).to eq("")
27
+ end
28
+ end
29
+
30
+ describe ".decode_entities" do
31
+ it "decodes numeric and standard named references in one pass" do
32
+ expect(described_class.decode_entities("&#65;&#x42;&amp;&lt;"))
33
+ .to eq("AB&<")
34
+ end
35
+
36
+ it "preserves invalid numeric codepoints verbatim" do
37
+ expect(described_class.decode_entities("&#0;&#xD800;")).to eq("&#0;&#xD800;")
38
+ end
39
+
40
+ it "skips strings without ampersands" do
41
+ input = "plain"
42
+ expect(described_class.decode_entities(input)).to equal(input)
43
+ end
44
+ end
45
+
46
+ describe ".restore_entities" do
47
+ it "maps markers back to named references" do
48
+ restored = described_class.restore_entities(
49
+ "a#{described_class::MARKER}copy;b",
50
+ )
51
+ expect(restored).to eq("a&copy;b")
52
+ end
53
+
54
+ it "restores markers a native serializer rendered as character references" do
55
+ expect(described_class.restore_entities("a&#xFFFC;&#xFEFF;copy;b"))
56
+ .to eq("a&copy;b")
57
+ end
58
+
59
+ it "returns marker-free strings without a regex pass" do
60
+ input = "no markers here"
61
+ expect(described_class.restore_entities(input)).to equal(input)
62
+ end
63
+ end
64
+
65
+ describe Moxml::Entity::Reference do
66
+ it "round-trips as a serialized entity reference" do
67
+ expect(described_class.new("copy").to_xml).to eq("&copy;")
68
+ end
69
+
70
+ it "compares by name" do
71
+ reference = described_class.new("copy")
72
+ expect(reference).to eq(described_class.new("copy"))
73
+ expect(reference).not_to eq(described_class.new("nbsp"))
74
+ end
75
+ end
76
+ end
@@ -92,4 +92,108 @@ RSpec.describe Moxml::Node do
92
92
  expect(child.content).to eq("text")
93
93
  end
94
94
  end
95
+
96
+ describe "#ancestors" do
97
+ let(:doc) { context.parse("<library><section><book><title>Book 1</title></book></section></library>") }
98
+ let(:title) { doc.at_xpath("//title") }
99
+
100
+ it "returns ancestors from parent up to and including the document" do
101
+ ancestors = title.ancestors
102
+ expect(ancestors).to be_a(Moxml::NodeSet)
103
+ expect(ancestors.select(&:element?).map(&:name)).to eq(["book", "section", "library"])
104
+ expect(ancestors[3]).to be_a(Moxml::Document)
105
+ end
106
+
107
+ it "returns empty for the document" do
108
+ expect(doc.ancestors).to be_empty
109
+ end
110
+
111
+ it "returns the document for the root element" do
112
+ ancestors = doc.root.ancestors
113
+ expect(ancestors.size).to eq(1)
114
+ expect(ancestors.first).to be_a(Moxml::Document)
115
+ end
116
+ end
117
+
118
+ describe "#descendants" do
119
+ let(:doc) { context.parse("<section><book><title>Book 1</title></book></section>") }
120
+ let(:section) { doc.at_xpath("//section") }
121
+
122
+ it "returns all descendants excluding self" do
123
+ descendants = section.descendants
124
+ expect(descendants).to be_a(Moxml::NodeSet)
125
+ element_names = descendants.select(&:element?).map(&:name)
126
+ expect(element_names).to eq(["book", "title"])
127
+ end
128
+
129
+ it "includes text nodes" do
130
+ expect(section.descendants.any?(&:text?)).to be true
131
+ end
132
+
133
+ it "returns no elements for a leaf element" do
134
+ expect(doc.at_xpath("//title").descendants.select(&:element?)).to be_empty
135
+ end
136
+ end
137
+
138
+ describe "#path" do
139
+ %i[nokogiri oga rexml ox libxml].each do |adapter_name|
140
+ context "with #{adapter_name} adapter" do
141
+ let(:ctx) { Moxml.new(adapter_name) }
142
+ let(:doc) do
143
+ ctx.parse("<library><section><book id='1'/><book id='2'><title>Book 2</title></book></section></library>")
144
+ end
145
+ let(:first_book) { doc.root.children.find(&:element?).children.find { |c| c.element? && c.name == "book" && c["id"] == "1" } }
146
+ let(:second_book) { doc.root.children.find(&:element?).children.find { |c| c.element? && c.name == "book" && c["id"] == "2" } }
147
+ let(:title) { second_book.children.find(&:element?) }
148
+
149
+ it "builds the path without a predicate for uniquely named elements" do
150
+ expect(title.path).to eq("/library/section/book[2]/title")
151
+ end
152
+
153
+ it "adds a positional predicate for same-named siblings" do
154
+ expect(first_book.path).to eq("/library/section/book[1]")
155
+ expect(second_book.path).to eq("/library/section/book[2]")
156
+ end
157
+
158
+ it "locates the node via its path" do
159
+ # Ox translates only a limited XPath subset (no positional predicates)
160
+ skip "ox xpath translation does not support positional predicates" if adapter_name == :ox
161
+
162
+ expect(doc.at_xpath(title.path)).to eq(title)
163
+ end
164
+
165
+ it "returns / for the document" do
166
+ expect(doc.path).to eq("/")
167
+ end
168
+ end
169
+ end
170
+
171
+ it "raises for node types without a path representation" do
172
+ text = doc.root.children.first.children.first
173
+ expect { text.path }.to raise_error(Moxml::NotImplementedError, /element and document/)
174
+ end
175
+ end
176
+
177
+ describe "#line_number" do
178
+ %i[nokogiri oga rexml ox libxml].each do |adapter_name|
179
+ tracks_source_lines = adapter_name.eql?(:nokogiri) || adapter_name.eql?(:libxml)
180
+ context "with #{adapter_name} adapter" do
181
+ let(:ctx) { Moxml.new(adapter_name) }
182
+ let(:doc) do
183
+ ctx.parse("<?xml version='1.0'?>\n<library>\n <book>\n <title>Book 1</title>\n </book>\n</library>\n")
184
+ end
185
+ let(:title) { doc.root.children.find(&:element?).children.find(&:element?) }
186
+
187
+ if tracks_source_lines
188
+ it "returns the 1-based source line" do
189
+ expect(title.line_number).to eq(4)
190
+ end
191
+ else
192
+ it "returns nil when the adapter does not track lines" do
193
+ expect(title.line_number).to be_nil
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
95
199
  end