herringbone 0.5.0 → 0.6.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.
@@ -42,8 +42,10 @@ module Herringbone
42
42
  # @param column [Schema::Column] the leaf column the chunk stores
43
43
  # @param converter [Proc, nil] physical value => Ruby value, applied to values and dictionaries
44
44
  # @param lazy [Boolean] leave non-dictionary values physical in #next_page (see #page_converter)
45
+ # @param crypto [Encryption::ModuleCrypto, nil] decryption of the chunk's pages, for an
46
+ # encrypted column
45
47
  # @raise [UnsupportedError] for chunks without metadata (encrypted) or stored in another file
46
- def initialize(io, chunk, column, converter: column.converter, lazy: false)
48
+ def initialize(io, chunk, column, converter: column.converter, lazy: false, crypto: nil)
47
49
  @io = io
48
50
  @chunk = chunk
49
51
  @column = column
@@ -59,6 +61,11 @@ module Herringbone
59
61
  start = dict if dict&.positive? && dict < start
60
62
  @pos = start
61
63
  @start = start
64
+ @crypto = crypto
65
+ # An encrypted header's AAD depends on whether it is the dictionary page's, which only its
66
+ # position tells before it is decrypted
67
+ @dictionary_offset = (start < @meta.data_page_offset) ? start : nil
68
+ @ordinal = 0 # data pages read so far, part of an encrypted data page's AAD
62
69
  @locations = nil # OffsetIndex page locations, when jumping between pages
63
70
  @page_number = nil
64
71
  @total = @meta.num_values
@@ -154,6 +161,7 @@ module Herringbone
154
161
  load_dictionary
155
162
  @pos = @locations.fetch(index).offset
156
163
  @page_number = index
164
+ @ordinal = index
157
165
  end
158
166
 
159
167
  # Whether all of the chunk's values have been returned
@@ -196,7 +204,9 @@ module Herringbone
196
204
  #
197
205
  # @return [Array(Format::PageHeader, String)] the header and the (still compressed) body
198
206
  # @raise [FormatError] for a negative page size or a page that overruns the file
207
+ # @raise [DecryptionError] when an encrypted page does not decrypt
199
208
  def read_page
209
+ return read_encrypted_page if @crypto
200
210
  # With an OffsetIndex the page's size (header included) is known, so read exactly that
201
211
  loc = @page_number && @locations[@page_number]
202
212
  exact = loc && loc.offset == @pos && loc.compressed_page_size.positive?
@@ -222,6 +232,50 @@ module Herringbone
222
232
  [header, body]
223
233
  end
224
234
 
235
+ # Like #read_page, for an encrypted column: the header and the body are each an encrypted
236
+ # module, the header's starting with its length
237
+ #
238
+ # @return [Array(Format::PageHeader, String)] the header and the decrypted (still compressed) body
239
+ # @raise [FormatError] for a page that overruns the file
240
+ # @raise [DecryptionError] when the header or the body does not decrypt
241
+ def read_encrypted_page
242
+ loc = @page_number && @locations[@page_number]
243
+ exact = loc && loc.offset == @pos && loc.compressed_page_size.positive?
244
+ dictionary = @pos == @dictionary_offset
245
+ ordinal = dictionary ? nil : @ordinal
246
+ mod = read_module(@pos, exact ? loc.compressed_page_size : HEADER_GUESS)
247
+ type = dictionary ? Encryption::DICTIONARY_PAGE_HEADER : Encryption::DATA_PAGE_HEADER
248
+ header = Format::PageHeader.decode(@crypto.decrypt(type, mod, ordinal)).first
249
+ size = header.compressed_page_size
250
+ raise FormatError, "Negative page size in #{@column.dotted_path}" if size.nil? || size.negative?
251
+ buf, off = window(@pos + mod.bytesize, size + (exact ? 0 : READ_AHEAD), size)
252
+ if buf.bytesize - off < size
253
+ raise FormatError, "Column #{@column.dotted_path}: page overruns the file (read #{@seen} of #{@total} values)"
254
+ end
255
+ type = (header.type == Format::PageType::DICTIONARY_PAGE) ? Encryption::DICTIONARY_PAGE : Encryption::DATA_PAGE
256
+ body = @crypto.decrypt(type, buf.byteslice(off, size), ordinal)
257
+ @pos += mod.bytesize + size
258
+ @ordinal += 1 unless dictionary
259
+ [header, body]
260
+ end
261
+
262
+ # An encrypted module (length prefix included) at +pos+
263
+ #
264
+ # @param pos [Integer] file offset of the module
265
+ # @param guess [Integer] bytes to read when the buffer does not hold the length prefix
266
+ # @return [String] the module
267
+ # @raise [FormatError] when the module overruns the file
268
+ def read_module(pos, guess)
269
+ buf, off = window(pos, guess, 4)
270
+ len = buf.bytesize - off >= 4 && buf.byteslice(off, 4).unpack1("V")
271
+ raise FormatError, "Column #{@column.dotted_path}: encrypted page header overruns the file" unless len
272
+ buf, off = window(pos, 4 + len + READ_AHEAD, 4 + len)
273
+ if buf.bytesize - off < 4 + len
274
+ raise FormatError, "Column #{@column.dotted_path}: encrypted page header overruns the file"
275
+ end
276
+ buf.byteslice(off, 4 + len)
277
+ end
278
+
225
279
  # Returns [buffer, offset] where buffer[offset..] holds at least +need+ bytes from file
