dnsruby 1.73.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -4
  3. data/DNSSEC +3 -2
  4. data/EXAMPLES +1 -1
  5. data/README.md +370 -64
  6. data/RELEASE_NOTES.md +314 -1
  7. data/Rakefile +0 -3
  8. data/demo/axfr.rb +24 -6
  9. data/dnsruby.gemspec +6 -4
  10. data/lib/dnsruby/cache.rb +279 -49
  11. data/lib/dnsruby/code_mapper.rb +35 -8
  12. data/lib/dnsruby/code_mappers.rb +63 -49
  13. data/lib/dnsruby/config.rb +77 -4
  14. data/lib/dnsruby/deadline_queue.rb +75 -0
  15. data/lib/dnsruby/dnssec.rb +148 -45
  16. data/lib/dnsruby/ipv6.rb +45 -5
  17. data/lib/dnsruby/key_cache.rb +202 -35
  18. data/lib/dnsruby/message/decoder.rb +85 -19
  19. data/lib/dnsruby/message/encoder.rb +27 -16
  20. data/lib/dnsruby/message/header.rb +8 -12
  21. data/lib/dnsruby/message/message.rb +53 -22
  22. data/lib/dnsruby/message/section.rb +12 -34
  23. data/lib/dnsruby/name.rb +5 -2
  24. data/lib/dnsruby/packet_sender.rb +664 -207
  25. data/lib/dnsruby/presentation.rb +147 -0
  26. data/lib/dnsruby/query_control.rb +132 -0
  27. data/lib/dnsruby/recursor.rb +798 -341
  28. data/lib/dnsruby/resolver.rb +472 -176
  29. data/lib/dnsruby/resource/CAA.rb +67 -16
  30. data/lib/dnsruby/resource/DLV.rb +6 -0
  31. data/lib/dnsruby/resource/DNSKEY.rb +108 -16
  32. data/lib/dnsruby/resource/DS.rb +27 -28
  33. data/lib/dnsruby/resource/HTTPS.rb +16 -0
  34. data/lib/dnsruby/resource/IN.rb +21 -2
  35. data/lib/dnsruby/resource/MX.rb +2 -2
  36. data/lib/dnsruby/resource/NSEC.rb +6 -10
  37. data/lib/dnsruby/resource/NSEC3.rb +55 -19
  38. data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
  39. data/lib/dnsruby/resource/OPT.rb +2 -3
  40. data/lib/dnsruby/resource/RR.rb +108 -65
  41. data/lib/dnsruby/resource/RRSIG.rb +5 -5
  42. data/lib/dnsruby/resource/RRSet.rb +38 -22
  43. data/lib/dnsruby/resource/SRV.rb +2 -2
  44. data/lib/dnsruby/resource/SVCB.rb +775 -0
  45. data/lib/dnsruby/resource/TSIG.rb +266 -231
  46. data/lib/dnsruby/resource/TXT.rb +34 -42
  47. data/lib/dnsruby/resource/domain_name.rb +1 -1
  48. data/lib/dnsruby/resource/generic.rb +31 -0
  49. data/lib/dnsruby/resource/generic_cache.rb +384 -0
  50. data/lib/dnsruby/resource/generic_constants.rb +79 -0
  51. data/lib/dnsruby/select_thread.rb +986 -291
  52. data/lib/dnsruby/serial_time.rb +25 -0
  53. data/lib/dnsruby/single_resolver.rb +25 -6
  54. data/lib/dnsruby/single_verifier.rb +1620 -530
  55. data/lib/dnsruby/transport_clock.rb +9 -0
  56. data/lib/dnsruby/update.rb +8 -6
  57. data/lib/dnsruby/validation_budget.rb +143 -0
  58. data/lib/dnsruby/validator_thread.rb +139 -58
  59. data/lib/dnsruby/version.rb +1 -1
  60. data/lib/dnsruby/zone_reader.rb +196 -129
  61. data/lib/dnsruby/zone_transfer.rb +157 -25
  62. data/lib/dnsruby.rb +7 -2
  63. data/test/ci_watchdog.rb +33 -0
  64. data/test/fixtures/org-ds-2026-11-05.zone +5 -0
  65. data/test/fixtures/root-dnskey-2026-11-05.zone +7 -0
  66. data/test/fork_test_helper.rb +37 -0
  67. data/test/local_dns_server.rb +57 -0
  68. data/test/soak_server.rb +152 -0
  69. data/test/socket_helpers.rb +26 -0
  70. data/test/spec_helper.rb +8 -13
  71. data/test/tc_attempt_configuration.rb +137 -0
  72. data/test/tc_caa.rb +2 -2
  73. data/test/tc_cache_keys.rb +36 -0
  74. data/test/tc_cancelled_send_capacity.rb +158 -0
  75. data/test/tc_dnssec_alias_validation.rb +289 -0
  76. data/test/tc_dnssec_budget.rb +489 -0
  77. data/test/tc_dnssec_cache_bounds.rb +197 -0
  78. data/test/tc_dnssec_cache_lifetime.rb +615 -0
  79. data/test/tc_dnssec_denial_proofs.rb +285 -0
  80. data/test/tc_dnssec_recursion_limits.rb +186 -0
  81. data/test/tc_dnssec_recursor_review_fixes.rb +351 -0
  82. data/test/tc_dnssec_trust_anchors.rb +286 -0
  83. data/test/tc_dnssec_trust_races.rb +147 -0
  84. data/test/tc_dnssec_wildcard_proofs.rb +249 -0
  85. data/test/tc_ds.rb +5 -5
  86. data/test/tc_edns_retry_regressions.rb +255 -0
  87. data/test/tc_escapedchars.rb +8 -0
  88. data/test/tc_final_review_fixes.rb +167 -0
  89. data/test/tc_final_transport_contracts.rb +104 -0
  90. data/test/tc_generic_cache.rb +852 -0
  91. data/test/tc_header.rb +26 -0
  92. data/test/tc_ipv6.rb +120 -0
  93. data/test/tc_key_cache_identity.rb +382 -0
  94. data/test/tc_malformed_tcp.rb +712 -0
  95. data/test/tc_name.rb +99 -5
  96. data/test/tc_nameserver_bootstrap.rb +322 -0
  97. data/test/tc_nsec.rb +4 -45
  98. data/test/tc_nsec3.rb +17 -1
  99. data/test/tc_pipeline_retirement.rb +358 -0
  100. data/test/tc_pipeline_retry.rb +462 -0
  101. data/test/tc_pipeline_send.rb +663 -0
  102. data/test/tc_protocol_review_fixes.rb +95 -0
  103. data/test/tc_query_control.rb +90 -0
  104. data/test/tc_record_codec_contracts.rb +107 -0
  105. data/test/tc_record_regressions.rb +225 -0
  106. data/test/tc_recur.rb +1 -0
  107. data/test/tc_recursor_address_fallback.rb +169 -0
  108. data/test/tc_recursor_limits.rb +139 -0
  109. data/test/tc_release_auxiliary_fixes.rb +288 -0
  110. data/test/tc_release_configuration_fixes.rb +612 -0
  111. data/test/tc_release_dnssec_completion.rb +405 -0
  112. data/test/tc_release_dnssec_fixes.rb +305 -0
  113. data/test/tc_release_negative_cache.rb +327 -0
  114. data/test/tc_release_recursor_cache.rb +212 -0
  115. data/test/tc_release_review_transport.rb +372 -0
  116. data/test/tc_release_review_transport_native.rb +56 -0
  117. data/test/tc_release_transport.rb +906 -0
  118. data/test/tc_release_transport_fixes.rb +196 -0
  119. data/test/tc_release_zone_defaults.rb +157 -0
  120. data/test/tc_release_zone_ttls.rb +39 -0
  121. data/test/tc_res_config.rb +53 -2
  122. data/test/tc_res_opt.rb +21 -1
  123. data/test/tc_resolver.rb +34 -19
  124. data/test/tc_resolver_concurrency.rb +383 -0
  125. data/test/tc_resolver_configuration_contracts.rb +31 -0
  126. data/test/tc_resolver_validation.rb +211 -0
  127. data/test/tc_review_cache.rb +163 -0
  128. data/test/tc_review_code_mapper.rb +279 -0
  129. data/test/tc_review_control_deadlines.rb +169 -0
  130. data/test/tc_review_followup_transport.rb +583 -0
  131. data/test/tc_review_packet_contracts.rb +369 -0
  132. data/test/tc_review_presentation.rb +213 -0
  133. data/test/tc_review_record_compatibility.rb +101 -0
  134. data/test/tc_review_transport_fixes.rb +195 -0
  135. data/test/tc_review_transport_native.rb +415 -0
  136. data/test/tc_review_validation_priming.rb +178 -0
  137. data/test/tc_rr-opt.rb +5 -2
  138. data/test/tc_rr-unknown.rb +19 -0
  139. data/test/tc_rr.rb +14 -0
  140. data/test/tc_rrset_grouping.rb +50 -0
  141. data/test/tc_rrset_review_fixes.rb +58 -0
  142. data/test/tc_rrsig.rb +21 -1
  143. data/test/tc_scoped_ipv6.rb +153 -0
  144. data/test/tc_scoped_ipv6_select.rb +157 -0
  145. data/test/tc_scoped_ipv6_transport.rb +329 -0
  146. data/test/tc_selector_wakeup.rb +42 -0
  147. data/test/tc_soak.rb +118 -196
  148. data/test/tc_socket_helpers.rb +90 -0
  149. data/test/tc_supporting_lookup_limits.rb +252 -0
  150. data/test/tc_svcb.rb +1043 -0
  151. data/test/tc_svcb_signed_target.rb +145 -0
  152. data/test/tc_svcb_transport.rb +120 -0
  153. data/test/tc_tcp.rb +69 -43
  154. data/test/tc_tcp_pipelining.rb +95 -204
  155. data/test/tc_transport_fork.rb +179 -0
  156. data/test/tc_tsig.rb +2 -2
  157. data/test/tc_tsig_api.rb +251 -0
  158. data/test/tc_tsig_rfc8945.rb +357 -0
  159. data/test/tc_v2_release_dnssec.rb +442 -0
  160. data/test/tc_v2_release_transport.rb +304 -0
  161. data/test/tc_v2_zone_transfer_native.rb +58 -0
  162. data/test/tc_zone_reader.rb +217 -1
  163. data/test/tcp_pipelining_server.rb +150 -0
  164. data/test/ts_offline.rb +77 -6
  165. data/test/ts_online.rb +2 -1
  166. metadata +127 -14
  167. data/.coveralls.yml +0 -2
