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,103 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
class ValidatorStore
|
|
11
|
+
# Validators in one small JSON file, so a sync that runs from cron gets a
|
|
12
|
+
# 304 on its second run rather than on its second run within one process.
|
|
13
|
+
#
|
|
14
|
+
# ActiveSanction::ValidatorStore::FileSystem.new
|
|
15
|
+
# # => ~/.cache/active_sanction/validators.json
|
|
16
|
+
#
|
|
17
|
+
# JSON, indented, keyed by the caller's key: this file is the first thing
|
|
18
|
+
# somebody opens when a sync is downloading more than it should, and it is
|
|
19
|
+
# a few hundred bytes even with every source in it. It is also the file the
|
|
20
|
+
# acceptance criterion is about -- deleting it forces a full re-download,
|
|
21
|
+
# and nothing else is lost with it, because validators are an optimization
|
|
22
|
+
# and never the record of what a list contained.
|
|
23
|
+
#
|
|
24
|
+
# Entries are re-read on every access rather than memoized. The file is
|
|
25
|
+
# tiny, and a cache that has to be reloaded to see another process's write
|
|
26
|
+
# is a cache that lies to a sync running beside a CLI command.
|
|
27
|
+
#
|
|
28
|
+
# Writes go to a temporary sibling and are renamed into place, so an
|
|
29
|
+
# interrupted write leaves the previous file rather than a truncated one.
|
|
30
|
+
# Two processes writing at once still resolve last-writer-wins: the cost is
|
|
31
|
+
# one avoidable download, which is the right trade for not putting a lock
|
|
32
|
+
# file in a user's cache directory.
|
|
33
|
+
class FileSystem < ValidatorStore
|
|
34
|
+
extend T::Sig
|
|
35
|
+
|
|
36
|
+
# @api private
|
|
37
|
+
DEFAULT_FILENAME = T.let("validators.json", String)
|
|
38
|
+
|
|
39
|
+
sig { returns(String) }
|
|
40
|
+
attr_reader :path
|
|
41
|
+
|
|
42
|
+
sig { params(path: T.untyped).void }
|
|
43
|
+
def initialize(path: nil)
|
|
44
|
+
@path = T.let(
|
|
45
|
+
::File.expand_path((path || ::File.join(ActiveSanction.config.cache_dir, DEFAULT_FILENAME)).to_s), String
|
|
46
|
+
)
|
|
47
|
+
super()
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { override.returns(String) }
|
|
51
|
+
def inspect = "#<#{self.class} #{path} #{size} entr#{size == 1 ? "y" : "ies"}>"
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
sig { override.returns(T::Hash[String, Validators]) }
|
|
56
|
+
def entries
|
|
57
|
+
raw = read
|
|
58
|
+
raw.to_h { |key, attributes| [key, Validators.from_h(attributes)] }
|
|
59
|
+
rescue ArgumentError, TypeError => e
|
|
60
|
+
raise CorruptStore, "#{path} does not hold validators (#{e.message}). Delete it to re-download in full."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig do
|
|
64
|
+
override.params(block: T.proc.params(all: T::Hash[String, Validators]).returns(T.untyped))
|
|
65
|
+
.returns(T.untyped)
|
|
66
|
+
end
|
|
67
|
+
def commit(&block)
|
|
68
|
+
all = entries
|
|
69
|
+
result = block.call(all)
|
|
70
|
+
write(all)
|
|
71
|
+
result
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A missing file is an empty store, not an error: it is what a first run
|
|
75
|
+
# sees, and what deleting the file leaves behind.
|
|
76
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
77
|
+
def read
|
|
78
|
+
return {} unless ::File.exist?(path)
|
|
79
|
+
|
|
80
|
+
contents = ::File.read(path, encoding: Encoding::UTF_8)
|
|
81
|
+
return {} if contents.strip.empty?
|
|
82
|
+
|
|
83
|
+
parsed = JSON.parse(contents)
|
|
84
|
+
raise CorruptStore, "#{path} is not a JSON object of validators. Delete it to re-download in full." unless
|
|
85
|
+
parsed.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
parsed
|
|
88
|
+
rescue JSON::ParserError => e
|
|
89
|
+
raise CorruptStore, "#{path} is not valid JSON (#{e.message}). Delete it to re-download in full."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
sig { params(all: T::Hash[String, Validators]).void }
|
|
93
|
+
def write(all)
|
|
94
|
+
FileUtils.mkdir_p(::File.dirname(path))
|
|
95
|
+
temporary = T.let("#{path}.#{Process.pid}.tmp", T.nilable(String))
|
|
96
|
+
::File.write(T.must(temporary), "#{JSON.pretty_generate(all.transform_values(&:to_h))}\n")
|
|
97
|
+
::File.rename(T.must(temporary), path)
|
|
98
|
+
ensure
|
|
99
|
+
FileUtils.rm_f(temporary) if temporary
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
class ValidatorStore
|
|
8
|
+
# Validators for the life of one process, and no longer.
|
|
9
|
+
#
|
|
10
|
+
# The default for a caller that builds its own client and does not want a
|
|
11
|
+
# file appearing under a home directory, and what the suite uses so an
|
|
12
|
+
# example never depends on -- or leaves behind -- state on disk. A
|
|
13
|
+
# long-running process that syncs on a schedule still gets the whole
|
|
14
|
+
# benefit of conditional GET from it; only a fresh boot pays for a full
|
|
15
|
+
# download.
|
|
16
|
+
class Memory < ValidatorStore
|
|
17
|
+
extend T::Sig
|
|
18
|
+
|
|
19
|
+
sig { params(entries: T::Hash[T.untyped, T.nilable(Validators)]).void }
|
|
20
|
+
def initialize(entries = {})
|
|
21
|
+
@entries = T.let({}, T::Hash[String, Validators])
|
|
22
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
23
|
+
entries.each { |key, validators| self[key] = validators }
|
|
24
|
+
super()
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
sig { override.returns(T::Hash[String, Validators]) }
|
|
30
|
+
attr_reader :entries
|
|
31
|
+
|
|
32
|
+
sig do
|
|
33
|
+
override.params(block: T.proc.params(all: T::Hash[String, Validators]).returns(T.untyped))
|
|
34
|
+
.returns(T.untyped)
|
|
35
|
+
end
|
|
36
|
+
def commit(&block)
|
|
37
|
+
@mutex.synchronize { block.call(@entries) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
require "active_sanction/validators"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
# Where a process remembers what a publisher last said about a list, so the
|
|
11
|
+
# next fetch can ask "still this?" instead of "give me everything".
|
|
12
|
+
#
|
|
13
|
+
# store = ActiveSanction::ValidatorStore::FileSystem.new
|
|
14
|
+
# store[:ofac_sdn] #=> #<ActiveSanction::Validators ...> or nil
|
|
15
|
+
# store.delete(:ofac_sdn) # next fetch downloads in full
|
|
16
|
+
#
|
|
17
|
+
# Keys are the caller's: a URL by default, or a stable source name for an
|
|
18
|
+
# adapter (#12) whose publisher may move its file. Validators carry the URL
|
|
19
|
+
# they came from either way, so a moved file is detected rather than sent a
|
|
20
|
+
# meaningless `If-None-Match`.
|
|
21
|
+
#
|
|
22
|
+
# This is a base class rather than an interface document: subclasses supply
|
|
23
|
+
# the two lines that differ -- how the entries are read and how a change to
|
|
24
|
+
# them is committed -- and inherit the rest. #24 will do the same for
|
|
25
|
+
# snapshots, at a scale where the difference matters more.
|
|
26
|
+
class ValidatorStore
|
|
27
|
+
extend T::Sig
|
|
28
|
+
|
|
29
|
+
# A store whose backing bytes cannot be read as validators. Deliberately
|
|
30
|
+
# fatal rather than treated as an empty store: silently re-downloading tens
|
|
31
|
+
# of megabytes on every sync is the kind of failure that hides for months,
|
|
32
|
+
# and the fix -- delete the file -- is in the message.
|
|
33
|
+
class CorruptStore < StorageError; end
|
|
34
|
+
|
|
35
|
+
# Reads validators, or nil when nothing is stored under the key. Nil is the
|
|
36
|
+
# answer to "have we ever fetched this?", so it is not conflated with a
|
|
37
|
+
# record that exists but carries no validators.
|
|
38
|
+
sig { params(key: T.untyped).returns(T.nilable(Validators)) }
|
|
39
|
+
def [](key)
|
|
40
|
+
entries[key!(key)]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Storing nil, or validators the publisher gave nothing to be conditional
|
|
44
|
+
# with, is a delete: a record that cannot save a download is not worth
|
|
45
|
+
# keeping, and leaving one behind makes #stale? lie about having a usable
|
|
46
|
+
# copy.
|
|
47
|
+
sig { params(key: T.untyped, validators: T.nilable(Validators)).returns(T.nilable(Validators)) }
|
|
48
|
+
def []=(key, validators)
|
|
49
|
+
normalized = key!(key)
|
|
50
|
+
commit do |all|
|
|
51
|
+
if validators.nil? || validators.empty?
|
|
52
|
+
all.delete(normalized)
|
|
53
|
+
else
|
|
54
|
+
all[normalized] = validators
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
validators
|
|
58
|
+
end
|
|
59
|
+
alias store []=
|
|
60
|
+
|
|
61
|
+
sig { params(key: T.untyped).returns(T.untyped) }
|
|
62
|
+
def delete(key)
|
|
63
|
+
normalized = key!(key)
|
|
64
|
+
commit { |all| all.delete(normalized) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sig { params(key: T.untyped).returns(T::Boolean) }
|
|
68
|
+
def key?(key) = entries.key?(key!(key))
|
|
69
|
+
|
|
70
|
+
sig { returns(T::Array[String]) }
|
|
71
|
+
def keys = entries.keys
|
|
72
|
+
|
|
73
|
+
sig { returns(Integer) }
|
|
74
|
+
def size = entries.size
|
|
75
|
+
|
|
76
|
+
sig { returns(T::Boolean) }
|
|
77
|
+
def empty? = entries.empty?
|
|
78
|
+
|
|
79
|
+
sig { returns(T.self_type) }
|
|
80
|
+
def clear
|
|
81
|
+
commit(&:clear)
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
86
|
+
def to_h = entries.transform_values(&:to_h)
|
|
87
|
+
|
|
88
|
+
sig { returns(String) }
|
|
89
|
+
def inspect = "#<#{self.class} #{size} entr#{size == 1 ? "y" : "ies"}>"
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Keys reach here as symbols from source adapters and as URL strings from
|
|
94
|
+
# ad-hoc callers; both have to survive a round-trip through JSON, which has
|
|
95
|
+
# only strings.
|
|
96
|
+
sig { params(key: T.untyped).returns(String) }
|
|
97
|
+
def key!(key)
|
|
98
|
+
string = key.to_s.strip
|
|
99
|
+
raise InvalidArgument, "a validator key is required" if string.empty?
|
|
100
|
+
|
|
101
|
+
-string
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A hash of key => Validators. Subclasses may rebuild it per call.
|
|
105
|
+
sig { returns(T::Hash[String, Validators]) }
|
|
106
|
+
def entries
|
|
107
|
+
raise UnsupportedError, "#{self.class} must implement #entries"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Yields the current entries for mutation and persists the result.
|
|
111
|
+
sig { params(_block: T.proc.params(all: T::Hash[String, Validators]).returns(T.untyped)).returns(T.untyped) }
|
|
112
|
+
def commit(&_block)
|
|
113
|
+
raise UnsupportedError, "#{self.class} must implement #commit"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
require "active_sanction/validator_store/memory"
|
|
119
|
+
require "active_sanction/validator_store/file_system"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "time"
|
|
7
|
+
require "active_sanction/error"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
# The cache validators a publisher handed back with a list, plus enough
|
|
11
|
+
# context to know whether they still apply.
|
|
12
|
+
#
|
|
13
|
+
# validators.request_headers
|
|
14
|
+
# # => { "If-None-Match" => "\"0953154d0fb5aff918c5ec1daf6e9c0e\"",
|
|
15
|
+
# # "If-Modified-Since" => "Fri, 28 Aug 2026 14:02:55 GMT" }
|
|
16
|
+
#
|
|
17
|
+
# Every launch source serves both, verified live -- OFAC's SDN.CSV, the UN's
|
|
18
|
+
# consolidated.xml and Canada's sema-lmes.xml -- so a routine sync of lists
|
|
19
|
+
# that change daily at most costs three 304s instead of tens of megabytes.
|
|
20
|
+
#
|
|
21
|
+
# Both values are stored and echoed back verbatim. An `ETag` is an opaque
|
|
22
|
+
# string that only its origin server can interpret, and `Last-Modified` is an
|
|
23
|
+
# HTTP-date whose formatting is the server's business; parsing either one to
|
|
24
|
+
# re-render it is how a working conditional GET turns into a full download
|
|
25
|
+
# nobody notices.
|
|
26
|
+
#
|
|
27
|
+
# `url` is the URL that was *requested*, not the one that finally answered:
|
|
28
|
+
# OFAC redirects to blob storage, and the hop it lands on is not stable
|
|
29
|
+
# enough to key a cache by. It is kept so validators can be discarded when a
|
|
30
|
+
# publisher moves its file -- sending a previous file's ETag to a new URL
|
|
31
|
+
# invites a 304 that means nothing.
|
|
32
|
+
#
|
|
33
|
+
# Instances are frozen on construction and compare by value.
|
|
34
|
+
class Validators
|
|
35
|
+
extend T::Sig
|
|
36
|
+
|
|
37
|
+
# @api private
|
|
38
|
+
MEMBERS = T.let(%i[url etag last_modified checked_at updated_at].freeze, T::Array[Symbol])
|
|
39
|
+
|
|
40
|
+
# The URL that was requested, not the one that finally answered -- see the
|
|
41
|
+
# class comment.
|
|
42
|
+
sig { returns(String) }
|
|
43
|
+
attr_reader :url
|
|
44
|
+
|
|
45
|
+
# Opaque, and echoed back verbatim: only the origin server can interpret
|
|
46
|
+
# either of them.
|
|
47
|
+
sig { returns(T.nilable(String)) }
|
|
48
|
+
attr_reader :etag
|
|
49
|
+
|
|
50
|
+
sig { returns(T.nilable(String)) }
|
|
51
|
+
attr_reader :last_modified
|
|
52
|
+
|
|
53
|
+
sig { returns(Time) }
|
|
54
|
+
attr_reader :checked_at
|
|
55
|
+
|
|
56
|
+
sig { returns(Time) }
|
|
57
|
+
attr_reader :updated_at
|
|
58
|
+
|
|
59
|
+
# `url` is the caller's URL rather than `response.uri` for the reason in
|
|
60
|
+
# the class comment: the response may have come from a redirect target.
|
|
61
|
+
sig { params(response: T.untyped, url: T.untyped, at: T.untyped).returns(T.attached_class) }
|
|
62
|
+
def self.from_response(response, url:, at: nil)
|
|
63
|
+
new(url: url, etag: response.etag, last_modified: response.last_modified, checked_at: at)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Rebuilds from #to_h output, accepting string keys so validators survive
|
|
67
|
+
# the round-trip through the JSON the store writes.
|
|
68
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
69
|
+
def self.from_h(hash)
|
|
70
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
71
|
+
unknown = attributes.keys - MEMBERS
|
|
72
|
+
raise InvalidArgument, "unknown Validators attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
73
|
+
|
|
74
|
+
# `new(**hash)` past a required keyword parameter is one of the few
|
|
75
|
+
# things Sorbet cannot check statically. #initialize validates what
|
|
76
|
+
# arrives, which is where a bad round-trip is caught.
|
|
77
|
+
T.unsafe(self).new(**attributes)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# `checked_at` is when the publisher last confirmed this copy, whether by
|
|
81
|
+
# 200 or by 304; `updated_at` is when the bytes themselves last changed.
|
|
82
|
+
# Staleness follows the first, because "when did we last ask?" is the
|
|
83
|
+
# question a sync schedule answers; the second is what a human wants when
|
|
84
|
+
# reading why a list looks old.
|
|
85
|
+
sig do
|
|
86
|
+
params(url: T.untyped, etag: T.untyped, last_modified: T.untyped, checked_at: T.untyped,
|
|
87
|
+
updated_at: T.untyped).void
|
|
88
|
+
end
|
|
89
|
+
def initialize(url:, etag: nil, last_modified: nil, checked_at: nil, updated_at: nil)
|
|
90
|
+
@url = T.let(url!(url), String)
|
|
91
|
+
@etag = T.let(string_or_nil(etag), T.nilable(String))
|
|
92
|
+
@last_modified = T.let(string_or_nil(last_modified), T.nilable(String))
|
|
93
|
+
@checked_at = T.let(time!(checked_at), Time)
|
|
94
|
+
@updated_at = T.let(updated_at.nil? ? @checked_at : time!(updated_at), Time)
|
|
95
|
+
freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# True when the publisher gave us nothing to be conditional with. Such a
|
|
99
|
+
# record is still worth storing -- it says we looked -- but it cannot save
|
|
100
|
+
# a download, so callers treat it as no validators at all.
|
|
101
|
+
sig { returns(T::Boolean) }
|
|
102
|
+
def empty? = etag.nil? && last_modified.nil?
|
|
103
|
+
|
|
104
|
+
sig { returns(T::Boolean) }
|
|
105
|
+
def present? = !empty?
|
|
106
|
+
|
|
107
|
+
# Both are sent when both are known. RFC 9110 has the server prefer
|
|
108
|
+
# `If-None-Match` and ignore the date, but a cache in front of it may only
|
|
109
|
+
# honour one, and the second header costs 40 bytes on a request that is
|
|
110
|
+
# trying to avoid 126 MB.
|
|
111
|
+
sig { returns(T::Hash[String, String]) }
|
|
112
|
+
def request_headers
|
|
113
|
+
headers = {}
|
|
114
|
+
headers["If-None-Match"] = etag if etag
|
|
115
|
+
headers["If-Modified-Since"] = last_modified if last_modified
|
|
116
|
+
headers.freeze
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Validators belong to the URL they came from. A publisher that moves its
|
|
120
|
+
# file gets a full download rather than a 304 from whatever is now at the
|
|
121
|
+
# old address.
|
|
122
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
123
|
+
def for?(other) = url == other.to_s
|
|
124
|
+
|
|
125
|
+
# What to store after the publisher answered 304. The copy is unchanged,
|
|
126
|
+
# so `updated_at` stands; only the moment we confirmed it moves. A 304 is
|
|
127
|
+
# allowed to carry a fresh ETag, and when it does that value is the one to
|
|
128
|
+
# send next time.
|
|
129
|
+
sig { params(response: T.untyped, at: T.untyped).returns(Validators) }
|
|
130
|
+
def confirmed_by(response, at: nil)
|
|
131
|
+
self.class.new(url: url, etag: response.etag || etag,
|
|
132
|
+
last_modified: response.last_modified || last_modified,
|
|
133
|
+
checked_at: at, updated_at: updated_at)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
137
|
+
def to_h
|
|
138
|
+
{
|
|
139
|
+
url: url,
|
|
140
|
+
etag: etag,
|
|
141
|
+
last_modified: last_modified,
|
|
142
|
+
checked_at: checked_at.iso8601,
|
|
143
|
+
updated_at: updated_at.iso8601
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
148
|
+
def ==(other)
|
|
149
|
+
return false unless other.instance_of?(self.class)
|
|
150
|
+
|
|
151
|
+
to_h == other.to_h
|
|
152
|
+
end
|
|
153
|
+
alias eql? ==
|
|
154
|
+
|
|
155
|
+
sig { returns(Integer) }
|
|
156
|
+
def hash = [self.class, to_h].hash
|
|
157
|
+
|
|
158
|
+
sig { returns(String) }
|
|
159
|
+
def inspect
|
|
160
|
+
"#<#{self.class} #{url} etag=#{etag.inspect} last_modified=#{last_modified.inspect} " \
|
|
161
|
+
"checked_at=#{checked_at.iso8601}>"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
sig { params(value: T.untyped).returns(String) }
|
|
167
|
+
def url!(value)
|
|
168
|
+
string = value.to_s.strip
|
|
169
|
+
raise InvalidArgument, "url is required" if string.empty?
|
|
170
|
+
|
|
171
|
+
-string
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Truncated to the second, which is the precision #to_h serializes, so a
|
|
175
|
+
# stored record reloads to a value equal to the one that was written.
|
|
176
|
+
sig { params(value: T.untyped).returns(Time) }
|
|
177
|
+
def time!(value)
|
|
178
|
+
time = case value
|
|
179
|
+
when nil then Time.now
|
|
180
|
+
when Time then value
|
|
181
|
+
when String then Time.parse(value)
|
|
182
|
+
else raise InvalidArgument, "not a time: #{value.inspect}"
|
|
183
|
+
end
|
|
184
|
+
Time.at(time.to_i).utc
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
188
|
+
def string_or_nil(value)
|
|
189
|
+
return nil if value.nil?
|
|
190
|
+
|
|
191
|
+
string = value.to_s.strip
|
|
192
|
+
string.empty? ? nil : -string
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveSanction
|
|
5
|
+
# The gem version, and what CHANGELOG.md is about. Moves under SemVer for a
|
|
6
|
+
# new source adapter, a storage fix or a documentation release -- none of
|
|
7
|
+
# which change what a name scores. MATCHER_VERSION, below, is the one that
|
|
8
|
+
# answers that question.
|
|
9
|
+
VERSION = "1.0.0"
|
|
10
|
+
|
|
11
|
+
# Which matching pipeline scored a decision, stamped onto every MatchResult
|
|
12
|
+
# and bumped whenever a change to the normalizer, the index, the similarity
|
|
13
|
+
# algorithms or the scorer could move a score.
|
|
14
|
+
#
|
|
15
|
+
# Deliberately not VERSION. The gem version moves for a new source adapter,
|
|
16
|
+
# a storage fix, a documentation release -- none of which change what a name
|
|
17
|
+
# scores -- and an auditor asking "would this screening come out the same
|
|
18
|
+
# today?" needs the answer to that question rather than a release number
|
|
19
|
+
# that also answers several others. Its companions on the record are the
|
|
20
|
+
# weights and the snapshot checksum; between the three, a past decision is
|
|
21
|
+
# re-derivable.
|
|
22
|
+
MATCHER_VERSION = "1"
|
|
23
|
+
end
|