nondisposable 0.4.0 → 0.6.1

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +185 -6
  4. data/app/models/nondisposable/disposable_domain.rb +53 -34
  5. data/app/models/nondisposable/source_snapshot.rb +66 -0
  6. data/data/known_typo_domains.txt +99 -0
  7. data/data/never_disposable.txt +480 -0
  8. data/data/privacy_relays.txt +42 -0
  9. data/data/public_suffix_list.dat +16571 -0
  10. data/gemfiles/rails_7.2.gemfile +1 -0
  11. data/gemfiles/rails_8.0.gemfile +1 -0
  12. data/gemfiles/rails_8.1.gemfile +1 -0
  13. data/lib/generators/nondisposable/install_generator.rb +1 -0
  14. data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
  15. data/lib/generators/nondisposable/templates/nondisposable.rb +29 -7
  16. data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
  17. data/lib/nondisposable/confirmable_email.rb +75 -0
  18. data/lib/nondisposable/domain.rb +238 -0
  19. data/lib/nondisposable/domain_list_updater.rb +529 -88
  20. data/lib/nondisposable/email_validator.rb +51 -4
  21. data/lib/nondisposable/known_typos.rb +64 -0
  22. data/lib/nondisposable/precedence.rb +209 -0
  23. data/lib/nondisposable/public_suffix.rb +147 -0
  24. data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
  25. data/lib/nondisposable/source.rb +233 -0
  26. data/lib/nondisposable/source_fetcher.rb +69 -0
  27. data/lib/nondisposable/source_merge.rb +127 -0
  28. data/lib/nondisposable/suggestion.rb +201 -31
  29. data/lib/nondisposable/tld.rb +8 -3
  30. data/lib/nondisposable/version.rb +1 -1
  31. data/lib/nondisposable.rb +68 -13
  32. data/lib/tasks/nondisposable.rake +29 -0
  33. metadata +18 -2
