Class: ActiveSanction::Snapshot::Bundle::Header

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/snapshot/bundle/header.rb

Overview

What a bundle says about itself, in one line, before any of its records are read.

header = ActiveSanction::Snapshot::Bundle.header(io)

header.source             # => :ofac_sdn
header.record_count       # => 19015
header.snapshot_checksum  # => "sha256:9f86d081884c7d65..."
header.generator          # => "active_sanction/1.0.0"
header.fetched_at         # => 2026-08-28 09:30:00 UTC

This is Storage::Meta's job for a file somebody sent you, and the two are deliberately the same shape: small, cheap, and answerable without inflating tens of megabytes. active_sanction import prints one, a mirror indexes them, and a deploy decides whether it already holds this list version by comparing snapshot_checksum against what it stored.

It is the thing that gets signed

A signature covers these bytes and nothing else -- see Bundle::Signature. It can, because payload_digest is a SHA-256 over every record in the file: signing ~300 bytes here transitively covers all 19,015 of them, and a verifier settles who published a bundle before it inflates a byte of what they sent.

So every field that could change what the payload means is in here, and a field whose value is not reproducible from the snapshot is not: there is no written-at timestamp, because two writes of one snapshot have to produce identical files.

Instances are frozen on construction and compare by value.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(format_version:, gem_version:, generator:, source:, schema_version:, snapshot_checksum:, record_count:, fetched_at:, payload_digest:, payload_bytes:, source_version: nil, payload_encoding: ENCODING, payload_compression: COMPRESSION) ⇒ void

Parameters:

  • format_version (T.untyped)
  • gem_version (T.untyped)
  • generator (T.untyped)
  • source (T.untyped)
  • schema_version (T.untyped)
  • snapshot_checksum (T.untyped)
  • record_count (T.untyped)
  • fetched_at (T.untyped)
  • payload_digest (T.untyped)
  • payload_bytes (T.untyped)
  • source_version (T.untyped) (defaults to: nil)
  • payload_encoding (T.untyped) (defaults to: ENCODING)
  • payload_compression (T.untyped) (defaults to: COMPRESSION)


192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 192

def initialize(format_version:, gem_version:, generator:, source:, schema_version:, snapshot_checksum:,
               record_count:, fetched_at:, payload_digest:, payload_bytes:, source_version: nil,
               payload_encoding: ENCODING, payload_compression: COMPRESSION)
  @format_version = T.let(version!(:format_version, format_version), Integer)
  @gem_version = T.let(string!(:gem_version, gem_version), String)
  @generator = T.let(string!(:generator, generator), String)
  @source = T.let(Sources::Definition.key!(source), Symbol)
  @schema_version = T.let(version!(:schema_version, schema_version), Integer)
  @snapshot_checksum = T.let(digest!(:snapshot_checksum, snapshot_checksum), String)
  @record_count = T.let(count!(:record_count, record_count), Integer)
  @fetched_at = T.let(time!(fetched_at), Time)
  @source_version = T.let(string_or_nil(source_version), T.nilable(String))
  @payload_encoding = T.let(string!(:payload_encoding, payload_encoding), String)
  @payload_compression = T.let(string!(:payload_compression, payload_compression), String)
  @payload_digest = T.let(digest!(:payload_digest, payload_digest), String)
  @payload_bytes = T.let(count!(:payload_bytes, payload_bytes), Integer)
  freeze
end

Instance Attribute Details

#fetched_at ⇒ Time (readonly)

UTC, truncated to the second: when the publisher's file was fetched, not when this bundle was written.

Returns:

  • (Time)


158
159
160
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 158

def fetched_at
  @fetched_at
end

#format_version ⇒ Integer (readonly)

Returns:

  • (Integer)


128
129
130
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 128

def format_version
  @format_version
end

#gem_version ⇒ String (readonly)

The active_sanction that serialized the records, which is what a schema_version this reader does not know is diagnosed against.

Returns:

  • (String)


133
134
135
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 133

def gem_version
  @gem_version
end

#generator ⇒ String (readonly)

Returns:

  • (String)


136
137
138
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 136

def generator
  @generator
end

#payload_bytes ⇒ Integer (readonly)

The length of the uncompressed payload. Checked as a reader inflates, so a bundle that decompresses to more than it declared is refused part way through rather than absorbed.

Returns:

  • (Integer)


184
185
186
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 184

def payload_bytes
  @payload_bytes
end

#payload_compression ⇒ String (readonly)

deflate in v1: a raw zlib stream, RFC 1950.

Returns:

  • (String)


171
172
173
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 171

def payload_compression
  @payload_compression
end

#payload_digest ⇒ String (readonly)

SHA-256 over the uncompressed payload. Over the uncompressed bytes because that is the half of the file two machines can be held to: zlib builds differ in what they emit for identical input, and the records do not.

Returns:

  • (String)


178
179
180
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 178

def payload_digest
  @payload_digest
end

#payload_encoding ⇒ String (readonly)

