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,157 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "rexml/parsers/pullparser"
7
+ require "rexml/text"
8
+ require "active_sanction/parsers/xml_records/backends"
9
+ require "active_sanction/parsers/xml_records/builder"
10
+
11
+ module ActiveSanction
12
+ module Parsers
13
+ class XmlRecords
14
+ module Backends
15
+ # The default backend: stdlib REXML, pulled one event at a time.
16
+ #
17
+ # Chosen as the default because it is everywhere Ruby is, needs no
18
+ # build step, and gives every installation the same answer -- see
19
+ # Backends for why that last property is the one that matters for a
20
+ # list whose parsed content gets checksummed into an audit trail.
21
+ #
22
+ # It is the slower of the two. On the UN's 2.2 MB consolidated list
23
+ # that is a fraction of a second on a job that already spent longer
24
+ # downloading the file; a host parsing OFAC's 126 MB advanced XML on a
25
+ # schedule is the case that should switch to libxml2, and can.
26
+ class Rexml
27
+ extend T::Sig
28
+
29
+ # Nothing to check: `rexml` is a declared dependency of this gem.
30
+ sig { returns(T::Boolean) }
31
+ def self.available? = true
32
+
33
+ sig { returns(T.nilable(String)) }
34
+ def self.unavailable_reason = nil
35
+
36
+ # The document element's attributes, which is where these publishers
37
+ # put the version of the list.
38
+ sig { returns(T.nilable(T::Hash[String, String])) }
39
+ attr_reader :root
40
+
41
+ sig { params(table: XmlRecords, xml: String).void }
42
+ def initialize(table:, xml:)
43
+ @table = T.let(table, XmlRecords)
44
+ @xml = T.let(xml, String)
45
+ @root = T.let(nil, T.nilable(T::Hash[String, String]))
46
+ @depth = T.let(0, Integer)
47
+ @builder = T.let(Builder.new(table: table), Builder)
48
+ @newlines = T.let(nil, T.nilable(T::Array[Integer]))
49
+ end
50
+
51
+ sig { params(block: T.proc.params(record: Record).void).void }
52
+ def each_record(&block)
53
+ parser = ::REXML::Parsers::PullParser.new(@xml)
54
+ pull(parser, &block)
55
+ truncated! if @builder.open? || @depth.positive?
56
+ rescue ::REXML::ParseException => e
57
+ raise malformed(e, parser)
58
+ end
59
+
60
+ private
61
+
62
+ sig { params(parser: T.untyped, block: T.proc.params(record: Record).void).void }
63
+ def pull(parser, &block)
64
+ handle(parser.pull, parser, &block) while parser.has_next?
65
+ end
66
+
67
+ sig { params(event: T.untyped, parser: T.untyped, block: T.proc.params(record: Record).void).void }
68
+ def handle(event, parser, &block)
69
+ case event.event_type
70
+ when :start_element then start(event[0], event[1], parser)
71
+ when :end_element then close(&block)
72
+ when :text then text(::REXML::Text.unnormalize(event[0]))
73
+ when :cdata then text(event[0])
74
+ end
75
+ end
76
+
77
+ sig { params(string: String).void }
78
+ def text(string)
79
+ @builder.text(string) if @builder.open?
80
+ end
81
+
82
+ # The first element in the document is its root, whose attributes are
83
+ # where these publishers put the version of the list -- the UN's
84
+ # `dateGenerated`. Captured whether or not anything else parses.
85
+ sig { params(name: T.untyped, attributes: T.untyped, parser: T.untyped).void }
86
+ def start(name, attributes, parser)
87
+ local = Backends.local_name(name)
88
+ attrs = Backends.local_attributes(attributes)
89
+ @root ||= attrs
90
+ @depth += 1
91
+ return unless @builder.open? || @table.record?(local)
92
+
93
+ @builder.enter(local, attrs, line_at(parser))
94
+ end
95
+
96
+ sig { params(block: T.proc.params(record: Record).void).void }
97
+ def close(&block)
98
+ @depth -= 1
99
+ return unless @builder.open?
100
+
101
+ record = @builder.leave
102
+ block.call(record) if record
103
+ end
104
+
105
+ # REXML stops at the end of the payload without complaint when a tag
106
+ # was never closed, so a download cut in half looks like a short list
107
+ # rather than like a problem. Elements still open at the end are the
108
+ # evidence, and it is worth raising on: a truncated list that loads
109
+ # quietly is how a screening run silently stops covering people.
110
+ #
111
+ # The depth counter is what catches the commoner half of it. A
112
+ # download truncated between two records leaves the builder closed
113
+ # and only the document element unfinished, so counting starts and
114
+ # ends is the only thing that can tell that list from a complete one.
115
+ sig { void }
116
+ def truncated!
117
+ raise MalformedDocument.new("the document ended inside an unclosed element", line: nil)
118
+ end
119
+
120
+ sig { params(error: T.untyped, parser: T.untyped).returns(MalformedDocument) }
121
+ def malformed(error, parser)
122
+ MalformedDocument.new(error.message.lines.first.to_s.strip, line: line_at(parser))
123
+ end
124
+
125
+ # REXML reports a byte offset rather than a line, so the offsets of
126
+ # every newline are indexed once per pass and binary-searched. The
127
+ # index is built over the bytes, not the characters, because that is
128
+ # what the offset counts and the two part company on the first
129
+ # accented name -- of which these lists have thousands.
130
+ sig { params(parser: T.untyped).returns(T.nilable(Integer)) }
131
+ def line_at(parser)
132
+ offset = parser&.source&.position
133
+ return nil unless offset.is_a?(Integer)
134
+
135
+ (newlines.bsearch_index { |at| at >= offset } || newlines.size) + 1
136
+ end
137
+
138
+ sig { returns(T::Array[Integer]) }
139
+ def newlines
140
+ @newlines ||= begin
141
+ bytes = @xml.b
142
+ offsets = T.let([], T::Array[Integer])
143
+ at = T.let(bytes.index("\n"), T.nilable(Integer))
144
+ while at
145
+ offsets << at
146
+ at = bytes.index("\n", at + 1)
147
+ end
148
+ offsets
149
+ end
150
+ end
151
+ end
152
+
153
+ register(:rexml, Rexml)
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,103 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Parsers
8
+ class XmlRecords
9
+ # The XML libraries this toolkit knows how to drive, and the seam a host
10
+ # application swaps one for another through.
11
+ #
12
+ # ActiveSanction.configure { |c| c.xml_backend = :nokogiri }
13
+ #
14
+ # ### The contract
15
+ #
16
+ # A backend is a class that answers `.available?` and, per pass, is built
17
+ # with `table:` and the decoded `xml:` and answers two things:
18
+ #
19
+ # backend.each_record { |record| ... } # yields XmlRecords::Record
20
+ # backend.root # the document element's attributes
21
+ #
22
+ # It raises XmlRecords::MalformedDocument, with a line where its library
23
+ # supplies one, when the payload stops being XML. Everything else --
24
+ # paths, null resolution, warnings, Enumerable -- lives above it and is
25
+ # written once, so a backend is only ever event translation. Registering
26
+ # another is a public act:
27
+ #
28
+ # ActiveSanction::Parsers::XmlRecords::Backends.register(:ox, MyOxBackend)
29
+ #
30
+ # ### Why the default is not chosen by what happens to be loaded
31
+ #
32
+ # It would be easy to prefer Nokogiri whenever a host has it, and #15
33
+ # first asked for exactly that. It is the wrong default here. Snapshot
34
+ # checksums a list's parsed content, and a screening decision is supposed
35
+ # to be re-derivable months later in front of an examiner. If the parser
36
+ # is picked by whether Rails happened to load Nokogiri, then two installs
37
+ # of the same gem screening the same file can checksum apart, and the
38
+ # thing that changed is invisible in every artifact either one keeps.
39
+ #
40
+ # So the default is REXML on every installation -- stdlib, no build step,
41
+ # and the same answer everywhere -- and a host that wants libxml2's speed
42
+ # says so out loud, in one line, where a reviewer can see it.
43
+ #
44
+ # @api private
45
+ module Backends
46
+ DEFAULT = T.let(:rexml, Symbol)
47
+
48
+ class << self
49
+ extend T::Sig
50
+
51
+ # A backend is a class answering the four methods the comment above
52
+ # names, which is why these signatures say `T.untyped` where one
53
+ # goes: registering another is a public act, and an out-of-repo
54
+ # backend is not a subclass of anything here.
55
+
56
+ sig { params(name: T.untyped, backend: T.untyped).returns(T.untyped) }
57
+ def register(name, backend)
58
+ registry[name.to_sym] = backend
59
+ end
60
+
61
+ sig { params(name: T.untyped).returns(T.untyped) }
62
+ def resolve(name)
63
+ backend = registry.fetch(name.to_sym) do
64
+ raise InvalidArgument,
65
+ "unknown XML backend #{name.inspect}. Registered: #{registry.keys.join(", ")}"
66
+ end
67
+ return backend if backend.available?
68
+
69
+ raise InvalidArgument, "the #{name.inspect} XML backend cannot run here: #{backend.unavailable_reason}"
70
+ end
71
+
72
+ # Every registered backend that could actually run in this process.
73
+ sig { returns(T::Array[Symbol]) }
74
+ def available = registry.select { |_, backend| backend.available? }.keys
75
+
76
+ sig { returns(T::Hash[Symbol, T.untyped]) }
77
+ def registry
78
+ @registry ||= T.let({}, T.nilable(T::Hash[Symbol, T.untyped]))
79
+ end
80
+
81
+ # An element name with any namespace prefix removed, so a list that
82
+ # grows an `xmlns` next quarter does not stop parsing. The prefix is
83
+ # discarded rather than resolved: none of these publishers uses two
84
+ # namespaces in one document, and an adapter written against
85
+ # `INDIVIDUAL` should not have to be rewritten as `un:INDIVIDUAL`.
86
+ sig { params(name: T.untyped).returns(String) }
87
+ def local_name(name)
88
+ string = name.to_s
89
+ index = string.rindex(":")
90
+ index.nil? ? string : string[(index + 1)..]
91
+ end
92
+
93
+ # Attribute keys are stripped the same way, so `xsi:type` is read as
94
+ # `type` and a default-namespaced document reads like a plain one.
95
+ sig { params(attributes: T.untyped).returns(T::Hash[String, String]) }
96
+ def local_attributes(attributes)
97
+ (attributes || {}).to_h { |key, value| [local_name(key), value.to_s] }
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,90 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/parsers/xml_records/record"
7
+
8
+ module ActiveSanction
9
+ module Parsers
10
+ class XmlRecords
11
+ # Turns a stream of parser events into one Record at a time.
12
+ #
13
+ # This is the part of XML parsing that is the same whichever library is
14
+ # doing it: enter an element, collect its text, leave it into its parent,
15
+ # and hand back a finished Record when the record element itself closes.
16
+ # A backend translates its library's events into these four calls and
17
+ # writes no tree-building code of its own, which is what keeps a second
18
+ # backend small enough to be worth having.
19
+ #
20
+ # It is also what makes the toolkit streaming rather than DOM-based: the
21
+ # stack only ever holds one record's depth, so a 126 MB file costs the
22
+ # memory of its largest single record and not of itself.
23
+ #
24
+ # @api private
25
+ class Builder
26
+ extend T::Sig
27
+
28
+ Frame = Struct.new(:name, :attributes, :text, :children, :line)
29
+ private_constant :Frame
30
+
31
+ sig { params(table: XmlRecords).void }
32
+ def initialize(table:)
33
+ @table = T.let(table, XmlRecords)
34
+ @stack = T.let([], T::Array[T.untyped])
35
+ end
36
+
37
+ # Whether we are inside a record. A backend asks this to decide whether
38
+ # an element is part of a record or is still the file's scaffolding.
39
+ sig { returns(T::Boolean) }
40
+ def open? = !@stack.empty?
41
+
42
+ sig { params(name: T.untyped, attributes: T.untyped, line: T.nilable(Integer)).void }
43
+ def enter(name, attributes = {}, line = nil)
44
+ @stack << Frame.new(name, attributes, nil, [], line)
45
+ end
46
+
47
+ # Appended rather than replaced: a parser is free to split one run of
48
+ # text across several events, and libxml2 does exactly that around
49
+ # entity references.
50
+ sig { params(string: String).void }
51
+ def text(string)
52
+ frame = @stack.last
53
+ return if frame.nil?
54
+
55
+ frame.text = frame.text ? frame.text + string : +string
56
+ end
57
+
58
+ # Closes the innermost element. Returns the finished Record when that
59
+ # was the record element itself, and nil while still inside one.
60
+ sig { returns(T.nilable(Record)) }
61
+ def leave
62
+ frame = @stack.pop
63
+ return nil if frame.nil?
64
+
65
+ record = build(frame)
66
+ return record if @stack.empty?
67
+
68
+ @stack.last.children << record
69
+ nil
70
+ end
71
+
72
+ # A void element -- `<QUALITY/>` -- which libxml2 reports as a start
73
+ # with no matching end.
74
+ sig { params(name: T.untyped, attributes: T.untyped, line: T.nilable(Integer)).returns(T.nilable(Record)) }
75
+ def void(name, attributes = {}, line = nil)
76
+ enter(name, attributes, line)
77
+ leave
78
+ end
79
+
80
+ private
81
+
82
+ sig { params(frame: T.untyped).returns(Record) }
83
+ def build(frame)
84
+ Record.new(table: @table, name: frame.name, attributes: frame.attributes,
85
+ text: frame.text, children: frame.children, line: frame.line)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,134 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Parsers
8
+ class XmlRecords
9
+ # One pass over one payload. Enumerable, and lazy: records are yielded as
10
+ # they are parsed rather than collected, so the memory cost of a pass is
11
+ # one record plus whatever the caller keeps -- which is the whole point
12
+ # of the toolkit, and the reason OFAC's 126 MB advanced XML can drop in
13
+ # later without a redesign.
14
+ #
15
+ # reader = table.read(bytes)
16
+ # reader.each { |record| record["DATAID"] }
17
+ # reader.root # => {"dateGenerated" => "2026-08-28T00:00:00"}
18
+ # reader.warnings # => what could not be read
19
+ #
20
+ # Re-enumerating re-parses from the start, which also resets #warnings --
21
+ # so `reader.count` followed by `reader.warnings` reports the warnings
22
+ # from the counting pass, not from two passes appended together.
23
+ #
24
+ # @api private
25
+ class Reader
26
+ extend T::Sig
27
+ extend T::Generic
28
+ include Enumerable
29
+
30
+ Elem = type_member { { fixed: Record } }
31
+
32
+ sig { returns(XmlRecords) }
33
+ attr_reader :table
34
+
35
+ # The records this pass could not read. Reset by each pass -- see the
36
+ # class comment.
37
+ sig { returns(T::Array[Warning]) }
38
+ attr_reader :warnings
39
+
40
+ sig { params(table: XmlRecords, payload: T.untyped).void }
41
+ def initialize(table:, payload:)
42
+ @table = T.let(table, XmlRecords)
43
+ @payload = T.let(payload, T.untyped)
44
+ @warnings = T.let([], T::Array[Warning])
45
+ @backend = T.let(nil, T.untyped)
46
+ @count = T.let(0, Integer)
47
+ end
48
+
49
+ sig { override.params(block: T.nilable(T.proc.params(record: Record).void)).returns(T.untyped) }
50
+ def each(&block)
51
+ return enum_for(:each) unless block
52
+
53
+ @warnings = []
54
+ @backend = table.backend.new(table: table, xml: decoded)
55
+ read(&block)
56
+ self
57
+ end
58
+
59
+ # The document element's attributes. The UN puts the list's generation
60
+ # date there and nowhere else, and it is the version string an examiner
61
+ # recognises, so it has to be reachable without the adapter reaching
62
+ # around the toolkit for it.
63
+ #
64
+ # Parsing far enough to answer costs only the bytes up to the first
65
+ # record, so asking before a pass is cheap; asking after one is free.
66
+ sig { returns(T::Hash[String, T.untyped]) }
67
+ def root
68
+ each.first if @backend.nil?
69
+ @backend&.root || {}
70
+ end
71
+
72
+ # Every record, in memory. The convenience the small lists get to use;
73
+ # anything list-sized should stay with #each.
74
+ sig { returns(T::Array[Record]) }
75
+ def to_a = each.to_a
76
+
77
+ sig { returns(String) }
78
+ def inspect = "#<#{self.class} #{table.record_names.join(", ")} via #{table.backend}>"
79
+
80
+ private
81
+
82
+ # A document that stops being XML part-way is not treated the way a
83
+ # malformed CSV row is, because it cannot be: XML has no row boundary
84
+ # to resynchronise on, so everything after the break is unreadable no
85
+ # matter how it is handled. What can be saved is everything before it,
86
+ # and that is what happens -- the records already yielded stand, and
87
+ # the break is recorded as a warning naming the line.
88
+ #
89
+ # Failing before the first record is the different case, and raises:
90
+ # nothing was salvaged, and the overwhelmingly likely cause is that the
91
+ # payload was never XML. An HTML error page saved under a .xml URL is
92
+ # the classic, and reporting that as "0 records, one warning" would let
93
+ # a sync succeed at screening against nothing.
94
+ sig { params(block: T.proc.params(record: Record).void).void }
95
+ def read(&block)
96
+ @count = 0
97
+ @backend.each_record do |record|
98
+ @count += 1
99
+ block.call(record)
100
+ end
101
+ rescue MalformedDocument => e
102
+ give_up!(e) if @count.zero?
103
+ record(e.line, "the document ended after #{@count} record(s): #{e.message}")
104
+ end
105
+
106
+ sig { returns(String) }
107
+ def decoded
108
+ string, replaced = table.decode(@payload)
109
+ record(nil, table.invalid_bytes_message) if replaced
110
+ raise ParseError, "expected an XML document, got an empty payload" if string.strip.empty?
111
+
112
+ string
113
+ end
114
+
115
+ sig { params(line: T.nilable(Integer), message: String).void }
116
+ def record(line, message)
117
+ @warnings << Warning.new(line: line, message: message)
118
+ end
119
+
120
+ # The line the backend stopped on is carried through, because that is
121
+ # the whole difference between "this 25 MB file is not XML" and a
122
+ # complaint somebody can open an editor to.
123
+ sig { params(error: MalformedDocument).void }
124
+ def give_up!(error)
125
+ raise ParseError.new(
126
+ "no <#{table.record_names.join("> or <")}> element could be read before the document stopped " \
127
+ "being XML. This payload is almost certainly not the XML it was read as -- check the URL, " \
128
+ "and whether the publisher served an error page. #{error.message}", line: error.line
129
+ )
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,174 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Parsers
8
+ class XmlRecords
9
+ # One record element and everything under it: the UN's `<INDIVIDUAL>`,
10
+ # Canada's `<record>`. Fields are read by path relative to the record,
11
+ # and a nested element is itself a Record, which is what lets an adapter
12
+ # walk repeated children without knowing how the file was parsed.
13
+ #
14
+ # record.name # => "INDIVIDUAL"
15
+ # record["DATAID"] # => "6907993"
16
+ # record["INDIVIDUAL_DATE_OF_BIRTH/YEAR"]
17
+ # record.values("NATIONALITY/VALUE") # => ["Chad", "Sudan"]
18
+ # record.nodes("INDIVIDUAL_ALIAS") # => [Record, Record]
19
+ # record["@dateGenerated"] # an attribute, XPath-style
20
+ #
21
+ # ### Absent, empty, and blank are one answer
22
+ #
23
+ # An element that is missing, self-closing, or holds only whitespace all
24
+ # read as nil. That is not laziness about the difference; it is the only
25
+ # reading that survives the UN, which files placeholder aliases as
26
+ # `<INDIVIDUAL_ALIAS><QUALITY/><ALIAS_NAME/></INDIVIDUAL_ALIAS>` and means
27
+ # nothing at all by them. An adapter that had to distinguish the three
28
+ # would produce blank-valued Names for every one of those placeholders.
29
+ #
30
+ # ### Why #[] does not raise the way a CSV Row does
31
+ #
32
+ # DelimitedTable::Row raises on a column its table never declared, since
33
+ # a table's shape is fixed and an unknown name there is a typo. XML has
34
+ # no such shape: one `<INDIVIDUAL>` carries elements the next one omits,
35
+ # so an absent path is ordinary and #[] answers nil. #fetch is there for
36
+ # the field an adapter considers mandatory, and it names the record and
37
+ # what the record does carry when the field is missing.
38
+ class Record
39
+ extend T::Sig
40
+
41
+ # @api private
42
+ SEPARATOR = T.let("/", String)
43
+ # @api private
44
+ ATTRIBUTE = T.let("@", String)
45
+
46
+ # @api private
47
+ UNSET = T.let(Object.new.freeze, Object)
48
+ private_constant :UNSET
49
+
50
+ # The element's own name, with any namespace prefix already removed by
51
+ # the backend -- see Backends.local_name.
52
+ sig { returns(String) }
53
+ attr_reader :name
54
+
55
+ sig { returns(T::Hash[String, T.untyped]) }
56
+ attr_reader :attributes
57
+
58
+ sig { returns(T::Array[Record]) }
59
+ attr_reader :children
60
+
61
+ # nil where the backend reports no position.
62
+ sig { returns(T.nilable(Integer)) }
63
+ attr_reader :line
64
+
65
+ sig do
66
+ params(table: XmlRecords, name: T.untyped, attributes: T::Hash[String, T.untyped], text: T.untyped,
67
+ children: T::Array[Record], line: T.nilable(Integer)).void
68
+ end
69
+ def initialize(table:, name:, attributes: {}, text: nil, children: [], line: nil)
70
+ @table = T.let(table, XmlRecords)
71
+ @name = T.let(-name.to_s, String)
72
+ @attributes = T.let(attributes.freeze, T::Hash[String, T.untyped])
73
+ @raw_text = T.let(text, T.untyped)
74
+ @children = T.let(children.freeze, T::Array[Record])
75
+ @line = T.let(line, T.nilable(Integer))
76
+ freeze
77
+ end
78
+
79
+ # This element's own text, with blanks and any declared null sentinel
80
+ # resolved to nil. Text belonging to child elements is not included.
81
+ sig { returns(T.nilable(String)) }
82
+ def text = table.value(@raw_text)
83
+
84
+ # The first value at `path`, or nil if nothing is there.
85
+ sig { params(path: T.untyped).returns(T.nilable(String)) }
86
+ def [](path) = values(path).first
87
+
88
+ # Every value at `path`, in document order, with blanks dropped. The
89
+ # answer to a repeated element: the UN files each nationality as its
90
+ # own `<NATIONALITY><VALUE>`.
91
+ sig { params(path: T.untyped).returns(T::Array[String]) }
92
+ def values(path)
93
+ steps, attribute = split(path)
94
+ nodes = descend(steps)
95
+ return nodes.filter_map { |node| node.attribute(attribute) } if attribute
96
+
97
+ nodes.filter_map(&:text)
98
+ end
99
+
100
+ # The elements at `path`, as Records, whether or not they hold text --
101
+ # an adapter reading `<INDIVIDUAL_ADDRESS>` wants the node, not a value.
102
+ sig { params(path: T.untyped).returns(T::Array[Record]) }
103
+ def nodes(path)
104
+ steps, attribute = split(path)
105
+ raise InvalidArgument, "#nodes reads elements, not the attribute #{path.inspect}" if attribute
106
+
107
+ descend(steps)
108
+ end
109
+
110
+ sig { params(key: T.untyped).returns(T.nilable(String)) }
111
+ def attribute(key) = table.value(attributes[key.to_s])
112
+
113
+ # For a field the adapter treats as mandatory. Raises rather than
114
+ # letting a renamed element arrive downstream as a nil nobody notices.
115
+ sig { params(path: T.untyped, default: T.untyped).returns(T.untyped) }
116
+ def fetch(path, default = UNSET)
117
+ value = self[path]
118
+ return value unless value.nil?
119
+ return default unless default.equal?(UNSET)
120
+
121
+ raise MissingKey, "no value at #{path.inspect} in <#{name}>#{" on line #{line}" if line}. " \
122
+ "It carries: #{present.join(", ")}"
123
+ end
124
+
125
+ sig { params(path: T.untyped).returns(T::Boolean) }
126
+ def null?(path) = self[path].nil?
127
+
128
+ # The child element names that actually carry something, which is what
129
+ # a #fetch failure has to print and what makes an unfamiliar list
130
+ # explorable from a console.
131
+ sig { returns(T::Array[String]) }
132
+ def present
133
+ names = children.select { |child| !child.text.nil? || child.children.any? }.map(&:name)
134
+ names.uniq
135
+ end
136
+
137
+ sig { returns(String) }
138
+ def to_s = text.to_s
139
+
140
+ sig { returns(String) }
141
+ def inspect = "#<#{self.class} <#{name}>#{" line=#{line}" if line} #{present.join(" ")}>"
142
+
143
+ protected
144
+
145
+ sig { returns(XmlRecords) }
146
+ attr_reader :table
147
+
148
+ sig { params(wanted: String).returns(T::Array[Record]) }
149
+ def children_named(wanted) = children.select { |child| child.name == wanted }
150
+
151
+ private
152
+
153
+ # Splits "INDIVIDUAL_ALIAS/ALIAS_NAME" into its steps, and peels off a
154
+ # trailing "@attr" as the attribute to read instead of the text.
155
+ sig { params(path: T.untyped).returns([T::Array[String], T.nilable(String)]) }
156
+ def split(path)
157
+ steps = path.to_s.split(SEPARATOR).map(&:strip).reject(&:empty?)
158
+ return [steps, nil] unless steps.last&.start_with?(ATTRIBUTE)
159
+
160
+ [steps[0..-2], steps.last.delete_prefix(ATTRIBUTE)]
161
+ end
162
+
163
+ # An empty path is the record itself, which is what makes `record["@id"]`
164
+ # and `record[""]` mean the obvious things.
165
+ sig { params(steps: T::Array[String]).returns(T::Array[Record]) }
166
+ def descend(steps)
167
+ steps.inject([self]) do |nodes, wanted|
168
+ nodes.flat_map { |node| node.children_named(wanted) }
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end