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,489 @@
1
+ require_relative 'tc_key_cache_identity'
2
+ require_relative 'tc_pipeline_send'
3
+ require_relative 'tc_resolver_validation'
4
+
5
+ class KeyCacheIdentityTest
6
+ def test_expiration_alone_revokes_learned_keys_and_preserves_other_anchors
7
+ clock = @now
8
+ Time.stub(:now, -> { clock }) do
9
+ @verifier.add_trust_anchor_with_expiration(@a, @now.to_i + 1)
10
+ @verifier.add_trust_anchor_with_expiration(@b, @now.to_i + 120)
11
+ assert(@verifier.verify(signed(@a, @a, 0)))
12
+ clock = @now + 2
13
+ # Do not read trust_anchors first: verification must notice expiry itself.
14
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(signed(@record, @a, 0)) }
15
+ assert_empty(@verifier.trusted_keys)
16
+ assert(@verifier.verify(signed(@record, @b, 1)))
17
+ assert_equal([@b], @verifier.trust_anchors)
18
+ end
19
+ end
20
+
21
+ def test_expiration_during_verification_blocks_publication_without_an_anchor_read
22
+ @verifier.add_trust_anchor_with_expiration(@a, @now.to_i + 1)
23
+ clock = @now
24
+ Time.stub(:now, -> { clock }) do
25
+ pause_after_verification(-> { clock = @now + 2 }) do
26
+ assert(@verifier.verify(signed(@a, @a, 0)))
27
+ end
28
+ assert_empty(@verifier.instance_variable_get(:@trusted_keys).keys)
29
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(signed(@record, @a, 0)) }
30
+ end
31
+ end
32
+
33
+ def test_matching_signatures_reuse_canonical_rdata_and_try_different_metadata
34
+ valid = signed(@record, @a, 0).sigs.first
35
+ invalid = 20.times.map do |i|
36
+ valid.clone.tap do |sig|
37
+ sig.original_ttl = 500 if i.odd?
38
+ sig.signature = [i].pack('n') + "\0" * 62
39
+ end
40
+ end
41
+ records = Dnsruby::RRSet.new([@record] + invalid + [valid])
42
+ record = records.rrs.first
43
+ encode = record.method(:encode_rdata)
44
+ encodings = 0
45
+ record.define_singleton_method(:encode_rdata) do |*args|
46
+ encodings += 1
47
+ encode.call(*args)
48
+ end
49
+ # Encoding reuse should not depend on verification crossing a TTL second.
50
+ Time.stub(:now, @now) { assert(@verifier.verify_rrset(records, [@b, @a])) }
51
+ assert_equal(1, encodings)
52
+ assert_equal(300, record.ttl)
53
+ end
54
+
55
+ def test_wildcard_signature_and_invalid_label_count
56
+ wildcard = Dnsruby::RR.create('*.collision.invalid. 300 IN A 192.0.2.1')
57
+ signature = signed(wildcard, @a, 0).sigs.first
58
+ signature.labels = 2
59
+ bytes = Dnsruby::MessageEncoder.new { |encoder| encoder.put_rr(wildcard, true) }.to_s
60
+ der = @private_keys.first.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signature.sig_data + bytes))
61
+ signature.signature = OpenSSL::ASN1.decode(der).value.map { |n| n.value.to_s(2).rjust(32, "\0") }.join
62
+ signature.name = @record.name
63
+ records = Dnsruby::RRSet.new([@record, signature])
64
+ assert(@verifier.verify_rrset(records, @a))
65
+ records.sigs.first.labels = 4
66
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify_rrset(records, @a) }
67
+ end
68
+
69
+ def test_signature_limit_is_shared_across_nested_operations_and_can_be_configured
70
+ limits = Dnsruby::Dnssec.validation_limits.merge(max_signature_verifications: 2)
71
+ records = signed(@record, @a, 0)
72
+ budget = Dnsruby::ValidationBudget.new(limits: limits)
73
+ error = assert_raises(Dnsruby::ValidationLimitError) do
74
+ Dnsruby::ValidationBudget.with(budget) do
75
+ assert(@verifier.verify_rrset(records, @a))
76
+ assert(@verifier.verify_rrset(records, @a))
77
+ begin
78
+ @verifier.verify_rrset(records, @a)
79
+ rescue Dnsruby::VerifyError
80
+ flunk('A nested limit must bypass ordinary verification fallbacks')
81
+ end
82
+ end
83
+ end
84
+ assert_nil(Dnsruby::ValidationBudget.current)
85
+ assert_equal(error.backtrace, error.cause.backtrace)
86
+ assert_raises(Dnsruby::ValidationLimitError) { budget.check! }
87
+ assert_raises(Dnsruby::ValidationLimitError) do
88
+ Dnsruby::ValidationBudget.with(budget) { flunk('An exhausted budget was reused') }
89
+ end
90
+ assert(@verifier.verify_rrset(records, @a))
91
+ end
92
+
93
+ def test_direct_verifier_limit_stops_colliding_key_fallback
94
+ limits = Dnsruby::Dnssec.validation_limits.merge(max_signature_verifications: 1)
95
+ records = signed(@record, @a, 0)
96
+ Dnsruby::Dnssec.stub(:validation_limits, limits) do
97
+ assert_raises(Dnsruby::ValidationLimitError) do
98
+ @verifier.verify_rrset(records, [@b, @a])
99
+ end
100
+ assert_nil(Dnsruby::ValidationBudget.current)
101
+ assert(@verifier.verify_rrset(records, @a))
102
+ end
103
+ end
104
+
105
+ def test_dnssec_rrset_entry_point_keeps_the_verifiers_limit
106
+ limits = Dnsruby::Dnssec.validation_limits.merge(max_signature_verifications: 1)
107
+ records = signed(@record, @a, 0)
108
+ Dnsruby::Dnssec.stub(:validation_limits, limits) do
109
+ Dnsruby::Dnssec.anchor_verifier.stub(:verify_rrset, @verifier.method(:verify_rrset)) do
110
+ assert_raises(Dnsruby::ValidationLimitError) do
111
+ Dnsruby::Dnssec.verify_rrset(records, [@b, @a])
112
+ end
113
+ end
114
+ end
115
+ assert_nil(Dnsruby::ValidationBudget.current)
116
+ assert_nil(Dnsruby::QueryControl.current)
117
+ end
118
+
119
+ def test_direct_verifier_checks_cancellation_after_an_early_success_return
120
+ records = signed(@record, @a, 0)
121
+ verify = ->(*) { Dnsruby::ValidationBudget.current.cancel; true }
122
+ @verifier.stub(:verify_rrset_with_key, verify) do
123
+ assert_raises(Dnsruby::ValidationCancelled) { @verifier.verify_rrset(records, @a) }
124
+ end
125
+ assert_nil(Dnsruby::ValidationBudget.current)
126
+ end
127
+
128
+ def test_discovery_exhaustion_does_not_become_an_insecure_delegation
129
+ @verifier.add_trust_anchor(@a)
130
+ key_response = Dnsruby::Message.new(@a.name, 'DNSKEY')
131
+ signed(@a, @a, 0).each { |rr| key_response.add_answer(rr) }
132
+ message = Dnsruby::Message.new(@record.name, 'A')
133
+ queries = []
134
+ resolver = Object.new
135
+ resolver.define_singleton_method(:query_no_validation_or_recursion) do |name, type|
136
+ queries << [name, type]
137
+ if type == Dnsruby::Types.DS
138
+ Dnsruby::ValidationBudget.current.check!(100_001)
139
+ end
140
+ key_response
141
+ end
142
+
143
+ Dnsruby::Dnssec.stub(:do_validation_with_recursor?, false) do
144
+ Dnsruby::Dnssec.stub(:default_resolver, resolver) do
145
+ @verifier.stub(:get_nameservers_for, resolver) do
146
+ @verifier.stub(:get_nameservers_from_message, resolver) do
147
+ assert_raises(Dnsruby::ValidationLimitError) do
148
+ @verifier.try_to_follow_from_anchor(@a, message, @record.name)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ assert_equal([Dnsruby::Types.DNSKEY, Dnsruby::Types.DS], queries.map(&:last))
155
+ refute_equal(Dnsruby::Message::SecurityLevel.INSECURE, message.security_level)
156
+ assert_nil(Dnsruby::ValidationBudget.current)
157
+ end
158
+
159
+ def test_work_limit_bounds_nsec3_hash_iterations
160
+ budget = Dnsruby::ValidationBudget.new(limits: Dnsruby::Dnssec.validation_limits.merge(max_work: 100))
161
+ assert_raises(Dnsruby::ValidationLimitError) do
162
+ Dnsruby::ValidationBudget.with(budget) do
163
+ Dnsruby::RR::NSEC3.calculate_hash('example.', 1000, ''.b, Dnsruby::Nsec3HashAlgorithms.SHA_1)
164
+ end
165
+ end
166
+ end
167
+
168
+ def test_cancellation_after_crypto_prevents_success_or_learned_key_publication
169
+ records = signed(@a, @a, 0)
170
+ @verifier.add_trust_anchor(@a)
171
+ budget = Dnsruby::ValidationBudget.new
172
+ key = @a.public_key
173
+ verify = key.method(:dsa_verify_asn1)
174
+ key.define_singleton_method(:dsa_verify_asn1) do |*args|
175
+ result = verify.call(*args)
176
+ budget.cancel
177
+ result
178
+ end
179
+ assert_raises(Dnsruby::ValidationCancelled) do
180
+ Dnsruby::ValidationBudget.with(budget) { @verifier.verify(records) }
181
+ end
182
+ assert_empty(@verifier.trusted_keys)
183
+ end
184
+ end
185
+
186
+ class DnssecBudgetTest < Minitest::Test
187
+ def test_lookup_stops_cross_validation_fallbacks_and_become_public_errors
188
+ [Dnsruby::QueryControl::Cancelled, Dnsruby::QueryControl::DeadlineExceeded].each do |stop_class|
189
+ public_class = stop_class == Dnsruby::QueryControl::Cancelled ?
190
+ Dnsruby::ValidationCancelled : Dnsruby::ValidationLimitError
191
+ error = assert_raises(public_class) do
192
+ Dnsruby::ValidationBudget.with do |budget|
193
+ assert_same(budget.control, Dnsruby::QueryControl.current)
194
+ begin
195
+ Dnsruby::ValidationBudget.with do
196
+ if stop_class == Dnsruby::QueryControl::Cancelled
197
+ budget.control.cancel
198
+ Dnsruby::QueryControl.current.check!
199
+ else
200
+ Dnsruby::TransportClock.stub(:now, budget.deadline) do
201
+ Dnsruby::QueryControl.current.check!
202
+ end
203
+ end
204
+ end
205
+ rescue Dnsruby::VerifyError, Dnsruby::ResolvError
206
+ flunk('A stopped lookup must bypass validation and lookup fallbacks')
207
+ end
208
+ end
209
+ end
210
+ assert_instance_of(stop_class, error.cause)
211
+ assert_equal(error.cause.backtrace, error.backtrace)
212
+ assert_nil(Dnsruby::ValidationBudget.current)
213
+ assert_nil(Dnsruby::QueryControl.current)
214
+ end
215
+ end
216
+
217
+ def test_validation_obeys_and_restores_an_enclosing_lookup_control
218
+ parent = Dnsruby::QueryControl.new(deadline: Dnsruby::TransportClock.now + 1)
219
+ assert_raises(Dnsruby::QueryControl::Cancelled) do
220
+ Dnsruby::QueryControl.with(parent) do
221
+ error = assert_raises(Dnsruby::ValidationCancelled) do
222
+ Dnsruby::ValidationBudget.with do |budget|
223
+ assert_equal(parent.deadline, budget.deadline)
224
+ parent.cancel
225
+ budget.check!
226
+ end
227
+ end
228
+ assert_instance_of(Dnsruby::QueryControl::Cancelled, error.cause)
229
+ assert_same(parent, Dnsruby::QueryControl.current)
230
+ assert_nil(Dnsruby::ValidationBudget.current)
231
+ end
232
+ end
233
+ assert_nil(Dnsruby::QueryControl.current)
234
+ end
235
+
236
+ def test_lookup_deadline_limits_validation_even_when_its_own_timeout_is_longer
237
+ parent = Dnsruby::QueryControl.new(deadline: Dnsruby::TransportClock.now + 1)
238
+ Dnsruby::QueryControl.with(parent) do
239
+ error = assert_raises(Dnsruby::ValidationLimitError) do
240
+ Dnsruby::ValidationBudget.with do |budget|
241
+ Dnsruby::TransportClock.stub(:now, parent.deadline) { budget.check! }
242
+ end
243
+ end
244
+ assert_instance_of(Dnsruby::QueryControl::DeadlineExceeded, error.cause)
245
+ assert_same(parent, Dnsruby::QueryControl.current)
246
+ end
247
+ assert_nil(Dnsruby::QueryControl.current)
248
+ end
249
+
250
+ def test_lookup_waits_do_not_consume_dnssec_work_allowance
251
+ limits = Dnsruby::Dnssec.validation_limits.merge(max_work: 1)
252
+ budget = Dnsruby::ValidationBudget.new(limits: limits)
253
+ replies = Queue.new
254
+ 3.times { replies << :reply }
255
+ Dnsruby::ValidationBudget.with(budget) do
256
+ budget.check!(1)
257
+ 3.times { assert_equal(:reply, Dnsruby::QueryControl.current.pop(replies)) }
258
+ end
259
+ assert_raises(Dnsruby::ValidationLimitError) { budget.check!(1) }
260
+ end
261
+
262
+ def test_exhaustion_stays_terminal_when_a_callback_swallows_the_abort
263
+ budget = Dnsruby::ValidationBudget.new(limits: Dnsruby::Dnssec.validation_limits.merge(max_work: 1))
264
+ rescued = false
265
+ assert_raises(Dnsruby::ValidationLimitError) do
266
+ Dnsruby::ValidationBudget.with(budget) do
267
+ begin
268
+ budget.check!(2)
269
+ rescue StandardError
270
+ rescued = true
271
+ end
272
+ true
273
+ end
274
+ end
275
+ assert(rescued)
276
+ assert_nil(Dnsruby::ValidationBudget.current)
277
+ end
278
+
279
+ def test_direct_validation_callback_does_not_swallow_cancellation
280
+ level = Dnsruby::Message::SecurityLevel.BOGUS
281
+ callback = ->(*) do
282
+ Dnsruby::ValidationBudget.with do |budget|
283
+ budget.cancel
284
+ budget.check!
285
+ end
286
+ end
287
+ assert_raises(Dnsruby::ValidationCancelled) do
288
+ Dnsruby::Dnssec.try_validation(level, '', level, callback, Dnsruby::Message.new, nil)
289
+ end
290
+ assert_nil(Dnsruby::ValidationBudget.current)
291
+ end
292
+
293
+ def test_anchor_fallbacks_share_limits_and_stop_at_exhaustion
294
+ limits = Dnsruby::Dnssec.validation_limits.merge(max_signature_verifications: 1)
295
+ calls = []
296
+ anchor = ->(*) do
297
+ calls << :anchor
298
+ Dnsruby::ValidationBudget.current.signature!
299
+ raise Dnsruby::VerifyError, 'Try another anchor'
300
+ end
301
+ root = ->(*) do
302
+ calls << :root
303
+ Dnsruby::ValidationBudget.current.signature!
304
+ raise Dnsruby::VerifyError, 'Try DLV'
305
+ end
306
+ dlv = ->(*) { flunk('Exhaustion must not try the DLV fallback') }
307
+ Dnsruby::Dnssec.stub(:validation_limits, limits) do
308
+ Dnsruby::Dnssec.anchor_verifier.stub(:verify_result, anchor) do
309
+ Dnsruby::Dnssec.root_verifier.stub(:verify_result, root) do
310
+ Dnsruby::Dnssec.dlv_verifier.stub(:verify_result, dlv) do
311
+ assert_raises(Dnsruby::ValidationLimitError) do
312
+ Dnsruby::Dnssec.verify(Dnsruby::Message.new('example.', 'A'))
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
318
+ assert_equal([:anchor, :root], calls)
319
+ assert_nil(Dnsruby::ValidationBudget.current)
320
+ end
321
+
322
+ def test_worker_delivers_one_public_terminal_error_and_unregisters
323
+ [Dnsruby::ValidationLimitError, Dnsruby::ValidationCancelled].each do |error_class|
324
+ completions, registrations, removals = [], [], []
325
+ selector = Object.new
326
+ selector.define_singleton_method(:register_validation) { |*args| registrations << args; true }
327
+ selector.define_singleton_method(:unregister_validation) { |*args| removals << args }
328
+ selector.define_singleton_method(:push_validation_response_to_select) { |*args| completions << args }
329
+ resolver = Object.new
330
+ resolver.define_singleton_method(:dnssec) { true }
331
+ query = Dnsruby::Message.new('example.', 'A')
332
+ query.do_validation = true
333
+ response = query.clone
334
+ response.header.qr = true
335
+ validator = Dnsruby::ValidatorThread.new(:client, Queue.new, response, nil, query, selector, resolver)
336
+ validate = ->(*) do
337
+ budget = Dnsruby::ValidationBudget.current
338
+ budget.cancel if error_class == Dnsruby::ValidationCancelled
339
+ budget.check!(100_001)
340
+ end
341
+ Dnsruby::Dnssec.stub(:validate_with_query, validate) do
342
+ Dnsruby::PacketSender.stub(:cache, ->(*) { flunk('An aborted response was cached') }) do
343
+ validator.do_validate
344
+ end
345
+ end
346
+ assert_equal(1, completions.length)
347
+ assert_instance_of(error_class, completions.first[3])
348
+ assert_equal(Dnsruby::Message::SecurityLevel.BOGUS, response.security_level)
349
+ assert_equal(1, registrations.length)
350
+ assert_instance_of(Dnsruby::QueryControl, registrations.first.last)
351
+ assert_equal(registrations, removals)
352
+ assert_nil(Dnsruby::ValidationBudget.current)
353
+ assert_nil(Dnsruby::QueryControl.current)
354
+ end
355
+ end
356
+
357
+ def test_deadline_and_configuration_validation
358
+ budget = Dnsruby::ValidationBudget.new(deadline: Dnsruby::TransportClock.now - 1)
359
+ assert_raises(Dnsruby::ValidationLimitError) { budget.check! }
360
+ budget = Dnsruby::ValidationBudget.new
361
+ budget.cancel
362
+ assert_raises(Dnsruby::ValidationCancelled) { budget.check! }
363
+ [:timeout, :max_work, :max_signature_verifications].each do |option|
364
+ [0, -1, nil, '100'].each do |bad|
365
+ assert_raises(ArgumentError) { Dnsruby::Dnssec.validation_limits = { option => bad } }
366
+ end
367
+ end
368
+ assert_raises(ArgumentError) { Dnsruby::Dnssec.validation_limits = { unknown: 3 } }
369
+ end
370
+
371
+ def test_async_validation_admission_bounds_workers_and_queue
372
+ code = <<~'RUBY'
373
+ require 'dnsruby'
374
+ require 'timeout'
375
+ entered, release = Queue.new, Queue.new
376
+ job = Object.new
377
+ job.define_singleton_method(:do_validate) { entered << true; release.pop }
378
+ 4.times { raise 'worker rejected' unless Dnsruby::ValidatorThread.enqueue(job) }
379
+ Timeout.timeout(3) { 4.times { entered.pop } }
380
+ 64.times { raise 'queue rejected' unless Dnsruby::ValidatorThread.enqueue(job) }
381
+ raise 'unbounded admission' if Dnsruby::ValidatorThread.enqueue(job)
382
+ raise 'too many workers' unless entered.empty?
383
+ 68.times { release << true }
384
+ Timeout.timeout(3) { 64.times { entered.pop } }
385
+ RUBY
386
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), '-e', code)
387
+ assert(status.success?, "#{stdout}\n#{stderr}")
388
+ assert_empty(stderr)
389
+ end
390
+
391
+ def test_validation_pool_recovers_after_fork_and_worker_exit
392
+ skip 'fork unavailable' unless Process.respond_to?(:fork)
393
+ code = <<~'RUBY'
394
+ require 'dnsruby'
395
+ require 'timeout'
396
+ completed = Queue.new
397
+ job = Object.new
398
+ job.define_singleton_method(:do_validate) { completed << true }
399
+ raise 'initial admission failed' unless Dnsruby::ValidatorThread.enqueue(job)
400
+ Timeout.timeout(3) { completed.pop }
401
+ child_status = ForkTestHelper.run do
402
+ raise 'child rejected' unless Dnsruby::ValidatorThread.enqueue(job)
403
+ Timeout.timeout(3) { completed.pop }
404
+ exit! 0
405
+ end
406
+ raise 'child worker did not run' unless child_status.success?
407
+ # Emulate external termination of otherwise idle workers.
408
+ Dnsruby::ValidatorThread.instance_variable_get(:@workers).each { |worker| worker.kill; worker.join }
409
+ raise 'replacement rejected' unless Dnsruby::ValidatorThread.enqueue(job)
410
+ Timeout.timeout(3) { completed.pop }
411
+ RUBY
412
+ source = "require #{File.expand_path('fork_test_helper', __dir__).inspect}\n#{code}"
413
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), '-e', source)
414
+ assert(status.success?, "#{stdout}\n#{stderr}")
415
+ assert_empty(stderr)
416
+ end
417
+
418
+ def test_partial_worker_creation_failure_keeps_pool_bounded_and_recoverable
419
+ code = <<~'RUBY'
420
+ require 'dnsruby'
421
+ require 'timeout'
422
+ original = Thread.method(:new)
423
+ Thread.define_singleton_method(:new) { |*| raise ThreadError, 'no worker available' }
424
+ raise 'work accepted with no workers' if Dnsruby::ValidatorThread.enqueue(Object.new)
425
+ started = 0
426
+ Thread.define_singleton_method(:new) do |*args, &block|
427
+ raise ThreadError, 'worker unavailable' if started == 1
428
+ started += 1
429
+ original.call(*args, &block)
430
+ end
431
+ completed = Queue.new
432
+ job = Object.new
433
+ job.define_singleton_method(:do_validate) { completed << true }
434
+ raise 'partial pool rejected work' unless Dnsruby::ValidatorThread.enqueue(job)
435
+ Thread.define_singleton_method(:new, original)
436
+ Timeout.timeout(3) { completed.pop }
437
+ raise 'partial workers untracked' unless Dnsruby::ValidatorThread.instance_variable_get(:@workers).count(&:alive?) == 1
438
+ raise 'restored admission failed' unless Dnsruby::ValidatorThread.enqueue(job)
439
+ Timeout.timeout(3) { completed.pop }
440
+ raise 'pool not replenished' unless Dnsruby::ValidatorThread.instance_variable_get(:@workers).count(&:alive?) == 4
441
+ RUBY
442
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), '-e', code)
443
+ assert(status.success?, "#{stdout}\n#{stderr}")
444
+ assert_empty(stderr)
445
+ end
446
+
447
+ def test_nested_discovery_timeout_cancels_only_its_own_query
448
+ code = TestPipelineSend::FIXTURE + <<~'RUBY'
449
+ SELECTOR.singleton_class.remove_method(:notify_queue_observers)
450
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', do_caching: false,
451
+ retry_times: 1, query_timeout: 60)
452
+ resolver.single_resolvers.each { |sender| sender.define_singleton_method(:send_async) { |*| } }
453
+ unrelated = Queue.new
454
+ resolver.send_async(query('unrelated.example.'), unrelated, :unrelated)
455
+ budget = Dnsruby::ValidationBudget.new(limits: Dnsruby::Dnssec.validation_limits.merge(timeout: 0.02))
456
+ begin
457
+ Dnsruby::ValidationBudget.with(budget) { resolver.query_no_validation_or_recursion('nested.example.', 'DNSKEY') }
458
+ raise 'discovery did not time out'
459
+ rescue Dnsruby::ValidationLimitError
460
+ end
461
+ engine = resolver.instance_variable_get(:@resolver_ruby)
462
+ raise 'nested query retained or unrelated cancelled' unless engine.instance_variable_get(:@query_list).keys == [:unrelated]
463
+ resolver.close
464
+ raise 'nested observer retained' unless K.class_variable_get(:@@observers).empty?
465
+ RUBY
466
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), '-e', code)
467
+ assert(status.success?, "#{stdout}\n#{stderr}")
468
+ assert_empty(stderr)
469
+ end
470
+ end
471
+
472
+ class TestResolverValidation
473
+ def test_validation_handoff_is_not_duplicated_if_delivery_crosses_deadline
474
+ isolated(<<~'RUBY')
475
+ start_query(resolver, results)
476
+ accept_answer(attempts.first)
477
+ push = SELECTOR.method(:push_validation_response_to_select)
478
+ SELECTOR.define_singleton_method(:push_validation_response_to_select) do |*args|
479
+ push.call(*args)
480
+ clock += 11
481
+ end
482
+ validators.shift.do_validate
483
+ raise 'validation delivered twice' unless K.class_variable_get(:@@queued_validation_responses).length == 1
484
+ SELECTOR.send_queued_validation_responses
485
+ expect_completion(results, Dnsruby::ResolvTimeout)
486
+ expect_clean(resolver)
487
+ RUBY
488
+ end
489
+ end
@@ -0,0 +1,197 @@
1
+ require_relative 'tc_dnssec_cache_lifetime'
2
+
3
+ class KeyCacheIdentityTest
4
+ def test_candidate_index_keeps_collisions_and_removes_evicted_and_expired_keys
5
+ cache = Dnsruby::KeyCache.new(max_keys: 2)
6
+ signature = signed(@record, @a, 0).sigs.first
7
+ cache.add_key_with_expiration(@a, @now.to_i + 60)
8
+ cache.add_key_with_expiration(@b, @now.to_i + 120)
9
+ assert_equal([@a, @b], cache.matching_keys([signature]))
10
+
11
+ unrelated = @a.clone
12
+ unrelated.name = 'another.invalid.'
13
+ cache.add_key_with_expiration(unrelated, @now.to_i + 120)
14
+ assert_equal([@b], cache.matching_keys([signature]))
15
+ assert_equal([@b, unrelated], cache.keys)
16
+ Time.stub(:now, @now + 121) do
17
+ assert_empty(cache.matching_keys([signature]))
18
+ assert_empty(cache.keys)
19
+ end
20
+ end
21
+
22
+ def test_unrelated_cached_keys_do_not_consume_signature_matching_budget
23
+ 24.times do |i|
24
+ key = @a.clone
25
+ key.name = "zone#{i}.invalid."
26
+ @verifier.add_trusted_key(Dnsruby::RRSet.new([key]), @now.to_i + 300)
27
+ end
28
+ @verifier.add_trust_anchor(@a)
29
+ budget = Dnsruby::ValidationBudget.new(limits: Dnsruby::Dnssec.validation_limits.merge(max_work: 16))
30
+ Dnsruby::ValidationBudget.with(budget) do
31
+ assert(@verifier.verify_rrset(signed(@record, @a, 0)))
32
+ end
33
+ end
34
+
35
+ def test_configured_ds_index_preserves_colliding_key_identities
36
+ [@a, @b].each { |key| @verifier.add_trust_anchor(Dnsruby::RR::DS.from_key(key, 2)) }
37
+ assert(@verifier.verify(signed(@a, @a, 0)))
38
+ assert(@verifier.verify(signed(@b, @b, 1)))
39
+ end
40
+
41
+ def test_ds_anchor_expiration_prevents_in_flight_key_publication
42
+ anchor = Dnsruby::RR::DS.from_key(@a, 2)
43
+ @verifier.add_trust_anchor_with_expiration(anchor, @now.to_i + 1)
44
+ clock = @now
45
+ Time.stub(:now, -> { clock }) do
46
+ pause_after_verification(-> { clock = @now + 1 }) do
47
+ assert(@verifier.verify(signed(@a, @a, 0)))
48
+ end
49
+ assert_empty(@verifier.trusted_keys)
50
+ assert_empty(@verifier.trust_anchors)
51
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(signed(@record, @a, 0)) }
52
+ end
53
+ end
54
+
55
+ def test_public_rrset_verification_uses_root_and_dlv_fallbacks
56
+ [:root_verifier, :dlv_verifier].each do |destination|
57
+ Dnsruby::Dnssec.reset
58
+ Dnsruby::Dnssec.public_send(destination).add_trust_anchor(@a)
59
+ assert(Dnsruby::Dnssec.verify_rrset(signed(@record, @a, 0)), destination.to_s)
60
+ end
61
+ ensure
62
+ Dnsruby::Dnssec.reset
63
+ end
64
+
65
+ def test_public_rrset_fallback_keeps_one_signature_budget
66
+ Dnsruby::Dnssec.reset
67
+ Dnsruby::Dnssec.anchor_verifier.add_trust_anchor(@b)
68
+ Dnsruby::Dnssec.root_verifier.add_trust_anchor(@a)
69
+ limits = Dnsruby::Dnssec.validation_limits.merge(max_signature_verifications: 1)
70
+ Dnsruby::Dnssec.stub(:validation_limits, limits) do
71
+ assert_raises(Dnsruby::ValidationLimitError) do
72
+ Dnsruby::Dnssec.verify_rrset(signed(@record, @a, 0))
73
+ end
74
+ end
75
+ assert_nil(Dnsruby::ValidationBudget.current)
76
+ assert_nil(Dnsruby::QueryControl.current)
77
+ ensure
78
+ Dnsruby::Dnssec.reset
79
+ end
80
+
81
+ def test_public_rrset_fallback_does_not_swallow_cancellation
82
+ visited = []
83
+ stopped = lambda do |*|
84
+ visited << :anchor
85
+ Dnsruby::ValidationBudget.current.cancel
86
+ raise Dnsruby::VerifyError, 'No usable local key'
87
+ end
88
+ root = ->(*) { visited << :root; true }
89
+ Dnsruby::Dnssec.anchor_verifier.stub(:verify_rrset, stopped) do
90
+ Dnsruby::Dnssec.root_verifier.stub(:verify_rrset, root) do
91
+ assert_raises(Dnsruby::ValidationCancelled) do
92
+ Dnsruby::Dnssec.verify_rrset(signed(@record, @a, 0))
93
+ end
94
+ end
95
+ end
96
+ assert_equal([:anchor], visited)
97
+ assert_nil(Dnsruby::ValidationBudget.current)
98
+ end
99
+ end
100
+
101
+ class DnssecCacheLifetimeTest
102
+ def test_learned_dnskey_capacity_also_bounds_provenance_and_keeps_configured_anchors
103
+ verifier = new_verifier
104
+ verifier.instance_variable_set(:@trusted_keys, Dnsruby::KeyCache.new(max_keys: 2))
105
+ verifier.add_trust_anchor(@parent)
106
+ at(0) do
107
+ 3.times do |i|
108
+ key = @child.clone
109
+ key.name = "child#{i}.cache.invalid."
110
+ assert(verifier.verify(signed(key, key, 1), key))
111
+ end
112
+ assert_equal(2, verifier.trusted_keys.length)
113
+ assert_equal(2, verifier.instance_variable_get(:@trusted_key_provenance).length)
114
+ assert_equal([@parent], verifier.trust_anchors)
115
+ end
116
+ end
117
+
118
+ def test_learned_ds_capacity_evicts_complete_rrsets_and_keeps_configured_anchors
119
+ verifier = new_verifier
120
+ verifier.instance_variable_set(:@discovered_ds_store, Dnsruby::DSCache.new(max_records: 4))
121
+ verifier.add_trust_anchor(@parent)
122
+ at(0) do
123
+ 3.times do |i|
124
+ key = @child.clone
125
+ key.name = "child#{i}.cache.invalid."
126
+ assert(verifier.verify(signed(Dnsruby::RR::DS.from_key(key, 2), @parent, 0)))
127
+ end
128
+ assert_equal(['child1.cache.invalid', 'child2.cache.invalid'], verifier.trusted_keys.map { |ds| ds.name.to_s })
129
+ assert_equal([@parent], verifier.trust_anchors)
130
+ end
131
+ end
132
+
133
+ def test_unrelated_ds_rrsets_do_not_consume_candidate_budget
134
+ verifier = new_verifier
135
+ verifier.add_trust_anchor(@parent)
136
+ at(0) do
137
+ 24.times do |i|
138
+ key = @child.clone
139
+ key.name = "child#{i}.cache.invalid."
140
+ assert(verifier.verify(signed(Dnsruby::RR::DS.from_key(key, 2), @parent, 0)))
141
+ end
142
+ assert(verifier.verify(signed(@ds, @parent, 0)))
143
+ budget = Dnsruby::ValidationBudget.new(limits: Dnsruby::Dnssec.validation_limits.merge(max_work: 16))
144
+ Dnsruby::ValidationBudget.with(budget) { assert(verifier.verify(signed(@child, @child, 1))) }
145
+ end
146
+ end
147
+
148
+ def test_timed_ds_and_dlv_anchors_expire_and_invalidate_dependent_trust
149
+ ['DS', 'DLV'].each do |type|
150
+ verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::DLV)
151
+ anchor = Dnsruby::RR.create(@ds.to_s.sub(' DS ', " #{type} "))
152
+ verifier.add_trust_anchor_with_expiration(anchor, @now.to_i + 5)
153
+ verifier.add_trust_anchor(@parent)
154
+ identity = at(0) do
155
+ assert(verifier.verify(signed(@child, @child, 1)))
156
+ assert_cached_child(verifier)
157
+ verifier.validation_cache_identity
158
+ end
159
+ at(5) do
160
+ assert_raises(Dnsruby::VerifyError) { verifier.verify(signed_child_answer) }
161
+ assert_equal([@parent], verifier.trust_anchors)
162
+ assert_empty(verifier.trusted_keys)
163
+ refute_equal(identity, verifier.validation_cache_identity)
164
+ end
165
+ end
166
+ end
167
+
168
+ def test_removing_equivalent_ds_anchor_ignores_ttl_and_clears_learned_keys
169
+ verifier = new_verifier
170
+ verifier.add_trust_anchor_with_expiration(@ds, @now.to_i + 60)
171
+ equivalent = @ds.clone
172
+ equivalent.ttl += 1
173
+ at(0) do
174
+ assert(verifier.verify(signed(@child, @child, 1)))
175
+ assert_equal(@ds, verifier.remove_trust_anchor(equivalent))
176
+ assert_empty(verifier.trust_anchors)
177
+ assert_empty(verifier.trusted_keys)
178
+ end
179
+ end
180
+
181
+ def test_ds_cache_retains_complete_rrsets_for_digest_preference
182
+ # SHA-256 anywhere in this DS RRset suppresses SHA-1, even if only the
183
+ # SHA-1 key tag matches the DNSKEY being checked (RFC 4509 section 3).
184
+ sha1 = @ds.clone
185
+ sha1.digest_type = 1
186
+ sha1.digestbin = sha1.digest_key(@child)
187
+ sha1.digest = sha1.digestbin.unpack1('H*')
188
+ other = @ds.clone
189
+ other.key_tag = (@ds.key_tag + 1) % 65_536
190
+ verifier = new_verifier
191
+ verifier.add_trust_anchor(sha1)
192
+ verifier.add_trust_anchor(other)
193
+ at(0) do
194
+ assert_raises(Dnsruby::VerifyError) { verifier.verify(signed(@child, @child, 1)) }
195
+ end
196
+ end
197
+ end