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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. 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