dnsruby 1.74.0 → 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 +302 -1
  7. data/Rakefile +0 -3
  8. data/demo/axfr.rb +24 -6
  9. data/demo/rubydig.rb +0 -2
  10. data/dnsruby.gemspec +5 -4
  11. data/lib/dnsruby/cache.rb +279 -47
  12. data/lib/dnsruby/code_mapper.rb +35 -8
  13. data/lib/dnsruby/code_mappers.rb +63 -49
  14. data/lib/dnsruby/config.rb +75 -4
  15. data/lib/dnsruby/deadline_queue.rb +75 -0
  16. data/lib/dnsruby/dnssec.rb +148 -45
  17. data/lib/dnsruby/ipv6.rb +45 -5
  18. data/lib/dnsruby/key_cache.rb +202 -35
  19. data/lib/dnsruby/message/decoder.rb +85 -19
  20. data/lib/dnsruby/message/encoder.rb +27 -16
  21. data/lib/dnsruby/message/header.rb +8 -12
  22. data/lib/dnsruby/message/message.rb +53 -22
  23. data/lib/dnsruby/message/section.rb +12 -34
  24. data/lib/dnsruby/name.rb +5 -2
  25. data/lib/dnsruby/packet_sender.rb +645 -203
  26. data/lib/dnsruby/presentation.rb +147 -0
  27. data/lib/dnsruby/query_control.rb +132 -0
  28. data/lib/dnsruby/recursor.rb +795 -339
  29. data/lib/dnsruby/resolver.rb +468 -186
  30. data/lib/dnsruby/resource/CAA.rb +67 -16
  31. data/lib/dnsruby/resource/DLV.rb +6 -0
  32. data/lib/dnsruby/resource/DNSKEY.rb +108 -16
  33. data/lib/dnsruby/resource/DS.rb +27 -28
  34. data/lib/dnsruby/resource/HTTPS.rb +16 -0
  35. data/lib/dnsruby/resource/IN.rb +19 -0
  36. data/lib/dnsruby/resource/MX.rb +2 -2
  37. data/lib/dnsruby/resource/NSEC.rb +6 -10
  38. data/lib/dnsruby/resource/NSEC3.rb +55 -19
  39. data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
  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 +979 -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 +42 -20
  122. data/test/tc_res_opt.rb +21 -1
  123. data/test/tc_resolver.rb +21 -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 +105 -10
  167. data/.coveralls.yml +0 -2
