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,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
|