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
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dnsruby
4
+ # Shared lexical handling for RR strings and zone-file continuation lines.
5
+ # Character-string escapes remain intact for the record-specific parser.
6
+ module Presentation #:nodoc: all
7
+ # Header tokens preserve escaped whitespace and punctuation in owners.
8
+ def self.tokens(text)
9
+ text.scan(/(?:\\.|[^\s])+/m)
10
+ end
11
+
12
+ # Decimal escapes keep escaped whitespace intact through older record
13
+ # parsers and layout normalization, without changing the represented octet.
14
+ def self.escape_whitespace(text)
15
+ text.gsub(/\\(.)/m) do |escaped|
16
+ /\s/.match?(escaped[1]) ? format('\\%03d', escaped[1].ord) : escaped
17
+ end
18
+ end
19
+
20
+ # TXT.parse historically accepts C-style escapes. Zone files instead use
21
+ # RFC 1035's literal nonnumeric escapes; express them as octets before
22
+ # handing the string to that compatibility parser.
23
+ def self.character_string_escapes(text)
24
+ text.gsub(/\\([^0-9])/m) do |escaped|
25
+ escaped[1].bytes.map { |octet| format('\\%03d', octet) }.join
26
+ end
27
+ end
28
+
29
+ def self.unescaped_quote(text)
30
+ position = 0
31
+ while (token = /\\.|"/m.match(text, position))
32
+ return token.begin(0) if token[0] == '"'
33
+ position = token.end(0)
34
+ end
35
+ nil
36
+ end
37
+
38
+ # TXT (and its SPF subclass) historically also accept single quotes. Do
39
+ # not extend that convention to domain names or other RDATA formats, where
40
+ # an apostrophe is an ordinary character. Return the end of the type token
41
+ # so that even a leading apostrophe in the owner cannot open a TXT string.
42
+ def self.single_quoted_start(text, owner_omitted: false)
43
+ # Grouping parentheses can touch the type (TXT('value')), while an
44
+ # escaped parenthesis in the owner remains part of that owner's token.
45
+ fields = []
46
+ position = 0
47
+ while fields.length < 4 && (field = /\G[\s()]*((?:\\.|[^\s()])+)/m.match(text, position))
48
+ fields << [field[1], field.end(0)]
49
+ position = field.end(0)
50
+ end
51
+ fields.shift unless owner_omitted
52
+ # Numeric classes learned from received records must not make parsing
53
+ # unrelated records rebuild an ever-growing regular expression.
54
+ ttl_seen = class_seen = false
55
+ 2.times do
56
+ token = fields.first&.first
57
+ if token && !ttl_seen && /\A\d/.match?(token)
58
+ ttl_seen = true
59
+ elsif token && !class_seen && (Classes.to_code(token) || /\ACLASS\d+\z/i.match?(token))
60
+ class_seen = true
61
+ else
62
+ break
63
+ end
64
+ fields.shift
65
+ end
66
+ type_token = fields.first&.first
67
+ type_code = Types.to_code(type_token) if type_token
68
+ if !type_code && (numeric = /\ATYPE(\d+)\z/i.match(type_token.to_s))
69
+ type_code = numeric[1].to_i
70
+ end
71
+ fields.first.last if [Types::TXT, Types::SPF].include?(type_code)
72
+ end
73
+
74
+ def self.scan(text, depth = 0, quoted = false, allow_unbalanced: false,
75
+ single_quoted_from: nil)
76
+ # Most zone records have no lexical syntax to interpret. Preserve the
77
+ # current continuation depth and only fold physical line endings.
78
+ if !quoted && !/[\\'"();]/.match?(text)
79
+ return [text.tr("\r\n", ' '), depth, false]
80
+ end
81
+
82
+ result = +''
83
+ quoted = '"' if quoted == true
84
+ just_closed_quote = false
85
+ position = 0
86
+
87
+ # Copy ordinary text in runs. Quoted TXT values often contain hundreds
88
+ # of characters but only two delimiters; allocating one String for each
89
+ # character makes that common case unnecessarily expensive. Regexp and
90
+ # String offsets both count characters, including for Unicode owners.
91
+ while (syntax = /[\\'"();\r\n]/.match(text, position))
92
+ index = syntax.begin(0)
93
+ if index > position
94
+ result << text[position...index]
95
+ just_closed_quote = false
96
+ end
97
+ char = syntax[0]
98
+ position = index + 1
99
+ after_quote = just_closed_quote
100
+ just_closed_quote = false
101
+
102
+ if char == '\\'
103
+ result << char
104
+ if (escaped = text[position])
105
+ result << escaped
106
+ position += 1
107
+ end
108
+ elsif char == '"' || (single_quoted_from && index >= single_quoted_from && char == "'" &&
109
+ (quoted == "'" || (!quoted && (after_quote || index.zero? || /[\s(]/.match?(text[index - 1])))))
110
+ result << char
111
+ if quoted == char
112
+ quoted = false
113
+ just_closed_quote = true
114
+ elsif !quoted
115
+ quoted = char
116
+ end
117
+ elsif !quoted && char == ';'
118
+ # Comments end only at LF, matching the zone reader's physical
119
+ # line handling. Leave that newline for the ordinary layout branch.
120
+ position = text.index("\n", position) || text.length
121
+ elsif !quoted && (char == '(' || char == ')')
122
+ depth += char == '(' ? 1 : -1
123
+ if depth.negative? && !allow_unbalanced
124
+ raise DecodeError.new('Unmatched closing parenthesis in record')
125
+ end
126
+ # Parentheses separate tokens when neither neighbor is whitespace.
127
+ # Preserve existing spacing otherwise; some older RR parsers use
128
+ # offsets into the original RDATA rather than whitespace tokens.
129
+ if !result.empty? && /\S/.match?(result[-1]) &&
130
+ text[index + 1] && /\S/.match?(text[index + 1])
131
+ result << ' '
132
+ end
133
+ elsif !quoted && (char == "\n" || char == "\r")
134
+ # A physical line break outside a quoted value is layout, not an
135
+ # octet in a TXT string or part of an RFC3597 hexadecimal value.
136
+ result << ' '
137
+ else
138
+ result << char
139
+ end
140
+ end
141
+ result << text[position..-1] if position < text.length
142
+
143
+ [result, depth, quoted]
144
+ end
145
+ end
146
+ private_constant :Presentation
147
+ end
@@ -0,0 +1,132 @@
1
+ require 'dnsruby/transport_clock'
2
+
3
+ module Dnsruby
4
+ # A deadline and cancellation signal shared by related lookups. This carries
5
+ # no DNSSEC policy; the caller decides how a stopped operation is reported.
6
+ class QueryControl # :nodoc: all
7
+ # These must bypass ordinary lookup and verification fallback handlers.
8
+ class Stopped < StandardError; end
9
+ class Cancelled < Stopped; end
10
+ class DeadlineExceeded < Stopped; end
11
+
12
+ # A normal Queue whose producers also wake deadline/cancellation waiters.
13
+ # Keeping the notification alongside push avoids polling and works on Ruby
14
+ # versions which do not support Queue#pop(timeout:).
15
+ class Queue < ::Queue
16
+ def initialize
17
+ super
18
+ @wait_mutex = Mutex.new
19
+ @condition = ConditionVariable.new
20
+ end
21
+
22
+ def push(value)
23
+ @wait_mutex.synchronize do
24
+ super
25
+ @condition.broadcast
26
+ end
27
+ self
28
+ end
29
+ alias << push
30
+ alias enq push
31
+
32
+ def close
33
+ @wait_mutex.synchronize do
34
+ super
35
+ @condition.broadcast
36
+ end
37
+ self
38
+ end
39
+
40
+ def wake_waiters
41
+ @wait_mutex.synchronize { @condition.broadcast }
42
+ end
43
+
44
+ def pop_with_control(control)
45
+ control.add_waiter(self)
46
+ @wait_mutex.synchronize do
47
+ loop do
48
+ control.check!
49
+ begin
50
+ return pop(true)
51
+ rescue ThreadError
52
+ return nil if closed?
53
+ remaining = control.deadline && control.deadline - TransportClock.now
54
+ @condition.wait(@wait_mutex, remaining && [remaining, 0].max)
55
+ end
56
+ end
57
+ end
58
+ ensure
59
+ control.remove_waiter(self)
60
+ end
61
+ end
62
+
63
+ attr_reader :deadline
64
+
65
+ def self.current
66
+ Thread.current[:dnsruby_query_control]
67
+ end
68
+
69
+ def self.with(control)
70
+ previous = current
71
+ Thread.current[:dnsruby_query_control] = control
72
+ control.check!
73
+ result = yield
74
+ control.check!
75
+ result
76
+ ensure
77
+ Thread.current[:dnsruby_query_control] = previous
78
+ end
79
+
80
+ def initialize(deadline: nil, parent: nil)
81
+ @parent = parent
82
+ @deadline = [deadline, parent&.deadline].compact.min
83
+ @cancelled = false
84
+ @mutex = Mutex.new
85
+ @waiters = []
86
+ end
87
+
88
+ def cancel
89
+ waiters = @mutex.synchronize do
90
+ @cancelled = true
91
+ @waiters.dup
92
+ end
93
+ waiters.each(&:wake_waiters)
94
+ true
95
+ end
96
+
97
+ def check!
98
+ @parent&.check!
99
+ raise Cancelled, 'Query cancelled' if @mutex.synchronize { @cancelled }
100
+ raise DeadlineExceeded, 'Query deadline exceeded' if @deadline && TransportClock.now >= @deadline
101
+ true
102
+ end
103
+
104
+ def add_waiter(queue)
105
+ @parent&.add_waiter(queue)
106
+ @mutex.synchronize { @waiters << queue }
107
+ end
108
+
109
+ def remove_waiter(queue)
110
+ @mutex.synchronize do
111
+ index = @waiters.index(queue)
112
+ @waiters.delete_at(index) if index
113
+ end
114
+ @parent&.remove_waiter(queue)
115
+ end
116
+
117
+ def pop(queue)
118
+ return queue.pop_with_control(self) if queue.is_a?(Queue)
119
+
120
+ # Preserve support for caller-supplied ordinary queues, whose producers
121
+ # cannot notify this control. Internal lookups use the queue above.
122
+ loop do
123
+ check!
124
+ begin
125
+ return queue.pop(true)
126
+ rescue ThreadError
127
+ sleep(@deadline ? (@deadline - TransportClock.now).clamp(0, 0.01) : 0.01)
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end