multi_compress 0.5.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/GET_STARTED.md +20 -1
  4. data/README.md +15 -2
  5. data/THIRD_PARTY_NOTICES.md +1 -1
  6. data/db_core/include/mcdb_format.h +67 -9
  7. data/db_core/src/mcdb_format.c +392 -139
  8. data/db_deployment/mysql/README.md +19 -0
  9. data/db_deployment/mysql/bin/common +70 -18
  10. data/db_deployment/mysql/bin/enable +5 -1
  11. data/db_deployment/mysql/bin/status +2 -0
  12. data/db_deployment/mysql/bin/upgrade +17 -5
  13. data/db_deployment/postgres/README.md +24 -0
  14. data/db_deployment/postgres/bin/enable +2 -0
  15. data/db_deployment/postgres/bin/install +4 -2
  16. data/docs/database-envelope-v2.md +147 -0
  17. data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
  18. data/ext/multi_compress/multi_compress.c +261 -76
  19. data/ext/multi_compress/vendor/.vendored +1 -1
  20. data/ext/multi_compress/vendor/brotli/c/common/version.h +2 -2
  21. data/ext/multi_compress/vendor/brotli/c/dec/decode.c +59 -30
  22. data/ext/multi_compress/vendor/brotli/c/include/brotli/decode.h +1 -3
  23. data/lib/multi_compress/active_record.rb +11 -5
  24. data/lib/multi_compress/codec.rb +164 -94
  25. data/lib/multi_compress/database/api.rb +42 -0
  26. data/lib/multi_compress/database/decoding.rb +42 -0
  27. data/lib/multi_compress/database/dictionary.rb +72 -0
  28. data/lib/multi_compress/database/encoding.rb +31 -0
  29. data/lib/multi_compress/database/header.rb +71 -0
  30. data/lib/multi_compress/database/operations.rb +73 -0
  31. data/lib/multi_compress/database/validation.rb +95 -0
  32. data/lib/multi_compress/database.rb +29 -95
  33. data/lib/multi_compress/db_deployment/bundle_archiver.rb +70 -0
  34. data/lib/multi_compress/db_deployment/bundle_metadata.rb +56 -0
  35. data/lib/multi_compress/db_deployment/bundle_stager.rb +62 -0
  36. data/lib/multi_compress/db_deployment/commands.rb +309 -0
  37. data/lib/multi_compress/db_deployment/deployment_bundle.rb +80 -0
  38. data/lib/multi_compress/db_deployment/deterministic_tar_writer.rb +76 -0
  39. data/lib/multi_compress/db_deployment/dictionary_registry.rb +244 -0
  40. data/lib/multi_compress/db_deployment/readable_view.rb +112 -0
  41. data/lib/multi_compress/db_deployment/readable_view_options.rb +51 -0
  42. data/lib/multi_compress/db_deployment/sql_identifiers.rb +45 -0
  43. data/lib/multi_compress/db_deployment.rb +13 -435
  44. data/lib/multi_compress/reader/buffer_operations.rb +57 -0
  45. data/lib/multi_compress/reader/read_operations.rb +67 -0
  46. data/lib/multi_compress/reader.rb +97 -0
  47. data/lib/multi_compress/stream_io.rb +13 -0
  48. data/lib/multi_compress/version.rb +1 -1
  49. data/lib/multi_compress/writer.rb +85 -0
  50. data/lib/multi_compress.rb +42 -333
  51. data/mysql_udf/README.md +45 -0
  52. data/mysql_udf/sql/examples.sql +5 -0
  53. data/mysql_udf/sql/install.sql +25 -1
  54. data/mysql_udf/sql/uninstall.sql +7 -1
  55. data/mysql_udf/sql/views.sql +16 -4
  56. data/mysql_udf/src/multi_compress_mysql.c +383 -44
  57. data/mysql_udf/test/run_e2e.sh +119 -1
  58. data/postgres_extension/Makefile +2 -2
  59. data/postgres_extension/README.md +48 -0
  60. data/postgres_extension/multi_compress.control +2 -2
  61. data/postgres_extension/multi_compress_pg.exports +12 -0
  62. data/postgres_extension/sql/examples.sql +11 -0
  63. data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
  64. data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
  65. data/postgres_extension/src/multi_compress_pg.c +301 -16
  66. data/postgres_extension/test/run_e2e.sh +90 -2
  67. metadata +29 -6
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module Database
5
+ class Engine
6
+ private
7
+
8
+ def decode_v1(blob, header)
9
+ frame = blob.byteslice(V1_HEADER_SIZE, blob.bytesize - V1_HEADER_SIZE) || +"".b
10
+ validate_canonical_zstd_frame!(frame, header.fetch(:original_size), expected_dictionary_id: 0)
11
+ out = MultiCompress.decompress(frame, algo: :zstd, max_output_size: MAX_OUTPUT)
12
+ validate_decoded!(blob, header, out)
13
+ end
14
+
15
+ def decode_v2(blob, header, dictionary)
16
+ dictionary = match_dictionary!(dictionary, header)
17
+ frame = blob.byteslice(V2_HEADER_SIZE, blob.bytesize - V2_HEADER_SIZE) || +"".b
18
+ validate_canonical_zstd_frame!(frame, header.fetch(:original_size), expected_dictionary_id: dictionary.zstd_id)
19
+ out = decompress_dictionary_frame(frame, dictionary)
20
+ validate_decoded!(blob, header, out)
21
+ end
22
+
23
+ def match_dictionary!(dictionary, header)
24
+ err("MCDB2 requires a dictionary") if dictionary.nil?
25
+ dictionary = database_dictionary!(dictionary)
26
+ expected_ref = header.fetch(:dictionary_ref)
27
+ return dictionary if dictionary.id == expected_ref
28
+
29
+ err("dictionary reference #{expected_ref} does not match supplied dictionary #{dictionary.id}")
30
+ end
31
+
32
+ def decompress_dictionary_frame(frame, dictionary)
33
+ MultiCompress.decompress(
34
+ frame,
35
+ algo: :zstd,
36
+ dictionary: dictionary.native,
37
+ max_output_size: MAX_OUTPUT
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -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
@@ -1,106 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "multi_compress"
4
5
 
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.
6
9
  module Database
7
10
  MAGIC = "MCDB".b
8
- VERSION = 1
11
+ VERSION_V1 = 1
12
+ VERSION_V2 = 2
13
+ VERSION = VERSION_V1 # MCDB1 compatibility alias
9
14
  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
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
14
29
  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
30
  end
106
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"
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ class BundleArchiver
6
+ DEFAULT_EPOCH = 315_619_200
7
+ attr_reader :output, :root_name
8
+ private :output, :root_name
9
+
10
+ def initialize(output, root_name)
11
+ @output = output
12
+ @root_name = root_name
13
+ end
14
+
15
+ def write!(temporary_directory)
16
+ temporary_output = temporary_path
17
+ FileUtils.mkdir_p(File.dirname(output))
18
+ write_archive(temporary_output, temporary_directory)
19
+ File.rename(temporary_output, output)
20
+ ensure
21
+ File.delete(temporary_output) if defined?(temporary_output) && File.exist?(temporary_output)
22
+ end
23
+
24
+ private
25
+
26
+ def temporary_path
27
+ File.join(File.dirname(output), ".#{File.basename(output)}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp")
28
+ end
29
+
30
+ def write_archive(path, temporary_directory)
31
+ File.open(path, "wb", 0o644) do |file|
32
+ Zlib::GzipWriter.wrap(file) do |gzip|
33
+ gzip.mtime = archive_epoch
34
+ DeterministicTarWriter.new(gzip, archive_epoch) do |tar|
35
+ add_tree(tar, File.join(temporary_directory, root_name), root_name)
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def archive_epoch
42
+ value = ENV.fetch("SOURCE_DATE_EPOCH", nil)
43
+ value&.match?(/\A\d+\z/) ? value.to_i : DEFAULT_EPOCH
44
+ end
45
+
46
+ def add_tree(tar, directory, archive_path)
47
+ tar.mkdir(archive_path, mode(directory))
48
+ Dir.children(directory).sort.each { |entry| add_entry(tar, directory, archive_path, entry) }
49
+ end
50
+
51
+ def add_entry(tar, directory, archive_path, entry)
52
+ source = File.join(directory, entry)
53
+ destination = File.join(archive_path, entry)
54
+ return add_tree(tar, source, destination) if File.directory?(source)
55
+
56
+ add_file(tar, source, destination)
57
+ end
58
+
59
+ def add_file(tar, source, destination)
60
+ tar.add_file_simple(destination, mode(source), File.size(source)) do |destination_io|
61
+ File.open(source, "rb") { |source_io| IO.copy_stream(source_io, destination_io) }
62
+ end
63
+ end
64
+
65
+ def mode(path)
66
+ File.stat(path).mode & 0o777
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ class BundleMetadata
6
+ attr_reader :target
7
+ private :target
8
+
9
+ def initialize(target)
10
+ @target = target
11
+ end
12
+
13
+ def write!(bundle_root)
14
+ write_manifest(bundle_root, manifest_files(bundle_root))
15
+ write_checksums(bundle_root)
16
+ end
17
+
18
+ private
19
+
20
+ def manifest_files(root)
21
+ regular_files(root).reject { |path| %w[manifest.json SHA256SUMS].include?(File.basename(path)) }
22
+ .sort.map { |path| file_record(root, path) }
23
+ end
24
+
25
+ def file_record(root, path)
26
+ { "path" => relative_to(root, path), "sha256" => Digest::SHA256.file(path).hexdigest }
27
+ end
28
+
29
+ def write_manifest(root, files)
30
+ manifest = {
31
+ "bundle_format_version" => BUNDLE_FORMAT_VERSION,
32
+ "gem" => { "name" => "multi_compress", "version" => MultiCompress::VERSION },
33
+ "database_target" => target,
34
+ "database_envelope" => FORMAT,
35
+ "files" => files
36
+ }
37
+ File.write(File.join(root, "manifest.json"), "#{JSON.pretty_generate(manifest)}\n")
38
+ end
39
+
40
+ def write_checksums(root)
41
+ checksums = regular_files(root).reject { |path| File.basename(path) == "SHA256SUMS" }.sort.map do |path|
42
+ "#{Digest::SHA256.file(path).hexdigest} #{relative_to(root, path)}"
43
+ end
44
+ File.write(File.join(root, "SHA256SUMS"), "#{checksums.join("\n")}\n")
45
+ end
46
+
47
+ def regular_files(root)
48
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }
49
+ end
50
+
51
+ def relative_to(root, path)
52
+ Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
53
+ end
54
+ end
55
+ end
56
+ end