nondisposable 0.3.0 → 0.6.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +305 -3
  4. data/Rakefile +5 -0
  5. data/app/models/nondisposable/disposable_domain.rb +53 -34
  6. data/app/models/nondisposable/source_snapshot.rb +66 -0
  7. data/data/email_providers.txt +276 -0
  8. data/data/iana_tlds.txt +1439 -0
  9. data/data/known_typo_domains.txt +99 -0
  10. data/data/never_disposable.txt +480 -0
  11. data/data/privacy_relays.txt +42 -0
  12. data/data/public_suffix_list.dat +16571 -0
  13. data/gemfiles/rails_7.2.gemfile +1 -0
  14. data/gemfiles/rails_8.0.gemfile +1 -0
  15. data/gemfiles/rails_8.1.gemfile +1 -0
  16. data/lib/generators/nondisposable/install_generator.rb +1 -0
  17. data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
  18. data/lib/generators/nondisposable/templates/nondisposable.rb +67 -1
  19. data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
  20. data/lib/nondisposable/confirmable_email.rb +75 -0
  21. data/lib/nondisposable/domain.rb +238 -0
  22. data/lib/nondisposable/domain_list_updater.rb +510 -88
  23. data/lib/nondisposable/email_validator.rb +101 -3
  24. data/lib/nondisposable/known_typos.rb +64 -0
  25. data/lib/nondisposable/precedence.rb +209 -0
  26. data/lib/nondisposable/public_suffix.rb +147 -0
  27. data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
  28. data/lib/nondisposable/source.rb +233 -0
  29. data/lib/nondisposable/source_fetcher.rb +69 -0
  30. data/lib/nondisposable/source_merge.rb +127 -0
  31. data/lib/nondisposable/suggestion.rb +336 -0
  32. data/lib/nondisposable/tld.rb +190 -0
  33. data/lib/nondisposable/tld_list_updater.rb +101 -0
  34. data/lib/nondisposable/version.rb +1 -1
  35. data/lib/nondisposable.rb +153 -6
  36. data/lib/tasks/nondisposable.rake +66 -0
  37. metadata +24 -2
@@ -1,126 +1,548 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'uri'
3
+ require 'digest'
4
+ require 'monitor'
5
+ require 'set'
5
6
 
6
7
  module Nondisposable
8
+ # Keeps nondisposable_disposable_domains current.
9
+ #
10
+ # TWO PATHS, CHOSEN BY THE SCHEMA
11
+ #
12
+ # - With the nondisposable_source_snapshots table (fresh installs from 0.6.0,
13
+ # or `rails g nondisposable:upgrade`): every source in config.sources is
14
+ # fetched into its own snapshot, then the stored list is REBUILT from each
15
+ # source's last good snapshot (#publish). One bad source costs its own
16
+ # update, never the list.
17
+ # - Without it: the single-source behaviour of 0.5 and earlier — fetch the
18
+ # default list and replace the table. Rolling deploys are safe in both
19
+ # directions: new code before the migration takes this path, and old code
20
+ # after the migration simply never looks at the new table.
21
+ #
22
+ # Both paths go through the same precedence table (Nondisposable::Precedence)
23
+ # and the same writer, under the same lock.
24
+ #
25
+ # WHAT IS NEVER PUBLISHED
26
+ #
27
+ # An empty list; a list built while any PRIMARY source has never been fetched
28
+ # successfully (on a first run that would replace the bundled seed with less
29
+ # than it); public suffixes; never-disposable domains; anything that is not a
30
+ # possible domain name.
7
31
  class DomainListUpdater
8
-
9
- LIST_URL = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf'
32
+ LIST_URL = Sources::DISPOSABLE_EMAIL_DOMAINS.url
10
33
 
11
34
  # Snapshot of the upstream blocklist vendored into the gem, used to seed
12
35
  # fresh installs so they are protected before the first remote update.
13
- BUNDLED_LIST_PATH = File.expand_path('../../data/disposable_email_blocklist.conf', __dir__)
36
+ BUNDLED_LIST_PATH = Sources::DISPOSABLE_EMAIL_DOMAINS.bundled_path
14
37
 
15
38
  # Network timeouts (seconds). Without these, a hung connection would block
