herringbone 0.5.0 → 0.6.1

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.
@@ -45,6 +45,14 @@ module Herringbone
45
45
  double: T_DOUBLE, binary: T_BINARY, string: T_BINARY
46
46
  }.freeze
47
47
 
48
+ # Values each declared integer type can hold, whatever width it came in on the wire
49
+ INT_RANGES = {
50
+ i16: -2**15...2**15, i32: -2**31...2**31, i64: -2**63...2**63
51
+ }.freeze
52
+
53
+ # Deepest nesting of structs, lists, sets and maps read or skipped, as in Apache Thrift
54
+ MAX_DEPTH = 64
55
+
48
56
  # Wire type written for a declared field type
49
57
  #
50
58
  # @param type [Symbol, Array, Class] a scalar type from WIRE_TYPES, a +[:list, elem]+ Array,
@@ -89,6 +97,7 @@ module Herringbone
89
97
  def initialize(buf, pos = 0)
90
98
  @buf = buf
91
99
  @pos = pos
100
+ @depth = 0
92
101
  end
93
102
 
94
103
  # Reads one raw byte
@@ -104,17 +113,21 @@ module Herringbone
104
113
 
105
114
  # Reads an unsigned LEB128 varint
106
115
  #
107
- # @return [Integer] the decoded non-negative value
108
- # @raise [Error] when the varint runs past the buffer or is longer than 64 bits allow
116
+ # @return [Integer] the decoded non-negative value, below 2**64
117
+ # @raise [Error] when the varint runs past the buffer, is longer than 10 bytes or does not
118
+ # fit in 64 bits
109
119
  def read_varint
110
120
  result = 0
111
121
  shift = 0
112
122
  while true
113
123
  b = read_byte
114
124
  result |= (b & 0x7F) << shift
115
- return result if b < 0x80
125
+ if b < 0x80
126
+ raise Error, "Varint exceeds 64 bits" if result >= 2**64
127
+ return result
128
+ end
116
129
  shift += 7
117
- raise Error, "Varint too long" if shift > 70
130
+ raise Error, "Varint too long" if shift > 63
118
131
  end
119
132
  end
120
133
 
@@ -141,8 +154,10 @@ module Herringbone
141
154
 
142
155
  # Reads an 8-byte little-endian double
143
156
  #
144
- # @return [Float, nil] the value (nil when fewer than 8 bytes are left: this is not checked)
157
+ # @return [Float] the value
158
+ # @raise [Error] when fewer than 8 bytes are left
145
159
  def read_double
160
+ raise Error, "Unexpected end of Thrift data at #{@pos}" if @pos + 8 > @buf.bytesize
146
161
  v = @buf.byteslice(@pos, 8).unpack1("E")
147
162
  @pos += 8
148
163
  v
@@ -155,26 +170,28 @@ module Herringbone
155
170
  #
156
171
  # @param klass [Class] a Thrift::Struct subclass
157
172
  # @return [Struct] an instance of +klass+ with the fields that were present set
158
- # @raise [Error] on truncated or malformed data
173
+ # @raise [Error] on truncated or malformed data, or nesting deeper than MAX_DEPTH
159
174
  def read_struct(klass)
160
- obj = klass.new
161
- fields = klass.fields_by_id
162
- last_id = 0
163
- while true
164
- header = read_byte
165
- wire = header & 0x0F
166
- break if wire == T_STOP
167
- delta = header >> 4
168
- fid = delta.zero? ? read_zigzag : last_id + delta
169
- last_id = fid
170
- field = fields[fid]
171
- if field && Thrift.compatible?(wire, field.type)
172
- obj.instance_variable_set(field.ivar, read_value(wire, field.type))
173
- else
174
- skip(wire)
175
+ nested do
176
+ obj = klass.new
177
+ fields = klass.fields_by_id
178
+ last_id = 0
179
+ while true
180
+ header = read_byte
181
+ wire = header & 0x0F
182
+ break if wire == T_STOP
183
+ delta = header >> 4
184
+ fid = delta.zero? ? read_zigzag : last_id + delta
185
+ last_id = fid
186
+ field = fields[fid]
187
+ if field && Thrift.compatible?(wire, field.type)
188
+ obj.instance_variable_set(field.ivar, read_value(wire, field.type))
189
+ else
190
+ skip(wire)
191
+ end
175
192
  end
