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,66 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "digest"
7
+
8
+ module ActiveSanction
9
+ class PayloadCache
10
+ # The content address of a payload: `sha256:<64 hex digits>`, the same form
11
+ # Snapshot stamps on a parsed list, so a payload and the snapshot parsed
12
+ # from it are quoted the same way in an audit trail.
13
+ #
14
+ # It is a module rather than a pair of methods on the cache because two
15
+ # things depend on it and one guarantee rests on it: every filename this
16
+ # library builds under the cache directory comes from .normalize!, so a
17
+ # checksum that arrived from a database column or a URL parameter cannot
18
+ # become a directory traversal. Anything that is not a SHA-256 raises here,
19
+ # before it is joined to a path.
20
+ module Checksum
21
+ extend T::Sig
22
+ extend T::Helpers
23
+
24
+ # Called as `Checksum.normalize!` -- module functions on a module, which
25
+ # is an Object, which is where `raise` comes from.
26
+ requires_ancestor { Kernel }
27
+
28
+ ALGORITHM = T.let("sha256", String)
29
+
30
+ # With or without the prefix: callers copy checksums out of log lines and
31
+ # database columns, and both forms show up there.
32
+ PATTERN = T.let(/\A(?:#{ALGORITHM}[:-])?(\h{64})\z/i, Regexp)
33
+
34
+ # Payloads run to 126 MB, so they are digested in pieces. A cache that
35
+ # had to hold a list in memory to verify it would defeat the point of
36
+ # having streamed it to disk in the first place.
37
+ CHUNK_SIZE = T.let(64 * 1024, Integer)
38
+
39
+ module_function
40
+
41
+ sig { params(value: T.untyped).returns(String) }
42
+ def normalize!(value)
43
+ match = PATTERN.match(value.to_s.strip)
44
+ raise InvalidArgument, "#{value.inspect} is not a #{ALGORITHM} checksum" if match.nil?
45
+
46
+ -"#{ALGORITHM}:#{T.must(match[1]).downcase}"
47
+ end
48
+
49
+ sig { params(path: T.untyped).returns(String) }
50
+ def of_file(path)
51
+ digest = Digest::SHA256.new
52
+ ::File.open(path, "rb") do |file|
53
+ while (chunk = file.read(CHUNK_SIZE))
54
+ digest << chunk
55
+ end
56
+ end
57
+ -"#{ALGORITHM}:#{digest.hexdigest}"
58
+ end
59
+
60
+ # The hex digits without the algorithm prefix, for comparing against a
61
+ # checksum a publisher printed on its download page.
62
+ sig { params(checksum: T.untyped).returns(T.nilable(String)) }
63
+ def hex(checksum) = checksum.to_s.split(":").last
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,304 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "time"
7
+ require "active_sanction/payload_cache/checksum"
8
+
9
+ module ActiveSanction
10
+ class PayloadCache
11
+ # One cached payload: where its bytes are, what they hash to, and what the
12
+ # publisher said while serving them.
13
+ #
14
+ # entry = cache.latest(:ofac_sdn)
15
+ # entry.checksum #=> "sha256:9f86d081884c7d65..."
16
+ # entry.byte_size #=> 132_046_336
17
+ # entry.read # verified bytes, or ChecksumMismatch
18
+ #
19
+ # An entry carries its own directory, so a caller holding one can read the
20
+ # bytes without asking the cache again -- which matters for the audit case
21
+ # this cache exists for, where the thing passed around is "the payload that
22
+ # produced that screening decision", not a source name and a moment.
23
+ #
24
+ # The metadata is the fetch's provenance and nothing else: which URL was
25
+ # asked for, which one finally answered (OFAC 302s to blob storage, and a
26
+ # bug report about a bad download is much easier to read when it names the
27
+ # host that actually served the bytes -- minus its query string, which on
28
+ # both OFAC and the UN is a presigned credential), when it was fetched, and
29
+ # the validators the publisher stamped on it. What the bytes *mean* is not
30
+ # here -- parsing them is #14 and #15's job, and a cache that recorded its
31
+ # own opinion of a payload's contents would be a second, quietly diverging
32
+ # record.
33
+ #
34
+ # Instances are frozen on construction and compare by value.
35
+ class Entry
36
+ extend T::Sig
37
+
38
+ # Bumped whenever the sidecar's shape changes, so an entry written by an
39
+ # older gem can be migrated or discarded rather than silently misread.
40
+ SCHEMA_VERSION = T.let(1, Integer)
41
+
42
+ # What a caller supplies about where the bytes came from. Everything else
43
+ # is measured from the bytes themselves.
44
+ PROVENANCE = T.let(%i[url final_url fetched_at etag last_modified].freeze, T::Array[Symbol])
45
+
46
+ MEMBERS = T.let([:source, :checksum, :byte_size, *PROVENANCE, :schema_version].freeze, T::Array[Symbol])
47
+
48
+ BLOB_EXTENSION = T.let(".blob", String)
49
+ METADATA_EXTENSION = T.let(".json", String)
50
+
51
+ sig { returns(Symbol) }
52
+ attr_reader :source
53
+
54
+ # `sha256:` and 64 hex digits -- see Checksum, which every filename under
55
+ # the cache directory is built from.
56
+ sig { returns(String) }
57
+ attr_reader :checksum
58
+
59
+ sig { returns(Integer) }
60
+ attr_reader :byte_size
61
+
62
+ sig { returns(String) }
63
+ attr_reader :url
64
+
65
+ # The host that actually served the bytes, minus its query string, which
66
+ # on both OFAC and the UN is a presigned credential. See #redacted.
67
+ sig { returns(T.nilable(String)) }
68
+ attr_reader :final_url
69
+
70
+ sig { returns(Time) }
71
+ attr_reader :fetched_at
72
+
73
+ sig { returns(T.nilable(String)) }
74
+ attr_reader :etag
75
+
76
+ sig { returns(T.nilable(String)) }
77
+ attr_reader :last_modified
78
+
79
+ sig { returns(Integer) }
80
+ attr_reader :schema_version
81
+
82
+ # Where the sidecar was found rather than anything stored in it, which is
83
+ # why moving the cache directory does not invalidate every entry in it.
84
+ sig { returns(String) }
85
+ attr_reader :dir
86
+
87
+ # Rebuilds from #to_h output, accepting string keys so an entry survives
88
+ # the round-trip through its sidecar JSON. `dir` is not stored: it is
89
+ # where the sidecar was found, and moving the cache directory should not
90
+ # invalidate every entry in it.
91
+ sig { params(hash: T.untyped, dir: T.untyped).returns(T.attached_class) }
92
+ def self.from_h(hash, dir:)
93
+ attributes = hash.to_h.transform_keys(&:to_sym)
94
+ unknown = attributes.keys - MEMBERS
95
+ raise InvalidArgument, "unknown Entry attribute(s): #{unknown.join(", ")}" if unknown.any?
96
+
97
+ # `new(**hash)` past required keyword parameters is one of the few
98
+ # things Sorbet cannot check statically. #initialize validates what
99
+ # arrives, which is where a bad sidecar is caught.
100
+ T.unsafe(self).new(dir: dir, **attributes)
101
+ end
102
+
103
+ # Content addressing puts the digest in the filename, so two fetches of
104
+ # the same bytes are one file and a payload cannot be confused with a
105
+ # different payload of the same age. The `sha256:` prefix Snapshot uses
106
+ # is spelled `sha256-` here, because a colon in a filename is legal on
107
+ # every platform this runs on and pleasant on none of them.
108
+ sig { params(checksum: T.untyped).returns(String) }
109
+ def self.basename_for(checksum) = checksum.to_s.tr(":", "-")
110
+
111
+ # Untyped on purpose, and for the same reason the value objects are: an
112
+ # entry is rebuilt from a sidecar JSON file whose keys have been through
113
+ # a serializer, and the coercions below say what happens to each of them.
114
+ sig do
115
+ params(source: T.untyped, checksum: T.untyped, byte_size: T.untyped, url: T.untyped, dir: T.untyped,
116
+ final_url: T.untyped, fetched_at: T.untyped, etag: T.untyped, last_modified: T.untyped,
117
+ schema_version: T.untyped).void
118
+ end
119
+ def initialize(source:, checksum:, byte_size:, url:, dir:, final_url: nil, fetched_at: nil,
120
+ etag: nil, last_modified: nil, schema_version: SCHEMA_VERSION)
121
+ @source = T.let(symbol!(:source, source), Symbol)
122
+ @checksum = T.let(Checksum.normalize!(checksum), String)
123
+ @byte_size = T.let(size!(byte_size), Integer)
124
+ @url = T.let(string!(:url, url), String)
125
+ @dir = T.let(-::File.expand_path(dir.to_s), String)
126
+ @final_url = T.let(redacted(final_url), T.nilable(String))
127
+ @fetched_at = T.let(time!(fetched_at), Time)
128
+ @etag = T.let(string_or_nil(etag), T.nilable(String))
129
+ @last_modified = T.let(string_or_nil(last_modified), T.nilable(String))
130
+ @schema_version = T.let(version!(schema_version), Integer)
131
+ freeze
132
+ end
133
+
134
+ sig { returns(String) }
135
+ def basename = self.class.basename_for(checksum)
136
+
137
+ sig { returns(String) }
138
+ def path = ::File.join(dir, "#{basename}#{BLOB_EXTENSION}")
139
+
140
+ sig { returns(String) }
141
+ def metadata_path = ::File.join(dir, "#{basename}#{METADATA_EXTENSION}")
142
+
143
+ sig { returns(T::Boolean) }
144
+ def exist? = ::File.exist?(path)
145
+
146
+ # The hex digest without the algorithm prefix, for callers that want to
147
+ # compare against a checksum a publisher printed on a download page.
148
+ sig { returns(T.nilable(String)) }
149
+ def hex = Checksum.hex(checksum)
150
+
151
+ # The bytes, verified first. A payload that no longer hashes to what was
152
+ # recorded beside it raises rather than being handed to a parser: the
153
+ # whole reason to keep raw payloads is to be able to say what a list
154
+ # contained on a given date, and a file that cannot prove it is unchanged
155
+ # answers that question with a guess.
156
+ sig { returns(String) }
157
+ def read
158
+ verify!
159
+ ::File.binread(path)
160
+ end
161
+
162
+ # The same guarantee for a caller that would rather stream 126 MB than
163
+ # hold it. Verification is a separate pass over the file before the
164
+ # handle is yielded, because a parser cannot un-parse the first half of a
165
+ # payload once the second half turns out to be corrupt.
166
+ # The block is untyped rather than a `T.proc`: it is handed straight to
167
+ # `File.open`, and sorbet-runtime checks a declared block against what it
168
+ # was passed strictly enough to reject the probe object RSpec's
169
+ # `expect { |probe| ... }` yields with -- a test idiom this method is
170
+ # exercised by, and worth more here than a shape the file handle already
171
+ # fixes.
172
+ sig { params(block: T.untyped).returns(T.untyped) }
173
+ def open(&block)
174
+ verify!
175
+ ::File.open(path, "rb", &block)
176
+ end
177
+
178
+ sig { returns(T.self_type) }
179
+ def verify!
180
+ raise PayloadMissing, "#{source} payload #{checksum} is not at #{path}" unless exist?
181
+
182
+ actual = Checksum.of_file(path)
183
+ return self if actual == checksum
184
+
185
+ raise ChecksumMismatch, "#{source} payload at #{path} hashes to #{actual}, not the #{checksum} " \
186
+ "recorded beside it (#{::File.size(path)} bytes on disk, #{byte_size} " \
187
+ "recorded). Delete the entry to re-fetch."
188
+ end
189
+
190
+ sig { returns(T::Boolean) }
191
+ def valid?
192
+ verify!
193
+ true
194
+ rescue PayloadMissing, ChecksumMismatch
195
+ false
196
+ end
197
+
198
+ sig { returns(T::Hash[Symbol, T.untyped]) }
199
+ def to_h
200
+ {
201
+ source: source,
202
+ checksum: checksum,
203
+ byte_size: byte_size,
204
+ url: url,
205
+ final_url: final_url,
206
+ fetched_at: fetched_at.iso8601(6),
207
+ etag: etag,
208
+ last_modified: last_modified,
209
+ schema_version: schema_version
210
+ }
211
+ end
212
+
213
+ sig { params(other: T.untyped).returns(T::Boolean) }
214
+ def ==(other)
215
+ return false unless other.instance_of?(self.class)
216
+
217
+ dir == other.dir && to_h == other.to_h
218
+ end
219
+ alias eql? ==
220
+
221
+ sig { returns(Integer) }
222
+ def hash = [self.class, dir, to_h].hash
223
+
224
+ sig { returns(String) }
225
+ def to_s = "#{source} #{checksum} (#{byte_size} bytes)"
226
+
227
+ sig { returns(String) }
228
+ def inspect
229
+ "#<#{self.class} #{source} #{checksum} #{byte_size}B url=#{url} fetched_at=#{fetched_at.iso8601}>"
230
+ end
231
+
232
+ private
233
+
234
+ sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
235
+ def symbol!(member, value)
236
+ raise InvalidArgument, "#{member} is required" if value.nil? || value.to_s.empty?
237
+
238
+ value.to_sym
239
+ end
240
+
241
+ sig { params(member: Symbol, value: T.untyped).returns(String) }
242
+ def string!(member, value)
243
+ string = value.to_s.strip
244
+ raise InvalidArgument, "#{member} is required" if string.empty?
245
+
246
+ -string
247
+ end
248
+
249
+ sig { params(value: T.untyped).returns(Integer) }
250
+ def size!(value)
251
+ integer = Integer(value)
252
+ raise InvalidArgument, "byte_size cannot be negative, got #{integer}" if integer.negative?
253
+
254
+ integer
255
+ end
256
+
257
+ sig { params(value: T.untyped).returns(Integer) }
258
+ def version!(value)
259
+ integer = Integer(value)
260
+ raise InvalidArgument, "schema_version must be positive, got #{integer}" unless integer.positive?
261
+
262
+ integer
263
+ end
264
+
265
+ # Kept to microseconds rather than truncated to the second, unlike the
266
+ # other timestamps in this library: retention orders entries by when they
267
+ # were fetched, and two payloads written in the same second have to sort
268
+ # in the order they were written or pruning discards the wrong one.
269
+ sig { params(value: T.untyped).returns(Time) }
270
+ def time!(value)
271
+ time = case value
272
+ when nil then Time.now
273
+ when Time then value
274
+ when String then Time.parse(value)
275
+ else raise InvalidArgument, "fetched_at is not a time: #{value.inspect}"
276
+ end
277
+ time.getutc.round(6)
278
+ end
279
+
280
+ # A publisher's last hop is usually presigned: OFAC's 302 lands on an S3
281
+ # URL carrying an `X-Amz-Security-Token`, the UN's on a blob SAS
282
+ # signature, both good for about an hour. What is worth keeping is which
283
+ # host served the bytes; the query string is a credential, and a cache
284
+ # file is the wrong place for one -- it outlives the token, gets copied
285
+ # into bug reports, and is readable by anything that can read the cache.
286
+ # So it is cut before the entry is written.
287
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
288
+ def redacted(value)
289
+ string = string_or_nil(value)
290
+ return nil if string.nil?
291
+
292
+ string_or_nil(string.split(/[?#]/).first)
293
+ end
294
+
295
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
296
+ def string_or_nil(value)
297
+ return nil if value.nil?
298
+
299
+ string = value.to_s.strip
300
+ string.empty? ? nil : -string
301
+ end
302
+ end
303
+ end
304
+ end