16
39
  # the calling job/thread indefinitely (Net::HTTP defaults to 60s open / 60s read).
17
- OPEN_TIMEOUT = 10
18
- READ_TIMEOUT = 10
40
+ OPEN_TIMEOUT = SourceFetcher::OPEN_TIMEOUT
41
+ READ_TIMEOUT = SourceFetcher::READ_TIMEOUT
19
42
 
20
- def self.update
21
- Rails.logger.info "[nondisposable] Refreshing list of disposable domains..."
43
+ # A fresh snapshot is refused when it holds fewer than half the domains of
44
+ # the largest snapshot we still retain for that source, or more than three
45
+ # times the last good one. Upstream lists change by a few percent a week;
46
+ # anything bigger is an outage, a truncation or a format change until a
47
+ # human says otherwise. Comparing against the retained MAXIMUM (not just
48
+ # the previous run) is what stops a series of 49% cuts from ratcheting a
49
+ # list down to nothing.
50
+ MAX_SHRINK_RATIO = 0.5
51
+ MAX_GROWTH_FACTOR = 3
22
52
 
23
- begin
24
- response = fetch_list
53
+ # Serializes publish and seed across processes. Any stable 63-bit integer;
54
+ # this one spells "nondisp" in ASCII, so it is easy to spot in pg_locks.
55
+ PUBLISH_LOCK_ID = 0x6e6f6e64697370
56
+ PUBLISH_LOCK_WAIT = 30
25
57
 
26
- if response.is_a?(Net::HTTPSuccess)
27
- downloaded_domains = response.body.split("\n")
28
- raise "The list is empty. This might indicate a problem with the format." if downloaded_domains.empty?
58
+ INSERT_BATCH_SIZE = 5_000
59
+ LOG_PREVIEW = 20
29
60
 
30
- Rails.logger.info "[nondisposable] Downloaded list of disposable domains..."
61
+ PROCESS_LOCK = Monitor.new
62
+ private_constant :PROCESS_LOCK
31
63
 
32
- replace_domains(downloaded_domains)
33
- true
64
+ class << self
65
+ # Returns true when every source was refreshed and the list published.
66
+ # Never raises: a failure is logged, and an empty table is seeded from
67
+ # the bundled snapshot so a broken network never means no protection.
68
+ def update
69
+ log :info, 'Refreshing list of disposable domains...'
70
+
71
+ if SourceSnapshot.available?
72
+ update_from_sources
34
73
  else
35
- Rails.logger.error "[nondisposable] Failed to download the list. HTTP Status: #{response.code}"
36
- seed
37
- false
74
+ update_single_source
38
75
  end
39
- rescue SocketError => e
40
- Rails.logger.error "[nondisposable] Network error occurred: #{e.message}"
76
+ rescue StandardError => e
77
+ log :error, "An error occurred when trying to update the list of disposable domains: #{e.class}: #{e.message}"
41
78
  seed
42
79
  false
