dnsruby 1.73.1 → 2.0.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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -4
  3. data/DNSSEC +3 -2
  4. data/EXAMPLES +1 -1
  5. data/README.md +370 -64
  6. data/RELEASE_NOTES.md +314 -1
  7. data/Rakefile +0 -3
  8. data/demo/axfr.rb +24 -6
  9. data/dnsruby.gemspec +6 -4
  10. data/lib/dnsruby/cache.rb +279 -49
  11. data/lib/dnsruby/code_mapper.rb +35 -8
  12. data/lib/dnsruby/code_mappers.rb +63 -49
  13. data/lib/dnsruby/config.rb +77 -4
  14. data/lib/dnsruby/deadline_queue.rb +75 -0
  15. data/lib/dnsruby/dnssec.rb +148 -45
  16. data/lib/dnsruby/ipv6.rb +45 -5
  17. data/lib/dnsruby/key_cache.rb +202 -35
  18. data/lib/dnsruby/message/decoder.rb +85 -19
  19. data/lib/dnsruby/message/encoder.rb +27 -16
  20. data/lib/dnsruby/message/header.rb +8 -12
  21. data/lib/dnsruby/message/message.rb +53 -22
  22. data/lib/dnsruby/message/section.rb +12 -34
  23. data/lib/dnsruby/name.rb +5 -2
  24. data/lib/dnsruby/packet_sender.rb +664 -207
  25. data/lib/dnsruby/presentation.rb +147 -0
  26. data/lib/dnsruby/query_control.rb +132 -0
  27. data/lib/dnsruby/recursor.rb +798 -341
  28. data/lib/dnsruby/resolver.rb +472 -176
  29. data/lib/dnsruby/resource/CAA.rb +67 -16
  30. data/lib/dnsruby/resource/DLV.rb +6 -0
  31. data/lib/dnsruby/resource/DNSKEY.rb +108 -16
  32. data/lib/dnsruby/resource/DS.rb +27 -28
  33. data/lib/dnsruby/resource/HTTPS.rb +16 -0
  34. data/lib/dnsruby/resource/IN.rb +21 -2
  35. data/lib/dnsruby/resource/MX.rb +2 -2
  36. data/lib/dnsruby/resource/NSEC.rb +6 -10
  37. data/lib/dnsruby/resource/NSEC3.rb +55 -19
  38. data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
  39. data/lib/dnsruby/resource/OPT.rb +2 -3
  40. data/lib/dnsruby/resource/RR.rb +108 -65
  41. data/lib/dnsruby/resource/RRSIG.rb +5 -5
  42. data/lib/dnsruby/resource/RRSet.rb +38 -22
  43. data/lib/dnsruby/resource/SRV.rb +2 -2
  44. data/lib/dnsruby/resource/SVCB.rb +775 -0
  45. data/lib/dnsruby/resource/TSIG.rb +266 -231
  46. data/lib/dnsruby/resource/TXT.rb +34 -42
  47. data/lib/dnsruby/resource/domain_name.rb +1 -1
  48. data/lib/dnsruby/resource/generic.rb +31 -0
  49. data/lib/dnsruby/resource/generic_cache.rb +384 -0
  50. data/lib/dnsruby/resource/generic_constants.rb +79 -0
  51. data/lib/dnsruby/select_thread.rb +986 -291
  52. data/lib/dnsruby/serial_time.rb +25 -0
  53. data/lib/dnsruby/single_resolver.rb +25 -6
  54. data/lib/dnsruby/single_verifier.rb +1620 -530
  55. data/lib/dnsruby/transport_clock.rb +9 -0
  56. data/lib/dnsruby/update.rb +8 -6
  57. data/lib/dnsruby/validation_budget.rb +143 -0
  58. data/lib/dnsruby/validator_thread.rb +139 -58
  59. data/lib/dnsruby/version.rb +1 -1
  60. data/lib/dnsruby/zone_reader.rb +196 -129
  61. data/lib/dnsruby/zone_transfer.rb +157 -25
  62. data/lib/dnsruby.rb +7 -2
  63. data/test/ci_watchdog.rb +33 -0
  64. data/test/fixtures/org-ds-2026-11-05.zone +5 -0
  65. data/test/fixtures/root-dnskey-2026-11-05.zone +7 -0
  66. data/test/fork_test_helper.rb +37 -0
  67. data/test/local_dns_server.rb +57 -0
  68. data/test/soak_server.rb +152 -0
  69. data/test/socket_helpers.rb +26 -0
  70. data/test/spec_helper.rb +8 -13
  71. data/test/tc_attempt_configuration.rb +137 -0
  72. data/test/tc_caa.rb +2 -2
  73. data/test/tc_cache_keys.rb +36 -0
  74. data/test/tc_cancelled_send_capacity.rb +158 -0
  75. data/test/tc_dnssec_alias_validation.rb +289 -0
  76. data/test/tc_dnssec_budget.rb +489 -0
  77. data/test/tc_dnssec_cache_bounds.rb +197 -0
  78. data/test/tc_dnssec_cache_lifetime.rb +615 -0
  79. data/test/tc_dnssec_denial_proofs.rb +285 -0
  80. data/test/tc_dnssec_recursion_limits.rb +186 -0
  81. data/test/tc_dnssec_recursor_review_fixes.rb +351 -0
  82. data/test/tc_dnssec_trust_anchors.rb +286 -0
  83. data/test/tc_dnssec_trust_races.rb +147 -0
  84. data/test/tc_dnssec_wildcard_proofs.rb +249 -0
  85. data/test/tc_ds.rb +5 -5
  86. data/test/tc_edns_retry_regressions.rb +255 -0
  87. data/test/tc_escapedchars.rb +8 -0
  88. data/test/tc_final_review_fixes.rb +167 -0
  89. data/test/tc_final_transport_contracts.rb +104 -0
  90. data/test/tc_generic_cache.rb +852 -0
  91. data/test/tc_header.rb +26 -0
  92. data/test/tc_ipv6.rb +120 -0
  93. data/test/tc_key_cache_identity.rb +382 -0
  94. data/test/tc_malformed_tcp.rb +712 -0
  95. data/test/tc_name.rb +99 -5
  96. data/test/tc_nameserver_bootstrap.rb +322 -0
  97. data/test/tc_nsec.rb +4 -45
  98. data/test/tc_nsec3.rb +17 -1
  99. data/test/tc_pipeline_retirement.rb +358 -0
  100. data/test/tc_pipeline_retry.rb +462 -0
  101. data/test/tc_pipeline_send.rb +663 -0
  102. data/test/tc_protocol_review_fixes.rb +95 -0
  103. data/test/tc_query_control.rb +90 -0
  104. data/test/tc_record_codec_contracts.rb +107 -0
  105. data/test/tc_record_regressions.rb +225 -0
  106. data/test/tc_recur.rb +1 -0
  107. data/test/tc_recursor_address_fallback.rb +169 -0
  108. data/test/tc_recursor_limits.rb +139 -0
  109. data/test/tc_release_auxiliary_fixes.rb +288 -0
  110. data/test/tc_release_configuration_fixes.rb +612 -0
  111. data/test/tc_release_dnssec_completion.rb +405 -0
  112. data/test/tc_release_dnssec_fixes.rb +305 -0
  113. data/test/tc_release_negative_cache.rb +327 -0
  114. data/test/tc_release_recursor_cache.rb +212 -0
  115. data/test/tc_release_review_transport.rb +372 -0
  116. data/test/tc_release_review_transport_native.rb +56 -0
  117. data/test/tc_release_transport.rb +906 -0
  118. data/test/tc_release_transport_fixes.rb +196 -0
  119. data/test/tc_release_zone_defaults.rb +157 -0
  120. data/test/tc_release_zone_ttls.rb +39 -0
  121. data/test/tc_res_config.rb +53 -2
  122. data/test/tc_res_opt.rb +21 -1
  123. data/test/tc_resolver.rb +34 -19
  124. data/test/tc_resolver_concurrency.rb +383 -0
  125. data/test/tc_resolver_configuration_contracts.rb +31 -0
  126. data/test/tc_resolver_validation.rb +211 -0
  127. data/test/tc_review_cache.rb +163 -0
  128. data/test/tc_review_code_mapper.rb +279 -0
  129. data/test/tc_review_control_deadlines.rb +169 -0
  130. data/test/tc_review_followup_transport.rb +583 -0
  131. data/test/tc_review_packet_contracts.rb +369 -0
  132. data/test/tc_review_presentation.rb +213 -0
  133. data/test/tc_review_record_compatibility.rb +101 -0
  134. data/test/tc_review_transport_fixes.rb +195 -0
  135. data/test/tc_review_transport_native.rb +415 -0
  136. data/test/tc_review_validation_priming.rb +178 -0
  137. data/test/tc_rr-opt.rb +5 -2
  138. data/test/tc_rr-unknown.rb +19 -0
  139. data/test/tc_rr.rb +14 -0
  140. data/test/tc_rrset_grouping.rb +50 -0
  141. data/test/tc_rrset_review_fixes.rb +58 -0
  142. data/test/tc_rrsig.rb +21 -1
  143. data/test/tc_scoped_ipv6.rb +153 -0
  144. data/test/tc_scoped_ipv6_select.rb +157 -0
  145. data/test/tc_scoped_ipv6_transport.rb +329 -0
  146. data/test/tc_selector_wakeup.rb +42 -0
  147. data/test/tc_soak.rb +118 -196
  148. data/test/tc_socket_helpers.rb +90 -0
  149. data/test/tc_supporting_lookup_limits.rb +252 -0
  150. data/test/tc_svcb.rb +1043 -0
  151. data/test/tc_svcb_signed_target.rb +145 -0
  152. data/test/tc_svcb_transport.rb +120 -0
  153. data/test/tc_tcp.rb +69 -43
  154. data/test/tc_tcp_pipelining.rb +95 -204
  155. data/test/tc_transport_fork.rb +179 -0
  156. data/test/tc_tsig.rb +2 -2
  157. data/test/tc_tsig_api.rb +251 -0
  158. data/test/tc_tsig_rfc8945.rb +357 -0
  159. data/test/tc_v2_release_dnssec.rb +442 -0
  160. data/test/tc_v2_release_transport.rb +304 -0
  161. data/test/tc_v2_zone_transfer_native.rb +58 -0
  162. data/test/tc_zone_reader.rb +217 -1
  163. data/test/tcp_pipelining_server.rb +150 -0
  164. data/test/ts_offline.rb +77 -6
  165. data/test/ts_online.rb +2 -1
  166. metadata +127 -14
  167. data/.coveralls.yml +0 -2
