multi_compress 0.6.0 → 0.6.1
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 +10 -0
- data/GET_STARTED.md +1 -1
- data/README.md +1 -1
- data/THIRD_PARTY_NOTICES.md +1 -1
- data/ext/multi_compress/multi_compress.c +230 -77
- data/ext/multi_compress/vendor/.vendored +1 -1
- data/ext/multi_compress/vendor/brotli/c/common/version.h +2 -2
- data/ext/multi_compress/vendor/brotli/c/dec/decode.c +59 -30
- data/ext/multi_compress/vendor/brotli/c/include/brotli/decode.h +1 -3
- data/lib/multi_compress/active_record.rb +11 -5
- data/lib/multi_compress/codec.rb +164 -94
- data/lib/multi_compress/database/api.rb +42 -0
- data/lib/multi_compress/database/decoding.rb +42 -0
- data/lib/multi_compress/database/dictionary.rb +72 -0
- data/lib/multi_compress/database/encoding.rb +31 -0
- data/lib/multi_compress/database/header.rb +71 -0
- data/lib/multi_compress/database/operations.rb +73 -0
- data/lib/multi_compress/database/validation.rb +95 -0
- data/lib/multi_compress/database.rb +10 -282
- data/lib/multi_compress/db_deployment/bundle_archiver.rb +70 -0
- data/lib/multi_compress/db_deployment/bundle_metadata.rb +56 -0
- data/lib/multi_compress/db_deployment/bundle_stager.rb +62 -0
- data/lib/multi_compress/db_deployment/commands.rb +309 -0
- data/lib/multi_compress/db_deployment/deployment_bundle.rb +80 -0
- data/lib/multi_compress/db_deployment/deterministic_tar_writer.rb +76 -0
- data/lib/multi_compress/db_deployment/dictionary_registry.rb +244 -0
- data/lib/multi_compress/db_deployment/readable_view.rb +112 -0
- data/lib/multi_compress/db_deployment/readable_view_options.rb +51 -0
- data/lib/multi_compress/db_deployment/sql_identifiers.rb +45 -0
- data/lib/multi_compress/db_deployment.rb +10 -844
- data/lib/multi_compress/reader/buffer_operations.rb +57 -0
- data/lib/multi_compress/reader/read_operations.rb +67 -0
- data/lib/multi_compress/reader.rb +97 -0
- data/lib/multi_compress/stream_io.rb +13 -0
- data/lib/multi_compress/version.rb +1 -1
- data/lib/multi_compress/writer.rb +85 -0
- data/lib/multi_compress.rb +42 -333
- metadata +25 -6
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
# A strict, immutable MCDB2 dictionary. It is intentionally distinct from
|
|
6
|
+
# MultiCompress::Dictionary: MCDB2 accepts only conformant zstd dictionaries
|
|
7
|
+
# with a non-zero zstd DictID and an application-level registry id.
|
|
8
|
+
class Dictionary
|
|
9
|
+
attr_reader :id, :bytes, :sha256, :zstd_id, :native
|
|
10
|
+
|
|
11
|
+
def self.train(samples, id:, size: 32 * 1024)
|
|
12
|
+
new(id: id, native: MultiCompress::Zstd.train_dictionary(samples, size: size))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.wrap(native, id:)
|
|
16
|
+
new(id: id, native: native)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(id:, native: nil, bytes: nil)
|
|
20
|
+
@id = normalize_id(id)
|
|
21
|
+
@native = build_native(native, bytes)
|
|
22
|
+
@bytes = validate_bytes(@native.bytes.b.freeze)
|
|
23
|
+
@zstd_id = validate_zstd_id(@native.zstd_id)
|
|
24
|
+
@sha256 = Digest::SHA256.digest(@bytes).freeze
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sha256_hex
|
|
29
|
+
sha256.unpack1("H*")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def registry_attributes
|
|
33
|
+
{ id: id, zstd_dict_id: zstd_id, sha256: sha256, bytes: bytes }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def build_native(native, bytes)
|
|
39
|
+
raise ArgumentError, "provide exactly one of native: or bytes:" if (!!native) == (!!bytes)
|
|
40
|
+
|
|
41
|
+
value = native || MultiCompress::Dictionary.new(bytes, algo: :zstd)
|
|
42
|
+
return value if value.is_a?(MultiCompress::Dictionary) && value.algo == :zstd
|
|
43
|
+
|
|
44
|
+
raise TypeError, "MCDB2 dictionary must be a MultiCompress::Dictionary with algo: :zstd"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_bytes(value)
|
|
48
|
+
return value unless value.empty? || value.bytesize > MAX_DICTIONARY_BYTES
|
|
49
|
+
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"MCDB2 dictionary is #{value.bytesize} bytes; allowed range is 1..#{MAX_DICTIONARY_BYTES}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_zstd_id(value)
|
|
55
|
+
return value unless value.zero?
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "MCDB2 dictionary must be a conformant zstd dictionary with a non-zero DictID"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_id(value)
|
|
61
|
+
raise TypeError, "MCDB2 dictionary id must be an Integer" unless value.respond_to?(:to_int)
|
|
62
|
+
|
|
63
|
+
integer = value.to_int
|
|
64
|
+
unless integer.between?(1, MAX_DICTIONARY_REF)
|
|
65
|
+
raise ArgumentError, "MCDB2 dictionary id must be in 1..#{MAX_DICTIONARY_REF}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
integer
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
class Engine
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def input_bytes!(text, format:)
|
|
9
|
+
validate_input_type!(text)
|
|
10
|
+
|
|
11
|
+
bytes = utf8_bytes(text)
|
|
12
|
+
validate_input_size!(bytes, format)
|
|
13
|
+
bytes
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate_input_type!(text)
|
|
17
|
+
return if text.is_a?(String)
|
|
18
|
+
|
|
19
|
+
raise TypeError, "MultiCompress::Database.compress expects a String, got #{text.class}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate_input_size!(bytes, format)
|
|
23
|
+
size = bytes.bytesize
|
|
24
|
+
return unless size > MAX_OUTPUT
|
|
25
|
+
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"MultiCompress::Database: input is #{size} bytes, over the #{MAX_OUTPUT}-byte #{format} limit"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
class Engine
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def parse_header!(blob)
|
|
9
|
+
bytes = header_bytes!(blob)
|
|
10
|
+
version = bytes.getbyte(4)
|
|
11
|
+
validate_header_size!(bytes, version)
|
|
12
|
+
header_metadata(bytes, version)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def header_bytes!(blob)
|
|
16
|
+
unless blob.is_a?(String)
|
|
17
|
+
raise TypeError, "MultiCompress::Database.decompress expects a String, got #{blob.class}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
bytes = blob.b
|
|
21
|
+
err("truncated envelope (#{bytes.bytesize} < #{V1_HEADER_SIZE} bytes)") if bytes.bytesize < V1_HEADER_SIZE
|
|
22
|
+
err("bad magic") unless bytes.byteslice(0, 4) == MAGIC
|
|
23
|
+
bytes
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_header_size!(bytes, version)
|
|
27
|
+
header_size, max_envelope = envelope_limits(version)
|
|
28
|
+
if bytes.bytesize < header_size
|
|
29
|
+
err("truncated #{format_name(version)} envelope (#{bytes.bytesize} < #{header_size} bytes)")
|
|
30
|
+
end
|
|
31
|
+
if bytes.bytesize > max_envelope
|
|
32
|
+
err("stored #{format_name(version)} envelope is #{bytes.bytesize} bytes, over the #{max_envelope}-byte limit")
|
|
33
|
+
end
|
|
34
|
+
validate_fixed_header!(bytes)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def envelope_limits(version)
|
|
38
|
+
case version
|
|
39
|
+
when VERSION_V1 then [V1_HEADER_SIZE, MAX_ENVELOPE_V1]
|
|
40
|
+
when VERSION_V2 then [V2_HEADER_SIZE, MAX_ENVELOPE_V2]
|
|
41
|
+
else err("unsupported version #{version}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_fixed_header!(bytes)
|
|
46
|
+
err("unsupported codec #{bytes.getbyte(5)}") unless bytes.getbyte(5) == CODEC_ZSTD
|
|
47
|
+
err("reserved flags must be 0, got #{bytes.getbyte(6)}") unless bytes.getbyte(6) == FLAGS_NONE
|
|
48
|
+
|
|
49
|
+
original_size = bytes.byteslice(ORIGINAL_SIZE_OFFSET, 8).unpack1("Q<")
|
|
50
|
+
err("declared size #{original_size} over the #{MAX_OUTPUT}-byte limit") if original_size > MAX_OUTPUT
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def header_metadata(bytes, version)
|
|
54
|
+
header = {
|
|
55
|
+
version: version,
|
|
56
|
+
header_size: version == VERSION_V2 ? V2_HEADER_SIZE : V1_HEADER_SIZE,
|
|
57
|
+
original_size: bytes.byteslice(ORIGINAL_SIZE_OFFSET, 8).unpack1("Q<"),
|
|
58
|
+
expected_crc: bytes.byteslice(CRC_OFFSET, 4).unpack1("L<")
|
|
59
|
+
}
|
|
60
|
+
header[:dictionary_ref] = dictionary_ref_from_header(bytes) if version == VERSION_V2
|
|
61
|
+
header
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def dictionary_ref_from_header(bytes)
|
|
65
|
+
dictionary_ref = bytes.byteslice(DICTIONARY_REF_OFFSET, 8).unpack1("Q<")
|
|
66
|
+
err("invalid dictionary reference #{dictionary_ref}") unless dictionary_ref.between?(1, MAX_DICTIONARY_REF)
|
|
67
|
+
dictionary_ref
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
class Engine
|
|
6
|
+
# Without a dictionary this emits MCDB1. With Database::Dictionary it emits
|
|
7
|
+
# MCDB2. Choose one format per database column; mixed use is deliberately
|
|
8
|
+
# not what the generated dictionary view is optimized for.
|
|
9
|
+
def compress(text, dictionary: nil)
|
|
10
|
+
return compress_v1(text) if dictionary.nil?
|
|
11
|
+
|
|
12
|
+
compress_v2(text, dictionary: dictionary)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def compress_v1(text)
|
|
16
|
+
bytes = input_bytes!(text, format: "v1")
|
|
17
|
+
frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL)
|
|
18
|
+
validate_canonical_zstd_frame!(frame, bytes.bytesize, expected_dictionary_id: 0)
|
|
19
|
+
envelope = header_v1(bytes.bytesize, MultiCompress.crc32(bytes)) << frame
|
|
20
|
+
check_envelope_size!(envelope, MAX_ENVELOPE_V1, "v1")
|
|
21
|
+
envelope
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def compress_v2(text, dictionary:)
|
|
25
|
+
dictionary = database_dictionary!(dictionary)
|
|
26
|
+
bytes = input_bytes!(text, format: "v2")
|
|
27
|
+
frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL, dictionary: dictionary.native)
|
|
28
|
+
validate_canonical_zstd_frame!(frame, bytes.bytesize, expected_dictionary_id: dictionary.zstd_id)
|
|
29
|
+
envelope = header_v2(bytes.bytesize, MultiCompress.crc32(bytes), dictionary.id) << frame
|
|
30
|
+
check_envelope_size!(envelope, MAX_ENVELOPE_V2, "v2")
|
|
31
|
+
envelope
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def decompress(blob, dictionary: nil)
|
|
35
|
+
header = parse_header!(blob)
|
|
36
|
+
case header.fetch(:version)
|
|
37
|
+
when VERSION_V1
|
|
38
|
+
raise ArgumentError, "MCDB1 does not accept a dictionary" unless dictionary.nil?
|
|
39
|
+
|
|
40
|
+
decode_v1(blob.b, header)
|
|
41
|
+
when VERSION_V2
|
|
42
|
+
decode_v2(blob.b, header, dictionary)
|
|
43
|
+
else
|
|
44
|
+
err("unsupported version #{header.fetch(:version)}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def valid?(blob, dictionary: nil)
|
|
49
|
+
decompress(blob, dictionary: dictionary)
|
|
50
|
+
true
|
|
51
|
+
rescue StandardError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Parses only the envelope header. It is safe for registry/FK consistency
|
|
56
|
+
# checks and does not decompress the payload.
|
|
57
|
+
def dictionary_ref(blob)
|
|
58
|
+
header = parse_header!(blob)
|
|
59
|
+
return nil if header.fetch(:version) == VERSION_V1
|
|
60
|
+
|
|
61
|
+
header.fetch(:dictionary_ref)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def original_size(blob)
|
|
65
|
+
parse_header!(blob).fetch(:original_size)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def version_string
|
|
69
|
+
"MCDB1 + MCDB2 (zstd #{MultiCompress.version(:zstd)})"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
class Engine
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def validate_decoded!(_blob, header, out)
|
|
9
|
+
validate_decoded_size!(header, out)
|
|
10
|
+
validate_decoded_crc!(header, out)
|
|
11
|
+
validate_decoded_text(out)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def validate_decoded_size!(header, out)
|
|
15
|
+
expected_size = header.fetch(:original_size)
|
|
16
|
+
return if out.bytesize == expected_size
|
|
17
|
+
|
|
18
|
+
err("size mismatch: header says #{expected_size}, got #{out.bytesize}")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_decoded_crc!(header, out)
|
|
22
|
+
expected_crc = header.fetch(:expected_crc)
|
|
23
|
+
actual_crc = MultiCompress.crc32(out)
|
|
24
|
+
return if actual_crc == expected_crc
|
|
25
|
+
|
|
26
|
+
err("crc32 mismatch: header #{expected_crc}, computed #{actual_crc}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_decoded_text(out)
|
|
30
|
+
text = out.force_encoding(Encoding::UTF_8)
|
|
31
|
+
err("decompressed bytes are not valid UTF-8") unless text.valid_encoding?
|
|
32
|
+
err("decompressed text contains a NUL byte") if text.include?("\0")
|
|
33
|
+
text
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def utf8_bytes(text)
|
|
37
|
+
s = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)
|
|
38
|
+
raise ArgumentError, "MultiCompress::Database: input is not valid UTF-8" unless s.valid_encoding?
|
|
39
|
+
raise ArgumentError, "MultiCompress::Database: input contains a NUL byte" if s.include?("\0")
|
|
40
|
+
|
|
41
|
+
s.b
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_canonical_zstd_frame!(frame, expected_size, expected_dictionary_id:)
|
|
45
|
+
_frame_size, content_size = MultiCompress.zstd_single_frame_info(frame)
|
|
46
|
+
if content_size != expected_size
|
|
47
|
+
err("zstd frame content size #{content_size} does not match header size #{expected_size}")
|
|
48
|
+
end
|
|
49
|
+
actual_dictionary_id = MultiCompress.zstd_frame_dictionary_id(frame)
|
|
50
|
+
if actual_dictionary_id != expected_dictionary_id
|
|
51
|
+
err("zstd frame dictionary id #{actual_dictionary_id} does not match expected #{expected_dictionary_id}")
|
|
52
|
+
end
|
|
53
|
+
rescue MultiCompress::DataError => e
|
|
54
|
+
err(e.message.sub(/\AMultiCompress::Database: /, ""))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def database_dictionary!(value)
|
|
58
|
+
return value if value.is_a?(Dictionary)
|
|
59
|
+
|
|
60
|
+
raise TypeError, "MCDB2 requires MultiCompress::Database::Dictionary, got #{value.class}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_envelope_size!(envelope, max, format)
|
|
64
|
+
return if envelope.bytesize <= max
|
|
65
|
+
|
|
66
|
+
raise MultiCompress::DataError,
|
|
67
|
+
"MultiCompress::Database: stored #{format} envelope is #{envelope.bytesize} bytes, " \
|
|
68
|
+
"over the #{max}-byte limit"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def header_v1(original_size, crc32)
|
|
72
|
+
encode_header(VERSION_V1, original_size, crc32)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def header_v2(original_size, crc32, dictionary_ref)
|
|
76
|
+
encode_header(VERSION_V2, original_size, crc32, dictionary_ref)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def encode_header(version, original_size, crc32, dictionary_ref = nil)
|
|
80
|
+
header = MAGIC + [version, CODEC_ZSTD, FLAGS_NONE].pack("C3") +
|
|
81
|
+
[original_size].pack("Q<") + [crc32].pack("L<")
|
|
82
|
+
header << [dictionary_ref].pack("Q<") unless dictionary_ref.nil?
|
|
83
|
+
header.b
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def format_name(version)
|
|
87
|
+
version == VERSION_V2 ? "MCDB2" : "MCDB1"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def err(message)
|
|
91
|
+
raise MultiCompress::DataError, "MultiCompress::Database: #{message}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -20,293 +20,21 @@ module MultiCompress
|
|
|
20
20
|
ORIGINAL_SIZE_OFFSET = 7
|
|
21
21
|
CRC_OFFSET = 15
|
|
22
22
|
DICTIONARY_REF_OFFSET = 19
|
|
23
|
-
MAX_OUTPUT
|
|
23
|
+
MAX_OUTPUT = 16 * 1024 * 1024
|
|
24
24
|
MAX_DICTIONARY_BYTES = 256 * 1024
|
|
25
25
|
MAX_DICTIONARY_REF = (2**63) - 1
|
|
26
26
|
MAX_ENVELOPE_V1 = 16_842_771
|
|
27
27
|
MAX_ENVELOPE_V2 = 16_842_779
|
|
28
28
|
MAX_ENVELOPE = MAX_ENVELOPE_V2
|
|
29
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
30
|
end
|
|
312
31
|
end
|
|
32
|
+
|
|
33
|
+
require "multi_compress/database/dictionary"
|
|
34
|
+
require "multi_compress/database/operations"
|
|
35
|
+
require "multi_compress/database/encoding"
|
|
36
|
+
require "multi_compress/database/decoding"
|
|
37
|
+
require "multi_compress/database/header"
|
|
38
|
+
require "multi_compress/database/validation"
|
|
39
|
+
|
|
40
|
+
require "multi_compress/database/api"
|