80
+ end
81
+
82
+ # Rebuilds the stored list from each configured source's last good
83
+ # snapshot, without fetching anything. Run it after changing
84
+ # excluded_domains, additional_domains, block_privacy_relays or sources,
85
+ # or after a rollback. Returns true when it published.
86
+ def publish
87
+ with_publish_lock { publish! } || false
88
+ end
89
+
90
+ # Withdraws a source's newest good snapshot (marking it `rolled_back`, so
91
+ # the same payload is refused if upstream serves it again) and republishes
92
+ # from the one before. The emergency brake for a poisoned upstream; a
93
+ # permanent fix is excluding the domain, or removing the source.
94
+ #
95
+ # If nothing good is left for any primary, the list bundled with the gem
96
+ # stands in, even over a populated table: a rollback must always end
97
+ # with the withdrawn list unpublished.
98
+ def rollback(source_name)
99
+ with_publish_lock do
100
+ # Chosen INSIDE the lock: a refresh accepted between choosing and
101
+ # locking would otherwise survive the rollback it raced.
102
+ snapshot = SourceSnapshot.last_good(source_name.to_s)
103
+ unless snapshot
104
+ log :error, "Nothing to roll back: source #{source_name} has no good snapshot."
105
+ next false
106
+ end
107
+
108
+ snapshot.update!(status: 'rolled_back')
109
+ log :warn, "Rolled back #{source_name} snapshot ##{snapshot.id} (#{snapshot.domain_count} domains, fetched #{snapshot.fetched_at})."
110
+ # A withdrawn source with no older good snapshot is simply left out:
111
+ # keeping its poisoned list published would defeat the rollback.
112
+ publish!(withdrawn: source_name.to_s)
113
+ end || false
114
+ end
115
+
116
+ # Accepts that a source really did shrink: resets its high-water mark to
117
+ # its current good snapshot, so the shrink guard measures from there.
118
+ # A human decision by design; nothing calls it automatically. The count
119
+ # is recomputed from that snapshot's payload, never copied from a
120
+ # stored figure that an older rule may have produced.
121
+ def reset_baseline(source_name)
122
+ with_publish_lock do
123
+ snapshot = SourceSnapshot.last_good(source_name.to_s)
124
+ next false unless snapshot
125
+
126
+ count = storable_count(snapshot.domain_list)
127
+ attributes = { domain_count: count }
128
+ attributes[:high_water_count] = count if high_water_column?
129
+ snapshot.update!(attributes)
130
+ log :warn, "Reset the #{source_name} shrink baseline to #{count} domains."
131
+ true
132
+ end || false
133
+ end
134
+
135
+ # Seeds the domain table from the blocklist snapshot bundled with the gem,
136
+ # but ONLY when the table is empty. This closes the fresh-install fail-open
137
+ # window where every email passed validation until the first successful
138
+ # remote update. Returns true if it seeded, false otherwise.
139
+ #
140
+ # The bundled list is a snapshot from the gem's release date — always run
141
+ # DomainListUpdater.update (e.g. via the provided job) to stay current.
142
+ def seed
143
+ with_publish_lock { seed! } || false
43
144
  rescue StandardError => e
44
- Rails.logger.error "[nondisposable] An error occurred when trying to update the list of disposable domains: #{e.message}"
45
- seed
145
+ log :error, "Could not seed from the bundled list: #{e.message}"
46
146
  false
47
147
  end
48
- end
49
148
 
50
- # Seeds the domain table from the blocklist snapshot bundled with the gem,
51
- # but ONLY when the table is empty. This closes the fresh-install fail-open
52
- # window where every email passed validation until the first successful
53
- # remote update. Returns true if it seeded, false otherwise.
54
- #
55
- # The bundled list is a snapshot from the gem's release date — always run
56
- # DomainListUpdater.update (e.g. via the provided job) to stay current.
57
- def self.seed
58
- return false unless Nondisposable::DisposableDomain.none?
59
-
60
- Rails.logger.info "[nondisposable] Domain table is empty; seeding from the blocklist bundled with the gem..."
61
- count = replace_domains(bundled_domains)
62
- Rails.logger.info "[nondisposable] Seeded #{count} disposable domains from the bundled snapshot. Run Nondisposable::DomainListUpdater.update to fetch the latest list."
63
- true
64
- rescue StandardError => e
65
- Rails.logger.error "[nondisposable] Could not seed from the bundled list: #{e.message}"
66
- false
67
- end
149
+ # config.sources as validated Source objects. Raises ArgumentError on a
150
+ # configuration we cannot honour, so a typo fails loudly in the log
151
+ # instead of silently shrinking the list.
152
+ def configured_sources
153
+ sources = Array(Nondisposable.configuration.sources).map { |source| Source.build(source) }
154
+ raise ArgumentError, 'config.sources is empty' if sources.empty?
68
155
 
69
- def self.bundled_domains
70
- File.readlines(BUNDLED_LIST_PATH, chomp: true)
71
- end
72
- private_class_method :bundled_domains
73
-
74
- def self.fetch_list
75
- uri = URI(LIST_URL)
76
- Net::HTTP.start(
77
- uri.host,
78
- uri.port,
79
- use_ssl: uri.scheme == 'https',
80
- open_timeout: OPEN_TIMEOUT,
81
- read_timeout: READ_TIMEOUT
82
- ) do |http|
83
- http.get(uri.request_uri)
156
+ [[:name, 'name'], [:url, 'URL']].each do |attribute, label|
157
+ dupes = sources.map(&attribute).tally.select { |_, count| count > 1 }.keys
158
+ raise ArgumentError, "config.sources lists the same #{label} twice: #{dupes.join(', ')}" if dupes.any?
159
+ end
160
+ raise ArgumentError, 'config.sources needs at least one :primary source' if sources.none?(&:primary?)
161
+
162
+ sources
84
163
  end