193
+ obj
176
194
  end
177
- obj
178
195
  end
179
196
 
180
197
  # Reads one value of wire type +wire+ as declared +type+
@@ -184,7 +201,8 @@ module Herringbone
184
201
  # an Array or Struct subclass gives the element type or struct to read
185
202
  # @return [Object] true/false, an Integer (bytes are signed), a Float, a String, an Array
186
203
  # (or nil, see #read_list) or a Struct
187
- # @raise [Error] on an unsupported wire type or truncated data
204
+ # @raise [Error] on an unsupported wire type, truncated data or an integer out of the range
205
+ # of +type+
188
206
  def read_value(wire, type)
189
207
  case wire
190
208
  when T_TRUE then true
@@ -192,7 +210,10 @@ module Herringbone
192
210
  when T_BYTE
193
211
  b = read_byte
194
212
  (b >= 0x80) ? b - 0x100 : b
195
- when T_I16, T_I32, T_I64 then read_zigzag
213
+ when T_I16, T_I32, T_I64
214
+ n = read_zigzag
215
+ raise Error, "Integer #{n} out of range for #{type}" unless INT_RANGES.fetch(type).cover?(n)
216
+ n
196
217
  when T_DOUBLE then read_double
197
218
  when T_BINARY
198
219
  s = read_binary
@@ -210,24 +231,26 @@ module Herringbone
210
231
  # @param type [Array] declared list type, +[:list, elem_type]+
211
232
  # @return [Array, nil] the elements, or nil (with the list skipped) when the element wire
212
233
  # type does not match +elem_type+
213
- # @raise [Error] on truncated or malformed data
234
+ # @raise [Error] on truncated or malformed data, or nesting deeper than MAX_DEPTH
214
235
  def read_list(type)
215
- header = read_byte
216
- size = header >> 4
217
- size = read_varint if size == 15
218
- elem_wire = header & 0x0F
219
- elem_type = type[1]
220
- bool_elems = elem_wire == T_TRUE || elem_wire == T_FALSE
221
- if size.positive? && !(bool_elems && elem_type == :bool) && !Thrift.compatible?(elem_wire, elem_type)
222
- size.times { bool_elems ? read_byte : skip(elem_wire) }
223
- return nil
224
- end
225
- Array.new(size) do
226
- if elem_wire == T_TRUE || elem_wire == T_FALSE
227
- # Booleans inside lists are encoded as full bytes
228
- read_byte == T_TRUE
229
- else
230
- read_value(elem_wire, elem_type)
236
+ nested do
237
+ header = read_byte
238
+ size = header >> 4
239
+ size = read_size if size == 15
240
+ elem_wire = header & 0x0F
241
+ elem_type = type[1]
242
+ bool_elems = elem_wire == T_TRUE || elem_wire == T_FALSE
243
+ if size.positive? && !(bool_elems && elem_type == :bool) && !Thrift.compatible?(elem_wire, elem_type)
244
+ size.times { bool_elems ? read_byte : skip(elem_wire) }
245
+ return nil
246
+ end
247
+ Array.new(size) do
248
+ if elem_wire == T_TRUE || elem_wire == T_FALSE
249
+ # Booleans inside lists are encoded as full bytes
250
+ read_byte == T_TRUE
251
+ else
252
+ read_value(elem_wire, elem_type)
253
+ end
231
254
  end
232
255
  end
233
256
  end
@@ -236,40 +259,76 @@ module Herringbone
236
259
  #
237
260
  # @param wire [Integer] wire type of the value to skip
238
261
  # @return [void]
239
- # @raise [Error] on an unknown wire type (including T_STOP) or truncated data
262
+ # @raise [Error] on an unknown wire type (including T_STOP), truncated data or nesting deeper
263
+ # than MAX_DEPTH
240
264
  def skip(wire)