226
280
  # position +pos+ (fewer only at EOF), reading +len+ bytes when the buffer does not cover it.
227
281
  #
@@ -21,9 +21,16 @@ module Herringbone
21
21
  # seconds as an Integer), a timezone object Time#getlocal accepts (e.g. a
22
22
  # TZInfo::Timezone), or anything responding to #at such as an
23
23
  # ActiveSupport::TimeZone (Time.zone), which yields ActiveSupport::TimeWithZone.
24
+ # decryption: keys for a file written with Parquet modular encryption:
25
+ # { footer_key: "...", columns: { "ssn" => "..." }, aad_prefix: "..." }, and/or
26
+ # keys: ->(key_metadata) { ... } (or a Hash) to look keys up by the key metadata
27
+ # stored in the file. Keys are 16, 24 or 32-byte Strings. Plaintext columns of a
28
+ # file with a plaintext footer can be read without keys.
24
29
  class Reader
25
30
  # The 4 bytes a Parquet file starts and ends with
26
31
  MAGIC = "PAR1"
32
+ # The 4 bytes a Parquet file with an encrypted footer starts and ends with
33
+ ENCRYPTED_MAGIC = "PARE"
27
34
  # Rows per batch in #each_batch when no size is given
28
35
  DEFAULT_BATCH_SIZE = 1024
29
36
  # Accepted values of the +keys:+ option
@@ -43,9 +50,20 @@ module Herringbone
43
50
  # @param keys [Symbol, String] +:string+ or +:symbol+, the key type of row and struct Hashes
44
51
  # @param time_zone [String, Integer, Object, nil] zone timestamps are returned in (see the
45
52
  # class docs); nil keeps them in UTC
46
- # @raise [ArgumentError] when +io+ cannot seek and read, or +keys+ / +time_zone+ are invalid
53
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, #call, nil] keys for an
54
+ # encrypted file (see the class docs); a callable is used as +keys:+
55
+ # @option decryption [String] :footer_key key of the footer (and of the columns encrypted with it)
56
+ # @option decryption [Hash{String => String}] :columns column path or field name => key
57
+ # @option decryption [#call, Hash{String => String}] :keys key metadata => key, for the keys not
58
+ # given above; returns nil for a key that is not available
59
+ # @option decryption [String] :aad_prefix the file's AAD prefix, when it does not store it (or
60
+ # to check the stored one)
61
+ # @raise [ArgumentError] when +io+ cannot seek and read, or +keys+ / +time_zone+ /
62
+ # +decryption+ are invalid
47
63
  # @raise [FormatError] when the footer is missing or cannot be decoded
48
- def initialize(io, keys: :string, time_zone: nil)
64
+ # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted, or its
65
+ # signature or a column's metadata does not check out
66
+ def initialize(io, keys: :string, time_zone: nil, decryption: nil)
49
67
  unless io.respond_to?(:seek) && io.respond_to?(:read)
