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