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,163 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+ require 'timeout'
4
+
5
+ class ReviewCacheTest < Minitest::Test
6
+ def setup
7
+ @now = Time.utc(2026, 11, 14, 12)
8
+ @cache = Dnsruby::Cache.new
9
+ end
10
+
11
+ def test_wire_snapshot_is_independent_of_source_and_returned_messages
12
+ response = answer
13
+ response.header.aa = true
14
+ response.security_level = Dnsruby::Message::SecurityLevel.SECURE
15
+ Time.stub(:now, @now) do
16
+ @cache.add(response)
17
+ response.answer.first.address = '192.0.2.2'
18
+ found = @cache.find('cache.example.', 'A')
19
+ assert_equal('192.0.2.1', found.answer.first.address.to_s)
20
+ assert(found.cached)
21
+ refute(found.header.aa)
22
+ assert_equal(Dnsruby::Message::SecurityLevel.UNCHECKED, found.security_level)
23
+ found.answer.first.address = '192.0.2.3'
24
+ found.answer.first.ttl = 0
25
+ again = @cache.find('cache.example.', 'A')
26
+ assert_equal('192.0.2.1', again.answer.first.address.to_s)
27
+ assert_equal(300, again.answer.first.ttl)
28
+ end
29
+ end
30
+
31
+ def test_cache_reads_do_not_reencode_messages
32
+ response = answer
33
+ Time.stub(:now, @now) do
34
+ @cache.add(response)
35
+ # The stored form is already wire data. Only the returned copy is decoded.
36
+ encodings = 0
37
+ caller_thread = Thread.current
38
+ trace = TracePoint.new(:call) do |event|
39
+ encodings += 1 if Thread.current == caller_thread &&
40
+ event.defined_class == Dnsruby::Message && event.method_id == :encode
41
+ end
42
+ trace.enable do
43
+ assert_equal('192.0.2.1', @cache.find('cache.example.', 'A').answer.first.address.to_s)
44
+ end
45
+ assert_equal(0, encodings)
46
+ end
47
+ end
48
+
49
+ def test_decoding_does_not_hold_the_cache_mutex
50
+ @cache.add(answer)
51
+ data = @cache.cache.values.first
52
+ original = data.method(:message)
53
+ started, resume = Queue.new, Queue.new
54
+ data.define_singleton_method(:message) do
55
+ started << true
56
+ resume.pop
57
+ original.call
58
+ end
59
+ reader = Thread.new { @cache.find('cache.example.', 'A') }
60
+ Timeout.timeout(5) do
61
+ started.pop
62
+ @cache.add(answer('other.example.'))
63
+ refute_nil(@cache.find('other.example.', 'A'))
64
+ end
65
+ ensure
66
+ resume << true if resume
67
+ reader&.join(5)
68
+ reader&.kill if reader&.alive?
69
+ end
70
+
71
+ def test_authenticated_deadlines_survive_storage_delay_and_reinsertion
72
+ response = answer
73
+ record = response.answer.first
74
+ record.instance_variable_set(:@dnsruby_authenticated_expiration, @now.to_i + 7)
75
+ clean = Dnsruby::RR.create(record.to_s)
76
+ assert_equal(clean, record)
77
+ assert_equal(clean.hash, record.hash)
78
+ Time.stub(:now, @now + 3) { @cache.add(response) }
79
+ Time.stub(:now, @now + 5) do
80
+ found = @cache.find('cache.example.', 'A')
81
+ assert_equal(2, found.answer.first.ttl)
82
+ assert_equal(@now.to_i + 7,
83
+ found.answer.first.instance_variable_get(:@dnsruby_authenticated_expiration))
84
+ @cache.add(found)
85
+ end
86
+ Time.stub(:now, @now + 7) { assert_nil(@cache.find('cache.example.', 'A')) }
87
+ end
88
+
89
+ def test_expiry_during_decode_does_not_remove_a_newer_entry
90
+ clock = @now
91
+ Time.stub(:now, -> { clock }) do
92
+ @cache.add(answer)
93
+ data = @cache.cache.values.first
94
+ original = data.method(:message)
95
+ cache = @cache
96
+ replacement = answer
97
+ data.define_singleton_method(:message) do
98
+ result = original.call
99
+ clock += 300
100
+ cache.add(replacement)
101
+ result
102
+ end
103
+ assert_nil(@cache.find('cache.example.', 'A'))
104
+ assert_equal(300, @cache.find('cache.example.', 'A').answer.first.ttl)
105
+ end
106
+ end
107
+
108
+ def test_decoded_response_ttl_runs_from_receipt_even_when_storage_is_delayed
109
+ response = answer
110
+ response.answer.first.ttl = 10
111
+ decoded = Time.stub(:now, @now) { Dnsruby::Message.decode(response.encode) }
112
+ Time.stub(:now, @now + 4) { @cache.add(decoded) }
113
+ Time.stub(:now, @now + 7) do
114
+ assert_equal(3, @cache.find('cache.example.', 'A').answer.first.ttl)
115
+ end
116
+ Time.stub(:now, @now + 10) { assert_nil(@cache.find('cache.example.', 'A')) }
117
+ end
118
+
119
+ def test_message_and_record_clones_preserve_absolute_lifetime_bounds
120
+ response = Time.stub(:now, @now) { Dnsruby::Message.decode(answer.encode) }
121
+ response.answer.first.instance_variable_set(:@dnsruby_authenticated_expiration, @now.to_i + 7)
122
+ Time.stub(:now, @now + 3) do
123
+ [response.clone.answer.first, response.answer.first.clone].each do |record|
124
+ assert_equal(@now.to_i, record.instance_variable_get(:@dnsruby_received_at))
125
+ assert_equal(@now.to_i + 7, record.instance_variable_get(:@dnsruby_authenticated_expiration))
126
+ end
127
+ @cache.add(response.clone)
128
+ end
129
+ Time.stub(:now, @now + 7) { assert_nil(@cache.find('cache.example.', 'A')) }
130
+ end
131
+
132
+ def test_opt_flags_are_not_aged_as_a_ttl
133
+ response = answer
134
+ opt = Dnsruby::RR::OPT.new(1232, 32768)
135
+ opt.name = '.'
136
+ response.add_additional(opt)
137
+ Time.stub(:now, @now) { @cache.add(response) }
138
+ Time.stub(:now, @now + 5) do
139
+ found = @cache.find('cache.example.', 'A')
140
+ assert_equal(295, found.answer.first.ttl)
141
+ assert_equal(response.get_opt.ttl, found.get_opt.ttl)
142
+ end
143
+ end
144
+
145
+ def test_successful_message_decoding_does_not_generate_a_query_id
146
+ response = answer
147
+ response.header.id = 4321
148
+ wire = response.encode
149
+ SecureRandom.stub(:random_number, ->(*) { flunk('Decoding allocated a random query ID') }) do
150
+ decoded = Dnsruby::Message.decode(wire)
151
+ assert_equal(4321, decoded.header.id)
152
+ assert_equal('192.0.2.1', decoded.answer.first.address.to_s)
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ def answer(name = 'cache.example.')
159
+ Dnsruby::Message.new(name, 'A').tap do |message|
160
+ message.add_answer(Dnsruby::RR.create("#{name} 300 IN A 192.0.2.1"))
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,279 @@
1
+ require_relative 'spec_helper'
2
+ require 'timeout'
3
+
4
+ class TestReviewCodeMapper < Minitest::Test
5
+ def setup
6
+ @mapper = Class.new(Dnsruby::CodeMapper)
7
+ @mapper.const_set(:ONE, 1)
8
+ @mapper.const_set(:TWO, 2)
9
+ @mapper.update
10
+ end
11
+
12
+ def teardown
13
+ Dnsruby::CodeMapper.class_variable_get(:@@arrays).delete(@mapper)
14
+ end
15
+
16
+ def maps
17
+ Dnsruby::CodeMapper.class_variable_get(:@@arrays).fetch(@mapper)
18
+ end
19
+
20
+ def test_new_numeric_mappings_do_not_rebuild_existing_reverse_index
21
+ # Count table traversals instead of depending on machine speed.
22
+ inversions = 0
23
+ maps.strings.define_singleton_method(:invert) do
24
+ inversions += 1
25
+ super()
26
+ end
27
+ 100.times { |i| @mapper.add_pair("TYPE#{i + 1000}", i + 1000) }
28
+ assert_equal(0, inversions)
29
+ assert_equal('TYPE1099', @mapper.new(1099).to_s)
30
+ end
31
+
32
+ def test_unknown_enum_code_is_registered_only_in_its_own_mapping
33
+ original_class_name = Dnsruby::Classes.to_string(254)
34
+ value = @mapper.new(254)
35
+ assert_equal(254, value.code)
36
+ assert_equal('254', value.to_s)
37
+ assert_equal('254', @mapper.new(254).to_s)
38
+ assert_equal(original_class_name, Dnsruby::Classes.to_string(254))
39
+ assert_equal('NONE', Dnsruby::Classes.new(Dnsruby::Classes::NONE).to_s)
40
+ end
41
+
42
+ def test_aliases_reassignment_case_and_setters_preserve_hash_invert_semantics
43
+ strings = maps.strings.dup
44
+ stringsdown = maps.stringsdown.dup
45
+ size = maps.maxcode
46
+ [['new', 3], ['alias', 3], ['new', 4], ['alias', 5], ['ONE', 5],
47
+ ['ONE', 5], ['ALIAS', 6], ['Mixed-Name', 7]].each do |name, code|
48
+ strings[name] = code
49
+ stringsdown[name.downcase] = code
50
+ assert_equal(size += 1, @mapper.add_pair(name, code))
51
+ assert_equal(strings, maps.strings)
52
+ assert_equal(strings.invert, maps.values)
53
+ assert_equal(stringsdown, maps.stringsdown)
54
+ end
55
+ value = @mapper.new('mixed_name')
56
+ assert_equal(7, value.code)
57
+ value.set_string('oNe')
58
+ assert_equal([5, 'alias'], [value.code, value.string])
59
+ value.set_code(6)
60
+ assert_equal([6, 'ALIAS'], [value.code, value.string])
61
+ assert_equal(6, @mapper.to_code('alias'))
62
+ assert_equal('literal', @mapper.to_string('literal'))
63
+ end
64
+
65
+ def test_mutable_name_is_copied_and_frozen_name_keeps_identity
66
+ name = +'mutable'
67
+ @mapper.add_pair(name, 10)
68
+ name.replace('changed')
69
+ assert_equal('mutable', @mapper.to_string(10))
70
+ assert_predicate(@mapper.to_string(10), :frozen?)
71
+ assert_equal(10, @mapper.to_code('mutable'))
72
+ assert_nil(@mapper.to_code('changed'))
73
+ frozen = 'frozen'.freeze
74
+ @mapper.add_pair(frozen, 11)
75
+ assert_same(frozen, @mapper.to_string(11))
76
+ end
77
+
78
+ def test_custom_string_subclass_keeps_native_hash_behavior
79
+ name_class = Class.new(String) do
80
+ def dup
81
+ raise 'Hash does not call user-defined dup'
82
+ end
83
+ end
84
+ name = name_class.new('custom')
85
+ expected = {name => 12}.invert
86
+ @mapper.add_pair(name, 12)
87
+ name.replace('changed')
88
+ assert_equal(expected[12], @mapper.to_string(12))
89
+ assert_equal(expected[12].frozen?, @mapper.to_string(12).frozen?)
90
+ end
91
+
92
+ def test_mutable_custom_code_is_rehashed_on_subsequent_insert
93
+ # add_pair accepts values other than integers. Mutable keys must be
94
+ # rehashed on insertion, matching Hash#invert.
95
+ code = ['before']
96
+ @mapper.add_pair('custom', code)
97
+ code.replace(['after'])
98
+ @mapper.add_pair('numeric', 13)
99
+ assert_equal('custom', maps.values[['after']])
100
+ assert_equal(maps.strings.invert, maps.values)
101
+ end
102
+
103
+ def test_update_discards_dynamic_pairs_and_rebuilds_constants
104
+ @mapper.add_pair('extra', 3)
105
+ @mapper.const_set(:THREE, 3)
106
+ returned = @mapper.update
107
+ assert_equal(maps.strings.keys, returned)
108
+ assert_nil(@mapper.to_code('extra'))
109
+ assert_equal(3, @mapper.to_code('three'))
110
+ assert_equal('THREE', @mapper.to_string(3))
111
+ assert_equal(3, maps.maxcode)
112
+ end
113
+
114
+ def test_update_retains_registrations_from_constant_lookup_callbacks
115
+ @mapper.define_singleton_method(:const_get) do |name|
116
+ add_pair('FROM_CALLBACK', 20)
117
+ super(name)
118
+ end
119
+ @mapper.update
120
+ assert_equal(20, @mapper.to_code('FROM_CALLBACK'))
121
+ assert_equal('FROM_CALLBACK', @mapper.to_string(20))
122
+ assert_equal(1, @mapper.to_code('ONE'))
123
+ assert_equal(maps.strings.invert, maps.values)
124
+ end
125
+
126
+ def test_reentrant_custom_downcase_can_register_another_pair
127
+ mapper = @mapper
128
+ name = +'outer'
129
+ name.define_singleton_method(:downcase) do
130
+ mapper.add_pair('inner', 15)
131
+ super()
132
+ end
133
+ Timeout.timeout(2) { @mapper.add_pair(name, 14) }
134
+ assert_equal(14, @mapper.to_code('outer'))
135
+ assert_equal(15, @mapper.to_code('inner'))
136
+ assert_equal(maps.strings.invert, maps.values)
137
+ assert_equal(4, maps.maxcode)
138
+ end
139
+
140
+ def test_custom_downcase_can_wait_for_another_writer_thread
141
+ mapper = @mapper
142
+ name = +'outer'
143
+ name.define_singleton_method(:downcase) do
144
+ Thread.new { mapper.add_pair('inner', 15) }.value
145
+ super()
146
+ end
147
+ Timeout.timeout(2) { @mapper.add_pair(name, 14) }
148
+ assert_equal(14, @mapper.to_code('outer'))
149
+ assert_equal(15, @mapper.to_code('inner'))
150
+ assert_equal(maps.strings.invert, maps.values)
151
+ end
152
+
153
+ def test_overridden_frozen_query_does_not_change_string_key_copying
154
+ name = +'mutable'
155
+ name.define_singleton_method(:frozen?) { true }
156
+ @mapper.add_pair(name, 16)
157
+ name.replace('changed')
158
+ assert_equal('mutable', @mapper.to_string(16))
159
+ assert_predicate(@mapper.to_string(16), :frozen?)
160
+ end
161
+
162
+ def test_concurrent_aliases_keep_reverse_mapping_consistent
163
+ threads = 6.times.map do |worker|
164
+ Thread.new do
165
+ 80.times { |i| @mapper.add_pair("NAME#{worker}-#{i}", 1000 + i) }
166
+ end
167
+ end
168
+ Timeout.timeout(5) { threads.each(&:value) }
169
+ assert_equal(maps.strings.invert, maps.values)
170
+ assert_equal(482, maps.maxcode)
171
+ 6.times { |worker| assert_equal(1079, @mapper.to_code("NAME#{worker}-79")) }
172
+ ensure
173
+ threads&.each { |thread| thread.kill if thread.alive? }
174
+ end
175
+
176
+ def test_dns_type_and_class_construction_rejects_out_of_range_without_interning
177
+ [[Dnsruby::Types, 'TYPE'], [Dnsruby::Classes, 'CLASS']].each do |mapper, prefix|
178
+ table = Dnsruby::CodeMapper.class_variable_get(:@@arrays).fetch(mapper)
179
+ before = [table.strings.dup, table.stringsdown.dup, table.values.dup, table.maxcode]
180
+ [-1, 65_536, 65_538, 2**128].each do |code|
181
+ assert_raises(ArgumentError) { mapper.new(code) }
182
+ assert_raises(ArgumentError) { mapper.new("#{prefix}#{code}") }
183
+ end
184
+ 100.times { |i| assert_raises(ArgumentError) { mapper.new("#{prefix}#{65_536 + i}") } }
185
+ ['', 'suffix', '.0', "\nignored"].each do |suffix|
186
+ # An empty suffix tests the bare TYPE or CLASS prefix.
187
+ text = suffix.empty? ? prefix : "#{prefix}123#{suffix}"
188
+ assert_raises(ArgumentError) { mapper.new(text) }
189
+ end
190
+ assert_equal(before, [table.strings, table.stringsdown, table.values, table.maxcode])
191
+ end
192
+ end
193
+
194
+ def test_dns_numeric_spelling_variants_do_not_grow_mapping_tables
195
+ [[Dnsruby::Types, 'TYPE'], [Dnsruby::Classes, 'CLASS']].each do |mapper, prefix|
196
+ code = 65_050
197
+ assert_equal(code, mapper.new(code).code)
198
+ table = Dnsruby::CodeMapper.class_variable_get(:@@arrays).fetch(mapper)
199
+ before = [table.strings.dup, table.stringsdown.dup, table.values.dup, table.maxcode]
200
+ 100.times do |zeroes|
201
+ text = "#{prefix.downcase}#{'0' * zeroes}#{code}"
202
+ mapped = mapper.new(text)
203
+ expected = zeroes.zero? ? "#{prefix}#{code}" : text
204
+ assert_equal(code, mapped.code)
205
+ assert_equal(expected, mapped.to_s)
206
+ assert_equal(expected, mapper.new(mapped).to_s)
207
+ end
208
+ assert_equal(before, [table.strings, table.stringsdown, table.values, table.maxcode])
209
+ [0, 65_535].each { |boundary| assert_equal(boundary, mapper.new(boundary).code) }
210
+ end
211
+ assert_equal('TYPE0001', Dnsruby::Types.new('TYPE0001').to_s)
212
+ assert_equal('CLASS0001', Dnsruby::Classes.new('CLASS0001').to_s)
213
+ assert_equal('A', Dnsruby::Types.new(1).to_s)
214
+ assert_equal('IN', Dnsruby::Classes.new(1).to_s)
215
+ end
216
+
217
+ def test_dns_code_helpers_validate_range_and_numeric_strings
218
+ [[Dnsruby::Types, 'TYPE', :typesbyname, :typesbyval],
219
+ [Dnsruby::Classes, 'CLASS', :classesbyname, :classesbyval]].each do |mapper, prefix, byname, byval|
220
+ assert_equal(1, mapper.public_send(byname, "#{prefix}0001"))
221
+ assert_equal(mapper.to_string(1), mapper.public_send(byval, ' 0001 '))
222
+ [-1, 65_536, 2**128].each do |code|
223
+ assert_raises(ArgumentError) { mapper.public_send(byval, code) }
224
+ assert_raises(ArgumentError) { mapper.public_send(byval, code.to_s) }
225
+ assert_raises(ArgumentError) { mapper.public_send(byname, "#{prefix}#{code}") }
226
+ end
227
+ end
228
+ error = assert_raises(ArgumentError) { Dnsruby::RR.create('x. IN TYPE65536 \\# 0') }
229
+ assert_match(/0\.\.65535/, error.message)
230
+ error = assert_raises(ArgumentError) { Dnsruby::RR.create('x. CLASS65536 TYPE65000 \\# 0') }
231
+ assert_match(/0\.\.65535/, error.message)
232
+ end
233
+
234
+ def test_copying_a_mutated_dns_mapper_still_validates_the_wire_range
235
+ [Dnsruby::Types, Dnsruby::Classes].each do |mapper|
236
+ value = mapper.new(1)
237
+ value.code = 65_536
238
+ assert_raises(ArgumentError) { mapper.new(value) }
239
+ end
240
+ # Custom CodeMapper subclasses can accept values outside the DNS
241
+ # type/class range.
242
+ @mapper.add_pair('LARGE', 2**32)
243
+ @mapper.add_pair('NEGATIVE', -1)
244
+ assert_equal(2**32, @mapper.new('LARGE').code)
245
+ assert_equal(-1, @mapper.new('NEGATIVE').code)
246
+ end
247
+
248
+ def test_dns_mappers_preserve_explicit_application_aliases
249
+ arrays = Dnsruby::CodeMapper.class_variable_get(:@@arrays)
250
+ [[Dnsruby::Types, 'TYPE'], [Dnsruby::Classes, 'CLASS']].each do |mapper, prefix|
251
+ original = arrays.fetch(mapper)
252
+ replacement = Dnsruby::CodeMapper::Arrays.new
253
+ %i[strings stringsdown values].each do |field|
254
+ replacement.public_send("#{field}=", original.public_send(field).dup)
255
+ end
256
+ replacement.maxcode = original.maxcode
257
+ replacement.incremental = original.incremental
258
+ arrays[mapper] = replacement
259
+ begin
260
+ existing = mapper.new(1)
261
+ mapper.add_pair('PREFERRED-ONE-ALIAS', 1)
262
+ assert_equal('PREFERRED-ONE-ALIAS', mapper.new(existing).to_s)
263
+ numeric_spelling = mapper.new("#{prefix}0001")
264
+ assert_equal("#{prefix}0001", mapper.new(numeric_spelling).to_s)
265
+ mapper.add_pair("#{prefix}0001", 1)
266
+ mapper.add_pair('LATEST-ONE-ALIAS', 1)
267
+ assert_equal('LATEST-ONE-ALIAS', mapper.new(numeric_spelling).to_s)
268
+ mapper.add_pair('APPLICATION-ALIAS', 65_052)
269
+ assert_equal(65_052, mapper.new('application_alias').code)
270
+ assert_equal("#{prefix}065052", mapper.new("#{prefix}065052").to_s)
271
+ assert_equal(65_052, mapper.to_code('APPLICATION-ALIAS'))
272
+ assert_equal('APPLICATION-ALIAS', mapper.new(65_052).to_s)
273
+ assert_nil(mapper.to_code("#{prefix}065052"), 'automatic spelling must not replace the explicit alias')
274
+ ensure
275
+ arrays[mapper] = original
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,169 @@
1
+ require_relative 'tc_pipeline_send'
2
+
3
+ class ReviewControlDeadlinesTest < Minitest::Test
4
+ def test_validation_budget_interrupts_initial_connect_write_and_pipeline_wait
5
+ [:connect, :write, :pipeline].each do |stage|
6
+ [:deadline, :cancel].each do |reason|
7
+ isolated("stage = #{stage.inspect}; reason = #{reason.inspect}\n" + <<~'RUBY')
8
+ SELECTOR.singleton_class.remove_method(:notify_queue_observers)
9
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', dnssec: false,
10
+ use_tcp: true, tcp_pipelining: stage == :pipeline, packet_timeout: 1,
11
+ query_timeout: 2, retry_times: 1, do_caching: false)
12
+ writer = resolver.single_resolvers.first
13
+ waiting, finished = Queue.new, Queue.new
14
+ sockets = []
15
+ Socket.define_singleton_method(:new) do |*|
16
+ socket = FrameSocket.new
17
+ sockets << socket
18
+ socket.define_singleton_method(:bind) { |*| 0 }
19
+ socket.define_singleton_method(:connect_nonblock) do |*, **|
20
+ waiting << true if stage == :connect
21
+ stage == :connect ? :wait_writable : 0
22
+ end
23
+ socket.define_singleton_method(:write_nonblock) do |*|
24
+ waiting << true
25
+ raise IO::EAGAINWaitWritable
26
+ end
27
+ socket
28
+ end
29
+ IO.define_singleton_method(:select) do |_, _, _, duration|
30
+ raise 'unbounded I/O wait' unless duration > 0 && duration <= 0.05
31
+ sleep(duration)
32
+ nil
33
+ end
34
+ pipeline_mutex = Mutex.new
35
+ if stage == :pipeline
36
+ pipeline_mutex.lock
37
+ writer.instance_variable_set(:@pipeline_mutex, pipeline_mutex)
38
+ original = writer.method(:send_dnsruby)
39
+ writer.define_singleton_method(:send_dnsruby) do |*args|
40
+ waiting << true
41
+ original.call(*args)
42
+ end
43
+ end
44
+ budget = Dnsruby::ValidationBudget.new(limits:
45
+ Dnsruby::Dnssec.validation_limits.merge(timeout: reason == :deadline ? 0.04 : 2))
46
+ started = Dnsruby::TransportClock.now
47
+ worker = Thread.new do
48
+ begin
49
+ Dnsruby::ValidationBudget.with(budget) do
50
+ resolver.query_no_validation_or_recursion('lookup.example.', 'A')
51
+ end
52
+ finished << :unexpected_success
53
+ rescue Dnsruby::ValidationLimitError => error
54
+ finished << error
55
+ end
56
+ end
57
+ Timeout.timeout(2) { waiting.pop }
58
+ budget.cancel if reason == :cancel
59
+ result = Timeout.timeout(0.5) { finished.pop }
60
+ worker.join
61
+ expected = reason == :cancel ? Dnsruby::ValidationCancelled : Dnsruby::ValidationLimitError
62
+ raise "wrong result: #{result.inspect}" unless result.instance_of?(expected)
63
+ elapsed = Dnsruby::TransportClock.now - started
64
+ raise "initial send ignored control: #{elapsed}" unless elapsed < 0.3
65
+ raise 'unconnected or blocked socket leaked' unless sockets.all?(&:closed?)
66
+ engine = resolver.instance_variable_get(:@resolver_ruby)
67
+ raise 'logical query leaked' unless engine.instance_variable_get(:@query_list).empty?
68
+ raise 'logical deadline leaked' unless engine.next_tick_at.nil?
69
+ raise 'packet attempt leaked' unless K.class_variable_get(:@@query_hash).empty?
70
+ raise 'observer leaked' unless K.class_variable_get(:@@observers).empty?
71
+ pipeline_mutex.unlock if stage == :pipeline
72
+ resolver.close
73
+ RUBY
74
+ end
75
+ end
76
+ end
77
+
78
+ def test_scheduled_and_protocol_retries_keep_the_original_control
79
+ isolated(<<~'RUBY')
80
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', dnssec: false,
81
+ packet_timeout: 10, query_timeout: 20, retry_times: 2)
82
+ writer = resolver.single_resolvers.first
83
+ sent = []
84
+ writer.define_singleton_method(:send_async) { |*| sent << Thread.current[:dnsruby_response_query] }
85
+ control = Dnsruby::QueryControl.new(deadline: Dnsruby::TransportClock.now + 1)
86
+ queue = Queue.new
87
+ Dnsruby::QueryControl.with(control) { resolver.send_async(query, queue, :controlled) }
88
+ engine = resolver.instance_variable_get(:@resolver_ruby)
89
+ plan = engine.instance_variable_get(:@timeouts)[:controlled][1]
90
+ attempt = plan.values.first
91
+ plan.clear
92
+ plan[Dnsruby::TransportClock.now - 1] = attempt
93
+ engine.send(:update_tick_deadline, :controlled)
94
+ engine.tick
95
+ raise 'scheduled retry lost its controlling lookup' unless sent.length == 2 &&
96
+ sent.all? { |settings| settings.send_token.control.equal?(control) && settings.endtime <= control.deadline }
97
+ previous = sent.last
98
+ previous.query_bytes = query.encode
99
+ writer.define_singleton_method(:send_dnsruby_with_socket) { |*| }
100
+ Thread.current[:dnsruby_response_query] = previous
101
+ writer.send_dnsruby(query.encode, query, previous.client_query_id, previous.client_queue, true)
102
+ Thread.current[:dnsruby_response_query] = nil
103
+ replacement = SELECTOR.query_settings_for_id(previous.client_query_id)
104
+ raise 'protocol retry extended the controlling deadline' unless replacement.endtime <= control.deadline
105
+ raise 'protocol retry lost cancellation' unless replacement.send_token.equal?(previous.send_token)
106
+ control.cancel
107
+ raise 'retry ignored parent cancellation' unless replacement.send_token.cancelled?
108
+ resolver.close
109
+ RUBY
110
+ end
111
+
112
+ def test_deadline_heap_matches_live_deadlines_without_retaining_removed_entries
113
+ deadlines = Dnsruby::DeadlineQueue.new
114
+ expected = {}
115
+ random = Random.new(73)
116
+ 5_000.times do
117
+ key = random.rand(100)
118
+ if random.rand(3).zero?
119
+ assert(expected.delete(key) == deadlines.delete(key), "removed deadline for #{key}")
120
+ else
121
+ deadlines[key] = expected[key] = random.rand(1000)
122
+ end
123
+ assert(expected.values.min == deadlines.first_deadline, 'minimum live deadline')
124
+ assert_equal(expected.length, deadlines.instance_variable_get(:@heap).length)
125
+ end
126
+ expected.keys.each { |key| deadlines.delete(key) }
127
+ assert_nil(deadlines.first)
128
+ assert_empty(deadlines.instance_variable_get(:@positions))
129
+ end
130
+
131
+ def test_logical_deadline_index_tracks_retries_validation_cancellation_and_reset
132
+ isolated(<<~'RUBY')
133
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', dnssec: false,
134
+ retry_times: 3, retry_delay: 0.02, query_timeout: 0.5)
135
+ resolver.single_resolvers.each { |sender| sender.define_singleton_method(:send_async) { |*| } }
136
+ queues = [Queue.new, Queue.new]
137
+ queues.each_with_index { |queue, id| resolver.send_async(query, queue, id) }
138
+ engine = resolver.instance_variable_get(:@resolver_ruby)
139
+ check_minimum = -> do
140
+ all = engine.instance_variable_get(:@timeouts).values.flat_map { |deadline, retries, _| [deadline, *retries.keys] }
141
+ raise 'deadline index differs from live plans' unless engine.next_tick_at == all.min
142
+ end
143
+ check_minimum.call
144
+ sleep 0.025
145
+ engine.tick
146
+ check_minimum.call
147
+ resolver.single_res_mutex.synchronize do
148
+ current = engine.instance_variable_get(:@query_list)[0]
149
+ engine.begin_validation(0, current[3].first, current[2])
150
+ end
151
+ check_minimum.call
152
+ engine.cancel_query(1, queues[1])
153
+ check_minimum.call
154
+ resolver.close
155
+ check_minimum.call
156
+ engine.reset_attributes
157
+ check_minimum.call
158
+ RUBY
159
+ end
160
+
161
+ private
162
+
163
+ def isolated(code)
164
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__),
165
+ '-e', TestPipelineSend::FIXTURE + code)
166
+ assert(status.success?, "#{stdout}\n#{stderr}")
167
+ assert_empty(stderr)
168
+ end
169
+ end