241
265
  case wire
242
266
  when T_TRUE, T_FALSE then nil
243
267
  when T_BYTE then read_byte
244
268
  when T_I16, T_I32, T_I64 then read_varint
245
- when T_DOUBLE then @pos += 8
269
+ when T_DOUBLE then read_double
246
270
  when T_BINARY then read_binary
247
271
  when T_LIST, T_SET
248
- header = read_byte
249
- size = header >> 4
250
- size = read_varint if size == 15
251
- elem = header & 0x0F
252
- size.times { (elem == T_TRUE || elem == T_FALSE) ? read_byte : skip(elem) }
272
+ nested do
273
+ header = read_byte
274
+ size = header >> 4
275
+ size = read_size if size == 15
276
+ elem = header & 0x0F
277
+ size.times { (elem == T_TRUE || elem == T_FALSE) ? read_byte : skip(elem) }
278
+ end
253
279
  when T_MAP
254
- size = read_varint
255
- unless size.zero?
256
- kv = read_byte
257
- size.times do
258
- [kv >> 4, kv & 0x0F].each { |w| (w == T_TRUE || w == T_FALSE) ? read_byte : skip(w) }
280
+ nested do
281
+ size = read_size
282
+ unless size.zero?
283
+ kv = read_byte
284
+ size.times do
285
+ [kv >> 4, kv & 0x0F].each { |w| (w == T_TRUE || w == T_FALSE) ? read_byte : skip(w) }
286
+ end
259
287
  end
260
288
  end
261
289
  when T_STRUCT
262
- while true
263
- header = read_byte
264
- w = header & 0x0F
265
- break if w == T_STOP
266
- read_zigzag if (header >> 4).zero?
267
- skip(w)
290
+ nested do
291
+ while true
292
+ header = read_byte
293
+ w = header & 0x0F
294
+ break if w == T_STOP
295
+ read_zigzag if (header >> 4).zero?
296
+ skip(w)
297
+ end
268
298
  end
269
299
  else
270
300
  raise Error, "Cannot skip wire type #{wire}"
271
301
  end
272
302
  end
303
+
304
+ private
305
+
306
+ # Reads the element count of a list, set or map
307
+ #
308
+ # Every element takes at least one byte, so a count above the bytes left is corrupt. Checking
309
+ # that up front keeps a forged count from preallocating a huge Array or skipping for ever.
310
+ #
311
+ # @return [Integer] the count, at most the number of bytes left in the buffer
312
+ # @raise [Error] when the count exceeds the bytes left
313
+ def read_size
314
+ size = read_varint
315
+ left = @buf.bytesize - @pos
316
+ raise Error, "Collection of #{size} elements exceeds the #{left} bytes left" if size > left
317
+ size
318
+ end
319
+
320
+ # Runs the block one nesting level deeper
321
+ #
322
+ # @yieldreturn [Object] the value read inside the nested container
323
+ # @return [Object] the block's value
324
+ # @raise [Error] when the nesting gets deeper than MAX_DEPTH
325
+ def nested
326
+ @depth += 1
327
+ raise Error, "Thrift data nested deeper than #{MAX_DEPTH} levels" if @depth > MAX_DEPTH
328
+ yield
329
+ ensure
330
+ @depth -= 1
331
+ end
273
332
  end
274
333
 
275
334
  # Encodes compact protocol data by appending to a binary String
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Herringbone
4
4
  # Gem version, also written to the +created_by+ field of every file's footer
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.1"
6
6
  end
@@ -24,6 +24,8 @@ module Herringbone
24
24
  # Options:
25
25
  # compression: :snappy (default), :zstd, :gzip, :lz4 (LZ4_RAW), :lz4_hadoop, :brotli, :none
26
26
  # (:zstd and :brotli need the zstd-ruby / brotli gems)
27
+ # compression_level: nil (the codec's default), or a level for :zstd (up to 22), :gzip (0-9)
28
+ # or :brotli (0-11)
27
29
  # row_group_bytes: flush a row group once the buffered values take roughly this much memory