@@ -1,126 +1,567 @@
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
+ # The bundled list may stand in for a never-fetched primary ONLY on an
307
+ # empty table. A populated table (a host upgrading from 0.4/0.5, whose
308
+ # rows are newer than this gem's release) is kept as it is until every
309
+ # primary has an accepted snapshot of its own: replacing it with the
310
+ # bundled snapshot would silently unblock every domain added upstream
311
+ # since the release (0.6.1; reported by a CarHey codex review).
312
+ # A rollback is the deliberate exception: withdrawing a poisoned list
313
+ # must succeed even when the default source never had a snapshot of
314
+ # its own, so there the stand-in is used whatever the table holds.
315
+ table_empty = DisposableDomain.uncached { DisposableDomain.none? }
316
+
317
+ configured_sources.each do |source|
318
+ snapshot = SourceSnapshot.last_good(source.name)
319
+ if snapshot
320
+ snapshots[source] = snapshot.domain_list
321
+ elsif source.name == withdrawn
322
+ log :warn, "#{source.name} has no good snapshot left after the rollback; building the list without it."
323
+ elsif source.primary? && source.bundled_path && (table_empty || withdrawn)
324
+ log :warn, "#{source.name} has no good snapshot yet; using the list bundled with the gem in its place."
325
+ snapshots[source] = Source.parse(File.read(source.bundled_path)).domains
326
+ elsif source.primary?
327
+ missing_primaries << source.name
328
+ else
329
+ log :warn, "#{source.name} has no good snapshot yet; building the list without it."
330
+ end
331
+ end
332
+
333
+ if missing_primaries.any?
334
+ log :error, "Not publishing: primary source(s) #{missing_primaries.join(', ')} have never been fetched successfully. " \
335
+ 'Keeping the current list unchanged (seeding it from the bundled snapshot only if it is empty) ' \
336
+ 'until every primary has an accepted snapshot of its own.'
337
+ seed!
338
+ return false
339
+ end
118
340
 
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
341
+ if withdrawn && snapshots.keys.none?(&:primary?)
342
+ log :warn, "No primary source has a good snapshot after rolling back #{withdrawn}; " \
343
+ 'publishing the list bundled with the gem in its place.'
344
+ snapshots[Sources::DISPOSABLE_EMAIL_DOMAINS.with(name: 'bundled-fallback')] = Source.parse(bundled_domains.join("\n")).domains
345
+ end
346
+
347
+ merged = SourceMerge.call(snapshots, threshold: Nondisposable.configuration.corroboration_threshold,
348
+ lineage: configured_sources)
349
+ log :info, "Merged #{snapshots.size} source(s): #{merged.primary_count} from primaries, #{merged.corroborated_count} corroborated."
350
+ published = publish_domains(merged.domains)
351
+ seed! unless published
352
+ published
353
+ end
354
+
355
+ # ---- Single-source path (no snapshots table) ------------------------
356
+
357
+ def update_single_source
358
+ if sources_customised?
359
+ log :warn, 'config.sources is customised but the nondisposable_source_snapshots table does not exist yet. ' \
360
+ 'Only the default list is used until you run `rails g nondisposable:upgrade` and `rails db:migrate`.'
361
+ end
362
+
363
+ parsed = Source.parse(SourceFetcher.fetch(Sources::DISPOSABLE_EMAIL_DOMAINS))
364
+ raise 'The list is empty. This might indicate a problem with the format.' if parsed.domains.empty?
365
+ if parsed.invalid_ratio > Source::MAX_INVALID_RATIO
366
+ raise "#{parsed.invalid} of #{parsed.lines} lines are not domain names (e.g. #{parsed.invalid_sample.map(&:inspect).join(', ')})"
367
+ end
368
+
369
+ log :info, 'Downloaded list of disposable domains...'
370
+ published = with_publish_lock { publish_domains(parsed.domains) }
371
+ raise 'Could not publish the list' unless published
372
+
373
+ true
374
+ end
375
+
376
+ def sources_customised?
377
+ Array(Nondisposable.configuration.sources).map { |source| Source.build(source) } != Sources::DEFAULT
378
+ rescue ArgumentError
379
+ true
380
+ end
381
+
382
+ # ---- Seeding and writing ----------------------------------------------
383
+
384
+ def seed!
385
+ # Callers hold the publish lock, which already disables the query
386
+ # cache; asked uncached here as well so no future caller can forget.
387
+ return false unless DisposableDomain.uncached { DisposableDomain.none? }
388
+
389
+ log :info, 'Domain table is empty; seeding from the blocklist bundled with the gem...'
390
+ domains = Source.parse(bundled_domains.join("\n")).domains
391
+ return false unless publish_domains(domains)
392
+
393
+ log :info, "Seeded #{DisposableDomain.count} disposable domains from the bundled snapshot. Run Nondisposable::DomainListUpdater.update to fetch the latest list."
394
+ true
395
+ end
396
+
397
+ def bundled_domains
398
+ File.readlines(BUNDLED_LIST_PATH, chomp: true)
399
+ end
400
+
401
+ # Applies the precedence table and writes the result. Callers hold the
402
+ # publish lock.
403
+ def publish_domains(domains)
404
+ result = Precedence.filter(domains)
405
+ log_dropped(result)
406
+
407
+ if result.kept.empty?
408
+ log :error, 'Refusing to publish an empty list of disposable domains. Keeping the current one.'
409
+ return false
410
+ end
411
+
412
+ write_domains(result.kept | legacy_spellings(domains, result.kept))
413
+ true
414
+ end
415
+
416
+ # The unicode spelling of every kept internationalised domain that
417
+ # arrived in unicode, stored ALONGSIDE its canonical punycode row.
418
+ #
419
+ # Readers since 0.5.0 look up the punycode form (and match either row);
420
+ # 0.4.0 looked up exactly what the user typed, lowercased. Storing both
421
+ # keeps a mixed-version deploy, or a rollback to 0.4.0, from silently
422
+ # unblocking `bücher.de`. Every upstream list already ships IDNs as
423
+ # `xn--`, so in practice this only adds rows for host `additional_domains`
424
+ # typed in unicode.
425
+ def legacy_spellings(domains, kept)
426
+ raw = domains.to_a + Array(Nondisposable.configuration.additional_domains)
427
+ raw.each_with_object(Set.new) do |entry, spellings|
428
+ next if entry.to_s.ascii_only?
429
+
430
+ spelling = Domain.unicode(entry)
431
+ next if spelling.ascii_only? || spelling.encoding == Encoding::BINARY
432
+
433
+ spellings << spelling if kept.include?(Domain.normalize(entry))
434
+ end
435
+ end
436
+
437
+ # Replaces the stored list with `domains` in one transaction, writing only
438
+ # the difference: readers see the old list or the new one, never a gap.
439
+ # Reports (instead of silently swallowing) any rows the bulk insert skipped.
440
+ #
441
+ # The current list is read INSIDE the transaction, so the diff is taken
442
+ # against what this transaction will overwrite. On SQLite a concurrent
443
+ # writer then makes this one fail (SQLITE_BUSY) instead of merging two
444
+ # stale diffs; on PostgreSQL/MySQL the publish lock already serializes us.
445
+ def write_domains(domains)
446
+ current = added = removed = nil
447
+
448
+ ActiveRecord::Base.transaction do
449
+ current = DisposableDomain.pluck(:name).to_set
450
+ added = (domains - current).to_a.sort
451
+ removed = (current - domains).to_a.sort
452
+ log :info, 'Updating disposable domains...'
453
+ removed.each_slice(INSERT_BATCH_SIZE) { |batch| DisposableDomain.where(name: batch).delete_all }
454
+ added.each_slice(INSERT_BATCH_SIZE) do |batch|
455
+ DisposableDomain.insert_all(batch.map { |name| { name: name } }, record_timestamps: true)
456
+ end
457
+ end
458
+
459
+ if added.any?
460
+ stored = added.each_slice(INSERT_BATCH_SIZE).sum { |batch| DisposableDomain.where(name: batch).count }
461
+ if stored < added.size
462
+ skipped = added - added.each_slice(INSERT_BATCH_SIZE).flat_map { |batch| DisposableDomain.where(name: batch).pluck(:name) }
463
+ log :warn, "Skipped #{added.size - stored} row(s) during bulk insert (duplicate or conflicting values): #{preview(skipped)}"
464
+ end
465
+ end
466
+
467
+ total = DisposableDomain.count
468
+ log :info, "Finished updating disposable domains. Total domains: #{total} (+#{added.size} -#{removed.size})."
469
+ log :info, "Added: #{preview(added)}" if added.any? && current.any?
470
+ log :info, "Removed: #{preview(removed)}" if removed.any?
471
+ total
472
+ end
473
+
474
+ # ---- Locking ------------------------------------------------------------
475
+
476
+ # Database-wide where the adapter has advisory locks (PostgreSQL, MySQL),
477
+ # process-wide otherwise. SQLite needs no more: it allows one writer at a
478
+ # time, and two processes publishing the same snapshots write the same
479
+ # rows. Returns the block's value, or nil when another process held the
480
+ # lock for longer than PUBLISH_LOCK_WAIT (it is publishing the same thing).
481
+ #
482
+ # Everything under the lock runs with the query cache OFF: an emptiness
483
+ # check (`DisposableDomain.none?`) answered from a cache filled before
484
+ # another process or thread wrote the table would let the bundled list
485
+ # replace rows it never saw.
486
+ def with_publish_lock(&block)
487
+ uncached = -> { DisposableDomain.uncached(&block) }
488
+ PROCESS_LOCK.synchronize do
489
+ DisposableDomain.connection_pool.with_connection do |connection|
490
+ next uncached.call unless connection.supports_advisory_locks?
491
+
492
+ acquired = false
493
+ deadline = monotonic + PUBLISH_LOCK_WAIT
494
+ until (acquired = connection.get_advisory_lock(PUBLISH_LOCK_ID))
495
+ if monotonic > deadline
496
+ log :warn, "Another process has been publishing for over #{PUBLISH_LOCK_WAIT}s; skipping this publish."
497
+ break
498
+ end
499
+ sleep 0.25
500
+ end
501
+ next nil unless acquired
502
+
503
+ begin
504
+ uncached.call
505
+ ensure
506
+ connection.release_advisory_lock(PUBLISH_LOCK_ID)
507
+ end
508
+ end
509
+ end
510
+ end
511
+
512
+ def monotonic
513
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
514
+ end
515
+
516
+ # ---- Logging ------------------------------------------------------------
517
+
518
+ def log_changes(source, previous, parsed)
519
+ before = previous ? previous.domain_list.to_set : Set.new
520
+ after = parsed.domains.to_set
521
+ added = (after - before).to_a.sort
522
+ removed = (before - after).to_a.sort
523
+ message = "#{source.name}: #{after.size} domains (+#{added.size} -#{removed.size})"
524
+ message += ", #{parsed.invalid} unparseable line(s) dropped" if parsed.invalid.positive?
525
+ log :info, message
526
+ return unless previous
527
+
528
+ log :info, "#{source.name} added: #{preview(added)}" if added.any?
529
+ log :info, "#{source.name} removed: #{preview(removed)}" if removed.any?
530
+ end
531
+
532
+ # A source listing a domain we KNOW is real is either an upstream false
533
+ # positive or an attempt to poison the list. Either way it is dropped at
534
+ # publish time, and it deserves a human's attention now.
535
+ def log_poison(source, domains)
536
+ never = Precedence.never_disposable
537
+ poisoned = domains.select { |domain| Precedence.namespace_hit(domain, never) }
538
+ suffixes = domains.select { |domain| PublicSuffix.public_suffix?(domain) }
539
+
540
+ if poisoned.any?
541
+ log :warn, "#{source.name} lists #{poisoned.size} domain(s) known to be real providers; they will NOT be blocked " \
542
+ "(upstream false positive or poisoning): #{preview(poisoned)}"
543
+ end
544
+ return if suffixes.empty?
545
+
546
+ log :warn, "#{source.name} lists #{suffixes.size} public suffix(es); they will NOT be blocked: #{preview(suffixes)}"
547
+ end
548
+
549
+ def log_dropped(result)
550
+ result.dropped_by_reason.each do |reason, domains|
551
+ level = %i[additional_domain_is_never_disposable invalid_additional_domain].include?(reason) ? :warn : :info
552
+ log level, "Not storing #{domains.size} domain(s) (#{reason}): #{preview(domains)}"
553
+ end
554
+ end
555
+
556
+ def preview(domains)
557
+ text = domains.first(LOG_PREVIEW).join(', ')
558
+ text += ", … (#{domains.size - LOG_PREVIEW} more)" if domains.size > LOG_PREVIEW
559
+ text
560
+ end
561
+
562
+ def log(level, message)
563
+ Rails.logger.public_send(level, "[nondisposable] #{message}")
564
+ end
123
565
  end
124
- private_class_method :normalize
125
566
  end
126
567
  end