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,366 @@
|
|
|
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 "active_sanction/configuration"
|
|
10
|
+
require "active_sanction/error"
|
|
11
|
+
|
|
12
|
+
module ActiveSanction
|
|
13
|
+
# The raw bytes a publisher served, kept on disk so a parse can be re-run or
|
|
14
|
+
# audited without re-fetching.
|
|
15
|
+
#
|
|
16
|
+
# cache = ActiveSanction::PayloadCache.new
|
|
17
|
+
#
|
|
18
|
+
# result = fetcher.fetch(url, key: :ofac_sdn)
|
|
19
|
+
# cache.write(:ofac_sdn, result.body, url: url, etag: result.etag,
|
|
20
|
+
# last_modified: result.last_modified) if result.changed?
|
|
21
|
+
#
|
|
22
|
+
# cache.latest(:ofac_sdn).read # the exact bytes, verified
|
|
23
|
+
#
|
|
24
|
+
# Anything list-sized streams instead, and the cache owns the temporary file
|
|
25
|
+
# so the atomicity guarantee below covers a 126 MB download as well as a
|
|
26
|
+
# string:
|
|
27
|
+
#
|
|
28
|
+
# cache.write(:ofac_sdn, url: url) do |sink|
|
|
29
|
+
# response = client.download(url, to: sink)
|
|
30
|
+
# next false unless response.success? # nothing is committed
|
|
31
|
+
#
|
|
32
|
+
# { etag: response.etag, last_modified: response.last_modified,
|
|
33
|
+
# final_url: response.uri.to_s }
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# ### Why keep the raw payload at all
|
|
37
|
+
#
|
|
38
|
+
# Because re-fetching cannot recover it. Publishers overwrite their files in
|
|
39
|
+
# place: OFAC's SDN.CSV lives at one URL forever and yesterday's contents are
|
|
40
|
+
# simply gone. When a parser bug is found in November, the question is what
|
|
41
|
+
# the list said in March -- the payload that produced a screening decision --
|
|
42
|
+
# and only the bytes can answer it. The parsed Snapshot cannot: it is the
|
|
43
|
+
# output of the code now under suspicion.
|
|
44
|
+
#
|
|
45
|
+
# ### What the layout guarantees
|
|
46
|
+
#
|
|
47
|
+
# Entries are content-addressed under `<cache_dir>/payloads/<source>/`, named
|
|
48
|
+
# by their SHA-256, with a JSON sidecar beside each one:
|
|
49
|
+
#
|
|
50
|
+
# ofac_sdn/sha256-9f86d081884c7d65....blob
|
|
51
|
+
# ofac_sdn/sha256-9f86d081884c7d65....json
|
|
52
|
+
#
|
|
53
|
+
# * **Nothing partial is ever readable.** Bytes are written to a `.part`
|
|
54
|
+
# sibling, hashed once complete, renamed into place, and only then given a
|
|
55
|
+
# sidecar. Listing reads sidecars, so a write killed at any point leaves at
|
|
56
|
+
# worst an unreferenced blob -- never an entry that looks whole.
|
|
57
|
+
# * **The checksum is computed from what landed on disk**, not from what the
|
|
58
|
+
# caller believed it was writing, and it is verified again on every read.
|
|
59
|
+
# A payload that no longer hashes to its sidecar raises ChecksumMismatch
|
|
60
|
+
# rather than reaching a parser.
|
|
61
|
+
# * **The same bytes fetched twice are one entry.** A re-download of an
|
|
62
|
+
# unchanged list overwrites its own sidecar, moving the fetch metadata
|
|
63
|
+
# forward; it does not consume a retention slot.
|
|
64
|
+
#
|
|
65
|
+
# ### What this is not
|
|
66
|
+
#
|
|
67
|
+
# A bounded cache, not an archive. Only the last `retain` payloads per source
|
|
68
|
+
# survive (default 3 -- enough to diff a suspicious list against what came
|
|
69
|
+
# before it), and the rest are pruned on the next write. An institution that
|
|
70
|
+
# must keep every version it ever screened against should copy payloads out
|
|
71
|
+
# to its own retention storage; this directory is under `~/.cache` and a user
|
|
72
|
+
# is entitled to delete it. Storage of the parsed record is #23 and #24.
|
|
73
|
+
#
|
|
74
|
+
# @api private
|
|
75
|
+
class PayloadCache
|
|
76
|
+
extend T::Sig
|
|
77
|
+
|
|
78
|
+
# The subdirectory under the configured cache directory. Kept separate from
|
|
79
|
+
# validators.json so that deleting one does not disturb the other.
|
|
80
|
+
DEFAULT_DIRNAME = T.let("payloads", String)
|
|
81
|
+
|
|
82
|
+
# Source names become directory names, so they are checked rather than
|
|
83
|
+
# sanitized: quietly rewriting `../../etc` into something safe would file a
|
|
84
|
+
# payload somewhere the caller cannot find it, and the callers here are
|
|
85
|
+
# source adapters (#12) whose names are symbols like `:ofac_sdn`.
|
|
86
|
+
SOURCE_PATTERN = T.let(/\A[a-z0-9][a-z0-9_-]*\z/i, Regexp)
|
|
87
|
+
|
|
88
|
+
# How long a file with no sidecar is left alone before pruning sweeps it.
|
|
89
|
+
# A blob is renamed into place two syscalls before its sidecar is written,
|
|
90
|
+
# and a `.part` file is live for as long as a download takes; an hour is
|
|
91
|
+
# far past both and far short of leaving abandoned megabytes forever.
|
|
92
|
+
ORPHAN_GRACE = T.let(3600, Integer)
|
|
93
|
+
|
|
94
|
+
# A cached payload that cannot be trusted: the sidecar is unreadable, or
|
|
95
|
+
# the bytes no longer match it. Never silently repaired -- an entry that
|
|
96
|
+
# cannot prove what it holds is worse than no entry, because a caller would
|
|
97
|
+
# act on it.
|
|
98
|
+
#
|
|
99
|
+
# @api public
|
|
100
|
+
class CorruptEntry < IntegrityError; end
|
|
101
|
+
|
|
102
|
+
# The bytes no longer hash to the checksum recorded beside them.
|
|
103
|
+
#
|
|
104
|
+
# @api public
|
|
105
|
+
class ChecksumMismatch < CorruptEntry; end
|
|
106
|
+
|
|
107
|
+
# Nothing is stored under that source and checksum, or its blob is gone.
|
|
108
|
+
#
|
|
109
|
+
# @api public
|
|
110
|
+
class PayloadMissing < StorageError; end
|
|
111
|
+
|
|
112
|
+
sig { returns(String) }
|
|
113
|
+
attr_reader :dir
|
|
114
|
+
|
|
115
|
+
# How many payloads are kept per source -- see the class comment: this is
|
|
116
|
+
# a bounded cache, not an archive.
|
|
117
|
+
sig { returns(Integer) }
|
|
118
|
+
attr_reader :retain
|
|
119
|
+
|
|
120
|
+
sig { params(dir: T.untyped, retain: T.untyped).void }
|
|
121
|
+
def initialize(dir: nil, retain: ActiveSanction.config.retain_payloads)
|
|
122
|
+
@dir = T.let(
|
|
123
|
+
-::File.expand_path((dir || ::File.join(ActiveSanction.config.cache_dir, DEFAULT_DIRNAME)).to_s), String
|
|
124
|
+
)
|
|
125
|
+
@retain = T.let(Configuration.retain_payloads!(retain), Integer)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Stores one payload and returns its Entry, pruning the source afterwards.
|
|
129
|
+
#
|
|
130
|
+
# Either hand it the bytes -- a String, or anything with #read -- or pass a
|
|
131
|
+
# block and write into the sink it yields. The block's return value refines
|
|
132
|
+
# the metadata: a Hash is merged over what was passed as keyword arguments,
|
|
133
|
+
# which is how a streaming caller supplies validators it only learns after
|
|
134
|
+
# the response has been read, and `false` abandons the write entirely so
|
|
135
|
+
# that a failed download commits nothing.
|
|
136
|
+
sig do
|
|
137
|
+
params(source: T.untyped, payload: T.untyped, metadata: T.untyped, block: T.untyped)
|
|
138
|
+
.returns(T.nilable(Entry))
|
|
139
|
+
end
|
|
140
|
+
def write(source, payload = nil, **metadata, &block)
|
|
141
|
+
raise InvalidArgument, "pass a payload or a block, not both" if block && payload
|
|
142
|
+
|
|
143
|
+
name = source!(source)
|
|
144
|
+
metadata!(metadata)
|
|
145
|
+
temporary = stage(name)
|
|
146
|
+
begin
|
|
147
|
+
outcome = ::File.open(temporary, "wb") { |sink| block ? block.call(sink) : copy(payload, sink) }
|
|
148
|
+
return nil if outcome == false
|
|
149
|
+
|
|
150
|
+
commit(name, temporary, metadata!(metadata.merge(outcome.is_a?(Hash) ? outcome : {})))
|
|
151
|
+
ensure
|
|
152
|
+
FileUtils.rm_f(temporary)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Every entry for a source, newest first, or every entry in the cache when
|
|
157
|
+
# asked for nothing in particular.
|
|
158
|
+
sig { params(source: T.untyped).returns(T::Array[Entry]) }
|
|
159
|
+
def entries(source = nil)
|
|
160
|
+
return sources.flat_map { |name| entries(name) }.sort_by { |entry| order(entry) } if source.nil?
|
|
161
|
+
|
|
162
|
+
name = source!(source)
|
|
163
|
+
Dir.glob(::File.join(directory_for(name), "*#{Entry::METADATA_EXTENSION}"))
|
|
164
|
+
.map { |path| read_entry(name, path) }
|
|
165
|
+
.sort_by { |entry| order(entry) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The payload a source was last fetched with, or nil. What a re-parse or an
|
|
169
|
+
# audit starts from.
|
|
170
|
+
sig { params(source: T.untyped).returns(T.nilable(Entry)) }
|
|
171
|
+
def latest(source) = entries(source).first
|
|
172
|
+
|
|
173
|
+
sig { params(source: T.untyped, checksum: T.untyped).returns(T.nilable(Entry)) }
|
|
174
|
+
def find(source, checksum)
|
|
175
|
+
name = source!(source)
|
|
176
|
+
path = ::File.join(directory_for(name), "#{Entry.basename_for(Checksum.normalize!(checksum))}" \
|
|
177
|
+
"#{Entry::METADATA_EXTENSION}")
|
|
178
|
+
return nil unless ::File.exist?(path)
|
|
179
|
+
|
|
180
|
+
read_entry(name, path)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# For a caller that means to read the payload: a missing entry is a failure
|
|
184
|
+
# rather than a nil to check, the same way a corrupt one is.
|
|
185
|
+
sig { params(source: T.untyped, checksum: T.untyped).returns(Entry) }
|
|
186
|
+
def fetch(source, checksum)
|
|
187
|
+
find(source, checksum) ||
|
|
188
|
+
raise(PayloadMissing, "no #{Checksum.normalize!(checksum)} payload cached for #{source}")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The bytes, verified. `cache.read(:ofac_sdn, checksum)` is the whole point
|
|
192
|
+
# of the class: the exact payload a past decision was made against.
|
|
193
|
+
sig { params(source: T.untyped, checksum: T.untyped).returns(String) }
|
|
194
|
+
def read(source, checksum) = fetch(source, checksum).read
|
|
195
|
+
|
|
196
|
+
sig { params(source: T.untyped, checksum: T.untyped).returns(T::Boolean) }
|
|
197
|
+
def include?(source, checksum) = !find(source, checksum).nil?
|
|
198
|
+
|
|
199
|
+
sig { returns(T::Array[Symbol]) }
|
|
200
|
+
def sources
|
|
201
|
+
return [] unless ::File.directory?(dir)
|
|
202
|
+
|
|
203
|
+
Dir.children(dir).select { |name| name.match?(SOURCE_PATTERN) && ::File.directory?(::File.join(dir, name)) }
|
|
204
|
+
.map(&:to_sym).sort
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
sig { params(source: T.untyped).returns(Integer) }
|
|
208
|
+
def size(source = nil) = entries(source).size
|
|
209
|
+
|
|
210
|
+
sig { params(source: T.untyped).returns(T::Boolean) }
|
|
211
|
+
def empty?(source = nil) = entries(source).empty?
|
|
212
|
+
|
|
213
|
+
# Removes one entry, bytes and sidecar together, and returns it -- or nil
|
|
214
|
+
# when there was nothing there.
|
|
215
|
+
sig { params(source: T.untyped, checksum: T.untyped).returns(T.nilable(Entry)) }
|
|
216
|
+
def delete(source, checksum)
|
|
217
|
+
entry = find(source, checksum)
|
|
218
|
+
remove(entry) if entry
|
|
219
|
+
entry
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Keeps the `retain` most recent payloads per source and discards the rest,
|
|
223
|
+
# returning what was discarded. Runs after every write, so a caller only
|
|
224
|
+
# calls it directly after lowering `retain` or to sweep what a crash left.
|
|
225
|
+
sig { params(source: T.untyped).returns(T::Array[Entry]) }
|
|
226
|
+
def prune(source = nil)
|
|
227
|
+
(source.nil? ? sources : [source!(source)]).flat_map { |name| prune_source(name) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Drops a source's payloads, or the whole cache. Nothing here is a record
|
|
231
|
+
# of what a list contained that is not also in storage (#24), so this is
|
|
232
|
+
# recoverable by re-fetching -- with the exception the class comment names:
|
|
233
|
+
# the *previous* contents of a list are gone once a publisher overwrites
|
|
234
|
+
# its file, whether or not this directory still has them.
|
|
235
|
+
sig { params(source: T.untyped).returns(T.self_type) }
|
|
236
|
+
def clear(source = nil)
|
|
237
|
+
if source.nil?
|
|
238
|
+
FileUtils.rm_rf(dir)
|
|
239
|
+
else
|
|
240
|
+
FileUtils.rm_rf(directory_for(source!(source)))
|
|
241
|
+
end
|
|
242
|
+
self
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
sig { returns(String) }
|
|
246
|
+
def inspect = "#<#{self.class} #{dir} retain=#{retain} #{sources.size} source(s)>"
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
# Newest first, with the checksum breaking a tie so that two entries
|
|
251
|
+
# written in the same microsecond still prune in a defined order.
|
|
252
|
+
sig { params(entry: Entry).returns([Rational, String]) }
|
|
253
|
+
def order(entry) = [-entry.fetched_at.to_r, entry.checksum]
|
|
254
|
+
|
|
255
|
+
sig { params(name: Symbol).returns(String) }
|
|
256
|
+
def stage(name)
|
|
257
|
+
directory = directory_for(name)
|
|
258
|
+
FileUtils.mkdir_p(directory)
|
|
259
|
+
::File.join(directory, "#{Process.pid}-#{SecureRandom.hex(8)}.part")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The blob goes into place before its sidecar is written, never after: a
|
|
263
|
+
# crash between the two leaves an unreferenced file that pruning sweeps,
|
|
264
|
+
# where the other order would leave a sidecar advertising bytes that are
|
|
265
|
+
# not there.
|
|
266
|
+
sig { params(name: Symbol, temporary: String, metadata: T::Hash[Symbol, T.untyped]).returns(Entry) }
|
|
267
|
+
def commit(name, temporary, metadata)
|
|
268
|
+
# `new(**hash)` past required keyword parameters is one of the few things
|
|
269
|
+
# Sorbet cannot check statically. #metadata! has already refused every
|
|
270
|
+
# key Entry does not declare.
|
|
271
|
+
entry = T.let(
|
|
272
|
+
T.unsafe(Entry).new(dir: directory_for(name), source: name, byte_size: ::File.size(temporary),
|
|
273
|
+
checksum: Checksum.of_file(temporary), **metadata),
|
|
274
|
+
Entry
|
|
275
|
+
)
|
|
276
|
+
::File.rename(temporary, entry.path)
|
|
277
|
+
write_metadata(entry)
|
|
278
|
+
prune_source(name)
|
|
279
|
+
entry
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
sig { params(entry: Entry).void }
|
|
283
|
+
def write_metadata(entry)
|
|
284
|
+
temporary = T.let("#{entry.metadata_path}.#{Process.pid}.part", T.nilable(String))
|
|
285
|
+
::File.write(T.must(temporary), "#{JSON.pretty_generate(entry.to_h)}\n")
|
|
286
|
+
::File.rename(T.must(temporary), entry.metadata_path)
|
|
287
|
+
ensure
|
|
288
|
+
FileUtils.rm_f(temporary) if temporary
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
sig { params(payload: T.untyped, sink: T.untyped).returns(T.untyped) }
|
|
292
|
+
def copy(payload, sink)
|
|
293
|
+
raise InvalidArgument, "a payload or a block is required" if payload.nil?
|
|
294
|
+
|
|
295
|
+
payload.respond_to?(:read) ? IO.copy_stream(payload, sink) : sink.write(payload.to_s)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
sig { params(name: Symbol).returns(T::Array[Entry]) }
|
|
299
|
+
def prune_source(name)
|
|
300
|
+
discarded = entries(name).drop(retain)
|
|
301
|
+
discarded.each { |entry| remove(entry) }
|
|
302
|
+
sweep(name)
|
|
303
|
+
discarded
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
sig { params(entry: Entry).void }
|
|
307
|
+
def remove(entry)
|
|
308
|
+
FileUtils.rm_f([entry.path, entry.metadata_path])
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# What a crashed write leaves behind: a blob whose sidecar never landed, a
|
|
312
|
+
# sidecar whose blob is gone, a `.part` file from a download that died.
|
|
313
|
+
sig { params(name: Symbol).void }
|
|
314
|
+
def sweep(name)
|
|
315
|
+
cutoff = Time.now - ORPHAN_GRACE
|
|
316
|
+
Dir.glob(::File.join(directory_for(name), "*.{blob,json,part}")).each do |path|
|
|
317
|
+
FileUtils.rm_f(path) if orphan?(path) && ::File.mtime(path) < cutoff
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
sig { params(path: String).returns(T::Boolean) }
|
|
322
|
+
def orphan?(path)
|
|
323
|
+
return true if ::File.extname(path) == ".part"
|
|
324
|
+
|
|
325
|
+
stem = path.chomp(::File.extname(path))
|
|
326
|
+
!(::File.exist?("#{stem}#{Entry::BLOB_EXTENSION}") && ::File.exist?("#{stem}#{Entry::METADATA_EXTENSION}"))
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
sig { params(name: Symbol, path: String).returns(Entry) }
|
|
330
|
+
def read_entry(name, path)
|
|
331
|
+
Entry.from_h(JSON.parse(::File.read(path)), dir: directory_for(name))
|
|
332
|
+
rescue JSON::ParserError, ArgumentError, TypeError => e
|
|
333
|
+
raise CorruptEntry, "#{path} does not describe a cached payload (#{e.message}). " \
|
|
334
|
+
"Delete it to drop the entry; the source re-fetches in full."
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
sig { params(name: T.untyped).returns(String) }
|
|
338
|
+
def directory_for(name) = ::File.join(dir, name.to_s)
|
|
339
|
+
|
|
340
|
+
sig { params(source: T.untyped).returns(Symbol) }
|
|
341
|
+
def source!(source)
|
|
342
|
+
name = source.to_s.strip
|
|
343
|
+
unless name.match?(SOURCE_PATTERN)
|
|
344
|
+
raise InvalidArgument, "#{source.inspect} is not a usable source name: it becomes a directory, " \
|
|
345
|
+
"so it must start alphanumeric and hold only letters, digits, _ and -"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
name.to_sym
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Checked before a byte is written, so a caller does not stream 126 MB to
|
|
352
|
+
# learn it misspelled a keyword.
|
|
353
|
+
sig { params(metadata: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
354
|
+
def metadata!(metadata)
|
|
355
|
+
unknown = metadata.keys - Entry::PROVENANCE
|
|
356
|
+
raise InvalidArgument, "unknown payload metadata: #{unknown.join(", ")}" if unknown.any?
|
|
357
|
+
raise InvalidArgument, "url: is required -- a cached payload records where it came from" if
|
|
358
|
+
metadata[:url].to_s.strip.empty?
|
|
359
|
+
|
|
360
|
+
metadata
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
require "active_sanction/payload_cache/checksum"
|
|
366
|
+
require "active_sanction/payload_cache/entry"
|