@@ -0,0 +1,369 @@
1
+ require_relative 'tc_pipeline_send'
2
+
3
+ class ReviewPacketContractsTest < Minitest::Test
4
+ def test_both_caches_match_query_class_and_keep_ordinary_cache_hits
5
+ isolated(<<~'RUBY')
6
+ [true, false].each do |recursive|
7
+ Dnsruby::PacketSender.clear_caches
8
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', dnssec: false, recurse: recursive)
9
+ sent = []
10
+ writer.define_singleton_method(:send_dnsruby) { |*args| sent << args }
11
+ internet = cache_query('IN')
12
+ internet.header.rd = recursive
13
+ writer.make_query_packet(internet)
14
+ Dnsruby::PacketSender.cache(internet, answer(internet, 'internet'), scope: writer.positive_cache_scope(internet))
15
+ chaos = cache_query('CH')
16
+ chaos.header.rd = recursive
17
+ writer.send_async(chaos, Queue.new, :miss)
18
+ raise 'wrong class hit cached IN answer' unless sent.length == 1
19
+ writer.make_query_packet(chaos)
20
+ Dnsruby::PacketSender.cache(chaos, answer(chaos, 'chaos'), scope: writer.positive_cache_scope(chaos))
21
+ [['CH', 'chaos'], ['IN', 'internet']].each do |klass, text|
22
+ queue = Queue.new
23
+ writer.send_async(cache_query(klass), queue, klass)
24
+ SELECTOR.send_queued_responses
25
+ received = queue.pop(true)
26
+ validated = queue.pop(true)
27
+ raise 'cache hit lost' unless received[2].cached && sent.length == 1
28
+ raise 'wrong cached class' unless received[2].question.first.qclass == Dnsruby::Classes.new(klass)
29
+ raise 'wrong cached answer' unless received[2].answer.first.strings == [text]
30
+ raise 'cache completion duplicated' unless validated[1] == Dnsruby::Resolver::EventType::VALIDATED && queue.empty?
31
+ end
32
+ end
33
+ RUBY
34
+ end
35
+
36
+ def test_tsig_transactions_bypass_both_cache_reads_and_verified_response_writes
37
+ skip('OpenSSL HMAC unavailable') unless defined?(OpenSSL::HMAC)
38
+ isolated(<<~'RUBY')
39
+ [true, false].each do |recursive|
40
+ [:sender_key, :message_key, :presigned].each do |mode|
41
+ Dnsruby::PacketSender.clear_caches
42
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', dnssec: false, recurse: recursive)
43
+ sent = []
44
+ writer.define_singleton_method(:send_dnsruby) { |*args| sent << args }
45
+ plain = cache_query('IN')
46
+ plain.header.rd = recursive
47
+ writer.make_query_packet(plain)
48
+ Dnsruby::PacketSender.cache(plain, answer(plain, 'unsigned cache'), scope: writer.positive_cache_scope(plain))
49
+ key = signing_key
50
+ signed = cache_query('IN')
51
+ signed.header.rd = recursive
52
+ if mode == :sender_key
53
+ writer.tsig = key
54
+ elsif mode == :message_key
55
+ signed.set_tsig(key)
56
+ else
57
+ key.apply(signed)
58
+ end
59
+ queue = Queue.new
60
+ writer.send_async(signed, queue, :signed)
61
+ raise 'signed request read shared cache' unless sent.length == 1
62
+ raise 'signed query not sent on wire' unless Dnsruby::Message.decode(sent.first.first).tsig
63
+ raise 'cache response was queued' unless K.class_variable_get(:@@queued_responses).empty?
64
+ unsigned = answer(signed, 'unsigned server')
65
+ result = writer.check_response(unsigned, unsigned.encode, signed, queue, :signed, false)
66
+ raise 'unsigned server answer accepted' unless result == false
67
+ response = answer(signed, 'private signed answer')
68
+ key.apply(response, signed)
69
+ bytes = response.encode
70
+ response = Dnsruby::Message.decode(bytes)
71
+ raise 'signed reply not verified' unless writer.check_response(response, bytes, signed, queue, :signed, false) == true
72
+ raise 'fixture has not exercised TSIG stripping' unless response.verified? && response.tsig.nil?
73
+ Dnsruby::PacketSender.clear_caches
74
+ Dnsruby::PacketSender.cache(signed, response)
75
+ writer.tsig = nil
76
+ writer.send_async(cache_query('IN'), Queue.new, :plain)
77
+ raise 'verified private answer entered shared cache' unless sent.length == 2
78
+ raise 'signed reply produced a cached result' unless K.class_variable_get(:@@queued_responses).empty?
79
+ end
80
+ end
81
+ RUBY
82
+ end
83
+
84
+ def test_invalid_headers_are_rejected_before_tsig_mutation_or_retry
85
+ skip('OpenSSL HMAC unavailable') unless defined?(OpenSSL::HMAC)
86
+ isolated(<<~'RUBY')
87
+ [:query_packet, :wrong_opcode, :wrong_id].each do |invalid_header|
88
+ [:ordinary, :formerr, :truncated].each do |kind|
89
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', dnssec: false)
90
+ retries = []
91
+ writer.define_singleton_method(:send_async) { |*args| retries << args }
92
+ q = cache_query('IN')
93
+ q.add_additional(Dnsruby::RR::OPT.new(4096))
94
+ key = signing_key
95
+ key.apply(q)
96
+ reply = answer(q, 'ignored')
97
+ reply.header.qr = false if invalid_header == :query_packet
98
+ reply.header.opcode = Dnsruby::OpCode::Update if invalid_header == :wrong_opcode
99
+ reply.header.rcode = Dnsruby::RCode::FORMERR if kind == :formerr
100
+ reply.header.tc = true if kind == :truncated
101
+ key.apply(reply, q)
102
+ reply.header.id = (q.header.id + 1) % 65_536 if invalid_header == :wrong_id
103
+ bytes = reply.encode
104
+ reply = Dnsruby::Message.decode(bytes)
105
+ signature = reply.tsig
106
+ state = reply.tsigstate
107
+ result = writer.check_response(reply, bytes, q, Queue.new, :ignored, false)
108
+ raise "invalid #{invalid_header}/#{kind} consumed request" unless result == false
109
+ raise 'invalid header mutated TSIG' unless reply.tsig.equal?(signature) && reply.tsigstate == state
110
+ raise 'invalid header caused retry' unless retries.empty?
111
+ end
112
+ end
113
+ RUBY
114
+ end
115
+
116
+ def test_invalid_then_valid_packets_preserve_query_and_pipeline_peer_ownership
117
+ isolated(<<~'RUBY')
118
+ [:query_packet, :wrong_opcode].each do |invalid_header|
119
+ socket = FrameSocket.new
120
+ writer = sender(socket)
121
+ q = query('target.example.')
122
+ q.header.id = 100
123
+ target = settings(socket, :target, q, writer)
124
+ peer = settings(socket, :peer, query('peer.example.'), writer)
125
+ peer.query.header.id = 100 # Exercise matching questions before response processing.
126
+ SELECTOR.add_to_select(target)
127
+ SELECTOR.add_to_select(peer)
128
+ invalid = answer(q, 'invalid')
129
+ invalid.header.qr = false if invalid_header == :query_packet
130
+ invalid.header.opcode = Dnsruby::OpCode::Update if invalid_header == :wrong_opcode
131
+ SELECTOR.process_message(invalid, invalid.encode, socket)
132
+ raise 'invalid reply completed target' unless SELECTOR.query_settings_for_id(:target).equal?(target)
133
+ raise 'invalid reply disturbed peer' unless SELECTOR.query_settings_for_id(:peer).equal?(peer)
134
+ raise 'invalid reply was delivered' unless target.client_queue.empty? && peer.client_queue.empty?
135
+ [peer, target].each do |current|
136
+ response = answer(current.query, 'valid')
137
+ SELECTOR.process_message(response, response.encode, socket)
138
+ SELECTOR.send_queued_validation_responses
139
+ events = 2.times.map { current.client_queue.pop(true) }
140
+ raise 'valid response not delivered exactly once' unless current.client_queue.empty? &&
141
+ events.map { |event| event[1] } == [Dnsruby::Resolver::EventType::RECEIVED, Dnsruby::Resolver::EventType::VALIDATED] &&
142
+ events.all? { |event| event.last.nil? && event[2].answer.first.strings == ['valid'] }
143
+ end
144
+ SELECTOR.retire_socket(socket)
145
+ assert_clean(socket)
146
+ end
147
+ RUBY
148
+ end
149
+
150
+ def test_edns_fallback_keeps_the_attempt_transport_and_raw_mode_stays_raw
151
+ isolated(<<~'RUBY')
152
+ [true, false].each do |tcp|
153
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', dnssec: false, use_tcp: !tcp)
154
+ retries = []
155
+ writer.define_singleton_method(:send_async) { |*args| retries << args }
156
+ q = query
157
+ q.add_additional(Dnsruby::RR::OPT.new(4096))
158
+ original = q.encode
159
+ reply = answer(q, 'formerr')
160
+ reply.header.rcode = Dnsruby::RCode::FORMERR
161
+ raise 'FORMERR was delivered' unless writer.check_response(reply, reply.encode, q, Queue.new, :retry, tcp) == false
162
+ raise 'wrong retry transport' unless retries.length == 1 && retries.first[3] == tcp
163
+ raise 'EDNS not disabled for retry' unless retries.first[4] && retries.first.first.get_opt.nil?
164
+ raise 'caller query was mutated' unless q.encode == original
165
+ q.send_raw = true
166
+ reply.header.qr = false
167
+ reply.header.opcode = Dnsruby::OpCode::Update
168
+ raise 'raw response was filtered' unless writer.check_response(reply, reply.encode, q, Queue.new, :raw, tcp) == true
169
+ raise 'raw response retried' unless retries.length == 1
170
+ end
171
+ RUBY
172
+ end
173
+
174
+ def test_malformed_invalid_headers_leave_udp_and_tcp_owners_active_for_valid_replies
175
+ isolated(<<~'RUBY')
176
+ [false, true].each do |tcp|
177
+ [false, true].each do |truncated|
178
+ [:query_packet, :wrong_opcode].each do |invalid_header|
179
+ socket = FrameSocket.new
180
+ writer = sender(socket)
181
+ retries = []
182
+ writer.define_singleton_method(:send_async) { |*args| retries << args }
183
+ q = query('target.example.')
184
+ q.header.id = 100
185
+ target = settings(socket, :target, q, writer)
186
+ target.is_persistent_socket = tcp
187
+ peer_socket = tcp ? socket : FrameSocket.new
188
+ peer = settings(peer_socket, :peer, query('peer.example.'), writer)
189
+ peer.query.header.id = 101
190
+ SELECTOR.add_to_select(target)
191
+ SELECTOR.add_to_select(peer)
192
+ flags = invalid_header == :query_packet ? 0 : 0xa800
193
+ flags |= 0x0200 if truncated
194
+ malformed = [100, flags, 1, 0, 0, 0].pack('n6') # Question is missing.
195
+ inject_packet(socket, malformed, tcp)
196
+ raise 'malformed packet decoded' if SELECTOR.get_incoming_data(socket, 512)
197
+ raise 'invalid malformed header triggered fallback' unless retries.empty?
198
+ raise 'invalid malformed header completed target' unless SELECTOR.query_settings_for_id(:target).equal?(target)
199
+ raise 'invalid malformed header consumed peer' unless SELECTOR.query_settings_for_id(:peer).equal?(peer)
200
+ raise 'invalid malformed header delivered an error' unless target.client_queue.empty? && peer.client_queue.empty?
201
+ valid = answer(q, 'valid')
202
+ inject_packet(socket, valid.encode, tcp)
203
+ incoming = SELECTOR.get_incoming_data(socket, 512)
204
+ raise 'following valid response was lost' unless incoming
205
+ SELECTOR.process_message(*incoming, socket)
206
+ SELECTOR.send_queued_validation_responses
207
+ events = 2.times.map { target.client_queue.pop(true) }
208
+ raise 'valid response not delivered once' unless target.client_queue.empty? &&
209
+ events.all? { |event| event.last.nil? && event[2].answer.first.strings == ['valid'] }
210
+ raise 'valid target response disturbed peer' unless SELECTOR.query_settings_for_id(:peer).equal?(peer)
211
+ SELECTOR.remove_id(:peer)
212
+ assert_clean(socket)
213
+ assert_clean(peer_socket)
214
+ end
215
+ end
216
+ end
217
+ RUBY
218
+ end
219
+
220
+ def test_malformed_matching_and_raw_headers_keep_existing_fallback_and_error_behavior
221
+ isolated(<<~'RUBY')
222
+ [false, true].each do |tcp|
223
+ [false, true].each do |truncated|
224
+ [false, true].each do |raw|
225
+ socket = FrameSocket.new
226
+ writer = sender(socket)
227
+ retries = []
228
+ writer.define_singleton_method(:send_async) { |*args| retries << args }
229
+ q = query
230
+ q.header.id = 100
231
+ q.send_raw = raw
232
+ current = settings(socket, :current, q, writer)
233
+ current.is_persistent_socket = tcp
234
+ SELECTOR.add_to_select(current)
235
+ # Raw queries intentionally retain permissive header handling.
236
+ flags = raw ? 0x2800 : 0x8000
237
+ flags |= 0x0200 if truncated
238
+ malformed = q.encode
239
+ malformed[2, 2] = [flags].pack('n')
240
+ malformed[6, 2] = [1].pack('n') # Complete question, missing answer.
241
+ inject_packet(socket, malformed, tcp)
242
+ raise 'malformed packet decoded' if SELECTOR.get_incoming_data(socket, 512)
243
+ if truncated && !tcp
244
+ raise 'valid/raw truncated UDP lost TCP fallback' unless retries.length == 1 && retries.first[3] == true
245
+ raise 'fallback emitted a premature error' unless current.client_queue.empty?
246
+ SELECTOR.remove_id(:current)
247
+ else
248
+ raise 'malformed TCP/nontruncated packet unexpectedly retried' unless retries.empty?
249
+ raise 'malformed packet did not report one decode error' unless current.client_queue.length == 1 &&
250
+ current.client_queue.pop(true).last.is_a?(Dnsruby::DecodeError)
251
+ raise 'terminal decode error retained owner' if SELECTOR.query_settings_for_id(:current)
252
+ end
253
+ assert_clean(socket)
254
+ end
255
+ end
256
+ end
257
+ RUBY
258
+ end
259
+
260
+ def test_short_malformed_headers_only_end_tcp_transactions
261
+ isolated(<<~'RUBY')
262
+ [false, true].each do |tcp|
263
+ [2, 4, 11].each do |length|
264
+ socket = FrameSocket.new
265
+ writer = sender(socket)
266
+ writer.define_singleton_method(:send_async) { |*| raise 'incomplete header triggered retry' }
267
+ q = query
268
+ q.header.id = 100
269
+ current = settings(socket, :current, q, writer)
270
+ current.is_persistent_socket = tcp
271
+ SELECTOR.add_to_select(current)
272
+ inject_packet(socket, [100, 0x2800, 1, 0, 0, 0].pack('n6').byteslice(0, length), tcp)
273
+ raise 'incomplete header decoded' if SELECTOR.get_incoming_data(socket, 512)
274
+ if tcp
275
+ raise 'incomplete TCP header error was suppressed' unless current.client_queue.length == 1 &&
276
+ current.client_queue.pop(true).last.is_a?(Dnsruby::DecodeError)
277
+ raise 'incomplete TCP header retained owner' if SELECTOR.query_settings_for_id(:current)
278
+ else
279
+ raise 'incomplete UDP header ended transaction' unless current.client_queue.empty? &&
280
+ SELECTOR.query_settings_for_id(:current).equal?(current)
281
+ SELECTOR.remove_id(:current)
282
+ end
283
+ assert_clean(socket)
284
+ end
285
+ end
286
+ RUBY
287
+ end
288
+
289
+ def test_malformed_udp_questions_cannot_fail_or_retry_an_unrelated_query
290
+ isolated(<<~'RUBY')
291
+ [:name, :type, :klass, :missing, :partial, :second].product([false, true]).each do |mismatch, truncated|
292
+ socket = FrameSocket.new
293
+ writer = sender(socket)
294
+ writer.define_singleton_method(:send_async) { |*| raise 'unrelated question triggered fallback' }
295
+ q = query('target.example.')
296
+ q.add_question('second.example.', 'AAAA') if mismatch == :second
297
+ current = settings(socket, :current, q, writer)
298
+ current.is_persistent_socket = false
299
+ SELECTOR.add_to_select(current)
300
+ original_deadline = current.endtime
301
+ reply = q.clone
302
+ reply.header.qr = true
303
+ reply.header.tc = truncated
304
+ case mismatch
305
+ when :name then reply.question.first.qname = Dnsruby::Name.create('unrelated.example.')
306
+ when :type then reply.question.first.qtype = Dnsruby::Types::AAAA
307
+ when :klass then reply.question.first.qclass = Dnsruby::Classes::CH
308
+ when :second then reply.question.last.qname = Dnsruby::Name.create('unrelated.example.')
309
+ end
310
+ wire = reply.encode
311
+ wire[6, 2] = [1].pack('n') # Missing answer follows the question.
312
+ wire = wire.byteslice(0, mismatch == :missing ? 12 : 15) if [:missing, :partial].include?(mismatch)
313
+ inject_packet(socket, wire, false)
314
+ raise 'malformed packet decoded' if SELECTOR.get_incoming_data(socket, 512)
315
+ raise 'unrelated question consumed transaction' unless current.client_queue.empty? &&
316
+ SELECTOR.query_settings_for_id(:current).equal?(current)
317
+ raise 'unrelated question extended deadline' unless current.endtime == original_deadline
318
+ valid = q.clone
319
+ valid.header.qr = true
320
+ inject_packet(socket, valid.encode, false)
321
+ incoming = SELECTOR.get_incoming_data(socket, 512)
322
+ SELECTOR.process_message(*incoming, socket)
323
+ SELECTOR.send_queued_validation_responses
324
+ raise 'following valid answer lost' unless current.client_queue.size == 2 &&
325
+ 2.times.map { current.client_queue.pop }.all? { |event| event.last.nil? }
326
+ assert_clean(socket)
327
+ end
328
+ RUBY
329
+ end
330
+
331
+ private
332
+
333
+ CONTRACT_FIXTURE = <<~'RUBY'
334
+ def cache_query(klass)
335
+ Dnsruby::Message.new('contract.example.', Dnsruby::Types::TXT, klass).tap do |message|
336
+ message.do_validation = false
337
+ end
338
+ end
339
+ def answer(query, text)
340
+ question = query.question.first
341
+ Dnsruby::Message.new(question.qname, question.qtype, question.qclass).tap do |reply|
342
+ reply.header.id = query.header.id
343
+ reply.header.qr = reply.header.aa = true
344
+ reply.add_answer(Dnsruby::RR.create("#{question.qname} 60 #{question.qclass} TXT \"#{text}\""))
345
+ end
346
+ end
347
+ def signing_key
348
+ Dnsruby::RR.create(name: 'contract-key.example.', type: 'TSIG', klass: 'ANY',
349
+ key: 'c2VjcmV0', algorithm: 'hmac-sha256')
350
+ end
351
+ def inject_packet(socket, bytes, tcp)
352
+ if tcp
353
+ socket.incoming = [bytes.bytesize].pack('n') + bytes
354
+ else
355
+ socket.define_singleton_method(:getsockopt) { |*| Struct.new(:data).new([Socket::SOCK_DGRAM].pack('i')) }
356
+ socket.define_singleton_method(:recvfrom_nonblock) { |*| [bytes, ['AF_INET', 53, '127.0.0.1', '127.0.0.1']] }
357
+ end
358
+ end
359
+ RUBY
360
+
361
+ def isolated(body)
362
+ options = ''.frozen? ? ['--enable-frozen-string-literal'] : []
363
+ source = "#{TestPipelineSend::FIXTURE}\n#{CONTRACT_FIXTURE}\nTimeout.timeout(10) do\n#{body}\nend"
364
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, *options, '-I',
365
+ File.expand_path('../lib', __dir__), '-e', source)
366
+ assert(status.success?, "#{stdout}\n#{stderr}")
367
+ assert_empty(stderr)
368
+ end
369
+ end
@@ -0,0 +1,213 @@
1
+ require_relative 'spec_helper'
2
+ require 'stringio'
3
+
4
+ class TestReviewPresentation < Minitest::Test
5
+ def presentation
6
+ Dnsruby.const_get(:Presentation)
7
+ end
8
+
9
+ def test_record_parsing_never_enumerates_learned_class_names
10
+ original = Dnsruby::Classes.method(:regexp)
11
+ own_method = Dnsruby::Classes.singleton_methods(false).include?(:regexp)
12
+ Dnsruby::Classes.define_singleton_method(:regexp) do
13
+ raise 'record parsing must use an indexed class lookup'
14
+ end
15
+ record = Dnsruby::RR.create('example. 300 IN A 192.0.2.1')
16
+ assert_equal('192.0.2.1', record.address.to_s)
17
+ record = Dnsruby::RR.create("example. 300 IN TXT 'one(two);three'")
18
+ assert_equal(['one(two);three'], record.strings)
19
+ ensure
20
+ if original
21
+ Dnsruby::Classes.singleton_class.remove_method(:regexp)
22
+ Dnsruby::Classes.define_singleton_method(:regexp, original) if own_method
23
+ end
24
+ end
25
+
26
+ def test_numeric_and_registered_class_tokens_preserve_quote_boundaries
27
+ arrays = Dnsruby::CodeMapper.class_variable_get(:@@arrays)
28
+ original = arrays.fetch(Dnsruby::Classes)
29
+ replacement = Dnsruby::CodeMapper::Arrays.new
30
+ %i[strings stringsdown values].each do |name|
31
+ replacement.public_send("#{name}=", original.public_send(name).dup)
32
+ end
33
+ replacement.maxcode = original.maxcode
34
+ arrays[Dnsruby::Classes] = replacement
35
+ Dnsruby::Classes.add_pair('PRIVATE', 60_000)
36
+ %w[IN in CLASS65000 class65000 PRIVATE private].each do |klass|
37
+ text = "owner 300 #{klass} TXT('one(two);three')"
38
+ assert_equal(text.index('TXT') + 3, presentation.single_quoted_start(text), klass)
39
+ end
40
+ assert_nil(presentation.single_quoted_start("owner 300 UNREGISTERED TXT 'value'"))
41
+ assert_nil(presentation.single_quoted_start("owner 300 IN A 'value'"))
42
+ ensure
43
+ arrays[Dnsruby::Classes] = original if original
44
+ end
45
+
46
+ def test_txt_continuation_recognizes_apostrophes_on_later_lines
47
+ %w[TXT SPF TYPE16 TYPE99 TYPE016 TYPE099 TYPE00016 TYPE00099].each do |type|
48
+ text = "owner 300 IN #{type} (\n 'one(two);three'\n 'four'\n)\n"
49
+ record = Dnsruby::ZoneReader.new('example.').process_io(StringIO.new(text)).first
50
+ assert_equal(['one(two);three', 'four'], record.strings, type)
51
+ end
52
+ end
53
+
54
+ def test_numeric_txt_and_spf_preserve_single_quoted_punctuation
55
+ %w[TYPE016 TYPE099 TYPE00016 TYPE00099].each do |type|
56
+ text = "owner 300 IN #{type} 'one(two);three'"
57
+ direct = Dnsruby::RR.create(text)
58
+ reader = Dnsruby::ZoneReader.new('example.')
59
+ normalized = reader.normalise_line(text)
60
+ record = reader.process_io(StringIO.new(text + "\n")).first
61
+ assert_equal(['one(two);three'], direct.strings, type)
62
+ assert_equal(direct.strings, Dnsruby::RR.create(normalized).strings, type)
63
+ assert_equal(direct.strings, record.strings, type)
64
+ end
65
+ end
66
+
67
+ def test_normalised_lines_remain_strings_accepted_by_public_rr_constructor
68
+ [
69
+ 'www 300 IN A 192.0.2.1',
70
+ "txt 300 IN TYPE016 'one(two);three'",
71
+ 'txt 300 IN TXT "one(two);three" \\"escaped ; ignored',
72
+ 'https 300 IN HTTPS 1 svc alpn="h2,h3"',
73
+ 'opaque 300 CLASS65000 TYPE65001 \\# 0'
74
+ ].each do |text|
75
+ reader = Dnsruby::ZoneReader.new('example.')
76
+ line = reader.process_line(text.freeze)
77
+ assert_instance_of(String, line)
78
+ expected = Dnsruby::RR.create(line)
79
+ record = Dnsruby::ZoneReader.new('example.').process_io(StringIO.new(text + "\n")).first
80
+ assert_equal(expected, record, text)
81
+ end
82
+ end
83
+
84
+ def test_process_line_preserves_subclass_normalization_hook_and_prefix_arguments
85
+ seen = []
86
+ reader_class = Class.new(Dnsruby::ZoneReader) do
87
+ define_method(:normalise_line) do |line, prefix = false|
88
+ seen << [line, prefix]
89
+ # Replacement strings must be scanned even though process_line has
90
+ # already scanned the original input passed to this hook.
91
+ super(line.sub('192.0.2.1', '(192.0.2.2) ; added comment ('), prefix)
92
+ end
93
+ end
94
+ line, type, name = reader_class.new('example.').process_line('www 300 IN A 192.0.2.1', true)
95
+ assert_equal(1, seen.length)
96
+ assert_instance_of(String, seen.first.first)
97
+ assert_equal(true, seen.first.last)
98
+ assert_equal('A', type)
99
+ assert_equal('www.example.', name)
100
+ assert_equal('192.0.2.2', Dnsruby::RR.create(line).address.to_s)
101
+ end
102
+
103
+ def test_prepend_hook_failure_does_not_leave_input_marked_as_scanned
104
+ first_line = nil
105
+ hook = Module.new do
106
+ define_method(:normalise_line) do |line, prefix = false|
107
+ unless first_line
108
+ first_line = line
109
+ raise ArgumentError, 'normalization hook rejected input'
110
+ end
111
+ super(line, prefix)
112
+ end
113
+ end
114
+ reader_class = Class.new(Dnsruby::ZoneReader) { prepend hook }
115
+ reader = reader_class.new('example.')
116
+ assert_raises(ArgumentError) { reader.process_line('www 300 IN A 192.0.2.1') }
117
+ # Reusing the same String after the failed call is a direct public call,
118
+ # so its new grouping and comments must receive the normal scanner.
119
+ first_line.replace('www 300 IN A (192.0.2.3) ; added comment (')
120
+ line = reader.normalise_line(first_line)
121
+ assert_equal('192.0.2.3', Dnsruby::RR.create(line).address.to_s)
122
+ assert_equal('192.0.2.4', Dnsruby::RR.create(reader.process_line('next 300 IN A 192.0.2.4')).address.to_s)
123
+ end
124
+
125
+ def test_normalization_hook_can_mutate_the_input_before_super
126
+ reader_class = Class.new(Dnsruby::ZoneReader) do
127
+ def normalise_line(line, prefix = false)
128
+ line.sub!('192.0.2.1', '(192.0.2.2) ; added comment (')
129
+ super
130
+ end
131
+ end
132
+ text = "www 300 IN A 192.0.2.1\n"
133
+ record = reader_class.new('example.').process_io(StringIO.new(text)).first
134
+ assert_equal('www.example.', record.name.to_s(true))
135
+ assert_equal('192.0.2.2', record.address.to_s)
136
+ end
137
+
138
+ def test_process_io_parses_presentation_returned_by_a_replacement_hook
139
+ reader_class = Class.new(Dnsruby::ZoneReader) do
140
+ def normalise_line(line, prefix = false)
141
+ # Applications can provide the normalization themselves and return
142
+ # any valid RR presentation, including grouping and comments.
143
+ 'custom.example. 300 IN A (192.0.2.3) ; hook supplied this record'
144
+ end
145
+ end
146
+ record = reader_class.new('example.').process_io(StringIO.new("www 300 IN A 192.0.2.1\n")).first
147
+ assert_equal('custom.example.', record.name.to_s(true))
148
+ assert_equal('192.0.2.3', record.address.to_s)
149
+ end
150
+
151
+ def test_process_line_scans_unchanged_input_once
152
+ scanner = presentation.singleton_class
153
+ ['www 300 IN A 192.0.2.1', 'txt 300 IN TXT "one(two);three"'].each do |line|
154
+ scans = 0
155
+ tracer = TracePoint.new(:call) do |event|
156
+ scans += 1 if event.defined_class == scanner && event.method_id == :scan
157
+ end
158
+ normalized = tracer.enable do
159
+ Dnsruby::ZoneReader.new('example.').process_line(line + "\n")
160
+ end
161
+ assert_equal(1, scans, line)
162
+ assert_instance_of(String, normalized)
163
+ assert_instance_of(Dnsruby::Name, Dnsruby::RR.create(normalized).name)
164
+ end
165
+ end
166
+
167
+ def test_omitted_owner_and_apostrophe_in_owner_keep_legacy_behavior
168
+ text = "'owner 300 IN TXT ('first')\n IN TXT ('two(three);four')\n"
169
+ records = Dnsruby::ZoneReader.new('example.').process_io(StringIO.new(text))
170
+ assert_equal(['first'], records.first.strings)
171
+ assert_equal(['two(three);four'], records.last.strings)
172
+ assert_equal(records.first.name, records.last.name)
173
+ end
174
+
175
+ def test_plain_records_and_grouped_records_produce_identical_wire_data
176
+ [
177
+ 'www.example. 60 IN A 192.0.2.1',
178
+ "www.example.\t60\tIN\tAAAA\t2001:db8::1",
179
+ 'example. IN MX 10 mail.example.',
180
+ 'example. 60 IN TXT plain',
181
+ 'example. 60 IN HTTPS 1 . alpn=h2 port=443',
182
+ 'é.example. 60 IN A 192.0.2.1'
183
+ ].each do |text|
184
+ plain = Dnsruby::RR.create(text.freeze)
185
+ grouped = Dnsruby::RR.create("(#{text})")
186
+ assert_equal(grouped, plain, text)
187
+ assert_equal(grouped.clone, plain.clone, text)
188
+ end
189
+ end
190
+
191
+ def test_runs_preserve_unicode_quotes_escapes_and_continuation_state
192
+ text = "é.example. TXT ('café; (value)'\"déjà\\\"vu\") ; ignored quote '\n"
193
+ start = presentation.single_quoted_start(text)
194
+ normalized, depth, quote = presentation.scan(text, single_quoted_from: start)
195
+ assert_equal("é.example. TXT 'café; (value)'\"déjà\\\"vu\" ", normalized)
196
+ assert_equal(0, depth)
197
+ assert_equal(false, quote)
198
+ assert_equal(Encoding::UTF_8, normalized.encoding)
199
+
200
+ normalized, depth, quote = presentation.scan("début; (one)\\\nfin\" )", 1, true)
201
+ assert_equal("début; (one)\\\nfin\" ", normalized)
202
+ assert_equal(0, depth)
203
+ assert_equal(false, quote)
204
+ end
205
+
206
+ def test_runs_keep_binary_character_strings_and_trailing_escapes
207
+ text = "owner TXT \"\xFE\xFF (value);\" \\".b
208
+ normalized, depth, quote = presentation.scan(text)
209
+ assert_equal(text, normalized)
210
+ assert_equal(Encoding::BINARY, normalized.encoding)
211
+ assert_equal([0, false], [depth, quote])
212
+ end
213
+ end
@@ -0,0 +1,101 @@
1
+ require_relative 'spec_helper'
2
+
3
+ class ReviewRecordCompatibilityTest < Minitest::Test
4
+ def test_decoder_byte_access_keeps_direct_multibyte_and_mutation_contracts
5
+ text = +'AA'
6
+ decoder = Dnsruby::MessageDecoder.new(text)
7
+ assert_equal(65, decoder.get_byte_at(1))
8
+ text.replace('éA')
9
+ assert_equal(195, decoder.get_byte_at(0))
10
+ assert_equal(65, decoder.get_byte_at(1))
11
+ assert_nil(decoder.get_byte_at(2))
12
+ binary = Dnsruby::MessageDecoder.new(text.b)
13
+ assert_equal([195, 169, 65], 3.times.map { |index| binary.get_byte_at(index) })
14
+ assert_nil(binary.get_byte_at(3))
15
+ end
16
+
17
+ def test_caa_clone_preserves_string_rdata_and_comparisons
18
+ record = Dnsruby::RR.create('example. 300 IN CAA 128 issue "ca.example"')
19
+ copy = record.clone
20
+ assert_instance_of(String, copy.rdata)
21
+ assert_equal(record.rdata, copy.rdata)
22
+ assert_equal(0, record <=> copy)
23
+ assert_equal(2, [record, copy].sort.length)
24
+ copy.property_value = 'other.example'
25
+ assert_equal('128 issue "ca.example"', copy.rdata)
26
+ assert_equal('128 issue "other.example"', copy.rdata_to_string)
27
+ end
28
+
29
+ def test_caa_binary_value_round_trips_without_presentation_reparsing
30
+ value = "\0\"\\\xFF".b
31
+ record = Dnsruby::RR.create(name: 'example.', type: 'CAA', klass: 'IN', ttl: 300,
32
+ flag: 0, property_tag: 'issue', property_value: value)
33
+ copy = record.clone
34
+ assert_equal(value, copy.property_value)
35
+ assert_instance_of(String, copy.rdata)
36
+ assert_equal(copy.rdata_to_string, copy.rdata)
37
+ assert_equal(value, copy.clone.property_value)
38
+ end
39
+
40
+ def test_escaped_origin_and_rdata_names_remain_complete
41
+ reader = Dnsruby::ZoneReader.new('example.')
42
+ reader.process_line('$ORIGIN foo\\ bar.example.')
43
+ address = Dnsruby::RR.create(reader.process_line('host 60 IN A 192.0.2.1'))
44
+ assert_equal(['host', 'foo bar', 'example'], address.name.labels.map(&:string))
45
+
46
+ mx = Dnsruby::RR.create(reader.process_line('mail 60 IN MX 10 target\\ name'))
47
+ assert_equal(['target name', 'foo bar', 'example'], mx.exchange.labels.map(&:string))
48
+ nsec = Dnsruby::RR.create(reader.process_line('owner 60 IN NSEC next\\ name A NSEC RRSIG'))
49
+ assert_equal(['next name', 'foo bar', 'example'], nsec.next_domain.labels.map(&:string))
50
+ assert_equal(%w[A NSEC RRSIG], nsec.types.map(&:to_s))
51
+
52
+ direct_mx = Dnsruby::RR.create('mail.example. 60 IN MX 10 target\\ name.example.')
53
+ direct_nsec = Dnsruby::RR.create('owner.example. 60 IN NSEC next\\ name.example. A NSEC')
54
+ assert_equal(['target name', 'example'], direct_mx.exchange.labels.map(&:string))
55
+ assert_equal(['next name', 'example'], direct_nsec.next_domain.labels.map(&:string))
56
+ end
57
+
58
+ def test_escaped_trailing_whitespace_in_zone_names
59
+ reader = Dnsruby::ZoneReader.new('example.')
60
+ rr = Dnsruby::RR.create(reader.process_line('alias 60 IN CNAME trailing\\ '))
61
+ assert_equal(['trailing ', 'example'], rr.domainname.labels.map(&:string))
62
+ end
63
+
64
+ def test_zone_txt_uses_dns_escapes_while_direct_parser_keeps_legacy_behavior
65
+ reader = Dnsruby::ZoneReader.new('example.')
66
+ %w[TXT SPF].each do |type|
67
+ rr = Dnsruby::RR.create(reader.process_line("owner 60 IN #{type} \"one\\ntwo\\tthree\\032four\\010five\""))
68
+ assert_equal(["onentwotthree four\nfive"], rr.strings)
69
+ quoted = Dnsruby::RR.create(reader.process_line("owner 60 IN #{type} \"say\\\"hi\\\"\\\\end\""))
70
+ assert_equal(['say"hi"\\end'], quoted.strings)
71
+ unquoted = Dnsruby::RR.create(reader.process_line("owner 60 IN #{type} one\\ two"))
72
+ assert_equal(['one two'], unquoted.strings)
73
+ end
74
+ assert_equal(["one\ntwo"], Dnsruby::RR::TXT.parse('"one\\ntwo"'))
75
+ end
76
+
77
+ def test_canonical_sort_keeps_references_and_normalizes_mutated_ttls
78
+ records = [3, 1, 2].map { |last| Dnsruby::RR.create("example. 300 IN A 192.0.2.#{last}") }
79
+ set = Dnsruby::RRSet.new(records)
80
+ originals = set.rrs
81
+ originals[0].ttl = 100
82
+ sorted = set.sort_canonical
83
+ assert_equal(%w[192.0.2.1 192.0.2.2 192.0.2.3], sorted.rrs.map { |rr| rr.address.to_s })
84
+ sorted.rrs.each { |rr| assert_same(originals.find { |old| old.address == rr.address }, rr) }
85
+ assert_equal([100, 100, 100], originals.map(&:ttl))
86
+ sorted.rrs.first.address = Dnsruby::IPv4.create('192.0.2.9')
87
+ assert(sorted.add(Dnsruby::RR.create('example. 100 IN A 192.0.2.1'), false))
88
+ assert_equal(4, sorted.length)
89
+ end
90
+
91
+ def test_canonical_sort_deduplicates_publicly_mutated_records
92
+ set = Dnsruby::RRSet.new([
93
+ Dnsruby::RR.create('example. 300 IN A 192.0.2.1'),
94
+ Dnsruby::RR.create('example. 300 IN A 192.0.2.2')
95
+ ])
96
+ set.rrs.last.address = Dnsruby::IPv4.create('192.0.2.1')
97
+ sorted = set.sort_canonical
98
+ assert_equal(1, sorted.length)
99
+ assert_same(set.rrs.last, sorted.rrs.first)
100
+ end
101
+ end