herringbone 0.4.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