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,324 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "fileutils"
7
+ require "net/http"
8
+ require "uri"
9
+ require "active_sanction/error"
10
+ require "active_sanction/configuration"
11
+ require "active_sanction/http_client/errors"
12
+ require "active_sanction/http_client/response"
13
+
14
+ module ActiveSanction
15
+ # A small GET client over `net/http`, built for one job: pulling published
16
+ # sanctions files off government servers.
17
+ #
18
+ # client = ActiveSanction::HttpClient.new
19
+ # client.get("https://sanctionslistservice.ofac.treas.gov/api/download/SDN.CSV").body
20
+ # client.download("https://.../SDN_ADVANCED.XML", to: "/tmp/sdn_advanced.xml")
21
+ #
22
+ # No HTTP gem: the stdlib covers every need here, and the gem's zero-runtime-
23
+ # dependency promise is worth more to an application embedding it than the
24
+ # ergonomics of a nicer adapter layer would be.
25
+ #
26
+ # What the endpoints force on us, all verified live:
27
+ #
28
+ # * **OFAC returns 403 to a request with no User-Agent**, so the header is
29
+ # mandatory rather than defaulted-and-forgotten, and a blank one raises
30
+ # before a socket is opened.
31
+ # * **OFAC's download URLs 302 to blob storage**, so following redirects is
32
+ # part of a working GET, not an option a caller might switch on.
33
+ # * **SDN_ADVANCED.XML is 126 MB**, so #download streams to disk. Holding a
34
+ # list that size in a String to write it out again is how a sync job gets
35
+ # itself OOM-killed in a container.
36
+ #
37
+ # Bodies come back as the server sent them, with no transcoding: OFAC's CSV
38
+ # and the UN's XML disagree about encoding, and guessing here would corrupt
39
+ # one of them. Parsers (#14, #15) declare what they expect.
40
+ #
41
+ # @api private
42
+ class HttpClient
43
+ extend T::Sig
44
+
45
+ # 303 is included even though it is defined to change the method, because
46
+ # this client only ever issues GET and so already complies.
47
+ #
48
+ # @api private
49
+ REDIRECT_STATUSES = T.let([301, 302, 303, 307, 308].freeze, T::Array[Integer])
50
+
51
+ # Failures worth trying again. A 4xx is never in here: a 403 for a missing
52
+ # User-Agent or a 404 for a retired URL says the request is wrong, and
53
+ # repeating it wastes the publisher's capacity to make the same point.
54
+ #
55
+ # OpenSSL errors are deliberately absent: a certificate that does not
56
+ # verify will not verify a second later either, and quietly retrying a TLS
57
+ # failure against a government endpoint is not a behaviour worth having.
58
+ # See FATAL_ERRORS, which is where they go instead.
59
+ #
60
+ # @api private
61
+ TRANSIENT_ERRORS = T.let(
62
+ [
63
+ EOFError, IOError, SocketError, Net::HTTPBadResponse, Net::ProtocolError,
64
+ Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::ECONNRESET,
65
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EPIPE, Errno::ETIMEDOUT
66
+ ].freeze,
67
+ T::Array[T.class_of(StandardError)]
68
+ )
69
+
70
+ # Failures translated on the first attempt rather than retried. A caller
71
+ # rescuing FetchError should not have to know that this client speaks
72
+ # `net/http`, and it certainly should not have to know that `net/http`
73
+ # speaks OpenSSL -- but a bad certificate is still not worth a second
74
+ # request, so it becomes a ConnectionError that says `retryable?` is false.
75
+ #
76
+ # Guarded because `net/http` loads OpenSSL optionally, and a Ruby built
77
+ # without it can still fetch a list over plain HTTP.
78
+ #
79
+ # @api private
80
+ FATAL_ERRORS = T.let(
81
+ (defined?(::OpenSSL::SSL::SSLError) ? [::OpenSSL::SSL::SSLError] : []).freeze,
82
+ T::Array[T.class_of(StandardError)]
83
+ )
84
+
85
+ # Checked when the client is built, so a blank one raises before a socket
86
+ # is opened rather than earning a 403 from OFAC.
87
+ sig { returns(String) }
88
+ attr_reader :user_agent
89
+
90
+ sig { returns(Numeric) }
91
+ attr_reader :open_timeout
92
+
93
+ sig { returns(Numeric) }
94
+ attr_reader :read_timeout
95
+
96
+ sig { returns(Integer) }
97
+ attr_reader :max_redirects
98
+
99
+ sig { returns(Integer) }
100
+ attr_reader :max_retries
101
+
102
+ sig { returns(Numeric) }
103
+ attr_reader :retry_backoff
104
+
105
+ # Settings default to the global configuration, read at construction, so a
106
+ # client built inside a source adapter honours whatever the host
107
+ # application set at boot without threading a config object through every
108
+ # adapter.
109
+ sig do
110
+ params(user_agent: T.untyped, open_timeout: Numeric, read_timeout: Numeric, max_redirects: Integer,
111
+ max_retries: Integer, retry_backoff: Numeric).void
112
+ end
113
+ def initialize(user_agent: ActiveSanction.config.user_agent,
114
+ open_timeout: ActiveSanction.config.open_timeout,
115
+ read_timeout: ActiveSanction.config.read_timeout,
116
+ max_redirects: ActiveSanction.config.max_redirects,
117
+ max_retries: ActiveSanction.config.max_retries,
118
+ retry_backoff: ActiveSanction.config.retry_backoff)
119
+ @user_agent = T.let(Configuration.user_agent!(user_agent), String)
120
+ @open_timeout = T.let(open_timeout, Numeric)
121
+ @read_timeout = T.let(read_timeout, Numeric)
122
+ @max_redirects = T.let(max_redirects, Integer)
123
+ @max_retries = T.let(max_retries, Integer)
124
+ @retry_backoff = T.let(retry_backoff, Numeric)
125
+ end
126
+
127
+ # Fetches a URL and buffers the body in memory. Right for the index pages
128
+ # and small XML lists; use #download for anything list-sized.
129
+ sig { params(url: T.untyped, headers: T::Hash[T.untyped, T.untyped]).returns(Response) }
130
+ def get(url, headers: {})
131
+ fetch(url, headers: headers)
132
+ end
133
+
134
+ # Streams a URL to disk, returning a Response whose body is nil -- the
135
+ # bytes are in the file, and materializing them twice defeats the point.
136
+ #
137
+ # `to:` is a path, or any IO-ish object that responds to #write, which is
138
+ # what the payload cache (#11) passes so it can own its own atomicity.
139
+ # Given a path, the download lands on a sibling `.part` file and is renamed
140
+ # only once the server has answered 2xx, so an interrupted or 404'd fetch
141
+ # never leaves something at the destination that looks like a list.
142
+ sig { params(url: T.untyped, to: T.untyped, headers: T::Hash[T.untyped, T.untyped]).returns(Response) }
143
+ def download(url, to:, headers: {})
144
+ return fetch(url, headers: headers, sink: to) if to.respond_to?(:write)
145
+
146
+ stream_to_path(url, to.to_s, headers)
147
+ end
148
+
149
+ private
150
+
151
+ sig { params(url: T.untyped, path: String, headers: T::Hash[T.untyped, T.untyped]).returns(Response) }
152
+ def stream_to_path(url, path, headers)
153
+ partial = "#{path}.part"
154
+ response = T.let(File.open(partial, "wb") { |file| fetch(url, headers: headers, sink: file) }, Response)
155
+ File.rename(partial, path) if response.success?
156
+ response
157
+ ensure
158
+ # `T.must` because Sorbet reads an `ensure` as reachable before the first
159
+ # assignment in the body; `partial` is that assignment.
160
+ FileUtils.rm_f(T.must(partial))
161
+ end
162
+
163
+ # One hop at a time: each is retried on its own, so a 500 from the blob
164
+ # store the second hop landed on does not replay the first.
165
+ sig { params(url: T.untyped, headers: T::Hash[T.untyped, T.untyped], sink: T.untyped).returns(Response) }
166
+ def fetch(url, headers:, sink: nil)
167
+ uri = uri!(url)
168
+ request_headers = headers!(headers)
169
+ seen = [uri.to_s]
170
+ redirects = []
171
+
172
+ loop do
173
+ response = with_retries(uri) { perform(uri, request_headers, sink, redirects) }
174
+ return response unless response.redirect? && response["location"]
175
+
176
+ redirects << uri
177
+ raise TooManyRedirects, chain_message(redirects) if redirects.size > max_redirects
178
+
179
+ uri = next_hop(uri, response["location"], seen)
180
+ end
181
+ end
182
+
183
+ sig do
184
+ params(uri: URI::Generic, headers: T::Hash[String, String], sink: T.untyped,
185
+ redirects: T::Array[URI::Generic]).returns(Response)
186
+ end
187
+ def perform(uri, headers, sink, redirects)
188
+ response = T.let(nil, T.nilable(Response))
189
+ build_http(uri).start do |session|
190
+ session.request(Net::HTTP::Get.new(uri, headers)) do |raw|
191
+ response = receive(raw, uri, sink, redirects)
192
+ end
193
+ end
194
+ T.must(response)
195
+ end
196
+
197
+ # Only a successful body is streamed. An error page is small and the caller
198
+ # will want to read it, and writing one into the sink would hand the
199
+ # payload cache a 404 notice to checksum as though it were a list.
200
+ #
201
+ # A 304 is the one status with nothing to read either way: it is defined to
202
+ # carry no body, and handing back the empty string `read_body` produces
203
+ # would give conditional GET (#10) something a caller could try to parse.
204
+ sig do
205
+ params(raw: T.untyped, uri: URI::Generic, sink: T.untyped,
206
+ redirects: T::Array[URI::Generic]).returns(Response)
207
+ end
208
+ def receive(raw, uri, sink, redirects)
209
+ status = raw.code.to_i
210
+ body = T.let(nil, T.nilable(String))
211
+ if status == 304
212
+ nil
213
+ elsif sink && status.between?(200, 299)
214
+ rewind(sink)
215
+ raw.read_body { |chunk| sink.write(chunk) }
216
+ else
217
+ body = raw.read_body
218
+ end
219
+ Response.new(status: status, headers: raw.to_hash, uri: uri, body: body, redirects: redirects)
220
+ end
221
+
222
+ # A read that dies mid-body has already written part of the file, so a
223
+ # retry has to start the sink over rather than append a second prefix to
224
+ # the first. An append-only sink cannot be reset; it is the caller's to
225
+ # handle, and #download's own path never produces one.
226
+ sig { params(sink: T.untyped).void }
227
+ def rewind(sink)
228
+ return unless sink.respond_to?(:truncate) && sink.respond_to?(:rewind)
229
+
230
+ sink.rewind
231
+ sink.truncate(0)
232
+ end
233
+
234
+ # Exponential backoff, no jitter: this is one process fetching a handful of
235
+ # files on a schedule, not a fleet that needs de-synchronizing, and a
236
+ # deterministic delay is one less thing for a stuck sync to explain.
237
+ sig { params(uri: URI::Generic, block: T.proc.returns(Response)).returns(Response) }
238
+ def with_retries(uri, &block)
239
+ attempt = 0
240
+ loop do
241
+ attempt += 1
242
+ begin
243
+ response = block.call
244
+ return response unless retry_status?(response, attempt)
245
+ rescue *FATAL_ERRORS => e
246
+ raise ConnectionError.new(failure_message(uri, e, attempt), retryable: false)
247
+ rescue Timeout::Error => e
248
+ raise TimeoutError, failure_message(uri, e, attempt) unless attempt <= max_retries
249
+ rescue *TRANSIENT_ERRORS => e
250
+ raise ConnectionError, failure_message(uri, e, attempt) unless attempt <= max_retries
251
+ end
252
+ sleep(backoff_for(attempt))
253
+ end
254
+ end
255
+
256
+ sig { params(response: Response, attempt: Integer).returns(T::Boolean) }
257
+ def retry_status?(response, attempt)
258
+ response.server_error? && attempt <= max_retries
259
+ end
260
+
261
+ sig { params(attempt: Integer).returns(Numeric) }
262
+ def backoff_for(attempt)
263
+ retry_backoff * (2**(attempt - 1))
264
+ end
265
+
266
+ sig { params(uri: URI::Generic).returns(Net::HTTP) }
267
+ def build_http(uri)
268
+ http = Net::HTTP.new(uri.host, uri.port)
269
+ http.use_ssl = uri.scheme == "https"
270
+ http.open_timeout = open_timeout
271
+ http.read_timeout = read_timeout
272
+ http
273
+ end
274
+
275
+ # The configured agent unless the caller overrode it, compared case-
276
+ # insensitively because HTTP header names are. Validating the merged value
277
+ # rather than only the configured one is what makes the guarantee real: the
278
+ # request that goes out is the one checked, and it is checked here, before
279
+ # a socket is opened.
280
+ sig { params(extra: T.untyped).returns(T::Hash[String, String]) }
281
+ def headers!(extra)
282
+ headers = extra.to_h.to_h { |name, value| [name.to_s, value.to_s] }
283
+ key = headers.keys.find { |name| name.casecmp?("user-agent") }
284
+ agent = key ? headers[key] : user_agent
285
+ Configuration.user_agent!(agent)
286
+ headers.delete(key) if key
287
+ headers.merge("User-Agent" => agent)
288
+ end
289
+
290
+ sig { params(url: T.untyped).returns(URI::Generic) }
291
+ def uri!(url)
292
+ uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
293
+ raise InvalidArgument, "#{url.inspect} is not an http(s) URL" unless uri.is_a?(URI::HTTP) && uri.host
294
+
295
+ uri
296
+ rescue URI::InvalidURIError => e
297
+ raise InvalidArgument, "#{url.inspect} is not a URL: #{e.message}"
298
+ end
299
+
300
+ # `Location` is allowed to be relative, and publishers use that, so it is
301
+ # resolved against the URL that produced it rather than parsed alone.
302
+ sig { params(from: URI::Generic, location: T.untyped, seen: T::Array[String]).returns(URI::Generic) }
303
+ def next_hop(from, location, seen)
304
+ target = URI.join(from, location)
305
+ raise InvalidRedirect, "#{from} redirected to #{location.inspect}" unless target.is_a?(URI::HTTP) && target.host
306
+ raise RedirectLoop, "#{from} redirected back to #{target}" if seen.include?(target.to_s)
307
+
308
+ seen << target.to_s
309
+ target
310
+ rescue URI::Error => e
311
+ raise InvalidRedirect, "#{from} redirected to an unusable location #{location.inspect}: #{e.message}"
312
+ end
313
+
314
+ sig { params(redirects: T::Array[URI::Generic]).returns(String) }
315
+ def chain_message(redirects)
316
+ "#{redirects.first} exceeded #{max_redirects} redirects: #{redirects.join(" -> ")}"
317
+ end
318
+
319
+ sig { params(uri: URI::Generic, error: Exception, attempts: Integer).returns(String) }
320
+ def failure_message(uri, error, attempts)
321
+ "GET #{uri} failed after #{attempts} attempt#{"s" unless attempts == 1}: #{error.class}: #{error.message}"
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,212 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/partial_date"
7
+
8
+ module ActiveSanction
9
+ # A document number a sanctions list published for an entity: passport,
10
+ # national ID, tax number, company registration. It is the highest-value
11
+ # signal the matcher has -- an exact passport match is near-decisive in the
12
+ # scorer (#32), where a name match never is.
13
+ #
14
+ # ActiveSanction::Identifier.new(
15
+ # kind: :passport,
16
+ # value: "AB-123 456",
17
+ # country: "Egypt",
18
+ # issued_on: PartialDate.parse("2004-06-01"),
19
+ # expires_on: PartialDate.parse("2009-05-31"),
20
+ # note: "expired"
21
+ # )
22
+ #
23
+ # `value` is the one required field: an identifier with no number is not an
24
+ # identifier. Everything else is optional, because OFAC's remarks often give
25
+ # just `Passport 123456 (Egypt)` and nothing more.
26
+ #
27
+ # The published string is kept verbatim -- it is what gets shown back to a
28
+ # user justifying a hit -- while comparison runs on #normalized_value, since
29
+ # two governments transcribing one passport rarely agree on its punctuation.
30
+ # Instances are frozen on construction and compare by value.
31
+ class Identifier
32
+ extend T::Sig
33
+
34
+ # The UN publishes TYPE_OF_DOCUMENT as free text ("Passport", "National
35
+ # Identification Number"), so adapters map onto these rather than passing a
36
+ # source's own vocabulary through. :other is a real answer, not a failure:
37
+ # a document we cannot classify still matches on its number.
38
+ KINDS = T.let(%i[passport national_id tax_id registration_number other].freeze, T::Array[Symbol])
39
+
40
+ # Canonical member order. Snapshot (#8) checksums the serialized form, so
41
+ # #to_h must lay its keys out the same way every time. `normalized_value`
42
+ # is derived rather than stored: a stored copy can disagree with the value
43
+ # it describes, and then two records that mean the same thing checksum
44
+ # apart.
45
+ #
46
+ # @api private
47
+ MEMBERS = T.let(%i[kind value country issued_on expires_on note].freeze, T::Array[Symbol])
48
+
49
+ # Everything a number is not: spaces, hyphens, slashes, dots. OFAC writes
50
+ # `AB-123 456` where the UN writes `AB123456`, and neither is more correct.
51
+ #
52
+ # @api private
53
+ INSIGNIFICANT = T.let(/[^[:alnum:]]+/, Regexp)
54
+
55
+ sig { returns(Symbol) }
56
+ attr_reader :kind
57
+
58
+ # The published string, verbatim: it is what gets shown back to whoever has
59
+ # to justify a hit.
60
+ sig { returns(String) }
61
+ attr_reader :value
62
+
63
+ sig { returns(T.nilable(String)) }
64
+ attr_reader :country
65
+
66
+ sig { returns(T.nilable(PartialDate)) }
67
+ attr_reader :issued_on
68
+
69
+ sig { returns(T.nilable(PartialDate)) }
70
+ attr_reader :expires_on
71
+
72
+ sig { returns(T.nilable(String)) }
73
+ attr_reader :note
74
+
75
+ # What comparison actually runs on -- see #== below.
76
+ sig { returns(String) }
77
+ attr_reader :normalized_value
78
+
79
+ # Rebuilds an identifier from #to_h output. Accepts string keys, and dates
80
+ # as the hashes JSON leaves behind, so a record survives a round-trip
81
+ # through storage (#24) without a separate coercion step.
82
+ sig { params(hash: T.untyped).returns(T.attached_class) }
83
+ def self.from_h(hash)
84
+ attributes = hash.to_h.transform_keys(&:to_sym)
85
+ unknown = attributes.keys - MEMBERS
86
+ raise InvalidArgument, "unknown Identifier attribute(s): #{unknown.join(", ")}" if unknown.any?
87
+
88
+ # `new(**hash)` past a required keyword parameter is one of the few
89
+ # things Sorbet cannot check statically. #initialize validates what
90
+ # arrives, which is where a bad round-trip is caught.
91
+ T.unsafe(self).new(**attributes)
92
+ end
93
+
94
+ # `kind` defaults to :other because a number we cannot classify is still
95
+ # worth matching on -- OFAC remarks carry plenty of them.
96
+ #
97
+ # Untyped on purpose, and the same choice Entity makes: all six are the
98
+ # publisher's own text arriving as whatever the parser made of it. The two
99
+ # dates additionally accept a PartialDate, a #to_h hash or anything
100
+ # PartialDate.parse reads -- see #date_or_nil.
101
+ sig do
102
+ params(value: T.untyped, kind: T.untyped, country: T.untyped, issued_on: T.untyped,
103
+ expires_on: T.untyped, note: T.untyped).void
104
+ end
105
+ def initialize(value:, kind: :other, country: nil, issued_on: nil, expires_on: nil, note: nil)
106
+ @value = T.let(value!(value), String)
107
+ @normalized_value = T.let(-@value.downcase.gsub(INSIGNIFICANT, ""), String)
108
+ @kind = T.let(kind!(kind), Symbol)
109
+ @country = T.let(string_or_nil(country), T.nilable(String))
110
+ @issued_on = T.let(date_or_nil(:issued_on, issued_on), T.nilable(PartialDate))
111
+ @expires_on = T.let(date_or_nil(:expires_on, expires_on), T.nilable(PartialDate))
112
+ @note = T.let(string_or_nil(note), T.nilable(String))
113
+ freeze
114
+ end
115
+
116
+ sig { returns(T::Boolean) }
117
+ def passport? = kind == :passport
118
+
119
+ sig { returns(T::Hash[Symbol, T.untyped]) }
120
+ def to_h
121
+ {
122
+ kind: kind,
123
+ value: value,
124
+ country: country,
125
+ issued_on: issued_on&.to_h,
126
+ expires_on: expires_on&.to_h,
127
+ note: note
128
+ }
129
+ end
130
+
131
+ sig { returns(String) }
132
+ def to_s = value
133
+
134
+ # Equality is the comparison the acceptance criteria asks for: `AB-123 456`
135
+ # and `ab123456` are one passport written down twice, and de-duplicating
136
+ # the same document across OFAC and the UN depends on saying so. Dates and
137
+ # notes stay out of the key -- publishers report them inconsistently, and
138
+ # letting them split one document into two records would defeat the dedup
139
+ # this exists for. Entity (#4) still compares its members through #to_h, so
140
+ # nothing here hides a differing published string from a record diff.
141
+ sig { params(other: T.untyped).returns(T::Boolean) }
142
+ def ==(other)
143
+ return false unless other.instance_of?(self.class)
144
+
145
+ comparison_key == other.comparison_key
146
+ end
147
+ alias eql? ==
148
+
149
+ sig { returns(Integer) }
150
+ def hash
151
+ [self.class, comparison_key].hash
152
+ end
153
+
154
+ sig { returns(String) }
155
+ def inspect
156
+ "#<#{self.class} #{kind.inspect} #{value.inspect}#{" country=#{country.inspect}" if country}>"
157
+ end
158
+
159
+ protected
160
+
161
+ # Country is folded rather than dropped: two passports with the same number
162
+ # from different countries are different documents.
163
+ sig { returns([Symbol, String, T.nilable(String)]) }
164
+ def comparison_key
165
+ [kind, normalized_value, country&.downcase]
166
+ end
167
+
168
+ private
169
+
170
+ sig { params(value: T.untyped).returns(String) }
171
+ def value!(value)
172
+ string = value.to_s.strip
173
+ raise InvalidArgument, "value is required" if string.empty?
174
+ raise InvalidArgument, "value has no alphanumerics: #{string.inspect}" if string.gsub(INSIGNIFICANT, "").empty?
175
+
176
+ -string
177
+ end
178
+
179
+ # Case is folded before the lookup: sources capitalize their document types
180
+ # however they like, and no adapter should have to remember which.
181
+ sig { params(value: T.untyped).returns(Symbol) }
182
+ def kind!(value)
183
+ raise InvalidArgument, "kind is required" if value.nil? || value.to_s.empty?
184
+
185
+ symbol = value.to_s.downcase.to_sym
186
+ return symbol if KINDS.include?(symbol)
187
+
188
+ raise InvalidArgument, "unknown kind #{symbol.inspect}, expected one of #{KINDS.join(", ")}"
189
+ end
190
+
191
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
192
+ def string_or_nil(value)
193
+ return nil if value.nil?
194
+
195
+ string = value.to_s.strip
196
+ string.empty? ? nil : -string
197
+ end
198
+
199
+ # Issue and expiry dates arrive as imprecisely as any other list date -- the
200
+ # UN publishes year-only expiries -- so they are PartialDates. Hashes and
201
+ # strings are coerced, which is what makes a JSON round-trip land where it
202
+ # started.
203
+ sig { params(member: Symbol, value: T.untyped).returns(T.nilable(PartialDate)) }
204
+ def date_or_nil(member, value)
205
+ case value
206
+ when nil, PartialDate then value
207
+ when Hash then PartialDate.from_h(value)
208
+ else PartialDate.parse(value) || raise(InvalidArgument, "#{member} is not a date: #{value.inspect}")
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,89 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ class Index
8
+ # The mutable half of an immutable index.
9
+ #
10
+ # Index.build is the entry point; this exists so that the thing a web
11
+ # process shares between threads has no `add` on it at all. An index that
12
+ # could be appended to would be an index that has to be locked, and the
13
+ # whole design is that it is not -- see Index on the swap.
14
+ #
15
+ # builder = ActiveSanction::Index::Builder.new
16
+ # store.each_entity { |entity| builder.add(entity) }
17
+ # index = builder.build
18
+ #
19
+ # A builder is single-threaded and single-use. Calling `build` twice
20
+ # returns two indexes over the same entries, which is harmless and not
21
+ # something anything needs.
22
+ class Builder
23
+ extend T::Sig
24
+
25
+ sig { void.checked(:tests) }
26
+ def initialize
27
+ @entries = T.let([], T::Array[Entry])
28
+ @tokens = T.let({}, T::Hash[String, T::Array[Integer]])
29
+ @trigrams = T.let({}, T::Hash[String, T::Array[Integer]])
30
+ @phonetics = T.let({}, T::Hash[String, T::Array[Integer]])
31
+ end
32
+
33
+ # Every name the entity carries, folded under its type and posted to the
34
+ # three feature spaces.
35
+ #
36
+ # Two names are skipped, and both are skips rather than errors because a
37
+ # government file is not something a build gets to reject:
38
+ #
39
+ # **A name that folds to nothing.** Punctuation, an emoji, a row of
40
+ # dashes -- Form#empty? exists for these. Such a name cannot be scored,
41
+ # so indexing it would only produce candidates no comparison can rank.
42
+ #
43
+ # **A name that folds onto one this entity already has.** OFAC publishes
44
+ # `MUÑOZ HERMANOS S.A.` and `MUNOZ HERMANOS` on one record, and after
45
+ # the fold they are the same string. Keeping both would post the same
46
+ # entity twice under every one of its features, which costs memory on
47
+ # the way in and hands the scorer the same comparison twice on the way
48
+ # out. The publisher's own spelling is not lost: the first one to arrive
49
+ # keeps its Name, and that is what a hit is reported in.
50
+ sig { params(entity: Entity).returns(T.self_type).checked(:tests) }
51
+ def add(entity)
52
+ folded = T.let({}, T::Hash[String, TrueClass])
53
+ entity.names.each do |name|
54
+ form = Normalizer.call(name.value, type: entity.type)
55
+ next if form.empty? || folded.key?(form.value)
56
+
57
+ folded[form.value] = true
58
+ index(Entry.new(id: @entries.size, entity: entity, name: name, form: form))
59
+ end
60
+ self
61
+ end
62
+
63
+ # The finished index. Everything it holds is frozen on the way in.
64
+ sig { returns(Index).checked(:tests) }
65
+ def build
66
+ Index.new(entries: @entries, tokens: @tokens, trigrams: @trigrams, phonetics: @phonetics)
67
+ end
68
+
69
+ private
70
+
71
+ sig { params(entry: Entry).void }
72
+ def index(entry)
73
+ @entries << entry
74
+ post(@tokens, Features.tokens(entry.form), entry.id)
75
+ post(@trigrams, Features.trigrams(entry.form), entry.id)
76
+ post(@phonetics, Features.phonetics(entry.form), entry.id)
77
+ end
78
+
79
+ # Ids arrive in ascending order because they are assigned in the order
80
+ # entries are made, so a posting list is sorted without ever being
81
+ # sorted. Query relies on that only for determinism; nothing binary
82
+ # searches these.
83
+ sig { params(postings: T::Hash[String, T::Array[Integer]], features: T::Array[String], id: Integer).void }
84
+ def post(postings, features, id)
85
+ features.each { |feature| (postings[feature] ||= []) << id }
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,63 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ class Index
8
+ # A name the index thinks is worth comparing, and how strongly it thought
9
+ # so.
10
+ #
11
+ # candidate.name.value # => "ABBAS, Abu"
12
+ # candidate.form.value # => "abbas abu"
13
+ # candidate.weight # => 14.82
14
+ #
15
+ # ### `weight` is not a score
16
+ #
17
+ # It is the sum of what the query and this name have in common, each
18
+ # shared feature counted by how rare it is -- see Index for the arithmetic.
19
+ # It says how confident the retrieval was, in a unit with no upper bound
20
+ # and no meaning outside the corpus it was computed against: a long name
21
+ # with rare tokens outranks a short one with common tokens before either
22
+ # has been compared to anything.
23
+ #
24
+ # So it is not comparable with the 0..100 a MatchResult (#33) carries, it
25
+ # is not a threshold anybody should set, and it must never reach a
26
+ # compliance user. Its one job is ordering the cap -- when more names
27
+ # match than the caller asked for, this decides which are dropped -- and
28
+ # it is exposed rather than hidden because that decision is the one thing
29
+ # about this stage that can silently cost a true match, and a caller
30
+ # investigating why a name was missed needs to see where it fell.
31
+ class Candidate
32
+ extend T::Sig
33
+
34
+ sig { returns(Entry).checked(:tests) }
35
+ attr_reader :entry
36
+
37
+ sig { returns(Float).checked(:tests) }
38
+ attr_reader :weight
39
+
40
+ sig { params(entry: Entry, weight: Float).void.checked(:tests) }
41
+ def initialize(entry:, weight:)
42
+ @entry = entry
43
+ @weight = weight
44
+ freeze
45
+ end
46
+
47
+ sig { returns(Entity).checked(:tests) }
48
+ def entity = entry.entity
49
+
50
+ sig { returns(Name).checked(:tests) }
51
+ def name = entry.name
52
+
53
+ sig { returns(Normalizer::Form).checked(:tests) }
54
+ def form = entry.form
55
+
56
+ sig { returns(Symbol).checked(:tests) }
57
+ def source = entry.source
58
+
59
+ sig { returns(String) }
60
+ def inspect = "#<#{self.class} #{name.value.inspect} weight=#{weight.round(2)}>"
61
+ end
62
+ end
63
+ end