50
68
  raise ArgumentError, "Herringbone::Reader expects an IO that supports #seek and #read " \
51
69
  "(e.g. File.open(path, \"rb\")), got #{io.is_a?(String) ? "a String" : io.class}" \
@@ -55,6 +73,7 @@ module Herringbone
55
73
  raise ArgumentError, "keys: must be :string or :symbol, got #{keys.inspect}" unless KEY_MODES.include?(keys)
56
74
  @symbolize = keys == :symbol
57
75
  @zone_converter = zone_converter(time_zone)
76
+ @decryption = decryption.nil? ? nil : DecryptionConfiguration.from(decryption)
58
77
  @io = io
59
78
  @file_metadata = read_footer
60
79
  @schema = Schema.from_elements(@file_metadata.schema)
@@ -162,7 +181,7 @@ module Herringbone
162
181
  partial = ranges != [[0, rg.num_rows]]
163
182
  cursors = fields.map do |f|
164
183
  f.leaves.map do |col|
165
- reader = ColumnChunkReader.new(@io, rg.columns.fetch(col.index), col, converter: converters[col.index], lazy: true)
184
+ reader = chunk_reader(rg_index, col, converter: converters[col.index], lazy: true)
166
185
  reader.locations = page_index(rg_index, col)[1]&.page_locations if partial
167
186
  [col.index, ColumnCursor.new(reader)]
168
187
  end
@@ -288,11 +307,58 @@ module Herringbone
288
307
  @page_indexes ||= {}
289
308
  @page_indexes[[row_group_index, column.index]] ||= begin
290
309
  chunk = row_groups.fetch(row_group_index).columns.fetch(column.index)
291
- [read_struct(Format::ColumnIndex, chunk.column_index_offset, chunk.column_index_length),
292
- read_struct(Format::OffsetIndex, chunk.offset_index_offset, chunk.offset_index_length)]
310
+ crypto = (chunk.column_index_offset || chunk.offset_index_offset) && chunk_crypto(row_group_index, column)
311
+ [read_struct(Format::ColumnIndex, chunk.column_index_offset, chunk.column_index_length, crypto, crypto && Encryption::COLUMN_INDEX),
312
+ read_struct(Format::OffsetIndex, chunk.offset_index_offset, chunk.offset_index_length, crypto, crypto && Encryption::OFFSET_INDEX)]
293
313
  end
294
314
  end
295
315
 
316
+ # How the file is encrypted, without needing its keys; nil for a file that is not encrypted.
317
+ # +columns+ lists the encrypted columns (as the first row group has them), with the key
318
+ # metadata of their own key, if any, and whether their key is available.
319
+ #
320
+ # reader.encryption
321
+ # # => { algorithm: :aes_gcm, footer: :encrypted, footer_key_metadata: "kf", aad_prefix: nil,
322
+ # # supply_aad_prefix: false, footer_verified: false,
323
+ # # columns: { "ssn" => { key: :column, key_metadata: "kc1", readable: true } } }
324
+ #
325
+ # @return [Hash{Symbol => Object}, nil] +:algorithm+ (+:aes_gcm+ or +:aes_gcm_ctr+), +:footer+
326
+ # (+:encrypted+ or +:plaintext+), +:footer_key_metadata+, +:aad_prefix+ (when stored),
327
+ # +:supply_aad_prefix+, +:footer_verified+ (whether a plaintext footer's signature was
328
+ # checked) and +:columns+
329
+ def encryption = @decryptor&.describe(@schema, row_groups.first&.columns || [])
330
+
331
+ # Internal (used by Redaction): the keys and settings of an encrypted file
332
+ #
333
+ # @return [Encryption::FileDecryptor, nil] nil for a file that is not encrypted
334
+ attr_reader :decryptor
335
+
336
+ # Internal: the decryption of one column chunk's modules
337
+ #
338
+ # @param row_group_index [Integer] position of the row group in the footer
339
+ # @param column [Schema::Column] leaf column
340
+ # @return [Encryption::ModuleCrypto, nil] nil when the chunk is not encrypted
341
+ # @raise [DecryptionError] when the chunk is encrypted and its key was not given
342
+ def chunk_crypto(row_group_index, column)
343
+ return nil unless @decryptor
344
+ rg = row_groups.fetch(row_group_index)
345
+ @decryptor.chunk(row_group_index, rg.ordinal, column, rg.columns.fetch(column.index))
346
+ end
347
+
348
+ # Internal: a ColumnChunkReader for a leaf column of a row group, decrypting when needed
349
+ #
350
+ # @param row_group_index [Integer] position of the row group in the footer
351
+ # @param column [Schema::Column] leaf column
352
+ # @param options [Hash{Symbol => Object}] passed to ColumnChunkReader.new
353
+ # @option options [Proc, nil] :converter physical value => Ruby value
354
+ # @option options [Boolean] :lazy leave non-dictionary values physical
355
+ # @return [ColumnChunkReader]
356
+ # @raise [DecryptionError] when the chunk is encrypted and its key was not given
357
+ def chunk_reader(row_group_index, column, **options)
358
+ chunk = row_groups.fetch(row_group_index).columns.fetch(column.index)
359
+ ColumnChunkReader.new(@io, chunk, column, crypto: chunk_crypto(row_group_index, column), **options)
360
+ end
361
+
296
362
  # Short summary for the console, without the schema