85
- end
86
- private_class_method :fetch_list
87
164
 
88
- # Atomically replaces the stored domain list with the given raw list,
89
- # merged with `additional_domains` and minus `excluded_domains`.
90
- # Reports (instead of silently swallowing) any rows the bulk insert skipped.
91
- def self.replace_domains(raw_domains)
92
- domains = normalize(raw_domains + Nondisposable.configuration.additional_domains)
93
- domains -= normalize(Nondisposable.configuration.excluded_domains)
165
+ private
94
166
 
95
- inserted_count = 0
167
+ # ---- Multi-source path ------------------------------------------------
96
168
 
97
- ActiveRecord::Base.transaction do
98
- Rails.logger.info "[nondisposable] Updating disposable domains..."
99
- Nondisposable::DisposableDomain.delete_all
169
+ def update_from_sources
170
+ sources = configured_sources
171
+ refreshed = sources.map { |source| refresh(source) }.all?
172
+ published = publish
173
+ refreshed && published
174
+ end
100
175
 
101
- records = domains.map { |domain| { name: domain } }
102
- Nondisposable::DisposableDomain.insert_all(records, record_timestamps: true) if records.any?
103
- inserted_count = Nondisposable::DisposableDomain.count
176
+ # Fetches one source into a snapshot row. Never raises.
177
+ #
178
+ # The download happens outside the publish lock (it can take a while);
179
+ # judging it and recording the verdict happen inside, so a rollback or a
180
+ # publish can never interleave with a snapshot being accepted.
181
+ def refresh(source)
182
+ fetched_at = Time.current
183
+ parsed = Source.parse(SourceFetcher.fetch(source))
184
+ with_publish_lock { accept(source, parsed) } || false
185
+ rescue StandardError => e
186
+ log :error, "Could not fetch #{source.name} (#{source.url}): #{e.class}: #{e.message}. Keeping its last good snapshot."
187
+ begin
188
+ record(source, 'failed', fetched_at || Time.current, error: "#{e.class}: #{e.message}"[0, 1_000])
189
+ rescue StandardError => record_error
190
+ log :error, "Could not record the failed #{source.name} fetch: #{record_error.message}"
191
+ end
192
+ false
193
+ ensure
194
+ begin
195
+ with_publish_lock { SourceSnapshot.prune(source.name) }
196
+ rescue StandardError => e
197
+ log :warn, "Could not prune old #{source.name} snapshots: #{e.message}"
198
+ end
104
199
  end
105
200
 
106
- skipped_count = domains.size - inserted_count
107
- if skipped_count.positive?
108
- skipped = domains - Nondisposable::DisposableDomain.where(name: domains).pluck(:name)
109
- preview = skipped.take(20).join(', ')
110
- preview += ", … (#{skipped.size - 20} more)" if skipped.size > 20
111
- Rails.logger.warn "[nondisposable] Skipped #{skipped_count} row(s) during bulk insert (duplicate or conflicting values): #{preview}"
201
+ # `fetched_at` is the ACCEPTANCE time, taken under the lock: snapshots
202
+ # are ordered by when they were judged, so a slow job that finishes late
203
+ # can never slot an older verdict above a newer one.
204
+ def accept(source, parsed)
205
+ fetched_at = Time.current
206
+ sha256 = Digest::SHA256.hexdigest(parsed.domains.join("\n"))
207
+ count = storable_count(parsed.domains)
208
+ previous = SourceSnapshot.last_good(source.name)
209
+
210
+ if (reason = guard(source, parsed, count, sha256, previous))
211
+ log :error, "Refusing the new #{source.name} list: #{reason}. Keeping its last good snapshot."
212
+ record(source, 'rejected', fetched_at, sha256: sha256, domain_count: count, error: reason)
213
+ return false
214
+ end
215
+
216
+ if previous && previous.sha256 == sha256
217
+ previous.update!(fetched_at: [previous.fetched_at, fetched_at].max)
218
+ log :info, "#{source.name}: unchanged (#{count} domains)."
219
+ return true
220
+ end
221
+
222
+ log_changes(source, previous, parsed)
223
+ log_poison(source, parsed.domains)
224
+ attributes = { sha256: sha256, domain_count: count, domains: parsed.domains.join("\n") }
225
+ attributes[:high_water_count] = [high_water(source, previous), count].compact.max if high_water_column?
226
+ record(source, 'ok', fetched_at, **attributes)
227
+ true
112
228
  end
