nondisposable 0.3.0 → 0.4.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.
@@ -16,4 +16,48 @@ Nondisposable.configure do |config|
16
16
  # Also match parent domains: an email at x.tempmail.com is blocked when
17
17
  # tempmail.com is on the list (checks up to 3 parent labels). Default: true
18
18
  # config.check_parent_domains = true
19
+
20
+ # ---- Catching typos ----
21
+ #
22
+ # A disposable address is someone hiding from you. A typo is someone who
23
+ # wanted to reach you and won't be able to: user@gmail.con is a real person
24
+ # whose account nobody can ever reach, because .con does not exist.
25
+ # Both checks below are OFF by default.
26
+
27
+ # Reject addresses whose TLD isn't in the IANA root zone (gmail.con,
28
+ # outlook.ed, and domains with no dot at all like example@gmailmcom).
29
+ # config.check_tld = true
30
+ #
31
+ # Accept a TLD delegated after your installed gem version shipped — so a
32
+ # stale snapshot can never leave a real customer stuck.
33
+ # config.additional_tlds = ['newtld']
34
+ #
35
+ # Refuse TLDs that are real but unwelcome (the historically free Freenom set):
36
+ # config.blocked_tlds = %w[tk ml ga cf gq]
37
+ #
38
+ # Or invert it into an allowlist. Careful: %w[es] turns away every .com
39
+ # customer you have.
40
+ # config.allowed_tlds = nil
41
+
42
+ # Reject addresses one keystroke from a well-known provider (gmail.co,
43
+ # gmial.com). A TLD check CANNOT catch these — .co, .cm and .om are Colombia,
44
+ # Cameroon and Oman, all real.
45
+ #
46
+ # Think before switching this on: a suggestion is a guess about intent, and a
47
+ # wrong guess stops a real person signing up with their real address.
48
+ # Nondisposable.suggestion_for(email) is always available and blocks nothing,
49
+ # which is the gentler way to use this — show the hint, let the human decide.
50
+ # config.reject_lookalike_domains = true
51
+ #
52
+ # How many edits still count as a typo. 1 is safe; 2 starts colliding with
53
+ # genuinely different domains. 0 disables suggestions entirely.
54
+ # config.lookalike_distance = 1
55
+ #
56
+ # Providers we missed, or your own domain. Adding one both makes it a
57
+ # suggestion candidate AND stops its users being told they made a typo.
58
+ # config.additional_email_providers = ['yourcompany.com']
59
+
60
+ # config.invalid_tld_error_message = "doesn't look like a real email address"
61
+ # config.blocked_tld_error_message = "domain ending is not allowed"
62
+ # config.lookalike_error_message = "looks like a typo. Did you mean %{suggestion}?"
19
63
  end
@@ -3,6 +3,23 @@
3
3
  module ActiveModel
4
4
  module Validations
5
5
  class NondisposableValidator < EachValidator
6
+ # Three independent questions about one address, in the order a human
7
+ # would ask them, and AT MOST ONE error however many of them fire —
8
+ # stacking "provider is not allowed" on top of "did you mean gmail.com?"
9
+ # helps nobody:
10
+ #
11
+ # 1. Is this a throwaway provider? (always on — the gem's whole job)
12
+ # 2. Can we name the address they meant? (config.reject_lookalike_domains,
13
+ # or any invalid TLD we can correct — see below)
14
+ # 3. Is the TLD real, and allowed? (config.check_tld)
15
+ #
16
+ # WHY A NAMEABLE CORRECTION OUTRANKS THE GENERIC TLD ERROR
17
+ #
18
+ # `user@gmail.con` fails both 2 and 3. "Doesn't look like a real email
19
+ # address" is true but useless; "Did you mean user@gmail.com?" is the same
20
+ # verdict with the fix attached. So whenever we can name a correction we
21
+ # use that message, whether the TLD was invalid or merely lookalike. The
22
+ # rule is: a suggestion, if we have one, always phrases the rejection.
6
23
  def validate_each(record, attribute, value)
7
24
  return if value.blank?
8
25
 
@@ -10,9 +27,36 @@ module ActiveModel
10
27
  domain = value.to_s.split('@').last&.downcase
11
28
  return if domain.nil? # Invalid email format
12
29
 
30
+ config = Nondisposable.configuration
31
+
13
32
  if Nondisposable::DisposableDomain.disposable?(domain)