How the records are laid out once decompressed. ndjson in v1, and named so that a later version can add another without a reader having to guess which it is looking at.

Returns:

  • (String)


167
168
169
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 167

def payload_encoding
  @payload_encoding
end

#record_count ⇒ Integer (readonly)

Returns:

  • (Integer)


153
154
155
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 153

def record_count
  @record_count
end

#schema_version ⇒ Integer (readonly)

Snapshot::SCHEMA_VERSION the records were written under.

Returns:

  • (Integer)


143
144
145
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 143

def schema_version
  @schema_version
end

#snapshot_checksum ⇒ String (readonly)

The checksum of the list itself: content only, and identical to what the snapshot had before it was ever written to a file. This is what a stored MatchResult cites, so a bundle can be matched to a screening decision made years earlier.

Returns:

  • (String)


150
151
152
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 150

def snapshot_checksum
  @snapshot_checksum
end

#source ⇒ Symbol (readonly)

Returns:

  • (Symbol)


139
140
141
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 139

def source
  @source
end

#source_version ⇒ String? (readonly)

Returns:

  • (String, nil)


161
162
163
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 161

def source_version
  @source_version
end

Class Method Details

.default_generator ⇒ String

Who wrote the file, defaulted to this gem and this version. A publisher that is not this gem -- a commercial mirror, a bank's internal pipeline -- overrides it, which is the point of the field: gem_version says what code serialized the records, and this says whose bundle it is.

Returns:

  • (String)


81
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 81

def self.default_generator = -"active_sanction/#{VERSION}"

.from_h(hash) ⇒ T.attached_class

Rebuilds from #to_h output, accepting string keys so a header survives the round-trip through JSON.

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 113

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown bundle header field(s): #{unknown.join(", ")}" if unknown.any?

  missing = MEMBERS - attributes.keys - OPTIONAL_MEMBERS
  raise InvalidArgument, "bundle header is missing #{missing.join(", ")}" if missing.any?

  # `new(**hash)` past required keyword parameters is one of the few
  # things Sorbet cannot check statically. #initialize validates what
  # arrives, which is where a bad header is caught.
  T.unsafe(self).new(**attributes)
end

.from_snapshot(snapshot, payload_digest:, payload_bytes:, generator: nil) ⇒ T.attached_class

The header for a snapshot about to be written, given what the payload came to. Both payload values are measurements of bytes that already exist rather than promises about bytes to come -- see Bundle.write.

Parameters:

  • snapshot (Snapshot)
  • payload_digest (String)
  • payload_bytes (Integer)
  • generator (T.untyped) (defaults to: nil)

Returns:

  • (T.attached_class)


90
91
92
93
94
95
96
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 90

def self.from_snapshot(snapshot, payload_digest:, payload_bytes:, generator: nil)
  new(format_version: FORMAT_VERSION, gem_version: VERSION, generator: generator || default_generator,
      source: snapshot.source, schema_version: snapshot.schema_version,
      snapshot_checksum: snapshot.checksum, record_count: snapshot.record_count,
      fetched_at: snapshot.fetched_at, source_version: snapshot.source_version,
      payload_digest: payload_digest, payload_bytes: payload_bytes)
end

.parse(line) ⇒ T.attached_class

Reads one serialized header line. Everything that can be wrong with it raises -- an unknown key, a missing one, a digest that is not a digest -- because a header this code half understands is a header it cannot say the signature covers.

Parameters:

  • line (T.untyped)

Returns:

  • (T.attached_class)

Raises:



103
104
105
106
107
108
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 103

def self.parse(line)
  parsed = JSON.parse(line.to_s)
  raise InvalidArgument, "a bundle header is a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)

  from_h(parsed)
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


231
232
233
234
235
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 231

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#hash ⇒ Integer

Returns:

  • (Integer)


239
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 239

def hash = [self.class, to_h].hash

#inspect ⇒ String

Returns:

  • (String)


242
243
244
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 242

def inspect
  "#<#{self.class} #{source} #{record_count} entities #{snapshot_checksum} by #{generator}>"
end

#to_h ⇒ Hash{Symbol => T.untyped}

The documented shape, in the documented order.

Returns:

  • (Hash{Symbol => T.untyped})


213
214
215
216
217
218
219
220
221
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 213

def to_h
  {
    format_version: format_version, gem_version: gem_version, generator: generator,
    source: source.to_s, schema_version: schema_version, snapshot_checksum: snapshot_checksum,
    record_count: record_count, fetched_at: fetched_at.iso8601, source_version: source_version,
    payload_encoding: payload_encoding, payload_compression: payload_compression,
    payload_digest: payload_digest, payload_bytes: payload_bytes
  }
end

#to_line ⇒ String

The exact bytes that go into the file, and the exact bytes a signature is computed over. No trailing newline: the newline is the container's, not the header's, so a reader that strips line endings differently still verifies.

Returns:

  • (String)


228
# File 'lib/active_sanction/snapshot/bundle/header.rb', line 228

def to_line = JSON.generate(to_h)