297
363
  #
298
364
  # @return [String] row count, row group count and the writer's created_by
@@ -348,7 +414,7 @@ module Herringbone
348
414
  rg = row_groups[rg_index]
349
415
  partial = ranges != [[0, rg.num_rows]]
350
416
  open = lambda do |col|
351
- reader = ColumnChunkReader.new(@io, rg.columns.fetch(col.index), col, converter: converters[col.index], lazy: true)
417
+ reader = chunk_reader(rg_index, col, converter: converters[col.index], lazy: true)
352
418
  reader.locations = page_index(rg_index, col)[1]&.page_locations if partial
353
419
  reader
354
420
  end
@@ -458,14 +524,18 @@ module Herringbone
458
524
  # @param klass [Class] Format struct class to decode with (responds to .decode)
459
525
  # @param offset [Integer, nil] file offset of the struct
460
526
  # @param length [Integer, nil] byte length of the struct
527
+ # @param crypto [Encryption::ModuleCrypto, nil] decryption of the chunk's modules
528
+ # @param type [Integer, nil] the struct's module type, when encrypted
461
529
  # @return [Object, nil] the decoded +klass+ instance, or nil when absent, truncated or corrupt
462
- def read_struct(klass, offset, length)
530
+ # @raise [DecryptionError] when an encrypted struct does not decrypt
531
+ def read_struct(klass, offset, length, crypto = nil, type = nil)
463
532
  return nil unless offset && length&.positive?
464
533
  @io.seek(offset)
465
534
  bytes = @io.read(length)
466
535
  return nil unless bytes&.bytesize == length
536
+ bytes = crypto.decrypt(type, bytes.b) if crypto
467
537
  klass.decode(bytes.b).first
468
- rescue Thrift::Error
538
+ rescue Thrift::Error, FormatError
469
539
  nil # a damaged index only means pages cannot be skipped
470
540
  end
471
541
 
@@ -579,22 +649,30 @@ module Herringbone
579
649
  # Reads and decodes the footer: the FileMetaData Thrift struct, its 4-byte little-endian
580
650
  # length and the closing magic.
581
651
  #
652
+ # In an encrypted file the footer is decrypted (or its signature checked), and so is the
653
+ # metadata of the columns whose key is available.
654
+ #
582
655
  # @return [Format::FileMetaData] the decoded footer
583
656
  # @raise [FormatError] when the file is too short, lacks the magic or the footer is corrupt
584
- # @raise [UnsupportedError] for an encrypted file (+PARE+ magic)
657
+ # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
585
658
  def read_footer
586
659
  @io.seek(0, IO::SEEK_END)
587
660
  size = @io.pos
588
661
  raise FormatError, "File too small to be Parquet (#{size} bytes)" if size < 12
589
662
  @io.seek(size - 8)
590
663
  tail = @io.read(8)
