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
|
@@ -119,7 +119,29 @@ module Herringbone
|
|
|
119
119
|
@row_group = row_group
|
|
120
120
|
@column = column
|
|
121
121
|
@chunk = chunk
|
|
122
|
-
|
|
122
|
+
# An encrypted footer leaves out the metadata of columns with their own key; without that
|
|
123
|
+
# key nothing is known about the chunk
|
|
124
|
+
@meta = chunk.meta_data || Format::ColumnMetaData.new(path_in_schema: column.path)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Boolean] whether the chunk is encrypted
|
|
128
|
+
def encrypted? = !@chunk.crypto_metadata.nil?
|
|
129
|
+
|
|
130
|
+
# Decryption of the chunk's modules (memoized)
|
|
131
|
+
# @return [Encryption::ModuleCrypto, nil] nil when the chunk is not encrypted or its key was
|
|
132
|
+
# not given
|
|
133
|
+
def crypto
|
|
134
|
+
return @crypto if defined?(@crypto)
|
|
135
|
+
@crypto = @inspector.chunk_crypto(self)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# How the chunk is encrypted, without its key
|
|
139
|
+
# @return [Hash{Symbol => Object}, nil] { key: :footer or :column, key_metadata:, readable: },
|
|
140
|
+
# nil for a plaintext chunk
|
|
141
|
+
def encryption
|
|
142
|
+
c = @chunk.crypto_metadata or return nil
|
|
143
|
+
with_column_key = c.encryption_with_column_key
|
|
144
|
+
{key: with_column_key ? :column : :footer, key_metadata: with_column_key&.key_metadata, readable: !crypto.nil?}
|
|
123
145
|
end
|
|
124
146
|
|
|
125
147
|
# @return [String] dotted path of the column, e.g. +"address.city"+
|
|
@@ -163,15 +185,16 @@ module Herringbone
|
|
|
163
185
|
end
|
|
164
186
|
|
|
165
187
|
# Where the chunk's first page starts
|
|
166
|
-
# @return [Integer] file offset
|
|
188
|
+
# @return [Integer, nil] file offset; nil when the chunk's metadata is encrypted and its key
|
|
189
|
+
# was not given
|
|
167
190
|
def start_offset = dictionary_page_offset || data_page_offset
|
|
168
191
|
|
|
169
192
|
# The end according to the metadata; some writers under-report it (see #end_offset)
|
|
170
|
-
# @return [Integer] file offset just past the chunk
|
|
171
|
-
def declared_end_offset = start_offset + compressed_size
|
|
193
|
+
# @return [Integer, nil] file offset just past the chunk; nil when it is not known
|
|
194
|
+
def declared_end_offset = start_offset && start_offset + compressed_size
|
|
172
195
|
|
|
173
196
|
# The end of the last page actually found (the declared end if the pages could not be walked)
|
|
174
|
-
# @return [Integer] file offset just past the chunk, never before #declared_end_offset
|
|
197
|
+
# @return [Integer, nil] file offset just past the chunk, never before #declared_end_offset
|
|
175
198
|
def end_offset
|
|
176
199
|
last = pages.last
|
|
177
200
|
last ? [last.end_offset, declared_end_offset].max : declared_end_offset
|
|
@@ -215,7 +238,7 @@ module Herringbone
|
|
|
215
238
|
return nil unless bloom_filter_offset
|
|
216
239
|
return @meta.bloom_filter_length if @meta.bloom_filter_length
|
|
217
240
|
return @bloom_filter_length if defined?(@bloom_filter_length)
|
|
218
|
-
@bloom_filter_length = @inspector.bloom_filter_size(bloom_filter_offset)
|
|
241
|
+
@bloom_filter_length = @inspector.bloom_filter_size(bloom_filter_offset, encrypted?)
|
|
219
242
|
end
|
|
220
243
|
|
|
221
244
|
# @return [Array(Integer, Integer), nil] [offset, length] of the chunk's ColumnIndex, nil when
|
|
@@ -268,9 +291,11 @@ module Herringbone
|
|
|
268
291
|
end
|
|
269
292
|
|
|
270
293
|
# Null count from the chunk statistics, else the sum over the data page headers
|
|
271
|
-
# @return [Integer, nil] nil when neither the statistics nor every data page has it
|
|
294
|
+
# @return [Integer, nil] nil when neither the statistics nor every data page has it, or the
|
|
295
|
+
# chunk is encrypted and its key was not given
|
|
272
296
|
def null_count
|
|
273
297
|
return statistics.null_count if statistics&.null_count
|
|
298
|
+
return nil if encrypted? && !crypto
|
|
274
299
|
counts = data_pages.map(&:num_nulls)
|
|
275
300
|
counts.all? ? counts.sum : nil
|
|
276
301
|
end
|
|
@@ -327,6 +352,7 @@ module Herringbone
|
|
|
327
352
|
offset_index_offset: offset_index_range&.first,
|
|
328
353
|
offset_index_length: offset_index_range&.last,
|
|
329
354
|
external_file: external_file,
|
|
355
|
+
encryption: encryption,
|
|
330
356
|
num_pages: pages.size,
|
|
331
357
|
num_data_pages: data_pages.size,
|
|
332
358
|
index_mismatches: index_mismatches.empty? ? nil : index_mismatches,
|
|
@@ -397,10 +423,10 @@ module Herringbone
|
|
|
397
423
|
def uncompressed_size = @columns.sum { |c| c.uncompressed_size.to_i }
|
|
398
424
|
|
|
399
425
|
# @return [Integer, nil] lowest start offset of the row group's chunks; nil without chunks
|
|
400
|
-
def start_offset = @columns.
|
|
426
|
+
def start_offset = @columns.filter_map(&:start_offset).min
|
|
401
427
|
|
|
402
428
|
# @return [Integer, nil] highest end offset of the row group's chunks; nil without chunks
|
|
403
|
-
def end_offset = @columns.
|
|
429
|
+
def end_offset = @columns.filter_map(&:end_offset).max
|
|
404
430
|
|
|
405
431
|
# @param path [String, Array<String>] dotted path (+"a.b"+) or path segments (+["a", "b"]+)
|
|
406
432
|
# @return [ColumnChunkInfo, nil] the chunk of that column, nil when there is none
|
|
@@ -447,12 +473,19 @@ module Herringbone
|
|
|
447
473
|
|
|
448
474
|
# +io+ is a random-access IO (responds to #seek and #read, e.g. File.open(path, "rb")). It is
|
|
449
475
|
# left open.
|
|
476
|
+
#
|
|
477
|
+
# An encrypted file needs +decryption:+ (see Reader.new) when its footer is encrypted. With a
|
|
478
|
+
# plaintext footer it opens without keys, and the chunks whose key is missing are shown
|
|
479
|
+
# without their pages, page indexes and statistics.
|
|
480
|
+
#
|
|
450
481
|
# @param io [IO] random-access IO positioned anywhere; only the footer is read here
|
|
482
|
+
# @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted file, see Reader.new
|
|
451
483
|
# @raise [ArgumentError] when +io+ does not support #seek and #read
|
|
452
484
|
# @raise [FormatError] when the file is too small, lacks the magic bytes or has a corrupt footer
|
|
453
|
-
# @raise [
|
|
454
|
-
def initialize(io)
|
|
485
|
+
# @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
|
|
486
|
+
def initialize(io, decryption: nil)
|
|
455
487
|
@io = io
|
|
488
|
+
@decryption = decryption.nil? ? nil : DecryptionConfiguration.from(decryption)
|
|
456
489
|
unless @io.respond_to?(:read) && @io.respond_to?(:seek)
|
|
457
490
|
raise ArgumentError, "Herringbone::Inspector expects an IO that supports #seek and #read " \
|
|
458
491
|
"(e.g. File.open(path, \"rb\")), got #{io.class}"
|
|
@@ -510,6 +543,12 @@ module Herringbone
|
|
|
510
543
|
# @return [Boolean] whether any column chunk has a bloom filter
|
|
511
544
|
def bloom_filters? = column_chunks.any?(&:bloom_filter_offset)
|
|
512
545
|
|
|
546
|
+
# How the file is encrypted (see Reader#encryption), nil for a file that is not
|
|
547
|
+
# @return [Hash{Symbol => Object}, nil] +:algorithm+, +:footer+, +:footer_key_metadata+,
|
|
548
|
+
# +:aad_prefix+, +:supply_aad_prefix+, +:footer_verified+ and +:columns+ (path => { key:,
|
|
549
|
+
# key_metadata:, readable: } for the encrypted columns of the first row group)
|
|
550
|
+
def encryption = @decryptor&.describe(@schema, @metadata.row_groups&.first&.columns || [])
|
|
551
|
+
|
|
513
552
|
# The file-level key/value metadata, each entry described: ARROW:schema is decoded, JSON
|
|
514
553
|
# values are parsed (pandas metadata summarized), binary values are shown as hex
|
|
515
554
|
# @return [Array<Hash{Symbol => Object}>] each with :key, :bytesize, :format ("arrow_schema",
|
|
@@ -531,7 +570,7 @@ module Herringbone
|
|
|
531
570
|
# Walks no page headers.
|
|
532
571
|
# @return [Hash{Symbol => Object}]
|
|
533
572
|
def summary
|
|
534
|
-
codecs = column_chunks.
|
|
573
|
+
codecs = column_chunks.filter_map(&:codec).uniq
|
|
535
574
|
{
|
|
536
575
|
name: @name,
|
|
537
576
|
file_size: @file_size,
|
|
@@ -547,7 +586,8 @@ module Herringbone
|
|
|
547
586
|
uncompressed_size: column_chunks.sum { |c| c.uncompressed_size.to_i },
|
|
548
587
|
page_index: page_index?,
|
|
549
588
|
bloom_filters: bloom_filters?,
|
|
550
|
-
column_orders: column_orders
|
|
589
|
+
column_orders: column_orders,
|
|
590
|
+
encryption: encryption
|
|
551
591
|
}.tap { |h| h[:checksums] = checksum_summary.except(:mismatches) if checksums_verified? }
|
|
552
592
|
end
|
|
553
593
|
|
|
@@ -656,7 +696,7 @@ module Herringbone
|
|
|
656
696
|
column: col.index,
|
|
657
697
|
path: col.dotted_path,
|
|
658
698
|
type: Inspector.type_name(col),
|
|
659
|
-
codecs: chunks.
|
|
699
|
+
codecs: chunks.filter_map(&:codec).uniq,
|
|
660
700
|
encodings: chunks.flat_map(&:encodings).uniq,
|
|
661
701
|
num_values: chunks.sum { |c| c.num_values.to_i },
|
|
662
702
|
null_count: nulls.all? ? nulls.sum : nil,
|
|
@@ -668,7 +708,8 @@ module Herringbone
|
|
|
668
708
|
dictionary_pages: chunks.count(&:dictionary_page),
|
|
669
709
|
dictionary_bytes: chunks.sum { |c| c.dictionary_page&.total_size.to_i },
|
|
670
710
|
min: mins.include?(nil) ? nil : safe_extreme(mins, :min),
|
|
671
|
-
max: maxes.include?(nil) ? nil : safe_extreme(maxes, :max)
|
|
711
|
+
max: maxes.include?(nil) ? nil : safe_extreme(maxes, :max),
|
|
712
|
+
metadata_unavailable: (!chunks.empty? && chunks.none?(&:start_offset)) || nil
|
|
672
713
|
}.compact
|
|
673
714
|
end
|
|
674
715
|
end
|
|
@@ -685,6 +726,7 @@ module Herringbone
|
|
|
685
726
|
rg = c.row_group.index
|
|
686
727
|
col = c.column.index
|
|
687
728
|
if c.pages.empty?
|
|
729
|
+
next unless c.start_offset
|
|
688
730
|
segs << {kind: :chunk, start: c.start_offset, length: c.compressed_size, row_group: rg, column: col}
|
|
689
731
|
else
|
|
690
732
|
c.pages.each_with_index do |p, i|
|
|
@@ -711,7 +753,7 @@ module Herringbone
|
|
|
711
753
|
# right after the chunk, at ColumnChunk.file_offset
|
|
712
754
|
meta_copies = column_chunks.each_with_object({}) do |c, h|
|
|
713
755
|
fo = c.chunk.file_offset
|
|
714
|
-
h[fo] = c if fo && fo >= c.end_offset
|
|
756
|
+
h[fo] = c if fo && c.end_offset && fo >= c.end_offset
|
|
715
757
|
end
|
|
716
758
|
out = []
|
|
717
759
|
pos = 0
|
|
@@ -766,6 +808,17 @@ module Herringbone
|
|
|
766
808
|
out << "codecs: #{s[:codecs].join(", ")}; data #{Inspector.human_bytes(s[:compressed_size])} compressed, " \
|
|
767
809
|
"#{Inspector.human_bytes(s[:uncompressed_size])} uncompressed#{ratio_text(s[:uncompressed_size], s[:compressed_size])}"
|
|
768
810
|
out << "page index: #{s[:page_index] ? "yes" : "no"}, bloom filters: #{s[:bloom_filters] ? "yes" : "no"}"
|
|
811
|
+
if (e = s[:encryption])
|
|
812
|
+
out << "encryption: #{e[:algorithm]}, #{e[:footer]} footer" \
|
|
813
|
+
"#{" (signature verified)" if e[:footer_verified]}" \
|
|
814
|
+
"#{", footer key metadata #{Inspector.display(e[:footer_key_metadata])}" if e[:footer_key_metadata]}" \
|
|
815
|
+
"#{", AAD prefix #{Inspector.display(e[:aad_prefix])}" if e[:aad_prefix]}" \
|
|
816
|
+
"#{", AAD prefix not stored" if e[:supply_aad_prefix]}"
|
|
817
|
+
e[:columns].each do |path, c|
|
|
818
|
+
out << " #{path}: #{c[:key]} key#{" #{Inspector.display(c[:key_metadata])}" if c[:key_metadata]}" \
|
|
819
|
+
"#{" (no key given)" unless c[:readable]}"
|
|
820
|
+
end
|
|
821
|
+
end
|
|
769
822
|
if (cs = checksum_summary)
|
|
770
823
|
out << "page CRCs: #{cs[:ok]} ok, #{cs[:mismatch]} mismatched, #{cs[:absent]} without a CRC"
|
|
771
824
|
cs[:mismatches].each do |m|
|
|
@@ -800,6 +853,10 @@ module Herringbone
|
|
|
800
853
|
schema_tree.each { |n| walk.call(n, 1) }
|
|
801
854
|
out << "columns:"
|
|
802
855
|
column_totals.each do |t|
|
|
856
|
+
if t[:metadata_unavailable]
|
|
857
|
+
out << " #{t[:path]}: #{t[:type]} encrypted, metadata unavailable without its key"
|
|
858
|
+
next
|
|
859
|
+
end
|
|
803
860
|
range = t.key?(:min) ? " [#{Inspector.display(t[:min])} .. #{Inspector.display(t[:max])}]" : ""
|
|
804
861
|
out << " #{t[:path]}: #{t[:type]} #{t[:codecs].join(",")} #{t[:encodings].join(",")} " \
|
|
805
862
|
"#{Inspector.human_bytes(t[:compressed_size])}/#{Inspector.human_bytes(t[:uncompressed_size])}" \
|
|
@@ -818,7 +875,12 @@ module Herringbone
|
|
|
818
875
|
extras << "column index" if c.column_index_range
|
|
819
876
|
extras << "offset index" if c.offset_index_range
|
|
820
877
|
extras << "bloom filter" if c.bloom_filter_offset
|
|
878
|
+
extras << "encrypted" if c.encrypted?
|
|
821
879
|
extras << "ERROR: #{c.error}" if c.error
|
|
880
|
+
unless c.start_offset
|
|
881
|
+
out << " #{c.path}: encrypted, metadata and pages unavailable without its key"
|
|
882
|
+
next
|
|
883
|
+
end
|
|
822
884
|
out << " #{c.path}: #{c.codec} #{c.encodings.join(",")} " \
|
|
823
885
|
"#{c.compressed_size}/#{c.uncompressed_size} bytes#{ratio_text(c.uncompressed_size, c.compressed_size)}, " \
|
|
824
886
|
"#{c.num_values} values, #{c.pages.size} pages#{", #{extras.join(", ")}" unless extras.empty?}#{range}"
|
|
@@ -842,6 +904,15 @@ module Herringbone
|
|
|
842
904
|
|
|
843
905
|
# ---- used by the info objects ----
|
|
844
906
|
|
|
907
|
+
# Decryption of an encrypted chunk's modules
|
|
908
|
+
# @param chunk [ColumnChunkInfo] an encrypted chunk
|
|
909
|
+
# @return [Encryption::ModuleCrypto, nil] nil when the chunk's key was not given
|
|
910
|
+
def chunk_crypto(chunk)
|
|
911
|
+
@decryptor&.chunk(chunk.row_group.index, chunk.row_group.row_group.ordinal, chunk.column, chunk.chunk)
|
|
912
|
+
rescue DecryptionError
|
|
913
|
+
nil
|
|
914
|
+
end
|
|
915
|
+
|
|
845
916
|
# Walks page headers from the chunk's first page. Returns [pages, error_message_or_nil].
|
|
846
917
|
# Mirrors the reader's tolerance: a chunk may extend past its declared total_compressed_size.
|
|
847
918
|
# @param chunk [ColumnChunkInfo] chunk whose pages to walk
|
|
@@ -849,6 +920,10 @@ module Herringbone
|
|
|
849
920
|
# the walk stopped early (nil when every value was accounted for)
|
|
850
921
|
def walk_pages(chunk)
|
|
851
922
|
return [[], "column chunk stored in external file #{chunk.external_file}"] if chunk.external_file
|
|
923
|
+
if chunk.encrypted?
|
|
924
|
+
return [[], "encrypted, and its key was not given"] unless chunk.crypto
|
|
925
|
+
return [[], "encrypted, and its metadata with it"] unless chunk.start_offset
|
|
926
|
+
end
|
|
852
927
|
pages = []
|
|
853
928
|
pos = chunk.start_offset
|
|
854
929
|
limit = footer_offset
|
|
@@ -860,9 +935,13 @@ module Herringbone
|
|
|
860
935
|
while pos < limit && (seen < total || pos < declared_end)
|
|
861
936
|
trailing = seen >= total
|
|
862
937
|
begin
|
|
863
|
-
header, header_size =
|
|
938
|
+
header, header_size = if chunk.crypto
|
|
939
|
+
read_encrypted_page_header(chunk, pos, limit, pages)
|
|
940
|
+
else
|
|
941
|
+
read_page_header(pos, limit)
|
|
942
|
+
end
|
|
864
943
|
page = page_info(pages.size, header, pos, header_size, chunk.column)
|
|
865
|
-
rescue Thrift::Error, FormatError => e
|
|
944
|
+
rescue Thrift::Error, FormatError, DecryptionError => e
|
|
866
945
|
break if trailing
|
|
867
946
|
return [pages, "corrupt page header at #{pos}: #{e.message}"]
|
|
868
947
|
end
|
|
@@ -883,7 +962,8 @@ module Herringbone
|
|
|
883
962
|
def read_column_index(chunk)
|
|
884
963
|
offset, length = chunk.column_index_range
|
|
885
964
|
return nil unless offset && length.positive?
|
|
886
|
-
|
|
965
|
+
bytes = read_module(chunk, offset, length, Encryption::COLUMN_INDEX) or return nil
|
|
966
|
+
ci = Format::ColumnIndex.decode(bytes).first
|
|
887
967
|
col = chunk.column
|
|
888
968
|
mins = ci.min_values || []
|
|
889
969
|
maxes = ci.max_values || []
|
|
@@ -898,7 +978,7 @@ module Herringbone
|
|
|
898
978
|
repetition_level_histograms: ci.repetition_level_histograms,
|
|
899
979
|
definition_level_histograms: ci.definition_level_histograms
|
|
900
980
|
)
|
|
901
|
-
rescue Thrift::Error
|
|
981
|
+
rescue Thrift::Error, FormatError, DecryptionError
|
|
902
982
|
nil
|
|
903
983
|
end
|
|
904
984
|
|
|
@@ -908,7 +988,8 @@ module Herringbone
|
|
|
908
988
|
def read_offset_index(chunk)
|
|
909
989
|
offset, length = chunk.offset_index_range
|
|
910
990
|
return nil unless offset && length.positive?
|
|
911
|
-
|
|
991
|
+
bytes = read_module(chunk, offset, length, Encryption::OFFSET_INDEX) or return nil
|
|
992
|
+
oi = Format::OffsetIndex.decode(bytes).first
|
|
912
993
|
OffsetIndexInfo.new(
|
|
913
994
|
offset: offset, length: length,
|
|
914
995
|
page_locations: (oi.page_locations || []).map do |l|
|
|
@@ -916,14 +997,21 @@ module Herringbone
|
|
|
916
997
|
end,
|
|
917
998
|
unencoded_byte_array_data_bytes: oi.unencoded_byte_array_data_bytes
|
|
918
999
|
)
|
|
919
|
-
rescue Thrift::Error
|
|
1000
|
+
rescue Thrift::Error, FormatError, DecryptionError
|
|
920
1001
|
nil
|
|
921
1002
|
end
|
|
922
1003
|
|
|
923
|
-
# Size in bytes of the bloom filter at +offset+ (its Thrift header plus the bitset)
|
|
1004
|
+
# Size in bytes of the bloom filter at +offset+ (its Thrift header plus the bitset). An
|
|
1005
|
+
# encrypted filter is two modules, whose lengths are stored in the clear.
|
|
924
1006
|
# @param offset [Integer] file offset of the BloomFilterHeader
|
|
1007
|
+
# @param encrypted [Boolean] whether the filter is encrypted
|
|
925
1008
|
# @return [Integer, nil] nil when the header can't be decoded or has no num_bytes
|
|
926
|
-
def bloom_filter_size(offset)
|
|
1009
|
+
def bloom_filter_size(offset, encrypted = false)
|
|
1010
|
+
if encrypted
|
|
1011
|
+
header = read_at(offset, 4).unpack1("V") or return nil
|
|
1012
|
+
bitset = read_at(offset + 4 + header, 4).unpack1("V") or return nil
|
|
1013
|
+
return 8 + header + bitset
|
|
1014
|
+
end
|
|
927
1015
|
buf = read_at(offset, 64)
|
|
928
1016
|
header, size = BloomFilterHeader.decode(buf)
|
|
929
1017
|
header.num_bytes ? size + header.num_bytes : nil
|
|
@@ -1617,19 +1705,25 @@ module Herringbone
|
|
|
1617
1705
|
end
|
|
1618
1706
|
|
|
1619
1707
|
# Reads the file size, the footer length and magic, and decodes the FileMetaData into @metadata
|
|
1708
|
+
# (decrypting it, or checking its signature, in an encrypted file)
|
|
1620
1709
|
# @return [void]
|
|
1621
1710
|
# @raise [FormatError] when the file is too small, lacks the magic bytes or has a corrupt footer
|
|
1622
|
-
# @raise [
|
|
1711
|
+
# @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
|
|
1623
1712
|
def read_footer
|
|
1624
1713
|
@io.seek(0, IO::SEEK_END)
|
|
1625
1714
|
@file_size = @io.pos
|
|
1626
1715
|
raise FormatError, "File too small to be Parquet (#{@file_size} bytes)" if @file_size < 12
|
|
1627
1716
|
tail = read_at(@file_size - 8, 8)
|
|
1628
|
-
|
|
1629
|
-
raise FormatError, "Missing PAR1 footer magic" unless
|
|
1717
|
+
magic = tail.byteslice(4, 4)
|
|
1718
|
+
raise FormatError, "Missing PAR1 footer magic" unless magic == MAGIC || magic == Reader::ENCRYPTED_MAGIC
|
|
1630
1719
|
@footer_size = tail.unpack1("V")
|
|
1631
1720
|
raise FormatError, "Footer length #{@footer_size} exceeds file size" if @footer_size + 12 > @file_size
|
|
1632
|
-
|
|
1721
|
+
footer = read_at(footer_offset, @footer_size)
|
|
1722
|
+
if magic == MAGIC
|
|
1723
|
+
@metadata = Format::FileMetaData.decode(footer).first
|
|
1724
|
+
return unless @metadata.encryption_algorithm
|
|
1725
|
+
end
|
|
1726
|
+
@metadata, @decryptor = Encryption.read_footer(footer, magic, @decryption)
|
|
1633
1727
|
rescue Thrift::Error => e
|
|
1634
1728
|
raise FormatError, "Corrupt file metadata: #{e.message}"
|
|
1635
1729
|
end
|
|
@@ -1653,6 +1747,42 @@ module Herringbone
|
|
|
1653
1747
|
end
|
|
1654
1748
|
end
|
|
1655
1749
|
|
|
1750
|
+
# A module of an encrypted chunk, decrypted
|
|
1751
|
+
# @param chunk [ColumnChunkInfo] the chunk the module belongs to
|
|
1752
|
+
# @param offset [Integer] file offset of the module
|
|
1753
|
+
# @param length [Integer] its length, length prefix included
|
|
1754
|
+
# @param type [Integer] module type
|
|
1755
|
+
# @return [String, nil] the plaintext (the bytes as stored for a plaintext chunk), nil when the
|
|
1756
|
+
# chunk's key was not given
|
|
1757
|
+
# @raise [DecryptionError] when the module does not decrypt
|
|
1758
|
+
def read_module(chunk, offset, length, type)
|
|
1759
|
+
bytes = read_at(offset, length)
|
|
1760
|
+
return bytes unless chunk.encrypted?
|
|
1761
|
+
chunk.crypto&.decrypt(type, bytes)
|
|
1762
|
+
end
|
|
1763
|
+
|
|
1764
|
+
# Decrypts and decodes the encrypted page header at +pos+. Its AAD depends on whether it is the
|
|
1765
|
+
# dictionary page's (the first page, when the chunk has a dictionary) and on the number of
|
|
1766
|
+
# data pages before it.
|
|
1767
|
+
# @param chunk [ColumnChunkInfo] the chunk, whose key is available
|
|
1768
|
+
# @param pos [Integer] file offset of the header module
|
|
1769
|
+
# @param limit [Integer] offset the header must not extend past (the footer's start)
|
|
1770
|
+
# @param pages [Array<PageInfo>] the chunk's pages before this one
|
|
1771
|
+
# @return [Array(Format::PageHeader, Integer)] the header and the module's size in bytes
|
|
1772
|
+
# @raise [FormatError] when the module does not fit before +limit+
|
|
1773
|
+
# @raise [DecryptionError] when it does not decrypt
|
|
1774
|
+
def read_encrypted_page_header(chunk, pos, limit, pages)
|
|
1775
|
+
raise FormatError, "no room for a page header" if pos + 4 > limit
|
|
1776
|
+
size = read_at(pos, 4).unpack1("V") + 4
|
|
1777
|
+
raise FormatError, "encrypted page header overruns the data section" if pos + size > limit
|
|
1778
|
+
plain = if pages.empty? && chunk.dictionary_page_offset == pos
|
|
1779
|
+
chunk.crypto.decrypt(Encryption::DICTIONARY_PAGE_HEADER, read_at(pos, size))
|
|
1780
|
+
else
|
|
1781
|
+
chunk.crypto.decrypt(Encryption::DATA_PAGE_HEADER, read_at(pos, size), pages.count(&:data?))
|
|
1782
|
+
end
|
|
1783
|
+
[Format::PageHeader.decode(plain).first, size]
|
|
1784
|
+
end
|
|
1785
|
+
|
|
1656
1786
|
# Decodes the page header at +pos+, reading more bytes when it is larger than the first guess
|
|
1657
1787
|
# (page statistics of long strings can make headers big)
|
|
1658
1788
|
# @param pos [Integer] file offset of the header
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Herringbone
|
|
6
|
+
# An encryption key and its id. The id is stored in the files the key encrypts (in the clear),
|
|
7
|
+
# so a reader holding several keys can pick the right one; without an id of your own it is a
|
|
8
|
+
# fingerprint of the key (an HMAC, which reveals nothing about the key), the same every time.
|
|
9
|
+
#
|
|
10
|
+
# key = Herringbone::Key.generate # random AES-256 key, fingerprint id
|
|
11
|
+
# key = Herringbone::Key.from_hex(ENV["PARQUET_KEY"]) # 32 or 64 hex digits
|
|
12
|
+
# key = Herringbone::Key.new(bytes, id: "2026-10")
|
|
13
|
+
#
|
|
14
|
+
# Herringbone.write(io, rows, encryption: key)
|
|
15
|
+
# Herringbone::Reader.new(io, decryption: key) # or [new_key, old_key, ...]
|
|
16
|
+
#
|
|
17
|
+
# Where a key may be given as a String instead (+encryption:+, +decryption:+,
|
|
18
|
+
# SimpleWriter#encrypt!), the String must be its hex: raw bytes are easy to mangle and to confuse
|
|
19
|
+
# with text, so they go through Key.new.
|
|
20
|
+
#
|
|
21
|
+
# Instances are frozen; #inspect and #to_s show the id but not the key.
|
|
22
|
+
class Key
|
|
23
|
+
# Message the fingerprint id is an HMAC of
|
|
24
|
+
FINGERPRINT_MESSAGE = "herringbone key id"
|
|
25
|
+
|
|
26
|
+
# @return [String] the id stored in files encrypted with the key
|
|
27
|
+
attr_reader :id
|
|
28
|
+
|
|
29
|
+
# @return [String] the key material, 16, 24 or 32 bytes (binary)
|
|
30
|
+
attr_reader :bytes
|
|
31
|
+
|
|
32
|
+
# @param id [String, nil] the id; nil for the key's fingerprint
|
|
33
|
+
# @param bits [Integer] 128, 192 or 256
|
|
34
|
+
# @return [Key] a random key
|
|
35
|
+
# @raise [ArgumentError] for another size
|
|
36
|
+
def self.generate(id: nil, bits: 256)
|
|
37
|
+
raise ArgumentError, "Key.generate: bits must be 128, 192 or 256, got #{bits.inspect}" unless [128, 192, 256].include?(bits)
|
|
38
|
+
new(OpenSSL::Random.random_bytes(bits / 8), id: id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param hex [String] 32, 48 or 64 hex digits
|
|
42
|
+
# @param id [String, nil] the id; nil for the key's fingerprint
|
|
43
|
+
# @return [Key]
|
|
44
|
+
# @raise [ArgumentError] when +hex+ is not hex, or not of a key's length
|
|
45
|
+
def self.from_hex(hex, id: nil)
|
|
46
|
+
hex = hex.to_s.strip
|
|
47
|
+
raise ArgumentError, "Key.from_hex: expected 32, 48 or 64 hex digits, got #{hex.size} characters" unless hex.match?(/\A(\h\h)+\z/)
|
|
48
|
+
new([hex].pack("H*"), id: id)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A Key, or a key given as its hex (with the fingerprint id): 32, 48 or 64 hex digits, as
|
|
52
|
+
# +Key#hex+ gives them and as keys usually sit in environment variables. Any other String is
|
|
53
|
+
# refused, raw key bytes included: those go through Key.new, so a key is never guessed at.
|
|
54
|
+
#
|
|
55
|
+
# @param value [Key, String] a key, or its hex
|
|
56
|
+
# @return [Key]
|
|
57
|
+
# @raise [ArgumentError] for anything else
|
|
58
|
+
def self.from(value)
|
|
59
|
+
return value if value.is_a?(Key)
|
|
60
|
+
unless value.is_a?(String)
|
|
61
|
+
raise ArgumentError, "Expected a Herringbone::Key or the hex of a key, got #{value.class}"
|
|
62
|
+
end
|
|
63
|
+
hex = value.strip
|
|
64
|
+
return from_hex(hex) if hex.match?(/\A(?:\h{32}|\h{48}|\h{64})\z/)
|
|
65
|
+
got = if hex.match?(/\A\h*\z/) then "#{hex.size} hex digits"
|
|
66
|
+
elsif Encryption::KEY_SIZES.include?(value.bytesize) then "#{value.bytesize} bytes that are not hex (raw key bytes?)"
|
|
67
|
+
else "a #{value.bytesize}-byte String that is not hex"
|
|
68
|
+
end
|
|
69
|
+
raise ArgumentError, "Give a key as 32 or 64 hex digits (Herringbone::Key#hex) or as a Herringbone::Key " \
|
|
70
|
+
"(Herringbone::Key.new(bytes) for raw bytes), got #{got}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param bytes [String] 16, 24 or 32 bytes (AES-128, 192 or 256)
|
|
74
|
+
# @return [String] the fingerprint of +bytes+: 16 hex digits of an HMAC-SHA256 keyed with them
|
|
75
|
+
def self.fingerprint(bytes)
|
|
76
|
+
OpenSSL::HMAC.digest("SHA256", bytes, FINGERPRINT_MESSAGE).unpack1("H16")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param bytes [String] 16, 24 or 32 bytes (AES-128, 192 or 256); +Key.generate+ makes one
|
|
80
|
+
# @param id [String, nil] the id; nil for the key's fingerprint
|
|
81
|
+
# @raise [ArgumentError] when +bytes+ is not 16, 24 or 32 bytes long, or +id+ is empty
|
|
82
|
+
def initialize(bytes, id: nil)
|
|
83
|
+
@bytes = Encryption.check_key!(bytes, "Key").freeze
|
|
84
|
+
@id = (id.nil? ? Key.fingerprint(@bytes) : id.to_s).dup.freeze
|
|
85
|
+
raise ArgumentError, "Key: id must not be empty" if @id.empty?
|
|
86
|
+
freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [String] the key material as hex
|
|
90
|
+
def hex = @bytes.unpack1("H*")
|
|
91
|
+
|
|
92
|
+
# @return [Integer] 128, 192 or 256
|
|
93
|
+
def bits = @bytes.bytesize * 8
|
|
94
|
+
|
|
95
|
+
# @param other [Object] object to compare with
|
|
96
|
+
# @return [Boolean] whether +other+ is a Key with the same id and bytes
|
|
97
|
+
def ==(other) = other.is_a?(Key) && other.id == @id && other.bytes == @bytes
|
|
98
|
+
alias_method :eql?, :==
|
|
99
|
+
|
|
100
|
+
# @return [Integer] hash of the id and bytes
|
|
101
|
+
def hash = [Key, @id, @bytes].hash
|
|
102
|
+
|
|
103
|
+
# @return [String] the id and size, without the key
|
|
104
|
+
def inspect = "#<#{self.class.name} id=#{@id.inspect} AES-#{bits}>"
|
|
105
|
+
alias_method :to_s, :inspect
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herringbone
|
|
4
|
+
class Reader
|
|
5
|
+
# Internal (used by reads with where:): the bloom filter of a column chunk. +column+ is a
|
|
6
|
+
# dotted path ("a.b"), an Array path or a Schema::Column. Returns a BloomFilter, or nil when
|
|
7
|
+
# the chunk has none (or one of an unknown kind).
|
|
8
|
+
#
|
|
9
|
+
# @param row_group_index [Integer] index of the row group
|
|
10
|
+
# @param column [String, Array<String, Symbol>, Symbol, Schema::Column] the leaf column
|
|
11
|
+
# @return [BloomFilter, nil] the filter, or nil when there is none or it is unsupported
|
|
12
|
+
# @raise [IndexError] when there is no such row group
|
|
13
|
+
# @raise [ArgumentError] when there is no such column
|
|
14
|
+
# @raise [FormatError] when the filter is truncated or its header cannot be decoded
|
|
15
|
+
# @raise [DecryptionError] when the column is encrypted and its key was not given, or the
|
|
16
|
+
# filter does not decrypt
|
|
17
|
+
def bloom_filter(row_group_index, column)
|
|
18
|
+
col = bloom_filter_column(column)
|
|
19
|
+
rg = row_groups.fetch(row_group_index) { raise IndexError, "No row group #{row_group_index}" }
|
|
20
|
+
# Raises for an encrypted column without its key, whose metadata may be encrypted too
|
|
21
|
+
crypto = chunk_crypto(row_group_index, col)
|
|
22
|
+
meta = rg.columns.fetch(col.index).meta_data
|
|
23
|
+
offset = meta&.bloom_filter_offset
|
|
24
|
+
return nil unless offset
|
|
25
|
+
return encrypted_bloom_filter(offset, col, crypto) if crypto
|
|
26
|
+
length = meta.bloom_filter_length
|
|
27
|
+
@io.seek(offset)
|
|
28
|
+
if length
|
|
29
|
+
buf = @io.read(length)
|
|
30
|
+
raise FormatError, "Truncated bloom filter" if buf.nil? || buf.bytesize < length
|
|
31
|
+
return BloomFilter.decode(buf, column: col)
|
|
32
|
+
end
|
|
33
|
+
# Without a length (older writers), read the header first, then the bitset it announces
|
|
34
|
+
head = @io.read(256) || "".b
|
|
35
|
+
reader = Thrift::Reader.new(head)
|
|
36
|
+
header = reader.read_struct(Format::BloomFilterHeader)
|
|
37
|
+
return nil unless BloomFilter.supported_header?(header)
|
|
38
|
+
@io.seek(offset + reader.pos)
|
|
39
|
+
bitset = @io.read(header.num_bytes)
|
|
40
|
+
raise FormatError, "Truncated bloom filter" if bitset.nil? || bitset.bytesize != header.num_bytes
|
|
41
|
+
BloomFilter.new(bitset: bitset, column: col)
|
|
42
|
+
rescue Thrift::Error => e
|
|
43
|
+
raise FormatError, "Corrupt bloom filter header for #{col.dotted_path}: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# An encrypted bloom filter: the header and the bitset are two modules, one after the other
|
|
49
|
+
#
|
|
50
|
+
# @param offset [Integer] file offset of the header module
|
|
51
|
+
# @param col [Schema::Column] the leaf column
|
|
52
|
+
# @param crypto [Encryption::ModuleCrypto] decryption of the chunk's modules
|
|
53
|
+
# @return [BloomFilter, nil] nil when the filter is of an unsupported kind
|
|
54
|
+
# @raise [FormatError] when a module is truncated
|
|
55
|
+
# @raise [DecryptionError] when a module does not decrypt
|
|
56
|
+
def encrypted_bloom_filter(offset, col, crypto)
|
|
57
|
+
header_module = read_module_at(offset)
|
|
58
|
+
header = Format::BloomFilterHeader.decode(crypto.decrypt(Encryption::BLOOM_FILTER_HEADER, header_module)).first
|
|
59
|
+
return nil unless BloomFilter.supported_header?(header)
|
|
60
|
+
bitset = crypto.decrypt(Encryption::BLOOM_FILTER_BITSET, read_module_at(offset + header_module.bytesize))
|
|
61
|
+
raise FormatError, "Truncated bloom filter" unless bitset.bytesize == header.num_bytes
|
|
62
|
+
BloomFilter.new(bitset: bitset, column: col)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param offset [Integer] file offset of an encrypted module
|
|
66
|
+
# @return [String] the module, length prefix included
|
|
67
|
+
# @raise [FormatError] when it is cut off
|
|
68
|
+
def read_module_at(offset)
|
|
69
|
+
@io.seek(offset)
|
|
70
|
+
prefix = @io.read(4)
|
|
71
|
+
raise FormatError, "Truncated bloom filter" unless prefix&.bytesize == 4
|
|
72
|
+
len = prefix.unpack1("V")
|
|
73
|
+
body = @io.read(len)
|
|
74
|
+
raise FormatError, "Truncated bloom filter" unless body&.bytesize == len
|
|
75
|
+
prefix.b << body
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Resolves the +column+ argument of #bloom_filter to a leaf column
|
|
79
|
+
#
|
|
80
|
+
# @param column [String, Array<String, Symbol>, Symbol, Schema::Column] dotted path, path
|
|
81
|
+
# Array or column
|
|
82
|
+
# @return [Schema::Column] the column
|
|
83
|
+
# @raise [ArgumentError] when the schema has no such column
|
|
84
|
+
def bloom_filter_column(column)
|
|
85
|
+
return column if column.is_a?(Schema::Column)
|
|
86
|
+
col = schema.column(column.is_a?(Array) ? column.map(&:to_s) : column.to_s)
|
|
87
|
+
raise ArgumentError, "No column #{column.inspect}" unless col
|
|
88
|
+
col
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|