domain_sanity 0.1.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.
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "public_suffix"
5
+ require_relative "idn"
6
+ require_relative "ip"
7
+ require_relative "reason"
8
+ require_relative "policy"
9
+
10
+ module DomainSanity
11
+ # Fully-qualified domain name validation.
12
+ #
13
+ # The checks implement widely published standards, not any one vendor's
14
+ # code: RFC 1035 label syntax and length limits, RFC 5890/5891 for IDN,
15
+ # the Public Suffix List for TLD membership, and the CA/Browser Forum
16
+ # Baseline Requirements for wildcard issuance. Structural length checks run
17
+ # on the ASCII (punycode) form, since the DNS limits are octet limits on the
18
+ # wire format; TLD membership runs on the Unicode form, which is what the
19
+ # Public Suffix List expects.
20
+ #
21
+ # Every entry point funnels a subject through {normalize} exactly once, which
22
+ # classifies it (hostname / wildcard / ip / reverse_zone / empty) and does
23
+ # all IDN conversion and Public Suffix parsing up front. Downstream checks
24
+ # read the precomputed form instead of re-converting.
25
+ #
26
+ # What counts as invalid is governed by a {Policy}: underscores, single-label
27
+ # names, trailing dots, private Public Suffix List entries, and script
28
+ # mixing are all policy choices rather than hardcoded rules.
29
+ module Name
30
+ MAX_DOMAIN_LENGTH = 253
31
+ MAX_LABEL_LENGTH = 63
32
+ MAX_LABELS = 127
33
+
34
+ # Hard cap on the raw input, checked before any IDN conversion or Public
35
+ # Suffix parsing so untrusted callers can't force unbounded work with a huge
36
+ # string. No real name comes close: the ASCII form maxes out at 253 octets,
37
+ # and even a pathological all-multibyte IDN stays well under this in its
38
+ # Unicode form. This is a denial-of-service guard, not the DNS length rule
39
+ # (that is MAX_DOMAIN_LENGTH, enforced on the converted ASCII form).
40
+ MAX_INPUT_BYTES = 1024
41
+
42
+ LDH_LABEL = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/i
43
+ LDH_UNDERSCORE_LABEL = /\A[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?\z/i
44
+
45
+ NUMERIC = /\A\d+\z/
46
+
47
+ # Special-use TLDs that are not delegated in the Public Suffix List but are
48
+ # legitimate in some contexts. Accepted only when a policy sets
49
+ # allow_reserved_tld (:lenient does). Sources: RFC 6761 (example, invalid,
50
+ # localhost, test), RFC 6762 (local), RFC 7686 (onion), and ICANN's
51
+ # private-use ".internal".
52
+ RESERVED_TLDS = Set[
53
+ "example", "invalid", "localhost", "test", "local", "onion", "internal"
54
+ ].freeze
55
+
56
+ # The one-pass normalized view of a subject. `kind` is always set; the
57
+ # remaining fields are populated only for the :hostname kind (the other
58
+ # kinds fail fast with a single reason and need no structural analysis).
59
+ # input - the original string (or nil)
60
+ # name - input with a single trailing "root" dot removed
61
+ # ascii - punycode form of name, or nil if unconvertible
62
+ # unicode - Unicode form of name (falls back to name)
63
+ # labels - ascii split on ".", preserving empty edge labels
64
+ # psl - PublicSuffix::Domain, or nil if not registrable
65
+ Normalized = Struct.new(
66
+ :input, :name, :ascii, :unicode, :labels, :kind, :psl,
67
+ keyword_init: true
68
+ )
69
+
70
+ module_function
71
+
72
+ # Classify and pre-parse a subject exactly once. Cheap for the non-hostname
73
+ # kinds, which return early before any IDN/PSL work. `policy` only affects
74
+ # whether private Public Suffix List entries count as suffixes.
75
+ def normalize(subject, policy = Policy.ca_baseline)
76
+ return Normalized.new(input: subject, kind: :nil) if subject.nil?
77
+ return Normalized.new(input: subject, kind: :not_a_string) unless subject.is_a?(String)
78
+ return Normalized.new(input: subject, kind: :oversized) if subject.bytesize > MAX_INPUT_BYTES
79
+ return Normalized.new(input: subject, kind: :empty) if subject.empty?
80
+ return Normalized.new(input: subject, kind: :whitespace) if subject.match?(/\s/)
81
+
82
+ kind = classify(subject)
83
+ return Normalized.new(input: subject, kind: kind) unless kind == :hostname
84
+
85
+ name = subject.chomp(".") # a single trailing dot denotes the same FQDN
86
+ ascii = IDN.to_ascii(name)
87
+ unicode = IDN.to_unicode(name) || name
88
+ Normalized.new(
89
+ input: subject,
90
+ name: name,
91
+ ascii: ascii,
92
+ unicode: unicode,
93
+ labels: ascii ? ascii.split(".", -1) : [],
94
+ kind: :hostname,
95
+ psl: parse_psl(unicode, policy)
96
+ )
97
+ end
98
+
99
+ # True when subject is a structurally valid FQDN under the given policy.
100
+ # Wildcards, IP literals, and reverse-zone names are never valid here.
101
+ def valid?(subject, policy: :ca_baseline)
102
+ reasons(subject, policy: policy).empty?
103
+ end
104
+
105
+ # Structured reasons the subject is not a valid FQDN, as Reason objects.
106
+ # An empty array means valid. This is the single source of truth.
107
+ def reasons(subject, policy: :ca_baseline)
108
+ pol = Policy.coerce(policy)
109
+ reasons_for(normalize(subject, pol), policy: pol)
110
+ end
111
+
112
+ # Reasons for an already-normalized subject, so callers that hold a
113
+ # Normalized (such as a Subject) don't re-parse.
114
+ def reasons_for(norm, policy: :ca_baseline)
115
+ pol = Policy.coerce(policy)
116
+ case norm.kind
117
+ when :nil then [reason(:nil, "is nil")]
118
+ when :not_a_string then [reason(:not_a_string, "is not a string")]
119
+ when :oversized then [reason(:input_too_long, "exceeds the maximum input length of #{MAX_INPUT_BYTES} bytes")]
120
+ when :empty then [reason(:empty, "is empty")]
121
+ when :whitespace then [reason(:whitespace, "contains whitespace")]
122
+ when :wildcard then [reason(:wildcard, "is a wildcard (use valid_wildcard?)")]
123
+ when :ip then [reason(:ip_address, "is an IP address, not a host name")]
124
+ when :reverse_zone then [reason(:reverse_zone, "is a reverse-DNS zone name, not a host name")]
125
+ else hostname_reasons(norm, pol)
126
+ end
127
+ end
128
+
129
+ # True when subject is itself a recognized public suffix (eTLD), including
130
+ # multi-label suffixes like "co.uk". A full registrable name such as
131
+ # "example.com" is NOT a public suffix and returns false. ICANN suffixes
132
+ # only, unless a policy including private suffixes is passed.
133
+ def valid_tld?(subject, policy: :ca_baseline)
134
+ return false unless subject.is_a?(String)
135
+ return false if subject.empty?
136
+
137
+ pol = Policy.coerce(policy)
138
+ unicode = IDN.to_unicode(subject) || subject
139
+ PublicSuffix.parse(unicode, default_rule: nil, ignore_private: !pol.include_private_suffixes)
140
+ false # parsed as a registrable domain, so it is not a bare suffix
141
+ rescue PublicSuffix::DomainNotAllowed
142
+ true # only a bare public suffix parses to "not allowed"
143
+ rescue PublicSuffix::Error
144
+ false
145
+ end
146
+
147
+ # True when subject is a wildcard name of the form "*.something".
148
+ def wildcard?(subject)
149
+ subject.is_a?(String) && subject.start_with?("*.")
150
+ end
151
+
152
+ # True when subject is a wildcard the Baseline Requirements permit:
153
+ # the "*" is the entire leftmost label, appears exactly once, and the
154
+ # remainder is a valid domain that is NOT itself a bare public suffix.
155
+ def valid_wildcard?(subject, policy: :ca_baseline)
156
+ return false unless wildcard?(subject)
157
+ return false if subject.count("*") != 1
158
+
159
+ pol = Policy.coerce(policy)
160
+ valid_wildcard_remainder?(normalize(subject[2..], pol), policy: pol)
161
+ end
162
+
163
+ # Whether an already-normalized wildcard remainder is eligible to carry a
164
+ # "*." label: it must validate, and it must have something to the left of
165
+ # its public suffix. For PSL names that means an sld (registrable != tld);
166
+ # for policy-permitted names with no PSL entry (a reserved TLD under
167
+ # allow_reserved_tld) it means at least two labels. Shared so Subject and
168
+ # valid_wildcard? normalize the remainder only once.
169
+ def valid_wildcard_remainder?(norm, policy: :ca_baseline)
170
+ pol = Policy.coerce(policy)
171
+ return false unless reasons_for(norm, policy: pol).empty?
172
+
173
+ registrable = norm.psl&.domain
174
+ return registrable != norm.psl.tld unless registrable.nil?
175
+
176
+ norm.labels.size >= 2
177
+ end
178
+
179
+ # The registrable domain ("example.co.uk" from "www.example.co.uk"), or nil.
180
+ def registrable_domain(subject, policy: :ca_baseline)
181
+ normalize(subject, Policy.coerce(policy)).psl&.domain
182
+ end
183
+
184
+ # The public suffix ("co.uk" from "www.example.co.uk"), or nil.
185
+ def public_suffix(subject, policy: :ca_baseline)
186
+ normalize(subject, Policy.coerce(policy)).psl&.tld
187
+ end
188
+
189
+ # --- internals -------------------------------------------------------
190
+
191
+ def classify(subject)
192
+ if subject.start_with?("*.") then :wildcard
193
+ elsif IP.ip?(subject) then :ip
194
+ elsif IP.reverse_zone?(subject) then :reverse_zone
195
+ else :hostname
196
+ end
197
+ end
198
+
199
+ def parse_psl(unicode, policy)
200
+ PublicSuffix.parse(unicode, default_rule: nil, ignore_private: !policy.include_private_suffixes)
201
+ rescue PublicSuffix::Error
202
+ nil
203
+ end
204
+
205
+ def hostname_reasons(norm, policy)
206
+ errors = []
207
+ name = norm.name
208
+
209
+ # Dot-structure checks run on the pre-IDN name, because IDN conversion
210
+ # silently drops empty edge labels and would hide a leading dot.
211
+ errors << reason(:leading_dot, "starts with a dot") if name.start_with?(".")
212
+ errors << reason(:trailing_dot, "ends with a dot") if name.end_with?(".")
213
+ errors << reason(:empty_label, "has an empty label (consecutive or edge dots)") if name.include?("..")
214
+ if !policy.allow_trailing_dot && norm.input.end_with?(".")
215
+ errors << reason(:trailing_dot, "ends with a dot")
216
+ end
217
+
218
+ if norm.ascii.nil?
219
+ errors << reason(:not_convertible, "is not convertible to a valid ASCII form")
220
+ return errors.uniq
221
+ end
222
+
223
+ errors << reason(:too_long, "exceeds #{MAX_DOMAIN_LENGTH} characters") if norm.ascii.length > MAX_DOMAIN_LENGTH
224
+
225
+ labels = norm.labels
226
+ single_label = policy.allow_single_label && labels.size == 1
227
+ errors << reason(:too_few_labels, "must have at least two labels") if labels.size < 2 && !single_label
228
+ errors << reason(:too_many_labels, "exceeds #{MAX_LABELS} labels") if labels.size > MAX_LABELS
229
+
230
+ pattern = policy.allow_underscore ? LDH_UNDERSCORE_LABEL : LDH_LABEL
231
+ labels.each { |label| append_label_reason(errors, label, pattern) }
232
+
233
+ append_tld_reasons(errors, norm, single_label, policy)
234
+
235
+ if policy.require_single_script && IDN.mixed_script_unicode?(norm.unicode)
236
+ errors << reason(:mixed_script, "mixes scripts within a label (possible homograph)")
237
+ end
238
+
239
+ errors.uniq
240
+ end
241
+
242
+ # TLD-level checks only apply when there is a TLD (i.e. more than one label,
243
+ # or single-label names aren't permitted).
244
+ def append_tld_reasons(errors, norm, single_label, policy)
245
+ return if single_label
246
+
247
+ tld = norm.labels.last
248
+ errors << reason(:numeric_tld, "has an all-numeric TLD") if tld&.match?(NUMERIC)
249
+
250
+ return unless norm.psl.nil?
251
+ return if policy.allow_reserved_tld && reserved_tld?(tld)
252
+
253
+ errors << reason(:unknown_tld, "has a TLD that is not in the Public Suffix List")
254
+ end
255
+
256
+ def reserved_tld?(tld)
257
+ !tld.nil? && RESERVED_TLDS.include?(tld.downcase)
258
+ end
259
+
260
+ def append_label_reason(errors, label, pattern)
261
+ if label.empty?
262
+ errors << reason(:empty_label, "has an empty label (consecutive or edge dots)")
263
+ elsif label.length > MAX_LABEL_LENGTH
264
+ errors << reason(:label_too_long, "has a label longer than #{MAX_LABEL_LENGTH} characters: #{label.inspect}", label)
265
+ elsif !label.match?(pattern)
266
+ errors << reason(:label_invalid, "has an invalid label: #{label.inspect}", label)
267
+ end
268
+ end
269
+
270
+ def reason(code, message, label = nil)
271
+ Reason.new(code: code, message: message, label: label)
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DomainSanity
4
+ # What counts as "valid" depends on who is asking. A certificate authority,
5
+ # a DNS zone editor, and a lenient form validator disagree about underscores,
6
+ # single-label names, private Public Suffix List entries, and script mixing.
7
+ #
8
+ # Policy captures those choices as data instead of letting them accrete as
9
+ # boolean keyword arguments on every method. Pass a preset symbol, a Hash of
10
+ # overrides, or a Policy instance anywhere a `policy:` argument is accepted;
11
+ # {coerce} turns all three into a Policy.
12
+ #
13
+ # DomainSanity.valid?("_dmarc.example.com", policy: :dns_zone)
14
+ # DomainSanity.valid?("host", policy: { allow_single_label: true })
15
+ #
16
+ # Policy only governs how a *host name* is judged; it never makes valid?
17
+ # accept an IP, wildcard, or reverse-zone subject (those are distinct kinds
18
+ # with their own predicates).
19
+ class Policy
20
+ # Each field defaults to the strict (CA-style) choice: false, except
21
+ # allow_trailing_dot, since a single root dot denotes the same name.
22
+ DEFAULTS = {
23
+ allow_underscore: false, # permit "_" in labels (RFC 952/1123 forbid it)
24
+ allow_single_label: false, # permit a bare host with no TLD ("intranet")
25
+ allow_trailing_dot: true, # accept & normalize one trailing root dot
26
+ include_private_suffixes: false, # treat private PSL entries (github.io) as suffixes
27
+ allow_reserved_tld: false, # accept RFC 6761/6762/7686 special-use TLDs (.test, .local, .onion, .internal)
28
+ require_single_script: false # reject confusable mixed-script IDN labels
29
+ }.freeze
30
+
31
+ FIELDS = DEFAULTS.keys.freeze
32
+
33
+ # The names of the built-in presets.
34
+ PRESET_NAMES = %i[ca_baseline dns_zone lenient].freeze
35
+
36
+ attr_reader(*FIELDS)
37
+
38
+ def initialize(**opts)
39
+ unknown = opts.keys - FIELDS
40
+ raise ArgumentError, "unknown policy option(s): #{unknown.join(", ")}" unless unknown.empty?
41
+
42
+ DEFAULTS.each { |field, default| instance_variable_set(:"@#{field}", opts.fetch(field, default)) }
43
+ freeze
44
+ end
45
+
46
+ # A copy with some fields overridden.
47
+ def with(**overrides)
48
+ self.class.new(**to_h.merge(overrides))
49
+ end
50
+
51
+ def to_h
52
+ FIELDS.to_h { |field| [field, public_send(field)] }
53
+ end
54
+
55
+ def ==(other)
56
+ other.is_a?(Policy) && other.to_h == to_h
57
+ end
58
+ alias_method :eql?, :==
59
+
60
+ def hash
61
+ to_h.hash
62
+ end
63
+
64
+ class << self
65
+ # Strict, certificate-authority-style defaults.
66
+ def ca_baseline
67
+ new
68
+ end
69
+
70
+ # DNS zone editing: underscores, single-label hosts, and private suffixes
71
+ # are all fine here. Reserved special-use TLDs are still rejected - a real
72
+ # zone shouldn't contain them.
73
+ def dns_zone
74
+ new(allow_underscore: true, allow_single_label: true, include_private_suffixes: true)
75
+ end
76
+
77
+ # Everything permissive, including RFC 6761/6762/7686 special-use TLDs
78
+ # (.test, .local, .onion, .internal). Script safety stays off to avoid
79
+ # false positives.
80
+ def lenient
81
+ new(
82
+ allow_underscore: true,
83
+ allow_single_label: true,
84
+ include_private_suffixes: true,
85
+ allow_reserved_tld: true
86
+ )
87
+ end
88
+
89
+ def presets
90
+ PRESET_NAMES
91
+ end
92
+
93
+ def preset(name)
94
+ case name
95
+ when :ca_baseline then ca_baseline
96
+ when :dns_zone then dns_zone
97
+ when :lenient then lenient
98
+ else raise ArgumentError, "unknown policy preset: #{name.inspect}"
99
+ end
100
+ end
101
+
102
+ # Turn a Symbol preset, Hash of overrides, Policy, or nil into a Policy.
103
+ def coerce(arg)
104
+ case arg
105
+ when Policy then arg
106
+ when Symbol then preset(arg)
107
+ when Hash then ca_baseline.with(**arg)
108
+ when nil then ca_baseline
109
+ else raise ArgumentError, "cannot coerce #{arg.inspect} into a Policy"
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DomainSanity
4
+ # A single, structured reason a subject failed validation.
5
+ #
6
+ # `code` is a stable Symbol meant for programmatic branching (it does not
7
+ # change when the wording of a message is tweaked). `message` is the
8
+ # human-readable explanation. `label` is the specific offending DNS label
9
+ # when one applies (e.g. a label that is too long or malformed), and nil
10
+ # otherwise.
11
+ #
12
+ # Reason#to_s returns the message, so an array of reasons still interpolates
13
+ # and joins into readable text, while callers that need to react in code can
14
+ # switch on `code`.
15
+ Reason = Struct.new(:code, :message, :label, keyword_init: true) do
16
+ def to_s
17
+ message
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "name"
4
+ require_relative "ip"
5
+ require_relative "idn"
6
+ require_relative "policy"
7
+
8
+ module DomainSanity
9
+ # A classified view of one subject string. {Subject.for} inspects the input
10
+ # once and returns the concrete type that fits it - Hostname, Wildcard,
11
+ # IPSubject, ReverseZone, or MalformedSubject - so a method that only makes
12
+ # sense for one kind (registrable_domain for a host, public? for an IP) lives
13
+ # only on that type instead of returning nil on a god-object.
14
+ #
15
+ # Facts are computed lazily and memoized, so asking one question is cheap and
16
+ # asking many re-parses nothing. `valid?` means the same thing on every
17
+ # subject and matches DomainSanity.valid? exactly: true only for a
18
+ # structurally valid plain host name. Wildcards, IPs, and reverse zones are
19
+ # not "valid" in that sense and expose their own predicates instead.
20
+ class Subject
21
+ attr_reader :input, :policy
22
+
23
+ # Classify `input` under `policy` (a preset symbol, Hash, or Policy) and
24
+ # return the matching Subject subclass.
25
+ def self.for(input, policy: :ca_baseline)
26
+ pol = Policy.coerce(policy)
27
+ norm = Name.normalize(input, pol)
28
+ klass = TYPES.fetch(norm.kind, MalformedSubject)
29
+ klass.new(input, norm, pol)
30
+ end
31
+
32
+ def initialize(input, normalized, policy)
33
+ @input = input
34
+ @norm = normalized
35
+ @policy = policy
36
+ end
37
+
38
+ def kind
39
+ @norm.kind
40
+ end
41
+
42
+ # Only a structurally valid plain host name is "valid"; every other kind
43
+ # overrides nothing and stays false. See Hostname.
44
+ def valid?
45
+ false
46
+ end
47
+
48
+ def hostname?
49
+ false
50
+ end
51
+
52
+ def wildcard?
53
+ false
54
+ end
55
+
56
+ def valid_wildcard?
57
+ false
58
+ end
59
+
60
+ def ip?
61
+ false
62
+ end
63
+
64
+ def reverse_zone?
65
+ false
66
+ end
67
+
68
+ def reasons
69
+ @reasons ||= Name.reasons_for(@norm, policy: @policy).freeze
70
+ end
71
+
72
+ def punycode?
73
+ return @punycode if defined?(@punycode)
74
+
75
+ @punycode = IDN.punycode?(@input)
76
+ end
77
+
78
+ def ascii
79
+ return @ascii if defined?(@ascii)
80
+
81
+ @ascii = IDN.to_ascii(@input)
82
+ end
83
+
84
+ def unicode
85
+ return @unicode if defined?(@unicode)
86
+
87
+ @unicode = IDN.to_unicode(@input)
88
+ end
89
+
90
+ # A uniform snapshot: every subject exposes the same keys, with nil where a
91
+ # fact does not apply to this kind. (The typed *methods* stay kind-specific -
92
+ # an IPSubject still has no #registrable_domain - but the serialized shape is
93
+ # stable so downstream `present?`/`dig` checks work the same for any kind.)
94
+ # Subclasses fill the optional slots via {type_facts}.
95
+ def to_h
96
+ {
97
+ input: @input,
98
+ kind: kind,
99
+ valid: valid?,
100
+ wildcard: wildcard?,
101
+ valid_wildcard: valid_wildcard?,
102
+ ip: ip?,
103
+ reserved_ip: nil,
104
+ public_ip: nil,
105
+ reverse_zone: reverse_zone?,
106
+ punycode: punycode?,
107
+ ascii: ascii,
108
+ unicode: unicode,
109
+ registrable_domain: nil,
110
+ public_suffix: nil,
111
+ reasons: reasons.map(&:to_s)
112
+ }.merge(type_facts)
113
+ end
114
+
115
+ # Kind-specific overrides for the optional to_h slots. Base has none.
116
+ def type_facts
117
+ {}
118
+ end
119
+ end
120
+
121
+ # Shared to_h slots for the kinds that carry a registrable domain (Hostname
122
+ # and Wildcard). Each including class supplies its own registrable_domain /
123
+ # public_suffix; this just maps them into the uniform to_h shape.
124
+ module RegistrableTypeFacts
125
+ def type_facts
126
+ {registrable_domain: registrable_domain, public_suffix: public_suffix}
127
+ end
128
+ end
129
+
130
+ # A candidate fully-qualified host name.
131
+ class Hostname < Subject
132
+ include RegistrableTypeFacts
133
+
134
+ def hostname?
135
+ true
136
+ end
137
+
138
+ def valid?
139
+ reasons.empty?
140
+ end
141
+
142
+ def registrable_domain
143
+ @norm.psl&.domain
144
+ end
145
+
146
+ def public_suffix
147
+ @norm.psl&.tld
148
+ end
149
+ end
150
+
151
+ # A "*.something" wildcard name.
152
+ class Wildcard < Subject
153
+ include RegistrableTypeFacts
154
+
155
+ def wildcard?
156
+ true
157
+ end
158
+
159
+ # Baseline-Requirements-valid wildcard. A wildcard is never a plain
160
+ # host name, so #valid? stays false; this is the predicate to ask. Derived
161
+ # from the single normalized remainder, so nothing re-parses.
162
+ def valid_wildcard?
163
+ return @valid_wildcard if defined?(@valid_wildcard)
164
+
165
+ @valid_wildcard = @input.count("*") == 1 &&
166
+ Name.valid_wildcard_remainder?(remainder, policy: @policy)
167
+ end
168
+
169
+ # The registrable domain / public suffix of the wildcard's base name.
170
+ def registrable_domain
171
+ remainder.psl&.domain
172
+ end
173
+
174
+ def public_suffix
175
+ remainder.psl&.tld
176
+ end
177
+
178
+ private
179
+
180
+ def remainder
181
+ @remainder ||= Name.normalize(@input[2..], @policy)
182
+ end
183
+ end
184
+
185
+ # A single IPv4 or IPv6 host address.
186
+ class IPSubject < Subject
187
+ def ip?
188
+ true
189
+ end
190
+
191
+ def reserved?
192
+ return @reserved if defined?(@reserved)
193
+
194
+ @reserved = IP.reserved?(@input)
195
+ end
196
+ alias_method :reserved_ip?, :reserved?
197
+
198
+ def public?
199
+ return @public if defined?(@public)
200
+
201
+ @public = IP.public?(@input)
202
+ end
203
+ alias_method :public_ip?, :public?
204
+
205
+ def type_facts
206
+ {reserved_ip: reserved?, public_ip: public?}
207
+ end
208
+ end
209
+
210
+ # An in-addr.arpa / ip6.arpa reverse-DNS zone name.
211
+ class ReverseZone < Subject
212
+ def reverse_zone?
213
+ true
214
+ end
215
+ end
216
+
217
+ # nil, empty, or whitespace-bearing input - no meaningful structure.
218
+ class MalformedSubject < Subject
219
+ end
220
+
221
+ class Subject
222
+ TYPES = {
223
+ hostname: Hostname,
224
+ wildcard: Wildcard,
225
+ ip: IPSubject,
226
+ reverse_zone: ReverseZone
227
+ }.freeze
228
+ end
229
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DomainSanity
4
+ VERSION = "0.1.0"
5
+ end