591
- raise UnsupportedError, "Encrypted Parquet files are not supported" if tail.byteslice(4, 4) == "PARE"
592
- raise FormatError, "Missing PAR1 footer magic" unless tail.byteslice(4, 4) == MAGIC
664
+ magic = tail.byteslice(4, 4)
665
+ raise FormatError, "Missing PAR1 footer magic" unless magic == MAGIC || magic == ENCRYPTED_MAGIC
593
666
  footer_len = tail.unpack1("V")
594
667
  raise FormatError, "Footer length #{footer_len} exceeds file size" if footer_len + 12 > size
595
668
  @io.seek(size - 8 - footer_len)
596
- footer = @io.read(footer_len)
597
- Format::FileMetaData.decode(footer).first
669
+ footer = @io.read(footer_len).b
670
+ if magic == MAGIC
671
+ meta = Format::FileMetaData.decode(footer).first
672
+ return meta unless meta.encryption_algorithm
673
+ end
674
+ meta, @decryptor = Encryption.read_footer(footer, magic, @decryption)
675
+ meta
598
676
  rescue Thrift::Error => e
599
677
  raise FormatError, "Corrupt file metadata: #{e.message}"
600
678
  end
@@ -778,5 +856,12 @@ end
778
856
  require_relative "reader/page_stream"
779
857
  require_relative "reader/column_chunk_reader"
780
858
  require_relative "reader/column_cursor"
781
- require_relative "reader/scan"
782
- require_relative "reader/numo"
859
+ require_relative "reader/bloom_filters"
860
+
861
+ module Herringbone
862
+ class Reader
863
+ autoload :Filter, File.expand_path("reader/scan", __dir__)
864
+ autoload :NumoColumns, File.expand_path("reader/numo", __dir__)
865
+ autoload :NumoCursor, File.expand_path("reader/numo", __dir__)
866
+ end
867
+ end
@@ -34,11 +34,13 @@ module Herringbone
34
34
 
35
35
  # @param redaction [Redaction] the statements and drops to apply
36
36
  # @param io [IO, StringIO] the input file, read with #seek and #read
37
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted input, see Reader.new
37
38
  # @raise [ArgumentError] when a statement or drop does not fit the file's schema
38
39
  # @raise [FormatError] when the footer cannot be read
39
- def initialize(redaction, io)
40
+ # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
41
+ def initialize(redaction, io, decryption: nil)
40
42
  @io = io
41
- @reader = Reader.new(io)
43
+ @reader = Reader.new(io, decryption: decryption)
42
44
  @schema = @reader.schema
43
45
  @statements = redaction.statements
44
46
  @filters = @statements.map { |s| s.where && Reader::Filter.new(@schema, s.where) }
@@ -67,6 +69,7 @@ module Herringbone
67
69
  # @param output [IO, #write] destination
68
70
  # @param options [Hash{Symbol => Object}] Writer options for re-encoded chunks, and +metadata:+
69
71
  # @option options [Symbol] :compression (codec of each source chunk) codec for re-encoded chunks
72
+ # @option options [Integer, nil] :compression_level (nil) level for that codec, see Writer
70
73
  # @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
71
74
  # columns whose re-encoded chunks get a bloom filter, besides those whose source chunk had one
72
75
  # @option options [Hash{String => String}] :metadata (the input's) footer key/value metadata
@@ -75,14 +78,17 @@ module Herringbone
75
78
  # @option options [Integer] :data_page_version (1) 1 or 2
76
79
  # @option options [Boolean, Array<String>] :dictionary (true) see Writer
77
80
  # @option options [Hash{String => Symbol}] :encodings ({}) see Writer
81
+ # @option options [EncryptionConfiguration, Hash{Symbol => Object}, false] :encryption (as the input) see Writer
78
82
  # @return [Report]
79
83
  # @raise [ArgumentError] for +row_group_bytes:+ / +row_group_rows:+ or an invalid writer option
80
84
  # @raise [EncodeError] when a replacement value cannot be written
85
+ # @raise [DecryptionError] when the output is to be encrypted like the input but a key is missing
81
86
  def apply(output, **options)
82
87
  bad = options.keys & ROW_GROUP_OPTIONS
83
88
  raise ArgumentError, "#{bad.join(", ")}: a redaction keeps the row groups of the input" unless bad.empty?
