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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +16 -4
- data/DNSSEC +3 -2
- data/EXAMPLES +1 -1
- data/README.md +370 -64
- data/RELEASE_NOTES.md +302 -1
- data/Rakefile +0 -3
- data/demo/axfr.rb +24 -6
- data/demo/rubydig.rb +0 -2
- data/dnsruby.gemspec +5 -4
- data/lib/dnsruby/cache.rb +279 -47
- data/lib/dnsruby/code_mapper.rb +35 -8
- data/lib/dnsruby/code_mappers.rb +63 -49
- data/lib/dnsruby/config.rb +75 -4
- data/lib/dnsruby/deadline_queue.rb +75 -0
- data/lib/dnsruby/dnssec.rb +148 -45
- data/lib/dnsruby/ipv6.rb +45 -5
- data/lib/dnsruby/key_cache.rb +202 -35
- data/lib/dnsruby/message/decoder.rb +85 -19
- data/lib/dnsruby/message/encoder.rb +27 -16
- data/lib/dnsruby/message/header.rb +8 -12
- data/lib/dnsruby/message/message.rb +53 -22
- data/lib/dnsruby/message/section.rb +12 -34
- data/lib/dnsruby/name.rb +5 -2
- data/lib/dnsruby/packet_sender.rb +645 -203
- data/lib/dnsruby/presentation.rb +147 -0
- data/lib/dnsruby/query_control.rb +132 -0
- data/lib/dnsruby/recursor.rb +795 -339
- data/lib/dnsruby/resolver.rb +468 -186
- data/lib/dnsruby/resource/CAA.rb +67 -16
- data/lib/dnsruby/resource/DLV.rb +6 -0
- data/lib/dnsruby/resource/DNSKEY.rb +108 -16
- data/lib/dnsruby/resource/DS.rb +27 -28
- data/lib/dnsruby/resource/HTTPS.rb +16 -0
- data/lib/dnsruby/resource/IN.rb +19 -0
- data/lib/dnsruby/resource/MX.rb +2 -2
- data/lib/dnsruby/resource/NSEC.rb +6 -10
- data/lib/dnsruby/resource/NSEC3.rb +55 -19
- data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
- data/lib/dnsruby/resource/RR.rb +108 -65
- data/lib/dnsruby/resource/RRSIG.rb +5 -5
- data/lib/dnsruby/resource/RRSet.rb +38 -22
- data/lib/dnsruby/resource/SRV.rb +2 -2
- data/lib/dnsruby/resource/SVCB.rb +775 -0
- data/lib/dnsruby/resource/TSIG.rb +266 -231
- data/lib/dnsruby/resource/TXT.rb +34 -42
- data/lib/dnsruby/resource/domain_name.rb +1 -1
- data/lib/dnsruby/resource/generic.rb +31 -0
- data/lib/dnsruby/resource/generic_cache.rb +384 -0
- data/lib/dnsruby/resource/generic_constants.rb +79 -0
- data/lib/dnsruby/select_thread.rb +979 -291
- data/lib/dnsruby/serial_time.rb +25 -0
- data/lib/dnsruby/single_resolver.rb +25 -6
- data/lib/dnsruby/single_verifier.rb +1620 -530
- data/lib/dnsruby/transport_clock.rb +9 -0
- data/lib/dnsruby/update.rb +8 -6
- data/lib/dnsruby/validation_budget.rb +143 -0
- data/lib/dnsruby/validator_thread.rb +139 -58
- data/lib/dnsruby/version.rb +1 -1
- data/lib/dnsruby/zone_reader.rb +196 -129
- data/lib/dnsruby/zone_transfer.rb +157 -25
- data/lib/dnsruby.rb +7 -2
- data/test/ci_watchdog.rb +33 -0
- data/test/fixtures/org-ds-2026-11-05.zone +5 -0
- data/test/fixtures/root-dnskey-2026-11-05.zone +7 -0
- data/test/fork_test_helper.rb +37 -0
- data/test/local_dns_server.rb +57 -0
- data/test/soak_server.rb +152 -0
- data/test/socket_helpers.rb +26 -0
- data/test/spec_helper.rb +8 -13
- data/test/tc_attempt_configuration.rb +137 -0
- data/test/tc_caa.rb +2 -2
- data/test/tc_cache_keys.rb +36 -0
- data/test/tc_cancelled_send_capacity.rb +158 -0
- data/test/tc_dnssec_alias_validation.rb +289 -0
- data/test/tc_dnssec_budget.rb +489 -0
- data/test/tc_dnssec_cache_bounds.rb +197 -0
- data/test/tc_dnssec_cache_lifetime.rb +615 -0
- data/test/tc_dnssec_denial_proofs.rb +285 -0
- data/test/tc_dnssec_recursion_limits.rb +186 -0
- data/test/tc_dnssec_recursor_review_fixes.rb +351 -0
- data/test/tc_dnssec_trust_anchors.rb +286 -0
- data/test/tc_dnssec_trust_races.rb +147 -0
- data/test/tc_dnssec_wildcard_proofs.rb +249 -0
- data/test/tc_ds.rb +5 -5
- data/test/tc_edns_retry_regressions.rb +255 -0
- data/test/tc_escapedchars.rb +8 -0
- data/test/tc_final_review_fixes.rb +167 -0
- data/test/tc_final_transport_contracts.rb +104 -0
- data/test/tc_generic_cache.rb +852 -0
- data/test/tc_header.rb +26 -0
- data/test/tc_ipv6.rb +120 -0
- data/test/tc_key_cache_identity.rb +382 -0
- data/test/tc_malformed_tcp.rb +712 -0
- data/test/tc_name.rb +99 -5
- data/test/tc_nameserver_bootstrap.rb +322 -0
- data/test/tc_nsec.rb +4 -45
- data/test/tc_nsec3.rb +17 -1
- data/test/tc_pipeline_retirement.rb +358 -0
- data/test/tc_pipeline_retry.rb +462 -0
- data/test/tc_pipeline_send.rb +663 -0
- data/test/tc_protocol_review_fixes.rb +95 -0
- data/test/tc_query_control.rb +90 -0
- data/test/tc_record_codec_contracts.rb +107 -0
- data/test/tc_record_regressions.rb +225 -0
- data/test/tc_recur.rb +1 -0
- data/test/tc_recursor_address_fallback.rb +169 -0
- data/test/tc_recursor_limits.rb +139 -0
- data/test/tc_release_auxiliary_fixes.rb +288 -0
- data/test/tc_release_configuration_fixes.rb +612 -0
- data/test/tc_release_dnssec_completion.rb +405 -0
- data/test/tc_release_dnssec_fixes.rb +305 -0
- data/test/tc_release_negative_cache.rb +327 -0
- data/test/tc_release_recursor_cache.rb +212 -0
- data/test/tc_release_review_transport.rb +372 -0
- data/test/tc_release_review_transport_native.rb +56 -0
- data/test/tc_release_transport.rb +906 -0
- data/test/tc_release_transport_fixes.rb +196 -0
- data/test/tc_release_zone_defaults.rb +157 -0
- data/test/tc_release_zone_ttls.rb +39 -0
- data/test/tc_res_config.rb +42 -20
- data/test/tc_res_opt.rb +21 -1
- data/test/tc_resolver.rb +21 -19
- data/test/tc_resolver_concurrency.rb +383 -0
- data/test/tc_resolver_configuration_contracts.rb +31 -0
- data/test/tc_resolver_validation.rb +211 -0
- data/test/tc_review_cache.rb +163 -0
- data/test/tc_review_code_mapper.rb +279 -0
- data/test/tc_review_control_deadlines.rb +169 -0
- data/test/tc_review_followup_transport.rb +583 -0
- data/test/tc_review_packet_contracts.rb +369 -0
- data/test/tc_review_presentation.rb +213 -0
- data/test/tc_review_record_compatibility.rb +101 -0
- data/test/tc_review_transport_fixes.rb +195 -0
- data/test/tc_review_transport_native.rb +415 -0
- data/test/tc_review_validation_priming.rb +178 -0
- data/test/tc_rr-opt.rb +5 -2
- data/test/tc_rr-unknown.rb +19 -0
- data/test/tc_rr.rb +14 -0
- data/test/tc_rrset_grouping.rb +50 -0
- data/test/tc_rrset_review_fixes.rb +58 -0
- data/test/tc_rrsig.rb +21 -1
- data/test/tc_scoped_ipv6.rb +153 -0
- data/test/tc_scoped_ipv6_select.rb +157 -0
- data/test/tc_scoped_ipv6_transport.rb +329 -0
- data/test/tc_selector_wakeup.rb +42 -0
- data/test/tc_soak.rb +118 -196
- data/test/tc_socket_helpers.rb +90 -0
- data/test/tc_supporting_lookup_limits.rb +252 -0
- data/test/tc_svcb.rb +1043 -0
- data/test/tc_svcb_signed_target.rb +145 -0
- data/test/tc_svcb_transport.rb +120 -0
- data/test/tc_tcp.rb +69 -43
- data/test/tc_tcp_pipelining.rb +95 -204
- data/test/tc_transport_fork.rb +179 -0
- data/test/tc_tsig.rb +2 -2
- data/test/tc_tsig_api.rb +251 -0
- data/test/tc_tsig_rfc8945.rb +357 -0
- data/test/tc_v2_release_dnssec.rb +442 -0
- data/test/tc_v2_release_transport.rb +304 -0
- data/test/tc_v2_zone_transfer_native.rb +58 -0
- data/test/tc_zone_reader.rb +217 -1
- data/test/tcp_pipelining_server.rb +150 -0
- data/test/ts_offline.rb +77 -6
- data/test/ts_online.rb +2 -1
- metadata +105 -10
- 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
|