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,381 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "json"
7
+ require "active_sanction/entity"
8
+ require "active_sanction/error"
9
+ require "active_sanction/snapshot"
10
+ require "active_sanction/sources"
11
+ require "active_sanction/version"
12
+
13
+ module ActiveSanction
14
+ class Snapshot
15
+ # One list, in one file, that another machine can load and trust without
16
+ # ever reaching the publisher.
17
+ #
18
+ # File.open("ofac_sdn.asb", "wb") do |io|
19
+ # ActiveSanction::Snapshot::Bundle.write(snapshot, io: io, sign_with: private_key)
20
+ # end
21
+ #
22
+ # File.open("ofac_sdn.asb", "rb") do |io|
23
+ # snapshot = ActiveSanction::Snapshot::Bundle.read(io, verify_with: public_key)
24
+ # snapshot.trusted? # => true
25
+ # end
26
+ #
27
+ # `ActiveSanction.export` and `.import` are the sugar over this that most
28
+ # applications want; this is the format itself, and it is public API. The
29
+ # byte-level specification is docs/bundle_format.md, which is written so
30
+ # that a bundle can be produced and read by something that is not this gem
31
+ # and not Ruby.
32
+ #
33
+ # ### Why a file, when there is already a store
34
+ #
35
+ # Storage::FileSystem writes gzipped JSON too, and its layout is explicitly
36
+ # private and expected to change. This is the opposite thing: a published
37
+ # artifact with a stability contract, which three situations need and a
38
+ # directory cannot serve.
39
+ #
40
+ # - **Publishers go down.** OFAC breaks, changes format, and rate-limits. A
41
+ # bundle produced once and copied is the difference between a bad
42
+ # afternoon at Treasury and a failed deploy for everyone downstream.
43
+ # - **Air-gapped and privacy-sensitive installations.** A compliance team
44
+ # that will not send subject names to a third-party API will happily
45
+ # consume fresh data. Only a file serves them.
46
+ # - **Audit.** A checksum proves a list is internally intact. A signature
47
+ # proves it is *the one that was published*, which is the claim an
48
+ # examiner is actually asking about.
49
+ #
50
+ # ### The shape of a bundle
51
+ #
52
+ # ACTIVESANCTION-BUNDLE/1\n magic, and the format version
53
+ # {"format_version":1,...}\n one canonical line -- see Header
54
+ # ecdsa-sha256 MEUCIQ...\n or "-" -- see Signature
55
+ # <deflated NDJSON> to EOF -- see Payload
56
+ #
57
+ # The first three lines are text on purpose: `head -c 512` on a bundle tells
58
+ # an operator what list it holds, how many records, from when, and who says
59
+ # so, without a tool and without decompressing anything.
60
+ #
61
+ # ### Determinism
62
+ #
63
+ # Two writes of one snapshot produce one file. Records are ordered by the
64
+ # fingerprint Snapshot's own checksum is built from rather than by whatever
65
+ # order a publisher's file happened to arrive in, the header's keys have a
66
+ # fixed order, the compression level is named by the specification rather
67
+ # than taken from a build's default, and there is deliberately no
68
+ # written-at timestamp anywhere in the file.
69
+ #
70
+ # What that buys is comparability: two mirrors that bundled the same
71
+ # snapshot can be held against each other byte for byte. The invariant that
72
+ # survives everything, including a zlib that packs differently, is the
73
+ # header line -- it pins the content, through `payload_digest`, and the
74
+ # provenance. A signature line is the one part that may differ between two
75
+ # signings, because ECDSA is randomized.
76
+ #
77
+ # ### What it refuses to do
78
+ #
79
+ # Return anything it cannot prove, on the same rule the stores follow. A
80
+ # flipped byte, a truncated download and an edited record all raise Corrupt
81
+ # rather than screening against a list that is quietly missing somebody. A
82
+ # format version from a newer gem raises UnsupportedFormat *before* the
83
+ # payload is touched, because a newer shape will usually deserialize into
84
+ # plausible, wrong records. And a snapshot only comes back `trusted?` when a
85
+ # key was supplied and the signature verified under it.
86
+ module Bundle
87
+ extend T::Sig
88
+ extend T::Helpers
89
+
90
+ # Called as `Bundle.read` -- module functions on a module, which is an
91
+ # Object, which is where `raise` comes from.
92
+ requires_ancestor { Kernel }
93
+
94
+ # Bumped when the container changes shape. Readers refuse anything above
95
+ # what they know rather than guessing, which is the whole reason it is on
96
+ # the first line of the file.
97
+ #
98
+ # @api private
99
+ FORMAT_VERSION = T.let(1, Integer)
100
+
101
+ # Formats this code can read. A bundle below the version it writes still
102
+ # round-trips; one above it does not, and says so.
103
+ #
104
+ # @api private
105
+ READABLE_FORMAT_VERSIONS = T.let(1..FORMAT_VERSION, T::Range[Integer])
106
+
107
+ # Snapshot schemas this code can read, held to the same range
108
+ # Storage::FileSystem holds a stored list to.
109
+ #
110
+ # @api private
111
+ READABLE_SCHEMA_VERSIONS = T.let(1..Snapshot::SCHEMA_VERSION, T::Range[Integer])
112
+
113
+ # @api private
114
+ MAGIC = T.let("ACTIVESANCTION-BUNDLE", String)
115
+
116
+ # @api private
117
+ MAGIC_PATTERN = T.let(%r{\A#{MAGIC}/(\d+)\z}, Regexp)
118
+
119
+ # The conventional extension, and what the CLI-shaped helpers default to.
120
+ #
121
+ # @api private
122
+ EXTENSION = T.let(".asb", String)
123
+
124
+ # How the records are laid out, and how they are packed. Named in the
125
+ # header of every bundle so that a v1 reader can refuse a v1 file that
126
+ # uses something it has never heard of, rather than misreading it.
127
+ #
128
+ # @api private
129
+ ENCODING = T.let("ndjson", String)
130
+ # @api private
131
+ COMPRESSION = T.let("deflate", String)
132
+
133
+ # The longest any of the three text lines may be. A malformed file must
134
+ # not be read as one 400 MB line before anything notices it is malformed.
135
+ #
136
+ # @api private
137
+ MAX_LINE_BYTES = T.let(64 * 1024, Integer)
138
+
139
+ # This bundle is not what it says it is: it does not begin like a bundle,
140
+ # it stops in the middle, its records no longer hash to the digest in its
141
+ # header, or it holds a different number of them than it claims.
142
+ #
143
+ # Never repaired and never partially returned, for the reason IntegrityError
144
+ # exists: a list that is quietly half there produces a report that looks
145
+ # exactly like a clean one.
146
+ class Corrupt < IntegrityError; end
147
+
148
+ # This bundle is intact, and somebody other than the expected publisher
149
+ # signed it. Deliberately a different error from Corrupt: "these bytes
150
+ # were damaged" and "these bytes came from somewhere else" are different
151
+ # incidents, and only one of them is fixed by downloading the file again.
152
+ class UntrustedSignature < IntegrityError; end
153
+
154
+ # Verification was asked for and there is no signature to verify. A
155
+ # subclass, so `rescue UntrustedSignature` covers both, while an operator
156
+ # can still tell "our publisher did not sign this" from "somebody else
157
+ # did".
158
+ class Unsigned < UntrustedSignature; end
159
+
160
+ # A bundle written under a format version, a snapshot schema or a payload
161
+ # encoding this code does not know -- almost always because it was written
162
+ # by a newer active_sanction.
163
+ #
164
+ # Separate from Corrupt because the file is fine and the fix is different:
165
+ # upgrade the gem. Raised before the payload is read, for the reason
166
+ # Storage::UnsupportedSchema is raised before a stored list is parsed.
167
+ class UnsupportedFormat < StorageError; end
168
+
169
+ module_function
170
+
171
+ # Writes `snapshot` to `io` and returns the Header it wrote.
172
+ #
173
+ # Bundle.write(snapshot, io: io) # unsigned, and fully usable
174
+ # Bundle.write(snapshot, io: io, sign_with: key) # OpenSSL::PKey, or a PEM
175
+ # Bundle.write(snapshot, io: io, generator: "acme/2.0") # whose bundle this is
176
+ #
177
+ # `generator` is the one field a publisher other than this gem should set.
178
+ # It is who published the file; `gem_version`, which is not overridable,
179
+ # is what serialized the records.
180
+ sig do
181
+ params(snapshot: T.untyped, io: T.untyped, sign_with: T.untyped, generator: T.untyped).returns(Header)
182
+ end
183
+ def write(snapshot, io:, sign_with: nil, generator: nil)
184
+ stored = snapshot!(snapshot)
185
+ payload, digest, bytes = Payload.pack(records(stored))
186
+ header = Header.from_snapshot(stored, payload_digest: digest, payload_bytes: bytes, generator: generator)
187
+ line = header.to_line
188
+ io.binmode if io.respond_to?(:binmode)
189
+ io.write("#{MAGIC}/#{FORMAT_VERSION}\n", "#{line}\n", "#{Signature.sign(line, sign_with)}\n", payload)
190
+ header
191
+ end
192
+
193
+ # Reads a bundle, verifying it as it goes, and returns the Snapshot.
194
+ #
195
+ # Bundle.read(io) # => Snapshot, trusted? false
196
+ # Bundle.read(io, verify_with: key) # => Snapshot, trusted? true, or an exception
197
+ #
198
+ # Without `verify_with:` the signature is not looked at: an unsigned
199
+ # bundle is a first-class bundle, and a signed one read without a key is
200
+ # exactly as useful as an unsigned one -- its records are still proven
201
+ # against the digest in its header and against the snapshot checksum.
202
+ # What it is not is attested, and `trusted?` says so.
203
+ #
204
+ # With a key, the signature is checked **before a byte of the payload is
205
+ # inflated**. Compressed data from a party that has not authenticated is
206
+ # the last thing anybody should expand.
207
+ sig { params(io: T.untyped, verify_with: T.untyped).returns(Snapshot) }
208
+ def read(io, verify_with: nil)
209
+ io.binmode if io.respond_to?(:binmode)
210
+ version = magic!(io)
211
+ line = line!(io, "header")
212
+ header = supported!(parse!(line), version)
213
+ trusted = verified!(line!(io, "signature"), line, verify_with)
214
+ build(header, entities(io, header), trusted)
215
+ end
216
+
217
+ # What a bundle says about itself, without reading its records:
218
+ #
219
+ # header = File.open(path, "rb") { |io| Bundle.header(io) }
220
+ # header.record_count # => 19015
221
+ # header.snapshot_checksum # => "sha256:9f86d081884c7d65..."
222
+ #
223
+ # Storage::Meta's job for a file somebody sent you, and cheap for the same
224
+ # reason: deciding whether a 25 MB bundle holds a list you already have
225
+ # should cost a few hundred bytes.
226
+ sig { params(io: T.untyped).returns(Header) }
227
+ def header(io)
228
+ io.binmode if io.respond_to?(:binmode)
229
+ version = magic!(io)
230
+ supported!(parse!(line!(io, "header")), version)
231
+ end
232
+
233
+ # The records of a snapshot, in the order a bundle lays them out.
234
+ #
235
+ # Sorted by the fingerprint Snapshot's checksum is built from, so that two
236
+ # snapshots holding the same entities in the order two publishers happened
237
+ # to emit them produce identical files -- and so that the order of a
238
+ # payload and the meaning of a checksum can never drift apart. Serializing
239
+ # each entity twice, once to fingerprint it and once to write it, is what
240
+ # one definition of "the fingerprint of an entity" costs; an export is not
241
+ # a hot path.
242
+ sig { params(snapshot: Snapshot).returns(T::Array[String]) }
243
+ def records(snapshot)
244
+ snapshot.entities
245
+ .sort_by { |entity| Snapshot.fingerprint(entity) }
246
+ .map { |entity| JSON.generate(entity.to_h) }
247
+ end
248
+
249
+ # The format version off the first line, checked before anything else in
250
+ # the file is looked at.
251
+ sig { params(io: T.untyped).returns(Integer) }
252
+ def magic!(io)
253
+ match = MAGIC_PATTERN.match(line!(io, "magic"))
254
+ raise Corrupt, "this is not an active_sanction bundle: it does not begin with #{MAGIC}/<version>" if match.nil?
255
+
256
+ version = Integer(T.must(match[1]))
257
+ return version if READABLE_FORMAT_VERSIONS.cover?(version)
258
+
259
+ raise UnsupportedFormat, format_message(version)
260
+ end
261
+
262
+ sig { params(line: String).returns(Header) }
263
+ def parse!(line)
264
+ Header.parse(line)
265
+ rescue JSON::ParserError, ArgumentError, TypeError, Sources::DeclarationError => e
266
+ raise Corrupt, "this bundle's header cannot be read (#{e.message})"
267
+ end
268
+
269
+ # Everything about a header that decides whether this code may read the
270
+ # payload at all. All of it happens before the payload is touched.
271
+ sig { params(header: Header, version: Integer).returns(Header) }
272
+ def supported!(header, version)
273
+ if header.format_version != version
274
+ raise Corrupt, "this bundle is labelled #{MAGIC}/#{version} and its header says format_version " \
275
+ "#{header.format_version}. The two have to agree -- only one of them is signed"
276
+ end
277
+ raise UnsupportedFormat, schema_message(header) unless READABLE_SCHEMA_VERSIONS.cover?(header.schema_version)
278
+ raise UnsupportedFormat, payload_message(header) unless readable_payload?(header)
279
+
280
+ header
281
+ end
282
+
283
+ sig { params(header: Header).returns(T::Boolean) }
284
+ def readable_payload?(header)
285
+ header.payload_encoding == ENCODING && header.payload_compression == COMPRESSION
286
+ end
287
+
288
+ # Whether this bundle was proven to come from the holder of `key`, or the
289
+ # exception that says why it was not. False -- rather than an exception --
290
+ # only when no key was given, which is the caller saying they do not care.
291
+ sig { params(signature: String, header_line: String, key: T.untyped).returns(T::Boolean) }
292
+ def verified!(signature, header_line, key)
293
+ return false if key.nil?
294
+
295
+ Signature.verify!(signature, header_line, key)
296
+ end
297
+
298
+ # The records, streamed, held against everything the header promised
299
+ # about them.
300
+ sig { params(io: T.untyped, header: Header).returns(T::Array[T.untyped]) }
301
+ def entities(io, header)
302
+ built = T.let([], T::Array[T.untyped])
303
+ digest, bytes = Payload.unpack(io, limit: header.payload_bytes) { |record| built << entity!(record) }
304
+ unless digest == header.payload_digest && bytes == header.payload_bytes
305
+ raise Corrupt, digest_message(header, digest, bytes)
306
+ end
307
+
308
+ built
309
+ end
310
+
311
+ sig { params(record: String).returns(T.untyped) }
312
+ def entity!(record)
313
+ Entity.from_h(JSON.parse(record))
314
+ rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
315
+ raise Corrupt, "a record in this bundle cannot be read (#{e.message})"
316
+ end
317
+
318
+ # The snapshot itself, which re-derives its own checksum over the records
319
+ # that actually arrived and refuses to be built if it does not match the
320
+ # one in the header. The same defence a store relies on, applied to a file
321
+ # that came from somebody else.
322
+ sig { params(header: Header, entities: T::Array[T.untyped], trusted: T::Boolean).returns(Snapshot) }
323
+ def build(header, entities, trusted)
324
+ Snapshot.new(source: header.source, entities: entities, fetched_at: header.fetched_at,
325
+ checksum: header.snapshot_checksum, record_count: header.record_count,
326
+ schema_version: header.schema_version, source_version: header.source_version,
327
+ trusted: trusted)
328
+ rescue Snapshot::ChecksumMismatch, InvalidArgument => e
329
+ raise Corrupt, "this bundle does not hold the list its header describes (#{e.message})"
330
+ end
331
+
332
+ # One line of the container, refused rather than read without limit.
333
+ sig { params(io: T.untyped, what: String).returns(String) }
334
+ def line!(io, what)
335
+ line = io.gets("\n", MAX_LINE_BYTES)
336
+ raise Corrupt, "this bundle ends before its #{what} line" if line.nil? || line.empty?
337
+ raise Corrupt, "this bundle's #{what} line is longer than #{MAX_LINE_BYTES} bytes" unless line.end_with?("\n")
338
+
339
+ line.chomp.force_encoding(Encoding::UTF_8)
340
+ end
341
+
342
+ sig { params(value: T.untyped).returns(Snapshot) }
343
+ def snapshot!(value)
344
+ return value if value.is_a?(Snapshot)
345
+
346
+ raise InvalidArgument, "Bundle.write takes an ActiveSanction::Snapshot, got #{value.class}"
347
+ end
348
+
349
+ sig { params(version: Integer).returns(String) }
350
+ def format_message(version)
351
+ "this bundle is written in bundle format version #{version}; active_sanction #{VERSION} reads " \
352
+ "#{READABLE_FORMAT_VERSIONS.first}-#{READABLE_FORMAT_VERSIONS.last}. Upgrade the gem -- nothing here " \
353
+ "can read it partially, and a format this code guessed at would produce a list nobody could defend"
354
+ end
355
+
356
+ sig { params(header: Header).returns(String) }
357
+ def schema_message(header)
358
+ "this bundle holds records written under snapshot schema_version #{header.schema_version} by " \
359
+ "#{header.generator}; active_sanction #{VERSION} reads #{READABLE_SCHEMA_VERSIONS.first}-" \
360
+ "#{READABLE_SCHEMA_VERSIONS.last}. Upgrade the gem, or ask the publisher for a bundle this old"
361
+ end
362
+
363
+ sig { params(header: Header).returns(String) }
364
+ def payload_message(header)
365
+ "this bundle's payload is #{header.payload_encoding}/#{header.payload_compression}; active_sanction " \
366
+ "#{VERSION} reads #{ENCODING}/#{COMPRESSION}. Upgrade the gem"
367
+ end
368
+
369
+ sig { params(header: Header, digest: String, bytes: Integer).returns(String) }
370
+ def digest_message(header, digest, bytes)
371
+ "this bundle's records hash to #{digest} over #{bytes} bytes, not the #{header.payload_digest} over " \
372
+ "#{header.payload_bytes} its header declares. The file was damaged in transit or edited after it was " \
373
+ "written -- fetch it again rather than screening against it"
374
+ end
375
+ end
376
+ end
377
+ end
378
+
379
+ require "active_sanction/snapshot/bundle/header"
380
+ require "active_sanction/snapshot/bundle/payload"
381
+ require "active_sanction/snapshot/bundle/signature"
@@ -0,0 +1,305 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "digest"
7
+ require "json"
8
+ require "time"
9
+ require "active_sanction/error"
10
+ require "active_sanction/entity"
11
+
12
+ module ActiveSanction
13
+ # One source's entities as they stood at one moment, with a checksum over
14
+ # their content. This is the unit of persistence (#24) and the anchor for
15
+ # reproducibility.
16
+ #
17
+ # ActiveSanction::Snapshot.new(
18
+ # source: :ofac_sdn,
19
+ # entities: [Entity, ...],
20
+ # fetched_at: Time.now.utc,
21
+ # source_version: "2026-08-28" # the publisher's own date, if it gives one
22
+ # )
23
+ #
24
+ # A screening decision has to be reproducible months later, in front of an
25
+ # examiner. MatchResult (#33) stamps `checksum` onto every result, so "why
26
+ # did we clear this customer on 5 Jan" is answered by reloading the exact
27
+ # list version that was screened against. Without that the library produces
28
+ # unauditable results.
29
+ #
30
+ # Instances are frozen on construction and compare by value.
31
+ class Snapshot
32
+ extend T::Sig
33
+
34
+ # Raised when a stored snapshot's content no longer hashes to the checksum
35
+ # stored beside it: the file is corrupt, was edited, or was written by a
36
+ # serializer this version does not agree with. Never silently repaired --
37
+ # a snapshot that cannot prove what it contains cannot anchor an audit.
38
+ class ChecksumMismatch < IntegrityError; end
39
+
40
+ # Bumped whenever the serialized form changes shape. Stored snapshots carry
41
+ # the version they were written under so they can be migrated or discarded
42
+ # rather than silently misread; it is folded into the checksum, so content
43
+ # that means one thing under v1 and another under v2 cannot collide.
44
+ #
45
+ # v2 added Entity#dates_of_birth, which the UN adapter (#21) needed and the
46
+ # canonical model had no slot for.
47
+ #
48
+ # @api private
49
+ SCHEMA_VERSION = T.let(2, Integer)
50
+
51
+ # Canonical member order, matching the layout #to_h must produce.
52
+ #
53
+ # @api private
54
+ MEMBERS = T.let(
55
+ %i[source entities fetched_at checksum record_count schema_version source_version].freeze,
56
+ T::Array[Symbol]
57
+ )
58
+
59
+ # @api private
60
+ ALGORITHM = T.let("sha256", String)
61
+
62
+ sig { returns(Symbol) }
63
+ attr_reader :source
64
+
65
+ # Deliberately not `T::Array[Entity]`, and the one member of the canonical
66
+ # model that is not declared. The storage conformance group builds a
67
+ # snapshot out of half-deserialized hashes on purpose, to prove it catches
68
+ # a store that hands them back that way (#24); #entities! below states the
69
+ # real contract -- anything that serializes -- in a message written for
70
+ # whoever has to fix the adapter. An element type would raise a TypeError
71
+ # there instead, one layer too early to say anything useful.
72
+ sig { returns(T::Array[T.untyped]) }
73
+ attr_reader :entities
74
+
75
+ # UTC, truncated to the second, which is the precision #to_h serializes.
76
+ sig { returns(Time) }
77
+ attr_reader :fetched_at
78
+
79
+ # `sha256:` and 64 hex digits, over the content and nothing else.
80
+ sig { returns(String) }
81
+ attr_reader :checksum
82
+
83
+ sig { returns(Integer) }
84
+ attr_reader :record_count
85
+
86
+ sig { returns(Integer) }
87
+ attr_reader :schema_version
88
+
89
+ # The publisher's own version string where it gives one, which is not
90
+ # something every list does.
91
+ sig { returns(T.nilable(String)) }
92
+ attr_reader :source_version
93
+
94
+ # Rebuilds a snapshot from #to_h output, verifying the checksum as it goes.
95
+ # Accepts string keys, so a snapshot survives the round-trip through
96
+ # gzipped JSON that storage (#24) puts it through.
97
+ sig { params(hash: T.untyped).returns(T.attached_class) }
98
+ def self.from_h(hash)
99
+ attributes = hash.to_h.transform_keys(&:to_sym)
100
+ unknown = attributes.keys - MEMBERS
101
+ raise InvalidArgument, "unknown Snapshot attribute(s): #{unknown.join(", ")}" if unknown.any?
102
+
103
+ attributes[:entities] &&= attributes[:entities].map { |value| build_entity(value) }
104
+ # `new(**hash)` past required keyword parameters is one of the few things
105
+ # Sorbet cannot check statically. #initialize validates what arrives --
106
+ # including, here, the checksum -- which is where a bad round-trip is
107
+ # caught.
108
+ T.unsafe(self).new(**attributes)
109
+ end
110
+
111
+ # Entities that are already objects pass through untouched, so from_h is
112
+ # safe to call on a half-deserialized hash.
113
+ sig { params(value: T.untyped).returns(T.untyped) }
114
+ def self.build_entity(value)
115
+ value.is_a?(Hash) ? Entity.from_h(value) : value
116
+ end
117
+ private_class_method :build_entity
118
+
119
+ # The content address of one entity: the SHA-256 of its serialized form.
120
+ #
121
+ # What #checksum is built out of, and what the bundle format (#57) sorts a
122
+ # payload by -- one definition rather than two, because an order that
123
+ # disagreed with the checksum's would be reproducible in a way nothing here
124
+ # could check.
125
+ #
126
+ # @api private
127
+ sig { params(entity: T.untyped).returns(String) }
128
+ def self.fingerprint(entity) = Digest::SHA256.hexdigest(JSON.generate(entity.to_h))
129
+
130
+ # `checksum` and `record_count` are derived, not supplied. Passing them --
131
+ # which is what .from_h does with a stored snapshot -- asserts what the
132
+ # content should be, and construction fails if it is not.
133
+ sig do
134
+ params(source: T.untyped, entities: T.untyped, fetched_at: T.untyped, checksum: T.untyped,
135
+ record_count: T.untyped, schema_version: T.untyped, source_version: T.untyped,
136
+ trusted: T.untyped).void
137
+ end
138
+ def initialize(source:, entities:, fetched_at: nil, checksum: nil, record_count: nil,
139
+ schema_version: SCHEMA_VERSION, source_version: nil, trusted: false)
140
+ @source = T.let(symbol!(:source, source), Symbol)
141
+ @entities = T.let(entities!(entities), T::Array[T.untyped])
142
+ @fetched_at = T.let(time!(fetched_at), Time)
143
+ @schema_version = T.let(version!(schema_version), Integer)
144
+ @source_version = T.let(string_or_nil(source_version), T.nilable(String))
145
+ @record_count = T.let(count!(record_count), Integer)
146
+ @checksum = T.let(checksum!(checksum), String)
147
+ @trusted = T.let(trusted == true, T::Boolean)
148
+ freeze
149
+ end
150
+
151
+ sig { returns(T::Boolean) }
152
+ def empty? = entities.empty?
153
+
154
+ # Whether this snapshot's *provenance* was verified: it came out of a
155
+ # bundle (#57) whose signature checked out under a public key the caller
156
+ # supplied. False for everything else, including a list this process
157
+ # fetched itself -- a sync proves that bytes parsed, not who published
158
+ # them.
159
+ #
160
+ # ### Why it is not a member
161
+ #
162
+ # It is out of MEMBERS, out of #to_h, out of the checksum and out of
163
+ # equality, because it is not a fact about the content. Two snapshots
164
+ # holding the same entities are the same list version whether or not one
165
+ # of them arrived signed, and a checksum that said otherwise would report
166
+ # a new list every time somebody imported one.
167
+ #
168
+ # ### It does not survive storage
169
+ #
170
+ # store.write_snapshot(bundle_snapshot)
171
+ # store.read_snapshot(:ofac_sdn).trusted? # => false
172
+ #
173
+ # Deliberately, and it is the honest answer. A signature attests to the
174
+ # bytes of a bundle file; once those entities have been rewritten into a
175
+ # store's own gzipped JSON or its own table, nothing signed covers what is
176
+ # on disk, and a stored flag claiming otherwise would be the library
177
+ # laundering an attestation it no longer holds. What a store guarantees is
178
+ # its own -- the checksum, re-derived on every read.
179
+ #
180
+ # So a process that wants `MatchResult#verified?` to be true keeps the
181
+ # bundle's snapshot in memory: Storage::Memory holds the object it was
182
+ # given, and Matcher reads this off the very snapshot it indexed.
183
+ sig { returns(T::Boolean) }
184
+ def trusted? = @trusted
185
+
186
+ sig { returns(T::Hash[Symbol, T.untyped]) }
187
+ def to_h
188
+ {
189
+ source: source,
190
+ entities: entities.map(&:to_h),
191
+ fetched_at: fetched_at.iso8601,
192
+ checksum: checksum,
193
+ record_count: record_count,
194
+ schema_version: schema_version,
195
+ source_version: source_version
196
+ }
197
+ end
198
+
199
+ # Two fetches of an unchanged list are the same snapshot with different
200
+ # timestamps, and the checksum is what says so; equality follows it rather
201
+ # than #to_h so a re-fetch does not read as a new list version.
202
+ sig { params(other: T.untyped).returns(T::Boolean) }
203
+ def ==(other)
204
+ return false unless other.instance_of?(self.class)
205
+
206
+ checksum == other.checksum
207
+ end
208
+ alias eql? ==
209
+
210
+ sig { returns(Integer) }
211
+ def hash
212
+ [self.class, checksum].hash
213
+ end
214
+
215
+ sig { returns(String) }
216
+ def inspect
217
+ "#<#{self.class} #{source} #{record_count} entities #{checksum} " \
218
+ "fetched_at=#{fetched_at.iso8601}#{" verified" if trusted?}>"
219
+ end
220
+
221
+ private
222
+
223
+ # Order is a fact about how a publisher happened to emit its file, not
224
+ # about what the file says, so each entity is digested on its own and the
225
+ # digests are sorted before being folded together. Duplicates survive that
226
+ # -- an entity listed twice hashes to two identical fingerprints -- and
227
+ # changing any field of any entity changes exactly one of them.
228
+ #
229
+ # `fetched_at` and `source_version` stay out: this is a checksum of
230
+ # content, and refetching an unchanged list has to reproduce it or it
231
+ # cannot answer "has this list changed since we last screened?".
232
+ sig { returns(String) }
233
+ def compute_checksum
234
+ digest = Digest::SHA256.new
235
+ digest << "#{schema_version}\n#{source}\n"
236
+ entities.map { |entity| Snapshot.fingerprint(entity) }
237
+ .sort
238
+ .each { |fingerprint| digest << fingerprint << "\n" }
239
+ -"#{ALGORITHM}:#{digest.hexdigest}"
240
+ end
241
+
242
+ sig { params(supplied: T.untyped).returns(String) }
243
+ def checksum!(supplied)
244
+ computed = compute_checksum
245
+ return computed if supplied.nil? || supplied.to_s == computed
246
+
247
+ raise ChecksumMismatch,
248
+ "#{source} snapshot content hashes to #{computed}, not the stored #{supplied} " \
249
+ "(schema_version #{schema_version})"
250
+ end
251
+
252
+ sig { params(value: T.untyped).returns(T::Array[T.untyped]) }
253
+ def entities!(value)
254
+ raise InvalidArgument, "entities must be an Array" unless value.is_a?(Array)
255
+
256
+ value.each do |entity|
257
+ raise InvalidArgument, "entities must respond to #to_h, got #{entity.class}" unless entity.respond_to?(:to_h)
258
+ end
259
+ value.dup.freeze
260
+ end
261
+
262
+ sig { params(value: T.untyped).returns(Integer) }
263
+ def count!(value)
264
+ return entities.size if value.nil? || value.to_i == entities.size
265
+
266
+ raise InvalidArgument, "record_count #{value} does not match the #{entities.size} entities given"
267
+ end
268
+
269
+ # Truncated to the second, which is the precision #to_h serializes, so a
270
+ # stored snapshot reloads to a value equal to the one that was written.
271
+ sig { params(value: T.untyped).returns(Time) }
272
+ def time!(value)
273
+ time = case value
274
+ when nil then Time.now
275
+ when Time then value
276
+ when String then Time.parse(value)
277
+ else raise InvalidArgument, "fetched_at is not a time: #{value.inspect}"
278
+ end
279
+ Time.at(time.to_i).utc
280
+ end
281
+
282
+ sig { params(value: T.untyped).returns(Integer) }
283
+ def version!(value)
284
+ integer = Integer(value)
285
+ raise InvalidArgument, "schema_version must be positive, got #{integer}" unless integer.positive?
286
+
287
+ integer
288
+ end
289
+
290
+ sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
291
+ def symbol!(member, value)
292
+ raise InvalidArgument, "#{member} is required" if value.nil? || value.to_s.empty?
293
+
294
+ value.to_sym
295
+ end
296
+
297
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
298
+ def string_or_nil(value)
299
+ return nil if value.nil?
300
+
301
+ string = value.to_s.strip
302
+ string.empty? ? nil : -string
303
+ end
304
+ end
305
+ end