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