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,291 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/parsers"
7
+ require "active_sanction/sources"
8
+ require "active_sanction/sources/base"
9
+
10
+ module ActiveSanction
11
+ module Sources
12
+ # Australia's Consolidated List: everybody the Foreign Minister has
13
+ # designated or declared under the Autonomous Sanctions Regulations 2011,
14
+ # together with every UN Security Council listing Australia gives effect to.
15
+ #
16
+ # snapshot = ActiveSanction::Sources[:australia_dfat].new.sync
17
+ #
18
+ # ### It is published as a spreadsheet, and as nothing else
19
+ #
20
+ # https://www.dfat.gov.au/sites/default/files/Australian_Sanctions_Consolidated_List.xlsx
21
+ #
22
+ # "Consolidated List" 11,163 rows, 19 columns, 1.3 MB
23
+ #
24
+ # One `.xlsx` file, one sheet. There is no CSV, no XML and no JSON: DFAT's
25
+ # Consolidated List page offers exactly one download, and this is it. So
26
+ # reading a spreadsheet is not a convenience here, it is the price of
27
+ # screening against Australian sanctions at all.
28
+ #
29
+ # That was the open question on this list, and the answer was not to take a
30
+ # dependency. Parsers::Spreadsheet reads the workbook with `zlib` and the
31
+ # XML toolkit the other five adapters already use -- an `.xlsx` being a ZIP
32
+ # of XML parts -- so this adapter costs a host nothing to install. See that
33
+ # class for what it does and does not read.
34
+ #
35
+ # ### The URL moved, and the old one still answers
36
+ #
37
+ # The path this gem was scoped against, `regulation8_consolidated.xlsx`,
38
+ # is gone. What is left in its place is a redirect to
39
+ # `regulation8_consolidated_2.xls` -- a real file, served 200, in the old
40
+ # binary Excel format, last modified in March 2022. An adapter pointed at it
41
+ # would download a list four years stale on every sync and never once look
42
+ # unhealthy. The URL above is the one DFAT's own page links today.
43
+ #
44
+ # ### DFAT's edge rejects this gem's User-Agent
45
+ #
46
+ # Verified against the live endpoint: `active_sanction/x.y.z (+https://...)`
47
+ # gets no response at all -- not a 403, a dropped connection -- while
48
+ # `curl/8.7.1`, `Wget/1.21` and `python-requests/2.31.0` are all served. The
49
+ # filter is on the leading product token, and an unrecognised one is
50
+ # dropped, so identifying ourselves honestly is what gets us blocked.
51
+ #
52
+ # What is sent instead is the configured agent inside the form written for
53
+ # exactly this: `Mozilla/5.0 (compatible; <agent>)`, which is how a
54
+ # well-behaved crawler has identified itself since Googlebot. It is not a
55
+ # disguise -- the agent, its version and the contact URL an operator
56
+ # configured are all still in the string, and DFAT can still see who we are
57
+ # and block us on purpose. It is the same string in a shape the edge parses.
58
+ # See #fetch_file, which is the only place this source departs from Base.
59
+ #
60
+ # ### A record is several rows
61
+ #
62
+ # DFAT publishes one row per name. Reference `1000` is a primary name and
63
+ # `1000a`, `1000b` are its aliases, with every other column repeated on each
64
+ # -- so 11,163 rows are 3,906 records: 2,543 people, 1,041 organizations and
65
+ # 322 vessels. Record says how they are joined, and what the Control Date
66
+ # is not.
67
+ #
68
+ # ### What a clean Australian result is worth
69
+ #
70
+ # About what a UN one is worth, which is unsurprising: 1,172 of the 3,906
71
+ # records are listings by a UN committee that Australia has given effect to,
72
+ # and they arrive with the UN's own text intact. Alias grading is published
73
+ # as a field on all 6,802 aliases. Birth dates are published on 6,823 rows
74
+ # in nine spellings, which PublishedDate reads to within four fragments of
75
+ # the whole list.
76
+ #
77
+ # Against that, DFAT publishes no document numbers of any kind -- no
78
+ # passport, no national identity number, no company registration -- for any
79
+ # of the 3,906 records. The only identifier on the list is an IMO number, on
80
+ # 344 vessel rows. So an Australian name match has nothing behind it to make
81
+ # it decisive, in the way an OFAC passport number usually settles one, and a
82
+ # screening policy should know that before it sets a threshold.
83
+ #
84
+ # ### What this adapter does not do
85
+ #
86
+ # It does not read the Listing Information prose for anything but the
87
+ # listing date. The column holds relisting histories, UN committee
88
+ # references and the reasons for a designation, in a dozen shapes, and
89
+ # reading it properly is the job OFAC's RemarksParser does and wants the
90
+ # same treatment -- a measured coverage figure -- rather than a regex added
91
+ # here in passing. It is kept verbatim in remarks.
92
+ #
93
+ # It does not decompose an address. DFAT publishes one free-text column and
94
+ # no parts, so the whole published string is the street; see Record.
95
+ class AustraliaDfat < Base
96
+ extend T::Sig
97
+
98
+ key :australia_dfat
99
+ jurisdiction :au
100
+ authority "Australian Sanctions Office, Department of Foreign Affairs and Trade"
101
+ format :xlsx
102
+
103
+ # Creative Commons Attribution 4.0, with the Commonwealth Coat of Arms
104
+ # carved out -- the same shape as the UK's: the data is reusable, the
105
+ # crest is not.
106
+ licence_notice "Creative Commons Attribution 4.0 International, " \
107
+ "attributed to the Department of Foreign Affairs and " \
108
+ "Trade. The Commonwealth Coat of Arms is excluded. " \
109
+ "Verified 2026-09-11."
110
+ licence_url "https://www.dfat.gov.au/about-us/about-this-website/copyright"
111
+
112
+ url :main, "https://www.dfat.gov.au/sites/default/files/Australian_Sanctions_Consolidated_List.xlsx"
113
+
114
+ # @api private
115
+ SHEET = T.let("Consolidated List", String)
116
+
117
+ # @api private
118
+ LIST = T.let(Parsers::Spreadsheet.new(sheet: SHEET), Parsers::Spreadsheet)
119
+
120
+ # The columns this adapter reads by name. The sheet names its own, so
121
+ # these are not a declaration of its shape -- they are what is checked
122
+ # before a single row is mapped, so that a column DFAT renames says so
123
+ # once and loudly rather than reading nil on all 11,163 rows.
124
+ #
125
+ # @api private
126
+ REQUIRED_COLUMNS = T.let(
127
+ %i[reference name_of_individual_or_entity type name_type alias_strength date_of_birth citizenship
128
+ address additional_information listing_information imo_number committees control_date
129
+ instrument_of_designation].freeze,
130
+ T::Array[Symbol]
131
+ )
132
+
133
+ # The letters DFAT suffixes an alias reference with: `1000a`, and six
134
+ # times `1000aa`. Stripping them is what joins a group.
135
+ #
136
+ # @api private
137
+ ALIAS_SUFFIX = T.let(/[a-z]+\z/, Regexp)
138
+
139
+ # `a) ... b) ...`: a UN enumeration inside one cell, which DFAT carries
140
+ # through into the addresses and the birth dates.
141
+ #
142
+ # @api private
143
+ ENUMERATOR = T.let(/(?:\A|[[:space:]])[a-z]\)[[:space:]]*/, Regexp)
144
+
145
+ # Every kind of space: these cells carry non-breaking ones, which
146
+ # `String#strip` leaves in place.
147
+ #
148
+ # @api private
149
+ SPACE = T.let(/[[:space:]]+/, Regexp)
150
+
151
+ # The form a filtered edge recognises -- see the class comment.
152
+ #
153
+ # @api private
154
+ COMPATIBLE_AGENT = T.let("Mozilla/5.0 (compatible; %s)", String)
155
+
156
+ # Records that could not be used, and fields that could not be read.
157
+ # Read after #parse; sync orchestration (#34) reports them.
158
+ sig { returns(T::Array[Parsers::Warning]) }
159
+ attr_reader :warnings
160
+
161
+ sig { params(args: T.untyped, options: T.untyped).void }
162
+ def initialize(*args, **options)
163
+ super
164
+ @warnings = T.let([], T::Array[Parsers::Warning])
165
+ @unmapped = T.let([], T::Array[Parsers::Warning])
166
+ @modified = T.let(nil, T.nilable(String))
167
+ end
168
+
169
+ sig { override.params(raw: T.untyped).returns(T::Array[Entity]) }
170
+ def parse(raw)
171
+ reader = LIST.read(raw)
172
+ entities = build(reader)
173
+ @modified = reader.modified
174
+ @warnings = reader.warnings + @unmapped
175
+ entities
176
+ end
177
+
178
+ # `2026-09-04T05:37:12Z` -- the moment DFAT saved the workbook, which it
179
+ # stamps inside the file and reports on its own download page as the date
180
+ # the list was last updated. Falls back to Last-Modified for a payload
181
+ # handed straight to #snapshot.
182
+ sig { override.returns(T.nilable(String)) }
183
+ def source_version = @modified || super
184
+
185
+ # A reference with its alias suffix removed: `1000a` and `1000b` are both
186
+ # part of `1000`. Public because it is what Record identifies a group by
187
+ # and what a caller reconciling a hit against DFAT's own site needs.
188
+ sig { params(reference: T.untyped).returns(T.nilable(String)) }
189
+ def self.group(reference)
190
+ base = reference.to_s.strip.sub(ALIAS_SUFFIX, "")
191
+ base.empty? ? nil : base
192
+ end
193
+
194
+ # One cell split on the `a) ... b) ...` enumeration DFAT writes several
195
+ # values with, or the whole cell where it wrote only one.
196
+ sig { params(cell: T.untyped).returns(T::Array[String]) }
197
+ def self.enumerated(cell)
198
+ cell.to_s.split(ENUMERATOR).map { |part| part.gsub(SPACE, " ").strip }.reject(&:empty?)
199
+ end
200
+
201
+ private
202
+
203
+ # The one place this source departs from Base: the request carries the
204
+ # configured User-Agent wrapped in the `Mozilla/5.0 (compatible; ...)`
205
+ # form, because DFAT's edge drops a request whose leading product token
206
+ # it does not recognise. The class comment says why that is identification
207
+ # rather than concealment.
208
+ sig { override.params(name: Symbol, address: String, force: T::Boolean).returns(Fetcher::Result) }
209
+ def fetch_file(name, address, force)
210
+ fetcher.fetch(address, key: file_key(name), force: force, headers: { "User-Agent" => compatible_agent })
211
+ .success!
212
+ end
213
+
214
+ # `Kernel.format` explicitly: `format` on a source is the Definition
215
+ # reader that answers `:xlsx`, and calling it here would be asking the
216
+ # declaration for a User-Agent.
217
+ sig { returns(String) }
218
+ def compatible_agent = Kernel.format(COMPATIBLE_AGENT, ActiveSanction.config.user_agent)
219
+
220
+ sig { params(reader: Parsers::Spreadsheet::Reader).returns(T::Array[Entity]) }
221
+ def build(reader)
222
+ @unmapped = []
223
+ rows = reader.to_a
224
+ verify_columns!(rows.first)
225
+ group(rows).filter_map do |reference, group|
226
+ record = Record.new(group)
227
+ record.entity || note_nameless(reference, group)
228
+ end
229
+ end
230
+
231
+ # Rows in published order, gathered under the reference they share. A row
232
+ # whose reference is blank cannot be joined to anything and is dropped
233
+ # with a warning; none of the 11,163 published today is.
234
+ sig do
235
+ params(rows: T::Array[Parsers::Spreadsheet::Row])
236
+ .returns(T::Hash[String, T::Array[Parsers::Spreadsheet::Row]])
237
+ end
238
+ def group(rows)
239
+ rows.each_with_object({}) do |row, groups|
240
+ reference = self.class.group(row[:reference]) || note_unreferenced(row)
241
+ (groups[reference] ||= []) << row unless reference.nil?
242
+ end
243
+ end
244
+
245
+ # A sheet whose header no longer names a column this reads is not a list
246
+ # with an empty column; it is a publisher who has changed the file, and
247
+ # every record built from it afterwards would be missing whatever that
248
+ # column carried. There is nothing to salvage, so nothing is.
249
+ sig { params(row: T.nilable(Parsers::Spreadsheet::Row)).void }
250
+ def verify_columns!(row)
251
+ raise Parsers::ParseError, "the #{SHEET.inspect} sheet has a header and no rows" if row.nil?
252
+
253
+ missing = REQUIRED_COLUMNS - row.columns
254
+ return if missing.empty?
255
+
256
+ raise Parsers::ParseError,
257
+ "the #{SHEET.inspect} sheet does not name the column(s) #{missing.join(", ")}. It names: " \
258
+ "#{row.columns.join(", ")}"
259
+ end
260
+
261
+ sig { params(row: Parsers::Spreadsheet::Row).returns(NilClass) }
262
+ def note_unreferenced(row)
263
+ @unmapped << Parsers::Warning.new(
264
+ line: row.number,
265
+ message: "row #{row.number} carries no reference, so it cannot be joined to a record",
266
+ snippet: row[:name_of_individual_or_entity]
267
+ )
268
+ nil
269
+ end
270
+
271
+ # A group whose rows are all nameless cannot be screened against. None of
272
+ # the 3,906 records published today is nameless; the warning exists so
273
+ # that the day one is, it is visible rather than absent.
274
+ sig do
275
+ params(reference: String, group: T::Array[Parsers::Spreadsheet::Row]).returns(NilClass)
276
+ end
277
+ def note_nameless(reference, group)
278
+ @unmapped << Parsers::Warning.new(
279
+ line: group.first&.number,
280
+ message: "reference #{reference.inspect} has no name on any of its #{group.size} row(s) and was skipped"
281
+ )
282
+ nil
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ require "active_sanction/sources/australia_dfat/published_date"
289
+ require "active_sanction/sources/australia_dfat/record"
290
+
291
+ ActiveSanction::Sources.register(ActiveSanction::Sources::AustraliaDfat)
@@ -0,0 +1,295 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "time"
7
+ require "active_sanction/error"
8
+ require "active_sanction/entity"
9
+ require "active_sanction/snapshot"
10
+ require "active_sanction/fetcher"
11
+ require "active_sanction/parsers"
12
+ require "active_sanction/payload_cache"
13
+ require "active_sanction/sources"
14
+ require "active_sanction/sources/definition"
15
+ require "active_sanction/sources/remarks"
16
+
17
+ module ActiveSanction
18
+ module Sources
19
+ # The contract every sanctions list adapter implements: declare what the
20
+ # list is and where it lives, then turn its bytes into Entities.
21
+ #
22
+ # class UnConsolidated < ActiveSanction::Sources::Base
23
+ # key :un_consolidated
24
+ # jurisdiction :un
25
+ # authority "United Nations Security Council"
26
+ # format :xml
27
+ # url :main, "https://scsanctions.un.org/resources/xml/en/consolidated.xml"
28
+ #
29
+ # def parse(raw)
30
+ # ... # => [Entity, ...]
31
+ # end
32
+ # end
33
+ #
34
+ # ActiveSanction::Sources.register(UnConsolidated)
35
+ #
36
+ # #parse is the whole of what an adapter must write. Everything above it is
37
+ # declaration (Definition), and everything below it -- conditional GET,
38
+ # payload caching, checksumming the result into a Snapshot -- is here, the
39
+ # same for every list, so that adding a jurisdiction is a parsing problem
40
+ # and not a plumbing one.
41
+ #
42
+ # snapshot = ActiveSanction::Sources[:un_consolidated].new.sync
43
+ # snapshot # => Snapshot, or nil if nothing changed
44
+ #
45
+ # ### What #parse is handed
46
+ #
47
+ # A source declaring one URL gets the bytes. One declaring several gets a
48
+ # Hash keyed by the names it declared, because OFAC's three files only mean
49
+ # anything joined:
50
+ #
51
+ # def parse(raw)
52
+ # join(raw[:sdn], raw[:alt], raw[:add])
53
+ # end
54
+ #
55
+ # Which of the two it is follows from the declaration, not from what a
56
+ # caller happened to pass, so an adapter's signature does not change under
57
+ # it when a fixture is handed to #snapshot directly.
58
+ #
59
+ # The bytes arrive as a String. A list too large to hold in memory wants
60
+ # #15's streaming parse rather than this path; the cached Entry, which
61
+ # knows how to hand out a verified file handle, is where that will start.
62
+ #
63
+ # ### What sync does not do
64
+ #
65
+ # It does not store the snapshot, and it does not rescue anything. One
66
+ # source's failure being isolated from the others, and the previous good
67
+ # snapshot being kept when a list fails, are decisions about a *run* rather
68
+ # than about a list -- they belong to sync orchestration (#34), which needs
69
+ # an exception here to notice.
70
+ class Base
71
+ extend T::Sig
72
+ extend Definition
73
+
74
+ sig { returns(Fetcher) }
75
+ attr_reader :fetcher
76
+
77
+ # nil turns payload caching off -- see #initialize.
78
+ sig { returns(T.nilable(PayloadCache)) }
79
+ attr_reader :cache
80
+
81
+ # Anything Logger-shaped, or nil, as Configuration#logger has it.
82
+ sig { returns(T.untyped) }
83
+ attr_reader :logger
84
+
85
+ # `cache: nil` turns off payload caching, which costs one thing worth
86
+ # knowing: a multi-file source can no longer answer a sync where some of
87
+ # its files changed and others came back 304, so the unchanged ones are
88
+ # downloaded again in full.
89
+ sig do
90
+ params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped).void
91
+ end
92
+ def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger)
93
+ @fetcher = T.let(fetcher, Fetcher)
94
+ @cache = T.let(cache, T.nilable(PayloadCache))
95
+ @logger = T.let(logger, T.untyped)
96
+ @results = T.let({}, T::Hash[Symbol, Fetcher::Result])
97
+ end
98
+
99
+ sig { returns(Symbol) }
100
+ def key = self.class.key
101
+
102
+ sig { returns(Symbol) }
103
+ def jurisdiction = self.class.jurisdiction
104
+
105
+ sig { returns(String) }
106
+ def authority = self.class.authority
107
+
108
+ sig { returns(T.nilable(Symbol)) }
109
+ def format = self.class.format
110
+
111
+ sig { returns(T::Hash[Symbol, String]) }
112
+ def urls = self.class.urls
113
+
114
+ sig { params(name: T.untyped).returns(String) }
115
+ def url(name = nil) = name.nil? ? self.class.url : self.class.url(name)
116
+
117
+ sig { params(name: T.untyped).returns(Symbol) }
118
+ def file_key(name) = self.class.file_key(name)
119
+
120
+ # The lower bounds this list is held to when there is nothing to compare
121
+ # it against. See Definition#floor, and Doctor, which is the only thing
122
+ # that reads them.
123
+ sig { returns(T::Hash[Symbol, Numeric]) }
124
+ def floors = self.class.floors
125
+
126
+ # A remark with everything this adapter appended stripped back off --
127
+ # the publisher's own words and nothing else. Inherited, so it reads the
128
+ # same for every source and a caller does not have to know which list a
129
+ # remark came from before it can strip one. See Sources::Remarks.
130
+ sig { params(remarks: T.untyped).returns(T.nilable(String)) }
131
+ def self.published_remarks(remarks) = Remarks.published(remarks)
132
+
133
+ # The one method an adapter must write: bytes in, canonical records out.
134
+ sig { params(_raw: T.untyped).returns(T::Array[Entity]) }
135
+ def parse(_raw)
136
+ raise UnsupportedError,
137
+ "#{self.class} must implement #parse(raw) and return an Array of ActiveSanction::Entity"
138
+ end
139
+
140
+ # Fetches, parses, and checksums -- or returns nil when the publisher
141
+ # says nothing has changed, which is the outcome to expect on most runs
142
+ # and the reason conditional GET exists.
143
+ sig { params(force: T::Boolean).returns(T.nilable(Snapshot)) }
144
+ def sync(force: false)
145
+ payloads = retrieve(force: force)
146
+ return nil if payloads.nil?
147
+
148
+ snapshot(payloads)
149
+ end
150
+
151
+ # Parses payloads already in hand into a Snapshot. What #sync calls, and
152
+ # what an adapter's own spec calls with a fixture and no network:
153
+ #
154
+ # source.snapshot(main: File.read("spec/fixtures/un_consolidated.xml"))
155
+ #
156
+ # The files may be named as keywords, as above, or passed as one Hash --
157
+ # or, for a source that declares a single file, as the bytes themselves.
158
+ sig { params(payloads: T.untyped, files: T.untyped).returns(Snapshot) }
159
+ def snapshot(payloads = nil, **files)
160
+ Snapshot.new(source: key, entities: parse(parse_argument(payloads || files)),
161
+ fetched_at: Time.now.utc, source_version: source_version)
162
+ rescue ActiveSanction::Error => e
163
+ raise e.in_source(declared_key)
164
+ end
165
+
166
+ # Every declared file, conditionally: a Hash of name => bytes, or nil
167
+ # when the publisher answered 304 for all of them.
168
+ #
169
+ # A file that came back unchanged is served from the payload cache, so a
170
+ # sync in which one of OFAC's three files moved downloads one file and
171
+ # not three. If the cache has nothing to serve -- a first run against a
172
+ # store that already has validators, a cache directory a user deleted --
173
+ # that file alone is re-fetched in full.
174
+ sig { params(force: T::Boolean).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
175
+ def retrieve(force: false)
176
+ raise DeclarationError, "#{self.class} declares no URL to retrieve" if urls.empty?
177
+
178
+ @results = urls.to_h { |name, address| [name, fetch_file(name, address, force)] }
179
+ return nil if @results.each_value.all?(&:unchanged?)
180
+
181
+ @results.keys.to_h { |name| [name, payload(name)] }
182
+ rescue ActiveSanction::Error => e
183
+ raise e.in_source(declared_key)
184
+ end
185
+
186
+ # The positional-column assertions this source's raw files satisfy, or
187
+ # do not. Takes what #retrieve returned, or what #snapshot would be given,
188
+ # and answers with one Parsers::ColumnShape::Tally per declared column.
189
+ #
190
+ # Empty here, because most publishers ship a file that names its own
191
+ # fields and a named field cannot be quietly swapped with the one beside
192
+ # it. An adapter over a headerless file overrides #column_shapes -- see
193
+ # Sources::Ofac, and Parsers::ColumnShape for why a declared width is not
194
+ # enough on its own.
195
+ sig { params(payloads: T.untyped, files: T.untyped).returns(T::Array[Parsers::ColumnShape::Tally]) }
196
+ def column_tallies(payloads = nil, **files)
197
+ column_shapes(parse_argument(payloads || files))
198
+ end
199
+
200
+ # The hook #column_tallies dispatches to, handed exactly what #parse is
201
+ # handed. Overridden by an adapter over a positional file.
202
+ sig { params(_raw: T.untyped).returns(T::Array[Parsers::ColumnShape::Tally]) }
203
+ def column_shapes(_raw) = []
204
+
205
+ # Whether any of this source's files is due a fetch, answered locally and
206
+ # without a request. See Fetcher#stale? for what that does and does not
207
+ # claim.
208
+ sig { returns(T::Boolean) }
209
+ def stale? = urls.any? { |name, address| fetcher.stale?(file_key(name), url: address) }
210
+
211
+ sig { returns(T::Boolean) }
212
+ def fresh? = !stale?
213
+
214
+ # The publisher's own marker for the version just fetched. Last-Modified
215
+ # is the only one every launch source serves; an adapter whose document
216
+ # carries a generation date inside it should override this and say so,
217
+ # because that is the string an examiner will recognise.
218
+ sig { returns(T.nilable(String)) }
219
+ def source_version = @results.values.first&.last_modified
220
+
221
+ sig { returns(String) }
222
+ def inspect
223
+ name = self.class.declared?(:key) ? key : "(no key)"
224
+ "#<#{self.class} #{name} #{urls.size} url(s)>"
225
+ end
226
+
227
+ # What #parse is handed, worked out from what #retrieve returned: the
228
+ # bytes for a source declaring one file, the Hash keyed by declaration
229
+ # name for one declaring several. Public because a caller that has
230
+ # already fetched -- Doctor, which parses and then reads the same
231
+ # payloads a second time -- has to be able to produce the same argument
232
+ # without knowing how many files this source declares.
233
+ sig { params(payloads: T.untyped).returns(T.untyped) }
234
+ def parse_argument(payloads)
235
+ return payloads if payloads.is_a?(String)
236
+
237
+ self.class.multi_url? ? payloads.to_h : payloads.to_h.values.first
238
+ end
239
+
240
+ private
241
+
242
+ # This adapter's key, or nil for one that never declared it. What
243
+ # #source_id is stamped from -- see Error#in_source. The layers under
244
+ # here are deliberately ignorant of which list they are working on: the
245
+ # HTTP client sees a URL, the XML reader sees a payload, and neither can
246
+ # name the list in the error it raises. This is the one place that can,
247
+ # and it is the boundary a caller rescues at.
248
+ sig { returns(T.nilable(Symbol)) }
249
+ def declared_key = self.class.declared?(:key) ? key : nil
250
+
251
+ sig { params(name: Symbol, address: String, force: T::Boolean).returns(Fetcher::Result) }
252
+ def fetch_file(name, address, force)
253
+ fetcher.fetch(address, key: file_key(name), force: force).success!
254
+ end
255
+
256
+ sig { params(name: Symbol).returns(T.untyped) }
257
+ def payload(name)
258
+ result = @results.fetch(name)
259
+ return store(name, result) if result.changed?
260
+
261
+ cached(name) || store(name, refetch(name))
262
+ end
263
+
264
+ sig { params(name: Symbol, result: Fetcher::Result).returns(T.nilable(String)) }
265
+ def store(name, result)
266
+ cache&.write(file_key(name), result.body, url: url(name), final_url: result.uri.to_s,
267
+ etag: result.etag, last_modified: result.last_modified)
268
+ result.body
269
+ end
270
+
271
+ # A cached payload that no longer hashes to its sidecar is not used and
272
+ # not repaired -- but it is also not fatal here, because the bytes it
273
+ # failed to prove are a download away. The corrupt entry stays on disk
274
+ # for whoever investigates it.
275
+ sig { params(name: Symbol).returns(T.nilable(String)) }
276
+ def cached(name)
277
+ cache&.latest(file_key(name))&.read
278
+ rescue PayloadCache::CorruptEntry => e
279
+ logger&.info("[active_sanction] #{key} #{name} cached payload unusable (#{e.class}); fetching in full")
280
+ nil
281
+ end
282
+
283
+ sig { params(name: Symbol).returns(Fetcher::Result) }
284
+ def refetch(name)
285
+ logger&.info("[active_sanction] #{key} #{name} unchanged but not cached; fetching in full")
286
+ result = fetcher.fetch(url(name), key: file_key(name), force: true).success!
287
+ return result if result.changed?
288
+
289
+ raise MissingPayload,
290
+ "#{key} #{name} answered #{result.status} to an unconditional request, so the bytes " \
291
+ "for #{url(name)} could not be obtained"
292
+ end
293
+ end
294
+ end
295
+ end