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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -0
- data/README.md +305 -3
- data/Rakefile +5 -0
- data/app/models/nondisposable/disposable_domain.rb +53 -34
- data/app/models/nondisposable/source_snapshot.rb +66 -0
- data/data/email_providers.txt +276 -0
- data/data/iana_tlds.txt +1439 -0
- data/data/known_typo_domains.txt +99 -0
- data/data/never_disposable.txt +480 -0
- data/data/privacy_relays.txt +42 -0
- data/data/public_suffix_list.dat +16571 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/generators/nondisposable/install_generator.rb +1 -0
- data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
- data/lib/generators/nondisposable/templates/nondisposable.rb +67 -1
- data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
- data/lib/nondisposable/confirmable_email.rb +75 -0
- data/lib/nondisposable/domain.rb +238 -0
- data/lib/nondisposable/domain_list_updater.rb +510 -88
- data/lib/nondisposable/email_validator.rb +101 -3
- data/lib/nondisposable/known_typos.rb +64 -0
- data/lib/nondisposable/precedence.rb +209 -0
- data/lib/nondisposable/public_suffix.rb +147 -0
- data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
- data/lib/nondisposable/source.rb +233 -0
- data/lib/nondisposable/source_fetcher.rb +69 -0
- data/lib/nondisposable/source_merge.rb +127 -0
- data/lib/nondisposable/suggestion.rb +336 -0
- data/lib/nondisposable/tld.rb +190 -0
- data/lib/nondisposable/tld_list_updater.rb +101 -0
- data/lib/nondisposable/version.rb +1 -1
- data/lib/nondisposable.rb +153 -6
- data/lib/tasks/nondisposable.rake +66 -0
- metadata +24 -2
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Nondisposable
|
|
6
|
+
# One upstream blocklist, described well enough to decide how far to trust it.
|
|
7
|
+
#
|
|
8
|
+
# TRUST
|
|
9
|
+
#
|
|
10
|
+
# :primary every domain it lists is blocked (subject to the
|
|
11
|
+
# precedence table, see Nondisposable::Precedence)
|
|
12
|
+
# :corroborating a domain it lists is blocked only once
|
|
13
|
+
# config.corroboration_threshold independent ORIGINS list it
|
|
14
|
+
#
|
|
15
|
+
# LINEAGE, AND WHY TWO LISTS ARE OFTEN ONE OBSERVATION
|
|
16
|
+
#
|
|
17
|
+
# Public blocklists copy each other. `disposable/disposable-email-domains`
|
|
18
|
+
# declares that it imports mailchecker, fakefilter, wesbos, unkn0w and the
|
|
19
|
+
# original disposable-email-domains list; groundcat imports
|
|
20
|
+
# disposable/disposable among others. Counting "two repositories list it" as
|
|
21
|
+
# two votes would let one mistaken line acquire as many votes as it has
|
|
22
|
+
# copies. So each source declares what it is `derived_from`, and a source's
|
|
23
|
+
# vote for a domain only counts when none of its (transitive) upstreams that
|
|
24
|
+
# we also loaded carries the same domain. See Nondisposable::SourceMerge.
|
|
25
|
+
#
|
|
26
|
+
# Declared lineage is the only lineage we know about. Two lists that copy each
|
|
27
|
+
# other without saying so still look independent, which is one more reason
|
|
28
|
+
# the recommended preset leans on primaries and keeps corroboration strict.
|
|
29
|
+
class Source
|
|
30
|
+
TRUST_LEVELS = %i[primary corroborating].freeze
|
|
31
|
+
|
|
32
|
+
# Generous for the largest list we know of (disposable/disposable, ~1.4 MB),
|
|
33
|
+
# small enough that a runaway response cannot exhaust a worker's memory.
|
|
34
|
+
DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
|
35
|
+
# Wall-clock budget for the whole download of one source, in seconds.
|
|
36
|
+
DEFAULT_DEADLINE = 30
|
|
37
|
+
|
|
38
|
+
# Lines that fail to parse as a domain are dropped one by one. When more than
|
|
39
|
+
# this share of the payload is junk, the payload is not a blocklist at all —
|
|
40
|
+
# an HTML error page, a captive portal, a format change — and the whole
|
|
41
|
+
# snapshot is refused rather than half-ingested.
|
|
42
|
+
MAX_INVALID_RATIO = 0.01
|
|
43
|
+
|
|
44
|
+
NAME_FORMAT = %r{\A[a-z0-9][a-z0-9._/-]*\z}
|
|
45
|
+
|
|
46
|
+
ATTRIBUTES = %i[name url trust derived_from license homepage minimum_count max_bytes deadline bundled_path].freeze
|
|
47
|
+
attr_reader(*ATTRIBUTES)
|
|
48
|
+
|
|
49
|
+
# Accepts a Source, or a Hash of its attributes (what a host writes in an
|
|
50
|
+
# initializer). Raises ArgumentError on anything it cannot trust.
|
|
51
|
+
def self.build(value)
|
|
52
|
+
return value if value.is_a?(Source)
|
|
53
|
+
raise ArgumentError, "a source must be a Nondisposable::Source or a Hash (got #{value.inspect})" unless value.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
new(**value.transform_keys(&:to_sym))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(name:, url:, trust: :corroborating, derived_from: [], license: nil, homepage: nil,
|
|
59
|
+
minimum_count: 1, max_bytes: DEFAULT_MAX_BYTES, deadline: DEFAULT_DEADLINE, bundled_path: nil)
|
|
60
|
+
@name = name.to_s
|
|
61
|
+
@url = url.to_s
|
|
62
|
+
@trust = trust.respond_to?(:to_sym) ? trust.to_sym : trust
|
|
63
|
+
@derived_from = Array(derived_from).map(&:to_s).freeze
|
|
64
|
+
@license = license
|
|
65
|
+
@homepage = homepage
|
|
66
|
+
@minimum_count = Integer(minimum_count)
|
|
67
|
+
@max_bytes = Integer(max_bytes)
|
|
68
|
+
@deadline = deadline
|
|
69
|
+
@bundled_path = bundled_path
|
|
70
|
+
|
|
71
|
+
validate!
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A copy with some attributes replaced, e.g. promoting a preset source:
|
|
76
|
+
# Nondisposable::Sources::DISPOSABLE.with(trust: :primary)
|
|
77
|
+
def with(**overrides)
|
|
78
|
+
self.class.new(**to_h.merge(overrides))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_h
|
|
82
|
+
ATTRIBUTES.to_h { |attribute| [attribute, public_send(attribute)] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def primary?
|
|
86
|
+
trust == :primary
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ==(other)
|
|
90
|
+
other.is_a?(Source) && other.to_h == to_h
|
|
91
|
+
end
|
|
92
|
+
alias eql? ==
|
|
93
|
+
|
|
94
|
+
def hash
|
|
95
|
+
to_h.hash
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Parsed = Struct.new(:domains, :lines, :invalid, :invalid_sample, keyword_init: true) do
|
|
99
|
+
def invalid_ratio
|
|
100
|
+
lines.zero? ? 0.0 : invalid.fdiv(lines)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Plain text, one domain per line. `#` starts a comment (whole-line or
|
|
105
|
+
# inline), blank lines are ignored, and every entry goes through
|
|
106
|
+
# Domain.normalize. Entries that are not a possible DNS name are counted
|
|
107
|
+
# and dropped; public suffixes are kept here (the snapshot records what the
|
|
108
|
+
# source said) and dropped by the precedence filter when the list is built.
|
|
109
|
+
#
|
|
110
|
+
# Returns sorted, unique, canonical domains: the count every guard is
|
|
111
|
+
# measured on, so duplicates and casing cannot inflate it.
|
|
112
|
+
def self.parse(body)
|
|
113
|
+
domains = Set.new
|
|
114
|
+
lines = 0
|
|
115
|
+
invalid = 0
|
|
116
|
+
sample = []
|
|
117
|
+
|
|
118
|
+
body.to_s.each_line do |line|
|
|
119
|
+
entry = line.sub(/#.*/, '').strip
|
|
120
|
+
next if entry.empty?
|
|
121
|
+
|
|
122
|
+
lines += 1
|
|
123
|
+
domain = Domain.normalize(entry)
|
|
124
|
+
if Precedence.hostname?(domain)
|
|
125
|
+
domains << domain
|
|
126
|
+
else
|
|
127
|
+
invalid += 1
|
|
128
|
+
sample << entry[0, 80] if sample.size < 5
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
Parsed.new(domains: domains.to_a.sort, lines: lines, invalid: invalid, invalid_sample: sample)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def validate!
|
|
138
|
+
raise ArgumentError, "source name #{name.inspect} must match #{NAME_FORMAT.inspect}" unless NAME_FORMAT.match?(name)
|
|
139
|
+
raise ArgumentError, "source #{name}: trust must be one of #{TRUST_LEVELS.inspect} (got #{trust.inspect})" unless TRUST_LEVELS.include?(trust)
|
|
140
|
+
# Plain http would let anybody on the path rewrite the blocklist.
|
|
141
|
+
raise ArgumentError, "source #{name}: url must be https (got #{url.inspect})" unless url.start_with?('https://')
|
|
142
|
+
raise ArgumentError, "source #{name}: max_bytes must be positive" unless max_bytes.positive?
|
|
143
|
+
raise ArgumentError, "source #{name}: minimum_count must be at least 1" unless minimum_count >= 1
|
|
144
|
+
raise ArgumentError, "source #{name}: deadline must be a positive number of seconds" unless deadline.is_a?(Numeric) && deadline.positive?
|
|
145
|
+
raise ArgumentError, "source #{name} cannot be derived from itself" if derived_from.include?(name)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The sources we have measured, with their licences and declared lineage.
|
|
150
|
+
# Measurements (2026-10-17) and the reasoning behind every trust level:
|
|
151
|
+
# docs/evidence/2026-10-17-multi-source-measurement.md
|
|
152
|
+
module Sources
|
|
153
|
+
# The list this gem has always used, and the one bundled as the seed.
|
|
154
|
+
DISPOSABLE_EMAIL_DOMAINS = Source.new(
|
|
155
|
+
name: 'disposable-email-domains',
|
|
156
|
+
url: 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf',
|
|
157
|
+
homepage: 'https://github.com/disposable-email-domains/disposable-email-domains',
|
|
158
|
+
license: 'CC0-1.0',
|
|
159
|
+
trust: :primary,
|
|
160
|
+
minimum_count: 1_000,
|
|
161
|
+
bundled_path: File.expand_path('../../data/disposable_email_blocklist.conf', __dir__)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Monitors temp-mail generators directly and publishes what they hand out.
|
|
165
|
+
# 0 known-real providers measured in it.
|
|
166
|
+
FAKEFILTER = Source.new(
|
|
167
|
+
name: 'fakefilter',
|
|
168
|
+
url: 'https://raw.githubusercontent.com/7c/fakefilter/main/txt/data.txt',
|
|
169
|
+
homepage: 'https://github.com/7c/fakefilter',
|
|
170
|
+
license: 'BSD-3-Clause',
|
|
171
|
+
trust: :primary,
|
|
172
|
+
minimum_count: 1_000
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
MAILCHECKER = Source.new(
|
|
176
|
+
name: 'mailchecker',
|
|
177
|
+
url: 'https://raw.githubusercontent.com/FGRibreau/mailchecker/master/list.txt',
|
|
178
|
+
homepage: 'https://github.com/FGRibreau/mailchecker',
|
|
179
|
+
license: 'MIT',
|
|
180
|
+
trust: :corroborating,
|
|
181
|
+
minimum_count: 1_000
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
WESBOS = Source.new(
|
|
185
|
+
name: 'wesbos',
|
|
186
|
+
url: 'https://raw.githubusercontent.com/wesbos/burner-email-providers/master/emails.txt',
|
|
187
|
+
homepage: 'https://github.com/wesbos/burner-email-providers',
|
|
188
|
+
license: 'MIT',
|
|
189
|
+
trust: :corroborating,
|
|
190
|
+
minimum_count: 1_000
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# The broadest maintained list (~97k) and the only one that carried
|
|
194
|
+
# mail-burn.com on 2026-10-17 — but as a PRIMARY it also blocks real mail
|
|
195
|
+
# providers (China Unicom's wo.com.cn, the Japanese ISP odn.ad.jp, ...), so
|
|
196
|
+
# it is corroborating by default. Promote it knowingly:
|
|
197
|
+
# Nondisposable::Sources::DISPOSABLE.with(trust: :primary)
|
|
198
|
+
DISPOSABLE = Source.new(
|
|
199
|
+
name: 'disposable',
|
|
200
|
+
url: 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt',
|
|
201
|
+
homepage: 'https://github.com/disposable/disposable',
|
|
202
|
+
license: 'MIT',
|
|
203
|
+
trust: :corroborating,
|
|
204
|
+
derived_from: %w[disposable-email-domains fakefilter mailchecker wesbos unkn0w],
|
|
205
|
+
minimum_count: 1_000
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
GROUNDCAT = Source.new(
|
|
209
|
+
name: 'groundcat',
|
|
210
|
+
url: 'https://raw.githubusercontent.com/groundcat/disposable-email-domain-list/master/domains.txt',
|
|
211
|
+
homepage: 'https://github.com/groundcat/disposable-email-domain-list',
|
|
212
|
+
license: 'MIT',
|
|
213
|
+
trust: :corroborating,
|
|
214
|
+
derived_from: %w[disposable-email-domains disposable mailchecker ivolo emailondeck],
|
|
215
|
+
minimum_count: 1_000
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Today's behaviour: the one list this gem has always used.
|
|
219
|
+
DEFAULT = [DISPOSABLE_EMAIL_DOMAINS].freeze
|
|
220
|
+
|
|
221
|
+
# Two primaries that observe generators independently. +3,275 domains over
|
|
222
|
+
# DEFAULT on 2026-10-17, none of them a known real provider.
|
|
223
|
+
#
|
|
224
|
+
# Why no corroborating sources: with lineage declared, disposable,
|
|
225
|
+
# mailchecker and groundcat are each explained by an upstream already
|
|
226
|
+
# loaded, so together they added ZERO domains for three more downloads a
|
|
227
|
+
# day. Adding wesbos (no declared lineage, but 57% of it is also in
|
|
228
|
+
# mailchecker) "corroborated" 11,751 more, among them real providers such
|
|
229
|
+
# as tom.com and sdf.org. Add corroborating sources once a genuinely
|
|
230
|
+
# independent list exists; the machinery is ready.
|
|
231
|
+
RECOMMENDED = [DISPOSABLE_EMAIL_DOMAINS, FAKEFILTER].freeze
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Nondisposable
|
|
8
|
+
# Downloads one source's body, refusing anything that would not fit or not
|
|
9
|
+
# finish: the body is streamed, and the download is abandoned the moment it
|
|
10
|
+
# crosses the source's `max_bytes` or its `deadline`. A slow or bottomless
|
|
11
|
+
# response therefore costs at most one deadline, never a stuck job or a
|
|
12
|
+
# worker's memory.
|
|
13
|
+
#
|
|
14
|
+
# No redirects are followed: every preset URL is a direct raw file, and a
|
|
15
|
+
# redirect to somewhere else is not the list we decided to trust.
|
|
16
|
+
module SourceFetcher
|
|
17
|
+
class FetchError < StandardError; end
|
|
18
|
+
|
|
19
|
+
OPEN_TIMEOUT = 10
|
|
20
|
+
READ_TIMEOUT = 10
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def fetch(source)
|
|
24
|
+
uri = URI(source.url)
|
|
25
|
+
started = monotonic
|
|
26
|
+
body = +''
|
|
27
|
+
|
|
28
|
+
# The per-chunk check below cannot fire while a server trickles headers
|
|
29
|
+
# (or nothing at all) within each read timeout, so the deadline is also
|
|
30
|
+
# one absolute timeout over the whole exchange.
|
|
31
|
+
Timeout.timeout(source.deadline, FetchError, "download exceeded its #{source.deadline}s deadline") do
|
|
32
|
+
Net::HTTP.start(
|
|
33
|
+
uri.host,
|
|
34
|
+
uri.port,
|
|
35
|
+
use_ssl: uri.scheme == 'https',
|
|
36
|
+
open_timeout: [OPEN_TIMEOUT, source.deadline].min,
|
|
37
|
+
read_timeout: [READ_TIMEOUT, source.deadline].min
|
|
38
|
+
) do |http|
|
|
39
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
40
|
+
|
|
41
|
+
http.request(Net::HTTP::Get.new(uri)) do |response|
|
|
42
|
+
raise FetchError, "HTTP status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
43
|
+
|
|
44
|
+
declared = response['Content-Length'].to_i
|
|
45
|
+
if declared > source.max_bytes
|
|
46
|
+
raise FetchError, "declared size #{declared} bytes exceeds max_bytes #{source.max_bytes}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
response.read_body do |chunk|
|
|
50
|
+
body << chunk
|
|
51
|
+
raise FetchError, "body exceeds max_bytes #{source.max_bytes}" if body.bytesize > source.max_bytes
|
|
52
|
+
raise FetchError, "download exceeded its #{source.deadline}s deadline" if monotonic - started > source.deadline
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
body.force_encoding(Encoding::UTF_8).scrub('')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Separate so tests can move time forward.
|
|
64
|
+
def monotonic
|
|
65
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Nondisposable
|
|
6
|
+
# Turns each source's last good snapshot into one list of blocked domains.
|
|
7
|
+
#
|
|
8
|
+
# blocked = every domain in any PRIMARY source
|
|
9
|
+
# + every domain that at least `threshold` independent ORIGINS
|
|
10
|
+
# among the CORROBORATING sources list
|
|
11
|
+
#
|
|
12
|
+
# Votes are counted per INDEPENDENT ORIGIN. Two voters are RELATED when one
|
|
13
|
+
# declares the other as an upstream, or both declare a common upstream
|
|
14
|
+
# (transitively, including upstreams we did not load: two mirrors of the
|
|
15
|
+
# same unfetched list are one observation, not two). A domain's origin
|
|
16
|
+
# count is the largest set of pairwise-unrelated voters. A list that merely
|
|
17
|
+
# copies two independent lists does not make them related, and adds no vote.
|
|
18
|
+
#
|
|
19
|
+
# Known blind spot: lineage is only what sources DECLARE. Two lists that copy
|
|
20
|
+
# each other silently (wesbos and mailchecker overlap by 57%) still look
|
|
21
|
+
# independent; that is why the recommended preset uses no corroborating
|
|
22
|
+
# sources at all.
|
|
23
|
+
#
|
|
24
|
+
# Pure: no database, no configuration, no logging. The precedence table is
|
|
25
|
+
# applied afterwards (Nondisposable::Precedence.filter).
|
|
26
|
+
module SourceMerge
|
|
27
|
+
Result = Struct.new(:domains, :primary_count, :corroborated_count, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# `snapshots` is { Source => Array<String> (normalized domains) }.
|
|
31
|
+
# `lineage` is every configured source, so a declared link still counts
|
|
32
|
+
# when the source in the middle of it has no snapshot (failed fetch).
|
|
33
|
+
def call(snapshots, threshold:, lineage: snapshots.keys)
|
|
34
|
+
threshold = Integer(threshold)
|
|
35
|
+
raise ArgumentError, 'corroboration_threshold must be at least 1' if threshold < 1
|
|
36
|
+
|
|
37
|
+
primaries, corroborating = snapshots.partition { |source, _| source.primary? }
|
|
38
|
+
|
|
39
|
+
blocked = Set.new
|
|
40
|
+
primaries.each { |_, domains| blocked.merge(domains) }
|
|
41
|
+
primary_count = blocked.size
|
|
42
|
+
|
|
43
|
+
ancestors = ancestry(lineage | snapshots.keys)
|
|
44
|
+
verdicts = {} # voter set => enough independent origins? (lists share few sets)
|
|
45
|
+
|
|
46
|
+
votes = Hash.new { |hash, key| hash[key] = [] }
|
|
47
|
+
corroborating.each do |source, domains|
|
|
48
|
+
domains.each { |domain| votes[domain] << source.name unless blocked.include?(domain) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
corroborated = 0
|
|
52
|
+
votes.each do |domain, voters|
|
|
53
|
+
next if voters.size < threshold # cannot reach it even before lineage
|
|
54
|
+
|
|
55
|
+
enough = verdicts.fetch(voters) { verdicts[voters] = independent_subset?(voters, threshold, ancestors) }
|
|
56
|
+
next unless enough
|
|
57
|
+
|
|
58
|
+
blocked << domain
|
|
59
|
+
corroborated += 1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Result.new(domains: blocked, primary_count: primary_count, corroborated_count: corroborated)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# How many independent origins vote for `domain`. For explaining a
|
|
66
|
+
# verdict in a console, not used by #call's fast path.
|
|
67
|
+
# A largest set of pairwise-unrelated sources voting for `domain`
|
|
68
|
+
# (alphabetically first among ties). For explaining a verdict in a
|
|
69
|
+
# console; #call uses the same rule.
|
|
70
|
+
def origins_for(domain, snapshots, lineage: snapshots.keys)
|
|
71
|
+
voters = snapshots.select { |_, domains| domains.include?(domain) }.keys.map(&:name)
|
|
72
|
+
independent_origins(voters, ancestry(lineage | snapshots.keys))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Is there a set of `size` pairwise-unrelated voters? Only subsets of
|
|
78
|
+
# exactly that size are tried, so the cost is C(voters, threshold), not
|
|
79
|
+
# 2^voters, and #call memoizes it per voter set.
|
|
80
|
+
def independent_subset?(voters, size, ancestors)
|
|
81
|
+
return false if voters.size < size
|
|
82
|
+
|
|
83
|
+
voters.sort.combination(size).any? do |subset|
|
|
84
|
+
subset.combination(2).none? { |a, b| related?(a, b, ancestors) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def related?(a, b, ancestors)
|
|
89
|
+
ancestors[a].include?(b) || ancestors[b].include?(a) || ancestors[a].intersect?(ancestors[b])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Brute force over subsets, largest first: there are only ever a handful
|
|
93
|
+
# of corroborating sources, and most domains have one or two voters.
|
|
94
|
+
def independent_origins(voters, ancestors)
|
|
95
|
+
voters = voters.sort
|
|
96
|
+
related = lambda do |a, b|
|
|
97
|
+
ancestors[a].include?(b) || ancestors[b].include?(a) || ancestors[a].intersect?(ancestors[b])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
voters.size.downto(1) do |size|
|
|
101
|
+
voters.combination(size) do |subset|
|
|
102
|
+
return subset if subset.combination(2).none? { |a, b| related.call(a, b) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
[]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# { source name => Set of every declared upstream, transitively,
|
|
109
|
+
# loaded or not }.
|
|
110
|
+
def ancestry(sources)
|
|
111
|
+
declared = sources.to_h { |source| [source.name, source.derived_from] }
|
|
112
|
+
declared.keys.to_h do |name|
|
|
113
|
+
seen = Set.new
|
|
114
|
+
stack = declared[name].dup
|
|
115
|
+
until stack.empty?
|
|
116
|
+
upstream = stack.pop
|
|
117
|
+
next if seen.include?(upstream)
|
|
118
|
+
|
|
119
|
+
seen << upstream
|
|
120
|
+
stack.concat(declared.fetch(upstream, []))
|
|
121
|
+
end
|
|
122
|
+
[name, seen]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|