@@ -0,0 +1,775 @@
1
+ require 'base64'
2
+
3
+ module Dnsruby
4
+ class RR
5
+ # Class for DNS Service Binding (SVCB) resource records.
6
+ #
7
+ # RFC 9460
8
+ #
9
+ # The presentation format is:
10
+ # Name TTL Class SVCB SvcPriority TargetName SvcParams
11
+ #
12
+ # A SvcPriority of 0 is AliasMode, where TargetName aliases the service to
13
+ # another name; non-zero is ServiceMode, where the SvcParams describe the
14
+ # endpoint.
15
+ #
16
+ # RR.create also takes a Hash, whose :params values are in presentation
17
+ # format -- the text a zone file carries after the "=". #params_to_hash is
18
+ # the inverse of that Hash.
19
+ class SVCB < RR
20
+ ClassValue = nil #:nodoc: all
21
+ TypeValue = Types::SVCB #:nodoc: all
22
+
23
+ # The registered SvcParamKey mnemonics: 0-4 and 6 from RFC 9460, 5 (ech)
24
+ # from RFC 9848, 7 (dohpath) from RFC 9461, 8 (ohttp) from RFC 9540 sec 4
25
+ # and 10 (docpath) from RFC 9953 sec 3. Keys whose value format is not
26
+ # yet published as an RFC are left out, and read and write as keyNNNNN.
27
+ KEY_NAME_TO_NUM = {
28
+ 'mandatory' => 0,
29
+ 'alpn' => 1,
30
+ 'no-default-alpn' => 2,
31
+ 'port' => 3,
32
+ 'ipv4hint' => 4,
33
+ 'ech' => 5,
34
+ 'ipv6hint' => 6,
35
+ 'dohpath' => 7,
36
+ 'ohttp' => 8,
37
+ 'docpath' => 10,
38
+ }.freeze
39
+
40
+ # The same table by number, for naming a key in presentation format and
41
+ # in error messages. A number with no mnemonic here becomes keyNNNNN.
42
+ KEY_NUM_TO_NAME = KEY_NAME_TO_NUM.invert.freeze
43
+
44
+ # Keys whose presentation format requires a SvcParamValue. An empty
45
+ # docpath means the root path, and an unknown key may stand alone.
46
+ KEYS_REQUIRING_VALUE = [0, 1, 3, 4, 5, 6, 7].freeze
47
+
48
+ # Keys defined to carry no value at all: no-default-alpn and ohttp.
49
+ KEYS_FORBIDDING_VALUE = [2, 8].freeze
50
+
51
+ # RFC 9460 sections 7.2, 7.3 and 8, and RFC 9848 section 3, prohibit
52
+ # escapes in these named parameters. Numeric keyNNNNN values still
53
+ # use the generic byte format.
54
+ KEYS_FORBIDDING_ESCAPES = [0, 3, 4, 5, 6].freeze
55
+
56
+ # RFC 9460 sec 14.3.2 reserves 65535 as an invalid SvcParamKey.
57
+ INVALID_KEY = 0xffff
58
+
59
+ # The SvcPriority field (0-65535). Zero indicates AliasMode.
60
+ attr_reader :priority
61
+
62
+ # Sets the SvcPriority, raising DecodeError unless the value is an
63
+ # integer in 0..65535.
64
+ def priority=(value)
65
+ @priority = to_uint16(value, 'SvcPriority')
66
+ end
67
+
68
+ # The TargetName field, a Dnsruby::Name.
69
+ attr_accessor :target
70
+
71
+ # The SvcParams, an ordered Hash mapping the numeric SvcParamKey to the
72
+ # SvcParamValue in wire format. See #params_to_hash for a readable view.
73
+ attr_accessor :params
74
+
75
+ # Keep the binary RDATA interface provided for TYPE64/TYPE65 in 1.74.0.
76
+ # Compute bytes on demand so they reflect changes to target or params.
77
+ def data
78
+ MessageEncoder.new { |encoder| encode_rdata(encoder) }.to_s
79
+ end
80
+ alias rdata data
81
+
82
+ # Parse before assigning, so an invalid replacement leaves the record
83
+ # unchanged. Raw RDATA is always interpreted as octets, not UTF-8 text.
84
+ def data=(bytes)
85
+ raise DecodeError, 'SVCB RDATA must be a binary String' unless bytes.is_a?(String)
86
+ raise DecodeError, 'SVCB RDATA exceeds 65535 octets' if bytes.bytesize > 0xffff
87
+
88
+ parsed = self.class.decode_rdata(MessageDecoder.new(bytes.b))
89
+ parsed.klass = klass if klass
90
+ parsed.validate_decoded_rdata
91
+ @priority, @target, @params = parsed.priority, parsed.target, parsed.params
92
+ end
93
+ alias rdata= data=
94
+
95
+ def rdlength
96
+ data.bytesize
97
+ end
98
+
99
+ # RR's default comparator uses the construction-time @rdata ivar, which
100
+ # can be presentation text or a tuple. Compare the same current bytes
101
+ # that callers receive from #rdata, regardless of construction path.
102
+ def <=>(other)
103
+ if @name.canonical == other.name.canonical && @type.code == other.type.code
104
+ rdata <=> other.rdata
105
+ else
106
+ super
107
+ end
108
+ end
109
+
110
+ # RFC 3597 section 6 compares names inside newer RDATA formats as
111
+ # octets. RR's normal equality already compares priority and params,
112
+ # but Name equality deliberately ignores case. Keep the usual RR
113
+ # behavior (including ignoring TTL), with this additional distinction.
114
+ # Comparing labels also works for unfinished records used by UPDATE.
115
+ def ==(other)
116
+ super && (@target.nil? ||
117
+ @target.labels.map(&:string) == other.target.labels.map(&:string))
118
+ end
119
+ alias eql? ==
120
+
121
+ def hash
122
+ super ^ (@target ? @target.labels.map(&:string).hash : 0)
123
+ end
124
+
125
+ def from_hash(hash) #:nodoc: all
126
+ if hash.key?(:data) && hash.key?(:rdata) && hash[:data] != hash[:rdata]
127
+ raise DecodeError, 'SVCB data and rdata must agree when both are supplied'
128
+ end
129
+ self.data = hash.fetch(:data) { hash[:rdata] } if hash.key?(:data) || hash.key?(:rdata)
130
+ self.priority = hash[:priority] if hash[:priority]
131
+ @target = self.class.create_target_name(hash[:target]) if hash[:target]
132
+ @params ||= {}
133
+ if hash[:params]
134
+ @params = {}
135
+ hash[:params].each do |key, value|
136
+ store_param(key.to_s, value.nil? ? nil : value.to_s)
137
+ end
138
+ end
139
+ validate_svcparams(@priority, @params)
140
+ check_complete_pair
141
+ end
142
+
143
+ def from_data(data) #:nodoc: all
144
+ # Generic constructors accepted a one-element Array of raw bytes.
145
+ # Retain that form alongside the parsed tuple used by this codec.
146
+ if data.length == 1 && data.first.is_a?(String)
147
+ self.data = data.first
148
+ else
149
+ @priority, @target, @params = data
150
+ end
151
+ end
152
+
153
+ def from_string(input) #:nodoc: all
154
+ # Generic.new historically accepted raw bytes. Every complete SVCB
155
+ # TargetName has a NUL terminator, so try its wire form when present.
156
+ # A presentation value can itself contain NUL; retain the normal text
157
+ # parser if the string is not also a complete, valid wire record.
158
+ if input && input.include?("\0")
159
+ begin
160
+ self.data = input
161
+ return
162
+ rescue DecodeError
163
+ # Not wire-format RDATA; parse the presentation form below.
164
+ end
165
+ end
166
+ @params = {}
167
+ return if input.nil? || input.strip.empty?
168
+
169
+ tokens = split_svcparams(input.strip)
170
+ self.priority = tokens.shift
171
+ target = tokens.shift
172
+ # Name.create(nil) would raise ArgumentError, not DecodeError.
173
+ raise DecodeError.new('SVCB record expects a TargetName after the SvcPriority') if target.nil?
174
+
175
+ @target = self.class.create_target_name(target)
176
+
177
+ tokens.each do |token|
178
+ key, eq, value = token.partition('=')
179
+ store_param(key, eq.empty? ? nil : value)
180
+ end
181
+ validate_svcparams(@priority, @params)
182
+ end
183
+
184
+ def rdata_to_string #:nodoc: all
185
+ return '' if @priority.nil? || @target.nil?
186
+
187
+ params_to_hash.inject(+"#{@priority} #{@target.to_s(true)}") do |s, (name, value)|
188
+ s << (value.nil? ? " #{name}" : " #{name}=#{value}")
189
+ end
190
+ end
191
+
192
+ def encode_rdata(msg, canonical=false) #:nodoc: all
193
+ # An RRset delete (RFC 2136 sec 2.5.2) names the type with no RDATA at
194
+ # all, which is what Update#delete builds; domain_name.rb does the same.
195
+ if @priority.nil? && @target.nil?
196
+ return if [Classes::NONE, Classes::ANY].include?(klass)
197
+ end
198
+
199
+ if @priority.nil? || @target.nil?
200
+ raise EncodeError.new('SVCB record needs both a SvcPriority and a ' \
201
+ "TargetName, got #{@priority.inspect} and #{@target.inspect}")
202
+ end
203
+
204
+ # params is publicly mutable, so construction-time checks alone are
205
+ # insufficient. Validate before pack('n') can truncate a wire length.
206
+ params = (@params || {}).to_a
207
+ length = 3 + @target.to_a.sum { |label| 1 + label.string.bytesize }
208
+ begin
209
+ params.each do |num, value|
210
+ unless num.is_a?(Integer) && (0...INVALID_KEY).cover?(num) && value.is_a?(String)
211
+ raise DecodeError.new('SvcParams must map valid numeric keys to wire-format strings')
212
+ end
213
+ validate_param_value(num, value)
214
+ length += 4 + value.bytesize
215
+ end
216
+ validate_svcparams(@priority, @params || {})
217
+ rescue DecodeError => e
218
+ raise EncodeError.new(e.message)
219
+ end
220
+ if length > 0xffff
221
+ raise EncodeError.new("SVCB RDATA exceeds 65535 octets: #{length}")
222
+ end
223
+
224
+ msg.put_pack('n', @priority)
225
+ # TargetName is never compressed (RFC 9460 section 2.2). Its case is
226
+ # preserved in canonical RDATA for DNSSEC (RFC 3597 section 7).
227
+ msg.put_name(@target, true, false)
228
+ params.sort_by(&:first).each do |num, value|
229
+ msg.put_pack('n', num)
230
+ msg.put_pack('n', value.bytesize)
231
+ msg.put_bytes(value.b)
232
+ end
233
+ end
234
+
235
+ def self.decode_rdata(msg) #:nodoc: all
236
+ # The reading half of the empty record above.
237
+ unless msg.has_remaining?
238
+ record = new([nil, nil, {}])
239
+ record.instance_variable_set(:@rdata, ''.b)
240
+ return record
241
+ end
242
+
243
+ start = msg.index
244
+ priority, = msg.get_unpack('n')
245
+ target = decode_target_name(msg)
246
+ params = {}
247
+ last_key = nil
248
+ while msg.has_remaining?
249
+ key, length = msg.get_unpack('nn')
250
+ if last_key && key <= last_key
251
+ raise DecodeError.new('SvcParams must be in strictly increasing key order without duplicates')
252
+ end
253
+ last_key = key
254
+ # get_bytes returns what is there, so a length past the end would end
255
+ # this loop short. Message.decode catches that at the RDLENGTH
256
+ # boundary; RR.new_from_data, decoding RDATA alone, has none.
257
+ value = msg.get_bytes(length).to_s # nil past the end of the buffer
258
+ if value.bytesize != length
259
+ raise DecodeError.new("SvcParamValue for #{num_to_key(key)} is truncated: " \
260
+ "#{length} octets declared, #{value.bytesize} present")
261
+ end
262
+ validate_param_value(key, value)
263
+ params[key] = value
264
+ end
265
+ validate_svcparams(priority, params)
266
+ record = new([priority, target, params])
267
+ # Keep the original bytes in @rdata for callers such as JSONDNS that
268
+ # read it directly. The public data/rdata readers rebuild the bytes
269
+ # from the current fields.
270
+ record.instance_variable_set(:@rdata, msg.data.byteslice(start, msg.index - start).b)
271
+ record
272
+ end
273
+
274
+ def self.create_target_name(value) #:nodoc: all
275
+ # IDNA conversion case-folds ASCII, but RFC 3597 sec 7 requires the
276
+ # original octets in SVCB/HTTPS canonical RDATA. DNS presentation
277
+ # strings (including escaped wire octets) are already ASCII; parse
278
+ # them directly. Retain Name.create's IDNA handling for Unicode input
279
+ # and its copying behavior for an existing Name.
280
+ if value.is_a?(String)
281
+ value = Name.punycode(value) unless value.ascii_only?
282
+ # Only an unescaped final dot denotes the root. A literal dot in
283
+ # the last label (foo\.) still needs the zone origin appended.
284
+ absolute = /(?<!\\)(?:\\\\)*\.\z/.match?(value)
285
+ Name.new(Name.split_escaped(value), absolute)
286
+ else
287
+ Name.create(value)
288
+ end
289
+ end
290
+
291
+ def validate_decoded_rdata #:nodoc: all
292
+ if @priority.nil? && ![Classes::ANY, Classes::NONE].include?(klass)
293
+ raise DecodeError.new('Empty SVCB RDATA is only valid for ANY/NONE UPDATE markers')
294
+ end
295
+ end
296
+
297
+ # RFC 9460 requires an uncompressed, complete TargetName. The generic
298
+ # name decoder also supports pointers and tolerates end-of-buffer, so
299
+ # read this field explicitly rather than synthesizing a missing root.
300
+ def self.decode_target_name(msg) #:nodoc: all
301
+ labels = []
302
+ loop do
303
+ raise DecodeError.new('SVCB TargetName is truncated') unless msg.has_remaining?
304
+ length, = msg.get_unpack('C')
305
+ raise DecodeError.new('SVCB TargetName is truncated') if length.nil?
306
+ break if length.zero?
307
+ if length > 63
308
+ raise DecodeError.new('SVCB TargetName must contain uncompressed labels')
309
+ end
310
+ msg.assert_buffer_position_valid(msg.index + length)
311
+ bytes = msg.get_bytes(length)
312
+ if bytes.nil? || bytes.bytesize != length
313
+ raise DecodeError.new('SVCB TargetName label is truncated')
314
+ end
315
+ labels << Name::Label.new(bytes)
316
+ end
317
+ Name.new(labels)
318
+ rescue ResolvError => e
319
+ raise DecodeError.new(e.message)
320
+ end
321
+ private_class_method :decode_target_name
322
+
323
+ # Returns the SvcParams as a Hash mapping the mnemonic (or "keyNNNNN") to
324
+ # its presentation-format value, nil for a value-less key.
325
+ def params_to_hash
326
+ sorted_params.each_with_object({}) do |(num, value), result|
327
+ result[num_to_key(num)] = decode_param_value(num, value)
328
+ end
329
+ end
330
+
331
+ private
332
+
333
+ # RFC 9460 sec 2.2: the SvcParams are written in increasing key order.
334
+ def sorted_params
335
+ (@params || {}).sort_by(&:first)
336
+ end
337
+
338
+ # Neither field at all is the empty record above, which stays allowed.
339
+ def check_complete_pair
340
+ return if @priority.nil? == @target.nil?
341
+
342
+ given, missing = @priority.nil? ? %w[target priority] : %w[priority target]
343
+ raise DecodeError.new("SVCB record given a #{given} but no #{missing}; both are mandatory")
344
+ end
345
+
346
+ # Both constructors supply presentation text. Check restrictions before
347
+ # character-string decoding can erase an otherwise forbidden escape.
348
+ def store_param(key, value)
349
+ num = key_to_num(key)
350
+ if @params.key?(num)
351
+ raise DecodeError.new("duplicate SvcParamKey: #{num_to_key(num)}")
352
+ end
353
+ generic = key.downcase.start_with?('key')
354
+ if !generic && KEYS_FORBIDDING_ESCAPES.include?(num) && value&.include?('\\')
355
+ raise DecodeError.new("SvcParamValue for #{num_to_key(num)} must not contain escape sequences")
356
+ end
357
+ value = decode_presentation_value(value) unless value.nil?
358
+ # RFC 9460 sec 2.1: keyNNNNN uses the generic character-string form,
359
+ # even when that number has a known mnemonic. Its decoded octets are
360
+ # already wire format, rather than input for the mnemonic's codec.
361
+ if generic
362
+ wire = value.to_s.b
363
+ validate_param_value(num, wire)
364
+ @params[num] = wire
365
+ else
366
+ @params[num] = encode_param_value(num, value)
367
+ end
368
+ end
369
+
370
+ # The presentation <-> wire codec for the SvcParams, needed by
371
+ # .decode_rdata and by the instance methods alike. Extended and included
372
+ # so that the one `private` above covers both scopes.
373
+ module Codec #:nodoc: all
374
+ private
375
+
376
+ # Translates a mnemonic or "keyNNNNN" token to its number. RFC 9460 sec
377
+ # 2.1 writes that number without leading zeros, so key01 is not alpn.
378
+ def key_to_num(key)
379
+ key = key.downcase
380
+ return KEY_NAME_TO_NUM[key] if KEY_NAME_TO_NUM.key?(key)
381
+ if (m = /\Akey(0|[1-9]\d*)\z/.match(key))
382
+ num = m[1].to_i
383
+ raise DecodeError.new("SvcParamKey out of range: #{key}") if num > 0xffff
384
+ if num == INVALID_KEY
385
+ raise DecodeError.new("SvcParamKey #{INVALID_KEY} is reserved as invalid")
386
+ end
387
+ return num
388
+ end
389
+ raise DecodeError.new("Unknown SvcParamKey: #{key.inspect}")
390
+ end
391
+
392
+ def num_to_key(num)
393
+ KEY_NUM_TO_NAME[num] || "key#{num}"
394
+ end
395
+
396
+ # String#to_i and pack('n') fail silently: "99999" becomes 34463.
397
+ def to_uint16(value, field)
398
+ text = value.to_s.strip
399
+ unless /\A\d+\z/.match?(text) && text.to_i <= 0xffff
400
+ raise DecodeError.new("#{field} must be an integer in 0..65535, got #{value.inspect}")
401
+ end
402
+ text.to_i
403
+ end
404
+
405
+ # Splits the SvcParams into whitespace-separated tokens, keeping
406
+ # double-quoted sections (which may contain spaces) intact and treating
407
+ # a backslash as escaping the octet after it.
408
+ def split_svcparams(str)
409
+ # Checked first: on an unterminated quote the scan below silently
410
+ # splits the value at its spaces instead.
411
+ if unterminated_quote?(str)
412
+ raise DecodeError.new("unterminated quoted SvcParamValue in #{str.inspect}")
413
+ end
414
+
415
+ str.scan(/(?:"(?:\\.|[^"\\])*"|\\.|[^\s"])+/m)
416
+ end
417
+
418
+ # Drops the escaped octets, then an odd number of quotes is left open.
419
+ def unterminated_quote?(str)
420
+ str.gsub(/\\./m, '').count('"').odd?
421
+ end
422
+
423
+ # Runs before the per-key encoding -- see the value-list note below.
424
+ def decode_presentation_value(value)
425
+ unescape_char_string(unquote(value))
426
+ end
427
+
428
+ def unquote(value)
429
+ if value.length >= 2 && value.start_with?('"') && value.end_with?('"')
430
+ value[1...-1]
431
+ else
432
+ value
433
+ end
434
+ end
435
+
436
+ # Resolves the backslash escapes of a character-string (\X and \DDD
437
+ # decimal), in octets, so a multibyte character becomes the octets it
438
+ # stands for.
439
+ def unescape_char_string(str)
440
+ str = str.b
441
+ result = +''.b
442
+ i = 0
443
+ while i < str.length
444
+ c = str[i]
445
+ if c == '\\'
446
+ # RFC 1035 sec 5.1: the backslash escapes the octet after it.
447
+ raise DecodeError.new("escape at end of #{str.inspect} has nothing to escape") if
448
+ i + 1 >= str.length
449
+
450
+ nxt = str[i + 1]
451
+ if nxt =~ /\d/
452
+ # \DDD is exactly three digits, and stands for one octet.
453
+ unless str[i + 1, 3] =~ /\A\d{3}\z/
454
+ raise DecodeError.new("escape #{str[i, 4].inspect} must be three digits")
455
+ end
456
+ octet = str[i + 1, 3].to_i
457
+ if octet > 0xff
458
+ raise DecodeError.new("escape \\#{str[i + 1, 3]} is not an octet value")
459
+ end
460
+ result << octet.chr
461
+ i += 4
462
+ else
463
+ result << nxt
464
+ i += 2
465
+ end
466
+ elsif c == '"'
467
+ # The surrounding quotes came off in unquote, so this one is data.
468
+ raise DecodeError.new("unescaped double quote in SvcParamValue #{str.inspect}")
469
+ else
470
+ result << c
471
+ i += 1
472
+ end
473
+ end
474
+ result
475
+ end
476
+
477
+ # Escapes raw octets into a presentation character-string. Four printable
478
+ # characters need escaping in an unquoted value: the quote delimits a
479
+ # character-string, a semicolon starts a comment, and parentheses group
480
+ # a record across lines. The numeric form handles them all uniformly.
481
+ def escape_char_string(str)
482
+ result = +''
483
+ str.each_byte do |b|
484
+ if b == 0x5c # backslash
485
+ result << '\\\\'
486
+ elsif b <= 0x20 || b > 0x7e || # non-printable or space
487
+ b == 0x22 || b == 0x3b || # " and ;
488
+ b == 0x28 || b == 0x29 # ( and )
489
+ result << format('\\%03d', b)
490
+ else
491
+ result << b.chr
492
+ end
493
+ end
494
+ result
495
+ end
496
+
497
+ # RFC 9460 Appendix A: value-list decoding follows character-string
498
+ # decoding. Only an escaped comma or backslash is valid at this layer.
499
+
500
+ def split_value_list(str)
501
+ str = str.b
502
+ items = []
503
+ current = +''.b
504
+ i = 0
505
+ while i < str.length
506
+ c = str[i]
507
+ if c == '\\'
508
+ escaped = str[i + 1]
509
+ unless escaped == ',' || escaped == '\\'
510
+ raise DecodeError.new("invalid value-list escape in #{str.inspect}: " \
511
+ 'expected an escaped comma or backslash')
512
+ end
513
+ current << escaped
514
+ i += 2
515
+ elsif c == ','
516
+ items << current
517
+ current = +''.b
518
+ i += 1
519
+ else
520
+ current << c
521
+ i += 1
522
+ end
523
+ end
524
+ items << current
525
+ items
526
+ end
527
+
528
+ # Splits a value-list, rejecting the empty item a leading, trailing or
529
+ # doubled comma leaves behind. No SvcParamValue list admits one.
530
+ def value_list_items(num, value)
531
+ items = split_value_list(value)
532
+ if items.any?(&:empty?)
533
+ raise DecodeError.new(
534
+ "SvcParamValue for #{num_to_key(num)} must not contain an empty item")
535
+ end
536
+ items
537
+ end
538
+
539
+ def join_value_list(items)
540
+ items.map do |item|
541
+ escaped = +''.b
542
+ item.to_s.each_byte do |b|
543
+ escaped << '\\' if b == 0x5c || b == 0x2c # backslash or comma
544
+ escaped << b.chr
545
+ end
546
+ escaped
547
+ end.join(',')
548
+ end
549
+
550
+ # Packs a value-list of address text into an address hint. IPv4.create
551
+ # and IPv6.create raise ArgumentError rather than DecodeError, and the
552
+ # key already names the family their message would.
553
+ def join_addresses(num, value, klass)
554
+ value_list_items(num, value).map do |text|
555
+ klass.create(text).address
556
+ rescue ArgumentError
557
+ raise DecodeError.new(
558
+ "SvcParamValue for #{num_to_key(num)} is not an address: #{text.inspect}")
559
+ end.join
560
+ end
561
+
562
+ # True when single-octet-length-prefixed items exactly tile the value,
563
+ # none of them empty -- the format shared by alpn and docpath. An empty
564
+ # value tiles trivially.
565
+ def length_prefixed_tile?(bytes)
566
+ i = 0
567
+ while i < bytes.bytesize
568
+ len = bytes.getbyte(i)
569
+ return false if len.zero? || i + 1 + len > bytes.bytesize
570
+ i += 1 + len
571
+ end
572
+ true
573
+ end
574
+
575
+ def pack_length_prefixed(num, items)
576
+ items.map do |item|
577
+ # pack('C') would truncate a longer item to a zero-length one and
578
+ # shift every item after it.
579
+ if item.bytesize > 0xff
580
+ raise DecodeError.new("#{num_to_key(num)} item must be at most 255 " \
581
+ "octets, got #{item.bytesize}")
582
+ end
583
+ [item.bytesize].pack('C') + item
584
+ end.join
585
+ end
586
+
587
+ # Counts in octets: see #decode_param_value.
588
+ def unpack_length_prefixed(bytes)
589
+ items = []
590
+ i = 0
591
+ while i < bytes.bytesize
592
+ len = bytes.getbyte(i)
593
+ items << bytes.byteslice(i + 1, len)
594
+ i += 1 + len
595
+ end
596
+ items
597
+ end
598
+
599
+ # Rejects a value whose length does not fit the shape its key defines,
600
+ # which the decoders below would truncate or pad. Unlisted keys take
601
+ # any octets.
602
+ def validate_param_value(num, bytes)
603
+ if num == INVALID_KEY
604
+ raise DecodeError.new("SvcParamKey #{INVALID_KEY} is reserved as invalid")
605
+ end
606
+
607
+ size = bytes.bytesize
608
+ if size > 0xffff
609
+ raise DecodeError.new("SvcParamValue for #{num_to_key(num)} exceeds 65535 octets: #{size}")
610
+ end
611
+ expected =
612
+ case num
613
+ when 0 # mandatory
614
+ 'a non-empty, even number of octets' unless size.positive? && size.even?
615
+ when 1, 10 # alpn, docpath
616
+ # Zero docpath segments is the root path (RFC 9953 sec 3).
617
+ unless length_prefixed_tile?(bytes) && (num == 10 || size.positive?)
618
+ 'a sequence of non-empty length-prefixed items'
619
+ end
620
+ when *KEYS_FORBIDDING_VALUE
621
+ 'empty' unless size.zero?
622
+ when 3 # port
623
+ 'exactly 2 octets' unless size == 2
624
+ when 4, 6 # ipv4hint, ipv6hint
625
+ width = num == 4 ? 4 : 16
626
+ "a non-empty multiple of #{width} octets" unless size.positive? && (size % width).zero?
627
+ when 5, 7 # ech, dohpath
628
+ 'non-empty' unless size.positive?
629
+ end
630
+ return if expected.nil?
631
+
632
+ raise DecodeError.new(
633
+ "SvcParamValue for #{num_to_key(num)} must be #{expected}, got #{size} octets")
634
+ end
635
+
636
+ def validate_svcparams(priority, params)
637
+ validate_mandatory_value(params)
638
+ validate_self_consistency(priority, params)
639
+ end
640
+
641
+ # RFC 9460 sec 8 on the mandatory list itself: it names "valid"
642
+ # SvcParamKeys, must not name itself, must not repeat a key, and must be
643
+ # in strictly increasing wire order. These describe one SvcParamValue,
644
+ # so they hold in either mode.
645
+ def validate_mandatory_value(params)
646
+ bytes = params[0]
647
+ return if bytes.nil?
648
+
649
+ keys = bytes.unpack('n*')
650
+ raise DecodeError.new('mandatory must not list itself') if keys.include?(0)
651
+ if keys.include?(INVALID_KEY)
652
+ raise DecodeError.new("mandatory must not list key #{INVALID_KEY}, " \
653
+ 'which sec 14.3.2 reserves as invalid')
654
+ end
655
+ unless keys.uniq.length == keys.length
656
+ raise DecodeError.new('mandatory must not list a key more than once')
657
+ end
658
+ # Mnemonic presentation format is unordered and the encoder sorts
659
+ # it. Wire input and generic key0 presentation must already be sorted.
660
+ unless keys == keys.sort
661
+ raise DecodeError.new('mandatory keys must be in strictly increasing order, got ' \
662
+ "#{keys.map { |k| num_to_key(k) }.join(', ')}")
663
+ end
664
+ end
665
+
666
+ # RFC 9460 sec 2.4.3: a ServiceMode RR is self-consistent when its
667
+ # SvcParams meet each other's requirements. Sec 8 gives mandatory one,
668
+ # and sec 7.1.1 gives no-default-alpn another. Sec 2.4.2 has recipients
669
+ # ignore an AliasMode RR's SvcParams, so neither applies at priority 0.
670
+ def validate_self_consistency(priority, params)
671
+ return if priority.nil? || priority.zero?
672
+
673
+ if params.key?(2) && !params.key?(1)
674
+ raise DecodeError.new('no-default-alpn requires alpn')
675
+ end
676
+
677
+ keys = params[0]&.unpack('n*') || []
678
+ missing = keys.reject { |k| params.key?(k) }
679
+ return if missing.empty?
680
+
681
+ raise DecodeError.new("mandatory lists #{missing.map { |k| num_to_key(k) }.join(', ')}, " \
682
+ 'which the record does not contain')
683
+ end
684
+
685
+ # Converts a SvcParamValue whose character-string escapes are already
686
+ # resolved (a String, or nil for a value-less key) into wire-format
687
+ # octets.
688
+ def encode_param_value(num, value)
689
+ if KEYS_FORBIDDING_VALUE.include?(num)
690
+ # An explicitly empty value encodes to the same nothing.
691
+ unless value.nil? || value.empty?
692
+ raise DecodeError.new("SvcParamValue for #{num_to_key(num)} must be empty")
693
+ end
694
+ return ''.b
695
+ end
696
+ if value.nil? && KEYS_REQUIRING_VALUE.include?(num)
697
+ raise DecodeError.new("#{num_to_key(num)} requires a SvcParamValue")
698
+ end
699
+
700
+ wire =
701
+ case num
702
+ when 0 # mandatory
703
+ value_list_items(num, value).map { |k| key_to_num(k) }.sort.
704
+ map { |k| [k].pack('n') }.join
705
+ when 1, 10 # alpn, docpath
706
+ # The root path must not reach value_list_items, which rejects an
707
+ # empty item.
708
+ items = value.nil? || value.empty? ? [] : value_list_items(num, value)
709
+ pack_length_prefixed(num, items)
710
+ when 3 # port
711
+ [to_uint16(value, 'port')].pack('n')
712
+ when 4 # ipv4hint
713
+ join_addresses(num, value, IPv4)
714
+ when 5 # ech
715
+ # Base64.decode64 discards anything outside the alphabet.
716
+ begin
717
+ Base64.strict_decode64(value)
718
+ rescue ArgumentError
719
+ raise DecodeError.new(
720
+ "SvcParamValue for ech must be base64, got #{value.inspect}")
721
+ end
722
+ when 6 # ipv6hint
723
+ join_addresses(num, value, IPv6)
724
+ else # dohpath and unknown keys
725
+ value.to_s
726
+ end
727
+
728
+ # dohpath and unknown keys hand the value straight back, and from_hash
729
+ # can pass in UTF-8, which MessageEncoder#put_bytes would reject.
730
+ wire = wire.b
731
+
732
+ # Applied here too, so a zone file cannot build a record that
733
+ # .decode_rdata would refuse.
734
+ validate_param_value(num, wire)
735
+ wire
736
+ end
737
+
738
+ # Converts a SvcParamValue into presentation format, or nil for a key
739
+ # written without one. The value normally arrives from the wire, but
740
+ # #params is public, so it may be any String the caller assigned, which
741
+ # is why the branches below work in octets rather than characters.
742
+ def decode_param_value(num, bytes)
743
+ case num
744
+ when 0 # mandatory
745
+ join_value_list(bytes.unpack('n*').map { |k| num_to_key(k) })
746
+ when 1, 10 # alpn, docpath
747
+ # Alone among the value-lists, these items are arbitrary octets, so
748
+ # they need the character-string layer. Empty is the docpath root
749
+ # path, and RFC 9460 Appendix A has no empty char-string, so it
750
+ # stands alone. alpn is never empty.
751
+ unless bytes.empty?
752
+ escape_char_string(join_value_list(unpack_length_prefixed(bytes)))
753
+ end
754
+ when *KEYS_FORBIDDING_VALUE
755
+ nil
756
+ when 3 # port
757
+ bytes.unpack1('n').to_s
758
+ when 4 # ipv4hint
759
+ # .b: String#scan and IPv4/IPv6::new count characters.
760
+ join_value_list(bytes.b.scan(/.{4}/m).map { |a| IPv4.new(a).to_s })
761
+ when 5 # ech
762
+ Base64.strict_encode64(bytes)
763
+ when 6 # ipv6hint
764
+ join_value_list(bytes.b.scan(/.{16}/m).map { |a| IPv6.new(a).to_s_canonical })
765
+ else # dohpath and unknown keys
766
+ bytes.empty? ? nil : escape_char_string(bytes)
767
+ end
768
+ end
769
+ end
770
+ private_constant :Codec
771
+ extend Codec
772
+ include Codec
773
+ end
774
+ end
775
+ end