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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/MANUAL.md +773 -0
- data/README.md +68 -437
- data/bin/herringbone +86 -7
- data/lib/herringbone/bloom_filter.rb +0 -55
- data/lib/herringbone/compression.rb +27 -4
- data/lib/herringbone/encryption.rb +716 -0
- data/lib/herringbone/encryption_configuration.rb +304 -0
- data/lib/herringbone/format.rb +55 -3
- data/lib/herringbone/inferring_writer.rb +17 -2
- data/lib/herringbone/inspector.rb +158 -28
- data/lib/herringbone/key.rb +107 -0
- data/lib/herringbone/reader/bloom_filters.rb +91 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +55 -1
- data/lib/herringbone/reader.rb +100 -15
- data/lib/herringbone/redaction/rewriter.rb +59 -7
- data/lib/herringbone/redaction.rb +20 -5
- data/lib/herringbone/schema.rb +2 -0
- data/lib/herringbone/simple_writer.rb +28 -0
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +102 -24
- data/lib/herringbone.rb +69 -25
- metadata +7 -2
|
@@ -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
|
#
|
data/lib/herringbone/reader.rb
CHANGED
|
@@ -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
|
-
# @
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
292
|
-
|
|
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 =
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
592
|
-
raise FormatError, "Missing PAR1 footer magic" unless
|
|
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
|
-
|
|
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/
|
|
782
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
+
module Herringbone
|
|
299
|
+
class Redaction
|
|
300
|
+
autoload :Rewriter, File.expand_path("redaction/rewriter", __dir__)
|
|
301
|
+
end
|
|
302
|
+
end
|
data/lib/herringbone/schema.rb
CHANGED
|
@@ -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
|
#
|
data/lib/herringbone/version.rb
CHANGED