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.
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
- USAGE = "usage: herringbone cat FILE [N]\n" \
47
- " herringbone inspect FILE [--format=text|json|html] [--pages] [--verify-checksums]\n" \
48
- " (--pages only with --format=text)"
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 file errors are reported in one line, without a backtrace
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