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
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "verdict"
4
+ require_relative "patterns"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ module Availability
9
+ # One link in the detection chain.
10
+ #
11
+ # A rule answers a single question about a response and either returns a
12
+ # {Verdict} — "I recognise this, here is the answer" — or +nil+, meaning
13
+ # "not mine, ask the next one". That is the whole contract, and it is what
14
+ # makes the chain extensible: a registry that invents new wording needs one
15
+ # more small object, not a change to {Analyzer}.
16
+ #
17
+ # class MyRule < Availability::Rule
18
+ # def call(context)
19
+ # return nil unless context.lower.include?("my special wording")
20
+ #
21
+ # registered(reason: "registry said so")
22
+ # end
23
+ # end
24
+ #
25
+ # MonoVM::Whois.configure { |c| c.rules.prepend(MyRule.new) }
26
+ class Rule
27
+ # Identifier used in {Verdict#rule} and in the analyzer's trace.
28
+ # +ServerRefusal+ becomes +"server_refusal"+.
29
+ #
30
+ # An anonymous class has no +Module#name+, so a rule defined inline — which is
31
+ # exactly how a host application tries one out — falls back to its inspect
32
+ # form rather than crashing.
33
+ def name
34
+ @name ||= begin
35
+ base = self.class.name.to_s.split("::").last
36
+ base = self.class.inspect if base.nil? || base.empty?
37
+ base.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
38
+ end
39
+ end
40
+
41
+ # @param context [Context]
42
+ # @return [Verdict, nil]
43
+ def call(context)
44
+ raise NotImplementedError, "#{self.class} must implement #call"
45
+ end
46
+
47
+ private
48
+
49
+ %i[available registered premium unknown].each do |status|
50
+ define_method(status) do |reason: nil, evidence: nil|
51
+ Verdict.public_send(status, rule: name, reason: reason, evidence: evidence)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rule"
4
+ require_relative "rules/server_refusal"
5
+ require_relative "rules/wrong_registry"
6
+ require_relative "rules/rdap_object"
7
+ require_relative "rules/premium_name"
8
+ require_relative "rules/explicit_unavailability"
9
+ require_relative "rules/registration_fields"
10
+ require_relative "rules/registry_marker"
11
+ require_relative "rules/availability_keywords"
12
+ require_relative "rules/no_match"
13
+ require_relative "rules/tld_specific"
14
+ require_relative "rules/status_field"
15
+ require_relative "rules/recordless"
16
+
17
+ module MonoVM
18
+ module Whois
19
+ module Availability
20
+ # The ordered chain of detection rules.
21
+ #
22
+ # Order is the whole design. Two invariants hold it together:
23
+ #
24
+ # 1. Rules that recognise a *non-answer* run first, so nothing that reaches the
25
+ # permissive rules could have been a refusal or a wrong-server reply.
26
+ # 2. Every rule that concludes +:registered+ runs before every rule that
27
+ # concludes +:available+, so when two signals conflict the safe one wins.
28
+ # Selling a registered domain is a far worse failure than telling a customer
29
+ # to check again.
30
+ #
31
+ # The set is mutable so a host application can add a rule for a registry that
32
+ # invents new wording, without forking the gem or waiting for a release.
33
+ class RuleSet
34
+ include Enumerable
35
+
36
+ # The order shipped with the gem.
37
+ def self.default
38
+ new([
39
+ Rules::ServerRefusal.new, # non-answers first
40
+ Rules::WrongRegistry.new,
41
+ Rules::RdapObject.new, # structured, authoritative
42
+ Rules::PremiumName.new, # premium is not available
43
+ Rules::ExplicitUnavailability.new, # then everything meaning "registered"
44
+ Rules::RegistrationFields.new,
45
+ Rules::RegistryMarker.new, # then everything meaning "available"
46
+ Rules::AvailabilityKeywords.new,
47
+ Rules::NoMatch.new,
48
+ Rules::TldSpecific.new,
49
+ Rules::StatusField.new,
50
+ Rules::Recordless.new # opt-in per TLD
51
+ ])
52
+ end
53
+
54
+ def initialize(rules = [])
55
+ @rules = rules.dup
56
+ end
57
+
58
+ def each(&)
59
+ @rules.each(&)
60
+ end
61
+
62
+ def to_a
63
+ @rules.dup
64
+ end
65
+
66
+ def size
67
+ @rules.size
68
+ end
69
+
70
+ def names
71
+ @rules.map(&:name)
72
+ end
73
+
74
+ # Add a rule at the very front, ahead of even the refusal checks.
75
+ def prepend(rule)
76
+ @rules.unshift(rule)
77
+ self
78
+ end
79
+
80
+ # Add a rule at the very end, after the shipped fallbacks.
81
+ def append(rule)
82
+ @rules.push(rule)
83
+ self
84
+ end
85
+ alias << append
86
+
87
+ # Insert +rule+ immediately before the named rule, which is usually what you
88
+ # want: a registry-specific check belongs next to the generic one it refines.
89
+ #
90
+ # @param name [String, Symbol] an existing rule's {Rule#name}
91
+ # @raise [ArgumentError] when no rule has that name
92
+ def insert_before(name, rule)
93
+ @rules.insert(index_of!(name), rule)
94
+ self
95
+ end
96
+
97
+ def insert_after(name, rule)
98
+ @rules.insert(index_of!(name) + 1, rule)
99
+ self
100
+ end
101
+
102
+ # Drop a shipped rule, for a deployment that disagrees with one of them.
103
+ def remove(name)
104
+ @rules.delete_at(index_of!(name))
105
+ self
106
+ end
107
+
108
+ # Swap a rule's implementation, keeping its position.
109
+ def replace(name, rule)
110
+ @rules[index_of!(name)] = rule
111
+ self
112
+ end
113
+
114
+ def include?(name)
115
+ @rules.any? { |rule| rule.name == name.to_s }
116
+ end
117
+
118
+ def dup
119
+ self.class.new(@rules)
120
+ end
121
+
122
+ def inspect
123
+ "#<#{self.class.name} #{names.join(" -> ")}>"
124
+ end
125
+
126
+ private
127
+
128
+ def index_of!(name)
129
+ index = @rules.index { |rule| rule.name == name.to_s }
130
+ raise ArgumentError, "no rule named #{name.inspect}; have #{names.join(", ")}" if index.nil?
131
+
132
+ index
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # Plain-language phrases that state a name is unregistered.
10
+ #
11
+ # The list spans several languages, because ccTLD registries answer in their
12
+ # own: +no se encontro el objeto+, +nicht vergeben+, +is vrij+. Matching is a
13
+ # substring search over the commentary-stripped response — the stripping is
14
+ # what keeps a phrase like "not found" in a registry's legal preamble from
15
+ # being read as an answer.
16
+ class AvailabilityKeywords < Rule
17
+ def call(context)
18
+ return nil if context.empty?
19
+
20
+ keyword = context.find_keyword(Patterns::AVAILABILITY_KEYWORDS)
21
+ return nil if keyword.nil?
22
+
23
+ available(
24
+ reason: "the response states the name is not registered",
25
+ evidence: keyword
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # The response states that the name exists.
10
+ #
11
+ # Per-TLD wording is consulted first, because some registries use words that
12
+ # would be ambiguous elsewhere: DENIC's +Status: invalid+ means the name
13
+ # cannot be registered as spelled, and Nominet answers a registered +.uk+
14
+ # name with nothing more definitive than "Registered".
15
+ #
16
+ # Matching happens against the response with commentary stripped. Registries
17
+ # put conditional prose in their preamble ("if the domain is registered,
18
+ # contact the registrar"), and matching that would mark a free name as taken.
19
+ class ExplicitUnavailability < Rule
20
+ def call(context)
21
+ return nil if context.empty?
22
+
23
+ tld_patterns = Patterns::TLD_UNAVAILABILITY[context.tld]
24
+ if tld_patterns
25
+ matched = context.find(tld_patterns, source: :significant)
26
+ if matched
27
+ return registered(
28
+ reason: "#{context.tld} registries report a registration this way",
29
+ evidence: matched
30
+ )
31
+ end
32
+ end
33
+
34
+ matched = context.find(Patterns::UNAVAILABILITY, source: :significant)
35
+ return nil if matched.nil?
36
+
37
+ registered(reason: "the response states the name is registered", evidence: matched)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # The regexp form of the not-found phrases.
10
+ #
11
+ # Overlaps {AvailabilityKeywords} on purpose. That rule does a fast substring
12
+ # scan, which misses a registry that writes +No match+ with two spaces or
13
+ # +not\tfound+ with a tab; these patterns are whitespace-tolerant and catch
14
+ # the rest. Cheap check first, thorough check second.
15
+ class NoMatch < Rule
16
+ def call(context)
17
+ return nil if context.empty?
18
+
19
+ matched = context.find(Patterns::NO_MATCH, source: :significant)
20
+ return nil if matched.nil?
21
+
22
+ available(
23
+ reason: "the response matched a not-found pattern",
24
+ evidence: matched
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # The registry flagged the name as premium, reserved or otherwise withheld.
10
+ #
11
+ # A premium name is unregistered but not obtainable at standard price, and a
12
+ # reserved one is not obtainable at all. Both are distinct from "available",
13
+ # and a registrar showing an add-to-cart button for either has a support
14
+ # ticket coming.
15
+ #
16
+ # The marker comes from the TLD definition, since the wording is registry
17
+ # specific. This runs before the availability rules because those registries
18
+ # tend to phrase a premium answer as a near-miss of "not found".
19
+ class PremiumName < Rule
20
+ def call(context)
21
+ marker = context.definition&.premium_match
22
+ return nil if marker.nil? || context.empty?
23
+
24
+ return nil unless context.lower.include?(marker.downcase)
25
+
26
+ premium(
27
+ reason: "the registry marked this name as premium or reserved",
28
+ evidence: marker
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # Reads an RDAP response, which says outright whether the object exists.
10
+ #
11
+ # This is why the library prefers RDAP. Every other rule in the chain infers
12
+ # a verdict from prose that varies by registry, by year and sometimes by
13
+ # query. RDAP does not need inference: a registered domain is a JSON object
14
+ # with an +objectClassName+ of +"domain"+, and an unregistered one is an
15
+ # error document with +errorCode+ 404 (RFC 7480 §5.3, RFC 9083 §6). Both
16
+ # answers are definitive, so this rule sits above all the pattern matching.
17
+ class RdapObject < Rule
18
+ def call(context)
19
+ return nil unless context.rdap?
20
+
21
+ document = context.json
22
+
23
+ # An HTTP 404 is RDAP's "no such domain", whether or not it carried a body.
24
+ if document.nil?
25
+ return nil unless Patterns::RDAP_NOT_FOUND_CODES.include?(context.http_status)
26
+
27
+ return available(
28
+ reason: "RDAP answered HTTP 404: no such domain",
29
+ evidence: "HTTP #{context.http_status}"
30
+ )
31
+ end
32
+
33
+ verdict_for_error(document, context) || verdict_for_object(document)
34
+ end
35
+
36
+ private
37
+
38
+ def verdict_for_error(document, _context)
39
+ code = document["errorCode"]
40
+ return nil if code.nil?
41
+
42
+ code = code.to_i
43
+
44
+ if Patterns::RDAP_NOT_FOUND_CODES.include?(code)
45
+ return available(
46
+ reason: "RDAP errorCode 404: no such domain",
47
+ evidence: error_evidence(document, code)
48
+ )
49
+ end
50
+
51
+ # Any other error code is the server declining, not an answer: 429 for
52
+ # rate limiting, 403 for a blocked client, 400 for a query it disliked.
53
+ unknown(
54
+ reason: "RDAP returned errorCode #{code}",
55
+ evidence: error_evidence(document, code)
56
+ )
57
+ rescue TypeError
58
+ nil
59
+ end
60
+
61
+ def verdict_for_object(document)
62
+ return nil unless describes_domain?(document)
63
+
64
+ present = Patterns::RDAP_REGISTRATION_KEYS.count { |key| document.key?(key) }
65
+
66
+ # A conformant domain object always carries an ldhName plus at least
67
+ # something about its state; requiring two keys avoids treating a bare
68
+ # help or notice document as a registration.
69
+ return nil unless present >= 2
70
+
71
+ registered(
72
+ reason: "RDAP returned a domain object",
73
+ evidence: document["ldhName"] || document["handle"]
74
+ )
75
+ end
76
+
77
+ def describes_domain?(document)
78
+ object_class = document["objectClassName"].to_s.downcase
79
+ return true if object_class == "domain"
80
+
81
+ # Some registries omit objectClassName but are unmistakably describing a
82
+ # registration; rdapConformance plus an ldhName is enough.
83
+ document.key?("ldhName") && document.key?("rdapConformance")
84
+ end
85
+
86
+ def error_evidence(document, code)
87
+ title = document["title"] || Array(document["description"]).first
88
+ [code, title].compact.join(" ")
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # Last resort: the server answered with its banner and nothing else.
10
+ #
11
+ # A few registries — NIC Monaco among them — reply to an unregistered name
12
+ # with no marker at all, just their header. For those, silence genuinely is
13
+ # the signal, and no other rule can help.
14
+ #
15
+ # Inferring availability from absence is unsound in general, so this rule is
16
+ # off unless a TLD definition sets +available_when_empty+, and even then it
17
+ # refuses to answer if the response looks like anything other than a clean
18
+ # empty reply: no registration fields, no refusal, no error or restriction
19
+ # notice. Opt-in per TLD rather than a global fallback is the difference
20
+ # between handling one registry's quirk and reporting every unreadable
21
+ # response as free to register.
22
+ class Recordless < Rule
23
+ # Below two registration fields the response is not a record. At two or more
24
+ # it is, however terse.
25
+ MAX_FIELDS = 2
26
+
27
+ def call(context)
28
+ return nil unless context.definition&.available_when_empty?
29
+ return nil if context.empty?
30
+ return nil if context.match?(Patterns::ERROR_OR_RESTRICTION, source: :significant)
31
+
32
+ fields = context.count(Patterns::REGISTRATION_FIELDS, source: :significant)
33
+ return nil if fields >= MAX_FIELDS
34
+
35
+ available(
36
+ reason: "#{context.tld} answers unregistered names with no record, " \
37
+ "and this response carries none",
38
+ evidence: context.preview(80)
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # The response is a record: it carries the fields only a registration has.
10
+ #
11
+ # Useful for the many registries that never say "registered" in so many
12
+ # words — they simply return the record, and the record's existence is the
13
+ # answer. Counting fields rather than looking for one is deliberate: a
14
+ # registrar name might appear in a preamble, but a registrar *and* a creation
15
+ # date *and* nameservers together are a registration.
16
+ class RegistrationFields < Rule
17
+ # Three independent fields. Two is reachable by a chatty error notice; four
18
+ # loses the GDPR-redacted records that only keep domain, registrar and
19
+ # status.
20
+ THRESHOLD = 3
21
+
22
+ def call(context)
23
+ return nil if context.empty?
24
+
25
+ found = context.count(Patterns::REGISTRATION_INDICATORS, source: :significant)
26
+ return nil if found < THRESHOLD
27
+
28
+ registered(
29
+ reason: "the response contains #{found} registration fields",
30
+ evidence: context.find(Patterns::REGISTRATION_INDICATORS, source: :significant)
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # The exact string this registry emits for an unregistered name, taken from
10
+ # the TLD definition.
11
+ #
12
+ # This is the curated, high-confidence signal — +"No match for"+ for Verisign,
13
+ # +"Domain not found"+ for PIR — and the obvious design would check it first,
14
+ # before anything else.
15
+ #
16
+ # Here it runs *after* the rules that establish a registration, and that
17
+ # reordering fixes a real failure mode: when a TLD is mapped to the wrong
18
+ # server, or a registry starts returning a record that happens to contain the
19
+ # marker text, checking the marker first turns a registered domain into an
20
+ # available one. Letting a genuine record win first costs nothing, because a
21
+ # response cannot both be a record and be the registry's not-found message.
22
+ class RegistryMarker < Rule
23
+ def call(context)
24
+ marker = context.definition&.available_match
25
+ return nil if marker.nil? || context.empty?
26
+
27
+ return nil unless context.significant_lower.include?(marker.downcase)
28
+
29
+ available(
30
+ reason: "matched the not-found marker configured for #{context.tld}",
31
+ evidence: marker
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # The server answered, but declined to say anything about the domain.
10
+ #
11
+ # First in the chain, and that position is the point. Rate-limit notices,
12
+ # "your client is blocked" messages and "port 43 is retired, use RDAP"
13
+ # banners contain none of the wording that means "registered", so every
14
+ # later rule passes on them and the response eventually reaches whichever
15
+ # heuristic is loosest. In a permissive detector that heuristic answers
16
+ # "available", which means a rate-limited registry reports its entire zone
17
+ # as free to register.
18
+ #
19
+ # Deciding this first, and deciding it +:unknown+, is what makes the rest of
20
+ # the chain safe to be permissive.
21
+ class ServerRefusal < Rule
22
+ def call(context)
23
+ return nil if context.empty?
24
+
25
+ # An RDAP document says "I refuse" structurally — an HTTP 401/403/429,
26
+ # which the transport turns into an error, or an +errorCode+, which
27
+ # {RdapObject} reads. Its prose must not be scanned for refusal wording,
28
+ # because registries embed rate-limit *policy* in the Terms of Service
29
+ # notice attached to every answer: PIR's .org responses explain that a
30
+ # client sending "too many queries" will be throttled, and matching that
31
+ # reported every unregistered .org name as unknown.
32
+ return nil unless context.json.nil?
33
+
34
+ matched = context.find(Patterns::REFUSAL)
35
+ return nil if matched.nil?
36
+
37
+ unknown(
38
+ reason: "the server refused to answer the query",
39
+ evidence: matched
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # An explicit status field stating the name is free.
10
+ #
11
+ # Some detectors pair this check with a second inference: "fewer than two
12
+ # registration fields present" also counts as availability. That inference is
13
+ # unsound and is deliberately not reproduced. Every response that is not a
14
+ # record has fewer than two registration fields — a rate-limit notice, a
15
+ # blocked-client message, a legal preamble, a truncated read — so it converts
16
+ # any non-answer into "free to register".
17
+ #
18
+ # Genuine availability always carries a positive signal, and the keyword,
19
+ # not-found and per-TLD rules already look for it. The one case where silence
20
+ # really is the only signal is handled by {Recordless}, which requires an
21
+ # explicit per-TLD opt-in.
22
+ class StatusField < Rule
23
+ def call(context)
24
+ return nil if context.empty?
25
+
26
+ # An error or restriction notice can contain a status line while still
27
+ # describing something that exists.
28
+ return nil if context.match?(Patterns::ERROR_OR_RESTRICTION, source: :significant)
29
+
30
+ matched = context.find(Patterns::STATUS_AVAILABLE, source: :significant)
31
+ return nil if matched.nil?
32
+
33
+ available(
34
+ reason: "an explicit status field reports the name as available",
35
+ evidence: matched
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Availability
8
+ module Rules
9
+ # Per-TLD availability wording.
10
+ #
11
+ # Runs late because several entries are single words — +.it+ answers a free
12
+ # name with little more than "available", +.ch+ with "we do not have an entry
13
+ # in our database matching your query". As a general rule those would be far
14
+ # too loose; scoped to one registry's output format they are reliable.
15
+ #
16
+ # By the time the chain gets here, every rule that could establish a
17
+ # registration has already declined, so a loose availability pattern can only
18
+ # fire on a response that carried no registration signal at all.
19
+ class TldSpecific < Rule
20
+ def call(context)
21
+ return nil if context.empty? || context.tld.nil?
22
+
23
+ patterns = Patterns::TLD_AVAILABILITY[context.tld]
24
+ return nil if patterns.nil?
25
+
26
+ matched = context.find(patterns, source: :significant)
27
+ return nil if matched.nil?
28
+
29
+ available(
30
+ reason: "matched an availability pattern specific to #{context.tld}",
31
+ evidence: matched
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end