monovm-whois-ruby 1.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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +75 -0
  3. data/LICENSE +21 -0
  4. data/README.md +348 -0
  5. data/data/rdap_bootstrap.json +5337 -0
  6. data/data/whois_servers.json +1460 -0
  7. data/exe/monovm-whois +6 -0
  8. data/lib/monovm/whois/availability/analyzer.rb +91 -0
  9. data/lib/monovm/whois/availability/context.rb +137 -0
  10. data/lib/monovm/whois/availability/patterns.rb +415 -0
  11. data/lib/monovm/whois/availability/rule.rb +57 -0
  12. data/lib/monovm/whois/availability/rule_set.rb +137 -0
  13. data/lib/monovm/whois/availability/rules/availability_keywords.rb +32 -0
  14. data/lib/monovm/whois/availability/rules/explicit_unavailability.rb +43 -0
  15. data/lib/monovm/whois/availability/rules/no_match.rb +31 -0
  16. data/lib/monovm/whois/availability/rules/premium_name.rb +35 -0
  17. data/lib/monovm/whois/availability/rules/rdap_object.rb +94 -0
  18. data/lib/monovm/whois/availability/rules/recordless.rb +45 -0
  19. data/lib/monovm/whois/availability/rules/registration_fields.rb +37 -0
  20. data/lib/monovm/whois/availability/rules/registry_marker.rb +38 -0
  21. data/lib/monovm/whois/availability/rules/server_refusal.rb +46 -0
  22. data/lib/monovm/whois/availability/rules/status_field.rb +42 -0
  23. data/lib/monovm/whois/availability/rules/tld_specific.rb +38 -0
  24. data/lib/monovm/whois/availability/rules/wrong_registry.rb +48 -0
  25. data/lib/monovm/whois/availability/verdict.rb +100 -0
  26. data/lib/monovm/whois/checker.rb +165 -0
  27. data/lib/monovm/whois/cli.rb +250 -0
  28. data/lib/monovm/whois/client.rb +227 -0
  29. data/lib/monovm/whois/configuration.rb +160 -0
  30. data/lib/monovm/whois/domain_name.rb +168 -0
  31. data/lib/monovm/whois/endpoint.rb +131 -0
  32. data/lib/monovm/whois/errors.rb +63 -0
  33. data/lib/monovm/whois/parser/base.rb +126 -0
  34. data/lib/monovm/whois/parser/icann_rdd.rb +79 -0
  35. data/lib/monovm/whois/parser/key_value.rb +169 -0
  36. data/lib/monovm/whois/parser/rdap_json.rb +170 -0
  37. data/lib/monovm/whois/parser/record.rb +165 -0
  38. data/lib/monovm/whois/parser/selector.rb +74 -0
  39. data/lib/monovm/whois/paths.rb +31 -0
  40. data/lib/monovm/whois/punycode.rb +206 -0
  41. data/lib/monovm/whois/referral/follower.rb +90 -0
  42. data/lib/monovm/whois/registry/definition.rb +119 -0
  43. data/lib/monovm/whois/registry/resolution.rb +57 -0
  44. data/lib/monovm/whois/registry/server_registry.rb +164 -0
  45. data/lib/monovm/whois/registry/sources/base.rb +58 -0
  46. data/lib/monovm/whois/registry/sources/iana_bootstrap.rb +142 -0
  47. data/lib/monovm/whois/registry/sources/json_file.rb +137 -0
  48. data/lib/monovm/whois/response.rb +89 -0
  49. data/lib/monovm/whois/result.rb +114 -0
  50. data/lib/monovm/whois/transport/base.rb +51 -0
  51. data/lib/monovm/whois/transport/factory.rb +51 -0
  52. data/lib/monovm/whois/transport/middleware/base.rb +55 -0
  53. data/lib/monovm/whois/transport/middleware/cache.rb +92 -0
  54. data/lib/monovm/whois/transport/middleware/instrumentation.rb +63 -0
  55. data/lib/monovm/whois/transport/middleware/retry.rb +56 -0
  56. data/lib/monovm/whois/transport/middleware/throttle.rb +62 -0
  57. data/lib/monovm/whois/transport/rdap_http.rb +146 -0
  58. data/lib/monovm/whois/transport/whois_socket.rb +130 -0
  59. data/lib/monovm/whois/version.rb +7 -0
  60. data/lib/monovm/whois/whois_handler.rb +157 -0
  61. data/lib/monovm/whois.rb +142 -0
  62. data/lib/monovm-whois-ruby.rb +5 -0
  63. data/lib/monovm-whois.rb +5 -0
  64. metadata +114 -0