113
229
 
114
- Rails.logger.info "[nondisposable] Finished updating disposable domains. Total domains: #{inserted_count}"
115
- inserted_count
116
- end
117
- private_class_method :replace_domains
230
+ # A snapshots table created by an earlier development build of this
231
+ # branch has no high_water_count. Never crash a refresh over it: fall
232
+ # back to the retained snapshots, and say so once per process.
233
+ def high_water_column?
234
+ present = SourceSnapshot.column_names.include?('high_water_count')
235
+ unless present || @warned_high_water
236
+ @warned_high_water = true
237
+ log :warn, 'nondisposable_source_snapshots has no high_water_count column; the shrink guard falls back to ' \
238
+ 'retained snapshots. Recreate the table from the current migration to restore it.'
239
+ end
240
+ present
241
+ end
242
+
243
+ # The domains of a list that would be stored under ANY host policy: not
244
+ # junk, not a public suffix, not a known real provider, not a privacy
245
+ # relay. Host settings (block_privacy_relays, excluded_domains,
246
+ # additional_domains) are deliberately ignored, so every count of a source
247
+ # is comparable with every other, before and after a policy change. Every
248
+ # size guard counts THESE, so a payload padded with anything the filter
249
+ # can drop cannot pass for a full list.
250
+ def storable_count(domains)
251
+ Precedence.filter(domains, host_policy: false).kept.size
252
+ end
253
+
254
+ # The largest storable count this source has ever had accepted, kept on
255
+ # its newest good snapshot so pruning can never forget it. Without a
256
+ # recorded mark yet: the largest retained count, or the bundled list.
257
+ def high_water(source, previous = SourceSnapshot.last_good(source.name))
258
+ recorded = previous&.high_water_count if high_water_column?
259
+ return recorded if recorded # authoritative, and what reset_baseline lowers
260
+
261
+ retained = SourceSnapshot.ok.where(source: source.name).maximum(:domain_count)
262
+ [retained, bundled_count(source)].compact.max
263
+ end
264
+
265
+ # nil when the snapshot may be used, otherwise why not. `count` is the
266
+ # storable count (see #storable_count); snapshot domain_counts are too.
267
+ def guard(source, parsed, count, sha256, previous)
268
+ return 'the list is empty' if count.zero?
269
+
270
+ if parsed.invalid_ratio > Source::MAX_INVALID_RATIO
271
+ return "#{parsed.invalid} of #{parsed.lines} lines are not domain names " \
272
+ "(e.g. #{parsed.invalid_sample.map(&:inspect).join(', ')}) — not a blocklist"
273
+ end
274
+ return "only #{count} domains (this source's minimum_count is #{source.minimum_count})" if count < source.minimum_count
275
+
276
+ if SourceSnapshot.where(source: source.name, status: 'rolled_back', sha256: sha256).exists?
277
+ return 'it is identical to a snapshot that was rolled back'
278
+ end
279
+
280
+ largest = high_water(source, previous)
281
+ if largest && count < largest * MAX_SHRINK_RATIO
282
+ return "it shrank to #{count} domains from #{largest} (more than #{(MAX_SHRINK_RATIO * 100).round}%)"
283
+ end
284
+
285
+ baseline = previous&.domain_count || bundled_count(source)
286
+ if baseline && baseline >= source.minimum_count && count > baseline * MAX_GROWTH_FACTOR
287
+ return "it grew to #{count} domains from #{baseline} (more than #{MAX_GROWTH_FACTOR}x)"
288
+ end
289
+
290
+ nil
291
+ end
292
+
293
+ def bundled_count(source)
294
+ return nil unless source.bundled_path && File.exist?(source.bundled_path)
295
+
296
+ storable_count(Source.parse(File.read(source.bundled_path)).domains)
297
+ end
298
+
299
+ def record(source, status, fetched_at, **attributes)
300
+ SourceSnapshot.create!(source: source.name, url: source.url, status: status, fetched_at: fetched_at, **attributes)
301
+ end
302
+
303
+ def publish!(withdrawn: nil)
304
+ snapshots = {}
305
+ missing_primaries = []
306
+
307
+ configured_sources.each do |source|
308
+ snapshot = SourceSnapshot.last_good(source.name)
309
+ if snapshot
310
+ snapshots[source] = snapshot.domain_list
311
+ elsif source.name == withdrawn
312
+ log :warn, "#{source.name} has no good snapshot left after the rollback; building the list without it."
313
+ elsif source.primary? && source.bundled_path
314
+ log :warn, "#{source.name} has no good snapshot yet; using the list bundled with the gem in its place."
315
+ snapshots[source] = Source.parse(File.read(source.bundled_path)).domains
316
+ elsif source.primary?
317
+ missing_primaries << source.name
318
+ else
319
+ log :warn, "#{source.name} has no good snapshot yet; building the list without it."
320
+ end
321
+ end
322
+
323
+ if missing_primaries.any?
324
+ log :error, "Not publishing: primary source(s) #{missing_primaries.join(', ')} have never been fetched successfully. " \
325
+ 'Keeping the current list (seeding it from the bundled snapshot if it is empty).'
326
+ seed!
327
+ return false
328
+ end
118
329
 