28
30
  # (default 16MB). This bounds memory use while writing.
29
31
  # row_group_rows: also flush after this many rows (default: no row limit)
@@ -38,6 +40,9 @@ module Herringbone
38
40
  # an Array of column paths, or { "path" => true | { ndv:, fpp:, max_bytes: } }.
39
41
  # Without ndv: the distinct values of each row group are counted. fpp defaults
40
42
  # to 0.01 and max_bytes to 1MB. Filters are written after each row group.
43
+ # encryption: Parquet modular encryption: { footer_key: "...", columns: { "ssn" => "..." } }.
44
+ # Without columns: every column is encrypted with the footer key. See
45
+ # #initialize for the settings.
41
46
  #
42
47
  # Statistics and page indexes (ColumnIndex/OffsetIndex) are always written.
43
48
  class Writer
@@ -83,6 +88,7 @@ module Herringbone
83
88
  # @param schema [Schema] schema of the rows
84
89
  # @param options [Hash{Symbol => Object}] see the class description and #initialize
85
90
  # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
91
+ # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
86
92
  # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
87
93
  # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
88
94
  # @option options [Integer] :page_bytes (1MB) approximate uncompressed data page size
@@ -95,6 +101,8 @@ module Herringbone
95
101
  # @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
96
102
  # @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
97
103
  # columns to write split block bloom filters for
104
+ # @option options [EncryptionConfiguration, Hash{Symbol => Object}, nil] :encryption (nil) modular
105
+ # encryption settings, see #initialize
98
106
  # @yield [writer] the open writer
99
107
  # @yieldparam writer [Writer] writer to append rows to
100
108
  # @yieldreturn [Object] returned by open
@@ -118,6 +126,8 @@ module Herringbone
118
126
  # @param io [IO, #write] destination; switched to binmode when it supports that
119
127
  # @param schema [Schema] schema of the rows
120
128
  # @param compression [Symbol, Integer] codec name (see Herringbone.codecs) or Format::Codec id
129
+ # @param compression_level [Integer, nil] level for :zstd, :gzip or :brotli (see
130
+ # Compression::LEVELS), nil for the codec's default
121
131
  # @param row_group_bytes [Integer] approximate buffered size that triggers a row group
122
132
  # @param row_group_rows [Integer, nil] also flush a row group after this many rows
123
133
  # @param page_bytes [Integer] approximate uncompressed data page size
@@ -131,17 +141,35 @@ module Herringbone
131
141
  # @param metadata [Hash{#to_s => #to_s}] key/value metadata for the footer
132
142
  # @param bloom_filters [Boolean, Array<String>, Hash{String => Boolean, Hash}, nil] see the class
133
143
  # description
134
- # @raise [ArgumentError] for an invalid IO, schema or option
144
+ # @param encryption [EncryptionConfiguration, Hash{Symbol => Object}, false, nil] encrypts the file
145
+ # (Parquet modular encryption); a Hash holds the keywords of EncryptionConfiguration.new.
146
+ # Keys are 16, 24 or 32-byte Strings; key metadata is stored as given, for readers to find
147
+ # the keys by.
148
+ # @option encryption [String] :footer_key key of the footer, and of columns without their own
149
+ # @option encryption [String, nil] :footer_key_metadata stored for the footer key
150
+ # @option encryption [Hash{String => String, Hash, Symbol}, nil] :columns column path or field
151
+ # name => key, +{key:, key_metadata:}+ or +:footer+. Columns not listed are not encrypted;
152
+ # without this option every column is, with the footer key.
153
+ # @option encryption [Boolean] :plaintext_footer (false) store the footer in the clear (signed),
154
+ # so readers without keys can read the plaintext columns
155
+ # @option encryption [Symbol] :algorithm (:aes_gcm) or :aes_gcm_ctr, which encrypts pages with
156
+ # AES-CTR (faster, but page contents are not authenticated)
157
+ # @option encryption [String, nil] :aad_prefix identity of the file, which readers can check
158
+ # @option encryption [Boolean] :store_aad_prefix (true) false leaves it out of the file, so
159
+ # readers must supply it
160
+ # @raise [ArgumentError] for an invalid IO, schema or option, or a compression level the codec
161
+ # does not take
135
162
  # @raise [MissingCodecError] when the codec's optional gem is not loaded
