diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index cd01fe1e9c43..fadd54d6fb6c 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,4 +1,24 @@ -* Add ENV["SKIP_TEST_DATABASE_TRUNCATE"] flag to speed up multi-process test runs on large DBs when all tests run within default txn. (This cuts ~10s from the test run of HEY when run by 24 processes against the 178 tables, since ~4,000 table truncates can then be skipped.) +* `ActiveRecord::Encryption::Encryptor` now supports a `:compressor` option to customize the compression algorithm used. + + ```ruby + module ZstdCompressor + def self.inflate(data) + Zstd.decompress(data) + end + + def self.deflate(data) + Zstd.compress(data) + end + end + + class User + encrypts :name, encryptor: ActiveRecord::Encryption::Encryptor.new(compressor: ZstdCompressor) + end + ``` + + *heka1024* + +* Add ENV["SKIP_TEST_DATABASE_TRUNCATE"] flag to speed up multi-process test runs on large DBs when all tests run within default txn. (This cuts ~10s from the test run of HEY when run by 24 processes against the 178 tables, since ~4,000 table truncates can then be skipped.) *DHH* diff --git a/activerecord/lib/active_record/encryption.rb b/activerecord/lib/active_record/encryption.rb index 73ec3087767f..bc408ff0864a 100644 --- a/activerecord/lib/active_record/encryption.rb +++ b/activerecord/lib/active_record/encryption.rb @@ -34,6 +34,7 @@ module Encryption autoload :Properties autoload :ReadOnlyNullEncryptor autoload :Scheme + autoload :Compressor end class Cipher diff --git a/activerecord/lib/active_record/encryption/compressor.rb b/activerecord/lib/active_record/encryption/compressor.rb new file mode 100644 index 000000000000..d69ddbbf92a1 --- /dev/null +++ b/activerecord/lib/active_record/encryption/compressor.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ActiveRecord + module Encryption + # The algorithm used for compressing and uncompressing data. + # + # It uses Zlib by default. If you want to use a different compressor, you can + # implement the +compress+ and +uncompress+ methods in a module and pass it to + # +ActiveRecord::Encryption::Encryptor+. + module Compressor + module Zlib # :nodoc: + def self.compress(data) + ::Zlib::Deflate.deflate(data) + end + + def self.uncompress(data) + ::Zlib::Inflate.inflate(data) + end + end + end + end +end diff --git a/activerecord/lib/active_record/encryption/config.rb b/activerecord/lib/active_record/encryption/config.rb index 368bbffb8262..5eb7eb55d61e 100644 --- a/activerecord/lib/active_record/encryption/config.rb +++ b/activerecord/lib/active_record/encryption/config.rb @@ -8,7 +8,8 @@ module Encryption class Config attr_accessor :primary_key, :deterministic_key, :store_key_references, :key_derivation_salt, :hash_digest_class, :support_unencrypted_data, :encrypt_fixtures, :validate_column_size, :add_to_filter_parameters, - :excluded_from_filter_parameters, :extend_queries, :previous_schemes, :forced_encoding_for_deterministic_encryption + :excluded_from_filter_parameters, :extend_queries, :previous_schemes, :forced_encoding_for_deterministic_encryption, + :compressor def initialize set_defaults @@ -55,6 +56,7 @@ def set_defaults self.previous_schemes = [] self.forced_encoding_for_deterministic_encryption = Encoding::UTF_8 self.hash_digest_class = OpenSSL::Digest::SHA1 + self.compressor = Compressor::Zlib # TODO: Setting to false for now as the implementation is a bit experimental self.extend_queries = false diff --git a/activerecord/lib/active_record/encryption/encryptor.rb b/activerecord/lib/active_record/encryption/encryptor.rb index baead108d235..340633b6d2a4 100644 --- a/activerecord/lib/active_record/encryption/encryptor.rb +++ b/activerecord/lib/active_record/encryption/encryptor.rb @@ -16,8 +16,13 @@ class Encryptor # # * :compress - Boolean indicating whether records should be compressed before encryption. # Defaults to +true+. - def initialize(compress: true) + # * :compressor - The compressor to use. + # 1. If compressor is provided, it will be used. + # 2. If not, it will use ActiveRecord::Encryption.config.compressor which default value is +ActiveRecord::Encryption::Compressor::Zlib+ + # If you want to use a custom compressor, it must implement the +compress+ and +uncompress+ methods. + def initialize(compress: true, compressor: nil) @compress = compress + @compressor = compressor || ActiveRecord::Encryption.config.compressor end # Encrypts +clean_text+ and returns the encrypted result @@ -135,7 +140,7 @@ def compress? end def compress(data) - Zlib::Deflate.deflate(data).tap do |compressed_data| + @compressor.compress(data).tap do |compressed_data| compressed_data.force_encoding(data.encoding) end end @@ -149,7 +154,7 @@ def uncompress_if_needed(data, compressed) end def uncompress(data) - Zlib::Inflate.inflate(data).tap do |uncompressed_data| + @compressor.uncompress(data).tap do |uncompressed_data| uncompressed_data.force_encoding(data.encoding) end end diff --git a/activerecord/test/cases/encryption/encryptor_test.rb b/activerecord/test/cases/encryption/encryptor_test.rb index 583ecaad2046..f431a8291e66 100644 --- a/activerecord/test/cases/encryption/encryptor_test.rb +++ b/activerecord/test/cases/encryption/encryptor_test.rb @@ -88,6 +88,22 @@ class ActiveRecord::Encryption::EncryptorTest < ActiveRecord::EncryptionTestCase assert_equal Encoding::ISO_8859_1, decrypted_text.encoding end + test "accept a custom compressor" do + compressor = Module.new do + def self.compress(data) + "compressed #{data}" + end + + def self.uncompress(data) + data.sub(/\Acompressed /, "") + end + end + @encryptor = ActiveRecord::Encryption::Encryptor.new(compressor: compressor) + content = SecureRandom.hex(5.kilobytes) + + assert_encrypt_text content + end + private def assert_encrypt_text(clean_text) encrypted_text = @encryptor.encrypt(clean_text) diff --git a/guides/source/active_record_encryption.md b/guides/source/active_record_encryption.md index e649b38e89de..227f2bc859fd 100644 --- a/guides/source/active_record_encryption.md +++ b/guides/source/active_record_encryption.md @@ -298,6 +298,34 @@ And you can disable this behavior and preserve the encoding in all cases with: config.active_record.encryption.forced_encoding_for_deterministic_encryption = nil ``` +### Compression + +The library compresses encrypted payloads by default. This can save up to 30% of the storage space for larger payloads. You can disable compression by settings `compress: false` to encrypted attributes: + +```ruby +class Article < ApplicationRecord + encrypts :content, encryptor: ActiveRecord::Encryption::Encryptor.new(compress: false) +end +``` + +Also, you can configure the algorithm used for the compression. The default is `Zlib`. You can implement your own compressor by creating a class that responds to `def compress(data)` and `def uncompress(data)`. + +```ruby +require "zstd-ruby" + +module ZstdCompressor + def self.compress(data) + Zstd.compress(data) + end + + def self.uncompress(data) + Zstd.decompress(data) + end +end + +config.active_record.encryption.compressor = ZstdCompressor +``` + ## Key Management Key providers implement key management strategies. You can configure key providers globally, or on a per attribute basis. @@ -497,6 +525,10 @@ The digest algorithm used to derive keys. `OpenSSL::Digest::SHA256` by default. Supports decrypting data encrypted non-deterministically with a digest class SHA1. Default is false, which means it will only support the digest algorithm configured in `config.active_record.encryption.hash_digest_class`. +#### `config.active_record.encryption.compressor` + +The compressor used to compress encrypted payloads. It should respond to `compress` and `uncompress`. Default is `Zlib`. You can find more information about compressors in the [Compression](#compression) section. + ### Encryption Contexts An encryption context defines the encryption components that are used in a given moment. There is a default encryption context based on your global configuration, but you can configure a custom context for a given attribute or when running a specific block of code.