active_sanction 1.0.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 +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/configuration"
|
|
7
|
+
require "active_sanction/error"
|
|
8
|
+
require "active_sanction/match_result"
|
|
9
|
+
|
|
10
|
+
module ActiveSanction
|
|
11
|
+
# One configured installation of this library, as an object a server can
|
|
12
|
+
# hold.
|
|
13
|
+
#
|
|
14
|
+
# client = ActiveSanction::Client.new(
|
|
15
|
+
# storage: ActiveSanction::Storage::FileSystem.new(root: "/srv/sanctions"),
|
|
16
|
+
# sources: %i[ofac_sdn un_consolidated canada_sema],
|
|
17
|
+
# user_agent: "acme-bank/1.0 (compliance@acme.example)"
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# client.sync!
|
|
21
|
+
# client.screen(name: "Bosco Ntaganda", threshold: 75)
|
|
22
|
+
#
|
|
23
|
+
# `ActiveSanction.screen` and everything beside it are sugar over a default
|
|
24
|
+
# client this module builds on first use, so a script and the README
|
|
25
|
+
# quickstart never have to know this class exists. What it exists for is the
|
|
26
|
+
# process that needs more than one configuration alive at once, which a
|
|
27
|
+
# process-global cannot express at all:
|
|
28
|
+
#
|
|
29
|
+
# - **A pinned list version beside the current one.** An audit re-run has to
|
|
30
|
+
# screen against the snapshot a decision was actually made under while
|
|
31
|
+
# live traffic screens against today's. Two clients over two stores, both
|
|
32
|
+
# warm, neither reaching for the other's.
|
|
33
|
+
# - **Different lists per tenant.** One customer's obligations are OFAC and
|
|
34
|
+
# the UN; another's add the EU and the UK. A client per source set, held
|
|
35
|
+
# for the life of the process.
|
|
36
|
+
# - **A different publisher identity per caller**, which is the fetch half of
|
|
37
|
+
# the same thing.
|
|
38
|
+
#
|
|
39
|
+
# ### It is a value object with one memo
|
|
40
|
+
#
|
|
41
|
+
# Everything a client is asked is answered from a frozen Configuration --
|
|
42
|
+
# the store, the source list, the weights, the candidate cap, the thresholds
|
|
43
|
+
# a query defaults to, the User-Agent every request carries. A client's
|
|
44
|
+
# settings cannot be edited after it is built. Deriving a neighbour from one
|
|
45
|
+
# is what `#with` is for:
|
|
46
|
+
#
|
|
47
|
+
# audit = client.with(storage: pinned_store)
|
|
48
|
+
#
|
|
49
|
+
# The one thing a client holds that is not settled at construction is the
|
|
50
|
+
# matcher, because building one reads every stored list and indexes it --
|
|
51
|
+
# seconds and tens of megabytes on a full corpus. It is built on first use
|
|
52
|
+
# under a lock and then only read. See #matcher.
|
|
53
|
+
#
|
|
54
|
+
# ### The thread-safety contract
|
|
55
|
+
#
|
|
56
|
+
# **A built client and the index it has loaded are safe to screen from
|
|
57
|
+
# concurrently.** `#screen`, `#screen_all`, `#matcher` and every reader here
|
|
58
|
+
# may be called from as many threads as a host has; the matcher is frozen at
|
|
59
|
+
# build and nothing on the query path writes to anything shared. This is the
|
|
60
|
+
# guarantee a web process needs, and it is the reason the matcher is
|
|
61
|
+
# replaced rather than updated.
|
|
62
|
+
#
|
|
63
|
+
# **`#sync!` is not concurrent-safe against readers of the same storage.**
|
|
64
|
+
# It is safe to run alongside screening -- a store publishes a list whole,
|
|
65
|
+
# so a thread mid-screen finishes against the version it started with, and
|
|
66
|
+
# `#reload!` is what moves the next screening call onto the new one. What is
|
|
67
|
+
# not supported is two syncs writing the same store at the same time, from
|
|
68
|
+
# this process or another: the last writer wins per source and the losers'
|
|
69
|
+
# downloads are discarded. A host that syncs from more than one process
|
|
70
|
+
# needs a lock of its own around the run, not a bigger `sync_concurrency:`
|
|
71
|
+
# -- that number is how many *publishers* one run fetches from at once, and
|
|
72
|
+
# is safe.
|
|
73
|
+
#
|
|
74
|
+
# **Nothing here makes a store thread-safe that is not.** The two shipped
|
|
75
|
+
# adapters are: Memory guards its hash, and FileSystem publishes a list by
|
|
76
|
+
# renaming one file over another. An adapter a host wrote is held to the
|
|
77
|
+
# same rule by the shared conformance group.
|
|
78
|
+
class Client
|
|
79
|
+
extend T::Sig
|
|
80
|
+
|
|
81
|
+
# The settings this client answers from, frozen. Reading one is how a host
|
|
82
|
+
# asks what a client is: `client.configuration.user_agent`.
|
|
83
|
+
sig { returns(Configuration) }
|
|
84
|
+
attr_reader :configuration
|
|
85
|
+
|
|
86
|
+
# Where screening happened, stamped onto every result. `:local` is this
|
|
87
|
+
# gem doing the work against a list on this machine; #56 introduces the
|
|
88
|
+
# seam and the names of the others.
|
|
89
|
+
sig { returns(Symbol) }
|
|
90
|
+
attr_reader :backend
|
|
91
|
+
|
|
92
|
+
# Takes a Configuration, or the settings to build one from, or both -- in
|
|
93
|
+
# which case the settings are applied on top of a copy and the
|
|
94
|
+
# configuration handed in is left alone:
|
|
95
|
+
#
|
|
96
|
+
# ActiveSanction::Client.new(user_agent: "acme-bank/1.0 (compliance@acme.example)")
|
|
97
|
+
# ActiveSanction::Client.new(configuration: ActiveSanction.config)
|
|
98
|
+
# ActiveSanction::Client.new(configuration: base, sources: %i[ofac_sdn])
|
|
99
|
+
#
|
|
100
|
+
# Every setting `ActiveSanction.configure` takes is a keyword argument
|
|
101
|
+
# here, held to exactly the same rule and failing with the same message --
|
|
102
|
+
# they are the same writers. An unknown one raises ConfigurationError
|
|
103
|
+
# rather than being ignored, because a misspelled setting that is silently
|
|
104
|
+
# dropped is a client running on a default somebody thinks they changed.
|
|
105
|
+
#
|
|
106
|
+
# A configuration passed in with no overrides is frozen in place rather
|
|
107
|
+
# than copied, so `ActiveSanction.config` is the object the `configure`
|
|
108
|
+
# block just wrote to. That is the one visible side effect of building a
|
|
109
|
+
# client, and it is the point: a settings object that can still move is
|
|
110
|
+
# the thing this class exists to remove.
|
|
111
|
+
sig { params(configuration: T.nilable(Configuration), backend: T.untyped, settings: T.untyped).void }
|
|
112
|
+
def initialize(configuration: nil, backend: MatchResult::DEFAULT_BACKEND, **settings)
|
|
113
|
+
base = configuration || Configuration.new
|
|
114
|
+
@configuration = T.let((settings.empty? ? base : base.with(**settings)).freeze, Configuration)
|
|
115
|
+
@backend = T.let(backend.to_s.to_sym, Symbol)
|
|
116
|
+
@lock = T.let(Mutex.new, Mutex)
|
|
117
|
+
@matcher = T.let(nil, T.nilable(Matcher))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Another client with some settings changed, sharing nothing with this one
|
|
121
|
+
# -- not the configuration, and not the matcher:
|
|
122
|
+
#
|
|
123
|
+
# audit = client.with(storage: januarys_snapshots)
|
|
124
|
+
#
|
|
125
|
+
# The second client builds its own index over its own store, which is the
|
|
126
|
+
# cost of the isolation and the reason this is an explicit call rather
|
|
127
|
+
# than a per-query option.
|
|
128
|
+
sig { params(overrides: T.untyped).returns(Client) }
|
|
129
|
+
def with(**overrides)
|
|
130
|
+
self.class.new(configuration: configuration.with(**overrides), backend: backend)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Where this client's synced lists are read from and written to.
|
|
134
|
+
sig { returns(Storage::Base) }
|
|
135
|
+
def storage = configuration.storage
|
|
136
|
+
|
|
137
|
+
# The lists this client syncs and screens against, or nil for every
|
|
138
|
+
# registered source -- which is what an installation that has not named
|
|
139
|
+
# any should get, so that a gem adding a jurisdiction takes effect without
|
|
140
|
+
# an edit to the host's initializer.
|
|
141
|
+
sig { returns(T.nilable(T::Array[Symbol])) }
|
|
142
|
+
def sources = configuration.sources
|
|
143
|
+
|
|
144
|
+
# This client's matcher, built from its store on first use and then held.
|
|
145
|
+
#
|
|
146
|
+
# Building it reads every list this client's configuration names, indexes
|
|
147
|
+
# them, and takes each list's checksum from the very snapshot it indexed.
|
|
148
|
+
# That costs seconds and tens of megabytes on a full corpus, so it happens
|
|
149
|
+
# once, under a lock -- two threads racing to do it at boot would build
|
|
150
|
+
# two indexes and throw one away.
|
|
151
|
+
#
|
|
152
|
+
# What comes back is immutable and safe to screen from concurrently. It is
|
|
153
|
+
# never updated: a sync builds a new one, which is what `#reload!` makes
|
|
154
|
+
# the next screening call do.
|
|
155
|
+
sig { returns(Matcher) }
|
|
156
|
+
def matcher
|
|
157
|
+
@lock.synchronize do
|
|
158
|
+
@matcher ||= with_configuration do
|
|
159
|
+
Matcher.build(storage, sources: configuration.sources, weights: configuration.scorer_weights,
|
|
160
|
+
candidate_limit: configuration.candidate_limit, backend: backend)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Screens one name against this client's lists:
|
|
166
|
+
#
|
|
167
|
+
# client.screen(name: "Bosco Ntaganda", type: :individual, threshold: 75)
|
|
168
|
+
#
|
|
169
|
+
# Sugar over #matcher, which is where everything this does is documented.
|
|
170
|
+
# A query that names no threshold or limit takes this client's, not the
|
|
171
|
+
# default client's.
|
|
172
|
+
sig { params(query: T.untyped, overrides: T.untyped).returns(T::Array[MatchResult]) }
|
|
173
|
+
def screen(query = nil, **overrides)
|
|
174
|
+
built = matcher
|
|
175
|
+
with_configuration { built.screen(query, **overrides) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A book of names against one list version, one array of results per
|
|
179
|
+
# query, in the order they were given. See Matcher#screen_all.
|
|
180
|
+
sig { params(queries: T.untyped, overrides: T.untyped).returns(T::Array[T::Array[MatchResult]]) }
|
|
181
|
+
def screen_all(queries, **overrides)
|
|
182
|
+
built = matcher
|
|
183
|
+
with_configuration { built.screen_all(queries, **overrides) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Fetches, parses and stores this client's lists, and returns what each
|
|
187
|
+
# one did:
|
|
188
|
+
#
|
|
189
|
+
# report = client.sync! # every source this client names
|
|
190
|
+
# report = client.sync!(:ofac_sdn) # one
|
|
191
|
+
# report = client.sync!(force: true) # bypass conditional GET
|
|
192
|
+
#
|
|
193
|
+
# A failing source does not raise and does not stop the others: it is
|
|
194
|
+
# captured into the report and **its previous snapshot is kept**. See
|
|
195
|
+
# Sync, which is where all of that is documented, and Sync::Report.
|
|
196
|
+
#
|
|
197
|
+
# Drops this client's matcher when any list changed, so the next screening
|
|
198
|
+
# call is answered by what was just synced. Not concurrent-safe against
|
|
199
|
+
# another sync of the same storage -- see the class comment.
|
|
200
|
+
sig do
|
|
201
|
+
params(sources: T.untyped, options: T.untyped,
|
|
202
|
+
block: T.nilable(T.proc.params(result: Sync::Result).void)).returns(Sync::Report)
|
|
203
|
+
end
|
|
204
|
+
def sync!(*sources, **options, &block)
|
|
205
|
+
report = with_configuration do
|
|
206
|
+
T.unsafe(Sync).new(sources: sources, store: storage, **options).call(&block)
|
|
207
|
+
end
|
|
208
|
+
reload! if report.updated.any?
|
|
209
|
+
report
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# What changed between two snapshots of one of this client's lists, read
|
|
213
|
+
# from this client's store. See Diff.
|
|
214
|
+
sig { params(source: T.untyped, options: T.untyped).returns(Diff) }
|
|
215
|
+
def diff(source = nil, **options)
|
|
216
|
+
with_configuration { T.unsafe(Diff).call(source, store: storage, **options) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Applies a snapshot diff to a book of subjects, and returns the alerts:
|
|
220
|
+
#
|
|
221
|
+
# diff = client.diff(:ofac_sdn, from: yesterdays_snapshot)
|
|
222
|
+
# alerts = client.rescreen(book, diff: diff, threshold: 75)
|
|
223
|
+
#
|
|
224
|
+
# alerts.first.subject_id # => "cust_1"
|
|
225
|
+
# alerts.first.change # => :newly_listed
|
|
226
|
+
#
|
|
227
|
+
# Who a list change affects, which is the step that turns a diff into an
|
|
228
|
+
# alert. Costs the book times the handful of records that moved rather
|
|
229
|
+
# than the book times the whole corpus, and does not touch this client's
|
|
230
|
+
# matcher -- a rescreen indexes the diff and nothing else. A host
|
|
231
|
+
# streaming a large book builds one Rescreen and calls it per batch, so
|
|
232
|
+
# that index is built once; see Rescreen, which is where all of it is
|
|
233
|
+
# documented.
|
|
234
|
+
sig do
|
|
235
|
+
params(subjects: T.untyped, diff: T.untyped, options: T.untyped,
|
|
236
|
+
block: T.nilable(T.proc.params(alert: Rescreen::Alert).void)).returns(T::Array[Rescreen::Alert])
|
|
237
|
+
end
|
|
238
|
+
def rescreen(subjects, diff:, **options, &block)
|
|
239
|
+
with_configuration do
|
|
240
|
+
T.unsafe(Rescreen).call(subjects, diff: diff, backend: backend, **options, &block)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Writes one of this client's lists to a portable bundle file, and returns
|
|
245
|
+
# the Bundle::Header it wrote:
|
|
246
|
+
#
|
|
247
|
+
# client.export(:ofac_sdn, to: "ofac_sdn.asb")
|
|
248
|
+
# client.export(:ofac_sdn, to: "ofac_sdn.asb", sign_with: private_key)
|
|
249
|
+
# client.export(snapshot, to: "ofac_sdn.asb") # one just synced, unstored
|
|
250
|
+
#
|
|
251
|
+
# The file that comes out is the unit of distribution: another machine loads
|
|
252
|
+
# it with #import and screens against it without ever reaching the
|
|
253
|
+
# publisher. See Snapshot::Bundle, and docs/bundle_format.md.
|
|
254
|
+
sig do
|
|
255
|
+
params(source: T.untyped, to: T.untyped, sign_with: T.untyped,
|
|
256
|
+
generator: T.untyped).returns(Snapshot::Bundle::Header)
|
|
257
|
+
end
|
|
258
|
+
def export(source, to:, sign_with: nil, generator: nil)
|
|
259
|
+
snapshot = source.is_a?(Snapshot) ? source : with_configuration { storage.fetch_snapshot(source) }
|
|
260
|
+
::File.open(to.to_s, "wb") do |io|
|
|
261
|
+
Snapshot::Bundle.write(snapshot, io: io, sign_with: sign_with, generator: generator)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Loads a bundle into this client's store and returns the Snapshot it held:
|
|
266
|
+
#
|
|
267
|
+
# client.import("ofac_sdn.asb") # unverified, and usable
|
|
268
|
+
# client.import("ofac_sdn.asb", verify_with: public_key)
|
|
269
|
+
#
|
|
270
|
+
# With a key, a bundle that was not signed by its holder raises rather than
|
|
271
|
+
# being stored -- an unverified list is a fine thing to screen against, and
|
|
272
|
+
# a list that failed a verification somebody asked for is not.
|
|
273
|
+
#
|
|
274
|
+
# Drops this client's matcher, so the next screening call is answered by
|
|
275
|
+
# what was just imported.
|
|
276
|
+
#
|
|
277
|
+
# ### What comes back is trusted; what is stored is not
|
|
278
|
+
#
|
|
279
|
+
# The snapshot returned reports `trusted?` when it verified. Reading the
|
|
280
|
+
# same list back out of a FileSystem or ActiveRecord store afterwards does
|
|
281
|
+
# not: a signature attests to the bundle's bytes, not to the copy this gem
|
|
282
|
+
# rewrote into its own layout. An installation that wants
|
|
283
|
+
# `MatchResult#verified?` on its results holds the imported snapshot in
|
|
284
|
+
# memory -- `client.with(storage: ActiveSanction::Storage::Memory.new)` --
|
|
285
|
+
# rather than round-tripping it through a directory. See Snapshot#trusted?.
|
|
286
|
+
sig { params(path: T.untyped, verify_with: T.untyped).returns(Snapshot) }
|
|
287
|
+
def import(path, verify_with: nil)
|
|
288
|
+
snapshot = ::File.open(path.to_s, "rb") { |io| Snapshot::Bundle.read(io, verify_with: verify_with) }
|
|
289
|
+
with_configuration { storage.write_snapshot(snapshot) }
|
|
290
|
+
reload!
|
|
291
|
+
snapshot
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Diagnoses whether one of this client's sources has changed format,
|
|
295
|
+
# against the version its store holds. Writes nothing. See Doctor.
|
|
296
|
+
sig do
|
|
297
|
+
params(sources: T.untyped, options: T.untyped,
|
|
298
|
+
block: T.nilable(T.proc.params(diagnosis: Doctor::Diagnosis).void)).returns(Doctor::Report)
|
|
299
|
+
end
|
|
300
|
+
def doctor(*sources, **options, &block)
|
|
301
|
+
with_configuration do
|
|
302
|
+
T.unsafe(Doctor).new(sources: sources, store: storage, **options).call(&block)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Drops this client's matcher so the next screening call builds one over
|
|
307
|
+
# what its store holds now. What a host calls after syncing from another
|
|
308
|
+
# process -- `#sync!` calls it itself when a list changed.
|
|
309
|
+
#
|
|
310
|
+
# A screening call already in flight keeps the matcher it started with and
|
|
311
|
+
# finishes against one consistent list version, which is what makes its
|
|
312
|
+
# results re-derivable.
|
|
313
|
+
sig { returns(T.self_type) }
|
|
314
|
+
def reload!
|
|
315
|
+
@lock.synchronize { @matcher = nil }
|
|
316
|
+
self
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Whether a matcher has been built and is being held. What a host checks
|
|
320
|
+
# to decide whether a screening call is about to cost an index build.
|
|
321
|
+
sig { returns(T::Boolean) }
|
|
322
|
+
def loaded? = !@lock.synchronize { @matcher }.nil?
|
|
323
|
+
|
|
324
|
+
sig { returns(String) }
|
|
325
|
+
def inspect
|
|
326
|
+
named = sources ? T.must(sources).join(", ") : "every registered source"
|
|
327
|
+
"#<#{self.class} #{named} in #{storage.class}#{" loaded" if loaded?}>"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
# Runs a block with this client's settings in force, so that everything
|
|
333
|
+
# reached from inside it -- the fetch layer's User-Agent and timeouts, the
|
|
334
|
+
# normalizer's dictionary, the XML backend, the threshold and limit a
|
|
335
|
+
# query defaults to -- reads this client's numbers rather than the default
|
|
336
|
+
# client's. See ActiveSanction.with_configuration, which is where the
|
|
337
|
+
# mechanism and its one limit are documented.
|
|
338
|
+
sig { params(block: T.proc.returns(T.untyped)).returns(T.untyped) }
|
|
339
|
+
def with_configuration(&block) = ActiveSanction.with_configuration(configuration, &block)
|
|
340
|
+
end
|
|
341
|
+
end
|