136
163
  # @raise [UnsupportedError] when the codec is not supported
137
- def initialize(io, schema, compression: :snappy, row_group_bytes: 16 * 1024 * 1024, row_group_rows: nil,
164
+ def initialize(io, schema, compression: :snappy, compression_level: nil, row_group_bytes: 16 * 1024 * 1024, row_group_rows: nil,
138
165
  page_bytes: 1024 * 1024, page_rows: 20_000, data_page_version: 1, dictionary: true, encodings: {},
139
- metadata: {}, bloom_filters: nil)
166
+ metadata: {}, bloom_filters: nil, encryption: nil)
140
167
  raise ArgumentError, "Expected a Herringbone::Schema, got #{schema.class}" unless schema.is_a?(Schema)
141
168
  @schema = schema
142
169
  @codec = Compression.codec_id(compression)
143
170
  # Fail before creating any file if the codec's library is missing
144
171
  Compression.ensure_available!(@codec)
172
+ @compression_level = Compression.check_level!(@codec, compression_level)
145
173
  @row_group_bytes = Integer(row_group_bytes)
146
174
  @row_group_rows = row_group_rows && Integer(row_group_rows)
147
175
  @row_limit = @row_group_rows || ESTIMATE_AFTER_ROWS
@@ -159,7 +187,8 @@ module Herringbone
159
187
  @encodings.each { |path, enc| check_encoding!(columns[path], enc) }
160
188
  @metadata = metadata
161
189
  @bloom_filters = bloom_filter_config(bloom_filters)
162
- @pending_bloom_filters = [] # [ColumnMetaData, BloomFilter] for the row group being written
190
+ @pending_bloom_filters = [] # [ColumnMetaData, BloomFilter, ModuleCrypto] for the row group being written
191
+ @encryption = encryption ? Encryption::FileEncryptor.new(EncryptionConfiguration.from(encryption), schema) : nil
163
192
  @row_groups = []
164
193
  @total_rows = 0
165
194
  @pos = 0
@@ -167,7 +196,7 @@ module Herringbone
167
196
  @aborted = false
168
197
  @bytes_per_row = nil
169
198
  @io = check_io!(io)
170
- write_raw(MAGIC)
199
+ write_raw(@encryption ? @encryption.magic : MAGIC)
171
200
  reset_buffers
172
201
  end
173
202
 
@@ -248,6 +277,7 @@ module Herringbone
248
277
  #
249
278
  # @param num_rows [Integer] rows in the row group
250
279
  # @param copies [Hash{Integer => CopiedChunk}] column index => chunk to copy instead of encoding
280
+ # (only plaintext chunks, into columns this writer does not encrypt)
251
281
  # @param codecs [Hash{Integer => Integer}] column index => codec id for an encoded chunk, instead
252
282
  # of the +compression:+ option
253
283
  # @param bloom_filters [Hash{Integer => Boolean}] column index => true to give an encoded chunk a
@@ -257,6 +287,9 @@ module Herringbone
257
287
  # @raise [Error] when the writer is closed
258
288
  def write_row_group(num_rows, copies: {}, codecs: {}, bloom_filters: {}, sorting_columns: nil)
259
289
  raise Error, "Writer is closed" if @closed
290
+ if @encryption && (encrypted = copies.keys.find { |i| @encryption.encrypted?(i) })
291
+ raise ArgumentError, "Cannot copy a chunk into the encrypted column #{@schema.columns[encrypted].dotted_path}"
292
+ end
260
293
  start = @pos
261
294
  chunks = @schema.columns.map do |col|
262
295
  if (copy = copies[col.index])
@@ -281,6 +314,12 @@ module Herringbone
281
314
  reset_buffers
282
315
  end