84
89
  @keep_codecs = !options.key?(:compression)
85
90
  options = {metadata: copied_metadata}.merge(options)
91
+ options[:encryption] = input_encryption unless options.key?(:encryption)
86
92
  writer = Writer.new(output, @output_schema, row_group_bytes: 1 << 62, **options)
87
93
  @report = Report.new(rows_read: 0, rows_deleted: 0, rows_changed: 0, row_groups: {copied: 0, rewritten: 0})
88
94
  begin
@@ -232,18 +238,25 @@ module Herringbone
232
238
  @schema.fields.to_h { |f| [f.name, @data[f.name][r]] }
233
239
  end
234
240
 
235
- # Tier 1: every kept column chunk is copied as it is
241
+ # Tier 1: every kept column chunk is copied as it is, except those encrypted in the input or
242
+ # the output, which are encoded again
236
243
  #
237
244
  # @param i [Integer] row group index
238
245
  # @param writer [Writer] the output
239
246
  # @return [void]
240
247
  def copy_row_group(i, writer)
248
+ unless encrypted_columns(i, writer).empty?
249
+ rewrite_columns(i, writer, [])
250
+ @report.row_groups[:copied] += 1
251
+ return
252
+ end
241
253
  copies = @output_columns.each_with_index.to_h { |col, j| [j, copied_chunk(i, col)] }
242
254
  writer.write_row_group(rows(i), copies: copies, sorting_columns: sorting_columns(i, []))
243
255
  @report.row_groups[:copied] += 1
244
256
  end
245
257
 
246
- # Tier 2: the chunks of the changed leaf columns are encoded again, the rest are copied
258
+ # Tier 2: the chunks of the changed leaf columns (and of encrypted ones) are encoded again,
259
+ # the rest are copied
247
260
  #
248
261
  # @param i [Integer] row group index
249
262
  # @param writer [Writer] the output
@@ -251,12 +264,15 @@ module Herringbone
251
264
  # @return [void]
252
265
  def rewrite_columns(i, writer, changed)
253
266
  rewritten = changed.map { |t| t.column.index }
254
- changed.map { |t| t.path.first }.uniq.each { |name| writer.buffer_field(name, @data[name]) }
267
+ encoded = rewritten | encrypted_columns(i, writer)
268
+ names = encoded.map { |index| @schema.columns[index].path.first }.uniq
269
+ load(i, names)
270
+ names.each { |name| writer.buffer_field(name, @data[name]) }
255
271
  copies = {}
256
272
  codecs = {}
257
273
  blooms = {}
258
274
  @output_columns.each_with_index do |col, j|
259
- if rewritten.include?(col.index)
275
+ if encoded.include?(col.index)
260
276
  chunk_settings(i, col, j, codecs, blooms)
261
277
  else
262
278
  copies[j] = copied_chunk(i, col)
@@ -264,7 +280,43 @@ module Herringbone
264
280
  end
265
281
  writer.write_row_group(rows(i), copies: copies, codecs: codecs, bloom_filters: blooms,
266
282
  sorting_columns: sorting_columns(i, rewritten))
