dnsruby 1.74.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -4
  3. data/DNSSEC +3 -2
  4. data/EXAMPLES +1 -1
  5. data/README.md +370 -64
  6. data/RELEASE_NOTES.md +302 -1
  7. data/Rakefile +0 -3
  8. data/demo/axfr.rb +24 -6
  9. data/demo/rubydig.rb +0 -2
  10. data/dnsruby.gemspec +5 -4
  11. data/lib/dnsruby/cache.rb +279 -47
  12. data/lib/dnsruby/code_mapper.rb +35 -8
  13. data/lib/dnsruby/code_mappers.rb +63 -49
  14. data/lib/dnsruby/config.rb +75 -4
  15. data/lib/dnsruby/deadline_queue.rb +75 -0
  16. data/lib/dnsruby/dnssec.rb +148 -45
  17. data/lib/dnsruby/ipv6.rb +45 -5
  18. data/lib/dnsruby/key_cache.rb +202 -35
  19. data/lib/dnsruby/message/decoder.rb +85 -19
  20. data/lib/dnsruby/message/encoder.rb +27 -16
  21. data/lib/dnsruby/message/header.rb +8 -12
  22. data/lib/dnsruby/message/message.rb +53 -22
  23. data/lib/dnsruby/message/section.rb +12 -34
  24. data/lib/dnsruby/name.rb +5 -2
  25. data/lib/dnsruby/packet_sender.rb +645 -203
  26. data/lib/dnsruby/presentation.rb +147 -0
  27. data/lib/dnsruby/query_control.rb +132 -0
  28. data/lib/dnsruby/recursor.rb +795 -339
  29. data/lib/dnsruby/resolver.rb +468 -186
  30. data/lib/dnsruby/resource/CAA.rb +67 -16
  31. data/lib/dnsruby/resource/DLV.rb +6 -0
  32. data/lib/dnsruby/resource/DNSKEY.rb +108 -16
  33. data/lib/dnsruby/resource/DS.rb +27 -28
  34. data/lib/dnsruby/resource/HTTPS.rb +16 -0
  35. data/lib/dnsruby/resource/IN.rb +19 -0
  36. data/lib/dnsruby/resource/MX.rb +2 -2
  37. data/lib/dnsruby/resource/NSEC.rb +6 -10
  38. data/lib/dnsruby/resource/NSEC3.rb +55 -19
  39. data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
  40. data/lib/dnsruby/resource/RR.rb +108 -65
  41. data/lib/dnsruby/resource/RRSIG.rb +5 -5
  42. data/lib/dnsruby/resource/RRSet.rb +38 -22
  43. data/lib/dnsruby/resource/SRV.rb +2 -2
  44. data/lib/dnsruby/resource/SVCB.rb +775 -0
  45. data/lib/dnsruby/resource/TSIG.rb +266 -231
  46. data/lib/dnsruby/resource/TXT.rb +34 -42
  47. data/lib/dnsruby/resource/domain_name.rb +1 -1
  48. data/lib/dnsruby/resource/generic.rb +31 -0
  49. data/lib/dnsruby/resource/generic_cache.rb +384 -0
  50. data/lib/dnsruby/resource/generic_constants.rb +79 -0
  51. data/lib/dnsruby/select_thread.rb +979 -291
  52. data/lib/dnsruby/serial_time.rb +25 -0
  53. data/lib/dnsruby/single_resolver.rb +25 -6
  54. data/lib/dnsruby/single_verifier.rb +1620 -530
  55. data/lib/dnsruby/transport_clock.rb +9 -0
  56. data/lib/dnsruby/update.rb +8 -6
  57. data/lib/dnsruby/validation_budget.rb +143 -0
  58. data/lib/dnsruby/validator_thread.rb +139 -58
  59. data/lib/dnsruby/version.rb +1 -1
  60. data/lib/dnsruby/zone_reader.rb +196 -129
  61. data/lib/dnsruby/zone_transfer.rb +157 -25
  62. data/lib/dnsruby.rb +7 -2
  63. data/test/ci_watchdog.rb +33 -0
  64. data/test/fixtures/org-ds-2026-11-05.zone +5 -0
  65. data/test/fixtures/root-dnskey-2026-11-05.zone +7 -0
  66. data/test/fork_test_helper.rb +37 -0
  67. data/test/local_dns_server.rb +57 -0
  68. data/test/soak_server.rb +152 -0
  69. data/test/socket_helpers.rb +26 -0
  70. data/test/spec_helper.rb +8 -13
  71. data/test/tc_attempt_configuration.rb +137 -0
  72. data/test/tc_caa.rb +2 -2
  73. data/test/tc_cache_keys.rb +36 -0
  74. data/test/tc_cancelled_send_capacity.rb +158 -0
  75. data/test/tc_dnssec_alias_validation.rb +289 -0
  76. data/test/tc_dnssec_budget.rb +489 -0
  77. data/test/tc_dnssec_cache_bounds.rb +197 -0
  78. data/test/tc_dnssec_cache_lifetime.rb +615 -0
  79. data/test/tc_dnssec_denial_proofs.rb +285 -0
  80. data/test/tc_dnssec_recursion_limits.rb +186 -0
  81. data/test/tc_dnssec_recursor_review_fixes.rb +351 -0
  82. data/test/tc_dnssec_trust_anchors.rb +286 -0
  83. data/test/tc_dnssec_trust_races.rb +147 -0
  84. data/test/tc_dnssec_wildcard_proofs.rb +249 -0
  85. data/test/tc_ds.rb +5 -5
  86. data/test/tc_edns_retry_regressions.rb +255 -0
  87. data/test/tc_escapedchars.rb +8 -0
  88. data/test/tc_final_review_fixes.rb +167 -0
  89. data/test/tc_final_transport_contracts.rb +104 -0
  90. data/test/tc_generic_cache.rb +852 -0
  91. data/test/tc_header.rb +26 -0
  92. data/test/tc_ipv6.rb +120 -0
  93. data/test/tc_key_cache_identity.rb +382 -0
  94. data/test/tc_malformed_tcp.rb +712 -0
  95. data/test/tc_name.rb +99 -5
  96. data/test/tc_nameserver_bootstrap.rb +322 -0
  97. data/test/tc_nsec.rb +4 -45
  98. data/test/tc_nsec3.rb +17 -1
  99. data/test/tc_pipeline_retirement.rb +358 -0
  100. data/test/tc_pipeline_retry.rb +462 -0
  101. data/test/tc_pipeline_send.rb +663 -0
  102. data/test/tc_protocol_review_fixes.rb +95 -0
  103. data/test/tc_query_control.rb +90 -0
  104. data/test/tc_record_codec_contracts.rb +107 -0
  105. data/test/tc_record_regressions.rb +225 -0
  106. data/test/tc_recur.rb +1 -0
  107. data/test/tc_recursor_address_fallback.rb +169 -0
  108. data/test/tc_recursor_limits.rb +139 -0
  109. data/test/tc_release_auxiliary_fixes.rb +288 -0
  110. data/test/tc_release_configuration_fixes.rb +612 -0
  111. data/test/tc_release_dnssec_completion.rb +405 -0
  112. data/test/tc_release_dnssec_fixes.rb +305 -0
  113. data/test/tc_release_negative_cache.rb +327 -0
  114. data/test/tc_release_recursor_cache.rb +212 -0
  115. data/test/tc_release_review_transport.rb +372 -0
  116. data/test/tc_release_review_transport_native.rb +56 -0
  117. data/test/tc_release_transport.rb +906 -0
  118. data/test/tc_release_transport_fixes.rb +196 -0
  119. data/test/tc_release_zone_defaults.rb +157 -0
  120. data/test/tc_release_zone_ttls.rb +39 -0
  121. data/test/tc_res_config.rb +42 -20
  122. data/test/tc_res_opt.rb +21 -1
  123. data/test/tc_resolver.rb +21 -19
  124. data/test/tc_resolver_concurrency.rb +383 -0
  125. data/test/tc_resolver_configuration_contracts.rb +31 -0
  126. data/test/tc_resolver_validation.rb +211 -0
  127. data/test/tc_review_cache.rb +163 -0
  128. data/test/tc_review_code_mapper.rb +279 -0
  129. data/test/tc_review_control_deadlines.rb +169 -0
  130. data/test/tc_review_followup_transport.rb +583 -0
  131. data/test/tc_review_packet_contracts.rb +369 -0
  132. data/test/tc_review_presentation.rb +213 -0
  133. data/test/tc_review_record_compatibility.rb +101 -0
  134. data/test/tc_review_transport_fixes.rb +195 -0
  135. data/test/tc_review_transport_native.rb +415 -0
  136. data/test/tc_review_validation_priming.rb +178 -0
  137. data/test/tc_rr-opt.rb +5 -2
  138. data/test/tc_rr-unknown.rb +19 -0
  139. data/test/tc_rr.rb +14 -0
  140. data/test/tc_rrset_grouping.rb +50 -0
  141. data/test/tc_rrset_review_fixes.rb +58 -0
  142. data/test/tc_rrsig.rb +21 -1
  143. data/test/tc_scoped_ipv6.rb +153 -0
  144. data/test/tc_scoped_ipv6_select.rb +157 -0
  145. data/test/tc_scoped_ipv6_transport.rb +329 -0
  146. data/test/tc_selector_wakeup.rb +42 -0
  147. data/test/tc_soak.rb +118 -196
  148. data/test/tc_socket_helpers.rb +90 -0
  149. data/test/tc_supporting_lookup_limits.rb +252 -0
  150. data/test/tc_svcb.rb +1043 -0
  151. data/test/tc_svcb_signed_target.rb +145 -0
  152. data/test/tc_svcb_transport.rb +120 -0
  153. data/test/tc_tcp.rb +69 -43
  154. data/test/tc_tcp_pipelining.rb +95 -204
  155. data/test/tc_transport_fork.rb +179 -0
  156. data/test/tc_tsig.rb +2 -2
  157. data/test/tc_tsig_api.rb +251 -0
  158. data/test/tc_tsig_rfc8945.rb +357 -0
  159. data/test/tc_v2_release_dnssec.rb +442 -0
  160. data/test/tc_v2_release_transport.rb +304 -0
  161. data/test/tc_v2_zone_transfer_native.rb +58 -0
  162. data/test/tc_zone_reader.rb +217 -1
  163. data/test/tcp_pipelining_server.rb +150 -0
  164. data/test/ts_offline.rb +77 -6
  165. data/test/ts_online.rb +2 -1
  166. metadata +105 -10
  167. data/.coveralls.yml +0 -2
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