283
316
 
317
+ # Internal (used by Redaction): whether the column is encrypted in this file
318
+ #
319
+ # @param index [Integer] leaf column index
320
+ # @return [Boolean]
321
+ def encrypted_column?(index) = @encryption&.encrypted?(index) || false
322
+
284
323
  # Internal (used by Redaction): shreds one value per row of a top-level field into the buffers,
285
324
  # for #write_row_group. Other fields are left as they are, so the caller decides which columns
286
325
  # are encoded and which are copied.
@@ -307,6 +346,7 @@ module Herringbone
307
346
  return if @closed
308
347
  flush_row_group
309
348
  write_page_indexes
349
+ @encryption&.finish(@row_groups)
310
350
  meta = Format::FileMetaData.new(
311
351
  version: 2,
312
352
  schema: @schema.to_elements,
@@ -316,10 +356,10 @@ module Herringbone
316
356
  created_by: "herringbone-ruby #{VERSION}",
317
357
  column_orders: @schema.columns.map { Format::ColumnOrder.new(type_order: Format::TypeDefinedOrder.new) }
318
358
  )
319
- footer = meta.encode
359
+ footer = @encryption ? @encryption.footer(meta) : meta.encode
320
360
  write_raw(footer)
321
361
  write_raw([footer.bytesize].pack("V"))
322
- write_raw(MAGIC)
362
+ write_raw(@encryption ? @encryption.magic : MAGIC)
323
363
  @io.flush if @io.respond_to?(:flush)
324
364
  @closed = true
325
365
  end
@@ -637,6 +677,7 @@ module Herringbone
637
677
  @encodings[path] || E::PLAIN
638
678
  end
639
679
 
680
+ crypto = @encryption&.chunk(@row_groups.size, col.index)
640
681
  chunk_start = @pos
641
682
  uncompressed_total = 0
642
683
  dictionary_offset = nil
@@ -647,7 +688,7 @@ module Herringbone
647
688
  type: Format::PageType::DICTIONARY_PAGE,
648
689
  dictionary_page_header: Format::DictionaryPageHeader.new(num_values: dict_values.size, encoding: E::PLAIN)
649
690
  )
650
- uncompressed_total += write_page(header, plain, codec: codec)
691
+ uncompressed_total += write_page(header, plain, codec: codec, crypto: crypto)
651
692
  end
652
693
 
653
694
  data_offset = @pos
@@ -680,11 +721,12 @@ module Herringbone
680
721
  encoded = encode_values(page_values, value_encoding, type, col.type_length, dict_values&.size)
681
722
  rep_bytes = max_rep.positive? ? Encodings::RLE.encode_hybrid(reps, max_rep.bit_length) : "".b
682
723
  def_bytes = max_def.positive? ? Encodings::RLE.encode_hybrid(defs, max_def.bit_length) : "".b
724
+ page_crypto = crypto && [crypto, pages.size]
683
725
  uncompressed_total += if @data_page_version == 1
684
- write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding, codec)
726
+ write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding, codec, page_crypto)
685
727
  else
686
728
  num_rows = max_rep.zero? ? n : reps.count(0)
687
- write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding, codec)
729
+ write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding, codec, page_crypto)
688
730
  end
689
731
  pages << PageInfo.new(page_offset, @pos - page_offset, first_row, n - non_null, non_null, range)
690
732
  first_row += max_rep.zero? ? n : reps.count(0)
@@ -707,9 +749,9 @@ module Herringbone
707
749
  )
708
750
  chunk = Format::ColumnChunk.new(file_offset: chunk_start, meta_data: meta)
709
751
  if bloom
710
- @pending_bloom_filters << [meta, build_bloom_filter(col, bloom, dict_values, values)]
752
+ @pending_bloom_filters << [meta, build_bloom_filter(col, bloom, dict_values, values), crypto]
711
753
  end
712
- @page_indexes << [chunk, column_index_for(col, pages, order), offset_index_for(pages)]
754
+ @page_indexes << [chunk, column_index_for(col, pages, order), offset_index_for(pages), crypto]
713
755
  chunk
