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,305 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+
4
+ # Decision-level tests use metadata doubles and mock cryptographic verification;
5
+ # ordinary published root fixtures exercise DS identity without generating keys.
6
+ class ReleaseDnssecFixesTest < Minitest::Test
7
+ def setup
8
+ @verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::ANCHOR)
9
+ @zone = Dnsruby::Name.create('example.')
10
+ @child = Dnsruby::Name.create('child.example.')
11
+ @anchor = metadata_double(name: @zone, klass: Dnsruby::Classes.IN, type: Dnsruby::Types.DNSKEY)
12
+ @saved_recursor = Dnsruby::Dnssec.do_validation_with_recursor?
13
+ Dnsruby::Dnssec.do_validation_with_recursor(false)
14
+ end
15
+
16
+ def teardown
17
+ Dnsruby::Dnssec.do_validation_with_recursor(@saved_recursor)
18
+ end
19
+
20
+ def test_signature_metadata_requires_zone_authority_and_apex_dnskeys
21
+ [[@child, 'A', @zone, true], [@zone, 'DNSKEY', @zone, true],
22
+ [@child, 'DNSKEY', @zone, false], [@child, 'DS', @zone, true],
23
+ [@child, 'DS', @child, false], [@zone, 'A', @child, false]].each do |owner, type, signer, valid|
24
+ rrset = metadata_double(name: owner, klass: Dnsruby::Classes.IN, type: Dnsruby::Types.new(type))
25
+ signature = metadata_double(name: owner, klass: rrset.klass, type_covered: rrset.type,
26
+ signers_name: signer, labels: owner.labels.length, inception: Time.now.to_i - 60,
27
+ expiration: Time.now.to_i + 60)
28
+ if valid
29
+ @verifier.check_rr_data(rrset, signature)
30
+ else
31
+ assert_raises(Dnsruby::VerifyError) { @verifier.check_rr_data(rrset, signature) }
32
+ end
33
+ end
34
+ end
35
+
36
+ def test_zone_key_flag_is_required_without_sep
37
+ key = Object.new
38
+ def key.zone_key?; false; end
39
+ error = assert_raises(Dnsruby::VerifyError) { @verifier.send(:verify_rrset_with_key, nil, key, nil) }
40
+ assert_match(/Zone Key flag/, error.message)
41
+ end
42
+
43
+ def test_dsa_algorithms_are_rejected_before_crypto
44
+ key = Object.new
45
+ def key.zone_key?; true; end
46
+ [3, 6].each do |algorithm|
47
+ signature = metadata_double(algorithm: Dnsruby::Algorithms.new(algorithm))
48
+ error = assert_raises(Dnsruby::VerifyError) do
49
+ @verifier.send(:verify_rrset_with_key, nil, key, signature)
50
+ end
51
+ assert_match(/prohibited/, error.message)
52
+ end
53
+ end
54
+
55
+ def test_ds_binds_owner_and_class_while_dlv_keeps_lookaside_owner_semantics
56
+ fixture = File.join(__dir__, 'fixtures', 'root-dnskey-2026-11-05.zone')
57
+ key = Dnsruby::RR.create(File.readlines(fixture).find { |line| line.include?(' IN DNSKEY ') })
58
+ ds = Dnsruby::RR::DS.from_key(key, 2)
59
+ assert(ds.check_key(key))
60
+ ds.name = 'example.'
61
+ refute(ds.check_key(key))
62
+ ds.name = key.name
63
+ ds.klass = Dnsruby::Classes.CH
64
+ refute(ds.check_key(key))
65
+ dlv = Dnsruby::RR.create(Dnsruby::RR::DS.from_key(key, 2).to_s.sub(/\bDS\b/, 'DLV'))
66
+ dlv.name = 'example.dlv.invalid.'
67
+ assert(dlv.check_key(key))
68
+ dlv.klass = Dnsruby::Classes.CH
69
+ refute(dlv.check_key(key))
70
+ end
71
+
72
+ def test_worker_rejects_false_or_bogus_without_an_exception
73
+ [[false, Dnsruby::Message::SecurityLevel.INSECURE],
74
+ [true, Dnsruby::Message::SecurityLevel.BOGUS]].each do |result, level|
75
+ worker = Dnsruby::ValidatorThread.allocate
76
+ worker.define_singleton_method(:should_validate) { true }
77
+ response = Dnsruby::Message.new('example.')
78
+ response.security_level = level
79
+ Dnsruby::Dnssec.stub(:validate_with_query, result) do
80
+ refute(worker.validate(Dnsruby::Message.new('example.'), response, nil))
81
+ end
82
+ assert_instance_of(Dnsruby::VerifyError, response.security_error)
83
+ assert_equal(Dnsruby::Message::SecurityLevel.BOGUS, response.security_level)
84
+ end
85
+ end
86
+
87
+ def test_policy_fallback_cannot_erase_failure_with_insecure_result
88
+ previous = Dnsruby::Dnssec.validation_policy
89
+ query = Dnsruby::Message.new('example.')
90
+ query.header.cd = true
91
+ insecure = ->(message, _) { message.security_level = Dnsruby::Message::SecurityLevel.INSECURE; true }
92
+ failure = ->(*) { raise Dnsruby::VerifyError, 'missing authenticated delegation' }
93
+ secure = ->(message, _) { message.security_level = Dnsruby::Message::SecurityLevel.SECURE; true }
94
+ [Dnsruby::Dnssec::ValidationPolicy::LOCAL_ANCHORS_THEN_ROOT,
95
+ Dnsruby::Dnssec::ValidationPolicy::ROOT_THEN_LOCAL_ANCHORS].each do |policy|
96
+ Dnsruby::Dnssec.validation_policy = policy
97
+ Dnsruby::Dnssec.stub(:validate_with_anchors, insecure) do
98
+ Dnsruby::Dnssec.stub(:validate_with_root, failure) do
99
+ Dnsruby::Dnssec.stub(:validate_with_dlv, insecure) do
100
+ response = Dnsruby::Message.new('example.')
101
+ assert_raises(Dnsruby::VerifyError) { Dnsruby::Dnssec.validate_with_query(query, response) }
102
+ assert_equal(Dnsruby::Message::SecurityLevel.BOGUS, response.security_level)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ Dnsruby::Dnssec.validation_policy = Dnsruby::Dnssec::ValidationPolicy::ROOT_THEN_LOCAL_ANCHORS
108
+ Dnsruby::Dnssec.stub(:validate_with_root, failure) do
109
+ Dnsruby::Dnssec.stub(:validate_with_anchors, secure) do
110
+ response = Dnsruby::Message.new('example.')
111
+ assert(Dnsruby::Dnssec.validate_with_query(query, response))
112
+ assert_equal(Dnsruby::Message::SecurityLevel.SECURE, response.security_level)
113
+ assert_equal('', response.security_error)
114
+ end
115
+ end
116
+ ensure
117
+ Dnsruby::Dnssec.validation_policy = previous
118
+ end
119
+
120
+ def test_ds_lookup_failure_or_unproven_absence_does_not_mean_insecure
121
+ response = Dnsruby::Message.new(@child, 'DS')
122
+ [->(*) { raise Dnsruby::ServFail, 'lookup failed' }, ->(*) { response }].each do |query|
123
+ resolver = Object.new
124
+ resolver.define_singleton_method(:query_no_validation_or_recursion, query)
125
+ assert_raises(Dnsruby::VerifyError) { @verifier.get_anchor_for(@child, @zone, @anchor, resolver) }
126
+ end
127
+ end
128
+
129
+ def test_missing_or_unsigned_dnskey_is_a_verification_failure
130
+ response = Dnsruby::Message.new(@zone, 'DNSKEY')
131
+ resolver = Object.new
132
+ resolver.define_singleton_method(:query_no_validation_or_recursion) { |*| response }
133
+ @verifier.stub(:get_nameservers_for, resolver) do
134
+ assert_raises(Dnsruby::VerifyError) { @verifier.get_anchor_for(@zone, @zone, @anchor, resolver) }
135
+ fixture = File.join(__dir__, 'fixtures', 'root-dnskey-2026-11-05.zone')
136
+ key = Dnsruby::RR.create(File.readlines(fixture).find { |line| line.include?(' IN DNSKEY ') })
137
+ key.name = @zone
138
+ response.add_answer(key)
139
+ assert_raises(Dnsruby::VerifyError) { @verifier.get_anchor_for(@zone, @zone, @anchor, resolver) }
140
+ end
141
+ end
142
+
143
+ def test_unsigned_ds_does_not_establish_child_trust
144
+ response = Dnsruby::Message.new(@child, 'DS')
145
+ response.add_answer(Dnsruby::RR.create('child.example. 60 IN DS 1 8 2 ' + '00' * 32))
146
+ resolver = Object.new
147
+ resolver.define_singleton_method(:query_no_validation_or_recursion) { |*| response }
148
+ assert_raises(Dnsruby::VerifyError) { @verifier.get_anchor_for(@child, @zone, @anchor, resolver) }
149
+ end
150
+
151
+ def test_only_authenticated_all_unsupported_ds_sets_are_insecure
152
+ [[3, 6], [17]].each do |algorithms|
153
+ message = Dnsruby::Message.new(@child, 'DS')
154
+ algorithms.each do |algorithm|
155
+ message.add_answer(Dnsruby::RR.create("child.example. 60 IN DS 1 #{algorithm} 2 " + '00' * 32))
156
+ end
157
+ calls = []
158
+ resolver = Object.new
159
+ resolver.define_singleton_method(:query_no_validation_or_recursion) do |name, type|
160
+ calls << type
161
+ message
162
+ end
163
+ @verifier.stub(:verify, true) do
164
+ key, returned_resolver = @verifier.get_anchor_for(@child, @zone, @anchor, resolver)
165
+ assert_equal(false, key)
166
+ assert_same(resolver, returned_resolver)
167
+ assert_equal([Dnsruby::Types.DS], calls)
168
+ end
169
+ @verifier.stub(:verify, false) do
170
+ assert_raises(Dnsruby::VerifyError) { @verifier.get_anchor_for(@child, @zone, @anchor, resolver) }
171
+ end
172
+ end
173
+ end
174
+
175
+ def test_cached_unsupported_ds_requires_live_provenance_before_insecure_classification
176
+ record = Dnsruby::RR.create('child.example. 60 IN DS 1 3 2 ' + '00' * 32)
177
+ [record, Dnsruby::RRSet.new([record])].each do |anchor|
178
+ admitted = []
179
+ @verifier.stub(:admit_learned_rrset, ->(input) { admitted << input }) do
180
+ @verifier.stub(:get_nameservers_for, ->(*) { flunk('unsupported DS must not fetch DNSKEY') }) do
181
+ result = @verifier.get_anchor_for(@child, @child, anchor)
182
+ assert_equal([false, nil], result)
183
+ assert_equal([anchor], admitted)
184
+ end
185
+ end
186
+ @verifier.stub(:admit_learned_rrset, ->(*) { raise Dnsruby::VerifyError, 'expired learned trust' }) do
187
+ assert_raises(Dnsruby::VerifyError) { @verifier.get_anchor_for(@child, @child, anchor) }
188
+ end
189
+ end
190
+ end
191
+
192
+ def test_cached_mixed_ds_still_requires_child_authentication
193
+ records = [3, 8].map { |algorithm| Dnsruby::RR.create("child.example. 60 IN DS 1 #{algorithm} 2 " + '00' * 32) }
194
+ anchor = Dnsruby::RRSet.new(records)
195
+ response = Dnsruby::Message.new(@child, 'DNSKEY')
196
+ calls = []
197
+ resolver = Object.new
198
+ resolver.define_singleton_method(:query_no_validation_or_recursion) do |name, type|
199
+ calls << type
200
+ response
201
+ end
202
+ @verifier.stub(:get_nameservers_for, resolver) do
203
+ assert_raises(Dnsruby::VerifyError) { @verifier.get_anchor_for(@child, @child, anchor, resolver) }
204
+ end
205
+ assert_equal([Dnsruby::Types.DNSKEY], calls)
206
+ end
207
+
208
+ def test_mixed_ds_algorithms_still_require_an_authenticated_child_key
209
+ ds = Dnsruby::Message.new(@child, 'DS')
210
+ [3, 8].each do |algorithm|
211
+ ds.add_answer(Dnsruby::RR.create("child.example. 60 IN DS 1 #{algorithm} 2 " + '00' * 32))
212
+ end
213
+ keys = Dnsruby::Message.new(@child, 'DNSKEY')
214
+ calls = []
215
+ resolver = Object.new
216
+ resolver.define_singleton_method(:query_no_validation_or_recursion) do |name, type|
217
+ calls << type
218
+ type == Dnsruby::Types.DS ? ds : keys
219
+ end
220
+ @verifier.stub(:verify, true) do
221
+ @verifier.stub(:get_nameservers_from_message, resolver) do
222
+ assert_raises(Dnsruby::VerifyError) { @verifier.get_anchor_for(@child, @zone, @anchor, resolver) }
223
+ end
224
+ end
225
+ assert_equal([Dnsruby::Types.DS, Dnsruby::Types.DNSKEY], calls)
226
+ end
227
+
228
+ def test_only_authenticated_delegation_denial_returns_insecure
229
+ %w[NSEC NSEC3].each do |type|
230
+ ['A', 'NS'].each do |types|
231
+ record = if type == 'NSEC'
232
+ Dnsruby::RR.create("child.example. 60 IN NSEC z.example. #{types} RRSIG NSEC")
233
+ else
234
+ nsec3 = Dnsruby::RR.create('example. 60 IN NSEC3 1 0 0 - 00000000000000000000000000000000')
235
+ hashed = nsec3.hash_name(@child)
236
+ Dnsruby::RR.create("#{hashed}.example. 60 IN NSEC3 1 0 0 - #{hashed} #{types}")
237
+ end
238
+ signature = metadata_double(signers_name: @zone, labels: record.name.labels.length)
239
+ proof = metadata_double(name: record.name, klass: record.klass, type: record.type, rrs: [record], sigs: [signature])
240
+ authority = Object.new
241
+ authority.define_singleton_method(:rrsets) { |requested| requested == record.type ? [proof] : [] }
242
+ message = metadata_double(question: [Dnsruby::Question.new(@child, 'DS')],
243
+ authority: authority, rcode: Dnsruby::RCode.NOERROR)
244
+ # Stub construction at the crypto boundary; the denial bitmap and hash
245
+ # matching decisions remain real, and no signed DNS response is created.
246
+ Dnsruby::RRSet.stub(:new, proof) do
247
+ @verifier.stub(:verify_rrset, true) do
248
+ expected = types == 'NS' ? :insecure : :secure
249
+ assert_equal(expected, @verifier.send(:authenticated_ds_absence, message, @child, @anchor))
250
+ end
251
+ @verifier.stub(:verify_rrset, ->(*) { raise Dnsruby::VerifyError, 'not authenticated' }) do
252
+ assert_raises(Dnsruby::VerifyError) do
253
+ @verifier.send(:authenticated_ds_absence, message, @child, @anchor)
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+
261
+ def test_actual_wildcard_nsec_signature_is_eligible_as_denial_evidence
262
+ record = Dnsruby::RR.create('*.example. 60 IN NSEC z.example. A RRSIG NSEC')
263
+ signature = metadata_double(signers_name: @zone, labels: 1)
264
+ proof = metadata_double(name: record.name, klass: record.klass, type: record.type, rrs: [record], sigs: [signature])
265
+ authority = Object.new
266
+ authority.define_singleton_method(:rrsets) { |type| type == Dnsruby::Types.NSEC ? [proof] : [] }
267
+ message = metadata_double(question: [Dnsruby::Question.new(@child, 'DS')],
268
+ authority: authority, rcode: Dnsruby::RCode.NOERROR)
269
+ verified = false
270
+ Dnsruby::RRSet.stub(:new, proof) do
271
+ @verifier.stub(:verify_rrset, ->(*) { verified = true }) do
272
+ @verifier.stub(:nsec_denial, :secure) do
273
+ assert_equal(:secure, @verifier.send(:authenticated_ds_absence, message, @child, @anchor))
274
+ assert(verified)
275
+ end
276
+ end
277
+ end
278
+ end
279
+
280
+ def test_non_delegation_keeps_parent_anchor_without_querying_child_dnskey
281
+ resolver = Object.new
282
+ response = Dnsruby::Message.new(@child, 'DS')
283
+ calls = []
284
+ resolver.define_singleton_method(:query_no_validation_or_recursion) do |name, type|
285
+ calls << [name, type]
286
+ response
287
+ end
288
+ @verifier.stub(:authenticated_ds_absence, :secure) do
289
+ key, returned_resolver = @verifier.get_anchor_for(@child, @zone, @anchor, resolver)
290
+ assert_same(@anchor, key)
291
+ assert_same(resolver, returned_resolver)
292
+ assert_equal([[@child, Dnsruby::Types.DS]], calls)
293
+ end
294
+ end
295
+
296
+ private
297
+
298
+ def metadata_double(**attributes)
299
+ Object.new.tap do |object|
300
+ attributes.each do |name, value|
301
+ object.define_singleton_method(name) { value }
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,327 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+
4
+ class ReleaseNegativeCacheTest < Minitest::Test
5
+ def setup
6
+ @now = Time.utc(2026, 11, 14, 12)
7
+ end
8
+
9
+ def test_negative_lifetime_is_the_lesser_of_soa_ttl_and_minimum
10
+ [[:NXDOMAIN, 80, 15], [:NOERROR, 10, 80]].each do |rcode, ttl, minimum|
11
+ cache = Dnsruby::Cache.new
12
+ response = negative(rcode, ttl: ttl, minimum: minimum)
13
+ lifetime = [ttl, minimum].min
14
+ Time.stub(:now, @now) { cache.add(response) }
15
+ Time.stub(:now, @now + lifetime - 1) do
16
+ found = cache.find('missing.example.', 'A')
17
+ assert_equal(1, found.authority.first.ttl)
18
+ assert_equal(rcode.to_s, found.rcode.to_s)
19
+ end
20
+ Time.stub(:now, @now + lifetime) { assert_nil(cache.find('missing.example.', 'A')) }
21
+ end
22
+ end
23
+
24
+ def test_plain_name_error_is_shared_across_types_but_nodata_is_not
25
+ [:NXDOMAIN, :NOERROR].each do |rcode|
26
+ cache = Dnsruby::Cache.new
27
+ cache.add(negative(rcode))
28
+ found = cache.find('missing.example.', 'AAAA')
29
+ if rcode == :NXDOMAIN
30
+ assert_equal(Dnsruby::Types::AAAA, found.question.first.qtype)
31
+ else
32
+ assert_nil(found)
33
+ end
34
+ assert_nil(cache.find('missing.example.', 'A', 'CH'))
35
+ end
36
+ end
37
+
38
+ def test_alias_name_error_does_not_claim_the_alias_itself_is_absent
39
+ cache = Dnsruby::Cache.new
40
+ response = negative(:NXDOMAIN, name: 'alias.example.')
41
+ response.add_answer(Dnsruby::RR.create('alias.example. 90 IN CNAME missing.example.'))
42
+ cache.add(response)
43
+ refute_nil(cache.find('alias.example.', 'A'))
44
+ assert_nil(cache.find('alias.example.', 'CNAME'))
45
+ end
46
+
47
+ def test_negative_without_enclosing_soa_or_with_zero_lifetime_is_not_cached
48
+ cache = Dnsruby::Cache.new
49
+ response = Dnsruby::Message.new('missing.example.', 'A')
50
+ response.header.rcode = Dnsruby::RCode::NXDOMAIN
51
+ assert_nil(cache.add(response))
52
+ response.add_authority(Dnsruby::RR.create('other. 30 IN SOA ns.other. hostmaster.other. 1 60 60 60 30'))
53
+ assert_nil(cache.add(response))
54
+ assert_nil(cache.add(negative(:NOERROR, minimum: 0)))
55
+ assert_equal(0, cache.length)
56
+ end
57
+
58
+ def test_alias_nodata_never_enters_the_global_positive_cache
59
+ Dnsruby::PacketSender.clear_caches
60
+ query = Dnsruby::Message.new('alias.example.', 'A')
61
+ query.header.rd = true
62
+ response = negative(:NOERROR, name: 'alias.example.')
63
+ response.add_answer(Dnsruby::RR.create('alias.example. 90 IN CNAME missing.example.'))
64
+ Dnsruby::PacketSender.cache(query, response)
65
+ assert_equal(0, Dnsruby::PacketSender.recursive_cache_length)
66
+ ensure
67
+ Dnsruby::PacketSender.clear_caches
68
+ end
69
+
70
+ def test_resolver_caches_completed_name_errors_and_retains_client_id
71
+ with_resolver(lambda { |query| [negative(:NXDOMAIN, name: query.question.first.qname), Dnsruby::NXDomain.new] }) do |resolver, attempts|
72
+ first = ask(resolver)
73
+ second = ask(resolver)
74
+ assert_equal(1, attempts.call)
75
+ assert_instance_of(Dnsruby::NXDomain, first[2])
76
+ assert_instance_of(Dnsruby::NXDomain, second[2])
77
+ assert(second[1].cached)
78
+ refute_equal(first[0], second[0])
79
+ assert_equal(second[0], second[1].header.id)
80
+ end
81
+ end
82
+
83
+ def test_resolver_configuration_and_validation_policy_scope_negative_answers
84
+ original_policy = Dnsruby::Dnssec.validation_policy
85
+ with_resolver(lambda { |_query| [negative(:NOERROR), nil] }) do |resolver, attempts|
86
+ ask(resolver)
87
+ resolver.port = 1053
88
+ ask(resolver)
89
+ assert_equal(2, attempts.call)
90
+ Dnsruby::Dnssec.validation_policy = original_policy == 1 ? 2 : 1
91
+ ask(resolver)
92
+ assert_equal(3, attempts.call)
93
+ end
94
+ ensure
95
+ Dnsruby::Dnssec.validation_policy = original_policy
96
+ end
97
+
98
+ def test_opt_identity_is_binary_and_includes_payload_flags_and_options
99
+ with_resolver(lambda { |_query| [negative(:NOERROR), nil] }) do |resolver, attempts|
100
+ 2.times do |index|
101
+ query = Dnsruby::Message.new('missing.example.', 'A')
102
+ query.add_additional(Dnsruby::RR::OPT.new(1232, 0,
103
+ [Dnsruby::RR::OPT::Option.new(65001, "value#{index}")]))
104
+ ask(resolver, query)
105
+ end
106
+ assert_equal(2, attempts.call)
107
+ end
108
+ end
109
+
110
+ def test_raw_disabled_caching_tsig_and_update_queries_bypass_completion_cache
111
+ with_resolver(lambda { |_query| [negative(:NOERROR), nil] }) do |resolver, attempts|
112
+ ask(resolver)
113
+ query = Dnsruby::Message.new('missing.example.', 'A')
114
+ query.do_caching = false
115
+ ask(resolver, query)
116
+ raw = Dnsruby::Message.new('missing.example.', 'A')
117
+ raw.send_raw = true
118
+ ask(resolver, raw)
119
+ assert_equal(3, attempts.call)
120
+ engine = resolver.instance_variable_get(:@resolver_ruby)
121
+ assert_nil(engine.send(:response_cache_scope, Dnsruby::Update.new('example.')))
122
+ resolver.tsig = ['key.example.', 'c2VjcmV0']
123
+ assert_nil(engine.send(:response_cache_scope, Dnsruby::Message.new('missing.example.', 'A')))
124
+ end
125
+ end
126
+
127
+ def test_failed_resolution_is_cached_for_one_second_then_retried
128
+ clock = 100.0
129
+ Dnsruby::TransportClock.stub(:now, -> { clock }) do
130
+ with_resolver(lambda { |_query| [nil, Dnsruby::ServFail.new('temporarily unavailable')] }) do |resolver, attempts|
131
+ assert_instance_of(Dnsruby::ServFail, ask(resolver)[2])
132
+ assert_instance_of(Dnsruby::ServFail, ask(resolver)[2])
133
+ assert_equal(1, attempts.call)
134
+ clock += 1
135
+ ask(resolver)
136
+ assert_equal(2, attempts.call)
137
+ end
138
+ end
139
+ end
140
+
141
+ def test_controlled_ordinary_failures_are_cached_but_cancellation_is_not
142
+ cache = Dnsruby::Cache::ResolutionCache.new
143
+ query = Dnsruby::Message.new('missing.example.', 'A')
144
+ control = Dnsruby::QueryControl.new
145
+ cache.add(query, [:scope], nil, Dnsruby::ServFail.new, control: control)
146
+ assert_instance_of(Dnsruby::ServFail, cache.find(query, [:scope]).last)
147
+ control.cancel
148
+ cache.add(query, [:cancelled], nil, Dnsruby::ResolvTimeout.new, control: control)
149
+ assert_nil(cache.find(query, [:cancelled]))
150
+ cache.add(query, [:budget], nil, Dnsruby::ValidationLimitError.new)
151
+ assert_instance_of(Dnsruby::ValidationLimitError, cache.find(query, [:budget]).last)
152
+ cache.add(query, [:cancelled_validation], nil, Dnsruby::ValidationCancelled.new)
153
+ assert_nil(cache.find(query, [:cancelled_validation]))
154
+ cache.add(query, [:inherited_budget], nil, Dnsruby::ValidationLimitError.new,
155
+ control: Dnsruby::QueryControl.new)
156
+ assert_nil(cache.find(query, [:inherited_budget]))
157
+ end
158
+
159
+ def test_cached_validation_failure_preserves_security_state_and_ages_rr_ttl
160
+ cache = Dnsruby::Cache::ResolutionCache.new
161
+ query = Dnsruby::Message.new('missing.example.', 'A')
162
+ response = Dnsruby::Message.new('missing.example.', 'A')
163
+ response.add_answer(Dnsruby::RR.create('missing.example. 90 IN A 192.0.2.1'))
164
+ response.security_level = Dnsruby::Message::SecurityLevel::BOGUS
165
+ response.security_error = 'verification failed'
166
+ Time.stub(:now, @now) { cache.add(query, [:scope], response, Dnsruby::VerifyError.new('verification failed')) }
167
+ Time.stub(:now, @now + 1) do
168
+ cached, error = cache.find(query, [:scope])
169
+ assert_equal(89, cached.answer.first.ttl)
170
+ assert_equal(Dnsruby::Message::SecurityLevel::BOGUS, cached.security_level)
171
+ assert_equal('verification failed', cached.security_error)
172
+ assert_instance_of(Dnsruby::VerifyError, error)
173
+ end
174
+ end
175
+
176
+ def test_insecure_negative_requires_and_preserves_authenticated_proof_lifetime
177
+ cache = Dnsruby::Cache::ResolutionCache.new
178
+ query = Dnsruby::Message.new('missing.example.', 'A')
179
+ query.do_validation = true
180
+ response = negative(:NOERROR)
181
+ response.security_level = Dnsruby::Message::SecurityLevel::INSECURE
182
+ Time.stub(:now, @now) do
183
+ cache.add(query, [:scope], response, nil)
184
+ assert_nil(cache.find(query, [:scope]))
185
+ response.instance_variable_set(:@dnsruby_authenticated_expiration, @now.to_i + 3)
186
+ cache.add(query, [:scope], response, nil)
187
+ end
188
+ Time.stub(:now, @now + 2) do
189
+ found, error = cache.find(query, [:scope])
190
+ assert_nil(error)
191
+ assert_equal(1, found.authority.first.ttl)
192
+ assert_equal(@now.to_i + 3, found.instance_variable_get(:@dnsruby_authenticated_expiration))
193
+ end
194
+ Time.stub(:now, @now + 3) { assert_nil(cache.find(query, [:scope])) }
195
+ end
196
+
197
+ def test_configuration_change_during_resolution_prevents_insertion
198
+ selected = nil
199
+ producer = lambda do |_query|
200
+ selected.query_timeout = 7
201
+ [negative(:NOERROR), nil]
202
+ end
203
+ with_resolver(producer) do |resolver, attempts|
204
+ selected = resolver
205
+ ask(resolver)
206
+ ask(resolver)
207
+ assert_equal(2, attempts.call)
208
+ ask(resolver)
209
+ assert_equal(2, attempts.call)
210
+ end
211
+ end
212
+
213
+ def test_explicit_clear_invalidates_resolver_completion_cache
214
+ with_resolver(lambda { |_query| [negative(:NOERROR), nil] }) do |resolver, attempts|
215
+ ask(resolver)
216
+ Dnsruby::PacketSender.clear_caches
217
+ ask(resolver)
218
+ assert_equal(2, attempts.call)
219
+ end
220
+ end
221
+
222
+ private
223
+
224
+ def negative(rcode, ttl: 80, minimum: 15, name: 'missing.example.')
225
+ response = Dnsruby::Message.new(name, 'A')
226
+ response.header.rcode = Dnsruby::RCode.new(rcode.to_s)
227
+ response.add_authority(Dnsruby::RR.create("example. #{ttl} IN SOA ns.example. hostmaster.example. 1 60 60 60 #{minimum}"))
228
+ response
229
+ end
230
+
231
+ def ask(resolver, message = Dnsruby::Message.new('missing.example.', 'A'))
232
+ queue = Queue.new
233
+ @id = (@id || 100) + 1
234
+ message.header.id = @id
235
+ resolver.send_async(message, queue, @id)
236
+ queue.pop
237
+ end
238
+
239
+ def with_resolver(producer)
240
+ resolver = Dnsruby::Resolver.new(nameserver: ['192.0.2.53'])
241
+ engine = Dnsruby::ResolverRuby.new(resolver)
242
+ resolver.instance_variable_set(:@resolver_ruby, engine)
243
+ selector = Object.new
244
+ selector.define_singleton_method(:add_observer) { |*_args| }
245
+ selector.define_singleton_method(:remove_observer) { |*_args| }
246
+ count = 0
247
+ engine.define_singleton_method(:tick) do |id = nil|
248
+ next if id.nil?
249
+ resolver.single_res_mutex.synchronize do
250
+ query, client_queue, select_queue = @query_list.fetch(id)
251
+ count += 1
252
+ response, error = producer.call(query)
253
+ send_result(client_queue, id, select_queue, response, error)
254
+ end
255
+ end
256
+ Dnsruby::SelectThread.stub(:instance, selector) do
257
+ yield resolver, -> { count }
258
+ end
259
+ ensure
260
+ resolver&.close
261
+ end
262
+ end
263
+
264
+ class ReleaseCacheCompletionFollowupsTest < Minitest::Test
265
+ def test_dname_only_negative_uses_target_soa_and_question_specific_cache
266
+ query = Dnsruby::Message.new('www.old.example.', 'AAAA')
267
+ message = Dnsruby::Message.new('www.old.example.', 'AAAA')
268
+ message.add_answer(Dnsruby::RR.create('old.example. 60 IN DNAME new.example.'))
269
+ soa = Dnsruby::RR.create('new.example. 60 IN SOA ns.new.example. hostmaster.new.example. 1 3600 600 86400 30')
270
+ message.add_authority(soa)
271
+ assert_same(soa, Dnsruby::Cache.negative_soa(message))
272
+ cache = Dnsruby::Cache::ResolutionCache.new
273
+ cache.add(query, [:scope], message, nil)
274
+ refute_nil(cache.find(query, [:scope]))
275
+ assert_nil(cache.find(Dnsruby::Message.new('www.old.example.', 'DNAME'), [:scope]))
276
+ message.add_answer(Dnsruby::RR.create('www.new.example. 60 IN AAAA 2001:db8::1'))
277
+ assert_nil(Dnsruby::Cache.negative_soa(message))
278
+ end
279
+
280
+ def test_latest_success_replaces_failure_and_negative_completions
281
+ query = Dnsruby::Message.new('host.example.', 'A')
282
+ positive = Dnsruby::Message.new('host.example.', 'A')
283
+ positive.add_answer(Dnsruby::RR.create('host.example. 60 IN A 192.0.2.1'))
284
+ negative = Dnsruby::Message.new('host.example.', 'A')
285
+ negative.header.rcode = Dnsruby::RCode.NXDOMAIN
286
+ negative.add_authority(Dnsruby::RR.create('example. 60 IN SOA ns.example. hostmaster.example. 1 3600 600 86400 30'))
287
+ cache = Dnsruby::Cache::ResolutionCache.new
288
+ cache.add(query, [:scope], nil, Dnsruby::ServFail.new('temporary failure'))
289
+ refute_nil(cache.find(query, [:scope]))
290
+ cache.add(query, [:scope], positive, nil)
291
+ assert_nil(cache.find(query, [:scope]))
292
+ cache.add(query, [:scope], negative, Dnsruby::NXDomain.new('missing'))
293
+ refute_nil(cache.find(query, [:scope]))
294
+ cache.add(query, [:scope], positive, nil)
295
+ assert_nil(cache.find(query, [:scope]))
296
+ cache.add(query, [:scope], negative, Dnsruby::NXDomain.new('missing'))
297
+ cache.add(query, [:scope], nil, Dnsruby::ServFail.new('later failure'))
298
+ _message, error = cache.find(query, [:scope])
299
+ assert_instance_of(Dnsruby::ServFail, error)
300
+ end
301
+
302
+ def test_validation_scope_includes_discovery_route_and_resolver_settings_without_lock_reentry
303
+ previous_resolver = Dnsruby::Dnssec.default_resolver
304
+ previous_mode = Dnsruby::Dnssec.do_validation_with_recursor?
305
+ resolver = Dnsruby::Resolver.new(nameserver: ['127.0.0.1'])
306
+ Dnsruby::Dnssec.default_resolver = resolver
307
+ before = Dnsruby::Cache.validation_scope
308
+ resolver.single_res_mutex.synchronize { assert_equal(before, Dnsruby::Cache.validation_scope) }
309
+ {port: 5353, use_tcp: true, query_timeout: 7, packet_timeout: 2,
310
+ retry_times: 3, retry_delay: 6, nameserver: ['127.0.0.2']}.each do |name, value|
311
+ before = Dnsruby::Cache.validation_scope
312
+ resolver.public_send("#{name}=", value)
313
+ refute_equal(before, Dnsruby::Cache.validation_scope, name.to_s)
314
+ end
315
+ before = Dnsruby::Cache.validation_scope
316
+ Dnsruby::Dnssec.do_validation_with_recursor(!previous_mode)
317
+ refute_equal(before, Dnsruby::Cache.validation_scope)
318
+ before = Dnsruby::Cache.validation_scope
319
+ Dnsruby::Dnssec.default_resolver = Dnsruby::Resolver.new(nameserver: ['127.0.0.2'])
320
+ refute_equal(before, Dnsruby::Cache.validation_scope)
321
+ ensure
322
+ Dnsruby::Dnssec.default_resolver&.close if Dnsruby::Dnssec.default_resolver != previous_resolver
323
+ resolver&.close
324
+ Dnsruby::Dnssec.default_resolver = previous_resolver
325
+ Dnsruby::Dnssec.do_validation_with_recursor(previous_mode)
326
+ end
327
+ end