data/exe/monovm-whois ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "monovm/whois/cli"
5
+
6
+ exit MonoVM::Whois::CLI.run(ARGV)
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context"
4
+ require_relative "rule_set"
5
+ require_relative "verdict"
6
+
7
+ module MonoVM
8
+ module Whois
9
+ module Availability
10
+ # Runs a response through the rule chain and returns the first verdict.
11
+ #
12
+ # The analyzer holds no detection logic of its own — it walks the {RuleSet},
13
+ # stops at the first rule that answers, and records what every rule said on the
14
+ # way. That trace is what turns "why does this say available?" from an
15
+ # afternoon of guessing into one call to {#explain}.
16
+ #
17
+ # When no rule answers, the result is +:unknown+. That is the other half of the
18
+ # correctness argument: the default is "I do not know", not "it is free".
19
+ class Analyzer
20
+ attr_reader :rules
21
+
22
+ def initialize(rules: nil)
23
+ @rules = rules || RuleSet.default
24
+ end
25
+
26
+ # @param response [Response, nil]
27
+ # @param tld [String, nil]
28
+ # @param definition [Registry::Definition, nil]
29
+ # @return [Verdict]
30
+ def analyze(response:, tld: nil, definition: nil)
31
+ context = Context.new(response: response, tld: tld, definition: definition)
32
+ run(context)
33
+ end
34
+
35
+ # Analyse and return the verdict together with every rule's outcome, for
36
+ # debugging a surprising classification.
37
+ #
38
+ # @return [Hash]
39
+ def explain(response:, tld: nil, definition: nil)
40
+ context = Context.new(response: response, tld: tld, definition: definition)
41
+ verdict = run(context)
42
+
43
+ {
44
+ status: verdict.status,
45
+ decided_by: verdict.rule,
46
+ reason: verdict.reason,
47
+ evidence: verdict.evidence,
48
+ trace: verdict.trace,
49
+ tld: tld,
50
+ response_length: context.length,
51
+ response_preview: context.preview
52
+ }
53
+ end
54
+
55
+ private
56
+
57
+ def run(context)
58
+ trace = []
59
+
60
+ rules.each do |rule|
61
+ begin
62
+ verdict = rule.call(context)
63
+ rescue StandardError => e
64
+ # A rule that raises must not take the lookup down with it, and must
65
+ # not be treated as a match either. Recording the failure keeps a
66
+ # broken custom rule visible rather than silently skipped.
67
+ trace << { rule: rule.name, matched: false, error: "#{e.class}: #{e.message}" }
68
+ next
69
+ end
70
+
71
+ if verdict.nil?
72
+ trace << { rule: rule.name, matched: false }
73
+ next
74
+ end
75
+
76
+ trace << {
77
+ rule: rule.name,
78
+ matched: true,
79
+ status: verdict.status,
80
+ evidence: verdict.evidence
81
+ }
82
+
83
+ return verdict.with_trace(trace)
84
+ end
85
+
86
+ Verdict.unknown(reason: "no rule recognised this response", trace: trace)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "patterns"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ # Everything a rule needs to reach a verdict, prepared once.
9
+ #
10
+ # Ten rules run over the same response, and most of them want the same derived
11
+ # forms of it: downcased, split into lines, stripped of registry commentary.
12
+ # Computing those here rather than in each rule keeps the rules to a few lines
13
+ # each and stops the same regexp work happening ten times per lookup.
14
+ class Context
15
+ attr_reader :response, :tld, :definition
16
+
17
+ # @param response [Response, nil] nil when no server was reached
18
+ # @param tld [String, nil] the matched suffix, with a leading dot
19
+ # @param definition [Registry::Definition, nil]
20
+ def initialize(response:, tld: nil, definition: nil)
21
+ @response = response
22
+ @tld = tld
23
+ @definition = definition
24
+ end
25
+
26
+ # The response text with line endings normalised, or "" when absent.
27
+ def text
28
+ @text ||= response ? response.text : ""
29
+ end
30
+
31
+ def lower
32
+ @lower ||= text.downcase
33
+ end
34
+
35
+ def empty?
36
+ text.strip.empty?
37
+ end
38
+
39
+ def length
40
+ text.length
41
+ end
42
+
43
+ # The parsed RDAP document, or nil.
44
+ def json
45
+ response&.json
46
+ end
47
+
48
+ def rdap?
49
+ response ? response.rdap? : false
50
+ end
51
+
52
+ def http_status
53
+ response&.status
54
+ end
55
+
56
+ def lines
57
+ @lines ||= text.split("\n").map(&:strip)
58
+ end
59
+
60
+ # Lines that carry record data rather than registry commentary.
61
+ #
62
+ # This matters more than it looks. Registries put phrases like "not found"
63
+ # and "no match" in their legal preamble, and a keyword scan over the raw
64
+ # text picks those up and calls a registered domain free. Stripping comment
65
+ # prefixes and known boilerplate first is what makes the keyword rules safe
66
+ # enough to use at all.
67
+ def significant_lines
68
+ @significant_lines ||= lines.reject do |line|
69
+ line.empty? ||
70
+ Patterns::COMMENT_PREFIXES.any? { |prefix| line.start_with?(prefix) } ||
71
+ Patterns::BOILERPLATE.any? { |pattern| pattern.match?(line) }
72
+ end
73
+ end
74
+
75
+ def significant_text
76
+ @significant_text ||= significant_lines.join("\n")
77
+ end
78
+
79
+ def significant_lower
80
+ @significant_lower ||= significant_text.downcase
81
+ end
82
+
83
+ # @return [String, nil] the matched text, for use as verdict evidence
84
+ def find(patterns, source: :text)
85
+ subject = subject_for(source)
86
+
87
+ patterns.each do |pattern|
88
+ match = pattern.match(subject)
89
+ return trim(match[0]) if match
90
+ end
91
+
92
+ nil
93
+ end
94
+
95
+ def match?(patterns, source: :text)
96
+ !find(patterns, source: source).nil?
97
+ end
98
+
99
+ # @return [Integer] how many of +patterns+ appear at least once
100
+ def count(patterns, source: :text)
101
+ subject = subject_for(source)
102
+ patterns.count { |pattern| pattern.match?(subject) }
103
+ end
104
+
105
+ # Substring search, for the plain keyword list.
106
+ #
107
+ # @return [String, nil] the keyword that matched
108
+ def find_keyword(keywords, source: :significant)
109
+ subject = source == :significant ? significant_lower : lower
110
+ keywords.find { |keyword| subject.include?(keyword) }
111
+ end
112
+
113
+ # A short excerpt for diagnostics, so a surprising verdict can be explained
114
+ # without dumping a whole record into a log.
115
+ def preview(limit = 200)
116
+ collapsed = text.strip.gsub(/\s+/, " ")
117
+ collapsed.length > limit ? "#{collapsed[0, limit]}..." : collapsed
118
+ end
119
+
120
+ private
121
+
122
+ def subject_for(source)
123
+ case source
124
+ when :significant then significant_text
125
+ when :lower then lower
126
+ else text
127
+ end
128
+ end
129
+
130
+ def trim(matched)
131
+ collapsed = matched.to_s.strip.gsub(/\s+/, " ")
132
+ collapsed.length > 120 ? "#{collapsed[0, 120]}..." : collapsed
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonoVM
4
+ module Whois
5
+ module Availability
6
+ # The pattern tables the rules match against.
7
+ #
8
+ # Kept as frozen data, separate from the logic that reads it, for two reasons.
9
+ # Registries change their wording without warning, so this is the file that
10
+ # gets edited most often and it should be possible to edit it without
11
+ # understanding the chain. And every table here is a claim about what some
12
+ # registry actually emits, which is easier to review as a list than as
13
+ # conditionals scattered through methods.
14
+ module Patterns
15
+ # Separator between a key and its value.
16
+ #
17
+ # Traficom (.fi) and several others align values by padding the key with
18
+ # dots — +status.............: Registered+. A plain +status:+ match misses
19
+ # that, and a missed "Registered" is read as availability, so every field
20
+ # pattern is built through this rather than written literally.
21
+ SEP = '[\s._·\-]*:[ \t]*'
22
+
23
+ # Build a field-matching regexp source tolerant of padded keys and of
24
+ # multiple spaces inside the key name.
25
+ #
26
+ # field("name server") # matches "Name Server:", "name.server....:", "name server :"
27
+ #
28
+ # @return [String] regexp source, not a Regexp, so callers can compose it
29
+ def self.field(name)
30
+ name.to_s.split(":").map { |part| Regexp.escape(part.strip).gsub("\\ ", '\s+') }
31
+ .join(SEP).then { |source| "#{source}#{SEP}" }
32
+ end
33
+
34
+ # Value of a status-like field equals one of +values+.
35
+ def self.status_is(*values)
36
+ "(?:domain[\\s_]*)?(?:registration[\\s_]*)?status#{SEP}(?:#{values.join("|")})"
37
+ end
38
+
39
+ # ------------------------------------------------------------------
40
+ # Server refused to answer (highest priority)
41
+ # ------------------------------------------------------------------
42
+
43
+ # Wording registries use when they will not answer: rate limits, blocked
44
+ # clients, and port 43 endpoints retired in favour of RDAP.
45
+ #
46
+ # Without this table each of these responses would fall through the
47
+ # heuristics and come back "available" — a rate-limited registry would
48
+ # report every registered domain as free to register. This table is the
49
+ # single most important correctness guard in the library.
50
+ REFUSAL = [
51
+ # Rate limiting: .pl, .lu, .cz, .ru, .dk and others.
52
+ /request(?:s)?\s+limit\s+exceeded/i,
53
+ /quer(?:y|ies)\s+limit\s+exceeded/i,
54
+ /limit\s+exceeded/i,
55
+ /maximum\s+quer(?:y|ies)\s+rate/i,
56
+ /quer(?:y|ies)\s+rate\s+exceeded/i,
57
+ /excessive\s+querying/i,
58
+ /too\s+many\s+quer(?:y|ies)/i,
59
+ /too\s+many\s+requests/i,
60
+ /rate\s+limit(?:ed|ing)?/i,
61
+ /lookup\s+quota/i,
62
+ /quota\s+exceeded/i,
63
+ /you\s+have\s+exceeded\s+/i,
64
+ # Client blocked, or told to use a web form: .li, .ch, .it.
65
+ /requests?\s+of\s+this\s+client\s+(?:are|is)\s+not\s+permitted/i,
66
+ /access\s+to\s+this\s+whois\s+server\s+is\s+(?:denied|blocked)/i,
67
+ /your\s+(?:ip|host|access)\s+has\s+been\s+(?:blocked|banned|denied)/i,
68
+ /permission\s+denied/i,
69
+ # Port 43 retired: .shop and other GMO / Identity Digital registries.
70
+ /whois\s+service\s+has\s+been\s+retired/i,
71
+ /(?:queries|service)\s+(?:are|is)\s+now\s+served\s+via\s+rdap/i,
72
+ /please\s+use\s+(?:our\s+)?rdap/i,
73
+ /rdap\s+base\s+url/i,
74
+ # An HTTP error page delivered over port 43, or by a server that answered
75
+ # 200 with an error document. Narrow on purpose: a bare "access denied"
76
+ # can legitimately appear in a record whose details are withheld, so only
77
+ # the unambiguous HTTP shapes count as a refusal.
78
+ %r{\bhttp/1\.[01]\s+4\d\d\b}i,
79
+ /\b40[13]\s+(?:forbidden|unauthori[sz]ed)\b/i,
80
+ /\b429\s+too\s+many\s+requests\b/i,
81
+ /\b50[0-4]\s+(?:internal\s+server\s+error|bad\s+gateway|service\s+unavailable)\b/i,
82
+ # Transient server-side conditions.
83
+ /server\s+(?:is\s+)?busy/i,
84
+ /(?:please\s+)?try\s+again\s+later/i,
85
+ /service\s+(?:is\s+)?temporarily\s+unavailable/i,
86
+ /temporarily\s+unavailable/i,
87
+ /database\s+(?:is\s+)?unavailable/i,
88
+ /internal\s+server\s+error/i
89
+ ].freeze
90
+
91
+ # ------------------------------------------------------------------
92
+ # Wrong server for this TLD
93
+ # ------------------------------------------------------------------
94
+
95
+ # The server answered, but it does not serve this TLD — so its answer says
96
+ # nothing about the domain.
97
+ UNSUPPORTED = [
98
+ /tld\s+(?:is\s+)?not\s+supported/i,
99
+ /extension\s+(?:is\s+)?not\s+supported/i,
100
+ /unsupported\s+(?:tld|extension|domain)/i,
101
+ /domain\s+(?:extension|type)\s+not\s+supported/i,
102
+ /not\s+supported\s+by\s+this\s+whois\s+server/i,
103
+ /no\s+whois\s+(?:server|service)\s+(?:available|found|known)/i,
104
+ /whois\s+(?:server\s+)?not\s+(?:known|available|found)/i,
105
+ /(?:server|service)\s+not\s+(?:available|found)\s+for/i,
106
+ /whois\s+(?:service\s+)?not\s+available\s+for/i,
107
+ /this\s+server\s+does\s+not\s+(?:serve|handle)/i
108
+ ].freeze
109
+
110
+ # Banners of the IP-address registries.
111
+ #
112
+ # Reaching one of these means a TLD is mapped to the wrong server. They
113
+ # answer +%ERROR:101: no entries found+ to any domain query, which a naive
114
+ # detector reads as availability for every name under that TLD.
115
+ WRONG_REGISTRY_BANNERS = [
116
+ /this\s+is\s+the\s+ripe\s+database\s+query\s+service/i,
117
+ /the\s+objects\s+are\s+in\s+rpsl\s+format/i,
118
+ /\[whois\.apnic\.net\]/i,
119
+ /apnic\s+whois\s+service/i,
120
+ /american\s+registry\s+for\s+internet\s+numbers/i,
121
+ /whois\.arin\.net/i,
122
+ /lacnic\s+whois\s+server/i,
123
+ /afrinic\s+whois\s+server/i,
124
+ /%error:101/i
125
+ ].freeze
126
+
127
+ # ------------------------------------------------------------------
128
+ # The name is registered
129
+ # ------------------------------------------------------------------
130
+
131
+ # Statuses that mean the object exists, whatever state it is in. A domain in
132
+ # redemption, on server hold or pending delete is emphatically not free.
133
+ REGISTERED_STATUSES = %w[
134
+ registered active connect client.* redemption.*
135
+ pending.*delete server.?hold client.?hold allocated in\\s?use
136
+ suspended locked expired quarantine
137
+ ].freeze
138
+
139
+ # General "this exists" wording, checked for every TLD.
140
+ UNAVAILABILITY = [
141
+ /---\s*not\s+available/i,
142
+ /---\s*not\s+found/i,
143
+ /---\s*domain\s+not\s+found/i,
144
+ /\bnot\s+available\s+for\s+registration\b/i,
145
+ /\bdomain\s+not\s+available\b/i,
146
+ Regexp.new(status_is("not available", "unavailable", "taken"), Regexp::IGNORECASE),
147
+ Regexp.new(status_is(*REGISTERED_STATUSES), Regexp::IGNORECASE),
148
+ /this\s+domain\s+has\s+been\s+registered/i,
149
+ /domain\s+(?:is\s+)?(?:already\s+|currently\s+)?registered/i,
150
+ /\bis\s+registered\b/i,
151
+ Regexp.new("#{field("registration")}registered", Regexp::IGNORECASE),
152
+ # Registry restriction and reservation notices: the object exists but the
153
+ # registry withholds its details. CIRA-backed registries answer
154
+ # "Error code: 01044 ... usage restrictions applied" for reserved names.
155
+ /usage\s+restrictions\s+applied/i,
156
+ /usage\s+restrictions/i,
157
+ /name\s+is\s+reserved/i,
158
+ /reserved\s+(?:domain|name)/i,
159
+ /domain\s+is\s+reserved/i,
160
+ /blocked\s+by\s+the\s+registry/i
161
+ ].freeze
162
+
163
+ # Per-TLD wording that means registered. Consulted before the general table,
164
+ # because a few registries use words that are ambiguous elsewhere.
165
+ TLD_UNAVAILABILITY = {
166
+ ".au" => [/---\s*not\s+available/i, /\bnot\s+available\b/i],
167
+ ".com.au" => [/---\s*not\s+available/i, /\bnot\s+available\b/i],
168
+ ".net.au" => [/---\s*not\s+available/i, /\bnot\s+available\b/i],
169
+ ".org.au" => [/---\s*not\s+available/i, /\bnot\s+available\b/i],
170
+ ".uk" => [/this\s+domain\s+has\s+been\s+registered/i, /\bregistered\b/i],
171
+ ".co.uk" => [/this\s+domain\s+has\s+been\s+registered/i, /\bregistered\b/i],
172
+ # DENIC. "Status: invalid" means the name cannot be registered as spelled;
173
+ # it must never read as available.
174
+ ".de" => [
175
+ Regexp.new(status_is("connect", "registered", "active", "invalid", "failed"),
176
+ Regexp::IGNORECASE)
177
+ ],
178
+ ".nl" => [Regexp.new(status_is("active", "in\\s?use"), Regexp::IGNORECASE)],
179
+ ".be" => [Regexp.new(status_is("registered", "allocated", "not\\s+available"), Regexp::IGNORECASE)],
180
+ ".ca" => [/domain\s+registered/i, Regexp.new(status_is("registered"), Regexp::IGNORECASE)],
181
+ ".fi" => [Regexp.new(status_is("registered"), Regexp::IGNORECASE)],
182
+ ".ir" => [/\bdomain\s*:/i]
183
+ }.freeze
184
+
185
+ # Fields that only appear in a real record. Counting them is a strong signal:
186
+ # a registry that answers with a registrar, a creation date and nameservers
187
+ # is describing something that exists.
188
+ REGISTRATION_INDICATORS = [
189
+ "domain", "domain name", "ascii", "nserver", "nameserver", "name server",
190
+ "registrar", "registrant", "registrar whois server", "registry domain id",
191
+ "creation date", "created", "created on", "registered on", "registration date",
192
+ "changed", "last updated", "updated date", "updated",
193
+ "expiry date", "expires", "expires on", "registry expiry date", "paid-till",
194
+ "admin contact", "technical contact", "billing contact", "tech-c", "admin-c",
195
+ "dnssec", "sponsoring registrar", "holder"
196
+ ].map { |name| Regexp.new(field(name), Regexp::IGNORECASE) }.freeze
197
+
198
+ # The narrower set used to judge whether a response is a record at all.
199
+ REGISTRATION_FIELDS = [
200
+ "registrar", "registrant", "creation date", "created", "expiry date",
201
+ "expires", "name server", "nameserver", "nserver", "admin contact",
202
+ "technical contact", "paid-till", "sponsoring registrar"
203
+ ].map { |name| Regexp.new(field(name), Regexp::IGNORECASE) }.freeze
204
+
205
+ # ------------------------------------------------------------------
206
+ # The name is available
207
+ # ------------------------------------------------------------------
208
+
209
+ # Phrases that state a name is unregistered. Matched against the response
210
+ # with comment and banner lines removed, because registries put words like
211
+ # "not found" in their legal preamble.
212
+ AVAILABILITY_KEYWORDS = [
213
+ "no match", "not found", "no data found", "no entries found",
214
+ "no matching record", "no object found", "no such domain",
215
+ "object does not exist", "nothing found", "no domain",
216
+ "domain not found", "domain name not known", "not registered",
217
+ "available for registration", "is available for registration",
218
+ "is available for purchase", "domain is available",
219
+ "domain has not been registered", "domain name has not been registered",
220
+ "domain does not exist", "does not exist in database", "was not found",
221
+ "not exist", "object_not_found", "free",
222
+ # Spanish and Portuguese registries.
223
+ "no se encontro el objeto", "el dominio no se encuentra registrado",
224
+ "no está registrado", "dominio no registrado",
225
+ # French, German, Dutch.
226
+ "aucun objet trouvé", "nicht vergeben", "is vrij", "vrij"
227
+ ].map(&:freeze).freeze
228
+
229
+ # Regexp forms, matched against the whole response. Broader than the keyword
230
+ # list because whitespace between words varies by registry.
231
+ NO_MATCH = [
232
+ /\bno\s+match\b/i,
233
+ /\bnot\s+found\b/i,
234
+ /\bno\s+data\s+found\b/i,
235
+ /\bno\s+entries\s+found\b/i,
236
+ /\bno\s+matching\s+record/i,
237
+ /\bno\s+object\s+found\b/i,
238
+ /object\s+does\s+not\s+exist/i,
239
+ /\bno\s+such\s+domain\b/i,
240
+ /domain\s+not\s+found/i,
241
+ /domain\s+name\s+not\s+known/i,
242
+ /domain\s+(?:name\s+)?has\s+not\s+been\s+registered/i,
243
+ /domain\s+(?:is\s+)?available/i,
244
+ /\bis\s+available\s+for\b/i,
245
+ /domain\s+does\s+not\s+exist/i,
246
+ /does\s+not\s+exist\s+in\s+database/i,
247
+ /\bwas\s+not\s+found\b/i,
248
+ /object_not_found/i,
249
+ /no\s+se\s+encontro\s+el\s+objeto/i,
250
+ /el\s+dominio\s+no\s+se\s+encuentra\s+registrado/i,
251
+ /no\s+está\s+registrado/i,
252
+ /---\s*available/i,
253
+ /%error:103/i
254
+ ].freeze
255
+
256
+ # An explicit status field saying the name is free.
257
+ STATUS_AVAILABLE = [
258
+ Regexp.new(status_is("available", "free", "not\\s+registered", "no\\s+object"),
259
+ Regexp::IGNORECASE),
260
+ /availability#{SEP}available/i,
261
+ /state#{SEP}available/i,
262
+ /status\s*=\s*available/i,
263
+ /\bstatus:\s*AVAILABLE\b/
264
+ ].freeze
265
+
266
+ # Per-TLD availability wording, consulted late. Several of these are single
267
+ # words like "available" that would be far too loose as a general rule but
268
+ # are unambiguous in a given registry's output format.
269
+ TLD_AVAILABILITY = {
270
+ ".com" => [/no\s+match\s+for/i, /domain\s+not\s+found/i],
271
+ ".net" => [/no\s+match\s+for/i, /domain\s+not\s+found/i],
272
+ ".org" => [/domain\s+not\s+found/i, /\bnot\s+found\b/i],
273
+ ".edu" => [/no\s+match\s+for/i],
274
+ ".uk" => [/\bno\s+match\b/i, /this\s+domain\s+is\s+available/i],
275
+ ".co.uk" => [/\bno\s+match\b/i, /this\s+domain\s+is\s+available/i],
276
+ ".de" => [/is\s+available\s+for\s+registration/i, /status#{SEP}free/i],
277
+ ".fr" => [/\bnot\s+found\b/i, /available/i],
278
+ ".it" => [/\bavailable\b/i, /status#{SEP}available/i],
279
+ ".au" => [/---\s*available/i, /is\s+available\s+for\s+registration/i],
280
+ ".com.au" => [/is\s+available\s+for\s+registration/i, /\bavailable\b/i],
281
+ ".be" => [/status#{SEP}available/i, /\bfree\b/i],
282
+ ".ca" => [/\bnot\s+found\b/i, /\bavailable\b/i],
283
+ ".ch" => [/---\s*1:/i, /\bavailable\b/i, /\bwe\s+do\s+not\s+have\s+an\s+entry\b/i],
284
+ ".li" => [/\bavailable\b/i, /\bwe\s+do\s+not\s+have\s+an\s+entry\b/i],
285
+ ".eu" => [/status#{SEP}available/i, /\bavailable\b/i],
286
+ ".nl" => [/\bis\s+free\b/i, /\bavailable\b/i],
287
+ ".dk" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
288
+ ".no" => [/\bno\s+match\b/i, /\bavailable\b/i],
289
+ ".se" => [/\bnot\s+found\b/i, /\bavailable\b/i],
290
+ ".nu" => [/\bnot\s+found\b/i, /\bavailable\b/i],
291
+ ".fi" => [/\bavailable\b/i, /domain\s+not\s+found/i],
292
+ ".pt" => [/\bavailable\b/i, /\bno\s+match\b/i],
293
+ ".es" => [/\bnot\s+found\b/i, /\bavailable\b/i],
294
+ ".jp" => [/no\s+match!!/i, /\bno\s+match\b/i],
295
+ ".cn" => [/no\s+matching\s+record/i, /\bnot\s+found\b/i],
296
+ ".in" => [/\bnot\s+found\b/i, /\bno\s+data\s+found\b/i],
297
+ ".hk" => [/the\s+domain\s+has\s+not\s+been\s+registered/i],
298
+ ".tw" => [/\bno\s+found\b/i, /\bnot\s+found\b/i],
299
+ ".kr" => [/\bnot\s+found\b/i, /above\s+domain\s+name\s+is\s+not\s+registered/i],
300
+ ".sg" => [/---\s*not\s+found/i, /domain\s+not\s+found/i],
301
+ ".my" => [/does\s+not\s+exist\s+in\s+database/i],
302
+ ".ph" => [/domain\s+is\s+available/i],
303
+ ".th" => [/no\s+match\s+found/i],
304
+ ".vn" => [/\bavailable\b/i, /\bnot\s+found\b/i],
305
+ ".id" => [/domain\s+not\s+found/i, /\bavailable\b/i],
306
+ ".us" => [/\bnot\s+found\b/i, /domain\s+not\s+found/i],
307
+ ".mx" => [/no_se_encontro_el_objeto/i, /\bnot\s+found\b/i],
308
+ ".br" => [/no\s+match\s+for/i, /domain\s+not\s+found/i],
309
+ ".ar" => [/el\s+dominio\s+no\s+se\s+encuentra\s+registrado/i],
310
+ ".co" => [/\bnot\s+found\b/i, /\bavailable\b/i],
311
+ ".cl" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
312
+ ".pe" => [/\bnot\s+found\b/i, /\bavailable\b/i],
313
+ ".ru" => [/\bno\s+entries\s+found\b/i, /\bnot\s+found\b/i],
314
+ ".su" => [/\bno\s+entries\s+found\b/i],
315
+ ".pl" => [/no\s+information\s+available/i, /\bavailable\b/i],
316
+ ".cz" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
317
+ ".sk" => [/domain\s+not\s+found/i, /\bavailable\b/i],
318
+ ".hu" => [/\bno\s+match\b/i, /\bavailable\b/i],
319
+ ".ro" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
320
+ ".rs" => [/\bnot\s+found\b/i, /\bavailable\b/i],
321
+ ".me" => [/\bnot\s+found\b/i, /\bavailable\b/i],
322
+ ".md" => [/\bno\s+object\s+found\b/i, /\bavailable\b/i],
323
+ ".ua" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
324
+ ".za" => [/\bavailable\b/i, /\bnot\s+found\b/i],
325
+ ".co.za" => [/\bavailable\b/i, /\bnot\s+found\b/i],
326
+ ".ng" => [/\bnot\s+found\b/i, /\bavailable\b/i],
327
+ ".ke" => [/\bno\s+object\s+found\b/i, /\bavailable\b/i],
328
+ ".ma" => [/\bno\s+object\s+found\b/i, /\bavailable\b/i],
329
+ ".nz" => [/\bnot\s+found\b/i, /\bavailable\b/i],
330
+ ".ws" => [/the\s+queried\s+object\s+does\s+not\s+exist/i],
331
+ ".cc" => [/\bno\s+match\b/i, /\bavailable\b/i],
332
+ ".tv" => [/\bno\s+match\b/i, /\bavailable\b/i],
333
+ ".to" => [/no\s+match\s+for/i, /\bavailable\b/i],
334
+ ".im" => [/\bwas\s+not\s+found\b/i, /\bavailable\b/i],
335
+ ".io" => [/---\s*domain\s+not\s+found/i, /\bavailable\b/i],
336
+ ".sh" => [/domain\s+not\s+found/i, /\bavailable\b/i],
337
+ ".ac" => [/domain\s+not\s+found/i, /\bavailable\b/i],
338
+ ".gg" => [/\bnot\s+found\b/i, /\bavailable\b/i],
339
+ ".je" => [/\bnot\s+found\b/i, /\bavailable\b/i],
340
+ ".tk" => [/domain\s+name\s+not\s+known/i, /\bavailable\b/i],
341
+ ".cm" => [/\bnot\s+registered\b/i, /\bavailable\b/i],
342
+ ".ir" => [/\bno\s+entries\s+found\b/i],
343
+ ".gr" => [/\bnot\s+exist\b/i],
344
+ ".is" => [/\bno\s+entries\s+found\b/i],
345
+ ".lt" => [/\bavailable\b/i, /status#{SEP}available/i],
346
+ ".lv" => [/status#{SEP}free/i, /\bavailable\b/i],
347
+ ".ee" => [/\bdomain\s+not\s+found\b/i],
348
+ ".bg" => [/\bdoes\s+not\s+exist\b/i],
349
+ ".hr" => [/\bnot\s+found\b/i],
350
+ ".si" => [/\bno\s+entries\s+found\b/i],
351
+ ".at" => [/\bnothing\s+found\b/i, /status#{SEP}free/i],
352
+ ".lu" => [/\bno\s+such\s+domain\b/i]
353
+ }.freeze
354
+
355
+ # ------------------------------------------------------------------
356
+ # Guards
357
+ # ------------------------------------------------------------------
358
+
359
+ # Text that means "this response is a notice, not a record". Absence of
360
+ # registration fields is only evidence of availability when none of these is
361
+ # present — an error page has no registration fields either.
362
+ ERROR_OR_RESTRICTION = [
363
+ /error\s+code#{SEP}/i,
364
+ /error\s+message#{SEP}/i,
365
+ /usage\s+restrictions/i,
366
+ /please\s+see\s+your\s+registrar/i,
367
+ /please\s+contact/i,
368
+ /\bis\s+reserved\b/i,
369
+ /reserved\s+name/i,
370
+ /\brestricted\b/i,
371
+ /access\s+denied/i,
372
+ /not\s+authori[sz]ed/i,
373
+ /unauthori[sz]ed/i,
374
+ /\bmalformed\b/i,
375
+ /invalid\s+(?:query|request|domain|input)/i,
376
+ /syntax\s+error/i
377
+ ].freeze
378
+
379
+ # Line prefixes that mark registry commentary rather than record data.
380
+ # Registries routinely use words like "not found" in their legal preamble.
381
+ COMMENT_PREFIXES = ["%", "#", ";", ">>>", "---", "*", "//"].freeze
382
+
383
+ # Boilerplate lines that survive prefix stripping but are still commentary.
384
+ BOILERPLATE = [
385
+ /available\s+on\s+web\s+at/i,
386
+ /find\s+the\s+terms\s+and\s+conditions/i,
387
+ /terms\s+of\s+use/i,
388
+ /by\s+submitting\s+(?:a|this|any)\s+quer/i,
389
+ /for\s+more\s+information\s+(?:on|about)\s+whois/i,
390
+ /whois\s+(?:database|server)\s+(?:is\s+)?provided/i,
391
+ /the\s+data\s+(?:in|contained)/i,
392
+ /this\s+(?:data|information)\s+is\s+provided/i,
393
+ /notice#{SEP}/i,
394
+ /copyright/i,
395
+ /all\s+rights\s+reserved/i,
396
+ /personal\s+data/i,
397
+ /gdpr/i,
398
+ /redacted\s+for\s+privacy/i
399
+ ].freeze
400
+
401
+ # ------------------------------------------------------------------
402
+ # RDAP
403
+ # ------------------------------------------------------------------
404
+
405
+ # Keys that only appear in an RDAP object describing a real registration.
406
+ RDAP_REGISTRATION_KEYS = %w[
407
+ ldhName handle nameservers secureDNS entities events status
408
+ ].freeze
409
+
410
+ # RDAP error codes that mean "no such object", i.e. the name is free.
411
+ RDAP_NOT_FOUND_CODES = [404].freeze
412
+ end
413
+ end
414
+ end
415
+ end