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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/GET_STARTED.md +188 -0
  4. data/README.md +56 -0
  5. data/db_core/include/mcdb_format.h +55 -0
  6. data/db_core/src/mcdb_format.c +341 -0
  7. data/db_deployment/mysql/Makefile +48 -0
  8. data/db_deployment/mysql/README.md +42 -0
  9. data/db_deployment/mysql/bin/common +160 -0
  10. data/db_deployment/mysql/bin/disable +40 -0
  11. data/db_deployment/mysql/bin/doctor +64 -0
  12. data/db_deployment/mysql/bin/enable +57 -0
  13. data/db_deployment/mysql/bin/install +64 -0
  14. data/db_deployment/mysql/bin/status +65 -0
  15. data/db_deployment/mysql/bin/uninstall +47 -0
  16. data/db_deployment/mysql/bin/upgrade +115 -0
  17. data/db_deployment/mysql/bin/verify +14 -0
  18. data/db_deployment/postgres/Makefile +45 -0
  19. data/db_deployment/postgres/README.md +60 -0
  20. data/db_deployment/postgres/bin/doctor +61 -0
  21. data/db_deployment/postgres/bin/enable +136 -0
  22. data/db_deployment/postgres/bin/install +104 -0
  23. data/db_deployment/postgres/bin/uninstall +54 -0
  24. data/db_deployment/postgres/bin/verify +14 -0
  25. data/docs/database-envelope-v1.md +159 -0
  26. data/exe/multi_compress +228 -0
  27. data/ext/multi_compress/extconf.rb +4 -0
  28. data/ext/multi_compress/multi_compress.c +30 -0
  29. data/lib/multi_compress/active_record.rb +61 -0
  30. data/lib/multi_compress/codec.rb +164 -0
  31. data/lib/multi_compress/database.rb +106 -0
  32. data/lib/multi_compress/db_deployment.rb +456 -0
  33. data/lib/multi_compress/version.rb +1 -1
  34. data/multi_compress.gemspec +55 -0
  35. data/mysql_udf/Makefile +96 -0
  36. data/mysql_udf/README.md +95 -0
  37. data/mysql_udf/sql/examples.sql +11 -0
  38. data/mysql_udf/sql/install.sql +15 -0
  39. data/mysql_udf/sql/uninstall.sql +4 -0
  40. data/mysql_udf/sql/views.sql +8 -0
  41. data/mysql_udf/src/multi_compress_mysql.c +133 -0
  42. data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
  43. data/mysql_udf/test/mcdb_cli.c +60 -0
  44. data/mysql_udf/test/run_e2e.sh +296 -0
  45. data/postgres_extension/Makefile +74 -0
  46. data/postgres_extension/README.md +112 -0
  47. data/postgres_extension/multi_compress.control +4 -0
  48. data/postgres_extension/multi_compress_pg.exports +12 -0
  49. data/postgres_extension/sql/examples.sql +12 -0
  50. data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
  51. data/postgres_extension/sql/uninstall.sql +2 -0
  52. data/postgres_extension/sql/views.sql +7 -0
  53. data/postgres_extension/src/mcdb_format.c +2 -0
  54. data/postgres_extension/src/multi_compress_pg.c +96 -0
  55. data/postgres_extension/test/run_e2e.sh +323 -0
  56. metadata +52 -3
@@ -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