multi_compress 0.3.5 → 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 +76 -0
- data/GET_STARTED.md +188 -0
- data/README.md +56 -0
- 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 +30 -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 +52 -3
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"
|
|
@@ -1560,6 +1560,35 @@ static VALUE compress_compress(int argc, VALUE *argv, VALUE self) {
|
|
|
1560
1560
|
return Qnil;
|
|
1561
1561
|
}
|
|
1562
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
|
+
|
|
1563
1592
|
static VALUE compress_decompress(int argc, VALUE *argv, VALUE self) {
|
|
1564
1593
|
VALUE data, opts;
|
|
1565
1594
|
scan_one_required_keywords(argc, argv, &data, &opts);
|
|
@@ -3478,6 +3507,7 @@ void Init_multi_compress(void) {
|
|
|
3478
3507
|
|
|
3479
3508
|
rb_define_module_function(mMultiCompress, "compress", compress_compress, -1);
|
|
3480
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);
|
|
3481
3511
|
rb_define_module_function(mMultiCompress, "crc32", compress_crc32, -1);
|
|
3482
3512
|
rb_define_module_function(mMultiCompress, "adler32", compress_adler32, -1);
|
|
3483
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
|