14
- record.errors.add(attribute, options[:message] || Nondisposable.configuration.error_message)
33
+ return reject(record, attribute, options[:message] || config.error_message)
34
+ end
35
+
36
+ return unless config.check_tld || config.reject_lookalike_domains
37
+ # No "@" at all is a malformed value, and this validator has never
38
+ # judged shape — that is what `format:` is for (see the README). Note
39
+ # `example@gmailmcom` DOES have one: a domain missing its dot is a real
40
+ # address at an impossible domain, which is very much our business.
41
+ return unless value.to_s.include?('@')
42
+ return unless Nondisposable::Tld.judgeable?(value)
43
+
44
+ # nil here means the domain has NO TLD (`example@gmailmcom` — a
45
+ # production-shaped typo that missed the dot), which fails "must end in a real TLD"
46
+ # just as surely as `.con` does.
47
+ tld = Nondisposable::Tld.extract(value)
48
+ invalid = config.check_tld && (tld.nil? || !Nondisposable::Tld.valid?(tld))
49
+ blocked = config.check_tld && !invalid && Nondisposable::Tld.blocked?(tld)
50
+
51
+ if invalid || config.reject_lookalike_domains
52
+ suggestion = Nondisposable::Suggestion.for(value)
53
+ if suggestion
54
+ return reject(record, attribute, config.lookalike_error_message.sub('%{suggestion}', suggestion))
55
+ end
15
56
  end
57
+
58
+ return reject(record, attribute, config.invalid_tld_error_message) if invalid
59
+ return reject(record, attribute, config.blocked_tld_error_message) if blocked
16
60
  rescue StandardError => e
17
61
  if Nondisposable.configuration.on_check_failure == :reject
18
62
  Rails.logger.error "[nondisposable] Nondisposable validation error: #{e.message} — rejecting #{attribute} (on_check_failure = :reject)"
@@ -22,6 +66,13 @@ module ActiveModel
22
66
  end
23
67
  end
24
68
  end
69
+
70
+ private
71
+
72
+ def reject(record, attribute, message)
73
+ record.errors.add(attribute, message)
74
+ nil
75
+ end
25
76
  end
26
77
 
