multi_compress 0.3.4 → 0.5.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 +89 -0
- data/GET_STARTED.md +194 -4
- data/README.md +58 -2
- data/db_core/include/mcdb_format.h +55 -0
- data/db_core/src/mcdb_format.c +341 -0
- data/db_deployment/mysql/Makefile +48 -0
- data/db_deployment/mysql/README.md +42 -0
- data/db_deployment/mysql/bin/common +160 -0
- data/db_deployment/mysql/bin/disable +40 -0
- data/db_deployment/mysql/bin/doctor +64 -0
- data/db_deployment/mysql/bin/enable +57 -0
- data/db_deployment/mysql/bin/install +64 -0
- data/db_deployment/mysql/bin/status +65 -0
- data/db_deployment/mysql/bin/uninstall +47 -0
- data/db_deployment/mysql/bin/upgrade +115 -0
- data/db_deployment/mysql/bin/verify +14 -0
- data/db_deployment/postgres/Makefile +45 -0
- data/db_deployment/postgres/README.md +60 -0
- data/db_deployment/postgres/bin/doctor +61 -0
- data/db_deployment/postgres/bin/enable +136 -0
- data/db_deployment/postgres/bin/install +104 -0
- data/db_deployment/postgres/bin/uninstall +54 -0
- data/db_deployment/postgres/bin/verify +14 -0
- data/docs/database-envelope-v1.md +159 -0
- data/exe/multi_compress +228 -0
- data/ext/multi_compress/extconf.rb +4 -0
- data/ext/multi_compress/multi_compress.c +39 -0
- data/lib/multi_compress/active_record.rb +61 -0
- data/lib/multi_compress/codec.rb +164 -0
- data/lib/multi_compress/database.rb +106 -0
- data/lib/multi_compress/db_deployment.rb +456 -0
- data/lib/multi_compress/version.rb +1 -1
- data/multi_compress.gemspec +55 -0
- data/mysql_udf/Makefile +96 -0
- data/mysql_udf/README.md +95 -0
- data/mysql_udf/sql/examples.sql +11 -0
- data/mysql_udf/sql/install.sql +15 -0
- data/mysql_udf/sql/uninstall.sql +4 -0
- data/mysql_udf/sql/views.sql +8 -0
- data/mysql_udf/src/multi_compress_mysql.c +133 -0
- data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
- data/mysql_udf/test/mcdb_cli.c +60 -0
- data/mysql_udf/test/run_e2e.sh +296 -0
- data/postgres_extension/Makefile +74 -0
- data/postgres_extension/README.md +112 -0
- data/postgres_extension/multi_compress.control +4 -0
- data/postgres_extension/multi_compress_pg.exports +12 -0
- data/postgres_extension/sql/examples.sql +12 -0
- data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
- data/postgres_extension/sql/uninstall.sql +2 -0
- data/postgres_extension/sql/views.sql +7 -0
- data/postgres_extension/src/mcdb_format.c +2 -0
- data/postgres_extension/src/multi_compress_pg.c +96 -0
- data/postgres_extension/test/run_e2e.sh +323 -0
- metadata +54 -5
data/exe/multi_compress
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
if ARGV.first == "db"
|
|
8
|
+
require "multi_compress/db_deployment"
|
|
9
|
+
exit MultiCompress::DBDeployment::CLI.run(ARGV.drop(1))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require "multi_compress"
|
|
13
|
+
|
|
14
|
+
module MultiCompress
|
|
15
|
+
class CLI
|
|
16
|
+
EXT = { zstd: ".zst", lz4: ".mclz4", brotli: ".br" }.freeze
|
|
17
|
+
KNOWN_EXT = { ".zst" => :zstd, ".zstd" => :zstd, ".mclz4" => :lz4, ".br" => :brotli }.freeze
|
|
18
|
+
NAMED_LEVELS = %w[fastest default best].freeze
|
|
19
|
+
READ_CHUNK = 256 * 1024
|
|
20
|
+
MAX_OUTPUT_LIMIT = (2**(1.size * 8 - 1)) - 1
|
|
21
|
+
|
|
22
|
+
def self.run(argv)
|
|
23
|
+
new.run(argv)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(argv)
|
|
27
|
+
opts = parse(argv)
|
|
28
|
+
files = opts.delete(:files)
|
|
29
|
+
|
|
30
|
+
validate_invocation!(files, opts)
|
|
31
|
+
|
|
32
|
+
if files.empty?
|
|
33
|
+
process_stream($stdin, $stdout, opts)
|
|
34
|
+
else
|
|
35
|
+
files.each { |path| process_file(path, opts) }
|
|
36
|
+
end
|
|
37
|
+
0
|
|
38
|
+
rescue OptionParser::ParseError => e
|
|
39
|
+
warn "multi_compress: #{e.message}"
|
|
40
|
+
2
|
|
41
|
+
rescue MultiCompress::Error, SystemCallError, IOError, ArgumentError, RangeError => e
|
|
42
|
+
warn "multi_compress: #{e.message}"
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def parse(argv)
|
|
49
|
+
o = { decompress: false, keep: false, force: false, stdout: false, quiet: false }
|
|
50
|
+
|
|
51
|
+
@parser = OptionParser.new do |p|
|
|
52
|
+
p.banner = "Usage: multi_compress [OPTIONS] [FILE...]"
|
|
53
|
+
p.separator ""
|
|
54
|
+
p.on("-a", "--algo NAME", %w[zstd lz4 brotli], "zstd | lz4 | brotli (default: by extension, else zstd)") { |v| o[:algo] = v.to_sym }
|
|
55
|
+
p.on("-l", "--level LEVEL", "number, or: fastest | default | best") { |v| o[:level] = parse_level(v) }
|
|
56
|
+
p.on("-d", "--decompress", "decompress instead of compress") { o[:decompress] = true }
|
|
57
|
+
p.on("-o", "--output PATH", "write result to PATH (single input only)") { |v| o[:output] = v }
|
|
58
|
+
p.on("-c", "--stdout", "write to stdout, keep input") { o[:stdout] = true }
|
|
59
|
+
p.on("-k", "--keep", "keep input file (default: remove on success)") { o[:keep] = true }
|
|
60
|
+
p.on("-f", "--force", "overwrite existing output") { o[:force] = true }
|
|
61
|
+
p.on("-q", "--quiet", "no progress line") { o[:quiet] = true }
|
|
62
|
+
p.on("--max-output SIZE", "cap decompressed size, e.g. 64MB") { |v| o[:max_output_size] = parse_size(v) }
|
|
63
|
+
p.on("--version", "print gem + library versions") do
|
|
64
|
+
puts "multi_compress #{MultiCompress::VERSION} (zstd #{MultiCompress.version(:zstd)}, " \
|
|
65
|
+
"lz4 #{MultiCompress.version(:lz4)}, brotli #{MultiCompress.version(:brotli)})"
|
|
66
|
+
exit 0
|
|
67
|
+
end
|
|
68
|
+
p.on("-h", "--help") { puts p; exit 0 }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
o[:files] = @parser.parse(argv)
|
|
72
|
+
o
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_level(v)
|
|
76
|
+
return v.to_sym if NAMED_LEVELS.include?(v)
|
|
77
|
+
Integer(v)
|
|
78
|
+
rescue ArgumentError
|
|
79
|
+
raise OptionParser::InvalidArgument, "invalid level: #{v}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parse_size(v)
|
|
83
|
+
m = v.strip.match(/\A(\d+)\s*([kmg]?)b?\z/i) or
|
|
84
|
+
raise OptionParser::InvalidArgument, "invalid size: #{v}"
|
|
85
|
+
mult = { "" => 1, "k" => 1024, "m" => 1024**2, "g" => 1024**3 }
|
|
86
|
+
bytes = m[1].to_i * mult[m[2].downcase]
|
|
87
|
+
if bytes <= 0
|
|
88
|
+
raise OptionParser::InvalidArgument, "max-output must be greater than zero: #{v}"
|
|
89
|
+
end
|
|
90
|
+
if bytes > MAX_OUTPUT_LIMIT
|
|
91
|
+
raise OptionParser::InvalidArgument, "max-output too large (limit #{MAX_OUTPUT_LIMIT} bytes): #{v}"
|
|
92
|
+
end
|
|
93
|
+
bytes
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_invocation!(files, opts)
|
|
97
|
+
return if opts[:output].nil?
|
|
98
|
+
|
|
99
|
+
if opts[:stdout]
|
|
100
|
+
raise OptionParser::InvalidOption, "-o and -c are mutually exclusive"
|
|
101
|
+
end
|
|
102
|
+
if files.size > 1
|
|
103
|
+
raise ArgumentError, "-o cannot be used with multiple input files (would overwrite the same output)"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def process_file(path, opts)
|
|
108
|
+
raise Errno::ENOENT, path unless File.exist?(path)
|
|
109
|
+
|
|
110
|
+
if opts[:stdout]
|
|
111
|
+
File.open(path, "rb") { |input| run_pipeline(input, $stdout, resolve_algo(path, opts), opts) }
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
out_path = opts[:output] || default_out_path(path, opts)
|
|
116
|
+
guard_paths!(path, out_path, opts)
|
|
117
|
+
|
|
118
|
+
in_size = File.size(path)
|
|
119
|
+
started = Time.now
|
|
120
|
+
write_atomically(out_path) do |tmp|
|
|
121
|
+
File.open(path, "rb") { |input| run_pipeline(input, tmp, resolve_algo(path, opts), opts) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
report(path, out_path, in_size, File.size(out_path), started, opts)
|
|
125
|
+
File.delete(path) unless opts[:keep]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def process_stream(input, output, opts)
|
|
129
|
+
run_pipeline(input, output, opts[:algo] || :zstd, opts)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def run_pipeline(input, output, algo, opts)
|
|
133
|
+
if opts[:decompress]
|
|
134
|
+
reader_opts = {}
|
|
135
|
+
reader_opts[:max_output_size] = opts[:max_output_size] if opts[:max_output_size]
|
|
136
|
+
reader = MultiCompress::Reader.new(input, algo: algo, **reader_opts)
|
|
137
|
+
while (chunk = reader.read(READ_CHUNK))
|
|
138
|
+
output.write(chunk)
|
|
139
|
+
end
|
|
140
|
+
reader.close
|
|
141
|
+
else
|
|
142
|
+
writer = MultiCompress::Writer.new(output, algo: algo, level: opts[:level])
|
|
143
|
+
while (chunk = input.read(READ_CHUNK))
|
|
144
|
+
writer.write(chunk)
|
|
145
|
+
end
|
|
146
|
+
writer.close
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def write_atomically(dst)
|
|
151
|
+
dir = File.dirname(dst)
|
|
152
|
+
tmp = unique_temp(dir)
|
|
153
|
+
file = File.open(tmp, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
|
|
154
|
+
begin
|
|
155
|
+
yield file
|
|
156
|
+
file.flush
|
|
157
|
+
file.fsync
|
|
158
|
+
file.close
|
|
159
|
+
File.rename(tmp, dst)
|
|
160
|
+
fsync_dir(dir)
|
|
161
|
+
rescue StandardError
|
|
162
|
+
file.close unless file.closed?
|
|
163
|
+
File.delete(tmp) if File.exist?(tmp)
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def unique_temp(dir)
|
|
169
|
+
loop do
|
|
170
|
+
candidate = File.join(dir, ".multi_compress.#{Process.pid}.#{SecureRandom.hex(8)}.tmp")
|
|
171
|
+
return candidate unless File.exist?(candidate)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def fsync_dir(dir)
|
|
176
|
+
d = File.open(dir)
|
|
177
|
+
begin
|
|
178
|
+
d.fsync
|
|
179
|
+
rescue NotImplementedError, Errno::EINVAL, Errno::EACCES
|
|
180
|
+
# some platforms/filesystems can't fsync a directory; best-effort
|
|
181
|
+
ensure
|
|
182
|
+
d.close
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def guard_paths!(src, dst, opts)
|
|
187
|
+
if same_file?(src, dst)
|
|
188
|
+
raise ArgumentError, "input and output are the same file: #{src}"
|
|
189
|
+
end
|
|
190
|
+
if File.exist?(dst) && !opts[:force]
|
|
191
|
+
raise ArgumentError, "#{dst} already exists (use -f to overwrite)"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def same_file?(a, b)
|
|
196
|
+
return true if File.expand_path(a) == File.expand_path(b)
|
|
197
|
+
File.exist?(a) && File.exist?(b) && File.identical?(a, b)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def resolve_algo(path, opts)
|
|
201
|
+
opts[:algo] || KNOWN_EXT[File.extname(path).downcase] || :zstd
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def default_out_path(path, opts)
|
|
205
|
+
if opts[:decompress]
|
|
206
|
+
stripped = strip_known_ext(path)
|
|
207
|
+
stripped == path ? "#{path}.out" : stripped
|
|
208
|
+
else
|
|
209
|
+
"#{path}#{EXT.fetch(resolve_algo(path, opts))}"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def strip_known_ext(path)
|
|
214
|
+
ext = File.extname(path).downcase
|
|
215
|
+
KNOWN_EXT.key?(ext) ? path[0...-ext.length] : path
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def report(src, dst, in_size, out_size, started, opts)
|
|
219
|
+
return if opts[:quiet]
|
|
220
|
+
pct = in_size.zero? ? 0 : (100.0 * out_size / in_size)
|
|
221
|
+
dir = opts[:decompress] ? "expanded" : "reduced"
|
|
222
|
+
warn format("%s -> %s %d -> %d bytes (%s to %.1f%%, %.2fs)",
|
|
223
|
+
src, dst, in_size, out_size, dir, pct, Time.now - started)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
exit MultiCompress::CLI.run(ARGV)
|
|
@@ -253,6 +253,10 @@ end
|
|
|
253
253
|
$CFLAGS += " -O3"
|
|
254
254
|
$CFLAGS += " -DXXH_NAMESPACE=MULTICOMPRESS_"
|
|
255
255
|
|
|
256
|
+
if RUBY_PLATFORM.include?("darwin") && try_cflags("-Wno-default-const-init-field-unsafe")
|
|
257
|
+
$CFLAGS += " -Wno-default-const-init-field-unsafe"
|
|
258
|
+
end
|
|
259
|
+
|
|
256
260
|
case RUBY_PLATFORM
|
|
257
261
|
when /x86_64|amd64|aarch64|arm64/
|
|
258
262
|
$CFLAGS += " -DBROTLI_BUILD_LITTLE_ENDIAN"
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#include <ruby.h>
|
|
2
2
|
#include <ruby/encoding.h>
|
|
3
3
|
#include <ruby/thread.h>
|
|
4
|
+
|
|
5
|
+
#ifdef HAVE_RUBY_FIBER_SCHEDULER_H
|
|
4
6
|
#include <ruby/fiber/scheduler.h>
|
|
7
|
+
#endif
|
|
5
8
|
#include <brotli/decode.h>
|
|
6
9
|
#include <brotli/encode.h>
|
|
7
10
|
#include <lz4.h>
|
|
@@ -616,10 +619,16 @@ static inline void enforce_output_and_ratio_limits(size_t total_output, size_t t
|
|
|
616
619
|
}
|
|
617
620
|
|
|
618
621
|
static VALUE current_fiber_scheduler(void) {
|
|
622
|
+
#ifdef HAVE_RUBY_FIBER_SCHEDULER_H
|
|
619
623
|
VALUE sched = rb_fiber_scheduler_current();
|
|
624
|
+
|
|
620
625
|
if (sched == Qnil || sched == Qfalse)
|
|
621
626
|
return Qnil;
|
|
627
|
+
|
|
622
628
|
return sched;
|
|
629
|
+
#else
|
|
630
|
+
return Qnil;
|
|
631
|
+
#endif
|
|
623
632
|
}
|
|
624
633
|
|
|
625
634
|
static int has_fiber_scheduler(void) {
|
|
@@ -1551,6 +1560,35 @@ static VALUE compress_compress(int argc, VALUE *argv, VALUE self) {
|
|
|
1551
1560
|
return Qnil;
|
|
1552
1561
|
}
|
|
1553
1562
|
|
|
1563
|
+
static VALUE zstd_single_frame_info(VALUE self, VALUE data) {
|
|
1564
|
+
(void)self;
|
|
1565
|
+
StringValue(data);
|
|
1566
|
+
|
|
1567
|
+
const void *src = RSTRING_PTR(data);
|
|
1568
|
+
size_t src_len = (size_t)RSTRING_LEN(data);
|
|
1569
|
+
size_t frame_size = ZSTD_findFrameCompressedSize(src, src_len);
|
|
1570
|
+
if (ZSTD_isError(frame_size)) {
|
|
1571
|
+
rb_raise(eDataError, "zstd: invalid frame: %s", ZSTD_getErrorName(frame_size));
|
|
1572
|
+
}
|
|
1573
|
+
if (frame_size != src_len) {
|
|
1574
|
+
rb_raise(eDataError, "zstd: expected exactly one frame with no trailing bytes");
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
unsigned long long content_size = ZSTD_getFrameContentSize(src, src_len);
|
|
1578
|
+
if (content_size == ZSTD_CONTENTSIZE_ERROR) {
|
|
1579
|
+
rb_raise(eDataError, "zstd: invalid frame content size");
|
|
1580
|
+
}
|
|
1581
|
+
if (content_size == ZSTD_CONTENTSIZE_UNKNOWN) {
|
|
1582
|
+
rb_raise(eDataError, "zstd: frame must declare content size");
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
VALUE result = rb_ary_new_capa(2);
|
|
1586
|
+
rb_ary_push(result, SIZET2NUM(frame_size));
|
|
1587
|
+
rb_ary_push(result, ULL2NUM(content_size));
|
|
1588
|
+
RB_GC_GUARD(data);
|
|
1589
|
+
return result;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1554
1592
|
static VALUE compress_decompress(int argc, VALUE *argv, VALUE self) {
|
|
1555
1593
|
VALUE data, opts;
|
|
1556
1594
|
scan_one_required_keywords(argc, argv, &data, &opts);
|
|
@@ -3469,6 +3507,7 @@ void Init_multi_compress(void) {
|
|
|
3469
3507
|
|
|
3470
3508
|
rb_define_module_function(mMultiCompress, "compress", compress_compress, -1);
|
|
3471
3509
|
rb_define_module_function(mMultiCompress, "decompress", compress_decompress, -1);
|
|
3510
|
+
rb_define_module_function(mMultiCompress, "zstd_single_frame_info", zstd_single_frame_info, 1);
|
|
3472
3511
|
rb_define_module_function(mMultiCompress, "crc32", compress_crc32, -1);
|
|
3473
3512
|
rb_define_module_function(mMultiCompress, "adler32", compress_adler32, -1);
|
|
3474
3513
|
rb_define_module_function(mMultiCompress, "algorithms", compress_algorithms, 0);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "multi_compress/codec"
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require "active_model/type"
|
|
7
|
+
rescue LoadError => e
|
|
8
|
+
raise LoadError, "multi_compress/active_record requires activemodel (#{e.message}). " \
|
|
9
|
+
"Add activerecord/activemodel to your bundle, or use MultiCompress::Codec directly."
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module MultiCompress
|
|
13
|
+
module ActiveRecordSupport
|
|
14
|
+
class Type < ActiveModel::Type::Value
|
|
15
|
+
def initialize(mutable: false, **codec_opts)
|
|
16
|
+
@codec = MultiCompress::Codec.new(**codec_opts)
|
|
17
|
+
@mutable = mutable
|
|
18
|
+
super()
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def type
|
|
22
|
+
:multi_compress
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# user assignment -> in-memory value (no compression here)
|
|
26
|
+
def cast(value)
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# DB -> in-memory (unwrap)
|
|
31
|
+
def deserialize(value)
|
|
32
|
+
@codec.load(value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# in-memory -> DB (compress)
|
|
36
|
+
def serialize(value)
|
|
37
|
+
@codec.dump(value)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def changed_in_place?(raw_old_value, new_value)
|
|
41
|
+
return false unless @mutable
|
|
42
|
+
|
|
43
|
+
deserialize(raw_old_value) != new_value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class Coder
|
|
48
|
+
def initialize(**codec_opts)
|
|
49
|
+
@codec = MultiCompress::Codec.new(**codec_opts)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dump(value)
|
|
53
|
+
@codec.dump(value)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def load(value)
|
|
57
|
+
@codec.load(value)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "multi_compress"
|
|
4
|
+
|
|
5
|
+
module MultiCompress
|
|
6
|
+
class Codec
|
|
7
|
+
MAGIC = "\x89MCDB".b # reserved 5-byte namespace; 0x89 is an
|
|
8
|
+
# invalid UTF-8 lead byte, so it won't
|
|
9
|
+
# collide with valid text. For BINARY data
|
|
10
|
+
# this prefix is reserved, not collision-free.
|
|
11
|
+
VERSION = 1
|
|
12
|
+
HEADER_SIZE = MAGIC.bytesize + 2 # magic + version byte + algo byte
|
|
13
|
+
B64_PREFIX = "mc1:"
|
|
14
|
+
MAX_NATIVE_OUTPUT = (2**(0.size * 8 - 1)) - 1
|
|
15
|
+
|
|
16
|
+
ALGO_ID = { zstd: 1, lz4: 2, brotli: 3 }.freeze
|
|
17
|
+
ID_ALGO = ALGO_ID.invert.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :algo, :level, :max_output_size
|
|
20
|
+
|
|
21
|
+
def initialize(algo: :zstd, level: nil, encode: nil, serializer: nil,
|
|
22
|
+
encoding: Encoding::UTF_8, max_output_size: nil,
|
|
23
|
+
legacy: :reject, dictionary: nil)
|
|
24
|
+
raise ArgumentError, "unknown algo #{algo.inspect}" unless ALGO_ID.key?(algo)
|
|
25
|
+
raise ArgumentError, "encode must be nil or :base64" unless [nil, :base64].include?(encode)
|
|
26
|
+
|
|
27
|
+
@algo = algo
|
|
28
|
+
@level = level
|
|
29
|
+
@encode = encode
|
|
30
|
+
@serializer = serializer
|
|
31
|
+
@encoding = encoding
|
|
32
|
+
limit = Integer(max_output_size || MultiCompress.config.max_output_size)
|
|
33
|
+
raise ArgumentError, "max_output_size must be greater than 0" unless limit.positive?
|
|
34
|
+
if limit > MAX_NATIVE_OUTPUT
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"max_output_size must be at most #{MAX_NATIVE_OUTPUT} bytes on this platform"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@max_output_size = limit
|
|
40
|
+
@legacy = normalize_legacy(legacy)
|
|
41
|
+
@dictionary = dictionary
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def dump(value)
|
|
45
|
+
return nil if value.nil?
|
|
46
|
+
|
|
47
|
+
payload = serialize_payload(value)
|
|
48
|
+
body = MultiCompress.compress(payload, algo: @algo, level: @level, dictionary: @dictionary)
|
|
49
|
+
framed = header + body
|
|
50
|
+
|
|
51
|
+
@encode == :base64 ? B64_PREFIX + [framed].pack("m0") : framed
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def load(stored)
|
|
55
|
+
return nil if stored.nil?
|
|
56
|
+
|
|
57
|
+
framed = decode_transport(stored)
|
|
58
|
+
return decode_payload(unwrap(framed)) if framed
|
|
59
|
+
|
|
60
|
+
read_legacy(stored)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def header
|
|
66
|
+
(MAGIC + [VERSION, ALGO_ID.fetch(@algo)].pack("C2")).b
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def decode_transport(stored)
|
|
70
|
+
if @encode == :base64
|
|
71
|
+
s = stored.to_s
|
|
72
|
+
return nil unless s.start_with?(B64_PREFIX)
|
|
73
|
+
|
|
74
|
+
decoded = optional_base64(s.byteslice(B64_PREFIX.bytesize, s.bytesize - B64_PREFIX.bytesize))
|
|
75
|
+
decoded && decoded.start_with?(MAGIC) ? decoded : nil
|
|
76
|
+
else
|
|
77
|
+
b = stored.b
|
|
78
|
+
b.start_with?(MAGIC) ? b : nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def unwrap(framed)
|
|
83
|
+
if framed.bytesize < HEADER_SIZE
|
|
84
|
+
raise MultiCompress::DataError, "MultiCompress::Codec: truncated envelope"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
version = framed.getbyte(MAGIC.bytesize)
|
|
88
|
+
algo_id = framed.getbyte(MAGIC.bytesize + 1)
|
|
89
|
+
unless version == VERSION
|
|
90
|
+
raise MultiCompress::DataError, "MultiCompress::Codec: unsupported envelope version #{version}"
|
|
91
|
+
end
|
|
92
|
+
algo = ID_ALGO[algo_id]
|
|
93
|
+
raise MultiCompress::DataError, "MultiCompress::Codec: unknown algorithm id #{algo_id}" unless algo
|
|
94
|
+
|
|
95
|
+
body = framed.byteslice(HEADER_SIZE, framed.bytesize - HEADER_SIZE) || +"".b
|
|
96
|
+
MultiCompress.decompress(body, algo: algo, dictionary: @dictionary, max_output_size: @max_output_size)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def read_legacy(stored)
|
|
100
|
+
case @legacy[:mode]
|
|
101
|
+
when :reject
|
|
102
|
+
raise MultiCompress::DataError,
|
|
103
|
+
"MultiCompress::Codec: value has no envelope and legacy: :reject is set"
|
|
104
|
+
when :plain
|
|
105
|
+
decode_payload(stored.b.dup)
|
|
106
|
+
when :compressed
|
|
107
|
+
payload = MultiCompress.decompress(
|
|
108
|
+
stored.b, algo: @legacy[:algo], dictionary: @dictionary, max_output_size: @max_output_size
|
|
109
|
+
)
|
|
110
|
+
decode_payload(payload)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_legacy(legacy)
|
|
115
|
+
case legacy
|
|
116
|
+
when :reject, :plain
|
|
117
|
+
{ mode: legacy }
|
|
118
|
+
when Hash
|
|
119
|
+
algo = legacy[:compressed]
|
|
120
|
+
raise ArgumentError, "legacy: { compressed: <algo> } required" unless ALGO_ID.key?(algo)
|
|
121
|
+
|
|
122
|
+
{ mode: :compressed, algo: algo }
|
|
123
|
+
else
|
|
124
|
+
raise ArgumentError, "legacy: must be :reject, :plain, or { compressed: :algo }"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def serialize_payload(value)
|
|
129
|
+
if @serializer
|
|
130
|
+
@serializer.dump(value).b
|
|
131
|
+
else
|
|
132
|
+
unless value.is_a?(String)
|
|
133
|
+
raise TypeError, "MultiCompress::Codec expects a String (or pass serializer:); got #{value.class}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
unless @encoding == Encoding::BINARY || value.dup.force_encoding(@encoding).valid_encoding?
|
|
137
|
+
raise ArgumentError, "MultiCompress::Codec: value is not valid #{@encoding} " \
|
|
138
|
+
"(use encoding: Encoding::BINARY for arbitrary bytes)"
|
|
139
|
+
end
|
|
140
|
+
value.b
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def decode_payload(bytes)
|
|
145
|
+
return @serializer.load(bytes) if @serializer
|
|
146
|
+
|
|
147
|
+
finalize_string(bytes)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def finalize_string(bytes)
|
|
151
|
+
s = bytes.dup.force_encoding(@encoding)
|
|
152
|
+
unless @encoding == Encoding::BINARY || s.valid_encoding?
|
|
153
|
+
raise MultiCompress::DataError, "MultiCompress::Codec: decoded bytes are not valid #{@encoding}"
|
|
154
|
+
end
|
|
155
|
+
s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def optional_base64(str)
|
|
159
|
+
str.unpack1("m0")&.b
|
|
160
|
+
rescue ArgumentError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "multi_compress"
|
|
4
|
+
|
|
5
|
+
module MultiCompress
|
|
6
|
+
module Database
|
|
7
|
+
MAGIC = "MCDB".b
|
|
8
|
+
VERSION = 1
|
|
9
|
+
CODEC_ZSTD = 1
|
|
10
|
+
FLAGS_V1 = 0
|
|
11
|
+
HEADER_SIZE = 19 # 4 magic +1 ver +1 codec +1 flags +8 size +4 crc32
|
|
12
|
+
MAX_OUTPUT = 16 * 1024 * 1024 # 16 MiB decompressed, enforced on write and read
|
|
13
|
+
MAX_ENVELOPE = 16_842_771
|
|
14
|
+
ZSTD_LEVEL = 6
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def compress(text)
|
|
19
|
+
raise TypeError, "MultiCompress::Database.compress expects a String, got #{text.class}" unless text.is_a?(String)
|
|
20
|
+
|
|
21
|
+
bytes = utf8_bytes(text)
|
|
22
|
+
n = bytes.bytesize
|
|
23
|
+
if n > MAX_OUTPUT
|
|
24
|
+
raise ArgumentError, "MultiCompress::Database: input is #{n} bytes, over the #{MAX_OUTPUT}-byte v1 limit"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL)
|
|
28
|
+
validate_canonical_zstd_frame!(frame, n)
|
|
29
|
+
|
|
30
|
+
envelope = header(n, MultiCompress.crc32(bytes)) << frame
|
|
31
|
+
if envelope.bytesize > MAX_ENVELOPE
|
|
32
|
+
raise MultiCompress::DataError,
|
|
33
|
+
"MultiCompress::Database: stored envelope is #{envelope.bytesize} bytes, over the #{MAX_ENVELOPE}-byte v1 limit"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
envelope
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def decompress(blob)
|
|
40
|
+
raise TypeError, "MultiCompress::Database.decompress expects a String, got #{blob.class}" unless blob.is_a?(String)
|
|
41
|
+
|
|
42
|
+
b = blob.b
|
|
43
|
+
err("truncated envelope (#{b.bytesize} < #{HEADER_SIZE} bytes)") if b.bytesize < HEADER_SIZE
|
|
44
|
+
err("stored envelope too large (#{b.bytesize} > #{MAX_ENVELOPE} bytes)") if b.bytesize > MAX_ENVELOPE
|
|
45
|
+
err("bad magic") unless b.byteslice(0, 4) == MAGIC
|
|
46
|
+
err("unsupported version #{b.getbyte(4)}") unless b.getbyte(4) == VERSION
|
|
47
|
+
err("unsupported codec #{b.getbyte(5)}") unless b.getbyte(5) == CODEC_ZSTD
|
|
48
|
+
err("reserved flags must be 0, got #{b.getbyte(6)}") unless b.getbyte(6) == FLAGS_V1
|
|
49
|
+
|
|
50
|
+
original_size = b.byteslice(7, 8).unpack1("Q<")
|
|
51
|
+
err("declared size #{original_size} over the #{MAX_OUTPUT}-byte v1 limit") if original_size > MAX_OUTPUT
|
|
52
|
+
expected_crc = b.byteslice(15, 4).unpack1("L<")
|
|
53
|
+
|
|
54
|
+
frame = b.byteslice(HEADER_SIZE, b.bytesize - HEADER_SIZE) || +"".b
|
|
55
|
+
validate_canonical_zstd_frame!(frame, original_size)
|
|
56
|
+
out = MultiCompress.decompress(frame, algo: :zstd, max_output_size: MAX_OUTPUT)
|
|
57
|
+
if out.bytesize != original_size
|
|
58
|
+
err("size mismatch: header says #{original_size}, got #{out.bytesize}")
|
|
59
|
+
end
|
|
60
|
+
actual_crc = MultiCompress.crc32(out)
|
|
61
|
+
err("crc32 mismatch: header #{expected_crc}, computed #{actual_crc}") if actual_crc != expected_crc
|
|
62
|
+
|
|
63
|
+
s = out.force_encoding(Encoding::UTF_8)
|
|
64
|
+
err("decompressed bytes are not valid UTF-8") unless s.valid_encoding?
|
|
65
|
+
err("decompressed text contains a NUL byte") if s.include?("\0")
|
|
66
|
+
s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def valid?(blob)
|
|
70
|
+
decompress(blob)
|
|
71
|
+
true
|
|
72
|
+
rescue StandardError
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def version_string
|
|
77
|
+
"MCDB1 (zstd #{MultiCompress.version(:zstd)})"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def utf8_bytes(text)
|
|
81
|
+
s = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)
|
|
82
|
+
raise ArgumentError, "MultiCompress::Database: input is not valid UTF-8" unless s.valid_encoding?
|
|
83
|
+
raise ArgumentError, "MultiCompress::Database: input contains a NUL byte" if s.include?("\0")
|
|
84
|
+
|
|
85
|
+
s.b
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_canonical_zstd_frame!(frame, expected_size)
|
|
89
|
+
_frame_size, content_size = MultiCompress.zstd_single_frame_info(frame)
|
|
90
|
+
err("zstd frame content size #{content_size} does not match header size #{expected_size}") if content_size != expected_size
|
|
91
|
+
rescue MultiCompress::DataError => e
|
|
92
|
+
err(e.message.sub(/\AMultiCompress::Database: /, ""))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def header(original_size, crc32)
|
|
96
|
+
(MAGIC + [VERSION, CODEC_ZSTD, FLAGS_V1].pack("C3") +
|
|
97
|
+
[original_size].pack("Q<") + [crc32].pack("L<")).b
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def err(message)
|
|
101
|
+
raise MultiCompress::DataError, "MultiCompress::Database: #{message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private_class_method :utf8_bytes, :validate_canonical_zstd_frame!, :header, :err
|
|
105
|
+
end
|
|
106
|
+
end
|