267
- @report.row_groups[:rewritten] += 1
283
+ @report.row_groups[:rewritten] += 1 unless changed.empty?
284
+ end
285
+
286
+ # Kept columns whose chunk in row group +i+ cannot be copied, because it is encrypted in the
287
+ # input (its AAD names the file and the chunk's place in it) or is to be encrypted
288
+ #
289
+ # @param i [Integer] row group index
290
+ # @param writer [Writer] the output
291
+ # @return [Array<Integer>] input column indexes
292
+ def encrypted_columns(i, writer)
293
+ chunks = @reader.row_groups[i].columns
294
+ @output_columns.each_with_index.filter_map do |col, j|
295
+ col.index if chunks.fetch(col.index).crypto_metadata || writer.encrypted_column?(j)
296
+ end
297
+ end
298
+
299
+ # The Writer +encryption:+ option that encrypts the output like the input: the same
300
+ # algorithm, footer mode, AAD prefix and keys, for the columns that are kept
301
+ #
302
+ # @return [EncryptionConfiguration, nil] nil for a plaintext input
303
+ # @raise [DecryptionError] when a key of the input was not given
304
+ def input_encryption
305
+ decryptor = @reader.decryptor or return nil
306
+ chunks = @reader.row_groups.first&.columns || []
307
+ columns = @output_columns.each_with_index.filter_map do |col, j|
308
+ chunk = chunks[col.index]
309
+ crypto = chunk&.crypto_metadata or next
310
+ path = @output_schema.columns[j].dotted_path
311
+ with_column_key = crypto.encryption_with_column_key
312
+ next [path, :footer] unless with_column_key
313
+ key = decryptor.chunk_key(chunk, col.dotted_path)
314
+ key or raise DecryptionError, "The output is encrypted like the input, which needs the key of #{col.dotted_path}: " \
315
+ "pass it in decryption:, or pass encryption: for the output"
316
+ [path, {key: key, key_metadata: with_column_key.key_metadata}]
317
+ end
318
+ uniform = !columns.empty? && columns.size == @output_columns.size && columns.all? { |_, v| v == :footer }
319
+ decryptor.writer_settings(uniform ? nil : columns.to_h)
268
320
  end
269
321
 
270
322
  # Tier 3: the remaining rows are encoded again, every column of them
@@ -198,11 +198,13 @@ module Herringbone
198
198
  # the columns the conditions name are read for the rest.
199
199
  #
200
200
  # @param input_io [IO, StringIO] the Parquet file, read with #seek and #read
201
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted file, see Reader.new
201
202
  # @return [Boolean]
202
203
  # @raise [ArgumentError] when the redaction does not fit the file's schema
203
- def affects?(input_io)
204
+ # @raise [DecryptionError] when the file is encrypted and a key it needs was not given
205
+ def affects?(input_io, decryption: nil)
204
206
  check_scopes!
205
- Rewriter.new(self, input_io).affects?
207
+ Rewriter.new(self, input_io, decryption: decryption).affects?
206
208
  end
207
209
 
208
210
  # Writes the redacted copy of +input_io+ to +output_io+. The redaction is checked against the
@@ -210,8 +212,14 @@ module Herringbone
210
212
  # verb raise before anything is written. The output is left unfinished (no footer) when an
211
213
  # error happens later, for instance a block returning a value its column cannot store.
212
214
  #
215
+ # An encrypted input (opened with +decryption:+) is written encrypted the same way: same
216
+ # algorithm, footer mode, AAD prefix, keys and key metadata. +encryption:+ (see Writer) writes
217
+ # it with other settings, and +encryption: false+ writes a plaintext file. Column chunks that
218
+ # are encrypted in either file are re-encoded rather than copied.
219
+ #
213
220
  # @param input_io [IO, StringIO] the Parquet file, read with #seek and #read; not closed
214
221
  # @param output_io [IO, #write] destination, written sequentially; not closed
222
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted input, see Reader.new
215
223
  # @param writer_options [Hash{Symbol => Object}] Writer options for the re-encoded column chunks
216
224
  # (+compression:+, +bloom_filters:+, +page_rows:+, +dictionary:+...), and +metadata:+ to
217
225
  # replace the footer key/value metadata instead of copying it
@@ -224,13 +232,16 @@ module Herringbone
224
232
  # @option writer_options [Integer] :data_page_version (1) 1 or 2
225
233
  # @option writer_options [Boolean, Array<String>] :dictionary (true) see Writer
226
234
  # @option writer_options [Hash{String => Symbol}] :encodings ({}) see Writer
235
+ # @option writer_options [EncryptionConfiguration, Hash{Symbol => Object}, false] :encryption (as the input) see Writer;
236
+ # false for a plaintext output
227
237
  # @return [Report] what was done
228
238
  # @raise [ArgumentError] when the redaction does not fit the file's schema, or for a writer
229
239
  # option that does not apply (+row_group_bytes:+, +row_group_rows:+)
230
240
  # @raise [EncodeError] when a replacement value cannot be written to its column
231
- def apply(input_io, output_io, **writer_options)
241
+ # @raise [DecryptionError] when the input is encrypted and a key it needs was not given
242
+ def apply(input_io, output_io, decryption: nil, **writer_options)
232
243
  check_scopes!
