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,304 @@
1
+ require_relative 'tc_pipeline_send'
2
+ require 'minitest/mock'
3
+
4
+ class V2ReleaseTransportTest < Minitest::Test
5
+ def test_udp_readiness_without_data_keeps_the_attempt_registered
6
+ isolated(<<~'SCENARIO')
7
+ SELECTOR.define_singleton_method(:tcp?) { |_| false }
8
+ socket = FrameSocket.new
9
+ request = query
10
+ setting = settings(socket, :datagram, request)
11
+ setting.is_persistent_socket = false
12
+ SELECTOR.add_to_select(setting)
13
+ [IO::EAGAINWaitReadable, Errno::EINTR].each do |exception|
14
+ socket.define_singleton_method(:recvfrom_nonblock) { |_| raise exception }
15
+ raise 'unexpected response' unless SELECTOR.get_incoming_data(socket, 512).nil?
16
+ raise 'attempt lost' unless SELECTOR.query_settings_for_id(:datagram).equal?(setting)
17
+ raise 'socket closed' if socket.closed?
18
+ raise 'retryable read became an error' unless setting.client_queue.empty?
19
+ end
20
+ SELECTOR.remove_id(:datagram)
21
+ SCENARIO
22
+ end
23
+
24
+ def test_pipeline_configuration_is_snapshotted_before_worker_dispatch
25
+ isolated(<<~'SCENARIO')
26
+ [false, true].each do |initial_mode|
27
+ socket = FrameSocket.new
28
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', tcp_pipelining: initial_mode)
29
+ writer.define_singleton_method(:connect_tcp) { |*| socket }
30
+ writer.define_singleton_method(:tcp_pipeline_socket) do |*|
31
+ raise 'shared writer mutex missing' unless instance_variable_get(:@pipeline_mutex).owned?
32
+ [socket, true]
33
+ end
34
+ K.class_variable_set(:@@select_thread, Thread.current)
35
+ dispatched = nil
36
+ SELECTOR.define_singleton_method(:defer_send) do |owner, &operation|
37
+ dispatched = owner
38
+ writer.tcp_pipelining = !initial_mode
39
+ operation.call
40
+ end
41
+ request = query
42
+ writer.send_dnsruby(request.encode, request, initial_mode, Queue.new, true)
43
+ registered = SELECTOR.query_settings_for_id(initial_mode)
44
+ raise 'configuration changed admitted socket mode' unless registered &&
45
+ !!registered.is_persistent_socket == initial_mode
46
+ raise 'worker grouping changed during admission' unless dispatched.equal?(initial_mode ? writer : nil)
47
+ raise 'incomplete frame' unless socket.written.bytesize == request.encode.bytesize + 2
48
+ SELECTOR.remove_id(initial_mode)
49
+ socket.close
50
+ end
51
+ SCENARIO
52
+ end
53
+
54
+ def test_packet_timeout_processing_visits_only_due_owners
55
+ isolated(<<~'SCENARIO')
56
+ now = Dnsruby::TransportClock.now
57
+ due = settings(FrameSocket.new, :due)
58
+ later = settings(FrameSocket.new, :later)
59
+ due.endtime, later.endtime = now - 1, now + 60
60
+ [due, later].each { |setting| SELECTOR.add_to_select(setting) }
61
+ table = K.class_variable_get(:@@timeouts)
62
+ [:each, :map, :keys].each { |method| table.define_singleton_method(method) { raise 'scanned packet table' } }
63
+ SELECTOR.process_timeouts
64
+ raise 'future attempt expired' unless SELECTOR.query_settings_for_id(:later).equal?(later)
65
+ raise 'due attempt retained' if SELECTOR.query_settings_for_id(:due)
66
+ raise 'timeout missing' unless due.client_queue.pop.last.is_a?(Dnsruby::ResolvTimeout)
67
+ SELECTOR.remove_id(:later)
68
+ SCENARIO
69
+ end
70
+
71
+ def test_logical_tick_visits_only_due_plans_in_admission_order
72
+ isolated(<<~'SCENARIO')
73
+ sent = []
74
+ writer = Object.new
75
+ writer.define_singleton_method(:server) { '192.0.2.53' }
76
+ writer.define_singleton_method(:prepare_send_async) { |_, _, id, *| -> { sent << id[2] } }
77
+ parent = Struct.new(:single_resolvers, :single_res_mutex).new([writer], Mutex.new)
78
+ engine = Dnsruby::ResolverRuby.new(parent)
79
+ now = Dnsruby::TransportClock.now
80
+ plans = engine.instance_variable_get(:@timeouts)
81
+ [:due, :later].each do |id|
82
+ engine.instance_variable_get(:@query_list)[id] = [query, Queue.new, Queue.new, [], nil]
83
+ plans[id] = [now + 120, {(id == :due ? now - 1 : now + 60) => [writer, 0]}, false]
84
+ engine.send(:update_tick_deadline, id)
85
+ end
86
+ plans.define_singleton_method(:keys) { raise 'scanned all logical queries' }
87
+ plans[:later][1].define_singleton_method(:each_key) { raise 'visited future retry plan' }
88
+ engine.tick
89
+ raise 'incorrect due sends' unless sent == [:due]
90
+ raise 'incorrect next deadline' unless engine.next_tick_at == now + 60
91
+ SCENARIO
92
+ end
93
+
94
+ def test_retries_are_bounded_per_server_and_transport
95
+ budget = Dnsruby::SelectThread::TransmissionBudget.new
96
+ 3.times { assert(budget.admit('192.0.2.53', 53, false)) }
97
+ refute(budget.admit('192.0.2.53', 53, false))
98
+ assert(budget.admit('192.0.2.53', 53, true))
99
+ assert(budget.admit('192.0.2.54', 53, false))
100
+ resolver = Dnsruby::Resolver.new(nameserver: ['192.0.2.53', '192.0.2.54'], retry_times: 6)
101
+ counts = resolver.generate_timeouts.values.group_by(&:first).transform_values(&:length)
102
+ assert_equal([3, 3], counts.values.sort)
103
+ ensure
104
+ resolver&.close
105
+ end
106
+
107
+ def test_validation_worker_shares_the_original_transmission_limit
108
+ budget = Dnsruby::SelectThread::TransmissionBudget.new
109
+ query = Dnsruby::Message.new('example.', 'A')
110
+ query.do_validation = true
111
+ assert(budget.admit('192.0.2.53', 53, false, query.question.first))
112
+ response = query.clone
113
+ completions = []
114
+ selector = Object.new
115
+ selector.define_singleton_method(:validation_transmission_budget) { |*| budget }
116
+ selector.define_singleton_method(:register_validation) { |*| true }
117
+ selector.define_singleton_method(:unregister_validation) { |*| }
118
+ selector.define_singleton_method(:push_validation_response_to_select) { |*args| completions << args }
119
+ sender = Struct.new(:dnssec).new(true)
120
+ validator = Dnsruby::ValidatorThread.new(:client, Queue.new, response, nil, query, selector, sender)
121
+ validate = lambda do |_, answer|
122
+ # Separate helper send tokens must count against the original resolution.
123
+ 2.times do
124
+ token = Dnsruby::SelectThread::SendToken.new
125
+ assert(token.transmission_budget.admit('192.0.2.53', 53, false, query.question.first))
126
+ end
127
+ token = Dnsruby::SelectThread::SendToken.new
128
+ refute(token.transmission_budget.admit('192.0.2.53', 53, false, query.question.first))
129
+ answer.security_level = Dnsruby::Message::SecurityLevel.SECURE
130
+ true
131
+ end
132
+ Dnsruby::Dnssec.stub(:validate_with_query, validate) do
133
+ Dnsruby::PacketSender.stub(:cache, ->(*) {}) do
134
+ assert_nil(Thread.new { validator.do_validate; Dnsruby::SelectThread::TransmissionBudget.current }.value)
135
+ end
136
+ end
137
+ assert_equal(1, completions.length)
138
+ assert_nil(completions.first[3])
139
+ end
140
+
141
+ class TransferSocket
142
+ attr_reader :written
143
+ def initialize(input = ''.b)
144
+ @input, @written, @closed = input.dup, +''.b, false
145
+ end
146
+ def write_nonblock(bytes)
147
+ count = [bytes.bytesize, 3].min
148
+ @written << bytes.byteslice(0, count)
149
+ count
150
+ end
151
+ def read_nonblock(length)
152
+ raise EOFError if @input.empty?
153
+ @input.slice!(0, [length, 5].min)
154
+ end
155
+ def close; @closed = true; end
156
+ def closed?; @closed; end
157
+ end
158
+
159
+ def test_zone_transfer_writes_complete_frames_and_reads_fragmented_frames
160
+ transfer = Dnsruby::ZoneTransfer.new
161
+ request = Dnsruby::Message.new('example.', 'AXFR')
162
+ socket = TransferSocket.new(frame(request))
163
+ transfer.send_message(socket, request)
164
+ assert_equal(frame(request), socket.written)
165
+ assert_equal(request.question, transfer.receive_message(socket).question)
166
+ end
167
+
168
+ def test_zone_transfer_accepts_multi_message_axfr_with_an_empty_later_question
169
+ transfer = Dnsruby::ZoneTransfer.new
170
+ soa = Dnsruby::RR.create('example. 300 IN SOA ns.example. hostmaster.example. 4 60 60 3600 60')
171
+ address = Dnsruby::RR.create('www.example. 300 IN A 192.0.2.1')
172
+ first = Dnsruby::Message.new('example.', 'AXFR')
173
+ first.add_answer(soa)
174
+ first.add_answer(address)
175
+ last = Dnsruby::Message.new
176
+ last.add_answer(soa.clone)
177
+ responses = [first, last]
178
+ socket = TransferSocket.new
179
+ transfer.define_singleton_method(:send_message) do |_, request|
180
+ responses.each { |response| response.header.id = request.header.id; response.header.qr = true }
181
+ end
182
+ transfer.define_singleton_method(:receive_message) { |_| responses.shift }
183
+ Socket.stub(:tcp, socket) do
184
+ assert_equal([soa, address], transfer.do_transfer('example.', '192.0.2.53'))
185
+ end
186
+ assert(socket.closed?)
187
+ end
188
+
189
+ def test_zone_transfer_timeout_configuration_and_interrupted_wait
190
+ transfer = Dnsruby::ZoneTransfer.new
191
+ assert_equal(30, transfer.io_timeout)
192
+ assert_nil(transfer.transfer_timeout)
193
+ transfer.io_timeout = 1
194
+ transfer.transfer_timeout = 2
195
+ assert_equal(1.0, transfer.io_timeout)
196
+ assert_equal(2.0, transfer.transfer_timeout)
197
+ transfer.transfer_timeout = nil
198
+ assert_raises(ArgumentError) { transfer.io_timeout = 0 }
199
+ assert_raises(ArgumentError) { transfer.transfer_timeout = Float::INFINITY }
200
+ calls = 0
201
+ IO.stub(:select, ->(*_) { calls += 1; raise Errno::EINTR if calls == 1; nil }) do
202
+ transfer.send(:wait_for_io, Object.new, Dnsruby::TransportClock.now + 1, true)
203
+ end
204
+ assert_equal(2, calls)
205
+ assert_raises(Dnsruby::ResolvTimeout) do
206
+ transfer.tcp_read(TransferSocket.new, 2, Dnsruby::TransportClock.now - 1)
207
+ end
208
+ end
209
+
210
+ def test_zone_transfer_matches_the_name_sent_in_the_question
211
+ zones = ['example', 'example.', 'EXAMPLE', Dnsruby::Name.create('example').freeze,
212
+ Dnsruby::Name.create('example.').freeze, '192.0.2.1']
213
+ ['AXFR', 'IXFR'].each do |type|
214
+ zones.each do |zone|
215
+ owner = zone == '192.0.2.1' ? '1.2.0.192.in-addr.arpa.' : 'example.'
216
+ soa = Dnsruby::RR.create("#{owner} 300 IN SOA ns.example. hostmaster.example. 4 60 60 3600 60")
217
+ address = Dnsruby::RR.create("www.#{owner} 300 IN A 192.0.2.1")
218
+ original_absolute = zone.absolute? if zone.is_a?(Dnsruby::Name)
219
+ answers = if type == 'AXFR'
220
+ [soa, address, soa.clone]
221
+ else
222
+ [soa, soa.clone.tap { |rr| rr.serial = 2 }, soa.clone, address, soa.clone]
223
+ end
224
+ result = transfer_with_answers(zone, type, owner, answers)
225
+ if type == 'AXFR'
226
+ assert_equal([soa, address], result, zone.inspect)
227
+ else
228
+ assert_equal([[2, 4]], result.map { |delta| [delta.start, delta.end] }, zone.inspect)
229
+ assert_equal([soa, address], result.first.adds)
230
+ end
231
+ assert_equal(original_absolute, zone.absolute?) if zone.is_a?(Dnsruby::Name)
232
+ end
233
+ end
234
+ end
235
+
236
+ def test_zone_transfer_still_rejects_an_initial_soa_for_another_zone_or_class
237
+ ['AXFR', 'IXFR'].each do |type|
238
+ ['example', 'example.'].each do |zone|
239
+ ['other. 300 IN', 'example. 300 CH'].each do |owner_and_class|
240
+ soa = Dnsruby::RR.create("#{owner_and_class} SOA ns.example. hostmaster.example. 4 60 60 3600 60")
241
+ error = assert_raises(Dnsruby::ResolvError) do
242
+ transfer_with_answers(zone, type, 'example.', [soa])
243
+ end
244
+ assert_equal('initial SOA does not match transfer zone', error.message)
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ def test_ixfr_terminal_soa_matches_the_opening_soa
251
+ opening = Dnsruby::RR.create('example. 300 IN SOA ns.example. hostmaster.example. 4 60 60 3600 60')
252
+ [opening.clone, opening.clone.tap { |rr| rr.refresh = 120 }].each do |closing|
253
+ transfer = Dnsruby::ZoneTransfer.new
254
+ transfer.instance_variable_set(:@zone_name, Dnsruby::Name.create('example.'))
255
+ transfer.instance_variable_set(:@transfer_type, Dnsruby::Types.IXFR)
256
+ transfer.instance_variable_set(:@serial, 2)
257
+ transfer.instance_variable_set(:@state, :InitialSoa)
258
+ # Two ordinary deltas, including their repeated intermediate SOA.
259
+ old = opening.clone.tap { |rr| rr.serial = 2 }
260
+ middle = opening.clone.tap { |rr| rr.serial = 3 }
261
+ [opening, old, middle, middle.clone, opening.clone].each { |rr| transfer.parseRR(rr) }
262
+ if closing.refresh == opening.refresh
263
+ transfer.parseRR(closing)
264
+ assert_equal(:End, transfer.instance_variable_get(:@state))
265
+ assert_equal([[2, 3], [3, 4]], transfer.instance_variable_get(:@ixfr).map { |delta| [delta.start, delta.end] })
266
+ else
267
+ assert_raises(Dnsruby::ResolvError) { transfer.parseRR(closing) }
268
+ assert_equal(:Ixfr_Add, transfer.instance_variable_get(:@state))
269
+ end
270
+ end
271
+ end
272
+
273
+ private
274
+
275
+ def transfer_with_answers(zone, type, owner, answers)
276
+ transfer = Dnsruby::ZoneTransfer.new
277
+ transfer.transfer_type = type
278
+ transfer.serial = 2
279
+ response = Dnsruby::Message.new(owner, type)
280
+ response.header.qr = true
281
+ answers.each { |rr| response.add_answer!(rr) }
282
+ socket = TransferSocket.new
283
+ transfer.define_singleton_method(:send_message) do |_, request|
284
+ response.header.id = request.header.id
285
+ end
286
+ transfer.define_singleton_method(:receive_message) { |_| Dnsruby::Message.decode(response.encode) }
287
+ Socket.stub(:tcp, socket) { transfer.do_transfer(zone, '192.0.2.53') }
288
+ ensure
289
+ assert(socket.closed?) if socket
290
+ end
291
+
292
+ def frame(message)
293
+ wire = message.encode
294
+ [wire.bytesize].pack('n') + wire
295
+ end
296
+
297
+ def isolated(script)
298
+ options = ''.frozen? ? ['--enable-frozen-string-literal'] : []
299
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, *options, '-I',
300
+ File.expand_path('../lib', __dir__), '-e', "#{TestPipelineSend::FIXTURE}\nTimeout.timeout(10) do\n#{script}\nend")
301
+ assert(status.success?, "#{stdout}\n#{stderr}")
302
+ assert_empty(stderr)
303
+ end
304
+ end
@@ -0,0 +1,58 @@
1
+ require_relative 'spec_helper'
2
+ require 'socket'
3
+ require 'timeout'
4
+
5
+ class V2ZoneTransferNativeTest < Minitest::Test
6
+ def test_axfr_over_tcp_with_and_without_tsig
7
+ [false, true].each do |signed|
8
+ listener = TCPServer.new('127.0.0.1', 0)
9
+ transfer = Dnsruby::ZoneTransfer.new
10
+ transfer.server = '127.0.0.1'
11
+ transfer.port = listener.addr[1]
12
+ transfer.io_timeout = 2
13
+ transfer.transfer_timeout = 3
14
+ transfer.tsig = ['key.example.', ['local-transfer-secret'].pack('m0')] if signed
15
+ soa = Dnsruby::RR.create('example. 300 IN SOA ns.example. hostmaster.example. 2 60 60 3600 60')
16
+ address = Dnsruby::RR.create('www.example. 300 IN A 192.0.2.1')
17
+ server = Thread.new do
18
+ socket = listener.accept
19
+ query = Dnsruby::Message.decode(socket.read(socket.read(2).unpack1('n')))
20
+ response = Dnsruby::Message.new('example.', 'AXFR')
21
+ response.header.id = query.header.id
22
+ response.header.qr = response.header.aa = true
23
+ response.add_answer(soa)
24
+ response.add_answer(address)
25
+ if signed
26
+ response.add_answer!(soa.clone)
27
+ key = Dnsruby::RR.create(name: 'key.example.', type: 'TSIG', klass: 'ANY',
28
+ key: ['local-transfer-secret'].pack('m0'))
29
+ key.apply(response, query)
30
+ responses = [response]
31
+ else
32
+ closing = Dnsruby::Message.new
33
+ closing.header.id = query.header.id
34
+ closing.header.qr = closing.header.aa = true
35
+ closing.add_answer(soa.clone)
36
+ responses = [response, closing]
37
+ end
38
+ responses.each do |message|
39
+ wire = message.encode
40
+ # TCP may coalesce these writes or deliver any combination of them.
41
+ socket.write([wire.bytesize].pack('n').byteslice(0, 1))
42
+ socket.write([wire.bytesize].pack('n').byteslice(1, 1))
43
+ socket.write(wire)
44
+ end
45
+ ensure
46
+ socket&.close
47
+ end
48
+ assert_equal([soa, address], Timeout.timeout(5) { transfer.transfer('example') })
49
+ assert_equal(:Verified, transfer.last_tsigstate) if signed
50
+ assert_nil(transfer.last_tsigstate) unless signed
51
+ Timeout.timeout(2) { server.value }
52
+ ensure
53
+ listener&.close
54
+ server&.kill
55
+ server&.join
56
+ end
57
+ end
58
+ end
@@ -71,5 +71,221 @@ ZONEDATA
71
71
  assert_equal(false, stringio.closed?)
