multi_compress 0.3.5 → 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 +111 -0
- data/GET_STARTED.md +207 -0
- data/README.md +69 -0
- data/db_core/include/mcdb_format.h +113 -0
- data/db_core/src/mcdb_format.c +594 -0
- data/db_deployment/mysql/Makefile +48 -0
- data/db_deployment/mysql/README.md +61 -0
- data/db_deployment/mysql/bin/common +212 -0
- data/db_deployment/mysql/bin/disable +40 -0
- data/db_deployment/mysql/bin/doctor +64 -0
- data/db_deployment/mysql/bin/enable +61 -0
- data/db_deployment/mysql/bin/install +64 -0
- data/db_deployment/mysql/bin/status +67 -0
- data/db_deployment/mysql/bin/uninstall +47 -0
- data/db_deployment/mysql/bin/upgrade +127 -0
- data/db_deployment/mysql/bin/verify +14 -0
- data/db_deployment/postgres/Makefile +45 -0
- data/db_deployment/postgres/README.md +84 -0
- data/db_deployment/postgres/bin/doctor +61 -0
- data/db_deployment/postgres/bin/enable +138 -0
- data/db_deployment/postgres/bin/install +106 -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/docs/database-envelope-v2.md +147 -0
- data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
- data/exe/multi_compress +228 -0
- data/ext/multi_compress/extconf.rb +4 -0
- data/ext/multi_compress/multi_compress.c +62 -0
- data/lib/multi_compress/active_record.rb +61 -0
- data/lib/multi_compress/codec.rb +164 -0
- data/lib/multi_compress/database.rb +312 -0
- data/lib/multi_compress/db_deployment.rb +868 -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 +140 -0
- data/mysql_udf/sql/examples.sql +16 -0
- data/mysql_udf/sql/install.sql +39 -0
- data/mysql_udf/sql/uninstall.sql +10 -0
- data/mysql_udf/sql/views.sql +20 -0
- data/mysql_udf/src/multi_compress_mysql.c +472 -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 +414 -0
- data/postgres_extension/Makefile +74 -0
- data/postgres_extension/README.md +160 -0
- data/postgres_extension/multi_compress.control +4 -0
- data/postgres_extension/multi_compress_pg.exports +24 -0
- data/postgres_extension/sql/examples.sql +23 -0
- data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
- data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
- data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -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 +381 -0
- data/postgres_extension/test/run_e2e.sh +411 -0
- metadata +56 -3
|
@@ -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,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "multi_compress"
|
|
5
|
+
|
|
6
|
+
module MultiCompress
|
|
7
|
+
# Long-term, SQL-readable envelopes. MCDB1 is dictionary-free. MCDB2 is an
|
|
8
|
+
# opt-in, dictionary-required format for one homogeneous database column.
|
|
9
|
+
module Database
|
|
10
|
+
MAGIC = "MCDB".b
|
|
11
|
+
VERSION_V1 = 1
|
|
12
|
+
VERSION_V2 = 2
|
|
13
|
+
VERSION = VERSION_V1 # MCDB1 compatibility alias
|
|
14
|
+
CODEC_ZSTD = 1
|
|
15
|
+
FLAGS_NONE = 0
|
|
16
|
+
FLAGS_V1 = FLAGS_NONE
|
|
17
|
+
V1_HEADER_SIZE = 19 # 4 magic + 1 version + 1 codec + 1 flags + 8 size + 4 crc32
|
|
18
|
+
V2_HEADER_SIZE = 27 # MCDB1 header + 8 byte application dictionary_ref
|
|
19
|
+
HEADER_SIZE = V1_HEADER_SIZE
|
|
20
|
+
ORIGINAL_SIZE_OFFSET = 7
|
|
21
|
+
CRC_OFFSET = 15
|
|
22
|
+
DICTIONARY_REF_OFFSET = 19
|
|
23
|
+
MAX_OUTPUT = 16 * 1024 * 1024
|
|
24
|
+
MAX_DICTIONARY_BYTES = 256 * 1024
|
|
25
|
+
MAX_DICTIONARY_REF = (2**63) - 1
|
|
26
|
+
MAX_ENVELOPE_V1 = 16_842_771
|
|
27
|
+
MAX_ENVELOPE_V2 = 16_842_779
|
|
28
|
+
MAX_ENVELOPE = MAX_ENVELOPE_V2
|
|
29
|
+
ZSTD_LEVEL = 6
|
|
30
|
+
|
|
31
|
+
# A strict, immutable MCDB2 dictionary. It is intentionally distinct from
|
|
32
|
+
# MultiCompress::Dictionary: MCDB2 accepts only conformant zstd dictionaries
|
|
33
|
+
# with a non-zero zstd DictID and an application-level registry id.
|
|
34
|
+
class Dictionary
|
|
35
|
+
attr_reader :id, :bytes, :sha256, :zstd_id, :native
|
|
36
|
+
|
|
37
|
+
def self.train(samples, id:, size: 32 * 1024)
|
|
38
|
+
new(id: id, native: MultiCompress::Zstd.train_dictionary(samples, size: size))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.wrap(native, id:)
|
|
42
|
+
new(id: id, native: native)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(id:, native: nil, bytes: nil)
|
|
46
|
+
@id = normalize_id(id)
|
|
47
|
+
raise ArgumentError, "provide exactly one of native: or bytes:" if (!!native) == (!!bytes)
|
|
48
|
+
|
|
49
|
+
@native = native || MultiCompress::Dictionary.new(bytes, algo: :zstd)
|
|
50
|
+
unless @native.is_a?(MultiCompress::Dictionary) && @native.algo == :zstd
|
|
51
|
+
raise TypeError, "MCDB2 dictionary must be a MultiCompress::Dictionary with algo: :zstd"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@bytes = @native.bytes.b.freeze
|
|
55
|
+
if @bytes.empty? || @bytes.bytesize > MAX_DICTIONARY_BYTES
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"MCDB2 dictionary is #{@bytes.bytesize} bytes; allowed range is 1..#{MAX_DICTIONARY_BYTES}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@zstd_id = @native.zstd_id
|
|
61
|
+
raise ArgumentError, "MCDB2 dictionary must be a conformant zstd dictionary with a non-zero DictID" if @zstd_id.zero?
|
|
62
|
+
|
|
63
|
+
@sha256 = Digest::SHA256.digest(@bytes).freeze
|
|
64
|
+
freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sha256_hex
|
|
68
|
+
@sha256.unpack1("H*")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def registry_attributes
|
|
72
|
+
{ id: @id, zstd_dict_id: @zstd_id, sha256: @sha256, bytes: @bytes }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def normalize_id(value)
|
|
78
|
+
raise TypeError, "MCDB2 dictionary id must be an Integer" unless value.respond_to?(:to_int)
|
|
79
|
+
|
|
80
|
+
integer = value.to_int
|
|
81
|
+
unless integer.between?(1, MAX_DICTIONARY_REF)
|
|
82
|
+
raise ArgumentError, "MCDB2 dictionary id must be in 1..#{MAX_DICTIONARY_REF}"
|
|
83
|
+
end
|
|
84
|
+
integer
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
module_function
|
|
89
|
+
|
|
90
|
+
# Without a dictionary this emits MCDB1. With Database::Dictionary it emits
|
|
91
|
+
# MCDB2. Choose one format per database column; mixed use is deliberately
|
|
92
|
+
# not what the generated dictionary view is optimized for.
|
|
93
|
+
def compress(text, dictionary: nil)
|
|
94
|
+
return compress_v1(text) if dictionary.nil?
|
|
95
|
+
|
|
96
|
+
compress_v2(text, dictionary: dictionary)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def compress_v1(text)
|
|
100
|
+
bytes = input_bytes!(text, format: "v1")
|
|
101
|
+
frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL)
|
|
102
|
+
validate_canonical_zstd_frame!(frame, bytes.bytesize, expected_dictionary_id: 0)
|
|
103
|
+
envelope = header_v1(bytes.bytesize, MultiCompress.crc32(bytes)) << frame
|
|
104
|
+
check_envelope_size!(envelope, MAX_ENVELOPE_V1, "v1")
|
|
105
|
+
envelope
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def compress_v2(text, dictionary:)
|
|
109
|
+
dictionary = database_dictionary!(dictionary)
|
|
110
|
+
bytes = input_bytes!(text, format: "v2")
|
|
111
|
+
frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL, dictionary: dictionary.native)
|
|
112
|
+
validate_canonical_zstd_frame!(frame, bytes.bytesize, expected_dictionary_id: dictionary.zstd_id)
|
|
113
|
+
envelope = header_v2(bytes.bytesize, MultiCompress.crc32(bytes), dictionary.id) << frame
|
|
114
|
+
check_envelope_size!(envelope, MAX_ENVELOPE_V2, "v2")
|
|
115
|
+
envelope
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def decompress(blob, dictionary: nil)
|
|
119
|
+
header = parse_header!(blob)
|
|
120
|
+
case header.fetch(:version)
|
|
121
|
+
when VERSION_V1
|
|
122
|
+
raise ArgumentError, "MCDB1 does not accept a dictionary" unless dictionary.nil?
|
|
123
|
+
|
|
124
|
+
decode_v1(blob.b, header)
|
|
125
|
+
when VERSION_V2
|
|
126
|
+
decode_v2(blob.b, header, dictionary)
|
|
127
|
+
else
|
|
128
|
+
err("unsupported version #{header.fetch(:version)}")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def valid?(blob, dictionary: nil)
|
|
133
|
+
decompress(blob, dictionary: dictionary)
|
|
134
|
+
true
|
|
135
|
+
rescue StandardError
|
|
136
|
+
false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Parses only the envelope header. It is safe for registry/FK consistency
|
|
140
|
+
# checks and does not decompress the payload.
|
|
141
|
+
def dictionary_ref(blob)
|
|
142
|
+
header = parse_header!(blob)
|
|
143
|
+
return nil if header.fetch(:version) == VERSION_V1
|
|
144
|
+
|
|
145
|
+
header.fetch(:dictionary_ref)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def original_size(blob)
|
|
149
|
+
parse_header!(blob).fetch(:original_size)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def version_string
|
|
153
|
+
"MCDB1 + MCDB2 (zstd #{MultiCompress.version(:zstd)})"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def input_bytes!(text, format:)
|
|
157
|
+
raise TypeError, "MultiCompress::Database.compress expects a String, got #{text.class}" unless text.is_a?(String)
|
|
158
|
+
|
|
159
|
+
bytes = utf8_bytes(text)
|
|
160
|
+
n = bytes.bytesize
|
|
161
|
+
if n > MAX_OUTPUT
|
|
162
|
+
raise ArgumentError, "MultiCompress::Database: input is #{n} bytes, over the #{MAX_OUTPUT}-byte #{format} limit"
|
|
163
|
+
end
|
|
164
|
+
bytes
|
|
165
|
+
end
|
|
166
|
+
private_class_method :input_bytes!
|
|
167
|
+
|
|
168
|
+
def decode_v1(blob, header)
|
|
169
|
+
frame = blob.byteslice(V1_HEADER_SIZE, blob.bytesize - V1_HEADER_SIZE) || +"".b
|
|
170
|
+
validate_canonical_zstd_frame!(frame, header.fetch(:original_size), expected_dictionary_id: 0)
|
|
171
|
+
out = MultiCompress.decompress(frame, algo: :zstd, max_output_size: MAX_OUTPUT)
|
|
172
|
+
validate_decoded!(blob, header, out)
|
|
173
|
+
end
|
|
174
|
+
private_class_method :decode_v1
|
|
175
|
+
|
|
176
|
+
def decode_v2(blob, header, dictionary)
|
|
177
|
+
err("MCDB2 requires a dictionary") if dictionary.nil?
|
|
178
|
+
dictionary = database_dictionary!(dictionary)
|
|
179
|
+
if dictionary.id != header.fetch(:dictionary_ref)
|
|
180
|
+
err("dictionary reference #{header.fetch(:dictionary_ref)} does not match supplied dictionary #{dictionary.id}")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
frame = blob.byteslice(V2_HEADER_SIZE, blob.bytesize - V2_HEADER_SIZE) || +"".b
|
|
184
|
+
validate_canonical_zstd_frame!(frame, header.fetch(:original_size), expected_dictionary_id: dictionary.zstd_id)
|
|
185
|
+
out = MultiCompress.decompress(
|
|
186
|
+
frame,
|
|
187
|
+
algo: :zstd,
|
|
188
|
+
dictionary: dictionary.native,
|
|
189
|
+
max_output_size: MAX_OUTPUT
|
|
190
|
+
)
|
|
191
|
+
validate_decoded!(blob, header, out)
|
|
192
|
+
end
|
|
193
|
+
private_class_method :decode_v2
|
|
194
|
+
|
|
195
|
+
def parse_header!(blob)
|
|
196
|
+
raise TypeError, "MultiCompress::Database.decompress expects a String, got #{blob.class}" unless blob.is_a?(String)
|
|
197
|
+
|
|
198
|
+
b = blob.b
|
|
199
|
+
err("truncated envelope (#{b.bytesize} < #{V1_HEADER_SIZE} bytes)") if b.bytesize < V1_HEADER_SIZE
|
|
200
|
+
err("bad magic") unless b.byteslice(0, 4) == MAGIC
|
|
201
|
+
|
|
202
|
+
version = b.getbyte(4)
|
|
203
|
+
case version
|
|
204
|
+
when VERSION_V1
|
|
205
|
+
header_size = V1_HEADER_SIZE
|
|
206
|
+
max_envelope = MAX_ENVELOPE_V1
|
|
207
|
+
when VERSION_V2
|
|
208
|
+
header_size = V2_HEADER_SIZE
|
|
209
|
+
max_envelope = MAX_ENVELOPE_V2
|
|
210
|
+
else
|
|
211
|
+
err("unsupported version #{version}")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
err("truncated #{format_name(version)} envelope (#{b.bytesize} < #{header_size} bytes)") if b.bytesize < header_size
|
|
215
|
+
err("stored #{format_name(version)} envelope is #{b.bytesize} bytes, over the #{max_envelope}-byte limit") if b.bytesize > max_envelope
|
|
216
|
+
err("unsupported codec #{b.getbyte(5)}") unless b.getbyte(5) == CODEC_ZSTD
|
|
217
|
+
err("reserved flags must be 0, got #{b.getbyte(6)}") unless b.getbyte(6) == FLAGS_NONE
|
|
218
|
+
|
|
219
|
+
original_size = b.byteslice(ORIGINAL_SIZE_OFFSET, 8).unpack1("Q<")
|
|
220
|
+
err("declared size #{original_size} over the #{MAX_OUTPUT}-byte limit") if original_size > MAX_OUTPUT
|
|
221
|
+
|
|
222
|
+
header = {
|
|
223
|
+
version: version,
|
|
224
|
+
header_size: header_size,
|
|
225
|
+
original_size: original_size,
|
|
226
|
+
expected_crc: b.byteslice(CRC_OFFSET, 4).unpack1("L<")
|
|
227
|
+
}
|
|
228
|
+
if version == VERSION_V2
|
|
229
|
+
dictionary_ref = b.byteslice(DICTIONARY_REF_OFFSET, 8).unpack1("Q<")
|
|
230
|
+
unless dictionary_ref.between?(1, MAX_DICTIONARY_REF)
|
|
231
|
+
err("invalid dictionary reference #{dictionary_ref}")
|
|
232
|
+
end
|
|
233
|
+
header[:dictionary_ref] = dictionary_ref
|
|
234
|
+
end
|
|
235
|
+
header
|
|
236
|
+
end
|
|
237
|
+
private_class_method :parse_header!
|
|
238
|
+
|
|
239
|
+
def validate_decoded!(blob, header, out)
|
|
240
|
+
if out.bytesize != header.fetch(:original_size)
|
|
241
|
+
err("size mismatch: header says #{header.fetch(:original_size)}, got #{out.bytesize}")
|
|
242
|
+
end
|
|
243
|
+
actual_crc = MultiCompress.crc32(out)
|
|
244
|
+
if actual_crc != header.fetch(:expected_crc)
|
|
245
|
+
err("crc32 mismatch: header #{header.fetch(:expected_crc)}, computed #{actual_crc}")
|
|
246
|
+
end
|
|
247
|
+
s = out.force_encoding(Encoding::UTF_8)
|
|
248
|
+
err("decompressed bytes are not valid UTF-8") unless s.valid_encoding?
|
|
249
|
+
err("decompressed text contains a NUL byte") if s.include?("\0")
|
|
250
|
+
s
|
|
251
|
+
end
|
|
252
|
+
private_class_method :validate_decoded!
|
|
253
|
+
|
|
254
|
+
def utf8_bytes(text)
|
|
255
|
+
s = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)
|
|
256
|
+
raise ArgumentError, "MultiCompress::Database: input is not valid UTF-8" unless s.valid_encoding?
|
|
257
|
+
raise ArgumentError, "MultiCompress::Database: input contains a NUL byte" if s.include?("\0")
|
|
258
|
+
|
|
259
|
+
s.b
|
|
260
|
+
end
|
|
261
|
+
private_class_method :utf8_bytes
|
|
262
|
+
|
|
263
|
+
def validate_canonical_zstd_frame!(frame, expected_size, expected_dictionary_id:)
|
|
264
|
+
_frame_size, content_size = MultiCompress.zstd_single_frame_info(frame)
|
|
265
|
+
err("zstd frame content size #{content_size} does not match header size #{expected_size}") if content_size != expected_size
|
|
266
|
+
actual_dictionary_id = MultiCompress.zstd_frame_dictionary_id(frame)
|
|
267
|
+
if actual_dictionary_id != expected_dictionary_id
|
|
268
|
+
err("zstd frame dictionary id #{actual_dictionary_id} does not match expected #{expected_dictionary_id}")
|
|
269
|
+
end
|
|
270
|
+
rescue MultiCompress::DataError => e
|
|
271
|
+
err(e.message.sub(/\AMultiCompress::Database: /, ""))
|
|
272
|
+
end
|
|
273
|
+
private_class_method :validate_canonical_zstd_frame!
|
|
274
|
+
|
|
275
|
+
def database_dictionary!(value)
|
|
276
|
+
return value if value.is_a?(Dictionary)
|
|
277
|
+
|
|
278
|
+
raise TypeError, "MCDB2 requires MultiCompress::Database::Dictionary, got #{value.class}"
|
|
279
|
+
end
|
|
280
|
+
private_class_method :database_dictionary!
|
|
281
|
+
|
|
282
|
+
def check_envelope_size!(envelope, max, format)
|
|
283
|
+
return if envelope.bytesize <= max
|
|
284
|
+
|
|
285
|
+
raise MultiCompress::DataError,
|
|
286
|
+
"MultiCompress::Database: stored #{format} envelope is #{envelope.bytesize} bytes, over the #{max}-byte limit"
|
|
287
|
+
end
|
|
288
|
+
private_class_method :check_envelope_size!
|
|
289
|
+
|
|
290
|
+
def header_v1(original_size, crc32)
|
|
291
|
+
(MAGIC + [VERSION_V1, CODEC_ZSTD, FLAGS_NONE].pack("C3") +
|
|
292
|
+
[original_size].pack("Q<") + [crc32].pack("L<")).b
|
|
293
|
+
end
|
|
294
|
+
private_class_method :header_v1
|
|
295
|
+
|
|
296
|
+
def header_v2(original_size, crc32, dictionary_ref)
|
|
297
|
+
(MAGIC + [VERSION_V2, CODEC_ZSTD, FLAGS_NONE].pack("C3") +
|
|
298
|
+
[original_size].pack("Q<") + [crc32].pack("L<") + [dictionary_ref].pack("Q<")).b
|
|
299
|
+
end
|
|
300
|
+
private_class_method :header_v2
|
|
301
|
+
|
|
302
|
+
def format_name(version)
|
|
303
|
+
version == VERSION_V2 ? "MCDB2" : "MCDB1"
|
|
304
|
+
end
|
|
305
|
+
private_class_method :format_name
|
|
306
|
+
|
|
307
|
+
def err(message)
|
|
308
|
+
raise MultiCompress::DataError, "MultiCompress::Database: #{message}"
|
|
309
|
+
end
|
|
310
|
+
private_class_method :err
|
|
311
|
+
end
|
|
312
|
+
end
|