233
- Rewriter.new(self, input_io).apply(output_io, **writer_options)
244
+ Rewriter.new(self, input_io, decryption: decryption).apply(output_io, **writer_options)
234
245
  end
235
246
 
236
247
  # Short summary for the console: one clause per statement, naming the columns but not the
@@ -284,4 +295,8 @@ module Herringbone
284
295
  end
285
296
  end
286
297
 
287
- require_relative "redaction/rewriter"
298
+ module Herringbone
299
+ class Redaction
300
+ autoload :Rewriter, File.expand_path("redaction/rewriter", __dir__)
301
+ end
302
+ end
@@ -844,3 +844,5 @@ module Herringbone
844
844
  end
845
845
  end
846
846
  end
847
+
848
+ require_relative "active_record"
@@ -24,6 +24,14 @@ module Herringbone
24
24
  # holds both numbers and text:
25
25
  #
26
26
  # Herringbone::SimpleWriter.new(io) { |s| s.string :code }
27
+ #
28
+ # #encrypt! encrypts the file with one key (see Key):
29
+ #
30
+ # Herringbone::SimpleWriter.open(file) do |sw|
31
+ # sw.encrypt!(key: ENV["PARQUET_KEY"]) # the key's hex, or a Herringbone::Key
32
+ # sw.headers!(:id, :name)
33
+ # sw << [1, "John"]
34
+ # end
27
35
  class SimpleWriter
28
36
  # Opens a writer, yields it and closes it, finishing the file. If the block raises, the file is
29
37
  # left unfinished (no footer), as with Writer.open. To declare columns, use #initialize and #close.
@@ -31,6 +39,7 @@ module Herringbone
31
39
  # @param io [IO, #write] destination; written sequentially, never closed
32
40
  # @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
33
41
  # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
42
+ # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
34
43
  # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
35
44
  # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
36
45
  # (other Writer options are passed on as well)
@@ -57,6 +66,7 @@ module Herringbone
57
66
  # @param io [IO, #write] destination; nothing is written to it until the column types are known
58
67
  # @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
59
68
  # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
69
+ # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
60
70
  # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
61
71
  # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
62
72
  # (other Writer options are passed on as well)
@@ -68,6 +78,7 @@ module Herringbone
68
78
  def initialize(io, **options, &overrides)
69
79
  Schema::Builder.check_block!(overrides, "Herringbone::SimpleWriter.new(io) { |s| s.string :code }")
70
80
  @headers = nil
81
+ @encrypted = !!options[:encryption]
71
82
  @overrides = overrides
72
83
  fix = "Herringbone::SimpleWriter.new(io) { |s| s.%s }"
73
84
  @writer = InferringWriter.new(io, fix: fix, **options) { |sample| schema_for(sample) }
@@ -89,6 +100,23 @@ module Herringbone
89
100
  self
90
101
  end
91
102
 
103
+ # Encrypts the file with one key, the way most Parquet readers can decrypt with that key (see
104
+ # EncryptionConfiguration.simple). Must be called before the first row. For a new key, pass
105
+ # +key: Herringbone::Key.generate+ and keep it (+key.hex+): the file can't be read without it.
106
+ #
107
+ # @param key [Key, String] a key, or its hex (32 or 64 digits; raw bytes go through Key.new)
108
+ # @return [Key] the key the file is encrypted with
109
+ # @raise [ArgumentError] after the first row, when called twice or after +encryption:+ was
110
+ # given, for a String that is not the hex of a key, or a 192-bit key
111
+ def encrypt!(key:)
112
+ raise ArgumentError, "encrypt! must be called before the first row" if rows_written.positive?
113
+ raise ArgumentError, "The file is already encrypted (encrypt! or encryption:)" if @encrypted
114
+ key = Key.from(key)
115
+ @writer.encryption = EncryptionConfiguration.simple(key)
116
+ @encrypted = true
117
+ key
118
+ end
119
+
92
120
  # Appends a row: an Array with one value per header, in header order, or a Hash keyed by
93
121
  # header (String or Symbol keys; missing columns are nulls).
94
122
  #
@@ -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.0"
6
6
  end