72
72
  stringio.close
73
73
  end
74
- end
75
74
 
75
+ # The reader reattaches a quoted tail for every type, so a parenthesis right
76
+ # before a quote must separate it too: the parenthesis is stripped from the
77
+ # line before the quoted text goes back on.
78
+ PAREN_QUOTE_ZONE = <<~ZONEDATA
79
+ $TTL 3600
80
+ caa IN CAA 0 issue("ca.example.net")
81
+ hinfo IN HINFO ("cpu" "os")
82
+ txt IN TXT ("abc" "def")
83
+ ZONEDATA
84
+
85
+ def test_zone_parenthesis_before_quote
86
+ reader = Dnsruby::ZoneReader.new('example.com.')
87
+ caa, hinfo, txt = reader.process_file(StringIO.new(PAREN_QUOTE_ZONE))
88
+
89
+ assert_equal('issue', caa.property_tag)
90
+ assert_equal('ca.example.net', caa.property_value)
91
+
92
+ assert_equal('cpu', hinfo.cpu)
93
+ assert_equal('os', hinfo.os)
94
+
95
+ assert_equal(['abc', 'def'], txt.strings)
96
+ end
97
+
98
+ # A backslash escaping the quote abuts it too. RFC 1035 sec 5.1 makes \" a
99
+ # literal quote within an unquoted character-string, so each of these is one
100
+ # string, not two.
101
+ ESCAPED_QUOTE_ZONE = <<~'ZONEDATA'
102
+ $TTL 3600
103
+ lead IN TXT \"escaped
104
+ mid IN TXT abc\"def
105
+ ZONEDATA
106
+
107
+ def test_zone_escaped_quote_not_separated
108
+ reader = Dnsruby::ZoneReader.new('example.com.')
109
+ lead, mid = reader.process_file(StringIO.new(ESCAPED_QUOTE_ZONE))
110
+
111
+ assert_equal(['"escaped'], lead.strings)
112
+ assert_equal(['abc"def'], mid.strings)
113
+ end
114
+
115
+ # SVCB/HTTPS records carry a TargetName in the middle of the RDATA. The zone
116
+ # reader must resolve relative TargetNames against the origin, while leaving
117
+ # the root (".") and already-absolute names untouched.
118
+ SVCB_ZONE = <<~ZONEDATA
119
+ $TTL 3600
120
+ $ORIGIN example.com.
121
+ @ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5
122
+ @ IN HTTPS 1 . alpn=h2,h3 ipv4hint=192.0.2.1
123
+ alias IN SVCB 0 svc4.example.com.
124
+ rel IN SVCB 1 foo alpn=h2
125
+ svc IN HTTPS 1 svc.example.net. port=8443
126
+ ZONEDATA
127
+
128
+ def svcb_zone_records
129
+ reader = Dnsruby::ZoneReader.new('example.com.')
130
+ records = reader.process_file(StringIO.new(SVCB_ZONE))
131
+ records.reject { |rr| rr.type == Types::SOA }
132
+ end
133
+
134
+ def test_zone_svcb_https_targets_resolved
135
+ https_root, svcb_alias, svcb_rel, https_svc = svcb_zone_records
136
+
137
+ # Root TargetName stays as the root.
138
+ assert_equal(Types::HTTPS, https_root.type)
139
+ assert_equal('.', https_root.target.to_s(true))
140
+ assert_equal('h2,h3', https_root.params_to_hash['alpn'])
141
+
142
+ # Absolute AliasMode TargetName is untouched.
143
+ assert_equal(0, svcb_alias.priority)
144
+ assert_equal('svc4.example.com.', svcb_alias.target.to_s(true))
145
+
146
+ # Relative TargetName gains the origin.
147
+ assert_equal('foo.example.com.', svcb_rel.target.to_s(true))
148
+ assert_equal('h2', svcb_rel.params_to_hash['alpn'])
149
+
150
+ # Absolute TargetName in another zone is untouched.
151
+ assert_equal('svc.example.net.', https_svc.target.to_s(true))
152
+ assert_equal('8443', https_svc.params_to_hash['port'])
153
+ end
154
+
155
+ def test_zone_svcb_https_escaped_final_dots_do_not_make_targets_absolute
156
+ %w[SVCB HTTPS].each do |type|
157
+ {
158
+ 'foo\\.' => ['foo.', 'example'],
159
+ 'foo\\046' => ['foo.', 'example'],
160
+ 'foo\\\\.' => ['foo\\'],
161
+ 'foo\\..' => ['foo.'],
162
+ '.' => [],
163
+ 'foo.' => ['foo']
164
+ }.each do |target, labels|
165
+ text = "owner 300 IN #{type} 1 #{target}\n"
166
+ record = ZoneReader.new('example.').process_io(StringIO.new(text)).first
167
+ assert_equal(labels, record.target.labels.map(&:string), "#{type} #{target}")
168
+ assert(record.target.absolute?)
169
+ assert_equal(record, record.clone)
170
+ end
171
+ end
172
+ end
173
+
174
+ # A HTTPS record spread across several lines with parentheses is valid zone
175
+ # format and must be parsed as a single record.
176
+ MULTILINE_ZONE = <<~ZONEDATA
177
+ $TTL 3600
178
+ $ORIGIN example.com.
179
+ @ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5
180
+ @ IN HTTPS ( 1 . alpn=h2,h3
181
+ port=443
182
+ ipv4hint=192.0.2.1 )
183
+ ZONEDATA
184
+
185
+ def test_zone_multiline_https
186
+ reader = Dnsruby::ZoneReader.new('example.com.')
187
+ records = reader.process_file(StringIO.new(MULTILINE_ZONE))
188
+ https = records.find { |rr| rr.type == Types::HTTPS }
189
+ refute_nil(https)
190
+ assert_equal(1, https.priority)
191
+ assert_equal('.', https.target.to_s(true))
192
+ assert_equal('h2,h3', https.params_to_hash['alpn'])
193
+ assert_equal('443', https.params_to_hash['port'])
194
+ assert_equal('192.0.2.1', https.params_to_hash['ipv4hint'])
195
+ end
196
+
197
+ # RFC 9460 Appendix A: a SvcParamValue may be quoted, and the quotes open
198
+ # mid-token, unlike the quoted RDATA of a TXT record. The ech value also puts
199
+ # base64 padding inside the quotes, and an unquoted param after them.
200
+ QUOTED_SVCB_ZONE = <<~ZONEDATA
201
+ $TTL 3600
202
+ alpn IN HTTPS 1 . alpn="h2,h3"
203
+ ech IN HTTPS 1 . ech="AEX+DQBBzQAgACD/AA==" port=8443
204
+ ZONEDATA
205
+
206
+ def test_zone_quoted_svcparam_values
207
+ reader = Dnsruby::ZoneReader.new('example.com.')
208
+ alpn, ech = reader.process_file(StringIO.new(QUOTED_SVCB_ZONE))
209
+
210
+ assert_equal('h2,h3', alpn.params_to_hash['alpn'])
211
+
212
+ assert_equal('AEX+DQBBzQAgACD/AA==', ech.params_to_hash['ech'])
213
+ assert_equal('8443', ech.params_to_hash['port'])
214
+ end
215
+
216
+ # A quoted SvcParamValue inside the parentheses that group a record across
217
+ # lines. The quote opens mid-token, so the closing parenthesis travels with
218
+ # the quoted tail rather than with the rest of the line. An ech value is what
219
+ # gets wrapped in practice, being long enough to want a second line.
220
+ PAREN_QUOTED_SVCB_ZONE = <<~ZONEDATA
221
+ $TTL 3600
222
+ one IN HTTPS ( 1 . alpn="h2,h3" )
223
+ two IN HTTPS ( 1 . ech="AEX+DQBBzQAgACD/AA=="
224
+ port=8443 )
225
+ ZONEDATA
226
+
227
+ def test_zone_parenthesised_quoted_svcparam_values
228
+ reader = Dnsruby::ZoneReader.new('example.com.')
229
+ one_line, wrapped = reader.process_file(StringIO.new(PAREN_QUOTED_SVCB_ZONE))
230
+
231
+ assert_equal('h2,h3', one_line.params_to_hash['alpn'])
232
+
233
+ assert_equal('AEX+DQBBzQAgACD/AA==', wrapped.params_to_hash['ech'])
234
+ assert_equal('8443', wrapped.params_to_hash['port'])
235
+ end
236
+
237
+ def test_zone_literal_parentheses_do_not_continue_a_record
238
+ zone = <<~'ZONE'
239
+ quoted IN HTTPS 1 . key667="a(b)c"
240
+ escaped IN SVCB 1 . key667=a\(b\)c
241
+ decimal IN HTTPS 1 . key667=a\040b\041c
242
+ open IN HTTPS 1 . key667="a\("
243
+ close IN SVCB 1 . key667=a\)
244
+ quote IN HTTPS 1 . key667="a\"(b)c"
245
+ following IN A 192.0.2.1
246
+ ZONE
247
+ records = @reader.process_file(StringIO.new(zone))
248
+ assert_equal(7, records.size)
249
+ assert_equal(['a(b)c', 'a(b)c', 'a(b)c', 'a(', 'a)', 'a"(b)c'],
250
+ records.first(6).map { |rr| rr.params[667] })
251
+ assert_equal('192.0.2.1', records.last.address.to_s)
252
+ end
253
+
254
+ def test_zone_grouping_ignores_parentheses_in_values_and_comments
255
+ zone = <<~'ZONE'
256
+ wrapped IN HTTPS (1 . key667="a;(b)c" ; unmatched ) in comment
257
+ key668=a\) ; unmatched ( in comment
258
+ port=443) ; ignored (
259
+ following IN A 192.0.2.1
260
+ ZONE
261
+ wrapped, following = @reader.process_file(StringIO.new(zone))
262
+ assert_equal('a;(b)c', wrapped.params[667])
263
+ assert_equal('a)', wrapped.params[668])
264
+ assert_equal('443', wrapped.params_to_hash['port'])
265
+ assert_equal('192.0.2.1', following.address.to_s)
266
+ end
267
+
268
+ def test_normalise_line_handles_grouping_when_called_directly
269
+ line = @reader.normalise_line('svc IN HTTPS (1 . key667="a\(b\)c")')
270
+ rr = RR.create(line)
271
+ assert_equal('svc.example.com.', rr.name.to_s(true))
272
+ assert_equal('a(b)c', rr.params[667])
273
+ end
274
+
275
+ def test_normalise_line_preserves_standalone_unmatched_grouping
276
+ ['host IN A (192.0.2.7', 'host IN A 192.0.2.7)'].each do |input|
277
+ rr = RR.create(@reader.normalise_line(input))
278
+ assert_equal('host.example.com.', rr.name.to_s(true))
279
+ assert_equal('192.0.2.7', rr.address.to_s)
280
+ end
281
+ end
282
+
283
+ def test_unbalanced_grouping_is_rejected
284
+ assert_raises(Dnsruby::ZoneReader::ParseException) do
285
+ @reader.process_file(StringIO.new('bad IN HTTPS 1 .)'))
286
+ end
287
+ assert_raises(Dnsruby::ZoneReader::ParseException) do
288
+ @reader.process_file(StringIO.new('bad IN HTTPS (1 .'))
289
+ end
290
+ end
291
+ end