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,373 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "json"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "zlib"
|
|
10
|
+
require "active_sanction/configuration"
|
|
11
|
+
require "active_sanction/snapshot"
|
|
12
|
+
require "active_sanction/sources"
|
|
13
|
+
require "active_sanction/storage"
|
|
14
|
+
require "active_sanction/storage/base"
|
|
15
|
+
require "active_sanction/storage/meta"
|
|
16
|
+
|
|
17
|
+
module ActiveSanction
|
|
18
|
+
module Storage
|
|
19
|
+
# Snapshots as gzipped JSON in a directory. The default adapter, and the
|
|
20
|
+
# reason this gem screens a name without an application having provisioned
|
|
21
|
+
# anything first.
|
|
22
|
+
#
|
|
23
|
+
# store = ActiveSanction::Storage::FileSystem.new # ~/.active_sanction
|
|
24
|
+
# store = ActiveSanction::Storage::FileSystem.new(root: "/srv/lists")
|
|
25
|
+
#
|
|
26
|
+
# store.write_snapshot(ActiveSanction::Sources[:ofac_sdn].new.sync)
|
|
27
|
+
# store.snapshot_meta(:ofac_sdn).age # without opening the list
|
|
28
|
+
# store.read_snapshot(:ofac_sdn) # => Snapshot, checksum verified
|
|
29
|
+
#
|
|
30
|
+
# Nothing here is required. `zlib` and `json` are stdlib, so the cost of
|
|
31
|
+
# persisting 19,015 OFAC records is a directory -- which is what makes this
|
|
32
|
+
# usable from a cron job, a CLI (#36), a CI run, and an air-gapped host
|
|
33
|
+
# that only ever gets a copied directory. Storage::ActiveRecord (#25) is
|
|
34
|
+
# for an installation that already has a database and wants to query the
|
|
35
|
+
# lists; it is not a prerequisite for using this library.
|
|
36
|
+
#
|
|
37
|
+
# ### The layout is private
|
|
38
|
+
#
|
|
39
|
+
# Under #62 the contents of `root` are `@api private`. What is on disk is
|
|
40
|
+
# optimized for reading and rewriting locally, and it is expected to change
|
|
41
|
+
# -- the portable, cross-machine, signature-verified representation is the
|
|
42
|
+
# bundle format (#57), which has its own stability contract and is specified
|
|
43
|
+
# in docs/bundle_format.md. An application that reads these files itself
|
|
44
|
+
# makes every future storage optimization a breaking change for it.
|
|
45
|
+
#
|
|
46
|
+
# As it stands:
|
|
47
|
+
#
|
|
48
|
+
# root/ofac_sdn/meta.json
|
|
49
|
+
# root/ofac_sdn/snapshot-sha256-9f86d081884c7d65....json.gz
|
|
50
|
+
#
|
|
51
|
+
# ### Why the snapshot file is named after its checksum
|
|
52
|
+
#
|
|
53
|
+
# Because "a sync interrupted mid-write leaves the previous good snapshot
|
|
54
|
+
# intact" cannot be honoured by two files at fixed names. Replacing a list
|
|
55
|
+
# means replacing both the list and the sidecar describing it, and whatever
|
|
56
|
+
# order those two renames happen in, a process killed between them leaves a
|
|
57
|
+
# snapshot and a meta that do not describe each other -- the new list under
|
|
58
|
+
# the old checksum, or a sidecar advertising records that are not there.
|
|
59
|
+
# Either way the previous list is gone and the source is unreadable until
|
|
60
|
+
# the next successful sync.
|
|
61
|
+
#
|
|
62
|
+
# Naming the list file after the content it holds removes the conflict. A
|
|
63
|
+
# new list is written to a name nothing else occupies, so it cannot destroy
|
|
64
|
+
# the list already there, and `meta.json` -- one small file, replaced by one
|
|
65
|
+
# atomic rename -- is the single point at which the new generation becomes
|
|
66
|
+
# the live one. Interrupt anywhere before that rename and the store is
|
|
67
|
+
# exactly as it was, plus a stray file the next write sweeps. Interrupt
|
|
68
|
+
# after it and the new list is live and complete. There is no third state.
|
|
69
|
+
#
|
|
70
|
+
# This is the pattern PayloadCache uses for raw payloads, for the same
|
|
71
|
+
# reason and with the same tradeoff: a brief second copy on disk.
|
|
72
|
+
#
|
|
73
|
+
# ### What it refuses to do
|
|
74
|
+
#
|
|
75
|
+
# Return anything it cannot prove. `Snapshot.from_h` re-derives the
|
|
76
|
+
# checksum from the records that came back and construction fails if it
|
|
77
|
+
# does not match what was stored, so a truncated file, an edited file and a
|
|
78
|
+
# half-written file all raise CorruptSnapshot instead of screening against
|
|
79
|
+
# a list that is missing records. A schema version this code does not know
|
|
80
|
+
# raises UnsupportedSchema *before* the list is parsed, since a snapshot
|
|
81
|
+
# from a newer gem will usually deserialize into a valid-looking, quietly
|
|
82
|
+
# wrong record set.
|
|
83
|
+
#
|
|
84
|
+
# ### Concurrency
|
|
85
|
+
#
|
|
86
|
+
# Many readers and one writer, across processes, which is the arrangement
|
|
87
|
+
# it exists for: a scheduled sync replacing a list while web workers screen
|
|
88
|
+
# against it. Committing is a rename, so a reader sees the whole previous
|
|
89
|
+
# generation or the whole new one; a reader that had already read the old
|
|
90
|
+
# `meta.json` when the new one landed re-reads it if the file it was sent
|
|
91
|
+
# to has since been swept.
|
|
92
|
+
#
|
|
93
|
+
# Two processes writing the *same* source at once is not supported and is
|
|
94
|
+
# not made safe by anything here -- run one sync.
|
|
95
|
+
class FileSystem < Base
|
|
96
|
+
extend T::Sig
|
|
97
|
+
|
|
98
|
+
# The sidecar, and the commit point. Its presence is what makes a
|
|
99
|
+
# directory a stored source, and replacing it is what publishes a write.
|
|
100
|
+
#
|
|
101
|
+
# @api private
|
|
102
|
+
META_FILENAME = T.let("meta.json", String)
|
|
103
|
+
|
|
104
|
+
# @api private
|
|
105
|
+
SNAPSHOT_PREFIX = T.let("snapshot-", String)
|
|
106
|
+
# @api private
|
|
107
|
+
SNAPSHOT_EXTENSION = T.let(".json.gz", String)
|
|
108
|
+
|
|
109
|
+
# Snapshot versions this code can read. Anything above the version it
|
|
110
|
+
# writes was produced by a newer gem; anything at or below it round-trips
|
|
111
|
+
# through Snapshot.from_h, which folds the version into the checksum it
|
|
112
|
+
# verifies, so a list cannot be read under a schema it was not written
|
|
113
|
+
# under without the mismatch being caught.
|
|
114
|
+
#
|
|
115
|
+
# @api private
|
|
116
|
+
READABLE_SCHEMA_VERSIONS = T.let(1..Snapshot::SCHEMA_VERSION, T::Range[Integer])
|
|
117
|
+
|
|
118
|
+
# Zlib's default, not its best. A parsed OFAC list is tens of megabytes
|
|
119
|
+
# of highly repetitive JSON that gzip already reduces by better than 90%;
|
|
120
|
+
# the last few points cost several seconds of a sync and buy a rounding
|
|
121
|
+
# error of disk.
|
|
122
|
+
#
|
|
123
|
+
# @api private
|
|
124
|
+
COMPRESSION_LEVEL = T.let(Zlib::DEFAULT_COMPRESSION, Integer)
|
|
125
|
+
|
|
126
|
+
# A checksum becomes a filename, so it is matched rather than sanitized:
|
|
127
|
+
# this is the one place a value read back off disk is joined to a path,
|
|
128
|
+
# and `sha256:<64 hex>` is the only shape allowed through.
|
|
129
|
+
#
|
|
130
|
+
# @api private
|
|
131
|
+
CHECKSUM_PATTERN = T.let(/\A#{Snapshot::ALGORITHM}:(\h{64})\z/, Regexp)
|
|
132
|
+
|
|
133
|
+
# A directory is only a source if it is named like one. Held to the rule
|
|
134
|
+
# source keys are held to everywhere, so an unrelated directory a user
|
|
135
|
+
# left under `root` is not reported as a sanctions list.
|
|
136
|
+
#
|
|
137
|
+
# @api private
|
|
138
|
+
SOURCE_PATTERN = T.let(Sources::Definition::KEY_PATTERN, Regexp)
|
|
139
|
+
|
|
140
|
+
# How long a `.part` file from a killed write is left alone before the
|
|
141
|
+
# next write sweeps it. Well past any real write and short of leaving
|
|
142
|
+
# abandoned megabytes on disk forever.
|
|
143
|
+
#
|
|
144
|
+
# @api private
|
|
145
|
+
ORPHAN_GRACE = T.let(3600, Integer)
|
|
146
|
+
|
|
147
|
+
# The directory every source is filed under. Its layout is private --
|
|
148
|
+
# see the class comment.
|
|
149
|
+
sig { returns(String) }
|
|
150
|
+
attr_reader :root
|
|
151
|
+
|
|
152
|
+
sig { params(root: T.untyped).void }
|
|
153
|
+
def initialize(root: nil)
|
|
154
|
+
@root = T.let(-::File.expand_path((root || ActiveSanction.config.storage_dir).to_s), String)
|
|
155
|
+
super()
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Writes the list, then publishes it by replacing `meta.json`. The
|
|
159
|
+
# previous generation stays readable until that rename lands and is swept
|
|
160
|
+
# immediately after it.
|
|
161
|
+
sig { override.params(snapshot: T.untyped).returns(Snapshot) }
|
|
162
|
+
def write_snapshot(snapshot)
|
|
163
|
+
stored = snapshot!(snapshot)
|
|
164
|
+
directory = directory_for(source_key!(stored.source))
|
|
165
|
+
FileUtils.mkdir_p(directory)
|
|
166
|
+
|
|
167
|
+
path = snapshot_path(directory, stored.checksum)
|
|
168
|
+
write_atomically(path) { |file| compress(JSON.generate(stored.to_h), file) }
|
|
169
|
+
commit(directory, Meta.from_snapshot(stored))
|
|
170
|
+
prune(directory, path)
|
|
171
|
+
stored
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
sig { override.params(source: T.untyped).returns(T.nilable(Snapshot)) }
|
|
175
|
+
def read_snapshot(source)
|
|
176
|
+
key = source_key!(source)
|
|
177
|
+
directory = directory_for(key)
|
|
178
|
+
meta = read_meta(directory)
|
|
179
|
+
meta, json = read_list(directory, meta) if meta
|
|
180
|
+
return nil if json.nil? || meta.nil?
|
|
181
|
+
|
|
182
|
+
build(key, meta, json, snapshot_path(directory, meta.checksum))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Off the sidecar, without opening the list. What makes `sources` in the
|
|
186
|
+
# CLI (#36) and the per-source summary in sync (#34) cheap: printing how
|
|
187
|
+
# old six lists are reads six small JSON files rather than inflating and
|
|
188
|
+
# deserializing tens of megabytes.
|
|
189
|
+
sig { override.params(source: T.untyped).returns(T.nilable(Meta)) }
|
|
190
|
+
def snapshot_meta(source) = read_meta(directory_for(source_key!(source)))
|
|
191
|
+
|
|
192
|
+
sig { override.params(source: T.untyped).returns(T::Boolean) }
|
|
193
|
+
def delete_snapshot(source)
|
|
194
|
+
directory = directory_for(source_key!(source))
|
|
195
|
+
stored = ::File.file?(::File.join(directory, META_FILENAME))
|
|
196
|
+
FileUtils.rm_rf(directory)
|
|
197
|
+
stored
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Every directory under `root` holding a committed sidecar. Deliberately
|
|
201
|
+
# does not parse them: this is what `stored?`, `empty?` and `clear` are
|
|
202
|
+
# built on, and one unreadable list must not make the store impossible to
|
|
203
|
+
# inspect or to repair.
|
|
204
|
+
sig { override.returns(T::Array[Symbol]) }
|
|
205
|
+
def sources
|
|
206
|
+
return [] unless ::File.directory?(root)
|
|
207
|
+
|
|
208
|
+
Dir.children(root)
|
|
209
|
+
.select { |name| name.match?(SOURCE_PATTERN) && ::File.file?(::File.join(root, name, META_FILENAME)) }
|
|
210
|
+
.map(&:to_sym).sort
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
sig { override.returns(String) }
|
|
214
|
+
def inspect = "#<#{self.class} #{root} #{list}>"
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
sig { params(key: T.untyped).returns(String) }
|
|
219
|
+
def directory_for(key) = ::File.join(root, key.to_s)
|
|
220
|
+
|
|
221
|
+
sig { params(directory: String, checksum: T.untyped).returns(String) }
|
|
222
|
+
def snapshot_path(directory, checksum)
|
|
223
|
+
::File.join(directory, "#{SNAPSHOT_PREFIX}#{checksum_slug!(checksum)}#{SNAPSHOT_EXTENSION}")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
sig { params(checksum: T.untyped).returns(String) }
|
|
227
|
+
def checksum_slug!(checksum)
|
|
228
|
+
match = CHECKSUM_PATTERN.match(checksum.to_s)
|
|
229
|
+
raise CorruptSnapshot, "#{checksum.inspect} is not a #{Snapshot::ALGORITHM} checksum" if match.nil?
|
|
230
|
+
|
|
231
|
+
"#{Snapshot::ALGORITHM}-#{T.must(match[1]).downcase}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Bytes go to a `.part` sibling, are flushed to the platter, and only
|
|
235
|
+
# then take the real name. A rename within a directory is atomic, so no
|
|
236
|
+
# reader ever opens a partially written file -- it sees the previous one
|
|
237
|
+
# or the new one.
|
|
238
|
+
sig { params(path: String, block: T.proc.params(file: ::File).void).void }
|
|
239
|
+
def write_atomically(path, &block)
|
|
240
|
+
temporary = T.let("#{path}.#{Process.pid}-#{SecureRandom.hex(8)}.part", T.nilable(String))
|
|
241
|
+
::File.open(T.must(temporary), "wb") do |file|
|
|
242
|
+
block.call(file)
|
|
243
|
+
file.flush
|
|
244
|
+
file.fsync
|
|
245
|
+
end
|
|
246
|
+
::File.rename(T.must(temporary), path)
|
|
247
|
+
ensure
|
|
248
|
+
FileUtils.rm_f(temporary) if temporary
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The rename that publishes a write. Everything the new generation needs
|
|
252
|
+
# is already on disk and fsynced by the time this runs, so the list a
|
|
253
|
+
# reader gets is whole whichever side of it they arrive on.
|
|
254
|
+
sig { params(directory: String, meta: Meta).void }
|
|
255
|
+
def commit(directory, meta)
|
|
256
|
+
write_atomically(::File.join(directory, META_FILENAME)) do |file|
|
|
257
|
+
file.write("#{JSON.pretty_generate(meta.to_h)}\n")
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
sig { params(json: String, sink: ::File).void }
|
|
262
|
+
def compress(json, sink)
|
|
263
|
+
gzip = Zlib::GzipWriter.new(sink, COMPRESSION_LEVEL)
|
|
264
|
+
begin
|
|
265
|
+
gzip.write(json)
|
|
266
|
+
ensure
|
|
267
|
+
gzip.finish
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The list `meta` names and the meta it actually came from, or nil when
|
|
272
|
+
# the source is gone. A missing file is not corruption on its own:
|
|
273
|
+
# another process can have committed a new generation and swept this one
|
|
274
|
+
# between our reading the sidecar and our opening what it pointed at, so
|
|
275
|
+
# the sidecar is read again before the absence is believed -- and the
|
|
276
|
+
# generation that read finds is the one the caller is answered with.
|
|
277
|
+
sig { params(directory: String, meta: Meta).returns(T.nilable([Meta, String])) }
|
|
278
|
+
def read_list(directory, meta)
|
|
279
|
+
json = inflate(snapshot_path(directory, meta.checksum))
|
|
280
|
+
return [meta, json] unless json.nil?
|
|
281
|
+
|
|
282
|
+
current = read_meta(directory)
|
|
283
|
+
return nil if current.nil?
|
|
284
|
+
|
|
285
|
+
path = snapshot_path(directory, current.checksum)
|
|
286
|
+
[current,
|
|
287
|
+
inflate(path) || raise(CorruptSnapshot, corrupt(path, "the sidecar names a list that is not there"))]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
sig { params(path: String).returns(T.nilable(String)) }
|
|
291
|
+
def inflate(path)
|
|
292
|
+
Zlib.gunzip(::File.binread(path))
|
|
293
|
+
rescue Errno::ENOENT
|
|
294
|
+
nil
|
|
295
|
+
rescue Zlib::Error => e
|
|
296
|
+
raise CorruptSnapshot, corrupt(path, e.message)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
sig { params(directory: String).returns(T.nilable(Meta)) }
|
|
300
|
+
def read_meta(directory)
|
|
301
|
+
path = T.let(::File.join(directory, META_FILENAME), T.nilable(String))
|
|
302
|
+
hash = JSON.parse(::File.read(T.must(path)))
|
|
303
|
+
raise CorruptSnapshot, corrupt(T.must(path), "it does not hold a JSON object") unless hash.is_a?(Hash)
|
|
304
|
+
|
|
305
|
+
schema_version!(hash["schema_version"], T.must(path))
|
|
306
|
+
Meta.from_h(hash)
|
|
307
|
+
rescue Errno::ENOENT
|
|
308
|
+
nil
|
|
309
|
+
rescue JSON::ParserError, ArgumentError, TypeError => e
|
|
310
|
+
# `T.must` because Sorbet reads a `rescue` as reachable before the
|
|
311
|
+
# first assignment in the body; `path` is that assignment.
|
|
312
|
+
raise CorruptSnapshot, corrupt(T.must(path), e.message)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Checked against the sidecar before a byte of the list is inflated, so
|
|
316
|
+
# an unreadable schema costs a small read rather than tens of megabytes,
|
|
317
|
+
# and so it is caught before Snapshot.from_h has a chance to build
|
|
318
|
+
# plausible records out of a shape this version does not understand.
|
|
319
|
+
sig { params(value: T.untyped, path: String).returns(Integer) }
|
|
320
|
+
def schema_version!(value, path)
|
|
321
|
+
# `exception: false` answers nil for anything unparseable, which the
|
|
322
|
+
# stdlib RBI does not say -- hence the nilable annotation.
|
|
323
|
+
version = T.let(Integer(value, exception: false), T.nilable(Integer))
|
|
324
|
+
return version if !version.nil? && READABLE_SCHEMA_VERSIONS.cover?(version)
|
|
325
|
+
|
|
326
|
+
raise UnsupportedSchema,
|
|
327
|
+
"#{path} was written under snapshot schema_version #{value.inspect}; active_sanction #{VERSION} " \
|
|
328
|
+
"reads #{READABLE_SCHEMA_VERSIONS.first}-#{READABLE_SCHEMA_VERSIONS.last}. Upgrade the gem, or " \
|
|
329
|
+
"delete the directory and re-sync the source."
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Snapshot.from_h recomputes the checksum over the records that came back
|
|
333
|
+
# and refuses to build if it does not match the one stored with them,
|
|
334
|
+
# which is what catches a truncated or edited list. The two checks after
|
|
335
|
+
# it catch the pair coming apart: a sidecar describing a different
|
|
336
|
+
# generation than the file it points at, or a list filed under the wrong
|
|
337
|
+
# source.
|
|
338
|
+
sig { params(key: Symbol, meta: Meta, json: String, path: String).returns(Snapshot) }
|
|
339
|
+
def build(key, meta, json, path)
|
|
340
|
+
snapshot = Snapshot.from_h(JSON.parse(json))
|
|
341
|
+
wrong_generation = "it holds #{snapshot.checksum}, not the #{meta.checksum} recorded in #{META_FILENAME}"
|
|
342
|
+
raise CorruptSnapshot, corrupt(path, wrong_generation) if snapshot.checksum != meta.checksum
|
|
343
|
+
raise CorruptSnapshot, corrupt(path, "it is #{snapshot.source}, filed under #{key}") if snapshot.source != key
|
|
344
|
+
|
|
345
|
+
snapshot
|
|
346
|
+
rescue JSON::ParserError, ArgumentError, TypeError, Snapshot::ChecksumMismatch => e
|
|
347
|
+
raise CorruptSnapshot, corrupt(path, e.message)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
sig { params(path: String, detail: String).returns(String) }
|
|
351
|
+
def corrupt(path, detail)
|
|
352
|
+
"#{path} cannot be trusted to be the list it says it is (#{detail}). Nothing partial is returned from " \
|
|
353
|
+
"storage -- delete #{::File.dirname(path)} and re-sync the source to replace it."
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Everything the committed generation does not need: the list files of
|
|
357
|
+
# generations it replaced, and `.part` files old enough to be from a
|
|
358
|
+
# write that died rather than one in flight.
|
|
359
|
+
sig { params(directory: String, keep: String).void }
|
|
360
|
+
def prune(directory, keep)
|
|
361
|
+
Dir.glob(::File.join(directory, "#{SNAPSHOT_PREFIX}*#{SNAPSHOT_EXTENSION}")).each do |path|
|
|
362
|
+
FileUtils.rm_f(path) unless path == keep
|
|
363
|
+
end
|
|
364
|
+
cutoff = Time.now - ORPHAN_GRACE
|
|
365
|
+
Dir.glob(::File.join(directory, "*.part")).each do |path|
|
|
366
|
+
FileUtils.rm_f(path) if ::File.mtime(path) < cutoff
|
|
367
|
+
rescue Errno::ENOENT
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/storage/base"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Storage
|
|
10
|
+
# Snapshots for the life of one process, and no longer.
|
|
11
|
+
#
|
|
12
|
+
# store = ActiveSanction::Storage::Memory.new
|
|
13
|
+
# store.write_snapshot(source.sync)
|
|
14
|
+
#
|
|
15
|
+
# ActiveSanction::Storage::Memory.new([ofac, un]) # seeded, for a spec
|
|
16
|
+
#
|
|
17
|
+
# Two jobs, and the second is the one that keeps it honest. It is a real
|
|
18
|
+
# storage adapter -- a process that syncs and screens without wanting to
|
|
19
|
+
# own a directory should use it, and pays only a full download on each
|
|
20
|
+
# boot. It is also the test double for storage everywhere in this suite,
|
|
21
|
+
# which is why it is written as a subclass of Base implementing the same
|
|
22
|
+
# four methods as everything else rather than as a Hash a spec passes
|
|
23
|
+
# around: a double that is not held to the contract stops describing what
|
|
24
|
+
# the real adapters do, usually a release or two before anybody notices.
|
|
25
|
+
#
|
|
26
|
+
# Snapshots are frozen by construction, so what is handed back is the
|
|
27
|
+
# object that was stored and nothing has to be copied to keep a caller from
|
|
28
|
+
# editing the list underneath the store. The hash itself is guarded by a
|
|
29
|
+
# mutex: a web process screens on many threads (#33) while a scheduled sync
|
|
30
|
+
# replaces a list under them, and a replacement has to be atomic from a
|
|
31
|
+
# reader's point of view -- a thread mid-screen holds the snapshot it
|
|
32
|
+
# started with and finishes against a consistent list.
|
|
33
|
+
class Memory < Base
|
|
34
|
+
extend T::Sig
|
|
35
|
+
|
|
36
|
+
sig { params(snapshots: T.untyped).void }
|
|
37
|
+
def initialize(snapshots = [])
|
|
38
|
+
@snapshots = T.let({}, T::Hash[Symbol, Snapshot])
|
|
39
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
40
|
+
Array(snapshots).each { |snapshot| write_snapshot(snapshot) }
|
|
41
|
+
super()
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
sig { override.params(snapshot: T.untyped).returns(Snapshot) }
|
|
45
|
+
def write_snapshot(snapshot)
|
|
46
|
+
stored = snapshot!(snapshot)
|
|
47
|
+
@mutex.synchronize { @snapshots[stored.source] = stored }
|
|
48
|
+
stored
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { override.params(source: T.untyped).returns(T.nilable(Snapshot)) }
|
|
52
|
+
def read_snapshot(source)
|
|
53
|
+
key = source_key!(source)
|
|
54
|
+
@mutex.synchronize { @snapshots[key] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { override.params(source: T.untyped).returns(T::Boolean) }
|
|
58
|
+
def delete_snapshot(source)
|
|
59
|
+
key = source_key!(source)
|
|
60
|
+
@mutex.synchronize { !@snapshots.delete(key).nil? }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { override.returns(T::Array[Symbol]) }
|
|
64
|
+
def sources = @mutex.synchronize { @snapshots.keys.sort }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "time"
|
|
7
|
+
require "active_sanction/snapshot"
|
|
8
|
+
require "active_sanction/storage"
|
|
9
|
+
|
|
10
|
+
module ActiveSanction
|
|
11
|
+
module Storage
|
|
12
|
+
# What a store can say about a stored list without reading the list.
|
|
13
|
+
#
|
|
14
|
+
# store.snapshot_meta(:ofac_sdn)
|
|
15
|
+
# # => #<ActiveSanction::Storage::Meta ofac_sdn 19015 entities sha256:1f3b... 3h old>
|
|
16
|
+
#
|
|
17
|
+
# Every field here is small and every field a snapshot holds besides these
|
|
18
|
+
# is not: OFAC's is 19,015 entities and tens of megabytes of JSON. The
|
|
19
|
+
# questions an operator and a sync actually ask -- when was this last
|
|
20
|
+
# fetched, how old is it now, how many records are on it, is it still the
|
|
21
|
+
# version we screened against in January -- are all answerable from these
|
|
22
|
+
# six values, so they are worth being able to answer separately.
|
|
23
|
+
#
|
|
24
|
+
# That separation is what makes the two things downstream cheap:
|
|
25
|
+
#
|
|
26
|
+
# - `sources` in the CLI (#36) and the per-source summary in sync
|
|
27
|
+
# orchestration (#34) print an age per list. A store that had to
|
|
28
|
+
# deserialize every snapshot to print a table would make the cheapest
|
|
29
|
+
# command in the library the slowest.
|
|
30
|
+
# - Storage::FileSystem (#24) writes exactly this beside each snapshot as
|
|
31
|
+
# `meta.json`, so `#to_h` is that file's contents and `.from_h` reads it
|
|
32
|
+
# back. Base derives a Meta from the snapshot for adapters that have
|
|
33
|
+
# nothing cheaper; an adapter with a sidecar or a metadata row overrides
|
|
34
|
+
# `#snapshot_meta` and never opens the list.
|
|
35
|
+
#
|
|
36
|
+
# `checksum` is the one that outlives the rest. A match result cites it
|
|
37
|
+
# (#33), so "which list version cleared this customer" is answered by
|
|
38
|
+
# comparing a stored meta against a checksum in an audit record -- without
|
|
39
|
+
# loading either list.
|
|
40
|
+
#
|
|
41
|
+
# Instances are frozen on construction and compare by value.
|
|
42
|
+
class Meta
|
|
43
|
+
extend T::Sig
|
|
44
|
+
|
|
45
|
+
# @api private
|
|
46
|
+
MEMBERS = T.let(
|
|
47
|
+
%i[source fetched_at checksum record_count schema_version source_version].freeze,
|
|
48
|
+
T::Array[Symbol]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
sig { returns(Symbol) }
|
|
52
|
+
attr_reader :source
|
|
53
|
+
|
|
54
|
+
sig { returns(Time) }
|
|
55
|
+
attr_reader :fetched_at
|
|
56
|
+
|
|
57
|
+
# The one field that outlives the rest: a match result cites it, so a
|
|
58
|
+
# stored meta answers "which list version cleared this customer".
|
|
59
|
+
sig { returns(String) }
|
|
60
|
+
attr_reader :checksum
|
|
61
|
+
|
|
62
|
+
sig { returns(Integer) }
|
|
63
|
+
attr_reader :record_count
|
|
64
|
+
|
|
65
|
+
sig { returns(Integer) }
|
|
66
|
+
attr_reader :schema_version
|
|
67
|
+
|
|
68
|
+
sig { returns(T.nilable(String)) }
|
|
69
|
+
attr_reader :source_version
|
|
70
|
+
|
|
71
|
+
# The metadata of a snapshot already in hand. What a store that keeps no
|
|
72
|
+
# separate record of it answers `#snapshot_meta` with.
|
|
73
|
+
sig { params(snapshot: Snapshot).returns(T.attached_class) }
|
|
74
|
+
def self.from_snapshot(snapshot)
|
|
75
|
+
new(source: snapshot.source, fetched_at: snapshot.fetched_at, checksum: snapshot.checksum,
|
|
76
|
+
record_count: snapshot.record_count, schema_version: snapshot.schema_version,
|
|
77
|
+
source_version: snapshot.source_version)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Rebuilds from #to_h output, accepting string keys so a sidecar survives
|
|
81
|
+
# the round-trip through JSON.
|
|
82
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
83
|
+
def self.from_h(hash)
|
|
84
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
85
|
+
unknown = attributes.keys - MEMBERS
|
|
86
|
+
raise InvalidArgument, "unknown Meta attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
87
|
+
|
|
88
|
+
# `new(**hash)` past required keyword parameters is one of the few
|
|
89
|
+
# things Sorbet cannot check statically. #initialize validates what
|
|
90
|
+
# arrives, which is where a bad sidecar is caught.
|
|
91
|
+
T.unsafe(self).new(**attributes)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sig do
|
|
95
|
+
params(source: T.untyped, fetched_at: T.untyped, checksum: T.untyped, record_count: T.untyped,
|
|
96
|
+
schema_version: T.untyped, source_version: T.untyped).void
|
|
97
|
+
end
|
|
98
|
+
def initialize(source:, fetched_at:, checksum:, record_count:, schema_version: Snapshot::SCHEMA_VERSION,
|
|
99
|
+
source_version: nil)
|
|
100
|
+
@source = T.let(symbol!(:source, source), Symbol)
|
|
101
|
+
@fetched_at = T.let(time!(fetched_at), Time)
|
|
102
|
+
@checksum = T.let(string!(:checksum, checksum), String)
|
|
103
|
+
@record_count = T.let(count!(record_count), Integer)
|
|
104
|
+
@schema_version = T.let(Integer(schema_version), Integer)
|
|
105
|
+
@source_version = T.let(source_version.nil? ? nil : -source_version.to_s, T.nilable(String))
|
|
106
|
+
freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# How long ago this list was fetched, in seconds. Sync (#34) reports it
|
|
110
|
+
# per source: a list that failed to refresh keeps its previous snapshot,
|
|
111
|
+
# which is the right call and only safe if the age of what is being
|
|
112
|
+
# screened against is visible.
|
|
113
|
+
sig { params(now: Time).returns(Integer) }
|
|
114
|
+
def age(now = Time.now) = now.to_i - fetched_at.to_i
|
|
115
|
+
|
|
116
|
+
# Whether this is the same list content as another meta, or as a snapshot
|
|
117
|
+
# about to be written. Ignores when either was fetched, because a refetch
|
|
118
|
+
# of an unchanged list is not a new version of it.
|
|
119
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
120
|
+
def same_content?(other) = !other.nil? && other.checksum == checksum
|
|
121
|
+
|
|
122
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
123
|
+
def to_h
|
|
124
|
+
{
|
|
125
|
+
source: source,
|
|
126
|
+
fetched_at: fetched_at.iso8601,
|
|
127
|
+
checksum: checksum,
|
|
128
|
+
record_count: record_count,
|
|
129
|
+
schema_version: schema_version,
|
|
130
|
+
source_version: source_version
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
135
|
+
def ==(other)
|
|
136
|
+
return false unless other.instance_of?(self.class)
|
|
137
|
+
|
|
138
|
+
to_h == other.to_h
|
|
139
|
+
end
|
|
140
|
+
alias eql? ==
|
|
141
|
+
|
|
142
|
+
sig { returns(Integer) }
|
|
143
|
+
def hash = [self.class, to_h].hash
|
|
144
|
+
|
|
145
|
+
sig { returns(String) }
|
|
146
|
+
def inspect
|
|
147
|
+
"#<#{self.class} #{source} #{record_count} entities #{checksum} fetched_at=#{fetched_at.iso8601}>"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# Truncated to the second, the precision #to_h serializes, so a meta read
|
|
153
|
+
# back off disk is equal to the one that was written.
|
|
154
|
+
sig { params(value: T.untyped).returns(Time) }
|
|
155
|
+
def time!(value)
|
|
156
|
+
time = case value
|
|
157
|
+
when Time then value
|
|
158
|
+
when String then Time.parse(value)
|
|
159
|
+
else raise InvalidArgument, "fetched_at is not a time: #{value.inspect}"
|
|
160
|
+
end
|
|
161
|
+
Time.at(time.to_i).utc
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
|
|
165
|
+
def symbol!(member, value)
|
|
166
|
+
raise InvalidArgument, "#{member} is required" if value.nil? || value.to_s.empty?
|
|
167
|
+
|
|
168
|
+
value.to_sym
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
sig { params(member: Symbol, value: T.untyped).returns(String) }
|
|
172
|
+
def string!(member, value)
|
|
173
|
+
string = value.to_s.strip
|
|
174
|
+
raise InvalidArgument, "#{member} is required" if string.empty?
|
|
175
|
+
|
|
176
|
+
-string
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
sig { params(value: T.untyped).returns(Integer) }
|
|
180
|
+
def count!(value)
|
|
181
|
+
integer = Integer(value)
|
|
182
|
+
raise InvalidArgument, "record_count cannot be negative, got #{integer}" if integer.negative?
|
|
183
|
+
|
|
184
|
+
integer
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|