27
78
  module HelperMethods
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nondisposable
4
+ # "Did you mean gmail.com?"
5
+ #
6
+ # A TLD check catches the typos that produce a domain ending which cannot
7
+ # exist — `.con`, `.cpm`, `.ocm`. It structurally cannot catch the far more
8
+ # common ones, because `.co` (Colombia), `.cm` (Cameroon) and `.om` (Oman) are
9
+ # all real, delegated TLDs, as are `.se` and `.es`. `user@gmail.co` is a
10
+ # perfectly well-formed address at a perfectly real TLD, and it is still
11
+ # almost always a finger that slipped off the `m`.
12
+ #
13
+ # So this layer asks a different question: is this domain one keystroke away
14
+ # from a well-known email provider, while not being one itself?
15
+ #
16
+ # HOW IT DECIDES
17
+ #
18
+ # Optimal string alignment distance (Damerau-Levenshtein restricted to
19
+ # adjacent transpositions) against data/email_providers.txt. Transposition
20
+ # matters more than it looks: `gmial.com` is the single most common Gmail
21
+ # misspelling, and plain Levenshtein scores it 2 while a human sees one
22
+ # mistake. Under OSA it scores 1, alongside `gmai.com`, `gmail.co` and
23
+ # `gmail.con`.
24
+ #
25
+ # WHY THE DEFAULT THRESHOLD IS 1, AND WHY THIS IS OFF BY DEFAULT
26
+ #
27
+ # Every suggestion is a guess about intent, and a wrong guess wired to
28
+ # `reject_lookalike_domains` stops a real person from signing up with their
29
+ # real address. One edit is the distance at which a guess is safe enough to
30
+ # act on; at two, legitimately distinct domains start colliding. The exact
31
+ # match check runs first and always wins, which is why the provider list has
32
+ # to be generous — see the header of data/email_providers.txt.
33
+ #
34
+ # Suggesting is always available and never blocks:
35
+ #
36
+ # Nondisposable.suggestion_for("someone@gmial.com") # => "someone@gmail.com"
37
+ # Nondisposable.suggestion_for("someone@gmail.com") # => nil
38
+ #
39
+ # Blocking on it is a separate, deliberate opt-in
40
+ # (`config.reject_lookalike_domains`), because "probably a typo" is a weaker
41
+ # claim than "this TLD does not exist" and deserves a weaker remedy.
42
+ module Suggestion
43
+ LIST_PATH = File.expand_path('../../data/email_providers.txt', __dir__)
44
+
45
+ class << self
46
+ # The full corrected address, or nil when we have nothing useful to say.
47
+ def for(email)
48
+ email = email.to_s.strip
49
+ local, _, domain = email.rpartition('@')
50
+ return nil if local.empty? || domain.empty?
51
+
52
+ corrected = correct_domain(domain.downcase)
53
+ return nil if corrected.nil?
54
+
55
+ "#{local}@#{corrected}"
56
+ end
57
+
58
+ # The corrected DOMAIN alone, or nil. Split out so a host can offer
59
+ # "did you mean …?" next to a domain field, not just an email field.
60
+ def correct_domain(domain)
61
+ domain = domain.to_s.strip.downcase.delete_suffix('.')
62
+ return nil if domain.empty?
63
+ # A real provider is never a typo of another real provider.
64
+ return nil if providers.include?(domain)
65
+
66
+ threshold = Nondisposable.configuration.lookalike_distance.to_i
67
+ return nil if threshold < 1
68
+
69
+ best = nil
70
+ best_distance = threshold + 1
71
+
72
+ providers.each do |candidate|
73
+ # Cheap rejection before the O(n*m) walk: an edit changes the length
74
+ # by at most 1 per operation, so anything further apart than the
75
+ # threshold in length alone cannot be within it.
76
+ next if (candidate.length - domain.length).abs > threshold
77
+
78
+ distance = osa_distance(domain, candidate, best_distance)
79
+ next if distance >= best_distance
80
+
81
+ best = candidate
82
+ best_distance = distance
83
+ break if distance == 1 # nothing can beat one edit; stop early
84
+ end
85
+
86
+ best
87
+ end
88
+
89
+ # Every domain we would suggest, as a frozen Set: the bundled list plus
90
+ # anything the host added. Memoised per configuration object so a host
91
+ # changing config in a test or console is picked up.
92
+ def providers
93
+ config = Nondisposable.configuration
94
+ extra = Array(config.additional_email_providers).map { |d| d.to_s.strip.downcase }
95
+ cache_key = extra.hash
96
+
97
+ if @cache_key != cache_key || @providers.nil?
98
+ @providers = (bundled_providers + extra).to_set.freeze
99
+ @cache_key = cache_key
100
+ end
101
+
102
+ @providers
103
+ end
104
+
105
+ def reload!
106
+ @providers = nil
107
+ @bundled_providers = nil
108
+ @cache_key = nil
109
+ self
110
+ end
111
+
112
+ private
113
+
114
+ def bundled_providers
115
+ @bundled_providers ||= File.readlines(LIST_PATH, chomp: true).filter_map do |line|
116
+ value = line.strip.downcase
117
+ value unless value.empty? || value.start_with?('#')
118
+ end
119
+ end
120
+
121
+ # Optimal string alignment distance, with an early bail-out.
122
+ #
123
+ # Two rows instead of a full matrix (we only ever need the previous two),
124
+ # and if the best cell in a row already exceeds `cutoff` no later row can
125
+ # come back under it — the distance only grows — so we stop. With ~250
126
+ # candidates per lookup and a threshold of 1, almost every candidate exits
127
+ # on the length check above or within the first row or two.
128
+ # (Empty inputs need no special case: with an empty `a` the loop never
129
+ # runs and the seeded row already holds the right answer, b.length.)
130
+ def osa_distance(a, b, cutoff)
131
+ prev_prev = nil
132
+ prev = (0..b.length).to_a
133
+ current = Array.new(b.length + 1)
134
+
135
+ a.each_char.with_index do |a_char, i|
136
+ current[0] = i + 1
137
+ row_min = current[0]
138
+
139
+ b.each_char.with_index do |b_char, j|
140
+ cost = a_char == b_char ? 0 : 1
141
+ value = [
142
+ current[j] + 1, # insertion
143
+ prev[j + 1] + 1, # deletion
144
+ prev[j] + cost # substitution
145
+ ].min
146
+
147
+ # Transposition: the one that makes `gmial` a single mistake.
148
+ if i.positive? && j.positive? && a_char == b[j - 1] && a[i - 1] == b_char
149
+ value = [value, prev_prev[j - 1] + cost].min
150
+ end
151
+
152
+ current[j + 1] = value
153
+ row_min = value if value < row_min
154
+ end
155
+
156
+ return cutoff if row_min >= cutoff
157
+
158
+ prev_prev = prev
159
+ prev = current.dup
160
+ end
161
+
162
+ prev[b.length]
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Nondisposable
6
+ # Is the bit after the last dot a real top-level domain?
7
+ #
8
+ # This is a different question from "is this a disposable provider", and it
9
+ # catches a different kind of bad address: the typo. `user@gmail.con` is not a
10
+ # throwaway — it is a real person who will never receive their confirmation
11
+ # email, because `.con` does not exist and never has. Those accounts are born
12
+ # dead: nobody can reach the user, and the user cannot recover the account.
13
+ #
14
+ # WHY A BUNDLED LIST AND NOT A DEPENDENCY
15
+ #
16
+ # The list is the IANA root zone database — the authoritative register of every
17
+ # delegated TLD on the internet, ~1,438 entries in about 9 KB:
18
+ #
19
+ # https://data.iana.org/TLD/tlds-alpha-by-domain.txt
20
+ #
21
+ # Every "TLD list" repository on GitHub is a scrape of that file, so we go to
22
+ # the source. The `tld` gem last shipped in 2014 and its list predates roughly
23
+ # 1,200 of today's TLDs. `public_suffix` is excellent and alive, but it answers
24
+ # "is this a valid public suffix" (which includes private entries like
25
+ # `github.io`) and it would pull the whole Public Suffix List in as a runtime
26
+ # dependency — the same trade-off DisposableDomain::PARENT_MATCH_DEPTH already
27
+ # declined, for the same reason.
28
+ #
29
+ # WHY IN MEMORY AND NOT IN THE DATABASE
30
+ #
31
+ # Disposable domains live in a table because there are 8,000+ of them and they
32
+ # change every few days. TLDs are 1,438 strings that change a handful of times
33
+ # a year. A frozen Set costs ~100 KB of process memory, answers in O(1) with no
34
+ # query per signup, needs no migration, and — unlike a table — cannot be empty
35
+ # on a fresh install. Refresh it with `rake nondisposable:tlds:update` (or
36
+ # `Nondisposable::TldListUpdater.update`) when cutting a release.
37
+ #
38
+ # STALENESS, AND WHY IT CANNOT LOCK ANYONE OUT
39
+ #
40
+ # The failure mode of a stale list is rejecting somebody whose TLD was
41
+ # delegated after the snapshot. `Nondisposable.configuration.additional_tlds`
42
+ # is the escape hatch: a host can accept a brand-new TLD immediately, without
43
+ # waiting for a gem release. And the check is opt-in (`config.check_tld`), so
44
+ # upgrading the gem never silently starts rejecting anybody.
45
+ module Tld
46
+ # The vendored IANA snapshot. Its first line is IANA's own version header,
47
+ # kept verbatim so the provenance and date of the data ship with it.
48
+ LIST_PATH = File.expand_path('../../data/iana_tlds.txt', __dir__)
49
+
50
+ # The names RFC 6761 and RFC 2606 reserve so they can NEVER be delegated.
51
+ # They are therefore absent from the root zone, and `check_tld` rejects
52
+ # them — which is right for a signup form, because no human types
53
+ # `me@home.test`, and an address there could never receive the confirmation
54
+ # email anyway.
55
+ #
56
+ # It is also, on the day you switch `check_tld` on, why half your test suite
57
+ # goes red: fixtures live at `user@example.test` for exactly the same reason
58
+ # the names are reserved. That is a configuration question, not a bug, and
59
+ # this constant is here so the answer reads like a sentence:
60
+ #
61
+ # config.additional_tlds = Nondisposable::Tld::SPECIAL_USE if Rails.env.local?
62
+ #
63
+ # Scope it to your non-production environments. Somewhere in the world
64
+ # somebody is running an app that really does deliver mail inside `.local`;
65
+ # if that is you, add it in production too and you are the exception that
66
+ # proves why this is configuration.
67
+ SPECIAL_USE = %w[test example invalid localhost local onion].freeze
68
+
69
+ class << self
70
+ # Every TLD IANA has delegated, downcased, as a frozen Set.
71
+ # Memoized: read once per process, never re-read.
72
+ def all
73
+ @all ||= parse(File.readlines(LIST_PATH, chomp: true)).freeze
74
+ end
75
+
76
+ # IANA's own version string for the bundled snapshot, e.g.
77
+ # "2026082301, Last Updated Mon Aug 24 07:07:01 2026 UTC". Useful in a
78
+ # health check to see how old your list is.
79
+ def version
80
+ @version ||= begin
81
+ header = File.open(LIST_PATH, &:readline).to_s.strip
82
+ header.start_with?('#') ? header.sub(/\A#\s*Version\s*/i, '') : nil
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ end
87
+
88
+ # The TLD of an email address or a bare domain, downcased.
89
+ #
90
+ # nil means THERE IS NO TLD HERE — which is not the same as "we can't
91
+ # tell". `example@gmailmcom` (the dot missed entirely) and
92
+ # `you@localhost` both land here, and under check_tld both are rejected:
93
+ # a domain with no TLD cannot end in a real one. Ask #judgeable? first if
94
+ # you need to tell that apart from an address this gem has no opinion on.
95
+ def extract(email_or_domain)
96
+ domain = normalize(email_or_domain)
97
+ return nil if domain.empty?
98
+
99
+ labels = domain.split('.')
100
+ return nil if labels.size < 2
101
+
102
+ tld = labels.last
103
+ tld.empty? ? nil : tld
104
+ end
105
+
106
+ # Is this an address the TLD rules have anything to say about?
107
+ #
108
+ # False for the two shapes where "what is the TLD" is the wrong question
109
+ # rather than a question with a bad answer: an empty domain, and an IP
110
+ # literal (`user@192.168.0.1`, `user@[10.0.0.1]`). Both are left alone —
111
+ # deciding whether to accept an IP-literal address is a format policy, and
112
+ # a validator called `check_tld` has no business making it.
113
+ def judgeable?(email_or_domain)
114
+ domain = normalize(email_or_domain)
115
+ return false if domain.empty?
116
+ return false if domain.start_with?('[') || domain.match?(/\A[\d.]+\z/)
117
+
118
+ true
119
+ end
120
+
121
+ # Is this TLD in the root zone (or in the host's additional_tlds)?
122
+ #
123
+ # ⚠️ Non-ASCII TLDs always answer true. IANA lists internationalised TLDs
124
+ # in punycode (`XN--FIQS8S`), and converting `中国` to that form needs an
125
+ # IDN library we deliberately do not depend on. Rather than reject every
126
+ # unicode address, we decline to judge them: a false accept is a nuisance,
127
+ # a false reject is a locked-out human. Punycode-form addresses, which is
128
+ # what mail clients actually send, are checked normally.
129
+ def valid?(tld)
130
+ tld = tld.to_s.downcase
131
+ return false if tld.empty?
132
+ return true unless tld.ascii_only?
133
+
134
+ all.include?(tld) || configured(:additional_tlds).include?(tld)
135
+ end
136
+
137
+ # Has the host explicitly blocked this TLD — either by naming it in
138
+ # `blocked_tlds`, or by naming everything else in `allowed_tlds`?
139
+ def blocked?(tld)
140
+ tld = tld.to_s.downcase
141
+ return false if tld.empty?
142
+
143
+ allowed = configured(:allowed_tlds)
144
+ return true if allowed && !allowed.include?(tld)
145
+
146
+ configured(:blocked_tlds).include?(tld)
147
+ end
148
+
149
+ # Reset the memoized list. Called by the updater after it rewrites the
150
+ # file, and by tests.
151
+ def reload!
152
+ @all = nil
153
+ @version = nil
154
+ self
155
+ end
156
+
157
+ private
158
+
159
+ # The domain half, trimmed, downcased, and without the trailing dot a
160
+ # fully-qualified name may carry (`gmail.com.` is the same host).
161
+ def normalize(email_or_domain)
162
+ email_or_domain.to_s.split('@').last.to_s.strip.downcase.delete_suffix('.')
163
+ end
164
+
165
+ # Skips IANA's comment header and any blank lines.
166
+ def parse(lines)
167
+ lines.each_with_object(Set.new) do |line, set|
168
+ value = line.to_s.strip.downcase
169
+ next if value.empty? || value.start_with?('#')
170
+
171
+ set << value
172
+ end
173
+ end
174
+
175
+ # Config lists are host-supplied, so normalise on every read rather than
176
+ # trusting them to be lowercase, dotless strings. `.tk` and `TK` both work.
177
+ def configured(key)
178
+ value = Nondisposable.configuration.public_send(key)
179
+ return nil if value.nil?
180
+
181
+ Set.new(Array(value).map { |tld| tld.to_s.strip.downcase.delete_prefix('.') })
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module Nondisposable
7
+ # Refreshes the vendored IANA TLD snapshot from the root zone database.
8
+ #
9
+ # ⚠️ This is a MAINTENANCE task, not a runtime one — the deliberate opposite of
10
+ # DomainListUpdater. That one runs in your app, on a schedule, because
11
+ # disposable domains change every few days and live in your database. This one
12
+ # rewrites a file inside the gem, which is read-only once the gem is installed.
13
+ # Run it in a checkout of the gem before cutting a release:
14
+ #
15
+ # rake nondisposable:tlds:update
16
+ #
17
+ # If you need a TLD that is newer than your installed gem, do NOT reach for
18
+ # this. Add it to `config.additional_tlds` and you are unblocked immediately,
19
+ # with no release and no writable-gem-directory problem:
20
+ #
21
+ # config.additional_tlds = %w[newtld]
22
+ #
23
+ # New TLDs are delegated in batches a few times a year, so a snapshot ages
24
+ # slowly. ICANN's next application round is the first real test of that.
25
+ class TldListUpdater
26
+ LIST_URL = 'https://data.iana.org/TLD/tlds-alpha-by-domain.txt'
27
+
28
+ OPEN_TIMEOUT = 10
29
+ READ_TIMEOUT = 10
30
+
31
+ # A root zone that suddenly contains a handful of entries means the fetch
32
+ # went wrong (a captive portal, an error page, a truncated body), not that
33
+ # ICANN deleted the internet. Refuse to overwrite a good list with junk.
34
+ MINIMUM_PLAUSIBLE_TLD_COUNT = 1_000
35
+
36
+ class << self
37
+ # Returns the number of TLDs written, or nil if nothing was written.
38
+ def update(path: Nondisposable::Tld::LIST_PATH)
39
+ log :info, "Fetching the IANA root zone TLD list from #{LIST_URL}..."
40
+
41
+ response = fetch
42
+ unless response.is_a?(Net::HTTPSuccess)
43
+ log :error, "Failed to download the TLD list. HTTP status: #{response.code}. Keeping the existing snapshot."
44
+ return nil
45
+ end
46
+
47
+ body = response.body.to_s
48
+ count = body.lines.count { |line| meaningful?(line) }
49
+
50
+ if count < MINIMUM_PLAUSIBLE_TLD_COUNT
51
+ log :error, "Refusing to write a TLD list with only #{count} entries (expected at least #{MINIMUM_PLAUSIBLE_TLD_COUNT}). Keeping the existing snapshot."
52
+ return nil
53
+ end
54
+
55
+ # Written whole, then moved into place, so an interrupted run can never
56
+ # leave a half-written root zone behind.
57
+ tmp = "#{path}.tmp"
58
+ File.write(tmp, body)
59
+ File.rename(tmp, path)
60
+ Nondisposable::Tld.reload!
61
+
62
+ log :info, "Wrote #{count} TLDs to #{path} (#{Nondisposable::Tld.version})."
63
+ count
64
+ rescue StandardError => e
65
+ log :error, "Could not update the TLD list: #{e.class}: #{e.message}. Keeping the existing snapshot."
66
+ nil
67
+ ensure
68
+ File.delete(tmp) if tmp && File.exist?(tmp)
69
+ end
70
+
71
+ private
72
+
73
+ def meaningful?(line)
74
+ value = line.to_s.strip
75
+ !value.empty? && !value.start_with?('#')
76
+ end
77
+
78
+ def fetch
79
+ uri = URI(LIST_URL)
80
+ Net::HTTP.start(
81
+ uri.host,
82
+ uri.port,
83
+ use_ssl: uri.scheme == 'https',
84
+ open_timeout: OPEN_TIMEOUT,
85
+ read_timeout: READ_TIMEOUT
86
+ ) { |http| http.get(uri.request_uri) }
87
+ end
88
+
89
+ # Usable from a bare `rake` run in a gem checkout, where there is no Rails
90
+ # logger to talk to.
91
+ def log(level, message)
92
+ message = "[nondisposable] #{message}"
93
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
94
+ Rails.logger.public_send(level, message)
95
+ else
96
+ warn message
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nondisposable
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end