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
data/bin/herringbone
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
# and open it in the browser (printed when stdout is redirected)
|
|
11
11
|
# add --verify-checksums to any inspect form to read page bodies and check their CRCs
|
|
12
12
|
# `inspect` reads only the footer, page headers and page indexes: no values are decompressed.
|
|
13
|
+
#
|
|
14
|
+
# Encrypted files: both commands take --key=KEY (a key matched by its fingerprint id, or used for
|
|
15
|
+
# the footer when it is the only one), --key=KEY_ID=KEY, --footer-key=KEY and
|
|
16
|
+
# --column-key=PATH=KEY (all repeatable but --footer-key), and --aad-prefix=PREFIX. KEY is hex, or
|
|
17
|
+
# base64:..., or raw:... for the bytes as typed (a 16, 24 or 32-character String that is not
|
|
18
|
+
# valid hex is taken as is too). Keys that are needed but not given are asked for on stdin
|
|
19
|
+
# (without echo in a terminal; an empty answer skips the key), unless --no-prompt is given.
|
|
13
20
|
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
14
21
|
require "herringbone"
|
|
15
22
|
require "json"
|
|
@@ -18,6 +25,7 @@ require "json"
|
|
|
18
25
|
rescue LoadError
|
|
19
26
|
end
|
|
20
27
|
require "tmpdir"
|
|
28
|
+
require "io/console"
|
|
21
29
|
|
|
22
30
|
def jsonable(v)
|
|
23
31
|
case v
|
|
@@ -43,12 +51,81 @@ def open_with_default_app(path)
|
|
|
43
51
|
!!system(*command, out: File::NULL, err: File::NULL, exception: false)
|
|
44
52
|
end
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
# A key as given on the command line or at the prompt: hex, base64:..., raw:..., or the bytes as
|
|
55
|
+
# typed when they are not valid hex but have the size of a key
|
|
56
|
+
# @return [String] the key
|
|
57
|
+
# @raise [ArgumentError] when it is none of those, or not 16, 24 or 32 bytes long
|
|
58
|
+
def parse_key(text, what)
|
|
59
|
+
text = text.strip
|
|
60
|
+
bytes = if text.start_with?("base64:")
|
|
61
|
+
text.delete_prefix("base64:").unpack1("m0")
|
|
62
|
+
elsif text.start_with?("raw:")
|
|
63
|
+
text.delete_prefix("raw:")
|
|
64
|
+
elsif text.match?(/\A(\h\h)+\z/) && Herringbone::Encryption::KEY_SIZES.include?(text.size / 2)
|
|
65
|
+
[text].pack("H*")
|
|
66
|
+
else
|
|
67
|
+
text
|
|
68
|
+
end
|
|
69
|
+
Herringbone::Encryption.check_key!(bytes, what)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Asks for a key on stderr and reads it from stdin, without echo when stdin is a terminal
|
|
73
|
+
# @return [String, nil] the key, nil when the answer is empty or stdin is exhausted
|
|
74
|
+
def ask_key(metadata, owner)
|
|
75
|
+
what = (owner == :footer) ? "the footer" : "column #{owner}"
|
|
76
|
+
$stderr.print "Key for #{what}#{" (key metadata #{metadata.inspect})" if metadata}, hex: "
|
|
77
|
+
line = $stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets
|
|
78
|
+
$stderr.puts
|
|
79
|
+
return nil if line.nil? || line.strip.empty?
|
|
80
|
+
parse_key(line, "the key for #{what}")
|
|
81
|
+
rescue ArgumentError => e
|
|
82
|
+
raise unless $stdin.tty?
|
|
83
|
+
warn "herringbone: #{e.message}"
|
|
84
|
+
retry
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The decryption: option from the key flags
|
|
88
|
+
# @return [Hash{Symbol => Object}]
|
|
89
|
+
def decryption_from(key_flags, prompt)
|
|
90
|
+
by_metadata = {}
|
|
91
|
+
plain = []
|
|
92
|
+
decryption = {columns: {}}
|
|
93
|
+
key_flags.each do |flag|
|
|
94
|
+
name, value = flag.split("=", 2)
|
|
95
|
+
case name
|
|
96
|
+
when "--footer-key" then decryption[:footer_key] = parse_key(value.to_s, "--footer-key")
|
|
97
|
+
when "--aad-prefix" then decryption[:aad_prefix] = value.to_s
|
|
98
|
+
when "--key"
|
|
99
|
+
target, key = value.to_s.split("=", 2)
|
|
100
|
+
if key.nil? || value.start_with?("base64:", "raw:")
|
|
101
|
+
plain << Herringbone::Key.new(parse_key(value.to_s, "--key"))
|
|
102
|
+
by_metadata[plain.last.id] = plain.last.bytes
|
|
103
|
+
else
|
|
104
|
+
by_metadata[target] = parse_key(key, "--key=#{target}")
|
|
105
|
+
end
|
|
106
|
+
when "--column-key"
|
|
107
|
+
target, key = value.to_s.split("=", 2)
|
|
108
|
+
raise ArgumentError, "--column-key expects --column-key=PATH=KEY" unless key
|
|
109
|
+
decryption[:columns][target] = parse_key(key, "--column-key=#{target}")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
decryption[:footer_key] ||= plain.first.bytes if plain.size == 1
|
|
113
|
+
decryption[:keys] = ->(metadata, owner) { by_metadata.fetch(metadata) { prompt ? ask_key(metadata, owner) : nil } }
|
|
114
|
+
decryption
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
USAGE = "usage: herringbone cat FILE [N] [KEY OPTIONS]\n" \
|
|
118
|
+
" herringbone inspect FILE [--format=text|json|html] [--pages] [--verify-checksums] [KEY OPTIONS]\n" \
|
|
119
|
+
" (--pages only with --format=text)\n" \
|
|
120
|
+
"key options, for encrypted files: --key=KEY --key=KEY_ID=KEY --footer-key=KEY --column-key=PATH=KEY\n" \
|
|
121
|
+
" --aad-prefix=PREFIX --no-prompt (KEY is hex, base64:... or raw:...)"
|
|
122
|
+
|
|
123
|
+
KEY_FLAGS = %w[--footer-key --column-key --key --aad-prefix].freeze
|
|
49
124
|
|
|
50
125
|
command, *rest = ARGV
|
|
51
126
|
flags, args = rest.partition { |a| a.start_with?("--") }
|
|
127
|
+
key_flags, flags = flags.partition { |f| KEY_FLAGS.include?(f.split("=", 2).first) }
|
|
128
|
+
prompt = !flags.delete("--no-prompt")
|
|
52
129
|
path, limit = args
|
|
53
130
|
valid = case command
|
|
54
131
|
when "cat" then flags.empty? && path && (limit.nil? || limit.match?(/\A\d+\z/))
|
|
@@ -64,14 +141,15 @@ unless valid
|
|
|
64
141
|
exit 1
|
|
65
142
|
end
|
|
66
143
|
|
|
67
|
-
# Parquet and
|
|
144
|
+
# Parquet, file and key errors are reported in one line, without a backtrace
|
|
68
145
|
begin
|
|
146
|
+
decryption = decryption_from(key_flags, prompt)
|
|
69
147
|
File.open(path, "rb") do |io|
|
|
70
148
|
if command == "cat"
|
|
71
149
|
n = limit&.to_i
|
|
72
|
-
Herringbone::Reader.new(io).each_row(limit: n) { |row| puts JSON.generate(jsonable(row)) }
|
|
150
|
+
Herringbone::Reader.new(io, decryption: decryption).each_row(limit: n) { |row| puts JSON.generate(jsonable(row)) }
|
|
73
151
|
else
|
|
74
|
-
inspector = Herringbone::Inspector.new(io)
|
|
152
|
+
inspector = Herringbone::Inspector.new(io, decryption: decryption)
|
|
75
153
|
inspector.verify_checksums if flags.include?("--verify-checksums")
|
|
76
154
|
case format
|
|
77
155
|
when "json"
|
|
@@ -90,7 +168,8 @@ begin
|
|
|
90
168
|
end
|
|
91
169
|
end
|
|
92
170
|
end
|
|
93
|
-
rescue Herringbone::Error, SystemCallError => e
|
|
171
|
+
rescue Herringbone::Error, SystemCallError, ArgumentError => e
|
|
94
172
|
warn "herringbone: #{e.message}"
|
|
173
|
+
warn "herringbone: give keys with --footer-key=KEY, --column-key=PATH=KEY or --key=KEY_METADATA=KEY" if e.is_a?(Herringbone::DecryptionError)
|
|
95
174
|
exit 1
|
|
96
175
|
end
|
|
@@ -300,8 +300,6 @@ module Herringbone
|
|
|
300
300
|
"#<#{self.class.name} #{num_bytes} bytes#{" for #{@column.dotted_path}" if @column}>"
|
|
301
301
|
end
|
|
302
302
|
|
|
303
|
-
private
|
|
304
|
-
|
|
305
303
|
# Header for this filter: split block algorithm, XXH64 hash, uncompressed
|
|
306
304
|
#
|
|
307
305
|
# @return [Format::BloomFilterHeader] the header
|
|
@@ -314,57 +312,4 @@ module Herringbone
|
|
|
314
312
|
)
|
|
315
313
|
end
|
|
316
314
|
end
|
|
317
|
-
|
|
318
|
-
class Reader
|
|
319
|
-
# Internal (used by reads with where:): the bloom filter of a column chunk. +column+ is a
|
|
320
|
-
# dotted path ("a.b"), an Array path or a Schema::Column. Returns a BloomFilter, or nil when
|
|
321
|
-
# the chunk has none (or one of an unknown kind).
|
|
322
|
-
#
|
|
323
|
-
# @param row_group_index [Integer] index of the row group
|
|
324
|
-
# @param column [String, Array<String, Symbol>, Symbol, Schema::Column] the leaf column
|
|
325
|
-
# @return [BloomFilter, nil] the filter, or nil when there is none or it is unsupported
|
|
326
|
-
# @raise [IndexError] when there is no such row group
|
|
327
|
-
# @raise [ArgumentError] when there is no such column
|
|
328
|
-
# @raise [FormatError] when the filter is truncated or its header cannot be decoded
|
|
329
|
-
def bloom_filter(row_group_index, column)
|
|
330
|
-
col = bloom_filter_column(column)
|
|
331
|
-
rg = row_groups.fetch(row_group_index) { raise IndexError, "No row group #{row_group_index}" }
|
|
332
|
-
meta = rg.columns.fetch(col.index).meta_data
|
|
333
|
-
offset = meta&.bloom_filter_offset
|
|
334
|
-
return nil unless offset
|
|
335
|
-
length = meta.bloom_filter_length
|
|
336
|
-
@io.seek(offset)
|
|
337
|
-
if length
|
|
338
|
-
buf = @io.read(length)
|
|
339
|
-
raise FormatError, "Truncated bloom filter" if buf.nil? || buf.bytesize < length
|
|
340
|
-
return BloomFilter.decode(buf, column: col)
|
|
341
|
-
end
|
|
342
|
-
# Without a length (older writers), read the header first, then the bitset it announces
|
|
343
|
-
head = @io.read(256) || "".b
|
|
344
|
-
reader = Thrift::Reader.new(head)
|
|
345
|
-
header = reader.read_struct(Format::BloomFilterHeader)
|
|
346
|
-
return nil unless BloomFilter.supported_header?(header)
|
|
347
|
-
@io.seek(offset + reader.pos)
|
|
348
|
-
bitset = @io.read(header.num_bytes)
|
|
349
|
-
raise FormatError, "Truncated bloom filter" if bitset.nil? || bitset.bytesize != header.num_bytes
|
|
350
|
-
BloomFilter.new(bitset: bitset, column: col)
|
|
351
|
-
rescue Thrift::Error => e
|
|
352
|
-
raise FormatError, "Corrupt bloom filter header for #{col.dotted_path}: #{e.message}"
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
private
|
|
356
|
-
|
|
357
|
-
# Resolves the +column+ argument of #bloom_filter to a leaf column
|
|
358
|
-
#
|
|
359
|
-
# @param column [String, Array<String, Symbol>, Symbol, Schema::Column] dotted path, path
|
|
360
|
-
# Array or column
|
|
361
|
-
# @return [Schema::Column] the column
|
|
362
|
-
# @raise [ArgumentError] when the schema has no such column
|
|
363
|
-
def bloom_filter_column(column)
|
|
364
|
-
return column if column.is_a?(Schema::Column)
|
|
365
|
-
col = schema.column(column.is_a?(Array) ? column.map(&:to_s) : column.to_s)
|
|
366
|
-
raise ArgumentError, "No column #{column.inspect}" unless col
|
|
367
|
-
col
|
|
368
|
-
end
|
|
369
|
-
end
|
|
370
315
|
end
|
|
@@ -83,6 +83,28 @@ module Herringbone
|
|
|
83
83
|
# Codec name => codec id, the inverse of NAMES
|
|
84
84
|
CODECS_BY_NAME = NAMES.invert.freeze
|
|
85
85
|
|
|
86
|
+
# Codec id => compression levels it accepts, for the writer's compression_level: option
|
|
87
|
+
LEVELS = Ractor.make_shareable({
|
|
88
|
+
Format::Codec::ZSTD => (-(1 << 17))..22,
|
|
89
|
+
Format::Codec::GZIP => 0..9,
|
|
90
|
+
Format::Codec::BROTLI => 0..11
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
# Checks that +codec+ takes a compression level and that +level+ is in its range
|
|
94
|
+
#
|
|
95
|
+
# @param codec [Integer] Format::Codec id
|
|
96
|
+
# @param level [Integer, nil] compression level, nil for the codec's default
|
|
97
|
+
# @return [Integer, nil] the level
|
|
98
|
+
# @raise [ArgumentError] when the codec has no levels or +level+ is out of its range
|
|
99
|
+
def check_level!(codec, level)
|
|
100
|
+
return if level.nil?
|
|
101
|
+
name = NAMES.fetch(codec, codec).inspect
|
|
102
|
+
range = LEVELS.fetch(codec) { raise ArgumentError, "#{name} compression has no levels" }
|
|
103
|
+
level = Integer(level)
|
|
104
|
+
raise ArgumentError, "#{name} compression level must be in #{range}, got #{level}" unless range.cover?(level)
|
|
105
|
+
level
|
|
106
|
+
end
|
|
107
|
+
|
|
86
108
|
# Codec id for a codec name; Integers are taken to be ids already and returned unchecked
|
|
87
109
|
#
|
|
88
110
|
# @param name [Integer, Symbol, String] codec id, or a name from NAMES (case-insensitive)
|
|
@@ -129,18 +151,19 @@ module Herringbone
|
|
|
129
151
|
#
|
|
130
152
|
# @param codec [Integer] Format::Codec id
|
|
131
153
|
# @param data [String] bytes to compress
|
|
154
|
+
# @param level [Integer, nil] compression level checked with check_level!, nil for the default
|
|
132
155
|
# @return [String] compressed bytes (binary)
|
|
133
156
|
# @raise [UnsupportedError] for a codec herringbone does not implement
|
|
134
157
|
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
135
|
-
def compress(codec, data)
|
|
158
|
+
def compress(codec, data, level = nil)
|
|
136
159
|
case codec
|
|
137
160
|
when Format::Codec::UNCOMPRESSED then data
|
|
138
161
|
when Format::Codec::SNAPPY then Codecs::Snappy.compress(data)
|
|
139
|
-
when Format::Codec::GZIP then Zlib.gzip(data)
|
|
162
|
+
when Format::Codec::GZIP then level ? Zlib.gzip(data, level: level) : Zlib.gzip(data)
|
|
140
163
|
when Format::Codec::LZ4_RAW then Codecs::LZ4.compress_block(data)
|
|
141
164
|
when Format::Codec::LZ4 then Codecs::LZ4.compress_hadoop(data)
|
|
142
|
-
when Format::Codec::ZSTD then library(codec).compress(data)
|
|
143
|
-
when Format::Codec::BROTLI then library(codec).deflate(data)
|
|
165
|
+
when Format::Codec::ZSTD then level ? library(codec).compress(data, level: level) : library(codec).compress(data)
|
|
166
|
+
when Format::Codec::BROTLI then level ? library(codec).deflate(data, quality: level) : library(codec).deflate(data)
|
|
144
167
|
else
|
|
145
168
|
raise UnsupportedError, "Unsupported compression codec #{Format::Codec::NAMES[codec] || codec}"
|
|
146
169
|
end.b
|