active_sanction 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
# The bundle format
|
|
2
|
+
|
|
3
|
+
A bundle is one sanctions list, in one file, that a machine which has never
|
|
4
|
+
spoken to the publisher can load and trust. It carries the records, everything
|
|
5
|
+
needed to prove they are intact, and — optionally — a signature saying who
|
|
6
|
+
published them.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
ofac_sdn.asb
|
|
10
|
+
├── ACTIVESANCTION-BUNDLE/1 the magic, and the format version
|
|
11
|
+
├── {"format_version":1,...} the header: one canonical JSON line
|
|
12
|
+
├── ecdsa-sha256 MEUCIQ... the signature, or "-"
|
|
13
|
+
└── <deflated NDJSON> the records, one JSON object per line
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This document specifies it completely enough to be produced and read by
|
|
17
|
+
something that is not this gem and is not Ruby. In Ruby it is
|
|
18
|
+
`ActiveSanction::Snapshot::Bundle`:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
File.open("ofac_sdn.asb", "wb") { |io| ActiveSanction::Snapshot::Bundle.write(snapshot, io: io) }
|
|
22
|
+
File.open("ofac_sdn.asb", "rb") { |io| ActiveSanction::Snapshot::Bundle.read(io) }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Contents
|
|
26
|
+
|
|
27
|
+
1. [Why the format exists](#1-why-the-format-exists)
|
|
28
|
+
2. [Conventions](#2-conventions)
|
|
29
|
+
3. [The container](#3-the-container)
|
|
30
|
+
4. [The magic line](#4-the-magic-line)
|
|
31
|
+
5. [The header line](#5-the-header-line)
|
|
32
|
+
6. [The signature line](#6-the-signature-line)
|
|
33
|
+
7. [The payload](#7-the-payload)
|
|
34
|
+
8. [Producing a bundle](#8-producing-a-bundle)
|
|
35
|
+
9. [Reading a bundle](#9-reading-a-bundle)
|
|
36
|
+
10. [Determinism, and what is comparable](#10-determinism-and-what-is-comparable)
|
|
37
|
+
11. [Versioning](#11-versioning)
|
|
38
|
+
12. [A worked example](#12-a-worked-example)
|
|
39
|
+
|
|
40
|
+
## 1. Why the format exists
|
|
41
|
+
|
|
42
|
+
`Storage::FileSystem` also writes compressed JSON, and its layout is private and
|
|
43
|
+
expected to change. A bundle is the opposite thing: a published artifact with a
|
|
44
|
+
stability contract, which three situations need and a private directory layout
|
|
45
|
+
cannot serve.
|
|
46
|
+
|
|
47
|
+
- **Publishers go down.** Government endpoints break, change format, and
|
|
48
|
+
rate-limit. A bundle produced once and distributed is the difference between
|
|
49
|
+
a bad afternoon at Treasury and a failed deploy for everyone downstream.
|
|
50
|
+
- **Air-gapped and privacy-sensitive installations.** A compliance team that
|
|
51
|
+
will not send subject names to a third-party API will happily consume fresh
|
|
52
|
+
data. A file serves them; a request/response API cannot.
|
|
53
|
+
- **Audit.** A checksum proves a list is internally intact. A signature proves
|
|
54
|
+
it is *the one that was published*, which is the claim an examiner is asking
|
|
55
|
+
about.
|
|
56
|
+
|
|
57
|
+
Anyone may produce a bundle, from any source, with no key and no licence. That
|
|
58
|
+
is the point of specifying it here rather than in a product.
|
|
59
|
+
|
|
60
|
+
## 2. Conventions
|
|
61
|
+
|
|
62
|
+
- **Bytes.** A bundle is a byte stream. It is not text, and it must not be
|
|
63
|
+
transferred in a mode that rewrites line endings.
|
|
64
|
+
- **Line terminator.** `LF` (`0x0A`) alone. `CRLF` is not accepted anywhere.
|
|
65
|
+
- **Text.** The three header lines are UTF-8. So is the decompressed payload.
|
|
66
|
+
- **JSON.** [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259). Serialized
|
|
67
|
+
compactly: no whitespace between tokens, no trailing newline inside a line,
|
|
68
|
+
and no reordering — key order is part of the format wherever this document
|
|
69
|
+
gives one.
|
|
70
|
+
- **Digests.** SHA-256, written `sha256:` followed by 64 lowercase hex digits.
|
|
71
|
+
The same form this library quotes every digest in.
|
|
72
|
+
- **Extension.** `.asb`, by convention. Nothing depends on it.
|
|
73
|
+
- **Media type.** `application/vnd.active-sanction.bundle`, by convention.
|
|
74
|
+
|
|
75
|
+
## 3. The container
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
bundle = magic LF header LF signature LF payload
|
|
79
|
+
magic = "ACTIVESANCTION-BUNDLE/" 1*DIGIT
|
|
80
|
+
header = <one JSON object, serialized compactly>
|
|
81
|
+
signature = "-" / algorithm SP base64
|
|
82
|
+
payload = <a zlib stream, RFC 1950>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The payload runs to end of file and is not length-prefixed in the container:
|
|
86
|
+
its uncompressed length and digest are in the header, and bytes after the end of
|
|
87
|
+
the compressed stream are an error (§9).
|
|
88
|
+
|
|
89
|
+
The first three lines are text on purpose. `head -c 512 ofac_sdn.asb` tells an
|
|
90
|
+
operator what list a file holds, how many records, from when, and who says so —
|
|
91
|
+
with no tooling and nothing decompressed.
|
|
92
|
+
|
|
93
|
+
Each of the three lines is at most **65536 bytes** including its terminator. A
|
|
94
|
+
reader must refuse a longer one rather than buffering it.
|
|
95
|
+
|
|
96
|
+
## 4. The magic line
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
ACTIVESANCTION-BUNDLE/1
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The literal `ACTIVESANCTION-BUNDLE`, a `/`, and the format version as decimal
|
|
103
|
+
digits. This is the first thing a reader checks, and a version it does not
|
|
104
|
+
implement is refused here — before any JSON is parsed, and before a byte is
|
|
105
|
+
decompressed.
|
|
106
|
+
|
|
107
|
+
The version also appears in the header, and the two must agree (§9). The magic
|
|
108
|
+
is what a reader acts on early; the copy in the header is the one a signature
|
|
109
|
+
covers, so the version cannot be reinterpreted without breaking the signature.
|
|
110
|
+
|
|
111
|
+
## 5. The header line
|
|
112
|
+
|
|
113
|
+
One JSON object, serialized compactly, with its keys **in this order**:
|
|
114
|
+
|
|
115
|
+
| # | Key | Type | Notes |
|
|
116
|
+
|---|-----|------|-------|
|
|
117
|
+
| 1 | `format_version` | integer | Equals the version in the magic line. |
|
|
118
|
+
| 2 | `gem_version` | string | The `active_sanction` that serialized the records. Diagnostic; a non-Ruby producer writes the version of whatever wrote it. |
|
|
119
|
+
| 3 | `generator` | string | Who published this bundle, e.g. `"active_sanction/1.0.0"`, `"acme-mirror/2.0"`. |
|
|
120
|
+
| 4 | `source` | string | The list's key: lowercase, `[a-z][a-z0-9_]*`, e.g. `"ofac_sdn"`. |
|
|
121
|
+
| 5 | `schema_version` | integer | `Snapshot::SCHEMA_VERSION` the records are written under. `2` at the time of writing. |
|
|
122
|
+
| 6 | `snapshot_checksum` | digest | Over the records' content. §7.3. |
|
|
123
|
+
| 7 | `record_count` | integer | Number of lines in the uncompressed payload. |
|
|
124
|
+
| 8 | `fetched_at` | string | When the publisher's file was fetched: RFC 3339, UTC, whole seconds, `Z` — `"2026-08-28T09:30:00Z"`. |
|
|
125
|
+
| 9 | `source_version` | string \| null | The publisher's own version string, where it gives one. |
|
|
126
|
+
| 10 | `payload_encoding` | string | `"ndjson"` in v1. |
|
|
127
|
+
| 11 | `payload_compression` | string | `"deflate"` in v1. |
|
|
128
|
+
| 12 | `payload_digest` | digest | SHA-256 over the **uncompressed** payload. |
|
|
129
|
+
| 13 | `payload_bytes` | integer | Length of the **uncompressed** payload. |
|
|
130
|
+
|
|
131
|
+
Every key is present. `source_version` is written as `null` rather than omitted
|
|
132
|
+
when there is none — the key order is the format, and a reader that had to cope
|
|
133
|
+
with holes in it would be coping with a different file for every publisher.
|
|
134
|
+
|
|
135
|
+
A reader must refuse a header carrying a key it does not know, rather than
|
|
136
|
+
ignoring it. A field a producer thought was meaningful and a reader silently
|
|
137
|
+
dropped is the shape of every quiet integrity failure this format exists to
|
|
138
|
+
avoid.
|
|
139
|
+
|
|
140
|
+
There is deliberately **no written-at timestamp**, and no field describing the
|
|
141
|
+
compressed bytes. Both would make two writes of one snapshot different files
|
|
142
|
+
(§10).
|
|
143
|
+
|
|
144
|
+
## 6. The signature line
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
- an unsigned bundle
|
|
148
|
+
ecdsa-sha256 MEUCIQD0kBCt... a signed one
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
An algorithm name, a single space, and the signature in
|
|
152
|
+
[RFC 4648 §4](https://www.rfc-editor.org/rfc/rfc4648#section-4) base64 with
|
|
153
|
+
padding and no line breaks. `-` when the bundle is unsigned.
|
|
154
|
+
|
|
155
|
+
**The signed bytes are the header line's, without its terminator.** Nothing
|
|
156
|
+
else. Because the header carries `payload_digest`, signing a few hundred bytes stands for
|
|
157
|
+
every record in the file, and three things follow:
|
|
158
|
+
|
|
159
|
+
- A verifier settles who published a bundle **before inflating any of it**.
|
|
160
|
+
Compressed data from a party you have not authenticated is the last thing
|
|
161
|
+
worth expanding.
|
|
162
|
+
- Verification costs the same for a 500-record list and a 6,000-record one.
|
|
163
|
+
- Re-compressing a bundle at another level does not invalidate its signature.
|
|
164
|
+
The claim is about the list, not about the packing.
|
|
165
|
+
|
|
166
|
+
| Algorithm | Key | Signature |
|
|
167
|
+
|-----------|-----|-----------|
|
|
168
|
+
| `rsa-sha256` | RSA | RSASSA-PKCS1-v1_5 over SHA-256 |
|
|
169
|
+
| `ecdsa-sha256` | EC | ECDSA over SHA-256, DER-encoded |
|
|
170
|
+
| `ed25519` | — | **Reserved.** Not produced or verified by v1 readers. |
|
|
171
|
+
|
|
172
|
+
`ed25519` is named so that a v1 reader refuses it with "this bundle is signed
|
|
173
|
+
with something I am too old for" rather than "unknown algorithm", and so that
|
|
174
|
+
adding it later is not a format change.
|
|
175
|
+
|
|
176
|
+
Verification is opt-in. A reader given no key does not look at this line, and an
|
|
177
|
+
unsigned bundle is a fully valid bundle — its records are still proven against
|
|
178
|
+
`payload_digest` and `snapshot_checksum`. What it is not is *attested*, and a
|
|
179
|
+
consumer is entitled to know which of the two it has. In this library that is
|
|
180
|
+
`Snapshot#trusted?`, and it reaches every screening result as
|
|
181
|
+
`MatchResult#verified?`.
|
|
182
|
+
|
|
183
|
+
## 7. The payload
|
|
184
|
+
|
|
185
|
+
### 7.1 Layout
|
|
186
|
+
|
|
187
|
+
Everything after the signature line's terminator is a zlib stream
|
|
188
|
+
([RFC 1950](https://www.rfc-editor.org/rfc/rfc1950): the 2-byte header, deflate
|
|
189
|
+
data, and the Adler-32 trailer). Not gzip — a gzip header carries a modification
|
|
190
|
+
time and an OS byte, neither of which is a fact about a sanctions list.
|
|
191
|
+
|
|
192
|
+
Compression level **6**. Named here rather than left to a library's default so
|
|
193
|
+
that two producers agree; the level does not affect correctness, and a reader
|
|
194
|
+
must not care what a file was packed at.
|
|
195
|
+
|
|
196
|
+
Decompressed, the payload is newline-delimited JSON: one entity per line, each
|
|
197
|
+
line terminated by `LF`, **including the last**. `payload_bytes` counts those
|
|
198
|
+
bytes, terminators included.
|
|
199
|
+
|
|
200
|
+
### 7.2 Record order
|
|
201
|
+
|
|
202
|
+
Records are sorted ascending by the hex SHA-256 of the line's own bytes, without
|
|
203
|
+
its terminator — the same per-entity fingerprint the snapshot checksum is built
|
|
204
|
+
from (§7.3).
|
|
205
|
+
|
|
206
|
+
Sorting rather than preserving the publisher's order is what makes the format
|
|
207
|
+
deterministic: a government reshuffling its file is not a new list, and two
|
|
208
|
+
producers who parsed the same list produce the same bytes. Duplicate records
|
|
209
|
+
survive it — an entity listed twice yields two identical lines.
|
|
210
|
+
|
|
211
|
+
### 7.3 The record shape, and the snapshot checksum
|
|
212
|
+
|
|
213
|
+
Each line is one `ActiveSanction::Entity` serialized as its documented hash, key
|
|
214
|
+
order as the model declares it: `id`, `source`, `source_ref`, `type`, `names`,
|
|
215
|
+
`addresses`, `identifiers`, `dates_of_birth`, `nationalities`, `programs`,
|
|
216
|
+
`listed_on`, `remarks`. Absent scalars are `null`; absent collections are `[]`.
|
|
217
|
+
|
|
218
|
+
`snapshot_checksum` is computed over the records and nothing else — not over
|
|
219
|
+
`fetched_at`, not over `source_version`, not over anything in the container. Its
|
|
220
|
+
definition is the snapshot's, reproduced here so that a non-Ruby implementation
|
|
221
|
+
can compute it:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
fingerprints = sorted([ hex_sha256(line) for line in records ]) # ascending
|
|
225
|
+
material = "{schema_version}\n{source}\n" + "".join(f + "\n" for f in fingerprints)
|
|
226
|
+
checksum = "sha256:" + hex_sha256(material)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Two consequences are the reason it is defined that way. A refetch of an
|
|
230
|
+
unchanged list reproduces it exactly, so "has this list changed since we last
|
|
231
|
+
screened?" is answerable; and changing any field of any record changes exactly
|
|
232
|
+
one fingerprint.
|
|
233
|
+
|
|
234
|
+
Because the fingerprints are the sort key of §7.2, a bundle's record order and
|
|
235
|
+
its checksum cannot drift apart.
|
|
236
|
+
|
|
237
|
+
## 8. Producing a bundle
|
|
238
|
+
|
|
239
|
+
1. Serialize each entity to compact JSON. Sort the lines by the hex SHA-256 of
|
|
240
|
+
each line (§7.2).
|
|
241
|
+
2. Concatenate them, each followed by `LF`. This is the uncompressed payload.
|
|
242
|
+
Take its SHA-256 and its length — these are `payload_digest` and
|
|
243
|
+
`payload_bytes`.
|
|
244
|
+
3. Compute `snapshot_checksum` (§7.3).
|
|
245
|
+
4. Build the header (§5) and serialize it compactly, keys in order.
|
|
246
|
+
5. Sign the header line's bytes if a key was supplied (§6).
|
|
247
|
+
6. Write: magic, `LF`, header, `LF`, signature, `LF`, then the payload
|
|
248
|
+
compressed as a zlib stream at level 6.
|
|
249
|
+
|
|
250
|
+
A producer that streams the payload straight to the file cannot: the header
|
|
251
|
+
states a digest and a length of bytes that do not exist yet, so it must be
|
|
252
|
+
buffered or written in two passes. This library buffers it — the snapshot is
|
|
253
|
+
already in memory by then, and the compressed copy of even the largest published
|
|
254
|
+
list is about 25 MB.
|
|
255
|
+
|
|
256
|
+
## 9. Reading a bundle
|
|
257
|
+
|
|
258
|
+
In this order. Every step is a refusal, never a repair.
|
|
259
|
+
|
|
260
|
+
1. **Magic.** Read the first line. If it is not `ACTIVESANCTION-BUNDLE/<digits>`,
|
|
261
|
+
this is not a bundle. If the version is above what the reader implements,
|
|
262
|
+
stop and say so — do not parse further.
|
|
263
|
+
2. **Header.** Read the second line and parse it. An unknown key, a missing key,
|
|
264
|
+
a malformed digest or an unusable source key are all refusals.
|
|
265
|
+
3. **Agreement.** `format_version` must equal the version on the magic line.
|
|
266
|
+
4. **Support.** `schema_version`, `payload_encoding` and `payload_compression`
|
|
267
|
+
must all be ones the reader implements. Anything else is a version problem,
|
|
268
|
+
not a corruption problem, and the two must be reported differently: one is
|
|
269
|
+
fixed by upgrading, the other by fetching the file again.
|
|
270
|
+
5. **Signature.** Read the third line. If the caller supplied a key, verify it
|
|
271
|
+
over the header line's bytes now, before decompressing anything. A bundle
|
|
272
|
+
with `-` and a caller asking for verification is a failure, distinct from a
|
|
273
|
+
bad signature.
|
|
274
|
+
6. **Payload.** Inflate incrementally. For each complete line: accumulate it
|
|
275
|
+
into the digest, count its bytes, and parse it into a record. **Stop the
|
|
276
|
+
moment the inflated total exceeds `payload_bytes`** — a file that lies about
|
|
277
|
+
its own size is either damaged or built to exhaust whoever opens it.
|
|
278
|
+
7. **Completeness.** The compressed stream must terminate cleanly, the last
|
|
279
|
+
record must be terminated, and there must be no bytes after the end of the
|
|
280
|
+
stream.
|
|
281
|
+
8. **Digest.** The accumulated digest and length must equal `payload_digest` and
|
|
282
|
+
`payload_bytes`.
|
|
283
|
+
9. **Content.** Re-derive `snapshot_checksum` from the records that actually
|
|
284
|
+
arrived (§7.3) and compare it to the header's. Count them and compare to
|
|
285
|
+
`record_count`.
|
|
286
|
+
|
|
287
|
+
Steps 8 and 9 are both required, and they catch different things: 8 says the
|
|
288
|
+
bytes are the bytes that were packed, and 9 says those bytes are the list the
|
|
289
|
+
header describes.
|
|
290
|
+
|
|
291
|
+
Nothing partial is ever returned. A reader that hands back the eight thousand
|
|
292
|
+
records it managed to parse out of nineteen thousand produces a report that
|
|
293
|
+
looks exactly like a clean one, which is the most expensive thing a screening
|
|
294
|
+
library can get wrong.
|
|
295
|
+
|
|
296
|
+
### Failure modes
|
|
297
|
+
|
|
298
|
+
| Condition | `ActiveSanction::Snapshot::Bundle` raises |
|
|
299
|
+
|-----------|--------------------------------------------|
|
|
300
|
+
| Not a bundle, truncated, digest or count mismatch, trailing bytes, oversized payload, magic disagrees with header | `Corrupt` |
|
|
301
|
+
| Signature does not verify under the key given | `UntrustedSignature` |
|
|
302
|
+
| No signature, and verification was asked for | `Unsigned` (a subclass of the above) |
|
|
303
|
+
| Format version, snapshot schema, payload encoding or signature algorithm this reader does not implement | `UnsupportedFormat` |
|
|
304
|
+
|
|
305
|
+
`Corrupt` and `UntrustedSignature` are deliberately different: "these bytes were
|
|
306
|
+
damaged" and "these bytes came from somebody else" are different incidents, and
|
|
307
|
+
only one of them is fixed by downloading the file again.
|
|
308
|
+
|
|
309
|
+
## 10. Determinism, and what is comparable
|
|
310
|
+
|
|
311
|
+
**The same snapshot produces the same bundle.** Record order is derived from
|
|
312
|
+
content, header keys have a fixed order, the compression level is stated rather
|
|
313
|
+
than defaulted, and nothing anywhere in the file says when it was written.
|
|
314
|
+
|
|
315
|
+
Two things qualify that, and both are deliberate:
|
|
316
|
+
|
|
317
|
+
- **The header line is the invariant that survives everything.** zlib
|
|
318
|
+
implementations disagree about what to emit for identical input, so a file
|
|
319
|
+
packed by zlib-ng and one packed by zlib may differ byte for byte while
|
|
320
|
+
holding exactly the same list. `payload_digest` is over the *uncompressed*
|
|
321
|
+
payload for this reason: it is the half of the file two machines can be held
|
|
322
|
+
to. Two bundles with identical header lines carry identical records.
|
|
323
|
+
- **A signature line may differ between two signings.** ECDSA is randomized.
|
|
324
|
+
Determinism is a property of the magic line, the header and the payload.
|
|
325
|
+
|
|
326
|
+
`gem_version` and `generator` are inputs, not noise: two mirrors that bundled the
|
|
327
|
+
same list produce different files, and should, because they are different
|
|
328
|
+
publications of it. The list they carry is the same, and `snapshot_checksum`
|
|
329
|
+
says so.
|
|
330
|
+
|
|
331
|
+
## 11. Versioning
|
|
332
|
+
|
|
333
|
+
`format_version` is the format's own number and moves independently of the gem's.
|
|
334
|
+
|
|
335
|
+
- A reader **must** refuse a `format_version` above what it implements, at
|
|
336
|
+
step 1, with an error that says to upgrade. It must never read a newer file
|
|
337
|
+
partially: a newer shape will usually deserialize into plausible, wrong
|
|
338
|
+
records, and the symptom is names that quietly stop matching.
|
|
339
|
+
- A reader **must** accept every version at or below its own.
|
|
340
|
+
- Within a format version, no key changes meaning, no key is removed, and no key
|
|
341
|
+
is reordered. Keys are not added either: a reader that refuses unknown keys
|
|
342
|
+
(§5) would reject them, which is what makes the refusal safe to require.
|
|
343
|
+
- `schema_version` is the record model's number and moves on its own. A bundle
|
|
344
|
+
can carry records too new for a reader while its container is a version that
|
|
345
|
+
reader understands, which is why the two are checked separately.
|
|
346
|
+
|
|
347
|
+
## 12. A worked example
|
|
348
|
+
|
|
349
|
+
One entity, unsigned. Reproduce it with:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
entity = ActiveSanction::Entity.new(
|
|
353
|
+
source: :ofac_sdn, source_ref: "2674", type: :individual, programs: ["SDGT"],
|
|
354
|
+
names: [ActiveSanction::Name.new(value: "AL ZAWAHIRI, Aiman", kind: :primary)]
|
|
355
|
+
)
|
|
356
|
+
snapshot = ActiveSanction::Snapshot.new(
|
|
357
|
+
source: :ofac_sdn, entities: [entity],
|
|
358
|
+
fetched_at: Time.utc(2026, 8, 28, 9, 30, 0), source_version: "2026-08-28"
|
|
359
|
+
)
|
|
360
|
+
File.open("example.asb", "wb") { |io| ActiveSanction::Snapshot::Bundle.write(snapshot, io: io) }
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The magic line:
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
ACTIVESANCTION-BUNDLE/1
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The header line, wrapped here and written as one line in the file:
|
|
370
|
+
|
|
371
|
+
```json
|
|
372
|
+
{"format_version":1,"gem_version":"1.0.0","generator":"active_sanction/1.0.0",
|
|
373
|
+
"source":"ofac_sdn","schema_version":2,
|
|
374
|
+
"snapshot_checksum":"sha256:ed0eebb275106f4e7075683af4ca7ae17c7aa100cd617f8bc41eebe9d9cf797d",
|
|
375
|
+
"record_count":1,"fetched_at":"2026-08-28T09:30:00Z","source_version":"2026-08-28",
|
|
376
|
+
"payload_encoding":"ndjson","payload_compression":"deflate",
|
|
377
|
+
"payload_digest":"sha256:f07b780b3849a40ad3d2ca0a679469809ffa8e69006728123a33e1bb156c32b9",
|
|
378
|
+
"payload_bytes":293}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
The signature line:
|
|
382
|
+
|
|
383
|
+
```
|
|
384
|
+
-
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
The payload, decompressed — 293 bytes, one line and its terminator:
|
|
388
|
+
|
|
389
|
+
```json
|
|
390
|
+
{"id":"ofac_sdn:2674","source":"ofac_sdn","source_ref":"2674","type":"individual","names":[{"value":"AL ZAWAHIRI, Aiman","kind":"primary","quality":null,"script":null}],"addresses":[],"identifiers":[],"dates_of_birth":[],"nationalities":[],"programs":["SDGT"],"listed_on":null,"remarks":null}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
The whole file is 695 bytes. Checking the two digests by hand is the shortest
|
|
394
|
+
useful conformance test for an implementation:
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
ruby -rzlib -rdigest -e 'b = File.binread(ARGV[0])
|
|
398
|
+
puts Digest::SHA256.hexdigest(Zlib::Inflate.inflate(b.split("\n", 4)[3]))' example.asb
|
|
399
|
+
# => f07b780b3849a40ad3d2ca0a679469809ffa8e69006728123a33e1bb156c32b9
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Everything before the payload is text, so the rest of the file needs no tooling
|
|
403
|
+
at all:
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
head -3 example.asb
|
|
407
|
+
```
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
# A place a sanctions list attached to an entity. OFAC ships 25,078 of them in
|
|
8
|
+
# ADD.CSV; the UN ships far fewer and far thinner ones.
|
|
9
|
+
#
|
|
10
|
+
# ActiveSanction::Address.new(
|
|
11
|
+
# street: "Ave. Luis Maria Drago 1136",
|
|
12
|
+
# city: "Buenos Aires",
|
|
13
|
+
# state_province: nil,
|
|
14
|
+
# postal_code: "C1414",
|
|
15
|
+
# country: "Argentina",
|
|
16
|
+
# note: "as of early 2016"
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# Every field is optional because the publishers populate wildly different
|
|
20
|
+
# subsets: the UN routinely supplies only COUNTRY plus a free-text NOTE, and
|
|
21
|
+
# an address type that insisted on a street would drop those rows entirely.
|
|
22
|
+
# What it will not accept is an address that says nothing at all -- a record
|
|
23
|
+
# with every field blank is a parsing accident, not a location.
|
|
24
|
+
#
|
|
25
|
+
# A pure data holder: it stores what the publisher said and nothing more. No
|
|
26
|
+
# geocoding, no country-code lookup, no case folding. Instances are frozen on
|
|
27
|
+
# construction and compare by value.
|
|
28
|
+
class Address
|
|
29
|
+
extend T::Sig
|
|
30
|
+
|
|
31
|
+
# Canonical member order, from the most specific part of an address to the
|
|
32
|
+
# least. Snapshot (#8) checksums the serialized form, so #to_h must lay its
|
|
33
|
+
# keys out the same way every time.
|
|
34
|
+
#
|
|
35
|
+
# @api private
|
|
36
|
+
MEMBERS = T.let(%i[street city state_province postal_code country note].freeze, T::Array[Symbol])
|
|
37
|
+
|
|
38
|
+
# `note` is an annotation about the address rather than a part of it, so it
|
|
39
|
+
# is rendered apart from the rest by #to_s.
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
PARTS = T.let((MEMBERS - %i[note]).freeze, T::Array[Symbol])
|
|
43
|
+
|
|
44
|
+
# Every field is nilable because the publishers populate wildly different
|
|
45
|
+
# subsets of them; what #initialize refuses is all six being empty at once.
|
|
46
|
+
sig { returns(T.nilable(String)) }
|
|
47
|
+
attr_reader :street
|
|
48
|
+
|
|
49
|
+
sig { returns(T.nilable(String)) }
|
|
50
|
+
attr_reader :city
|
|
51
|
+
|
|
52
|
+
sig { returns(T.nilable(String)) }
|
|
53
|
+
attr_reader :state_province
|
|
54
|
+
|
|
55
|
+
sig { returns(T.nilable(String)) }
|
|
56
|
+
attr_reader :postal_code
|
|
57
|
+
|
|
58
|
+
sig { returns(T.nilable(String)) }
|
|
59
|
+
attr_reader :country
|
|
60
|
+
|
|
61
|
+
sig { returns(T.nilable(String)) }
|
|
62
|
+
attr_reader :note
|
|
63
|
+
|
|
64
|
+
# Rebuilds an address from #to_h output. Accepts string keys, so a record
|
|
65
|
+
# that has been through JSON round-trips without a separate coercion step.
|
|
66
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
67
|
+
def self.from_h(hash)
|
|
68
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
69
|
+
unknown = attributes.keys - MEMBERS
|
|
70
|
+
raise InvalidArgument, "unknown Address attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
71
|
+
|
|
72
|
+
new(**attributes)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Untyped on purpose, and the same choice Entity makes: all six are the
|
|
76
|
+
# publisher's own text arriving as whatever the parser made of it, and
|
|
77
|
+
# #string_or_nil says below what happens to it.
|
|
78
|
+
sig do
|
|
79
|
+
params(street: T.untyped, city: T.untyped, state_province: T.untyped, postal_code: T.untyped,
|
|
80
|
+
country: T.untyped, note: T.untyped).void
|
|
81
|
+
end
|
|
82
|
+
def initialize(street: nil, city: nil, state_province: nil, postal_code: nil, country: nil, note: nil)
|
|
83
|
+
@street = T.let(string_or_nil(street), T.nilable(String))
|
|
84
|
+
@city = T.let(string_or_nil(city), T.nilable(String))
|
|
85
|
+
@state_province = T.let(string_or_nil(state_province), T.nilable(String))
|
|
86
|
+
@postal_code = T.let(string_or_nil(postal_code), T.nilable(String))
|
|
87
|
+
@country = T.let(string_or_nil(country), T.nilable(String))
|
|
88
|
+
@note = T.let(string_or_nil(note), T.nilable(String))
|
|
89
|
+
reject_empty!
|
|
90
|
+
freeze
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The address parts the publisher actually filled in, in canonical order.
|
|
94
|
+
sig { returns(T::Array[String]) }
|
|
95
|
+
def parts
|
|
96
|
+
PARTS.filter_map { |member| public_send(member) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# True when nothing but a note survived parsing -- the UN's "as of early
|
|
100
|
+
# 2016" with no place attached. Such an address is worth keeping (it is
|
|
101
|
+
# evidence the publisher had something) but is not worth matching on.
|
|
102
|
+
sig { returns(T::Boolean) }
|
|
103
|
+
def note_only? = parts.empty?
|
|
104
|
+
|
|
105
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
106
|
+
def to_h
|
|
107
|
+
{
|
|
108
|
+
street: street,
|
|
109
|
+
city: city,
|
|
110
|
+
state_province: state_province,
|
|
111
|
+
postal_code: postal_code,
|
|
112
|
+
country: country,
|
|
113
|
+
note: note
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A single line, which is how an address is displayed in a hit list and how
|
|
118
|
+
# the normalizer (#26) will want it before folding.
|
|
119
|
+
sig { returns(String) }
|
|
120
|
+
def to_s
|
|
121
|
+
line = parts.join(", ")
|
|
122
|
+
note = self.note
|
|
123
|
+
return line if note.nil?
|
|
124
|
+
|
|
125
|
+
line.empty? ? note : "#{line} (#{note})"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Class is part of the comparison to keep #== and #hash agreeing, which is
|
|
129
|
+
# what Hash and Set rely on.
|
|
130
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
131
|
+
def ==(other)
|
|
132
|
+
return false unless other.instance_of?(self.class)
|
|
133
|
+
|
|
134
|
+
to_h == other.to_h
|
|
135
|
+
end
|
|
136
|
+
alias eql? ==
|
|
137
|
+
|
|
138
|
+
sig { returns(Integer) }
|
|
139
|
+
def hash
|
|
140
|
+
[self.class, to_h].hash
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
sig { returns(String) }
|
|
144
|
+
def inspect
|
|
145
|
+
"#<#{self.class} #{to_s.inspect}>"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Surrounding whitespace is stripped -- the delimited sources pad their
|
|
151
|
+
# fields -- but nothing else is touched. Case, diacritics and punctuation
|
|
152
|
+
# are all signal the matcher needs to see as published. A field that was
|
|
153
|
+
# only whitespace is nil: an empty string is not a smaller address, it is
|
|
154
|
+
# an absent field, and storing one would split two identical addresses.
|
|
155
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
156
|
+
def string_or_nil(value)
|
|
157
|
+
return nil if value.nil?
|
|
158
|
+
|
|
159
|
+
string = value.to_s.strip
|
|
160
|
+
string.empty? ? nil : -string
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
sig { void }
|
|
164
|
+
def reject_empty!
|
|
165
|
+
return if MEMBERS.any? { |member| public_send(member) }
|
|
166
|
+
|
|
167
|
+
raise InvalidArgument, "an address needs at least one populated field"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|