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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/GET_STARTED.md +1 -1
  4. data/README.md +1 -1
  5. data/THIRD_PARTY_NOTICES.md +1 -1
  6. data/ext/multi_compress/multi_compress.c +230 -77
  7. data/ext/multi_compress/vendor/.vendored +1 -1
  8. data/ext/multi_compress/vendor/brotli/c/common/version.h +2 -2
  9. data/ext/multi_compress/vendor/brotli/c/dec/decode.c +59 -30
  10. data/ext/multi_compress/vendor/brotli/c/include/brotli/decode.h +1 -3
  11. data/lib/multi_compress/active_record.rb +11 -5
  12. data/lib/multi_compress/codec.rb +164 -94
  13. data/lib/multi_compress/database/api.rb +42 -0
  14. data/lib/multi_compress/database/decoding.rb +42 -0
  15. data/lib/multi_compress/database/dictionary.rb +72 -0
  16. data/lib/multi_compress/database/encoding.rb +31 -0
  17. data/lib/multi_compress/database/header.rb +71 -0
  18. data/lib/multi_compress/database/operations.rb +73 -0
  19. data/lib/multi_compress/database/validation.rb +95 -0
  20. data/lib/multi_compress/database.rb +10 -282
  21. data/lib/multi_compress/db_deployment/bundle_archiver.rb +70 -0
  22. data/lib/multi_compress/db_deployment/bundle_metadata.rb +56 -0
  23. data/lib/multi_compress/db_deployment/bundle_stager.rb +62 -0
  24. data/lib/multi_compress/db_deployment/commands.rb +309 -0
  25. data/lib/multi_compress/db_deployment/deployment_bundle.rb +80 -0
  26. data/lib/multi_compress/db_deployment/deterministic_tar_writer.rb +76 -0
  27. data/lib/multi_compress/db_deployment/dictionary_registry.rb +244 -0
  28. data/lib/multi_compress/db_deployment/readable_view.rb +112 -0
  29. data/lib/multi_compress/db_deployment/readable_view_options.rb +51 -0
  30. data/lib/multi_compress/db_deployment/sql_identifiers.rb +45 -0
  31. data/lib/multi_compress/db_deployment.rb +10 -844
  32. data/lib/multi_compress/reader/buffer_operations.rb +57 -0
  33. data/lib/multi_compress/reader/read_operations.rb +67 -0
  34. data/lib/multi_compress/reader.rb +97 -0
  35. data/lib/multi_compress/stream_io.rb +13 -0
  36. data/lib/multi_compress/version.rb +1 -1
  37. data/lib/multi_compress/writer.rb +85 -0
  38. data/lib/multi_compress.rb +42 -333
  39. 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 = 16 * 1024 * 1024
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"