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,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "domain_name"
5
+ require_relative "result"
6
+ require_relative "parser/record"
7
+ require_relative "referral/follower"
8
+ require_relative "errors"
9
+
10
+ module MonoVM
11
+ module Whois
12
+ # Looks up one domain, end to end.
13
+ #
14
+ # The client owns the *sequence* — resolve the TLD, pick an endpoint, fetch,
15
+ # classify, parse, maybe follow a referral — and none of the policy. Which server
16
+ # serves a TLD is the registry's business, how to talk to it is the transport's,
17
+ # what the answer means is the analyzer's, and what the record says is the
18
+ # parser's. Every one of them arrives by injection, so a spec can replace any
19
+ # single step without touching the others.
20
+ #
21
+ # client = MonoVM::Whois::Client.new
22
+ # client.lookup("monovm.com").status # => :registered
23
+ class Client
24
+ # Fields where the registrar's copy is the better one, used when a thin
25
+ # registry's record is merged with the registrar's. Everywhere else the registry
26
+ # wins, because it is authoritative for whether the name exists, what state it is
27
+ # in and when it expires — and registrars are frequently stale on exactly those.
28
+ # The registrant and the contact block are the reverse: a thin registry never has
29
+ # them at all.
30
+ REGISTRAR_WINS = %i[registrant contacts].freeze
31
+
32
+ attr_reader :config, :server_registry, :transport_factory, :analyzer, :parsers, :follower
33
+
34
+ # Every argument defaults from +config+; pass one to replace that collaborator.
35
+ def initialize(config: nil, server_registry: nil, transport_factory: nil,
36
+ analyzer: nil, parsers: nil, follower: nil)
37
+ @config = config || Configuration.new
38
+ @server_registry = server_registry || @config.server_registry
39
+ @transport_factory = transport_factory || @config.transport_factory
40
+ @analyzer = analyzer || @config.analyzer
41
+ @parsers = parsers || @config.parsers
42
+ @follower = follower || default_follower
43
+ end
44
+
45
+ # @param domain [String, DomainName]
46
+ # @return [Result] never raises for a lookup failure; the failure is the result
47
+ def lookup(domain)
48
+ name = domain.is_a?(DomainName) ? domain : DomainName.parse(domain)
49
+
50
+ return Result.invalid(name, reason: "#{name} is not a valid domain name") unless name.valid?
51
+
52
+ if name.bare?
53
+ return Result.invalid(
54
+ name,
55
+ reason: "#{name} has no TLD; give one, or use Checker to try popular TLDs"
56
+ )
57
+ end
58
+
59
+ resolution = server_registry.resolve(name)
60
+ return Result.invalid(name, reason: "no WHOIS or RDAP server is known for #{name}") if resolution.nil?
61
+
62
+ perform(name, resolution)
63
+ end
64
+
65
+ # @return [Boolean] true only when the lookup positively established availability
66
+ def available?(domain)
67
+ lookup(domain).available?
68
+ end
69
+
70
+ # Why a lookup came out the way it did, including every rule consulted.
71
+ #
72
+ # @return [Hash]
73
+ def explain(domain)
74
+ result = lookup(domain)
75
+
76
+ {
77
+ domain: result.name,
78
+ status: result.status,
79
+ tld: result.tld,
80
+ reason: result.reason,
81
+ trace: result.verdict&.trace || [],
82
+ endpoint: result.response&.endpoint&.to_s,
83
+ record: result.record&.to_h
84
+ }
85
+ end
86
+
87
+ private
88
+
89
+ def default_follower
90
+ return nil unless config.follow_referrals
91
+
92
+ Referral::Follower.new(transport_factory: transport_factory, max_hops: config.max_referrals)
93
+ end
94
+
95
+ def perform(name, resolution)
96
+ response, failure = fetch_first(resolution)
97
+
98
+ return failure_result(name, resolution, failure) if response.nil?
99
+
100
+ verdict = analyzer.analyze(
101
+ response: response,
102
+ tld: resolution.tld,
103
+ definition: resolution.definition
104
+ )
105
+
106
+ record = parsers.parse(response)
107
+ record, response = enrich(record, response, resolution, verdict)
108
+
109
+ Result.new(
110
+ domain: name,
111
+ status: verdict.status,
112
+ sld: resolution.sld,
113
+ tld: resolution.tld,
114
+ verdict: verdict,
115
+ record: record.empty? ? nil : record,
116
+ response: response
117
+ )
118
+ end
119
+
120
+ # Try the definition's endpoints in preference order, keeping the first answer.
121
+ #
122
+ # Falling through to port 43 when RDAP is unreachable is what keeps coverage
123
+ # high: a registry that has an RDAP entry in the IANA bootstrap but whose
124
+ # endpoint is broken still gets answered.
125
+ #
126
+ # @return [Array(Response, nil), Array(nil, Exception)]
127
+ def fetch_first(resolution)
128
+ endpoints = resolution.definition.endpoints(prefer: config.prefer)
129
+ return [nil, UnsupportedTldError.new(resolution.tld)] if endpoints.empty?
130
+
131
+ first_failure = nil
132
+
133
+ endpoints.each do |endpoint|
134
+ return [fetch(endpoint, resolution), nil]
135
+ rescue Error => e
136
+ # Remember the first failure: it came from the preferred endpoint and is
137
+ # the more relevant one to report.
138
+ first_failure ||= e
139
+ end
140
+
141
+ [nil, first_failure]
142
+ end
143
+
144
+ def fetch(endpoint, resolution)
145
+ transport_factory.for(endpoint).fetch(
146
+ query: query_for(endpoint, resolution),
147
+ endpoint: endpoint
148
+ )
149
+ end
150
+
151
+ # Punycode by default; the Unicode form for the few registries that insist on
152
+ # it, and only over port 43 since a URL must be ASCII either way.
153
+ def query_for(endpoint, resolution)
154
+ if endpoint.socket? && config.unicode_query?(resolution.tld)
155
+ resolution.registrable.to_s
156
+ else
157
+ resolution.query
158
+ end
159
+ end
160
+
161
+ # Follow a thin registry's referral to fill in the record, keeping the
162
+ # registry's verdict either way.
163
+ def enrich(record, response, resolution, verdict)
164
+ return [record, response] if follower.nil?
165
+ return [record, response] unless verdict.registered?
166
+
167
+ referral = follower.follow(response: response, record: record, query: resolution.query)
168
+ return [record, response] if referral.nil?
169
+
170
+ enriched = parsers.parse(referral)
171
+ return [record, response] if enriched.empty?
172
+
173
+ [merge_records(record, enriched), referral]
174
+ end
175
+
176
+ # Combine the registry's record with the registrar's, one policy stated once
177
+ # rather than a conditional per field.
178
+ def merge_records(registry_record, registrar_record)
179
+ registry = registry_record.attributes
180
+ registrar = registrar_record.attributes
181
+
182
+ merged = registrar.merge(registry) do |key, from_registrar, from_registry|
183
+ preferred, fallback =
184
+ if REGISTRAR_WINS.include?(key)
185
+ [from_registrar, from_registry]
186
+ else
187
+ [from_registry, from_registrar]
188
+ end
189
+
190
+ blank_value?(preferred) ? fallback : preferred
191
+ end
192
+
193
+ # Three fields are not a choice between two values.
194
+ merged[:registrar_whois_server] = registry[:registrar_whois_server]
195
+ merged[:fields] = registrar[:fields].merge(registry[:fields])
196
+ merged[:source] = [registry_record.source, registrar_record.source].compact.uniq.join("+")
197
+
198
+ Parser::Record.new(**merged)
199
+ end
200
+
201
+ def blank_value?(value)
202
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
203
+ end
204
+
205
+ # Map a transport failure onto a result. The distinction being preserved here:
206
+ # "we could not find out" is not "it is free".
207
+ def failure_result(name, resolution, failure)
208
+ case failure
209
+ when UnsupportedTldError
210
+ Result.invalid(
211
+ name,
212
+ reason: failure.message,
213
+ sld: resolution.sld,
214
+ tld: resolution.tld
215
+ )
216
+ else
217
+ Result.unknown(
218
+ name,
219
+ reason: failure&.message || "the lookup produced no response",
220
+ sld: resolution.sld,
221
+ tld: resolution.tld
222
+ )
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry/server_registry"
4
+ require_relative "transport/factory"
5
+ require_relative "transport/middleware/cache"
6
+ require_relative "transport/middleware/throttle"
7
+ require_relative "transport/middleware/retry"
8
+ require_relative "transport/middleware/instrumentation"
9
+ require_relative "availability/analyzer"
10
+ require_relative "availability/rule_set"
11
+ require_relative "parser/selector"
12
+ require_relative "paths"
13
+
14
+ module MonoVM
15
+ module Whois
16
+ # Every knob in one place, plus the defaults for the objects {Client} depends on.
17
+ #
18
+ # {Client} takes its collaborators by injection, which is what makes it testable
19
+ # — but nobody wants to assemble a registry, a transport factory, an analyzer and
20
+ # a parser selector just to look up one domain. This class is the seam between
21
+ # those two needs: it holds the settings and builds sensible collaborators from
22
+ # them, while leaving every one of them replaceable.
23
+ #
24
+ # MonoVM::Whois.configure do |config|
25
+ # config.prefer = :whois
26
+ # config.throttle_interval = 1.0
27
+ # config.rules.insert_before("registry_marker", MyRule.new)
28
+ # end
29
+ class Configuration
30
+ # TLDs whose registry wants an internationalised name in Unicode rather than
31
+ # Punycode, over port 43.
32
+ #
33
+ # DENIC answers +Status: invalid+ for +xn--mnchen-3ya.de+ but resolves
34
+ # +münchen.de+ correctly. Almost every other registry is the reverse — Verisign
35
+ # answers "No match" to a UTF-8 query, which reads as availability — so
36
+ # Punycode is the default and this is the short exception list. URLs are always
37
+ # ASCII, so it never applies to RDAP.
38
+ DEFAULT_UNICODE_QUERY_TLDS = [".de"].freeze
39
+
40
+ # Tried in order when a name arrives without a TLD.
41
+ DEFAULT_POPULAR_TLDS = [".com", ".net", ".org", ".info"].freeze
42
+
43
+ # Transport
44
+ attr_accessor :socket_connect_timeout, :socket_read_timeout,
45
+ :http_open_timeout, :http_read_timeout, :verify_ssl, :user_agent
46
+
47
+ # Behaviour
48
+ attr_accessor :prefer, :follow_referrals, :max_referrals, :unicode_query_tlds,
49
+ :popular_tlds, :concurrency, :override_path
50
+
51
+ # Middleware
52
+ attr_accessor :cache, :cache_ttl, :cache_max_entries,
53
+ :throttle_interval, :retry_attempts, :retry_backoff, :instrumentation
54
+
55
+ # Injected collaborators; each defaults lazily and can be replaced outright.
56
+ attr_writer :server_registry, :transport_factory, :analyzer, :rules, :parsers
57
+
58
+ def initialize
59
+ @socket_connect_timeout = Transport::WhoisSocket::DEFAULT_CONNECT_TIMEOUT
60
+ @socket_read_timeout = Transport::WhoisSocket::DEFAULT_READ_TIMEOUT
61
+ @http_open_timeout = Transport::RdapHttp::DEFAULT_OPEN_TIMEOUT
62
+ @http_read_timeout = Transport::RdapHttp::DEFAULT_READ_TIMEOUT
63
+ @verify_ssl = true
64
+ @user_agent = nil
65
+
66
+ # RDAP first: a structured answer beats pattern-matching registry prose.
67
+ @prefer = :rdap
68
+ @follow_referrals = true
69
+ @max_referrals = 1
70
+ @unicode_query_tlds = DEFAULT_UNICODE_QUERY_TLDS.dup
71
+ @popular_tlds = DEFAULT_POPULAR_TLDS.dup
72
+ @concurrency = 8
73
+ @override_path = Paths.override
74
+
75
+ @cache = true
76
+ @cache_ttl = Transport::Middleware::Cache::DEFAULT_TTL
77
+ @cache_max_entries = Transport::Middleware::Cache::DEFAULT_MAX_ENTRIES
78
+ @throttle_interval = Transport::Middleware::Throttle::DEFAULT_INTERVAL
79
+ @retry_attempts = Transport::Middleware::Retry::DEFAULT_ATTEMPTS
80
+ @retry_backoff = Transport::Middleware::Retry::DEFAULT_BACKOFF
81
+ @instrumentation = nil
82
+ end
83
+
84
+ def server_registry
85
+ @server_registry ||= Registry::ServerRegistry.default(override_path: override_path)
86
+ end
87
+
88
+ def rules
89
+ @rules ||= Availability::RuleSet.default
90
+ end
91
+
92
+ def analyzer
93
+ @analyzer ||= Availability::Analyzer.new(rules: rules)
94
+ end
95
+
96
+ def parsers
97
+ @parsers ||= Parser::Selector.default
98
+ end
99
+
100
+ def transport_factory
101
+ @transport_factory ||= Transport::Factory.new(
102
+ transports: default_transports,
103
+ middleware: default_middleware
104
+ )
105
+ end
106
+
107
+ # True when +tld+'s registry wants the Unicode form over port 43.
108
+ def unicode_query?(tld)
109
+ unicode_query_tlds.include?(tld)
110
+ end
111
+
112
+ # A copy, so a per-call override cannot mutate the global default.
113
+ def dup
114
+ copy = super
115
+ copy.unicode_query_tlds = unicode_query_tlds.dup
116
+ copy.popular_tlds = popular_tlds.dup
117
+ copy
118
+ end
119
+
120
+ private
121
+
122
+ def default_transports
123
+ [
124
+ Transport::WhoisSocket.new(
125
+ connect_timeout: socket_connect_timeout,
126
+ read_timeout: socket_read_timeout
127
+ ),
128
+ Transport::RdapHttp.new(
129
+ open_timeout: http_open_timeout,
130
+ read_timeout: http_read_timeout,
131
+ verify_ssl: verify_ssl,
132
+ user_agent: user_agent
133
+ )
134
+ ]
135
+ end
136
+
137
+ # Order matters, and it reads outermost first. Instrumentation sees everything
138
+ # including cache hits; the cache answers before a throttle delay is paid;
139
+ # retries happen closest to the wire so a retried attempt is still throttled.
140
+ def default_middleware
141
+ layers = []
142
+ if instrumentation
143
+ layers << Transport::Middleware::Instrumentation.builder(subscriber: instrumentation)
144
+ end
145
+ if cache
146
+ layers << Transport::Middleware::Cache.builder(ttl: cache_ttl,
147
+ max_entries: cache_max_entries)
148
+ end
149
+ if throttle_interval.to_f.positive?
150
+ layers << Transport::Middleware::Throttle.builder(interval: throttle_interval)
151
+ end
152
+ if retry_attempts.to_i > 1
153
+ layers << Transport::Middleware::Retry.builder(attempts: retry_attempts,
154
+ backoff: retry_backoff)
155
+ end
156
+ layers
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "punycode"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ # An immutable, normalised domain name.
9
+ #
10
+ # This class knows about the *name* and nothing else: how to clean up what a
11
+ # user pasted, how to move between Unicode and Punycode, and whether the
12
+ # result is a legal host name. Deciding which part of it is the TLD needs the
13
+ # server definitions, so that belongs to {Registry::ServerRegistry} — a name
14
+ # cannot know on its own whether +.co.uk+ is a suffix or two labels.
15
+ #
16
+ # name = DomainName.parse(" HTTPS://WWW.MÜNCHEN.de:8080/path?q=1 ")
17
+ # name.to_s # => "www.münchen.de"
18
+ # name.ascii # => "www.xn--mnchen-3ya.de"
19
+ # name.valid? # => true
20
+ class DomainName
21
+ ACE_PREFIX = "xn--"
22
+ MAX_LENGTH = 253
23
+ MAX_LABEL_LENGTH = 63
24
+
25
+ # A single legal DNS label: alphanumeric ends, hyphens allowed inside.
26
+ LABEL = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/
27
+
28
+ attr_reader :input, :unicode
29
+
30
+ class << self
31
+ # Normalise +input+ into a name. Never raises: an unusable string yields
32
+ # an instance whose {#valid?} is false, which is what bulk callers want so
33
+ # one bad entry does not abort a batch.
34
+ #
35
+ # @param input [String]
36
+ # @return [DomainName]
37
+ def parse(input)
38
+ raise InvalidDomainError, input unless input.is_a?(String)
39
+
40
+ new(input)
41
+ end
42
+
43
+ # Like {.parse} but raises when the result is not a usable host name.
44
+ #
45
+ # @raise [InvalidDomainError]
46
+ def parse!(input)
47
+ parse(input).tap do |name|
48
+ raise InvalidDomainError, input unless name.valid?
49
+ end
50
+ end
51
+ end
52
+
53
+ # Everything is computed here rather than memoised on demand, because the
54
+ # instance is frozen: a value object that lazily fills in an ivar would raise
55
+ # FrozenError the first time anyone asked for it.
56
+ def initialize(input)
57
+ @input = input
58
+ @unicode = normalise(input).freeze
59
+ @labels = @unicode.split(".", -1).freeze
60
+ @ascii = @labels.map { |label| label_to_ascii(label) }.join(".").freeze
61
+ @ascii_labels = @ascii.split(".", -1).freeze
62
+ @valid = compute_valid?
63
+ freeze
64
+ end
65
+
66
+ # The Unicode form — what a human reads.
67
+ def to_s
68
+ unicode
69
+ end
70
+
71
+ # The ASCII (Punycode) form — what goes on the wire and into URLs.
72
+ #
73
+ # Registries are queried in this form by default: Verisign answers "No match"
74
+ # to a UTF-8 query, which would read as availability.
75
+ attr_reader :ascii, :labels, :ascii_labels
76
+
77
+ # True when every label is a legal DNS label and the whole name fits in a
78
+ # DNS query. A single-label name like "monovm" is valid — callers append a
79
+ # TLD to it — so this deliberately does not require a dot.
80
+ def valid?
81
+ @valid
82
+ end
83
+
84
+ # True when the name needed Punycode, i.e. it has non-ASCII labels.
85
+ def idn?
86
+ ascii != unicode
87
+ end
88
+
89
+ # True when the name carries no dot, so no TLD was supplied.
90
+ def bare?
91
+ !unicode.include?(".")
92
+ end
93
+
94
+ # Build a new name by appending +suffix+ to this one.
95
+ #
96
+ # DomainName.parse("monovm").join(".com").to_s # => "monovm.com"
97
+ def join(suffix)
98
+ suffix = suffix.to_s
99
+ suffix = ".#{suffix}" unless suffix.start_with?(".")
100
+ self.class.parse("#{unicode}#{suffix}")
101
+ end
102
+
103
+ def ==(other)
104
+ other.is_a?(DomainName) && other.unicode == unicode
105
+ end
106
+ alias eql? ==
107
+
108
+ def hash
109
+ [self.class, unicode].hash
110
+ end
111
+
112
+ def inspect
113
+ "#<#{self.class.name} #{unicode.inspect}#{" invalid" unless valid?}>"
114
+ end
115
+
116
+ private
117
+
118
+ # Reduce what people actually paste — URLs, mixed case, credentials, a
119
+ # trailing root dot — down to a bare host name.
120
+ def normalise(raw)
121
+ text = raw.to_s.strip
122
+ text = text.split("://", 2).last.to_s if text.include?("://")
123
+ text = text.rpartition("@").last unless text.rpartition("@").last.empty?
124
+
125
+ ["/", "?", "#"].each { |separator| text = text.split(separator, 2).first.to_s }
126
+
127
+ # A bare colon is always a port here; an IPv6 literal is not a domain.
128
+ text = text.split(":", 2).first.to_s
129
+
130
+ text = text.strip.gsub(/\A\.+|\.+\z/, "")
131
+ downcase(text)
132
+ end
133
+
134
+ # Unicode-aware downcasing plus NFC, so "MÜNCHEN" and a decomposed "münchen"
135
+ # both reach Punycode as the same sequence of code points.
136
+ def downcase(text)
137
+ return text.downcase if text.ascii_only?
138
+
139
+ text.unicode_normalize(:nfc).downcase
140
+ rescue ArgumentError, Encoding::CompatibilityError
141
+ # Invalid byte sequences cannot be normalised; leave them for #valid? to reject.
142
+ text
143
+ end
144
+
145
+ def label_to_ascii(label)
146
+ return label if label.ascii_only?
147
+
148
+ "#{ACE_PREFIX}#{Punycode.encode(label)}"
149
+ rescue Punycode::Error
150
+ # Not convertible; hand it back unchanged so #valid? rejects the name
151
+ # rather than this raising from a plain reader method.
152
+ label
153
+ end
154
+
155
+ def compute_valid?
156
+ return false if unicode.empty?
157
+
158
+ candidate = ascii
159
+ return false if candidate.empty? || candidate.length > MAX_LENGTH
160
+ return false unless candidate.ascii_only?
161
+
162
+ ascii_labels.all? do |label|
163
+ !label.empty? && label.length <= MAX_LABEL_LENGTH && LABEL.match?(label)
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "errors"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ # Where a query gets sent, and over which protocol.
9
+ #
10
+ # Server definitions describe two very different things with one string:
11
+ # +socket://whois.nic.uk+ is a host to open a TCP connection to, while
12
+ # +https://rdap.verisign.com/com/v1/domain/+ is a URL prefix. This object
13
+ # normalises both so {Transport::Factory} can pick a transport by asking
14
+ # {#kind} rather than by re-parsing strings all over the codebase.
15
+ class Endpoint
16
+ SOCKET_SCHEME = "socket"
17
+ DEFAULT_WHOIS_PORT = 43
18
+
19
+ # Substituted with the queried name when present, so an unusual endpoint can
20
+ # put the domain somewhere other than the end of the URL.
21
+ PLACEHOLDER = "{domain}"
22
+
23
+ attr_reader :uri, :scheme, :host, :port
24
+
25
+ class << self
26
+ # @param uri [String] e.g. +socket://whois.nic.uk+, +socket://host:4343+,
27
+ # or +https://rdap.example/domain/+
28
+ # @return [Endpoint]
29
+ # @raise [DefinitionsError] when the string is not a usable endpoint
30
+ def parse(uri)
31
+ raise DefinitionsError, "endpoint is empty" if uri.nil? || uri.to_s.strip.empty?
32
+
33
+ new(uri.to_s.strip)
34
+ end
35
+ end
36
+
37
+ def initialize(uri)
38
+ @uri = uri
39
+ @scheme = uri.split("://", 2).first.to_s.downcase
40
+ parse_target!
41
+ freeze
42
+ end
43
+
44
+ # True for port 43 WHOIS.
45
+ def socket?
46
+ scheme == SOCKET_SCHEME
47
+ end
48
+
49
+ # True for RDAP or any other HTTP-delivered service.
50
+ def http?
51
+ %w[http https].include?(scheme)
52
+ end
53
+
54
+ def tls?
55
+ scheme == "https"
56
+ end
57
+
58
+ # A coarse label used to select a transport and to tag a {Response}.
59
+ def kind
60
+ socket? ? :whois : :rdap
61
+ end
62
+
63
+ # The URL to fetch for +name+. Only meaningful for HTTP endpoints.
64
+ #
65
+ # Endpoint.parse("https://rdap.example/domain/").url_for("a.com")
66
+ # # => "https://rdap.example/domain/a.com"
67
+ def url_for(name)
68
+ raise DefinitionsError, "#{uri} is not an HTTP endpoint" unless http?
69
+ return uri.sub(PLACEHOLDER, name) if uri.include?(PLACEHOLDER)
70
+
71
+ "#{uri}#{name}"
72
+ end
73
+
74
+ def to_s
75
+ uri
76
+ end
77
+
78
+ def ==(other)
79
+ other.is_a?(Endpoint) && other.uri == uri
80
+ end
81
+ alias eql? ==
82
+
83
+ def hash
84
+ [self.class, uri].hash
85
+ end
86
+
87
+ def inspect
88
+ "#<#{self.class.name} #{uri}>"
89
+ end
90
+
91
+ private
92
+
93
+ def parse_target!
94
+ if socket?
95
+ parse_socket_target!
96
+ elsif http?
97
+ parse_http_target!
98
+ else
99
+ raise DefinitionsError, "unsupported endpoint scheme in #{uri.inspect}"
100
+ end
101
+ end
102
+
103
+ # +socket://host+ or +socket://host:port+. A trailing slash is tolerated
104
+ # because a few definition files carry one.
105
+ def parse_socket_target!
106
+ target = uri.split("://", 2).last.to_s.delete_suffix("/")
107
+ host, _, port = target.rpartition(":")
108
+
109
+ if host.empty? || !port.match?(/\A\d+\z/)
110
+ @host = target
111
+ @port = DEFAULT_WHOIS_PORT
112
+ else
113
+ @host = host
114
+ @port = port.to_i
115
+ end
116
+
117
+ raise DefinitionsError, "no host in endpoint #{uri.inspect}" if @host.empty?
118
+ end
119
+
120
+ def parse_http_target!
121
+ parsed = URI.parse(uri.sub(PLACEHOLDER, "placeholder"))
122
+ raise DefinitionsError, "no host in endpoint #{uri.inspect}" if parsed.host.nil?
123
+
124
+ @host = parsed.host
125
+ @port = parsed.port
126
+ rescue URI::InvalidURIError => e
127
+ raise DefinitionsError, "invalid endpoint #{uri.inspect}: #{e.message}"
128
+ end
129
+ end
130
+ end
131
+ end