714
756
  end
715
757
 
@@ -805,19 +847,22 @@ module Herringbone
805
847
 
806
848
  # Page indexes go after the last row group: all column indexes, then all offset indexes.
807
849
  # A copied chunk brings its ColumnIndex already encoded, and may come without either index.
850
+ # Those of encrypted columns are encrypted.
808
851
  #
809
852
  # @return [void]
810
853
  def write_page_indexes
811
- @page_indexes.each do |chunk, column_index, _|
854
+ @page_indexes.each do |chunk, column_index, _, crypto|
812
855
  next unless column_index
813
856
  bytes = column_index.is_a?(String) ? column_index : column_index.encode
857
+ bytes = crypto.encrypt(Encryption::COLUMN_INDEX, bytes) if crypto
814
858
  chunk.column_index_offset = @pos
815
859
  chunk.column_index_length = bytes.bytesize
816
860
  write_raw(bytes)
817
861
  end
818
- @page_indexes.each do |chunk, _, offset_index|
862
+ @page_indexes.each do |chunk, _, offset_index, crypto|
819
863
  next unless offset_index
820
864
  bytes = offset_index.encode
865
+ bytes = crypto.encrypt(Encryption::OFFSET_INDEX, bytes) if crypto
821
866
  chunk.offset_index_offset = @pos
822
867
  chunk.offset_index_length = bytes.bytesize
823
868
  write_raw(bytes)
@@ -893,12 +938,18 @@ module Herringbone
893
938
  end
894
939
 
895
940
  # Bloom filters go right after the row group's column chunks, in column order. A copied
896
- # chunk brings its filter already encoded.
941
+ # chunk brings its filter already encoded. In an encrypted column the header and the bitset
942
+ # are encrypted separately.
897
943
  #
898
944
  # @return [void]
899
945
  def write_bloom_filters
900
- @pending_bloom_filters.each do |meta, filter|
901
- bytes = filter.is_a?(String) ? filter : filter.encode
946
+ @pending_bloom_filters.each do |meta, filter, crypto|
947
+ bytes = if crypto
948
+ crypto.encrypt(Encryption::BLOOM_FILTER_HEADER, filter.header.encode) <<
949
+ crypto.encrypt(Encryption::BLOOM_FILTER_BITSET, filter.bitset)
950
+ else
951
+ filter.is_a?(String) ? filter : filter.encode
952
+ end
902
953
  meta.bloom_filter_offset = @pos
903
954
  meta.bloom_filter_length = bytes.bytesize
904
955
  write_raw(bytes)
@@ -1015,18 +1066,40 @@ module Herringbone
1015
1066
 
1016
1067
  # Writes a page, returning the uncompressed size including the header
1017
1068
  #
1069
+ # Compresses with the configured level when +codec+ is the writer's own; chunks a redaction
1070
+ # keeps in their source codec use that codec's default
1071
+ #
1072
+ # @param codec [Integer] codec id
1073
+ # @param data [String] bytes to compress
1074
+ # @return [String] compressed bytes
1075
+ def compress(codec, data)
1076
+ Compression.compress(codec, data, (codec == @codec) ? @compression_level : nil)
1077
+ end
1078
+
1079
+ # In an encrypted column the body and the header are encrypted; the CRC covers the body as
1080
+ # stored, encrypted.
1081
+ #
1018
1082
  # @param header [Format::PageHeader] header; sizes and CRC32 are filled in here
1019
1083
  # @param body [String] uncompressed page body
1020
1084
  # @param compressed [String, nil] bytes to write as the page body, when already prepared (v2 data
1021
1085
  # pages, whose levels stay uncompressed); +body+ is compressed otherwise
1022
1086
  # @param codec [Integer] codec id to compress +body+ with
1087
+ # @param crypto [Encryption::ModuleCrypto, nil] encryption of the column's modules
1088
+ # @param ordinal [Integer, nil] data page ordinal within the chunk; nil for a dictionary page
1023
1089
  # @return [Integer]