119
- # Downcases, strips whitespace (including stray \r from CRLF payloads),
120
- # drops blank lines, and dedupes.
121
- def self.normalize(domains)
122
- domains.map { |domain| domain.to_s.strip.downcase }.reject(&:empty?).uniq
330
+ if withdrawn && snapshots.keys.none?(&:primary?)
331
+ log :warn, "No primary source has a good snapshot after rolling back #{withdrawn}; " \
332
+ 'publishing the list bundled with the gem in its place.'
333
+ snapshots[Sources::DISPOSABLE_EMAIL_DOMAINS.with(name: 'bundled-fallback')] = Source.parse(bundled_domains.join("\n")).domains
334
+ end
335
+
336
+ merged = SourceMerge.call(snapshots, threshold: Nondisposable.configuration.corroboration_threshold,
337
+ lineage: configured_sources)
338
+ log :info, "Merged #{snapshots.size} source(s): #{merged.primary_count} from primaries, #{merged.corroborated_count} corroborated."
339
+ published = publish_domains(merged.domains)
340
+ seed! unless published
341
+ published
342
+ end
343
+
344
+ # ---- Single-source path (no snapshots table) ------------------------
345
+
346
+ def update_single_source
347
+ if sources_customised?
348
+ log :warn, 'config.sources is customised but the nondisposable_source_snapshots table does not exist yet. ' \
349
+ 'Only the default list is used until you run `rails g nondisposable:upgrade` and `rails db:migrate`.'
350
+ end
351
+
352
+ parsed = Source.parse(SourceFetcher.fetch(Sources::DISPOSABLE_EMAIL_DOMAINS))
353
+ raise 'The list is empty. This might indicate a problem with the format.' if parsed.domains.empty?
354
+ if parsed.invalid_ratio > Source::MAX_INVALID_RATIO
355
+ raise "#{parsed.invalid} of #{parsed.lines} lines are not domain names (e.g. #{parsed.invalid_sample.map(&:inspect).join(', ')})"
356
+ end
357
+
358
+ log :info, 'Downloaded list of disposable domains...'
359
+ published = with_publish_lock { publish_domains(parsed.domains) }
360
+ raise 'Could not publish the list' unless published
361
+
362
+ true
363
+ end
364
+
365
+ def sources_customised?
366
+ Array(Nondisposable.configuration.sources).map { |source| Source.build(source) } != Sources::DEFAULT
367
+ rescue ArgumentError
368
+ true
369
+ end
370
+
371
+ # ---- Seeding and writing ----------------------------------------------
372
+
373
+ def seed!
374
+ return false unless DisposableDomain.none?
375
+
376
+ log :info, 'Domain table is empty; seeding from the blocklist bundled with the gem...'
377
+ domains = Source.parse(bundled_domains.join("\n")).domains
378
+ return false unless publish_domains(domains)
379
+
380
+ log :info, "Seeded #{DisposableDomain.count} disposable domains from the bundled snapshot. Run Nondisposable::DomainListUpdater.update to fetch the latest list."
381
+ true
382
+ end
383
+
384
+ def bundled_domains
385
+ File.readlines(BUNDLED_LIST_PATH, chomp: true)
386
+ end
387
+
388
+ # Applies the precedence table and writes the result. Callers hold the
389
+ # publish lock.
390
+ def publish_domains(domains)
391
+ result = Precedence.filter(domains)
392
+ log_dropped(result)
393
+
394
+ if result.kept.empty?
395
+ log :error, 'Refusing to publish an empty list of disposable domains. Keeping the current one.'
396
+ return false
397
+ end
398
+
399
+ write_domains(result.kept | legacy_spellings(domains, result.kept))
400
+ true
401
+ end
402
+
403
+ # The unicode spelling of every kept internationalised domain that
404
+ # arrived in unicode, stored ALONGSIDE its canonical punycode row.
405
+ #
406
+ # Readers since 0.5.0 look up the punycode form (and match either row);
407
+ # 0.4.0 looked up exactly what the user typed, lowercased. Storing both
408
+ # keeps a mixed-version deploy, or a rollback to 0.4.0, from silently
409
+ # unblocking `bücher.de`. Every upstream list already ships IDNs as
410
+ # `xn--`, so in practice this only adds rows for host `additional_domains`
411
+ # typed in unicode.
412
+ def legacy_spellings(domains, kept)
413
+ raw = domains.to_a + Array(Nondisposable.configuration.additional_domains)
414
+ raw.each_with_object(Set.new) do |entry, spellings|
415
+ next if entry.to_s.ascii_only?
416
+
417
+ spelling = Domain.unicode(entry)
418
+ next if spelling.ascii_only? || spelling.encoding == Encoding::BINARY
419
+
420
+ spellings << spelling if kept.include?(Domain.normalize(entry))
421
+ end
422
+ end
423
+
424
+ # Replaces the stored list with `domains` in one transaction, writing only
425
+ # the difference: readers see the old list or the new one, never a gap.
426
+ # Reports (instead of silently swallowing) any rows the bulk insert skipped.
427
+ #
428
+ # The current list is read INSIDE the transaction, so the diff is taken
429
+ # against what this transaction will overwrite. On SQLite a concurrent
430
+ # writer then makes this one fail (SQLITE_BUSY) instead of merging two
431
+ # stale diffs; on PostgreSQL/MySQL the publish lock already serializes us.
432
+ def write_domains(domains)
433
+ current = added = removed = nil
434
+
435
+ ActiveRecord::Base.transaction do
436
+ current = DisposableDomain.pluck(:name).to_set
437
+ added = (domains - current).to_a.sort
438
+ removed = (current - domains).to_a.sort
439
+ log :info, 'Updating disposable domains...'
440
+ removed.each_slice(INSERT_BATCH_SIZE) { |batch| DisposableDomain.where(name: batch).delete_all }
441
+ added.each_slice(INSERT_BATCH_SIZE) do |batch|
442
+ DisposableDomain.insert_all(batch.map { |name| { name: name } }, record_timestamps: true)
443
+ end
444
+ end
445
+
446
+ if added.any?
447
+ stored = added.each_slice(INSERT_BATCH_SIZE).sum { |batch| DisposableDomain.where(name: batch).count }
448
+ if stored < added.size
449
+ skipped = added - added.each_slice(INSERT_BATCH_SIZE).flat_map { |batch| DisposableDomain.where(name: batch).pluck(:name) }
450
+ log :warn, "Skipped #{added.size - stored} row(s) during bulk insert (duplicate or conflicting values): #{preview(skipped)}"
451
+ end
452
+ end
453
+
454
+ total = DisposableDomain.count
455
+ log :info, "Finished updating disposable domains. Total domains: #{total} (+#{added.size} -#{removed.size})."
456
+ log :info, "Added: #{preview(added)}" if added.any? && current.any?
457
+ log :info, "Removed: #{preview(removed)}" if removed.any?
458
+ total
459
+ end
460
+
461
+ # ---- Locking ------------------------------------------------------------
462
+
463
+ # Database-wide where the adapter has advisory locks (PostgreSQL, MySQL),
464
+ # process-wide otherwise. SQLite needs no more: it allows one writer at a
465
+ # time, and two processes publishing the same snapshots write the same
466
+ # rows. Returns the block's value, or nil when another process held the
467
+ # lock for longer than PUBLISH_LOCK_WAIT (it is publishing the same thing).
468
+ def with_publish_lock(&block)
469
+ PROCESS_LOCK.synchronize do
470
+ DisposableDomain.connection_pool.with_connection do |connection|
471
+ next block.call unless connection.supports_advisory_locks?
472
+
473
+ acquired = false
474
+ deadline = monotonic + PUBLISH_LOCK_WAIT
475
+ until (acquired = connection.get_advisory_lock(PUBLISH_LOCK_ID))
476
+ if monotonic > deadline
477
+ log :warn, "Another process has been publishing for over #{PUBLISH_LOCK_WAIT}s; skipping this publish."
478
+ break
479
+ end
480
+ sleep 0.25
481
+ end
482
+ next nil unless acquired
483
+
484
+ begin
485
+ block.call
486
+ ensure
487
+ connection.release_advisory_lock(PUBLISH_LOCK_ID)
488
+ end
489
+ end
490
+ end
491
+ end
492
+
493
+ def monotonic
494
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
495
+ end
496
+
497
+ # ---- Logging ------------------------------------------------------------
498
+
499
+ def log_changes(source, previous, parsed)
500
+ before = previous ? previous.domain_list.to_set : Set.new
501
+ after = parsed.domains.to_set
502
+ added = (after - before).to_a.sort
503
+ removed = (before - after).to_a.sort
504
+ message = "#{source.name}: #{after.size} domains (+#{added.size} -#{removed.size})"
505
+ message += ", #{parsed.invalid} unparseable line(s) dropped" if parsed.invalid.positive?
506
+ log :info, message
507
+ return unless previous
508
+
509
+ log :info, "#{source.name} added: #{preview(added)}" if added.any?
510
+ log :info, "#{source.name} removed: #{preview(removed)}" if removed.any?
511
+ end
512
+
513
+ # A source listing a domain we KNOW is real is either an upstream false
514
+ # positive or an attempt to poison the list. Either way it is dropped at
515
+ # publish time, and it deserves a human's attention now.
516
+ def log_poison(source, domains)
517
+ never = Precedence.never_disposable
518
+ poisoned = domains.select { |domain| Precedence.namespace_hit(domain, never) }
519
+ suffixes = domains.select { |domain| PublicSuffix.public_suffix?(domain) }
520
+
521
+ if poisoned.any?
522
+ log :warn, "#{source.name} lists #{poisoned.size} domain(s) known to be real providers; they will NOT be blocked " \
523
+ "(upstream false positive or poisoning): #{preview(poisoned)}"
524
+ end
525
+ return if suffixes.empty?
526
+
527
+ log :warn, "#{source.name} lists #{suffixes.size} public suffix(es); they will NOT be blocked: #{preview(suffixes)}"
528
+ end
529
+
530
+ def log_dropped(result)
531
+ result.dropped_by_reason.each do |reason, domains|
532
+ level = %i[additional_domain_is_never_disposable invalid_additional_domain].include?(reason) ? :warn : :info
533
+ log level, "Not storing #{domains.size} domain(s) (#{reason}): #{preview(domains)}"
534
+ end
535
+ end
536
+
537
+ def preview(domains)
538
+ text = domains.first(LOG_PREVIEW).join(', ')
539
+ text += ", … (#{domains.size - LOG_PREVIEW} more)" if domains.size > LOG_PREVIEW
540
+ text
541
+ end
542
+
543
+ def log(level, message)
544
+ Rails.logger.public_send(level, "[nondisposable] #{message}")
545
+ end
123
546
  end
124
- private_class_method :normalize
125
547
  end
126
548
  end