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
+ module MonoVM
4
+ module Whois
5
+ module Registry
6
+ # A name split into its registrable parts, with the definition that serves it.
7
+ #
8
+ # Produced by {ServerRegistry#resolve}. Exists so the split and the definition
9
+ # travel together: knowing that +example.co.uk+ has SLD +example+ is only
10
+ # meaningful alongside the fact that +.co.uk+ is the suffix a registry
11
+ # actually serves.
12
+ class Resolution
13
+ attr_reader :domain, :sld, :tld, :definition
14
+
15
+ # @param domain [DomainName] the full name as queried
16
+ # @param sld [String] the label immediately left of the suffix, ASCII
17
+ # @param tld [String] the matched suffix, with a leading dot, ASCII — this is
18
+ # the key the definitions and the per-TLD pattern tables are indexed by
19
+ # @param definition [Definition] how to look this suffix up
20
+ # @param registrable [DomainName, nil] the registrable name in its *Unicode*
21
+ # form. Passed in because only the caller that did the splitting still knows
22
+ # the Unicode labels; rebuilding it from the ASCII +sld+ and +tld+ would
23
+ # lose them, and the registries that insist on Unicode over port 43 would
24
+ # silently be sent Punycode.
25
+ def initialize(domain:, sld:, tld:, definition:, registrable: nil)
26
+ @domain = domain
27
+ @sld = sld
28
+ @tld = tld
29
+ @definition = definition
30
+ # Built here, not memoised: the instance is frozen, so a lazy ivar would
31
+ # raise FrozenError on first use.
32
+ @registrable = registrable || DomainName.parse("#{sld}#{tld}")
33
+ freeze
34
+ end
35
+
36
+ # The registrable name — the SLD plus its suffix, with any subdomain
37
+ # dropped. A lookup for +www.example.co.uk+ must query +example.co.uk+,
38
+ # because that is the object the registry holds. In Unicode form; call
39
+ # {#query} for the wire form.
40
+ attr_reader :registrable
41
+
42
+ # The ASCII form to put on the wire.
43
+ def query
44
+ registrable.ascii
45
+ end
46
+
47
+ def to_h
48
+ { domain: domain.to_s, sld: sld, tld: tld, registrable: registrable.to_s }
49
+ end
50
+
51
+ def inspect
52
+ "#<#{self.class.name} #{sld}|#{tld}>"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "definition"
4
+ require_relative "resolution"
5
+ require_relative "sources/base"
6
+ require_relative "sources/json_file"
7
+ require_relative "sources/iana_bootstrap"
8
+ require_relative "../domain_name"
9
+ require_relative "../paths"
10
+
11
+ module MonoVM
12
+ module Whois
13
+ module Registry
14
+ # Answers two questions: which suffix of a name is its TLD, and how is that
15
+ # TLD looked up.
16
+ #
17
+ # It owns no data of its own — definitions come from an ordered list of
18
+ # {Sources::Base} objects, later sources overriding earlier ones. The default
19
+ # order is bundled WHOIS list, then the IANA RDAP bootstrap, then an optional
20
+ # user override file, so a deployment can correct a stale registry entry
21
+ # without a gem release.
22
+ class ServerRegistry
23
+ attr_reader :sources
24
+
25
+ class << self
26
+ # The registry as most callers want it.
27
+ #
28
+ # @param override_path [String, nil] extra definitions, highest priority
29
+ # @return [ServerRegistry]
30
+ def default(override_path: Paths.override)
31
+ sources = [
32
+ Sources::JsonFile.new(path: Paths::WHOIS_SERVERS, name: "bundled"),
33
+ Sources::IanaBootstrap.new
34
+ ]
35
+
36
+ if override_path
37
+ sources << Sources::JsonFile.new(path: override_path, optional: true, name: "override")
38
+ end
39
+
40
+ new(sources: sources)
41
+ end
42
+ end
43
+
44
+ # @param sources [Array<Sources::Base>] consulted in order; later wins
45
+ def initialize(sources:)
46
+ raise ArgumentError, "at least one source is required" if sources.nil? || sources.empty?
47
+
48
+ @sources = sources
49
+ @mutex = Mutex.new
50
+ end
51
+
52
+ # Every TLD this registry can look up, keyed with a leading dot.
53
+ #
54
+ # @return [Hash{String => Definition}]
55
+ def definitions
56
+ # Bulk checks resolve concurrently, and the first of those threads would
57
+ # otherwise trigger this build several times over.
58
+ @definitions || @mutex.synchronize { @definitions ||= build }
59
+ end
60
+
61
+ # @param tld [String, nil] with or without a leading dot
62
+ # @return [Boolean]
63
+ def supports?(tld)
64
+ !definition_for(tld).nil?
65
+ end
66
+
67
+ # @param tld [String, nil]
68
+ # @return [Definition, nil]
69
+ def definition_for(tld)
70
+ return nil if tld.nil?
71
+
72
+ key = tld.to_s.strip.downcase
73
+ key = ".#{key}" unless key.start_with?(".")
74
+ definitions[key]
75
+ end
76
+
77
+ # @return [Array<String>] sorted, dotted TLDs
78
+ def supported_tlds
79
+ definitions.keys.sort
80
+ end
81
+
82
+ def size
83
+ definitions.size
84
+ end
85
+
86
+ # Split +name+ at its longest known suffix.
87
+ #
88
+ # Longest-first is what makes multi-label suffixes and subdomains both land
89
+ # on the registrable name: +example.co.uk+ resolves against +.co.uk+ rather
90
+ # than +.uk+, and +www.example.com+ resolves to +example.com+ rather than
91
+ # being queried as-is.
92
+ #
93
+ # @param name [DomainName, String]
94
+ # @return [Resolution, nil] nil when no suffix of the name is known
95
+ def resolve(name)
96
+ domain = name.is_a?(DomainName) ? name : DomainName.parse(name)
97
+ labels = domain.ascii_labels
98
+ return nil if labels.length < 2
99
+
100
+ # The Unicode labels line up one-for-one with the ASCII ones, so the split
101
+ # found below can be applied to either form.
102
+ unicode_labels = domain.labels
103
+
104
+ # Start at the longest candidate suffix and shrink, so ".co.uk" is tried
105
+ # before ".uk".
106
+ (1...labels.length).each do |index|
107
+ candidate = ".#{labels[index..].join(".")}"
108
+ definition = definitions[candidate]
109
+ next if definition.nil?
110
+
111
+ return Resolution.new(
112
+ domain: domain,
113
+ sld: labels[index - 1],
114
+ tld: candidate,
115
+ definition: definition,
116
+ registrable: registrable_from(unicode_labels, index)
117
+ )
118
+ end
119
+
120
+ nil
121
+ end
122
+
123
+ # Drop the memoised definitions so the next call re-reads every source.
124
+ # Mainly for tests and for long-running processes that refresh a data file.
125
+ def reload
126
+ @mutex.synchronize { @definitions = nil }
127
+ self
128
+ end
129
+
130
+ def inspect
131
+ "#<#{self.class.name} #{@definitions ? "#{@definitions.size} tlds" : "not loaded"} " \
132
+ "sources=#{sources.map(&:name).join(",")}>"
133
+ end
134
+
135
+ private
136
+
137
+ # Rebuild the registrable name from the Unicode labels, keeping the same split
138
+ # point the ASCII match found. Returns nil if the two label lists somehow
139
+ # disagree in length, in which case {Resolution} falls back to the ASCII form.
140
+ def registrable_from(unicode_labels, index)
141
+ return nil if index > unicode_labels.length - 1
142
+
143
+ DomainName.parse([unicode_labels[index - 1], *unicode_labels[index..]].join("."))
144
+ end
145
+
146
+ def build
147
+ merged = sources.each_with_object({}) do |source, accumulator|
148
+ source.load.each do |tld, definition|
149
+ existing = accumulator[tld]
150
+ accumulator[tld] = existing ? existing.merge(definition) : definition
151
+ end
152
+ end
153
+
154
+ if merged.empty?
155
+ raise DefinitionsError,
156
+ "no definitions loaded from #{sources.map(&:name).join(", ")}"
157
+ end
158
+
159
+ merged.select { |_tld, definition| definition.usable? }.freeze
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../definition"
4
+ require_relative "../../errors"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ module Registry
9
+ module Sources
10
+ # Interface for anything that can supply TLD definitions.
11
+ #
12
+ # {ServerRegistry} depends on this, not on files: that is what lets a host
13
+ # application feed definitions from a database, an internal HTTP service or
14
+ # a hard-coded hash in a test, without the registry knowing. A source needs
15
+ # two methods, {#name} and {#load}.
16
+ class Base
17
+ # A short identifier recorded in {Definition#sources}, so a surprising
18
+ # endpoint can be traced back to whichever source supplied it.
19
+ #
20
+ # @return [String]
21
+ def name
22
+ self.class.name.to_s.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
23
+ end
24
+
25
+ # @return [Hash{String => Definition}] keyed by TLD, with the leading dot
26
+ def load
27
+ raise NotImplementedError, "#{self.class} must implement #load"
28
+ end
29
+
30
+ # Sources that cannot be read are usually optional (an override file that
31
+ # does not exist yet). Returning true here lets {ServerRegistry} skip a
32
+ # failure instead of aborting every lookup.
33
+ def optional?
34
+ false
35
+ end
36
+
37
+ private
38
+
39
+ # Normalise +com+, +.COM+ or + .com + to +.com+.
40
+ def normalise_tld(raw)
41
+ cleaned = raw.to_s.strip.downcase.sub(/\A\.+/, "").sub(/\.+\z/, "")
42
+ return nil if cleaned.empty?
43
+
44
+ ".#{cleaned}"
45
+ end
46
+
47
+ # A TLD may be internationalised (.рф, .中国); definitions are keyed by the
48
+ # ASCII form because that is what a resolved name is matched against.
49
+ def ascii_tld(tld)
50
+ return nil if tld.nil?
51
+
52
+ DomainName.parse(tld.delete_prefix(".")).ascii.then { |ascii| ".#{ascii}" }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+ require_relative "../../paths"
6
+
7
+ module MonoVM
8
+ module Whois
9
+ module Registry
10
+ module Sources
11
+ # The IANA RDAP bootstrap registry (RFC 7484).
12
+ #
13
+ # IANA publishes the authoritative TLD -> RDAP base URL map at
14
+ # https://data.iana.org/rdap/dns.json. A snapshot ships with the gem, which
15
+ # is why this library can answer for far more TLDs than a hand-maintained
16
+ # port 43 list covers, and why those answers are structured JSON instead of
17
+ # registry prose.
18
+ #
19
+ # The file looks like:
20
+ #
21
+ # {
22
+ # "version": "1.0",
23
+ # "publication": "2026-07-23T02:00:03Z",
24
+ # "services": [
25
+ # [ ["com", "net"], ["https://rdap.verisign.com/com/v1/"] ]
26
+ # ]
27
+ # }
28
+ #
29
+ # Reading is deliberately offline. Fetching at load time would put a network
30
+ # round trip in front of every process boot and make the library's own
31
+ # startup depend on IANA being reachable; +rake data:refresh_rdap+ updates
32
+ # the snapshot instead.
33
+ class IanaBootstrap < Base
34
+ BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json"
35
+
36
+ # RDAP domain lookups live under +{base}/domain/{name}+ (RFC 7482 §3.1.3).
37
+ DOMAIN_PATH = "domain/"
38
+
39
+ attr_reader :path
40
+
41
+ def initialize(path: Paths::RDAP_BOOTSTRAP, optional: true)
42
+ @path = path
43
+ @optional = optional
44
+ super()
45
+ end
46
+
47
+ def name
48
+ "iana-rdap"
49
+ end
50
+
51
+ def optional?
52
+ @optional
53
+ end
54
+
55
+ # @return [String, nil] the snapshot's publication timestamp
56
+ def published_at
57
+ document&.fetch("publication", nil)
58
+ end
59
+
60
+ # @return [Hash{String => Definition}]
61
+ def load
62
+ services = document&.fetch("services", nil)
63
+ return {} unless services.is_a?(Array)
64
+
65
+ services.each_with_object({}) do |service, definitions|
66
+ endpoint = endpoint_for(service)
67
+ next if endpoint.nil?
68
+
69
+ tlds_in(service).each do |tld|
70
+ definitions[tld] ||= Definition.new(tld: tld, rdap_endpoint: endpoint, sources: [name])
71
+ end
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def document
78
+ return @document if defined?(@document)
79
+
80
+ @document = read_document
81
+ end
82
+
83
+ def read_document
84
+ raw = read
85
+ return nil if raw.nil?
86
+
87
+ parsed = JSON.parse(raw)
88
+ parsed.is_a?(Hash) ? parsed : nil
89
+ rescue JSON::ParserError => e
90
+ raise DefinitionsError, "#{path} is not valid JSON: #{e.message}" unless optional?
91
+
92
+ nil
93
+ end
94
+
95
+ def read
96
+ return File.read(path, encoding: "UTF-8") if File.file?(path)
97
+ return nil if optional?
98
+
99
+ raise DefinitionsError, "RDAP bootstrap not found at #{path}"
100
+ rescue SystemCallError => e
101
+ raise DefinitionsError, "cannot read #{path}: #{e.message}" unless optional?
102
+
103
+ nil
104
+ end
105
+
106
+ # A service entry is +[[tld, ...], [url, ...]]+.
107
+ def tlds_in(service)
108
+ return [] unless service.is_a?(Array) && service[0].is_a?(Array)
109
+
110
+ service[0].filter_map { |tld| ascii_tld(normalise_tld(tld)) }
111
+ end
112
+
113
+ def endpoint_for(service)
114
+ return nil unless service.is_a?(Array) && service[1].is_a?(Array)
115
+
116
+ url = preferred_url(service[1])
117
+ return nil if url.nil?
118
+
119
+ Endpoint.parse("#{url}#{DOMAIN_PATH}")
120
+ rescue DefinitionsError
121
+ # One malformed entry must not cost us the other 1,199.
122
+ nil
123
+ end
124
+
125
+ # HTTPS where a registry offers both: RDAP responses carry registrant
126
+ # data, and there is no reason to read it off the wire in clear text.
127
+ def preferred_url(urls)
128
+ candidates = urls.filter_map do |url|
129
+ text = url.to_s.strip
130
+ text.empty? ? nil : text
131
+ end
132
+
133
+ chosen = candidates.find { |url| url.start_with?("https://") } || candidates.first
134
+ return nil if chosen.nil?
135
+
136
+ chosen.end_with?("/") ? chosen : "#{chosen}/"
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ module Registry
9
+ module Sources
10
+ # Reads the server-list JSON format: an array of entries, each mapping a
11
+ # comma-separated +extensions+ list to one endpoint.
12
+ #
13
+ # [
14
+ # {
15
+ # "extensions": ".com,.net",
16
+ # "uri": "socket://whois.verisign-grs.com",
17
+ # "available": "No match for",
18
+ # "premium": "Reserved",
19
+ # "rdap": "https://rdap.verisign.com/com/v1/domain/",
20
+ # "available_when_empty": false,
21
+ # "comment": "why this entry looks like this"
22
+ # }
23
+ # ]
24
+ #
25
+ # Both the bundled list and a user override file use this class — they
26
+ # differ only in path and in whether a missing file is fatal.
27
+ class JsonFile < Base
28
+ attr_reader :path
29
+
30
+ # @param path [String]
31
+ # @param optional [Boolean] when true, a missing or unreadable file
32
+ # yields no definitions instead of raising
33
+ # @param name [String, nil] identifier recorded on each definition
34
+ def initialize(path:, optional: false, name: nil)
35
+ @path = path
36
+ @optional = optional
37
+ @name = name
38
+ super()
39
+ end
40
+
41
+ def name
42
+ @name || (optional? ? "override" : "bundled")
43
+ end
44
+
45
+ def optional?
46
+ @optional
47
+ end
48
+
49
+ # @return [Hash{String => Definition}]
50
+ def load
51
+ raw = read
52
+ return {} if raw.nil?
53
+
54
+ entries = parse(raw)
55
+ entries.each_with_index.with_object({}) do |(entry, index), definitions|
56
+ add_entry(definitions, entry, index)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def read
63
+ return File.read(path, encoding: "UTF-8") if File.file?(path)
64
+ return nil if optional?
65
+
66
+ raise DefinitionsError, "server definitions not found at #{path}"
67
+ rescue SystemCallError => e
68
+ raise DefinitionsError, "cannot read #{path}: #{e.message}" unless optional?
69
+
70
+ nil
71
+ end
72
+
73
+ def parse(raw)
74
+ parsed = JSON.parse(raw)
75
+ unless parsed.is_a?(Array)
76
+ raise DefinitionsError, "#{path} must hold a JSON array, got #{parsed.class}"
77
+ end
78
+
79
+ parsed
80
+ rescue JSON::ParserError => e
81
+ raise DefinitionsError, "#{path} is not valid JSON: #{e.message}"
82
+ end
83
+
84
+ def add_entry(definitions, entry, index)
85
+ unless entry.is_a?(Hash)
86
+ raise DefinitionsError, "#{path}: entry #{index} must be an object, got #{entry.class}"
87
+ end
88
+
89
+ whois = build_endpoint(entry["uri"], index)
90
+ rdap = build_endpoint(entry["rdap"], index)
91
+ return if whois.nil? && rdap.nil?
92
+
93
+ tlds_in(entry).each do |tld|
94
+ definition = definition_for(tld, entry, whois, rdap)
95
+ definitions[tld] = definitions.key?(tld) ? definitions[tld].merge(definition) : definition
96
+ end
97
+ end
98
+
99
+ def tlds_in(entry)
100
+ entry["extensions"].to_s.split(",").filter_map do |extension|
101
+ ascii_tld(normalise_tld(extension))
102
+ end
103
+ end
104
+
105
+ def definition_for(tld, entry, whois, rdap)
106
+ Definition.new(
107
+ tld: tld,
108
+ whois_endpoint: whois,
109
+ rdap_endpoint: rdap,
110
+ available_match: entry["available"],
111
+ premium_match: entry["premium"],
112
+ available_when_empty: truthy?(entry["available_when_empty"]),
113
+ comment: entry["comment"],
114
+ sources: [name]
115
+ )
116
+ end
117
+
118
+ def build_endpoint(uri, index)
119
+ return nil if uri.nil? || uri.to_s.strip.empty?
120
+
121
+ Endpoint.parse(uri)
122
+ rescue DefinitionsError => e
123
+ raise DefinitionsError, "#{path}: entry #{index} has a bad endpoint: #{e.message}"
124
+ end
125
+
126
+ # JSON gives real booleans, but hand-maintained files and environment
127
+ # overrides tend to carry strings.
128
+ def truthy?(value)
129
+ return value if [true, false].include?(value)
130
+
131
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ # One raw answer from one server.
8
+ #
9
+ # Transports return this; availability rules and parsers read it. Keeping the
10
+ # raw body verbatim matters — callers of a WHOIS library legitimately want to
11
+ # see exactly what the registry said, so nothing here mutates {#body}.
12
+ class Response
13
+ attr_reader :body, :endpoint, :query, :status, :elapsed
14
+
15
+ # @param body [String] exactly what the server sent
16
+ # @param endpoint [Endpoint] where it came from
17
+ # @param query [String] the name that was asked about
18
+ # @param status [Integer, nil] HTTP status, nil for port 43
19
+ # @param elapsed [Float, nil] seconds spent on the exchange
20
+ def initialize(body:, endpoint:, query:, status: nil, elapsed: nil)
21
+ @body = body.to_s.freeze
22
+ @endpoint = endpoint
23
+ @query = query
24
+ @status = status
25
+ @elapsed = elapsed
26
+ end
27
+
28
+ # Not frozen as a whole: {#text} and {#json} are memoised, and parsing a large
29
+ # RDAP document on every rule that asks for it would be wasted work. The body
30
+ # itself is frozen, so the response is still effectively immutable.
31
+
32
+ def rdap?
33
+ endpoint.kind == :rdap
34
+ end
35
+
36
+ def whois?
37
+ endpoint.kind == :whois
38
+ end
39
+
40
+ def empty?
41
+ body.strip.empty?
42
+ end
43
+
44
+ # The body as text, with line endings normalised to \n. Availability rules
45
+ # match against this so a pattern anchored to a line start behaves the same
46
+ # whether the registry sent CRLF or LF.
47
+ def text
48
+ @text ||= body.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
49
+ .gsub(/\r\n?/, "\n")
50
+ .freeze
51
+ end
52
+
53
+ # The parsed RDAP document, or nil when the body is not JSON.
54
+ #
55
+ # Memoised including the nil case: a non-JSON body is re-checked by several
56
+ # rules and reparsing it each time is wasted work.
57
+ def json
58
+ return @json if defined?(@json)
59
+
60
+ @json = begin
61
+ parsed = JSON.parse(body)
62
+ parsed.is_a?(Hash) ? parsed : nil
63
+ rescue JSON::ParserError
64
+ nil
65
+ end
66
+ end
67
+
68
+ def json?
69
+ !json.nil?
70
+ end
71
+
72
+ def to_h
73
+ {
74
+ query: query,
75
+ endpoint: endpoint.to_s,
76
+ kind: endpoint.kind,
77
+ status: status,
78
+ elapsed: elapsed,
79
+ length: body.length
80
+ }
81
+ end
82
+
83
+ def inspect
84
+ "#<#{self.class.name} query=#{query.inspect} endpoint=#{endpoint} " \
85
+ "status=#{status.inspect} bytes=#{body.length}>"
86
+ end
87
+ end
88
+ end
89
+ end