1024
- def write_page(header, body, compressed = nil, codec: @codec)
1025
- compressed ||= Compression.compress(codec, body)
1090
+ def write_page(header, body, compressed = nil, codec: @codec, crypto: nil, ordinal: nil)
1091
+ compressed ||= compress(codec, body)
1026
1092
  header.uncompressed_page_size ||= body.bytesize
1093
+ dictionary = header.type == Format::PageType::DICTIONARY_PAGE
1094
+ if crypto
1095
+ compressed = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE : Encryption::DATA_PAGE, compressed, ordinal)
1096
+ end
1027
1097
  header.compressed_page_size = compressed.bytesize
1028
1098
  header.crc = Zlib.crc32(compressed).then { |c| (c >= 0x8000_0000) ? c - 0x1_0000_0000 : c }
1029
1099
  encoded = header.encode
1100
+ if crypto
1101
+ encoded = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE_HEADER : Encryption::DATA_PAGE_HEADER, encoded, ordinal)
1102
+ end
1030
1103
  write_raw(encoded)
1031
1104
  write_raw(compressed)
1032
1105
  encoded.bytesize + header.uncompressed_page_size
@@ -1040,8 +1113,10 @@ module Herringbone
1040
1113
  # @param encoded [String] encoded values
1041
1114
  # @param encoding [Integer] encoding id of the values
1042
1115
  # @param codec [Integer] codec id to compress the page with
1116
+ # @param page_crypto [Array(Encryption::ModuleCrypto, Integer), nil] encryption of the column's
1117
+ # modules and the page's ordinal, for an encrypted column
1043
1118
  # @return [Integer] uncompressed size including the header
1044
- def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding, codec)
1119
+ def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
1045
1120
  body = String.new(encoding: Encoding::BINARY)
1046
1121
  body << [rep_bytes.bytesize].pack("V") << rep_bytes unless rep_bytes.empty?
1047
1122
  body << [def_bytes.bytesize].pack("V") << def_bytes unless def_bytes.empty?
@@ -1053,7 +1128,7 @@ module Herringbone
1053
1128
  definition_level_encoding: E::RLE, repetition_level_encoding: E::RLE
1054
1129
  )
1055
1130
  )
1056
- write_page(header, body, codec: codec)
1131
+ write_page(header, body, codec: codec, crypto: page_crypto&.first, ordinal: page_crypto&.last)
1057
1132
  end
1058
1133
 
1059
1134
  # A DATA_PAGE_V2: levels without length prefixes and uncompressed, then the compressed values
@@ -1066,9 +1141,11 @@ module Herringbone
1066
1141
  # @param encoded [String] encoded values
1067
1142
  # @param encoding [Integer] encoding id of the values
1068
1143
  # @param codec [Integer] codec id to compress the values with
1144
+ # @param page_crypto [Array(Encryption::ModuleCrypto, Integer), nil] encryption of the column's
1145
+ # modules and the page's ordinal, for an encrypted column
1069
1146
  # @return [Integer] uncompressed size including the header
1070
- def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding, codec)
1071
- compressed = Compression.compress(codec, encoded)
1147
+ def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
1148
+ compressed = compress(codec, encoded)
1072
1149
  header = Format::PageHeader.new(
1073
1150
  type: Format::PageType::DATA_PAGE_V2,
1074
1151
  uncompressed_page_size: rep_bytes.bytesize + def_bytes.bytesize + encoded.bytesize,
@@ -1079,7 +1156,8 @@ module Herringbone
1079
1156
  is_compressed: codec != Format::Codec::UNCOMPRESSED
1080
1157
  )
1081
1158
  )
1082
- write_page(header, "".b, rep_bytes + def_bytes + compressed, codec: codec)
1159
+ write_page(header, "".b, rep_bytes + def_bytes + compressed, codec: codec, crypto: page_crypto&.first,
1160
+ ordinal: page_crypto&.last)
1083
1161
  end
1084
1162
 
1085
1163
  # Statistics and column index bounds longer than this are truncated, see #truncate_min