@@ -0,0 +1,212 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+
4
+ class ReleaseRecursorCacheTest < Minitest::Test
5
+ def setup
6
+ @klass = Dnsruby::Recursor
7
+ @original = %i[@@zones_cache @@zone_expirations @@authority_cache @@hints].to_h do |name|
8
+ [name, @klass.class_variable_get(name)]
9
+ end
10
+ @root = {'ns.root.' => @klass::AddressCache.new}
11
+ @root.fetch('ns.root.').push(['192.0.2.53', 300])
12
+ @klass.class_variable_set(:@@zones_cache, {'.' => @root})
13
+ @klass.class_variable_set(:@@hints, @root)
14
+ @klass.class_variable_set(:@@zone_expirations, {})
15
+ @klass.class_variable_set(:@@authority_cache, {})
16
+ @recursor = @klass.allocate
17
+ @now = Time.utc(2026, 11, 14, 12)
18
+ end
19
+
20
+ def teardown
21
+ @original.each { |name, value| @klass.class_variable_set(name, value) }
22
+ end
23
+
24
+ def test_delegation_expires_before_longer_lived_glue
25
+ addresses = @klass::AddressCache.new
26
+ Time.stub(:now, @now) do
27
+ addresses.push(['192.0.2.53', 300])
28
+ @klass.cache_zone('example.', {'ns.example.' => addresses}, 30)
29
+ assert_equal('example.', @recursor.get_closest_known_zone_for('WWW.EXAMPLE.'))
30
+ end
31
+ Time.stub(:now, @now + 30) do
32
+ assert_equal(['192.0.2.53'], addresses.values)
33
+ assert_equal('.', @recursor.get_closest_known_zone_for('www.example.'))
34
+ end
35
+ end
36
+
37
+ def test_working_authority_map_is_separate_from_shared_mapping
38
+ @klass.cache_zone('example.', {'ns.example.' => @klass::AddressCache.new}, 30)
39
+ _zone, working = @recursor.get_closest_known_zone_authorities_for('www.example.')
40
+ working.delete('ns.example.')
41
+ assert_equal(['ns.example.'], @klass.class_variable_get(:@@zones_cache).fetch('example.').keys)
42
+ end
43
+
44
+ def test_empty_working_map_cannot_evict_a_newer_delegation
45
+ replacement = {'ns.example.' => @klass::AddressCache.new}
46
+ replacement.fetch('ns.example.').push(['192.0.2.54', 60])
47
+ working = {}
48
+ klass = @klass
49
+ working.define_singleton_method(:values) do
50
+ klass.cache_zone('example.', replacement, 60)
51
+ []
52
+ end
53
+ @klass.cache_zone('example.', {}, 30)
54
+ published = @klass.class_variable_get(:@@zones_cache).fetch('example.')
55
+ published.define_singleton_method(:dup) { working }
56
+
57
+ zone, authorities = @recursor.get_closest_known_zone_authorities_for('www.example.')
58
+ assert_equal('example.', zone)
59
+ assert_equal(replacement.keys, authorities.keys)
60
+ assert_equal(['192.0.2.54'], authorities.fetch('ns.example.').values)
61
+ latest = @klass.class_variable_get(:@@zones_cache).fetch('example.')
62
+ refute_same(replacement, latest)
63
+ refute_same(latest, authorities)
64
+ assert_same(latest, @klass.class_variable_get(:@@zone_expirations)['example.'].first)
65
+ end
66
+
67
+ def test_zero_ttl_delegation_is_not_retained
68
+ @klass.cache_zone('example.', {'ns.example.' => @klass::AddressCache.new}, 0)
69
+ assert_equal('.', @recursor.get_closest_known_zone_for('www.example.'))
70
+ end
71
+
72
+ def test_address_cache_length_excludes_expired_addresses
73
+ addresses = @klass::AddressCache.new
74
+ Time.stub(:now, @now) { addresses.push(['192.0.2.53', 5]) }
75
+ Time.stub(:now, @now + 5) { assert_equal(0, addresses.length) }
76
+ end
77
+
78
+ def test_fresh_zero_ttl_delegation_invalidates_an_older_mapping
79
+ @klass.cache_zone('example.', {'ns.example.' => @klass::AddressCache.new}, 60)
80
+ @klass.cache_zone('example.', {'other.example.' => @klass::AddressCache.new}, 0)
81
+ assert_equal('.', @recursor.get_closest_known_zone_for('www.example.'))
82
+ end
83
+
84
+ def test_top_level_recursor_caches_nil_failure_and_honors_do_caching
85
+ resolver = Dnsruby::Resolver.new(nameserver: ['192.0.2.53'])
86
+ recursor = @klass.new(resolver)
87
+ count = 0
88
+ recursor.define_singleton_method(:_dorecursion) { |*_args| count += 1; nil }
89
+ 2.times { assert_nil(recursor.query('missing.example.', 'A', 'IN', true)) }
90
+ assert_equal(1, count)
91
+ resolver.do_caching = false
92
+ assert_nil(recursor.query('missing.example.', 'A', 'IN', true))
93
+ assert_equal(2, count)
94
+ ensure
95
+ resolver&.close
96
+ end
97
+
98
+ def test_increasing_recursion_limits_does_not_reuse_a_smaller_limits_failure
99
+ resolver = Dnsruby::Resolver.new(nameserver: ['192.0.2.53'])
100
+ recursor = @klass.new(resolver)
101
+ count = 0
102
+ recursor.define_singleton_method(:_dorecursion) do |*_args|
103
+ count += 1
104
+ 2.times { Dnsruby::Recursor.send(:current_budget).step! }
105
+ nil
106
+ end
107
+ recursor.recursion_limits = {max_steps: 1}
108
+ 2.times { assert_raises(Dnsruby::RecursionLimitError) { recursor.query('example.', 'A', 'IN', true) } }
109
+ assert_equal(2, count, 'Shared recursion budget exhaustion must not cache a question failure')
110
+ recursor.recursion_limits = {max_steps: 2}
111
+ assert_nil(recursor.query('example.', 'A', 'IN', true))
112
+ assert_equal(3, count)
113
+ ensure
114
+ resolver&.close
115
+ end
116
+
117
+ def test_cached_completion_honors_cancellation_and_deadlines
118
+ resolver = Dnsruby::Resolver.new(nameserver: ['192.0.2.53'])
119
+ recursor = @klass.new(resolver)
120
+ count = 0
121
+ recursor.define_singleton_method(:_dorecursion) { |*_args| count += 1; nil }
122
+ recursor.query('missing.example.', 'A', 'IN', true)
123
+ control = Dnsruby::QueryControl.new
124
+ control.cancel
125
+ Dnsruby::QueryControl.stub(:current, control) do
126
+ assert_raises(Dnsruby::QueryControl::Cancelled) do
127
+ recursor.query('missing.example.', 'A', 'IN', true)
128
+ end
129
+ end
130
+ expired = Dnsruby::QueryControl.new(deadline: Dnsruby::TransportClock.now - 1)
131
+ Dnsruby::QueryControl.stub(:current, expired) do
132
+ assert_raises(Dnsruby::QueryControl::DeadlineExceeded) do
133
+ recursor.query('missing.example.', 'A', 'IN', true)
134
+ end
135
+ end
136
+ assert_equal(1, count)
137
+ ensure
138
+ resolver&.close
139
+ end
140
+
141
+ def test_top_level_recursor_caches_negative_answers_and_scopes_validation_policy
142
+ resolver = Dnsruby::Resolver.new(nameserver: ['192.0.2.53'])
143
+ recursor = @klass.new(resolver)
144
+ response = Dnsruby::Message.new('missing.example.', 'A')
145
+ response.header.rcode = Dnsruby::RCode::NXDOMAIN
146
+ response.add_authority(Dnsruby::RR.create('example. 60 IN SOA ns.example. hostmaster.example. 1 60 60 60 30'))
147
+ count = 0
148
+ recursor.define_singleton_method(:_dorecursion) { |*_args| count += 1; response }
149
+ refute(recursor.query('missing.example.', 'A', 'IN', true).cached)
150
+ assert(recursor.query('missing.example.', 'A', 'IN', true).cached)
151
+ assert_equal(1, count)
152
+ recursor.dnssec = true
153
+ recursor.query('missing.example.', 'A', 'IN', true)
154
+ assert_equal(2, count)
155
+ ensure
156
+ resolver&.close
157
+ end
158
+
159
+ def test_top_level_nil_failure_does_not_attempt_dnssec_validation
160
+ resolver = Dnsruby::Resolver.new(nameserver: ['192.0.2.53'])
161
+ recursor = @klass.new(resolver)
162
+ count = 0
163
+ recursor.define_singleton_method(:_dorecursion) { |*_args| count += 1; nil }
164
+ Dnsruby::Dnssec.stub(:validate, ->(*) { flunk('There is no response to validate') }) do
165
+ 2.times { assert_nil(recursor.query('missing.example.')) }
166
+ end
167
+ assert_equal(1, count)
168
+ ensure
169
+ resolver&.close
170
+ end
171
+
172
+ def test_top_level_name_error_exception_keeps_its_exception_convention
173
+ resolver = Dnsruby::Resolver.new(nameserver: ['192.0.2.53'])
174
+ recursor = @klass.new(resolver)
175
+ response = Dnsruby::Message.new('missing.example.', 'A')
176
+ response.header.rcode = Dnsruby::RCode::NXDOMAIN
177
+ response.add_authority(Dnsruby::RR.create('example. 60 IN SOA ns.example. hostmaster.example. 1 60 60 60 30'))
178
+ count = 0
179
+ recursor.define_singleton_method(:_dorecursion) do |*_args|
180
+ count += 1
181
+ error = Dnsruby::NXDomain.new('missing')
182
+ error.response = response
183
+ raise error
184
+ end
185
+ 2.times do
186
+ error = assert_raises(Dnsruby::NXDomain) { recursor.query('missing.example.', 'A', 'IN', true) }
187
+ assert_equal(Dnsruby::RCode::NXDOMAIN, error.response.rcode)
188
+ end
189
+ assert_equal(1, count)
190
+ ensure
191
+ resolver&.close
192
+ end
193
+
194
+ def test_outer_maps_and_address_lists_have_capacity_bounds
195
+ # Exercise bounded insertion with a small temporary capacity, not a load test.
196
+ old_limit = @klass::CACHE_LIMIT
197
+ @klass.send(:remove_const, :CACHE_LIMIT)
198
+ @klass.const_set(:CACHE_LIMIT, 3)
199
+ 4.times do |index|
200
+ addresses = @klass::AddressCache.new
201
+ @klass.cache_authority("ns#{index}.example.", addresses)
202
+ @klass.cache_zone("zone#{index}.example.", {"ns#{index}.example." => addresses}, 30)
203
+ end
204
+ assert_equal(3, @klass.class_variable_get(:@@authority_cache).length)
205
+ zones = @klass.class_variable_get(:@@zones_cache)
206
+ assert_equal(3, zones.length)
207
+ assert_same(@root, zones['.'])
208
+ ensure
209
+ @klass.send(:remove_const, :CACHE_LIMIT)
210
+ @klass.const_set(:CACHE_LIMIT, old_limit)
211
+ end
212
+ end
@@ -0,0 +1,372 @@
1
+ require_relative 'tc_pipeline_send'
2
+
3
+ class ReleaseReviewTransportTest < Minitest::Test
4
+ def test_malformed_tsig_responses_preserve_the_attempt_until_an_authenticated_answer
5
+ isolated(<<~'RUBY')
6
+ [false, true].product([:short_header, :bad_rdata, :truncated]).each do |tcp, malformed|
7
+ socket = FrameSocket.new
8
+ unless tcp
9
+ socket.define_singleton_method(:getsockopt) { |*| Struct.new(:data).new([Socket::SOCK_DGRAM].pack('i')) }
10
+ socket.define_singleton_method(:recvfrom_nonblock) do |*|
11
+ [incoming, ['AF_INET', 53, '127.0.0.1', '127.0.0.1']]
12
+ end
13
+ end
14
+ writer = sender(socket)
15
+ writer.define_singleton_method(:send_async) { |*| raise 'unauthenticated TC triggered a retry' }
16
+ request = query
17
+ key = Dnsruby::RR.create(name: 'key.example.', type: 'TSIG', klass: 'ANY',
18
+ key: 'c2VjcmV0', algorithm: 'hmac-sha256')
19
+ key.apply(request)
20
+ live = settings(socket, [tcp, malformed], request, writer)
21
+ live.is_persistent_socket = tcp
22
+ SELECTOR.add_to_select(live)
23
+ original_deadline = live.endtime
24
+ reply = query
25
+ reply.header.id, reply.header.qr = request.header.id, true
26
+ reply.header.tc = true if malformed == :truncated
27
+ bytes = if malformed == :short_header
28
+ [request.header.id].pack('n')
29
+ else
30
+ reply.encode.tap { |wire| wire.setbyte(7, 1) } # Claims a missing answer RR.
31
+ end
32
+ socket.incoming = tcp ? [bytes.bytesize].pack('n') + bytes : bytes
33
+ raise 'malformed response was returned' if SELECTOR.get_incoming_data(socket, 512)
34
+ raise 'malformed reply consumed the query' unless SELECTOR.query_settings_for_id(live.client_query_id).equal?(live)
35
+ raise 'malformed reply completed the query' unless live.client_queue.empty?
36
+ raise 'malformed reply extended the deadline' unless live.endtime == original_deadline
37
+ reply.header.tc = false
38
+ key.apply(reply, request)
39
+ bytes = reply.encode
40
+ socket.incoming = tcp ? [bytes.bytesize].pack('n') + bytes : bytes
41
+ decoded = SELECTOR.get_incoming_data(socket, 512)
42
+ SELECTOR.process_message(*decoded, socket)
43
+ SELECTOR.send_queued_validation_responses
44
+ raise 'valid signed reply lost' unless live.client_queue.size == 2 &&
45
+ 2.times.map { live.client_queue.pop }.all? { |event| event.last.nil? && event[2].verified? }
46
+ SELECTOR.retire_sender(writer)
47
+ assert_clean(socket)
48
+ end
49
+ RUBY
50
+ end
51
+
52
+ def test_tcp_eof_keeps_only_configured_retries_and_the_original_query_deadline
53
+ isolated(<<~'RUBY')
54
+ SELECTOR.singleton_class.remove_method(:notify_queue_observers)
55
+ [false, true].product([:success, :failure, :deadline, :update]).each do |pipeline, outcome|
56
+ sockets = []
57
+ Dnsruby::PacketSender.define_method(:connect_tcp) do |*|
58
+ FrameSocket.new.tap do |socket|
59
+ socket.define_singleton_method(:recv_nonblock) { |*| ''.b }
60
+ sockets << socket
61
+ end
62
+ end
63
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', dnssec: false,
64
+ use_tcp: true, tcp_pipelining: pipeline, packet_timeout: 30,
65
+ query_timeout: 30, retry_times: 2, retry_delay: 10)
66
+ request, completion = query, Queue.new
67
+ if outcome == :update
68
+ request = Dnsruby::Update.new('example.')
69
+ request.do_validation = request.do_caching = false
70
+ end
71
+ resolver.send_async(request, completion, :eof)
72
+ engine = resolver.instance_variable_get(:@resolver_ruby)
73
+ plan = engine.instance_variable_get(:@timeouts)[:eof]
74
+ deadline = plan.first
75
+ raise 'initial attempt missing' unless sockets.size == 1
76
+ SELECTOR.get_incoming_data(sockets.first, 0)
77
+ if outcome == :update
78
+ raise 'UPDATE did not report ambiguous EOF' unless completion.size == 1 &&
79
+ completion.pop.last.is_a?(Dnsruby::SocketEofResolvError)
80
+ engine.tick
81
+ raise 'UPDATE was replayed' unless sockets.size == 1 && engine.instance_variable_get(:@timeouts).empty?
82
+ resolver.close
83
+ assert_clean(sockets.first)
84
+ next
85
+ end
86
+ raise 'EOF completed a retryable query' unless completion.empty? && plan[1].size == 1
87
+ raise 'EOF changed the deadline' unless plan.first == deadline
88
+ if outcome == :deadline
89
+ plan[0] = Dnsruby::TransportClock.now - 1
90
+ engine.send(:update_tick_deadline, :eof)
91
+ engine.tick
92
+ raise 'expired query reconnected' unless sockets.size == 1
93
+ raise 'wrong deadline completion' unless completion.size == 1 && completion.pop.last.is_a?(Dnsruby::ResolvTimeout)
94
+ else
95
+ retry_value = plan[1].values.first
96
+ plan[1].clear
97
+ plan[1][Dnsruby::TransportClock.now - 1] = retry_value
98
+ engine.send(:update_tick_deadline, :eof)
99
+ engine.tick
100
+ raise 'retry did not reconnect' unless sockets.size == 2 && !sockets.last.equal?(sockets.first)
101
+ raise 'retry changed logical deadline' unless plan.first == deadline
102
+ if outcome == :success
103
+ reply = request.clone
104
+ reply.header.qr = true
105
+ SELECTOR.process_message(reply, reply.encode, sockets.last)
106
+ SELECTOR.send_queued_validation_responses
107
+ raise 'retry answer lost' unless completion.size == 1 && completion.pop.last.nil?
108
+ else
109
+ SELECTOR.get_incoming_data(sockets.last, 0)
110
+ raise 'exhausted EOF query did not finish' unless completion.size == 1 &&
111
+ completion.pop.last.is_a?(Dnsruby::SocketEofResolvError)
112
+ end
113
+ end
114
+ engine.tick
115
+ resolver.close
116
+ raise 'retry plan retained after completion' unless engine.instance_variable_get(:@timeouts).empty?
117
+ raise 'completion repeated' unless completion.empty?
118
+ sockets.each { |socket| assert_clean(socket) }
119
+ end
120
+ RUBY
121
+ end
122
+
123
+ def test_ambiguous_update_eof_cancels_other_servers_and_outstanding_attempts
124
+ isolated(<<~'RUBY')
125
+ SELECTOR.singleton_class.remove_method(:notify_queue_observers)
126
+ [false, true].product([false, true]).each do |pipeline, other_already_sent|
127
+ sockets = []
128
+ Dnsruby::PacketSender.define_method(:connect_tcp) do |*|
129
+ FrameSocket.new.tap do |socket|
130
+ socket.define_singleton_method(:recv_nonblock) { |*| ''.b }
131
+ sockets << [server, socket]
132
+ end
133
+ end
134
+ resolver = Dnsruby::Resolver.new(nameserver: ['127.0.0.1', '127.0.0.2'],
135
+ dnssec: false, use_tcp: true, tcp_pipelining: pipeline,
136
+ packet_timeout: 30, query_timeout: 30, retry_times: 2, retry_delay: 10)
137
+ request, completion = Dnsruby::Update.new('example.'), Queue.new
138
+ request.do_validation = request.do_caching = false
139
+ resolver.send_async(request, completion, :update)
140
+ engine = resolver.instance_variable_get(:@resolver_ruby)
141
+ plan = engine.instance_variable_get(:@timeouts)[:update]
142
+ raise 'initial UPDATE missing' unless sockets.map(&:first) == ['127.0.0.1']
143
+ if other_already_sent
144
+ scheduled = plan[1].keys.min
145
+ next_attempt = plan[1].delete(scheduled)
146
+ raise 'unexpected retry ordering' unless next_attempt.first.server == '127.0.0.2'
147
+ plan[1][Dnsruby::TransportClock.now - 1] = next_attempt
148
+ plan[1].replace(plan[1].sort.to_h)
149
+ engine.send(:update_tick_deadline, :update)
150
+ engine.tick
151
+ raise 'second attempt missing' unless sockets.map(&:first) == ['127.0.0.1', '127.0.0.2']
152
+ end
153
+ SELECTOR.get_incoming_data(sockets.first.last, 0)
154
+ raise 'ambiguous UPDATE not terminal' unless completion.size == 1 &&
155
+ completion.pop.last.is_a?(Dnsruby::SocketEofResolvError)
156
+ raise 'UPDATE retries retained' unless engine.instance_variable_get(:@timeouts).empty?
157
+ raise 'UPDATE outstanding ownership retained' unless K.class_variable_get(:@@query_hash).empty?
158
+ engine.tick
159
+ expected_sends = other_already_sent ? 2 : 1
160
+ raise 'UPDATE replayed after ambiguous EOF' unless sockets.length == expected_sends
161
+ resolver.close
162
+ raise 'duplicate UPDATE completion' unless completion.empty?
163
+ sockets.each { |_, socket| assert_clean(socket) }
164
+ end
165
+ RUBY
166
+ end
167
+
168
+ def test_sender_retirement_uses_indexes_and_cleans_pending_active_and_idle_ownership
169
+ isolated(<<~'RUBY')
170
+ senders, attempts, sockets = [], [], []
171
+ 12.times do |index|
172
+ active, idle = FrameSocket.new, FrameSocket.new
173
+ writer = sender(active)
174
+ senders << writer
175
+ sockets.concat([active, idle])
176
+ live = settings(active, [index, :active], query, writer)
177
+ unused = settings(idle, [index, :idle], query, writer)
178
+ pending = settings(nil, [index, :pending], query, writer)
179
+ SELECTOR.add_to_select(live)
180
+ SELECTOR.add_to_select(unused)
181
+ SELECTOR.claim_query(unused, true)
182
+ SELECTOR.reserve_send(pending)
183
+ attempts << [live, pending]
184
+ end
185
+ # A sender close must never enumerate other senders' global tables.
186
+ [K.class_variable_get(:@@query_hash), K.class_variable_get(:@@socket_owners)].each do |table|
187
+ [:each, :each_value, :select].each do |method|
188
+ table.define_singleton_method(method) { |*| raise 'retirement scanned a global index' }
189
+ end
190
+ end
191
+ SELECTOR.retire_sender(senders.first)
192
+ raise 'active or pending reservation not retired' unless attempts.first.all?(&:retire_connection)
193
+ raise 'another sender was retired' unless attempts.drop(1).flatten.none?(&:retire_connection)
194
+ raise 'idle stream retained' unless sockets[1].closed?
195
+ raise 'active stream closed before draining' if sockets[0].closed?
196
+ attempts.first.each { |attempt| SELECTOR.remove_id(attempt.client_query_id) }
197
+ [:queries_by_sender, :sockets_by_sender].each do |name|
198
+ raise 'retired sender retained' if K.class_variable_get("@@#{name}").key?(senders.first)
199
+ end
200
+ senders.drop(1).each { |writer| SELECTOR.retire_sender(writer) }
201
+ attempts.drop(1).flatten.each { |attempt| SELECTOR.remove_id(attempt.client_query_id) }
202
+ [:queries_by_sender, :sockets_by_sender].each do |name|
203
+ raise 'sender index leaked' unless K.class_variable_get("@@#{name}").empty?
204
+ end
205
+ sockets.each { |socket| assert_clean(socket) }
206
+
207
+ # Re-registering a reusable stream under a different helper sender must
208
+ # replace its owner instead of leaving a stale retirement entry.
209
+ shared = FrameSocket.new
210
+ first, second = sender(shared), sender(shared)
211
+ old = settings(shared, :old_owner, query, first)
212
+ SELECTOR.add_to_select(old)
213
+ SELECTOR.claim_query(old, true)
214
+ current = settings(shared, :new_owner, query, second)
215
+ SELECTOR.add_to_select(current)
216
+ raise 'old socket owner retained' if K.class_variable_get(:@@sockets_by_sender).key?(first)
217
+ SELECTOR.retire_sender(first)
218
+ raise 'old owner retired reassigned socket' if K.class_variable_get(:@@retiring_sockets).include?(shared)
219
+ SELECTOR.retire_sender(second)
220
+ SELECTOR.remove_id(:new_owner)
221
+ assert_clean(shared)
222
+ raise 'reassigned sender retained' unless K.class_variable_get(:@@sockets_by_sender).empty?
223
+ RUBY
224
+ end
225
+
226
+ def test_validation_registration_obeys_queue_and_sender_cancellation_without_leaking
227
+ isolated(<<~'RUBY')
228
+ budget_class = Class.new do
229
+ attr_accessor :cancelled
230
+ def initialize(cancelled); @cancelled = cancelled; end
231
+ def cancel; @cancelled = true; end
232
+ end
233
+ queue, observer, writer = Queue.new, Object.new, sender(FrameSocket.new)
234
+ other_queue, other_writer = Queue.new, sender(FrameSocket.new)
235
+ other_budget = budget_class.new(false)
236
+ raise 'unrelated validation rejected' unless SELECTOR.register_validation(other_queue, other_writer, other_budget)
237
+ SELECTOR.add_observer(queue, observer)
238
+ active = budget_class.new(false)
239
+ raise 'active validation rejected' unless SELECTOR.register_validation(queue, writer, active)
240
+ SELECTOR.register_validation(queue, writer, active)
241
+ raise 'duplicate registration retained' unless K.class_variable_get(:@@validations).length == 2
242
+ SELECTOR.remove_observer(queue, observer)
243
+ raise 'finished query did not cancel work' unless active.cancelled
244
+ raise 'queue completion cancelled unrelated work' if other_budget.cancelled
245
+ late = budget_class.new(false)
246
+ raise 'completed queue started late validation' if SELECTOR.register_validation(queue, writer, late)
247
+ raise 'late work not cancelled' unless late.cancelled
248
+ SELECTOR.unregister_validation(queue, writer, active)
249
+
250
+ # Direct PacketSender queues have no observer and remain supported. A
251
+ # shared budget may be registered with more than one nested query.
252
+ first, second, shared = Queue.new, Queue.new, budget_class.new(false)
253
+ [first, second].each do |direct|
254
+ raise 'direct validation rejected' unless SELECTOR.register_validation(direct, writer, shared)
255
+ end
256
+ SELECTOR.unregister_validation(first, writer, shared)
257
+ SELECTOR.retire_sender(writer)
258
+ raise 'shared remaining validation escaped retirement' unless shared.cancelled
259
+ raise 'sender retirement cancelled unrelated work' if other_budget.cancelled
260
+ SELECTOR.unregister_validation(second, writer, shared)
261
+ SELECTOR.retire_sender(other_writer)
262
+ raise 'unrelated sender lost its registration' unless other_budget.cancelled
263
+ SELECTOR.unregister_validation(other_queue, other_writer, other_budget)
264
+ raise 'validation registration leaked' unless K.class_variable_get(:@@validations).empty?
265
+ RUBY
266
+ end
267
+
268
+ def test_sender_retirement_preserves_resolver_validation_but_owner_close_cancels_it
269
+ isolated(<<~'RUBY')
270
+ SELECTOR.singleton_class.remove_method(:notify_queue_observers)
271
+ [:reconfigure, :other_close, :owner_close].each do |action|
272
+ socket = FrameSocket.new
273
+ writer = sender(socket)
274
+ writer.use_tcp = writer.dnssec = true
275
+ first, second = 2.times.map do
276
+ Dnsruby::Resolver.new(nameserver: '127.0.0.1', dnssec: true, use_tcp: true, do_validation: true)
277
+ end
278
+ [first, second].each { |resolver| resolver.single_resolvers = [writer] }
279
+ entered, release = Queue.new, Queue.new
280
+ Dnsruby::Dnssec.define_singleton_method(:validate_with_query) do |_query, answer|
281
+ entered << true
282
+ release.pop
283
+ answer.security_level = Dnsruby::Message::SecurityLevel::SECURE
284
+ end
285
+ request = query('validating.example.')
286
+ request.do_validation = true
287
+ results = Queue.new
288
+ second.send_async(request, results, :client)
289
+ answer = request.clone
290
+ answer.header.qr = true
291
+ SELECTOR.process_message(answer, answer.encode, socket)
292
+ entered.pop
293
+ case action
294
+ when :reconfigure then second.nameserver = '127.0.0.2'
295
+ when :other_close then first.close
296
+ when :owner_close then second.close
297
+ end
298
+ release << true
299
+ sleep 0.001 until K.class_variable_get(:@@validations).empty?
300
+ SELECTOR.send_queued_validation_responses
301
+ raise "#{action}: missing or duplicate completion" unless results.size == 1
302
+ _id, response, error = results.pop
303
+ if action == :owner_close
304
+ raise 'owner close did not cancel its query' unless error.is_a?(Dnsruby::OtherResolvError)
305
+ else
306
+ raise "#{action}: accepted validation was cancelled: #{error.inspect}" unless
307
+ error.nil? && response.security_level == Dnsruby::Message::SecurityLevel::SECURE
308
+ end
309
+ first.close
310
+ second.close
311
+ SELECTOR.send_queued_validation_responses
312
+ raise 'late validation completed twice' unless results.empty?
313
+ raise 'validation registration leaked' unless K.class_variable_get(:@@validations).empty?
314
+ end
315
+ RUBY
316
+ end
317
+
318
+ def test_fork_discards_parent_validation_registrations_without_losing_child_work
319
+ skip 'fork unavailable' unless Process.respond_to?(:fork)
320
+ isolated(<<~'RUBY')
321
+ entered, release = Queue.new, Queue.new
322
+ request = query
323
+ request.do_validation = true
324
+ response = request.clone
325
+ response.header.qr = true
326
+ writer = sender(FrameSocket.new)
327
+ writer.dnssec = true
328
+ parent_queue = Queue.new
329
+ parent = Dnsruby::ValidatorThread.new(:parent, parent_queue, response, nil, request, SELECTOR, writer)
330
+ parent.define_singleton_method(:validate) { |*| entered << true; release.pop; true }
331
+ parent.run
332
+ Timeout.timeout(3) { entered.pop }
333
+ [false, true].each do |retire_first|
334
+ child_status = ForkTestHelper.run do
335
+ SELECTOR.retire_sender(writer) if retire_first
336
+ child_entered, child_release = Queue.new, Queue.new
337
+ child_queue = Queue.new
338
+ child = Dnsruby::ValidatorThread.new(:child, child_queue, response.clone, nil, request, SELECTOR, writer)
339
+ child.define_singleton_method(:validate) { |*| child_entered << true; child_release.pop; true }
340
+ child.run
341
+ Timeout.timeout(3) { child_entered.pop }
342
+ registrations = K.class_variable_get(:@@validations)
343
+ raise 'parent validation retained after fork' if registrations.any? { |queue, _, _| queue.equal?(parent_queue) }
344
+ raise 'child registration lost during pool restart' unless registrations.length == 1 &&
345
+ registrations.first[0].equal?(child_queue) && registrations.first[1].equal?(writer)
346
+ child_release << true
347
+ Timeout.timeout(3) do
348
+ sleep(0.001) until K.class_variable_get(:@@validations).empty?
349
+ end
350
+ exit! 0
351
+ end
352
+ raise 'child registration lifecycle failed' unless child_status.success?
353
+ raise 'child cleared parent registration' unless K.class_variable_get(:@@validations).any? { |queue, _, _| queue.equal?(parent_queue) }
354
+ end
355
+ release << true
356
+ Timeout.timeout(3) do
357
+ sleep(0.001) until K.class_variable_get(:@@validations).empty?
358
+ end
359
+ RUBY
360
+ end
361
+
362
+ private
363
+
364
+ def isolated(body)
365
+ options = ''.frozen? ? ['--enable-frozen-string-literal'] : []
366
+ source = "require #{File.expand_path('fork_test_helper', __dir__).inspect}\n#{TestPipelineSend::FIXTURE}\nTimeout.timeout(12) do\n#{body}\nend"
367
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, *options, '-I',
368
+ File.expand_path('../lib', __dir__), '-e', source)
369
+ assert(status.success?, "#{stdout}\n#{stderr}")
370
+ assert_empty(stderr)
371
+ end
372
+ end
@@ -0,0 +1,56 @@
1
+ require_relative 'tc_review_transport_native'
2
+
3
+ class ReviewTransportNativeTest < Minitest::Test
4
+ def test_malformed_unsigned_tsig_reply_cannot_preempt_a_valid_signed_answer
5
+ key = tsig_key
6
+ port, requests = udp_server do |request|
7
+ malformed = response(request, 'untrusted').encode
8
+ malformed[10, 2] = [1].pack('n') # Advertise an absent additional RR.
9
+ truncated = malformed.dup
10
+ truncated.setbyte(2, truncated.getbyte(2) | 2)
11
+ signed = response(request, 'authenticated')
12
+ key.apply(signed, request)
13
+ [[request.header.id].pack('n'), malformed, truncated, signed]
14
+ end
15
+ resolver = resolver_for(port, do_caching: false)
16
+ resolver.tsig = key
17
+ reply = query(resolver)
18
+ assert(reply.verified?)
19
+ assert_equal(['authenticated'], reply.answer.first.strings)
20
+ assert_equal(1, requests.size)
21
+ end
22
+
23
+ def test_tcp_connection_loss_reconnects_within_the_configured_retry_plan
24
+ [false, true].product([:success, :exhausted, :deadline]).each do |pipeline, outcome|
25
+ server = TCPServer.new('127.0.0.1', 0)
26
+ @sockets << server
27
+ requests = Queue.new
28
+ worker do
29
+ count = 0
30
+ loop do
31
+ client = server.accept
32
+ begin
33
+ request = read_frame(client)
34
+ requests << request
35
+ count += 1
36
+ write_frame(client, response(request, 'retried')) if count == 2 && outcome == :success
37
+ ensure
38
+ client.close
39
+ end
40
+ end
41
+ end
42
+ resolver = resolver_for(server.addr[1], use_tcp: true, tcp_pipelining: pipeline,
43
+ do_caching: false, retry_times: 2, retry_delay: outcome == :deadline ? 10 : 0.01,
44
+ query_timeout: outcome == :deadline ? 0.15 : 3)
45
+ if outcome == :success
46
+ assert_equal(['retried'], query(resolver).answer.first.strings)
47
+ elsif outcome == :exhausted
48
+ assert_raises(Dnsruby::SocketEofResolvError) { query(resolver) }
49
+ else
50
+ assert_raises(Dnsruby::ResolvTimeout) { query(resolver) }
51
+ end
52
+ assert_equal(outcome == :deadline ? 1 : 2, requests.size)
53
+ resolver.close
54
+ end
55
+ end
56
+ end