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.
- 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 +314 -1
- data/Rakefile +0 -3
- data/demo/axfr.rb +24 -6
- data/dnsruby.gemspec +6 -4
- data/lib/dnsruby/cache.rb +279 -49
- data/lib/dnsruby/code_mapper.rb +35 -8
- data/lib/dnsruby/code_mappers.rb +63 -49
- data/lib/dnsruby/config.rb +77 -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 +664 -207
- data/lib/dnsruby/presentation.rb +147 -0
- data/lib/dnsruby/query_control.rb +132 -0
- data/lib/dnsruby/recursor.rb +798 -341
- data/lib/dnsruby/resolver.rb +472 -176
- 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 +21 -2
- 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/OPT.rb +2 -3
- 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 +986 -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 +53 -2
- data/test/tc_res_opt.rb +21 -1
- data/test/tc_resolver.rb +34 -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 +127 -14
- data/.coveralls.yml +0 -2
data/test/tc_svcb.rb
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
require_relative 'spec_helper'
|
|
2
|
+
|
|
3
|
+
# Tests for the SVCB (type 64) and HTTPS (type 65) resource records, RFC 9460.
|
|
4
|
+
class TestSVCB < Minitest::Test
|
|
5
|
+
|
|
6
|
+
include Dnsruby
|
|
7
|
+
|
|
8
|
+
# Encodes just the RDATA of an RR to a hex string.
|
|
9
|
+
def rdata_hex(rr)
|
|
10
|
+
enc = Dnsruby::MessageEncoder.new
|
|
11
|
+
rr.encode_rdata(enc)
|
|
12
|
+
enc.to_s.unpack1('H*')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Builds an RR from the RDATA half of its presentation format.
|
|
16
|
+
def svcb(rdata, type = 'SVCB')
|
|
17
|
+
RR.create("example.com. 3600 IN #{Types.new(type)} #{rdata}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Builds an RR from its RDATA in wire-format hex.
|
|
21
|
+
def decode(hex, type = RR::IN::SVCB)
|
|
22
|
+
type.decode_rdata(Dnsruby::MessageDecoder.new([hex].pack('H*')))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Asserts every case raises DecodeError. A Hash labels its cases for the
|
|
26
|
+
# failure message; an Array labels each case with itself.
|
|
27
|
+
def assert_all_rejected(cases)
|
|
28
|
+
cases = cases.to_h { |input| [input, input] } unless cases.is_a?(Hash)
|
|
29
|
+
cases.each { |label, input| assert_raises(Dnsruby::DecodeError, label.to_s) { yield(input) } }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Round-trips an RR through a Message and asserts wire and presentation equality.
|
|
33
|
+
def assert_roundtrip(rr)
|
|
34
|
+
m = Dnsruby::Message.new
|
|
35
|
+
m.add_additional(rr)
|
|
36
|
+
m2 = Dnsruby::Message.decode(m.encode)
|
|
37
|
+
rr2 = m2.additional[0]
|
|
38
|
+
assert_equal(rr, rr2, 'record should survive encode/decode')
|
|
39
|
+
assert_equal(rr.rdata_to_string, rr2.rdata_to_string, 'presentation should survive encode/decode')
|
|
40
|
+
rr2
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# HTTPS shares SVCB's whole implementation by subclassing it.
|
|
44
|
+
def test_types_registered
|
|
45
|
+
assert_equal(64, Types::SVCB)
|
|
46
|
+
assert_equal(65, Types::HTTPS)
|
|
47
|
+
assert_equal('SVCB', Types.new(64).string)
|
|
48
|
+
assert_equal('HTTPS', Types.new(65).string)
|
|
49
|
+
assert(RR::IN::HTTPS < RR::SVCB)
|
|
50
|
+
assert_equal(RR::IN::HTTPS, svcb('1 .', 'HTTPS').class)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_former_generic_raw_data_interface_is_independent_of_construction
|
|
54
|
+
bytes = ['0001000003000201bb'].pack('H*')
|
|
55
|
+
[64, 65].each do |type|
|
|
56
|
+
records = [
|
|
57
|
+
RR.create("example.com. 3600 IN TYPE#{type} \\# 9 #{bytes.unpack1('H*')}"),
|
|
58
|
+
RR.create(name: 'example.com.', ttl: 3600, type: type, rdata: bytes),
|
|
59
|
+
RR.create(name: 'example.com.', ttl: 3600, type: type, data: bytes),
|
|
60
|
+
RR.create(name: 'example.com.', ttl: 3600, type: type, priority: 1, target: '.', params: {port: 443}),
|
|
61
|
+
svcb('1 . port=443', type),
|
|
62
|
+
]
|
|
63
|
+
records.each do |rr|
|
|
64
|
+
assert_equal(bytes, rr.data)
|
|
65
|
+
assert_equal(bytes, rr.rdata)
|
|
66
|
+
assert_equal(Encoding::BINARY, rr.rdata.encoding)
|
|
67
|
+
assert_equal(9, rr.rdlength)
|
|
68
|
+
assert_equal(1, rr.priority)
|
|
69
|
+
assert_equal('443', rr.params_to_hash['port'])
|
|
70
|
+
assert_equal(records.first, rr)
|
|
71
|
+
assert_equal(records.first.hash, rr.hash)
|
|
72
|
+
assert_equal(0, records.first <=> rr)
|
|
73
|
+
assert_equal(0, rr <=> records.first)
|
|
74
|
+
assert_roundtrip(rr)
|
|
75
|
+
end
|
|
76
|
+
# Public callers flatten arbitrary answer.rdata values, including TYPE65.
|
|
77
|
+
assert_equal([bytes], records.first(1).map(&:rdata).flatten.map(&:to_s).sort)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_raw_rdata_assignment_updates_typed_fields_and_is_atomic_on_error
|
|
82
|
+
[64, 65].each do |type|
|
|
83
|
+
rr = svcb('1 . port=443', type)
|
|
84
|
+
replacement = ['000200000300020035'].pack('H*')
|
|
85
|
+
[:rdata=, :data=].each do |writer|
|
|
86
|
+
rr.public_send(writer, replacement)
|
|
87
|
+
assert_equal(2, rr.priority)
|
|
88
|
+
assert_equal('53', rr.params_to_hash['port'])
|
|
89
|
+
assert_equal(replacement, rr.rdata)
|
|
90
|
+
assert_equal(replacement, [rdata_hex(rr)].pack('H*'))
|
|
91
|
+
[nil, [], '', replacement.byteslice(0, 4), ['000100000300030035'].pack('H*'), 'x' * 65_536].each do |invalid|
|
|
92
|
+
assert_raises(DecodeError) { rr.public_send(writer, invalid) }
|
|
93
|
+
assert_equal(replacement, rr.rdata, 'invalid assignment must leave the complete record intact')
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
assert_roundtrip(rr)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def test_decoded_rdata_preserves_the_former_generic_reflection_snapshot
|
|
101
|
+
bytes = ['000103537663074578616d706c65000003000201bb02bc000300ff80'].pack('H*')
|
|
102
|
+
[64, 65].each do |type|
|
|
103
|
+
rr = RR.new_from_data('example.com.', type, Classes::IN, 60, bytes.bytesize, bytes, 0)
|
|
104
|
+
# JSONDNS reads @rdata with instance_variable_get and treats Arrays as
|
|
105
|
+
# printable tuples. Decoded TYPE64/65 must retain their raw String shape.
|
|
106
|
+
assert_instance_of(String, rr.instance_variable_get('@rdata'))
|
|
107
|
+
assert_equal(bytes, rr.instance_variable_get(:@rdata))
|
|
108
|
+
assert_equal(Encoding::BINARY, rr.instance_variable_get(:@rdata).encoding)
|
|
109
|
+
|
|
110
|
+
message = Message.new
|
|
111
|
+
message.add_answer(rr)
|
|
112
|
+
message.add_answer(RR.create('after.example. IN A 192.0.2.1'))
|
|
113
|
+
copy = Message.decode(message.encode).answer.first
|
|
114
|
+
assert_equal(bytes, copy.instance_variable_get(:@rdata), 'snapshot must contain only this RDATA')
|
|
115
|
+
|
|
116
|
+
# RR keeps construction-time metadata in @rdata. Its public accessors
|
|
117
|
+
# remain the source of current bytes after typed fields are mutated.
|
|
118
|
+
rr.priority = 2
|
|
119
|
+
rr.params[3].replace([53].pack('n'))
|
|
120
|
+
assert_equal(bytes, rr.instance_variable_get(:@rdata))
|
|
121
|
+
refute_equal(bytes, rr.rdata)
|
|
122
|
+
assert_equal(rr.rdata, rr.clone.instance_variable_get(:@rdata))
|
|
123
|
+
assert_equal(rr, Marshal.load(Marshal.dump(rr)))
|
|
124
|
+
assert_equal(bytes, rr.freeze.instance_variable_get(:@rdata))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def test_decoded_empty_update_rdata_preserves_a_binary_snapshot
|
|
129
|
+
[64, 65].each do |type|
|
|
130
|
+
[Classes::ANY, Classes::NONE].each do |klass|
|
|
131
|
+
rr = RR.new_from_data('example.com.', type, klass, 60, 0, ''.b, 0)
|
|
132
|
+
assert_equal(''.b, rr.instance_variable_get(:@rdata))
|
|
133
|
+
assert_equal(Encoding::BINARY, rr.instance_variable_get(:@rdata).encoding)
|
|
134
|
+
assert_equal(rr, rr.clone)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def test_former_generic_direct_constructor_accepts_raw_bytes
|
|
140
|
+
bytes = ['0001000003000201bb'].pack('H*')
|
|
141
|
+
[64, 65].each do |type|
|
|
142
|
+
rr = RR.get_class(type, Classes::IN).new(bytes)
|
|
143
|
+
assert_equal(bytes, rr.data)
|
|
144
|
+
assert_equal(bytes, rr.rdata)
|
|
145
|
+
assert_equal(9, rr.rdlength)
|
|
146
|
+
assert_equal(1, rr.priority)
|
|
147
|
+
assert_equal('443', rr.params_to_hash['port'])
|
|
148
|
+
end
|
|
149
|
+
# A NUL inside a presentation value must still reach the text parser.
|
|
150
|
+
rr = svcb("1 . key667=one\0two")
|
|
151
|
+
assert_equal("one\0two", rr.params[667])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def test_former_generic_array_constructor_accepts_raw_bytes
|
|
155
|
+
bytes = ['0001000003000201bb'].pack('H*')
|
|
156
|
+
[64, 65].each do |type|
|
|
157
|
+
record_class = RR.get_class(type, Classes::IN)
|
|
158
|
+
raw_record = record_class.new([bytes])
|
|
159
|
+
typed_record = record_class.new([1, Name.create('.'), {3 => [443].pack('n')}])
|
|
160
|
+
[raw_record, typed_record].each do |rr|
|
|
161
|
+
assert_equal(bytes, rr.data)
|
|
162
|
+
assert_equal(bytes, rr.rdata)
|
|
163
|
+
assert_equal(bytes, [rdata_hex(rr)].pack('H*'))
|
|
164
|
+
assert_equal(bytes.bytesize, rr.rdlength)
|
|
165
|
+
assert_equal(1, rr.priority)
|
|
166
|
+
assert_equal('.', rr.target.to_s(true))
|
|
167
|
+
assert_equal('443', rr.params_to_hash['port'])
|
|
168
|
+
rr.name = 'example.com.'
|
|
169
|
+
rr.ttl = 60
|
|
170
|
+
rr.type = Types.new(type)
|
|
171
|
+
rr.klass = Classes::IN
|
|
172
|
+
assert_roundtrip(rr)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def test_raw_rdata_tracks_publicly_mutable_typed_fields
|
|
178
|
+
rr = svcb('1 . port=443')
|
|
179
|
+
rr.priority = 2
|
|
180
|
+
rr.target = Name.create('svc.example.')
|
|
181
|
+
rr.params[3].replace([8443].pack('n'))
|
|
182
|
+
rr.params[667] = 'abc'.b
|
|
183
|
+
expected = svcb('2 svc.example. port=8443 key667=abc')
|
|
184
|
+
assert_equal(expected.rdata, rr.rdata)
|
|
185
|
+
assert_equal([rdata_hex(rr)].pack('H*'), rr.data)
|
|
186
|
+
assert_equal(rr.data.bytesize, rr.rdlength)
|
|
187
|
+
assert_equal(expected, rr)
|
|
188
|
+
assert_equal(0, rr <=> expected)
|
|
189
|
+
rr.params.delete(667)
|
|
190
|
+
assert_equal(svcb('2 svc.example. port=8443').rdata, rr.rdata)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def test_raw_hash_fields_allow_typed_overrides_and_reject_conflicts
|
|
194
|
+
bytes = ['0001000003000201bb'].pack('H*')
|
|
195
|
+
rr = RR.create(name: 'example.com.', type: 65, rdata: bytes, priority: 2, params: {port: 53})
|
|
196
|
+
assert_equal(svcb('2 . port=53', 65), rr)
|
|
197
|
+
assert_raises(DecodeError) do
|
|
198
|
+
RR.create(name: 'example.com.', type: 65, rdata: bytes, data: bytes + 'x')
|
|
199
|
+
end
|
|
200
|
+
[Classes::ANY, Classes::NONE].each do |klass|
|
|
201
|
+
rr = RR.create(name: 'example.com.', type: 65, klass: klass, rdata: ''.b)
|
|
202
|
+
assert_equal('', rr.data)
|
|
203
|
+
assert_equal(0, rr.rdlength)
|
|
204
|
+
assert_roundtrip(rr)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def test_basic_parse
|
|
209
|
+
rr = RR.create('crypto.cloudflare.com. 300 IN HTTPS 1 . alpn="h2,h3" ipv4hint=162.159.135.79')
|
|
210
|
+
assert_instance_of(RR::IN::HTTPS, rr)
|
|
211
|
+
assert_equal(Types::HTTPS, rr.type)
|
|
212
|
+
assert_equal(1, rr.priority)
|
|
213
|
+
assert_equal('.', rr.target.to_s(true))
|
|
214
|
+
assert_equal(%w[h2 h3].join(','), rr.params_to_hash['alpn'])
|
|
215
|
+
assert_equal('162.159.135.79', rr.params_to_hash['ipv4hint'])
|
|
216
|
+
assert_roundtrip(rr)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def test_alias_mode
|
|
220
|
+
rr = svcb('0 foo.example.com.')
|
|
221
|
+
assert_equal(Types::SVCB, rr.type)
|
|
222
|
+
assert_equal(0, rr.priority)
|
|
223
|
+
assert_equal('foo.example.com.', rr.target.to_s(true))
|
|
224
|
+
assert_empty(rr.params)
|
|
225
|
+
assert_roundtrip(rr)
|
|
226
|
+
|
|
227
|
+
# RFC 9460 sec 2.5.1: a "." TargetName in AliasMode means the service does
|
|
228
|
+
# not exist, which is a meaningful record rather than one to reject.
|
|
229
|
+
root = svcb('0 .', 'HTTPS')
|
|
230
|
+
assert_equal('0 .', root.rdata_to_string)
|
|
231
|
+
assert_roundtrip(root)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# RFC 9460 sec 2.4.3 calls a ServiceMode RR self-consistent when its SvcParams
|
|
235
|
+
# meet each other's requirements. Sec 7.1.1 gives one of the two: alpn must
|
|
236
|
+
# accompany no-default-alpn. The other, that mandatory name only keys the
|
|
237
|
+
# record carries (sec 8), is the "mandatory key absent" case in the two
|
|
238
|
+
# rejection tables below.
|
|
239
|
+
def test_no_default_alpn_requires_alpn
|
|
240
|
+
assert_raises(Dnsruby::DecodeError) { svcb('1 . no-default-alpn') }
|
|
241
|
+
# And on the wire: key 2 present, with no alpn.
|
|
242
|
+
assert_raises(Dnsruby::DecodeError) { decode('000100' '0002' '0000') }
|
|
243
|
+
|
|
244
|
+
rr = svcb('1 . alpn=h2 no-default-alpn')
|
|
245
|
+
assert_equal('1 . alpn=h2 no-default-alpn', rr.rdata_to_string)
|
|
246
|
+
assert_roundtrip(rr)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# sec 2.4.2: "In AliasMode, recipients MUST ignore any SvcParams that are
|
|
250
|
+
# present" -- ignore, so they stay on the record rather than being dropped or
|
|
251
|
+
# rejected, and the sec 2.4.3 rules above do not apply at priority 0. The
|
|
252
|
+
# sec 8 rules about the mandatory value itself still do.
|
|
253
|
+
def test_alias_mode_ignores_svcparams
|
|
254
|
+
['0 foo.example.com. alpn=h2',
|
|
255
|
+
'0 foo.example.com. mandatory=alpn',
|
|
256
|
+
'0 foo.example.com. no-default-alpn'].each do |rdata|
|
|
257
|
+
rr = svcb(rdata)
|
|
258
|
+
assert_equal(rdata, rr.rdata_to_string)
|
|
259
|
+
assert_roundtrip(rr)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
assert_all_rejected(['0 foo.example.com. mandatory=mandatory',
|
|
263
|
+
'0 foo.example.com. mandatory=alpn,alpn alpn=h2']) { |r| svcb(r) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# DNSSEC preserves TargetName case (RFC 3597 section 7). Decode from the
|
|
267
|
+
# wire here because Name.create lowercases text input.
|
|
268
|
+
def test_targetname_case_preserved_in_canonical_encoding
|
|
269
|
+
# priority 0, TargetName "Foo.Example.COM." with mixed case.
|
|
270
|
+
mixed_case_rdata = '000003466f6f074578616d706c6503434f4d00'
|
|
271
|
+
rr = decode(mixed_case_rdata)
|
|
272
|
+
assert_equal(%w[Foo Example COM], rr.target.to_a.map(&:to_s))
|
|
273
|
+
|
|
274
|
+
enc = Dnsruby::MessageEncoder.new
|
|
275
|
+
rr.encode_rdata(enc, true) # canonical
|
|
276
|
+
assert_equal(mixed_case_rdata, enc.to_s.unpack1('H*'))
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# RFC 9460 sec 2.2: the TargetName MUST NOT be compressed. The owner name here
|
|
280
|
+
# ends in the same two labels, so a compressing encoder would write a pointer
|
|
281
|
+
# to it and still decode correctly, which is why this counts octets rather
|
|
282
|
+
# than round-tripping.
|
|
283
|
+
def test_targetname_not_compressed
|
|
284
|
+
m = Dnsruby::Message.new
|
|
285
|
+
m.add_answer(svcb('0 foo.example.com.'))
|
|
286
|
+
# RDLENGTH=19, SvcPriority=0, then the TargetName in full. Compressed, the
|
|
287
|
+
# tail would be RDLENGTH=8 ... 03666f6f c00c.
|
|
288
|
+
assert_equal('0013' '0000' '03666f6f076578616d706c6503636f6d00',
|
|
289
|
+
m.encode[-21..-1].unpack1('H*'))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# RFC 9460 Appendix D wire-format test vectors, in the appendix's own order and
|
|
293
|
+
# labelled with its figure numbers: Figure 2 is the whole of D.1 and Figures 3
|
|
294
|
+
# to 9 are D.2. Figure 10 prints one record in two presentation formats, so it
|
|
295
|
+
# needs a comparison rather than a table entry: test_escaped_alpn_value has it.
|
|
296
|
+
def test_rfc9460_wire_vectors
|
|
297
|
+
{
|
|
298
|
+
# Figure 2: AliasMode
|
|
299
|
+
'example.com. 3600 IN HTTPS 0 foo.example.com.' =>
|
|
300
|
+
'000003666f6f076578616d706c6503636f6d00',
|
|
301
|
+
# Figure 3: TargetName is "."
|
|
302
|
+
'example.com. 3600 IN SVCB 1 .' =>
|
|
303
|
+
'000100',
|
|
304
|
+
# Figure 4: specifies a port
|
|
305
|
+
'example.com. 3600 IN SVCB 16 foo.example.com. port=53' =>
|
|
306
|
+
'001003666f6f076578616d706c6503636f6d00000300020035',
|
|
307
|
+
# Figure 5: a generic key and unquoted value
|
|
308
|
+
'example.com. 7200 IN SVCB 1 foo.example.com. key667=hello' =>
|
|
309
|
+
'0001' '03666f6f076578616d706c6503636f6d00' '029b' '0005' '68656c6c6f',
|
|
310
|
+
# Figure 6: a generic key and quoted value with a decimal escape
|
|
311
|
+
'example.com. 7200 IN SVCB 1 foo.example.com. key667="hello\210qoo"' =>
|
|
312
|
+
'0001' '03666f6f076578616d706c6503636f6d00' '029b' '0009' '68656c6c6fd2716f6f',
|
|
313
|
+
# Figure 7: two quoted IPv6 hints
|
|
314
|
+
'example.com. 7200 IN SVCB 1 foo.example.com. ipv6hint="2001:db8::1,2001:db8::53:1"' =>
|
|
315
|
+
'0001' '03666f6f076578616d706c6503636f6d00' '0006' '0020' \
|
|
316
|
+
'20010db8000000000000000000000001' '20010db8000000000000000000530001',
|
|
317
|
+
# Figure 8: an IPv6 hint using the embedded IPv4 syntax
|
|
318
|
+
'example.com. 7200 IN SVCB 1 example.com. ipv6hint="2001:db8:122:344::192.0.2.33"' =>
|
|
319
|
+
'0001' '076578616d706c6503636f6d00' '0006' '0010' \
|
|
320
|
+
'20010db80122034400000000c0000221',
|
|
321
|
+
# Figure 9: SvcParamKey ordering is arbitrary in presentation format but
|
|
322
|
+
# sorted in wire format
|
|
323
|
+
'example.com. 7200 IN SVCB 16 foo.example.org. alpn=h2,h3-19 mandatory=ipv4hint,alpn ipv4hint=192.0.2.1' =>
|
|
324
|
+
'001003666f6f076578616d706c65036f7267000000000400010004000100090268320568332d313900040004c0000201',
|
|
325
|
+
}.each do |text, expected_hex|
|
|
326
|
+
rr = RR.create(text)
|
|
327
|
+
assert_equal(expected_hex, rdata_hex(rr), "wire format mismatch for: #{text}")
|
|
328
|
+
assert_roundtrip(rr)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def test_all_known_params
|
|
333
|
+
rr = RR.create('example.com. 3600 IN SVCB 1 svc.example.net. ' \
|
|
334
|
+
'mandatory=alpn,ipv4hint alpn=h2,h3 no-default-alpn port=8443 ' \
|
|
335
|
+
'ipv4hint=192.0.2.1,192.0.2.2 ipv6hint=2001:db8::1 ech=Zm9vYmFy')
|
|
336
|
+
params = rr.params_to_hash
|
|
337
|
+
assert_equal('alpn,ipv4hint', params['mandatory'])
|
|
338
|
+
assert_equal('h2,h3', params['alpn'])
|
|
339
|
+
assert_equal('', rr.params[2], 'no-default-alpn must be stored, value-less')
|
|
340
|
+
assert_nil(params['no-default-alpn'])
|
|
341
|
+
assert_equal('8443', params['port'])
|
|
342
|
+
assert_equal('192.0.2.1,192.0.2.2', params['ipv4hint'])
|
|
343
|
+
assert_equal('Zm9vYmFy', params['ech'])
|
|
344
|
+
assert_roundtrip(rr)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def test_ech_mnemonic_forbids_escapes_but_generic_key5_keeps_binary_syntax
|
|
348
|
+
# The RFC 9848 section 3 example uses Base64 for ech; key5 uses raw bytes.
|
|
349
|
+
value = 'AEj+DQBEAQAgACAdd+scUi0IYFsXnUIU7ko2Nd9+F8M26pAGZVpz/KrWPgAEAAEAAWQVZWNoLXNpdGVzLmV4YW1wbGUubmV0AAA='
|
|
350
|
+
escaped = '\\065' + value[1..]
|
|
351
|
+
binary = value.unpack1('m0')
|
|
352
|
+
generic = binary.bytes.map { |byte| format('\\%03d', byte) }.join
|
|
353
|
+
%w[SVCB HTTPS].each do |type|
|
|
354
|
+
[value, %Q{"#{value}"}].each do |plain|
|
|
355
|
+
assert_equal(binary, svcb("1 . ech=#{plain}", type).params[5])
|
|
356
|
+
end
|
|
357
|
+
[escaped, %Q{"#{escaped}"}, '\\A' + value[1..]].each do |invalid|
|
|
358
|
+
assert_raises(DecodeError) { svcb("1 . ech=#{invalid}", type) }
|
|
359
|
+
assert_raises(DecodeError) do
|
|
360
|
+
RR.create(name: 'example.', type: type, priority: 1, target: '.', params: {ech: invalid})
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
[generic, %Q{"#{generic}"}].each do |raw|
|
|
364
|
+
assert_equal(binary, svcb("1 . key5=#{raw}", type).params[5])
|
|
365
|
+
record = RR.create(name: 'example.', type: type, priority: 1, target: '.', params: {key5: raw})
|
|
366
|
+
assert_equal(binary, record.params[5])
|
|
367
|
+
assert_roundtrip(record)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# RFC 9460 sec 2.2: SvcParams are written in increasing key order, which is
|
|
373
|
+
# numeric, so mandatory(0) alpn(1) port(3) rather than anything alphabetical.
|
|
374
|
+
def test_params_sorted_on_output
|
|
375
|
+
rr = svcb('1 . port=443 alpn=h2 mandatory=alpn', 'HTTPS')
|
|
376
|
+
assert_equal('1 . mandatory=alpn alpn=h2 port=443', rr.rdata_to_string)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# A literal comma and backslash within an ALPN id must survive; both reach the
|
|
380
|
+
# zone file doubled. RFC 9460 Appendix D.2 Figure 10 prints the record twice,
|
|
381
|
+
# quoted and unquoted, so both spellings must give the same wire value.
|
|
382
|
+
def test_escaped_alpn_value
|
|
383
|
+
rr, unquoted = ['alpn="f\\\\\\\\oo\\\\,bar,h2"',
|
|
384
|
+
'alpn=f\\\\\\092oo\\092,bar,h2'].map do |param|
|
|
385
|
+
RR.create("example.com. 7200 IN SVCB 16 foo.example.org. #{param}")
|
|
386
|
+
end
|
|
387
|
+
# Wire: [8]"f\oo,bar" [2]"h2"
|
|
388
|
+
assert_equal('08665c6f6f2c626172026832', rr.params[1].unpack1('H*'))
|
|
389
|
+
assert_equal(rr.params, unquoted.params, 'the RFC prints these as one record')
|
|
390
|
+
rr2 = assert_roundtrip(rr)
|
|
391
|
+
# The presentation of rr2 must re-parse to the same wire value.
|
|
392
|
+
rr3 = RR.create("example.com. IN SVCB #{rr2.rdata_to_string}")
|
|
393
|
+
assert_equal(rr.params, rr3.params)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# The RFC 9460 Appendix A example of "decoding of value-lists happens after
|
|
397
|
+
# character-string decoding": two spellings it states are equivalent.
|
|
398
|
+
def test_value_list_decoded_after_char_string
|
|
399
|
+
quoted, unquoted = ['alpn="part1,part2,part3\\\\,part4\\\\\\\\"',
|
|
400
|
+
'alpn=part1\\,\\p\\a\\r\\t2\\044part3\\092,part4\\092\\\\'].map do |param|
|
|
401
|
+
RR.create("example.com. 7200 IN SVCB 1 . #{param}")
|
|
402
|
+
end
|
|
403
|
+
assert_equal('0570617274310570617274320c70617274332c70617274345c',
|
|
404
|
+
quoted.params[1].unpack1('H*'))
|
|
405
|
+
assert_equal(quoted.params, unquoted.params, 'the RFC states these spellings are equivalent')
|
|
406
|
+
# The emitter must write back at the same depth it read.
|
|
407
|
+
assert_equal('1 . alpn=part1,part2,part3\\\\,part4\\\\\\\\', quoted.rdata_to_string)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# RFC 9461 sec 5 makes dohpath a UTF-8 URI Template, so a value can be
|
|
411
|
+
# multibyte, and every wire length is an octet count.
|
|
412
|
+
def test_multibyte_values_encode_as_octets
|
|
413
|
+
{ 'dohpath=/é{?dns}' => [7, '2fc3a97b3f646e737d'],
|
|
414
|
+
'alpn=hé2' => [1, '0468c3a932'], # [4]"h\xC3\xA92"
|
|
415
|
+
'key667="é\210q"' => [667, 'c3a9d271'],
|
|
416
|
+
}.each do |param, (num, hex)|
|
|
417
|
+
rr = svcb("1 . #{param}")
|
|
418
|
+
assert_equal(hex, rr.params[num].unpack1('H*'), param)
|
|
419
|
+
assert_roundtrip(rr)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# The emitter uses numeric escapes for whitespace and characters with
|
|
424
|
+
# lexical meaning in the unquoted presentation form it produces.
|
|
425
|
+
def test_presentation_escaping
|
|
426
|
+
{ # dohpath = /a()o
|
|
427
|
+
'00010000070005' + '2f6128296f' => '1 . dohpath=/a\040\041o',
|
|
428
|
+
# dohpath = /a" né
|
|
429
|
+
'00010000070007' + '2f6122206ec3a9' => '1 . dohpath=/a\034\032n\195\169',
|
|
430
|
+
# alpn = "a b", key667 = "x;y"
|
|
431
|
+
'000100' + '0001' + '0004' + '03612062' + '029b' + '0003' + '783b79' =>
|
|
432
|
+
'1 . alpn=a\032b key667=x\059y',
|
|
433
|
+
}.each do |hex, presentation|
|
|
434
|
+
rr = decode(hex, RR::IN::HTTPS)
|
|
435
|
+
assert_equal(presentation, rr.rdata_to_string)
|
|
436
|
+
|
|
437
|
+
rr2 = svcb(rr.rdata_to_string, 'HTTPS')
|
|
438
|
+
assert_equal(rr.params, rr2.params, presentation)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# The literal escape another implementation may emit is equally valid, and
|
|
443
|
+
# SvcParamKeys are case-insensitive in both forms.
|
|
444
|
+
def test_alternative_presentation_spellings
|
|
445
|
+
{ '1 . alpn=a\ b key667=x\"y' => '1 . alpn=a\032b key667=x\034y',
|
|
446
|
+
'1 . ALPN=h2 KEY667=hi' => '1 . alpn=h2 key667=hi',
|
|
447
|
+
}.each do |rdata, expected|
|
|
448
|
+
assert_equal(expected, svcb(rdata).rdata_to_string)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def test_parentheses_are_data_when_quoted_or_escaped
|
|
453
|
+
{
|
|
454
|
+
'key667="a(b)c"' => 'a(b)c',
|
|
455
|
+
'key667="a\(b\)c"' => 'a(b)c',
|
|
456
|
+
'key667=a\(b\)c' => 'a(b)c',
|
|
457
|
+
'key667=a\040b\041c' => 'a(b)c',
|
|
458
|
+
'key667="a\("' => 'a(',
|
|
459
|
+
'key667=a\)' => 'a)',
|
|
460
|
+
'key667="a\"(b)c"' => 'a"(b)c',
|
|
461
|
+
'key667="a;(b)c"' => 'a;(b)c',
|
|
462
|
+
}.each do |param, expected|
|
|
463
|
+
%w[SVCB HTTPS].each do |type|
|
|
464
|
+
rr = svcb("(1 . #{param}) ; comment with an unmatched (", type)
|
|
465
|
+
assert_equal(expected, rr.params[667], "#{type} #{param}")
|
|
466
|
+
assert_roundtrip(rr)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# A \DDD escape stands for a single octet, so 255 is as high as it can reach.
|
|
472
|
+
def test_ddd_escape_octet_boundary
|
|
473
|
+
rr = svcb('1 . key667="\000\255"')
|
|
474
|
+
assert_equal('00ff', rr.params[667].unpack1('H*'))
|
|
475
|
+
assert_equal('1 . key667=\000\255', rr.rdata_to_string)
|
|
476
|
+
assert_roundtrip(rr)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# A SvcParamValue must be stored as octets, or a multibyte one raises
|
|
480
|
+
# Encoding::CompatibilityError out of Message#encode.
|
|
481
|
+
def test_from_hash_stores_octets
|
|
482
|
+
rr = RR.create(name: 'example.com.', type: Types::HTTPS, ttl: 3600, priority: 1, target: '.',
|
|
483
|
+
params: { alpn: 'hé2', dohpath: '/é{?dns}', key667: 'é' })
|
|
484
|
+
rr.params.each do |num, value|
|
|
485
|
+
assert_equal(Encoding::BINARY, value.encoding, "key #{num} must hold octets")
|
|
486
|
+
end
|
|
487
|
+
assert_equal(9, rr.params[7].bytesize)
|
|
488
|
+
assert_roundtrip(rr)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# A hash value is presentation format, the same text a zone file carries after
|
|
492
|
+
# the "=", so the two constructors must agree.
|
|
493
|
+
def test_from_hash_matches_from_string
|
|
494
|
+
{ 'alpn=h2,h3 port=443' => { alpn: 'h2,h3', port: 443 },
|
|
495
|
+
'dohpath=/é{?dns}' => { dohpath: '/é{?dns}' },
|
|
496
|
+
'key667=é' => { key667: 'é' },
|
|
497
|
+
'key667="a\032b" alpn=h2,h\,3' => { key667: '"a\032b"', alpn: 'h2,h\,3' },
|
|
498
|
+
'key1=\002h2 key3=\001\187' => { key1: '\002h2', key3: '\001\187' },
|
|
499
|
+
}.each do |rdata, params|
|
|
500
|
+
from_string = svcb("1 . #{rdata}", 'HTTPS')
|
|
501
|
+
from_hash = RR.create(name: 'example.com.', type: Types::HTTPS, ttl: 3600,
|
|
502
|
+
priority: 1, target: '.', params: params)
|
|
503
|
+
assert_equal(from_string, from_hash, rdata)
|
|
504
|
+
assert_equal(from_string.rdata_to_string, from_hash.rdata_to_string, rdata)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# params_to_hash emits presentation format, which is exactly what from_hash
|
|
509
|
+
# accepts.
|
|
510
|
+
def test_params_to_hash_round_trips_through_from_hash
|
|
511
|
+
# key667 holds one of every octet the emitter escapes: space, semicolon,
|
|
512
|
+
# quote, both parens, backslash, comma, a multibyte pair and NUL.
|
|
513
|
+
nasty = [0x61, 0x20, 0x3b, 0x22, 0x28, 0x29, 0x5c, 0x2c, 0xc3, 0xa9, 0x00].pack('C*')
|
|
514
|
+
from_wire = RR::IN::SVCB.decode_rdata(Dnsruby::MessageDecoder.new(
|
|
515
|
+
['0001' '00' '029b'].pack('H*') + [nasty.bytesize].pack('n') + nasty))
|
|
516
|
+
assert_equal(nasty, from_wire.params[667])
|
|
517
|
+
|
|
518
|
+
all_keys = RR.create('example.com. 3600 IN SVCB 1 svc.example.net. ' \
|
|
519
|
+
'mandatory=alpn,port alpn=h2,h3 no-default-alpn port=8443 ' \
|
|
520
|
+
'ipv4hint=192.0.2.1,192.0.2.2 ipv6hint=2001:db8::1 ' \
|
|
521
|
+
'ech=Zm9vYmFy dohpath=/dns{?dns}')
|
|
522
|
+
|
|
523
|
+
[from_wire, all_keys].each do |orig|
|
|
524
|
+
rebuilt = RR.create(name: 'example.com.', type: Types::SVCB, ttl: 3600,
|
|
525
|
+
priority: orig.priority, target: orig.target.to_s(true),
|
|
526
|
+
params: orig.params_to_hash)
|
|
527
|
+
# from_wire has no name/type/class, so compare the RDATA only.
|
|
528
|
+
assert_equal(orig.params, rebuilt.params, orig.rdata_to_string)
|
|
529
|
+
assert_equal(orig.rdata_to_string, rebuilt.rdata_to_string)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# ech carries a Base64 ECHConfigList (RFC 9848 section 3). Reject invalid
|
|
534
|
+
# characters, which Base64.decode64 would silently discard.
|
|
535
|
+
def test_ech_requires_valid_base64
|
|
536
|
+
assert_all_rejected(
|
|
537
|
+
['1 . ech=!!!not-base64!!!', # permissive decoding would emit "ech=notbase6"
|
|
538
|
+
'1 . ech=Zm9vYmF@', # single stray character
|
|
539
|
+
'1 . ech=aGk', # unpadded
|
|
540
|
+
'1 . ech='] # an ECHConfigList is length-prefixed, never empty
|
|
541
|
+
) { |r| svcb(r) }
|
|
542
|
+
|
|
543
|
+
rr = svcb('1 . ech=Zm9vYmFy')
|
|
544
|
+
assert_equal('foobar', rr.params[5])
|
|
545
|
+
assert_equal('1 . ech=Zm9vYmFy', rr.rdata_to_string)
|
|
546
|
+
assert_roundtrip(rr)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# RFC 9461 sec 5 requires a URI Template containing the "dns" variable, so
|
|
550
|
+
# there is no empty dohpath, in any of its three spellings.
|
|
551
|
+
def test_dohpath_requires_a_value
|
|
552
|
+
assert_all_rejected(['1 . dohpath', '1 . dohpath=', '1 . dohpath=""']) { |r| svcb(r, 'HTTPS') }
|
|
553
|
+
|
|
554
|
+
rr = svcb('1 . dohpath=/dns{?dns}', 'HTTPS')
|
|
555
|
+
assert_equal('1 . dohpath=/dns{?dns}', rr.rdata_to_string)
|
|
556
|
+
assert_roundtrip(rr)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# RFC 9540 sec 4: "Both the presentation and wire-format values for the
|
|
560
|
+
# 'ohttp' parameter MUST be empty", so it behaves like no-default-alpn.
|
|
561
|
+
def test_ohttp
|
|
562
|
+
['1 . ohttp', '1 . ohttp=""'].each do |rdata|
|
|
563
|
+
rr = svcb(rdata, 'HTTPS')
|
|
564
|
+
assert_equal('', rr.params[8])
|
|
565
|
+
assert_nil(rr.params_to_hash['ohttp'])
|
|
566
|
+
assert_equal('1 . ohttp', rr.rdata_to_string)
|
|
567
|
+
assert_roundtrip(rr)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
assert_raises(Dnsruby::DecodeError) { svcb('1 . ohttp=x', 'HTTPS') }
|
|
571
|
+
# And the same on the wire: key 8 with a one-octet value.
|
|
572
|
+
assert_raises(Dnsruby::DecodeError) { decode('000100' '0008' '0001' '61') }
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# RFC 9953 sec 3: length-prefixed segments like alpn, except that zero
|
|
576
|
+
# segments is legal and means the root path.
|
|
577
|
+
def test_docpath
|
|
578
|
+
rr = svcb('1 . docpath=.well-known,core', 'HTTPS')
|
|
579
|
+
assert_equal(['0b2e77656c6c2d6b6e6f776e04636f7265'].pack('H*'), rr.params[10])
|
|
580
|
+
assert_equal('.well-known,core', rr.params_to_hash['docpath'])
|
|
581
|
+
assert_roundtrip(rr)
|
|
582
|
+
|
|
583
|
+
# The root path: an empty value, however it is written, goes back out as
|
|
584
|
+
# the standalone key RFC 9953 sec 3.2.1 spells it with.
|
|
585
|
+
['1 . docpath', '1 . docpath=""', '1 . docpath='].each do |rdata|
|
|
586
|
+
root = svcb(rdata, 'HTTPS')
|
|
587
|
+
assert_equal('', root.params[10], rdata)
|
|
588
|
+
assert_nil(root.params_to_hash['docpath'], rdata)
|
|
589
|
+
assert_equal('1 . docpath', root.rdata_to_string, rdata)
|
|
590
|
+
assert_roundtrip(root)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# An empty *segment* is still an error -- only the whole list may be empty.
|
|
594
|
+
assert_raises(Dnsruby::DecodeError) { svcb('1 . docpath=a,,b', 'HTTPS') }
|
|
595
|
+
# A zero-length segment on the wire is the same error.
|
|
596
|
+
assert_raises(Dnsruby::DecodeError) { decode('000100' '000a' '0001' '00') }
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# RFC 9460 sec 14.3.2 reserves 65535 as an invalid SvcParamKey. 65534 is
|
|
600
|
+
# the top of the private-use range and stays usable.
|
|
601
|
+
def test_key_65535_reserved
|
|
602
|
+
assert_raises(Dnsruby::DecodeError) { svcb('1 . key65535=a') }
|
|
603
|
+
assert_raises(Dnsruby::DecodeError) { decode('000100' 'ffff' '0001' '61') }
|
|
604
|
+
|
|
605
|
+
# A mandatory list naming it. AliasMode has no self-consistency rule to
|
|
606
|
+
# catch the dangling key, so validity is checked on the value itself.
|
|
607
|
+
assert_raises(Dnsruby::DecodeError) { decode('000100' '0000' '0002' 'ffff') }
|
|
608
|
+
assert_raises(Dnsruby::DecodeError) { decode('000000' '0000' '0002' 'ffff') }
|
|
609
|
+
|
|
610
|
+
rr = svcb('1 . key65534=a')
|
|
611
|
+
assert_equal('1 . key65534=a', rr.rdata_to_string)
|
|
612
|
+
assert_roundtrip(rr)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# RFC 9460 sec 2.1 writes the number of an unknown key "without leading
|
|
616
|
+
# zeros". Padded spellings such as key01 must not become aliases for alpn.
|
|
617
|
+
def test_key_leading_zeros_rejected
|
|
618
|
+
assert_all_rejected(['key01=h2', 'mandatory=key01,alpn alpn=h2']) { |p| svcb("1 . #{p}") }
|
|
619
|
+
|
|
620
|
+
# key0 uses generic wire octets for mandatory, so the zero stays legal.
|
|
621
|
+
assert_equal('1 . mandatory=alpn alpn=h2',
|
|
622
|
+
svcb('1 . key0=\000\001 alpn=h2').rdata_to_string)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# Numeric syntax carries raw wire octets even for a key with a registered
|
|
626
|
+
# mnemonic. In particular alpn must not acquire a second length prefix, and
|
|
627
|
+
# a port's two binary octets must not be parsed as decimal text.
|
|
628
|
+
def test_numeric_keys_use_generic_wire_values
|
|
629
|
+
{
|
|
630
|
+
'key0=\000\001\000\003 alpn=h2 port=443' => 'mandatory=alpn,port alpn=h2 port=443',
|
|
631
|
+
'key1=\002h2' => 'alpn=h2',
|
|
632
|
+
'KEY1="\002h2"' => 'alpn=h2',
|
|
633
|
+
'key2 alpn=h2' => 'no-default-alpn alpn=h2',
|
|
634
|
+
'key3=\001\187' => 'port=443',
|
|
635
|
+
'key4=\192\000\002\001' => 'ipv4hint=192.0.2.1',
|
|
636
|
+
'key5=foo' => 'ech=Zm9v',
|
|
637
|
+
'key6=\032\001\013\184\000\000\000\000\000\000\000\000\000\000\000\001' => 'ipv6hint=2001:db8::1',
|
|
638
|
+
'key7=/dns{?dns}' => 'dohpath=/dns{?dns}',
|
|
639
|
+
'key8=""' => 'ohttp',
|
|
640
|
+
'key10=\003foo\003bar' => 'docpath=foo,bar',
|
|
641
|
+
}.each do |generic, mnemonic|
|
|
642
|
+
%w[SVCB HTTPS].each do |type|
|
|
643
|
+
rr = svcb("1 . #{generic}", type)
|
|
644
|
+
assert_equal(rdata_hex(svcb("1 . #{mnemonic}", type)), rdata_hex(rr), generic)
|
|
645
|
+
assert_roundtrip(rr)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def test_numeric_keys_still_validate_wire_values_and_duplicates
|
|
651
|
+
assert_all_rejected([
|
|
652
|
+
'key1=h2', 'key1', 'key3=443', 'key3', 'key2=x alpn=h2',
|
|
653
|
+
'key4=\192\000\002', 'key6=\000',
|
|
654
|
+
'key0=\000\003\000\001 alpn=h2 port=443',
|
|
655
|
+
'key1=\002h2 alpn=h2', 'alpn=h2 key1=\002h2',
|
|
656
|
+
]) { |params| svcb("1 . #{params}") }
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# RFC 9460 sec 8 puts the mandatory keys in "strictly increasing numeric
|
|
660
|
+
# order" on the wire. Presentation format has no such rule and the encoder
|
|
661
|
+
# sorts, so an unsorted zone-file list is fine.
|
|
662
|
+
def test_mandatory_wire_order
|
|
663
|
+
tail = '0001' '0003' '026832' '0003' '0002' '01bb' # alpn=h2 port=443
|
|
664
|
+
ascending = '000100' + '0000' + '0004' + '00010003' + tail
|
|
665
|
+
descending = '000100' + '0000' + '0004' + '00030001' + tail
|
|
666
|
+
|
|
667
|
+
rr = decode(ascending)
|
|
668
|
+
assert_equal('1 . mandatory=alpn,port alpn=h2 port=443', rr.rdata_to_string)
|
|
669
|
+
|
|
670
|
+
assert_raises(Dnsruby::DecodeError) { decode(descending) }
|
|
671
|
+
|
|
672
|
+
from_zone = svcb('1 . mandatory=port,alpn alpn=h2 port=443')
|
|
673
|
+
assert_equal(['00010003'].pack('H*'), from_zone.params[0], 'the encoder must sort')
|
|
674
|
+
assert_equal(rr.params, from_zone.params)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# The priority setter enforces the same range as the presentation format.
|
|
678
|
+
def test_priority_setter_validates
|
|
679
|
+
rr = svcb('1 .')
|
|
680
|
+
rr.priority = 65535
|
|
681
|
+
assert_equal(65535, rr.priority)
|
|
682
|
+
assert_equal('65535 .', rr.rdata_to_string)
|
|
683
|
+
|
|
684
|
+
assert_all_rejected(['65536', 65536, -1, 'abc', nil]) { |bad| rr.priority = bad }
|
|
685
|
+
assert_equal(65535, rr.priority, 'a rejected assignment must not change the record')
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# String#to_i and pack('n') fail silently, turning a typo into a wrong number.
|
|
689
|
+
def test_out_of_range_numeric_fields_rejected
|
|
690
|
+
assert_all_rejected(['1 . port=65536', '1 . port=-1', '1 . port=abc',
|
|
691
|
+
'65536 .', 'foo.example. 1']) { |r| svcb(r) }
|
|
692
|
+
# 65535 is in range -- the check must not be off by one.
|
|
693
|
+
rr = svcb('65535 . port=65535')
|
|
694
|
+
assert_equal('65535 . port=65535', rr.rdata_to_string)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Presentation-format records a parser must reject: all ten of RFC 9460
|
|
698
|
+
# Appendix D.3 (Figure 12 alone lists five value-less keys), empty value-list
|
|
699
|
+
# items, and addresses and escapes that fail underneath as ArgumentError or
|
|
700
|
+
# RangeError. All must surface as DecodeError, or a zone loader rescuing one
|
|
701
|
+
# line at a time dies on the whole file.
|
|
702
|
+
def test_presentation_failures_rejected
|
|
703
|
+
assert_all_rejected(
|
|
704
|
+
'duplicate key' => '1 foo.example.com. key123=abc key123=def',
|
|
705
|
+
'mandatory with no value' => '1 foo.example.com. mandatory',
|
|
706
|
+
'alpn with no value' => '1 foo.example.com. alpn',
|
|
707
|
+
'port with no value' => '1 foo.example.com. port',
|
|
708
|
+
'ipv4hint with no value' => '1 foo.example.com. ipv4hint',
|
|
709
|
+
'ipv6hint with no value' => '1 foo.example.com. ipv6hint',
|
|
710
|
+
'port is not a number' => '1 foo.example.com. port=NaN',
|
|
711
|
+
'no-default-alpn has value' => '1 foo.example.com. no-default-alpn=abc',
|
|
712
|
+
'mandatory key absent' => '1 foo.example.com. mandatory=key123',
|
|
713
|
+
'mandatory lists itself' => '1 foo.example.com. mandatory=mandatory',
|
|
714
|
+
'mandatory lists a dup' => '1 foo.example.com. mandatory=key123,key123 key123=abc',
|
|
715
|
+
'empty alpn' => '1 . alpn=',
|
|
716
|
+
'alpn trailing comma' => '1 . alpn=h2,',
|
|
717
|
+
'alpn leading comma' => '1 . alpn=,h2',
|
|
718
|
+
'empty mandatory' => '1 . mandatory=',
|
|
719
|
+
'empty ipv4hint' => '1 . ipv4hint=',
|
|
720
|
+
'ipv4hint trailing comma' => '1 . ipv4hint=192.0.2.1,',
|
|
721
|
+
'empty ipv6hint' => '1 . ipv6hint=',
|
|
722
|
+
'ipv4 octet out of range' => '1 . ipv4hint=999.1.1.1',
|
|
723
|
+
'ipv4 too short' => '1 . ipv4hint=1.2.3',
|
|
724
|
+
'second ipv4 invalid' => '1 . ipv4hint=192.0.2.1,not-an-address',
|
|
725
|
+
'ipv6 is not hex' => '1 . ipv6hint=zz::1',
|
|
726
|
+
'escape above 255' => '1 . key667="\256"',
|
|
727
|
+
'escape far above 255' => '1 . key667="\999"'
|
|
728
|
+
) { |r| svcb(r) }
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# RFC 1035 sec 5.1 escapes: a backslash must escape something, \DDD is exactly
|
|
732
|
+
# three digits, and an unescaped quote is not character-string data. An
|
|
733
|
+
# unterminated quote must be reported as one, not as an unknown key.
|
|
734
|
+
def test_invalid_escapes_and_quotes_rejected
|
|
735
|
+
assert_all_rejected(
|
|
736
|
+
# Unquoted: inside quotes this escapes the closing quote instead.
|
|
737
|
+
'trailing backslash' => '1 . key667=abc\\',
|
|
738
|
+
'one-digit escape' => '1 . key667="\\1"',
|
|
739
|
+
'two-digit escape' => '1 . key667="\\12"',
|
|
740
|
+
'digits then non-digit' => '1 . key667="\\12x"',
|
|
741
|
+
'unescaped quote mid-token' => '1 . dohpath=/a"b"c'
|
|
742
|
+
) { |r| svcb(r) }
|
|
743
|
+
|
|
744
|
+
['1 . key667="abc\\"', '1 . key667="abc', '1 . alpn="h2,h3 port=443'].each do |rdata|
|
|
745
|
+
e = assert_raises(Dnsruby::DecodeError, rdata) { svcb(rdata) }
|
|
746
|
+
assert_match(/unterminated quoted SvcParamValue/, e.message, rdata)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# The valid neighbours of each case must still work.
|
|
750
|
+
assert_equal('1 . key667=a\\\\\\\\b',
|
|
751
|
+
svcb('1 . key667="a\\\\\\\\b"').rdata_to_string)
|
|
752
|
+
assert_equal('1 . key667=\\012',
|
|
753
|
+
svcb('1 . key667="\\012"').rdata_to_string)
|
|
754
|
+
assert_equal('1 . key667=\\034',
|
|
755
|
+
svcb('1 . key667="\\034"').rdata_to_string)
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def test_invalid_value_list_escapes_rejected_in_both_constructors
|
|
759
|
+
# Each character-string decodes to either h2\q or a final h2\.
|
|
760
|
+
# The second layer permits only an escaped comma or backslash.
|
|
761
|
+
invalid = ['h2' + '\\' * 2 + 'q', 'h2' + '\\' * 2,
|
|
762
|
+
'h2\092q', 'h2\092']
|
|
763
|
+
%w[SVCB HTTPS].each do |type|
|
|
764
|
+
%w[alpn docpath].each do |key|
|
|
765
|
+
invalid.each do |value|
|
|
766
|
+
[value, "\"#{value}\""].each do |presentation|
|
|
767
|
+
assert_raises(Dnsruby::DecodeError) { svcb("1 . #{key}=#{presentation}", type) }
|
|
768
|
+
assert_raises(Dnsruby::DecodeError) do
|
|
769
|
+
RR.create(name: 'example.com.', type: type, priority: 1, target: '.',
|
|
770
|
+
params: {key => presentation})
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def test_value_list_escaping_preserves_valid_octets_in_both_constructors
|
|
779
|
+
# A first-layer \q is valid and becomes q. Doubled second-layer slashes
|
|
780
|
+
# and escaped commas remain data in an item, for ALPN and DoC paths alike.
|
|
781
|
+
{
|
|
782
|
+
'h2\q' => ['h2q'],
|
|
783
|
+
'part1\,part2' => ['part1', 'part2'],
|
|
784
|
+
'h2' + '\\' * 2 + ',q' => ['h2,q'],
|
|
785
|
+
'h2' + '\\' * 4 + 'q' => ['h2\\q'],
|
|
786
|
+
'h2' + '\\' * 4 => ['h2\\'],
|
|
787
|
+
'h2\092\092q' => ['h2\\q'],
|
|
788
|
+
}.each do |value, items|
|
|
789
|
+
expected = items.map { |item| [item.bytesize].pack('C') + item }.join.b
|
|
790
|
+
%w[SVCB HTTPS].each do |type|
|
|
791
|
+
{alpn: 1, docpath: 10}.each do |key, number|
|
|
792
|
+
from_string = svcb("1 . #{key}=#{value}", type)
|
|
793
|
+
from_hash = RR.create(name: 'example.com.', ttl: 3600, type: type,
|
|
794
|
+
priority: 1, target: '.', params: {key => value})
|
|
795
|
+
assert_equal(expected, from_string.params[number], value)
|
|
796
|
+
assert_equal(from_string, from_hash)
|
|
797
|
+
assert_roundtrip(from_string)
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def test_mnemonics_forbid_presentation_escapes_but_generic_keys_accept_them
|
|
804
|
+
# RFC 9460 sections 7.2, 7.3 and 8 forbid even first-layer escapes for
|
|
805
|
+
# these mnemonics. keyNNNNN still carries escaped raw wire octets.
|
|
806
|
+
{
|
|
807
|
+
mandatory: ['al\pn', '\097lpn'],
|
|
808
|
+
port: ['4\0523'],
|
|
809
|
+
ipv4hint: ['192.0.2.\049'],
|
|
810
|
+
ipv6hint: ['2001\058db8::1'],
|
|
811
|
+
}.each do |key, values|
|
|
812
|
+
values.each do |value|
|
|
813
|
+
%w[SVCB HTTPS].each do |type|
|
|
814
|
+
[value, "\"#{value}\""].each do |presentation|
|
|
815
|
+
assert_raises(Dnsruby::DecodeError) { svcb("1 . alpn=h2 #{key}=#{presentation}", type) }
|
|
816
|
+
assert_raises(Dnsruby::DecodeError) do
|
|
817
|
+
RR.create(name: 'example.com.', type: type, priority: 1, target: '.',
|
|
818
|
+
params: {alpn: 'h2', key => presentation})
|
|
819
|
+
end
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
%w[SVCB HTTPS].each do |type|
|
|
826
|
+
raw = 'key0=\000\001 alpn=h2 key3=\001\187 key4=\192\000\002\001 ' \
|
|
827
|
+
'key6=\032\001\013\184\000\000\000\000\000\000\000\000\000\000\000\001'
|
|
828
|
+
rr = svcb("1 . #{raw}", type)
|
|
829
|
+
assert_equal('1 . mandatory=alpn alpn=h2 port=443 ipv4hint=192.0.2.1 ipv6hint=2001:db8::1', rr.rdata_to_string)
|
|
830
|
+
rebuilt = RR.create(name: 'example.com.', ttl: 3600, type: type,
|
|
831
|
+
priority: 1, target: '.', params: rr.params_to_hash)
|
|
832
|
+
assert_equal(rr, rebuilt)
|
|
833
|
+
assert_roundtrip(rr)
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
# The length prefix is a single octet, so a longer id has no representation.
|
|
838
|
+
def test_oversized_alpn_id_rejected
|
|
839
|
+
assert_raises(Dnsruby::DecodeError) { svcb("1 . alpn=#{'a' * 256}") }
|
|
840
|
+
# 255 octets fits -- the check must not be off by one.
|
|
841
|
+
rr = svcb("1 . alpn=#{'a' * 255}")
|
|
842
|
+
assert_equal(256, rr.params[1].bytesize)
|
|
843
|
+
assert_roundtrip(rr)
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
# RFC 2136 sec 2.5.2: "delete an RRset" is the type with class ANY and no
|
|
847
|
+
# RDATA, which is what Update#delete builds, and must encode to a zero RDLENGTH.
|
|
848
|
+
def test_empty_rdata
|
|
849
|
+
rr = RR.create('example.com. 3600 IN SVCB')
|
|
850
|
+
assert_nil(rr.priority)
|
|
851
|
+
assert_empty(rr.params)
|
|
852
|
+
assert_equal('', rr.rdata_to_string)
|
|
853
|
+
# The same record built from a Hash, where the two fields are set separately.
|
|
854
|
+
assert_equal('', RR.create(name: 'example.com.', type: Types::SVCB).rdata_to_string)
|
|
855
|
+
|
|
856
|
+
update = Dnsruby::Update.new('example.com.')
|
|
857
|
+
deleted = update.delete('www.example.com.', 'SVCB')
|
|
858
|
+
assert_equal(Dnsruby::Classes.ANY, deleted.klass)
|
|
859
|
+
encoder = Dnsruby::MessageEncoder.new
|
|
860
|
+
encoder.put_rr(deleted)
|
|
861
|
+
# ... TYPE=SVCB(64) CLASS=ANY(255) TTL=0 RDLENGTH=0, and nothing after it.
|
|
862
|
+
assert_equal('0040' '00ff' '00000000' '0000', encoder.to_s[-10..-1].unpack1('H*'))
|
|
863
|
+
|
|
864
|
+
# And the whole message must survive the round trip a resolver puts it through.
|
|
865
|
+
decoded = Dnsruby::Message.decode(update.encode).authority.first
|
|
866
|
+
assert_equal(Types::SVCB, decoded.type)
|
|
867
|
+
assert_nil(decoded.target)
|
|
868
|
+
assert_empty(decoded.params)
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def test_other_dns_classes_preserve_opaque_rdata_and_class_identity
|
|
872
|
+
[64, 65].each do |type|
|
|
873
|
+
[Classes::CH, Classes::HS, 65_280].each do |klass|
|
|
874
|
+
['', 'ff', '000100'].each do |hex|
|
|
875
|
+
rr = RR.create("example.com. 60 CLASS#{klass} TYPE#{type} \\# #{hex.length / 2} #{hex}")
|
|
876
|
+
assert_kind_of(RR::Generic, rr)
|
|
877
|
+
assert_equal(klass, rr.class::ClassValue)
|
|
878
|
+
assert_same(rr.class, RR.get_class(type, klass))
|
|
879
|
+
assert_equal(hex, rdata_hex(rr))
|
|
880
|
+
assert_equal(rr, rr.clone)
|
|
881
|
+
assert_equal(rr.hash, rr.clone.hash)
|
|
882
|
+
assert_equal(rr, RR.create(rr.to_s))
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
# An unknown format can also have empty RDATA; the IN constraints
|
|
886
|
+
# must not be imposed on it during decoding or encoding.
|
|
887
|
+
empty = RR.new_from_data('example.com.', type, klass, 60, 0, ''.b, 0)
|
|
888
|
+
assert_kind_of(RR::Generic, empty)
|
|
889
|
+
assert_equal('', rdata_hex(empty))
|
|
890
|
+
assert_equal(empty, empty.clone)
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def test_empty_generic_presentation_for_update_markers
|
|
896
|
+
[64, 65].each do |type|
|
|
897
|
+
%w[ANY NONE].each do |klass|
|
|
898
|
+
rr = RR.create("example.com. 0 #{klass} TYPE#{type} \\# 0")
|
|
899
|
+
assert_kind_of(RR::SVCB, rr)
|
|
900
|
+
assert_equal(Classes.new(klass), rr.klass)
|
|
901
|
+
assert_equal('', rdata_hex(rr))
|
|
902
|
+
assert_roundtrip(rr)
|
|
903
|
+
end
|
|
904
|
+
assert_raises(DecodeError) { RR.create("example.com. 0 IN TYPE#{type} \\# 0") }
|
|
905
|
+
end
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def test_update_pseudo_classes_keep_svcb_and_https_codecs
|
|
909
|
+
%w[SVCB HTTPS].each do |type|
|
|
910
|
+
update = Dnsruby::Update.new('example.com.')
|
|
911
|
+
present = update.present('www.example.com.', type)
|
|
912
|
+
absent = update.absent('www.example.com.', type)
|
|
913
|
+
delete_set = update.delete('www.example.com.', type)
|
|
914
|
+
delete_record = update.delete('www.example.com.', type, '1 . alpn=h2')
|
|
915
|
+
[present, absent, delete_set].each do |rr|
|
|
916
|
+
assert_kind_of(RR::SVCB, rr)
|
|
917
|
+
assert_equal('', rdata_hex(rr))
|
|
918
|
+
end
|
|
919
|
+
assert_equal(Classes::ANY, present.klass)
|
|
920
|
+
assert_equal(Classes::NONE, absent.klass)
|
|
921
|
+
assert_equal(Classes::ANY, delete_set.klass)
|
|
922
|
+
assert_equal(Classes::NONE, delete_record.klass)
|
|
923
|
+
assert_equal('1 . alpn=h2', delete_record.rdata_to_string)
|
|
924
|
+
|
|
925
|
+
decoded = Dnsruby::Message.decode(update.encode)
|
|
926
|
+
original_records = update.answer + update.authority
|
|
927
|
+
decoded_records = decoded.answer + decoded.authority
|
|
928
|
+
original_records.zip(decoded_records).each do |rr, copy|
|
|
929
|
+
assert_same(rr.class, copy.class)
|
|
930
|
+
assert_equal(rr.klass, copy.klass)
|
|
931
|
+
assert_equal(rdata_hex(rr), rdata_hex(copy))
|
|
932
|
+
end
|
|
933
|
+
assert_equal(update.encode, decoded.encode)
|
|
934
|
+
|
|
935
|
+
# Explicit pseudo-class construction must accept the same presentation
|
|
936
|
+
# format, and keep the requested class on the wire.
|
|
937
|
+
[Classes::ANY, Classes::NONE].each do |klass|
|
|
938
|
+
rr = RR.create("example.com. 0 CLASS#{klass} #{type} 1 . alpn=h2")
|
|
939
|
+
assert_equal(rr, rr.clone)
|
|
940
|
+
assert_equal(rr, RR.create(rr.to_s))
|
|
941
|
+
assert_same(rr.class, RR.get_class(type, klass))
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# Outside that dynamic-update case an incomplete record is a bug, and a Hash
|
|
947
|
+
# sets the two mandatory fields independently, so half a record is buildable.
|
|
948
|
+
def test_incomplete_record_rejected
|
|
949
|
+
[{ priority: 1 }, { target: 'foo.example.com.' }].each do |fields|
|
|
950
|
+
assert_raises(Dnsruby::DecodeError, fields.inspect) do
|
|
951
|
+
RR.create({ name: 'example.com.', type: Types::SVCB }.merge(fields))
|
|
952
|
+
end
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
['1', '0', '1 '].each do |rdata|
|
|
956
|
+
assert_raises(Dnsruby::DecodeError, rdata.inspect) do
|
|
957
|
+
svcb(rdata)
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# An empty record is legal to hold, but only the update classes may encode it.
|
|
962
|
+
['example.com. 3600 IN SVCB', 'example.com. 3600 IN HTTPS'].each do |str|
|
|
963
|
+
rr = RR.create(str)
|
|
964
|
+
assert_raises(Dnsruby::EncodeError, str) { rr.encode_rdata(Dnsruby::MessageEncoder.new) }
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
rr = svcb('1 . alpn=h2')
|
|
968
|
+
rr.target = nil
|
|
969
|
+
assert_raises(Dnsruby::EncodeError) { rr.encode_rdata(Dnsruby::MessageEncoder.new) }
|
|
970
|
+
# And it has no presentation format either, rather than raising ArgumentError.
|
|
971
|
+
assert_equal('', rr.rdata_to_string)
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# A malformed value off the wire would be truncated or padded and re-encode to
|
|
975
|
+
# the same bad octets, so it is rejected on the way in.
|
|
976
|
+
def test_decode_rejects_malformed_rdata
|
|
977
|
+
{
|
|
978
|
+
'ech, empty' => '000100' + '0005' + '0000',
|
|
979
|
+
'dohpath, empty' => '000100' + '0007' + '0000',
|
|
980
|
+
'port, 4 octets' => '000100' + '0003' + '0004' + '000001bb',
|
|
981
|
+
'ipv4hint, 3 octets' => '000100' + '0004' + '0003' + 'c00002',
|
|
982
|
+
'ipv6hint, 15 octets' => '000100' + '0006' + '000f' + '20010db80000000000000000000000',
|
|
983
|
+
'alpn id overruns' => '000100' + '0001' + '0003' + '036831',
|
|
984
|
+
'alpn zero-length id' => '000100' + '0001' + '0001' + '00',
|
|
985
|
+
'mandatory odd length' => '000100' + '0000' + '0003' + '000101',
|
|
986
|
+
'no-default-alpn, value' => '000100' + '0002' + '0003' + '616263',
|
|
987
|
+
'mandatory lists itself' => '000100' + '0000' + '0002' + '0000',
|
|
988
|
+
'mandatory key absent' => '000100' + '0000' + '0002' + '0003',
|
|
989
|
+
# mandatory=[port,port], with a port param present
|
|
990
|
+
'mandatory lists a dup' => '000100' + '0000' + '0004' + '00030003' +
|
|
991
|
+
'0003' + '0002' + '01bb',
|
|
992
|
+
# key 3 (port) before key 1 (alpn): SvcParams must strictly increase
|
|
993
|
+
'params out of order' => '000100' + '0003' + '0002' + '01bb' +
|
|
994
|
+
'0001' + '0003' + '026832',
|
|
995
|
+
}.each do |label, hex|
|
|
996
|
+
assert_raises(Dnsruby::DecodeError, label) do
|
|
997
|
+
decode(hex)
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
# Message.decode rejects a SvcParamValue length past the end of the RDATA at
|
|
1003
|
+
# the RDLENGTH boundary; the RFC 3597 "\# len hex" form decodes RDATA alone.
|
|
1004
|
+
def test_decode_rejects_truncated_svcparamvalue
|
|
1005
|
+
{
|
|
1006
|
+
'dohpath, 3 of 10 octets' => '000100' + '0007' + '000a' + '616263',
|
|
1007
|
+
'ech, 3 of 4 octets' => '000100' + '0005' + '0004' + 'abcdef',
|
|
1008
|
+
'alpn, no value at all' => '000100' + '0001' + '0003',
|
|
1009
|
+
}.each do |label, hex|
|
|
1010
|
+
assert_raises(Dnsruby::DecodeError, label) { decode(hex) }
|
|
1011
|
+
assert_raises(Dnsruby::DecodeError, label) do
|
|
1012
|
+
RR.create("example.com. 3600 IN SVCB \\# #{hex.length / 2} #{hex}")
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
# Callers can pass multibyte strings in params. Length fields count bytes,
|
|
1018
|
+
# whatever the String's encoding.
|
|
1019
|
+
def test_length_prefixed_decode_counts_octets
|
|
1020
|
+
rr = svcb('1 .')
|
|
1021
|
+
# [2]"\xC3\xA9" [2]"h2"
|
|
1022
|
+
rr.params[1] = "\x02\xc3\xa9\x02h2"
|
|
1023
|
+
assert_equal(6, rr.params[1].bytesize)
|
|
1024
|
+
assert_equal(5, rr.params[1].length, 'the point of the test is that these differ')
|
|
1025
|
+
assert_equal('1 . alpn=\195\169,h2', rr.rdata_to_string)
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# Address hints also count bytes, whatever the String's encoding.
|
|
1029
|
+
def test_address_hint_decode_counts_octets
|
|
1030
|
+
rr = svcb('1 .')
|
|
1031
|
+
|
|
1032
|
+
rr.params[4] = "\xc3\xa9\x01\x02\x03\x04\x05\x06"
|
|
1033
|
+
assert_equal(8, rr.params[4].bytesize)
|
|
1034
|
+
assert_equal(7, rr.params[4].length, 'the point of the test is that these differ')
|
|
1035
|
+
assert_equal('1 . ipv4hint=195.169.1.2,3.4.5.6', rr.rdata_to_string)
|
|
1036
|
+
|
|
1037
|
+
rr.params.delete(4)
|
|
1038
|
+
rr.params[6] = "\xc3\xa9" + "\x00" * 14 + "\x20\x01\x0d\xb8" + "\x00" * 12
|
|
1039
|
+
assert_equal(32, rr.params[6].bytesize)
|
|
1040
|
+
assert_equal(31, rr.params[6].length, 'the point of the test is that these differ')
|
|
1041
|
+
assert_equal('1 . ipv6hint=c3a9::,2001:db8::', rr.rdata_to_string)
|
|
1042
|
+
end
|
|
1043
|
+
end
|