diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index d9cc7f4ce7c..7eaa825f05b 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -1,3 +1,31 @@
+* `ActiveRecord::Encryption::Encryptor` now supports a `:compressor` option to customize the compression algorithm used.
+
+ ```ruby
+ module ZstdCompressor
+ def self.deflate(data)
+ Zstd.compress(data)
+ end
+
+ def self.inflate(data)
+ Zstd.decompress(data)
+ end
+ end
+
+ class User
+ encrypts :name, compressor: ZstdCompressor
+ end
+ ```
+
+ You disable compression by passing `compress: false`.
+
+ ```ruby
+ class User
+ encrypts :name, compress: false
+ end
+ ```
+
+ *heka1024*
+
* Add condensed `#inspect` for `ConnectionPool`, `AbstractAdapter`, and
`DatabaseConfig`.
diff --git a/activerecord/lib/active_record/encryption/config.rb b/activerecord/lib/active_record/encryption/config.rb
index 368bbffb826..7bbf023f29e 100644
--- a/activerecord/lib/active_record/encryption/config.rb
+++ b/activerecord/lib/active_record/encryption/config.rb
@@ -8,7 +8,8 @@ module ActiveRecord
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 @@ module ActiveRecord
self.previous_schemes = []
self.forced_encoding_for_deterministic_encryption = Encoding::UTF_8
self.hash_digest_class = OpenSSL::Digest::SHA1
+ self.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/encryptable_record.rb b/activerecord/lib/active_record/encryption/encryptable_record.rb
index cc2e7e165b3..5ea045d152a 100644
--- a/activerecord/lib/active_record/encryption/encryptable_record.rb
+++ b/activerecord/lib/active_record/encryption/encryptable_record.rb
@@ -46,11 +46,13 @@ module ActiveRecord
# * :previous - List of previous encryption schemes. When provided, they will be used in order when trying to read
# the attribute. Each entry of the list can contain the properties supported by #encrypts. Also, when deterministic
# encryption is used, they will be used to generate additional ciphertexts to check in the queries.
- def encrypts(*names, key_provider: nil, key: nil, deterministic: false, support_unencrypted_data: nil, downcase: false, ignore_case: false, previous: [], **context_properties)
+ def encrypts(*names, key_provider: nil, key: nil, deterministic: false, support_unencrypted_data: nil, downcase: false, ignore_case: false, previous: [],
+ compress: true, compressor: nil, **context_properties)
self.encrypted_attributes ||= Set.new # not using :default because the instance would be shared across classes
names.each do |name|
- encrypt_attribute name, key_provider: key_provider, key: key, deterministic: deterministic, support_unencrypted_data: support_unencrypted_data, downcase: downcase, ignore_case: ignore_case, previous: previous, **context_properties
+ encrypt_attribute name, key_provider: key_provider, key: key, deterministic: deterministic, support_unencrypted_data: support_unencrypted_data, downcase: downcase, ignore_case: ignore_case, previous: previous,
+ compress: compress, compressor: compressor, **context_properties
end
end
@@ -81,12 +83,13 @@ module ActiveRecord
end
end
- def encrypt_attribute(name, key_provider: nil, key: nil, deterministic: false, support_unencrypted_data: nil, downcase: false, ignore_case: false, previous: [], **context_properties)
+ def encrypt_attribute(name, key_provider: nil, key: nil, deterministic: false, support_unencrypted_data: nil, downcase: false, ignore_case: false, previous: [],
+ compress: true, compressor: nil, **context_properties)
encrypted_attributes << name.to_sym
decorate_attributes([name]) do |name, cast_type|
scheme = scheme_for key_provider: key_provider, key: key, deterministic: deterministic, support_unencrypted_data: support_unencrypted_data, \
- downcase: downcase, ignore_case: ignore_case, previous: previous, **context_properties
+ downcase: downcase, ignore_case: ignore_case, previous: previous, compress: compress, compressor: compressor, **context_properties
ActiveRecord::Encryption::EncryptedAttributeType.new(scheme: scheme, cast_type: cast_type, default: columns_hash[name.to_s]&.default)
end
diff --git a/activerecord/lib/active_record/encryption/encryptor.rb b/activerecord/lib/active_record/encryption/encryptor.rb
index baead108d23..f12656b4101 100644
--- a/activerecord/lib/active_record/encryption/encryptor.rb
+++ b/activerecord/lib/active_record/encryption/encryptor.rb
@@ -12,12 +12,20 @@ module ActiveRecord
# It interacts with a KeyProvider for getting the keys, and delegate to
# ActiveRecord::Encryption::Cipher the actual encryption algorithm.
class Encryptor
+ # The compressor to use for compressing the payload
+ attr_reader :compressor
+
# === Options
#
# * :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 +Zlib+.
+ # If you want to use a custom compressor, it must respond to +deflate+ and +inflate+.
+ def initialize(compress: true, compressor: nil)
@compress = compress
+ @compressor = compressor || ActiveRecord::Encryption.config.compressor
end
# Encrypts +clean_text+ and returns the encrypted result
@@ -78,6 +86,10 @@ module ActiveRecord
serializer.binary?
end
+ def compress? # :nodoc:
+ @compress
+ end
+
private
DECRYPT_ERRORS = [OpenSSL::Cipher::CipherError, Errors::EncryptedContentIntegrity, Errors::Decryption]
ENCODING_ERRORS = [EncodingError, Errors::Encoding]
@@ -130,12 +142,8 @@ module ActiveRecord
end
end
- def compress?
- @compress
- end
-
def compress(data)
- Zlib::Deflate.deflate(data).tap do |compressed_data|
+ @compressor.deflate(data).tap do |compressed_data|
compressed_data.force_encoding(data.encoding)
end
end
@@ -149,7 +157,7 @@ module ActiveRecord
end
def uncompress(data)
- Zlib::Inflate.inflate(data).tap do |uncompressed_data|
+ @compressor.inflate(data).tap do |uncompressed_data|
uncompressed_data.force_encoding(data.encoding)
end
end
diff --git a/activerecord/lib/active_record/encryption/scheme.rb b/activerecord/lib/active_record/encryption/scheme.rb
index ba9b790ca6e..122ae11575a 100644
--- a/activerecord/lib/active_record/encryption/scheme.rb
+++ b/activerecord/lib/active_record/encryption/scheme.rb
@@ -11,7 +11,7 @@ module ActiveRecord
attr_accessor :previous_schemes
def initialize(key_provider: nil, key: nil, deterministic: nil, support_unencrypted_data: nil, downcase: nil, ignore_case: nil,
- previous_schemes: nil, **context_properties)
+ previous_schemes: nil, compress: true, compressor: nil, **context_properties)
# Initializing all attributes to +nil+ as we want to allow a "not set" semantics so that we
# can merge schemes without overriding values with defaults. See +#merge+
@@ -24,8 +24,13 @@ module ActiveRecord
@previous_schemes_param = previous_schemes
@previous_schemes = Array.wrap(previous_schemes)
@context_properties = context_properties
+ @compress = compress
+ @compressor = compressor
validate_config!
+
+ @context_properties[:encryptor] = Encryptor.new(compress: @compress) unless @compress
+ @context_properties[:encryptor] = Encryptor.new(compressor: compressor) if compressor
end
def ignore_case?
@@ -78,6 +83,8 @@ module ActiveRecord
def validate_config!
raise Errors::Configuration, "ignore_case: can only be used with deterministic encryption" if @ignore_case && !@deterministic
raise Errors::Configuration, "key_provider: and key: can't be used simultaneously" if @key_provider_param && @key
+ raise Errors::Configuration, "compressor: can't be used with compress: false" if !@compress && @compressor
+ raise Errors::Configuration, "compressor: can't be used with encryptor" if @compressor && @context_properties[:encryptor]
end
def key_provider_from_key
diff --git a/activerecord/test/cases/encryption/encryptable_record_test.rb b/activerecord/test/cases/encryption/encryptable_record_test.rb
index 18affa72110..010f6a9b118 100644
--- a/activerecord/test/cases/encryption/encryptable_record_test.rb
+++ b/activerecord/test/cases/encryption/encryptable_record_test.rb
@@ -413,6 +413,11 @@ class ActiveRecord::Encryption::EncryptableRecordTest < ActiveRecord::Encryption
assert_equal json_bytes, EncryptedBookWithSerializedBinary.create!(logo: json_bytes).logo
end
+ test "can compress data with custom compressor" do
+ name = "a" * 141
+ assert EncryptedBookWithCustomCompressor.create!(name: name).name.start_with?("[compressed]")
+ end
+
private
def build_derived_key_provider_with(hash_digest_class)
ActiveRecord::Encryption.with_encryption_context(key_generator: ActiveRecord::Encryption::KeyGenerator.new(hash_digest_class: hash_digest_class)) do
diff --git a/activerecord/test/cases/encryption/encryptor_test.rb b/activerecord/test/cases/encryption/encryptor_test.rb
index 583ecaad204..07a62c0bfc8 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.deflate(data)
+ "compressed #{data}"
+ end
+
+ def self.inflate(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/activerecord/test/cases/encryption/scheme_test.rb b/activerecord/test/cases/encryption/scheme_test.rb
index 11fd51c4c4a..1ac0137860e 100644
--- a/activerecord/test/cases/encryption/scheme_test.rb
+++ b/activerecord/test/cases/encryption/scheme_test.rb
@@ -7,12 +7,36 @@ class ActiveRecord::Encryption::SchemeTest < ActiveRecord::EncryptionTestCase
test "validates config options when using encrypted attributes" do
assert_invalid_declaration deterministic: false, ignore_case: true
assert_invalid_declaration key: "1234", key_provider: ActiveRecord::Encryption::DerivedSecretKeyProvider.new("my secret")
+ assert_invalid_declaration compress: false, compressor: Zlib
+ assert_invalid_declaration compressor: Zlib, encryptor: ActiveRecord::Encryption::Encryptor.new
assert_valid_declaration deterministic: true
assert_valid_declaration key: "1234"
assert_valid_declaration key_provider: ActiveRecord::Encryption::DerivedSecretKeyProvider.new("my secret")
end
+ test "should create a encryptor well when compressor is given" do
+ MyCompressor = Class.new do
+ def self.deflate(data)
+ "deflated #{data}"
+ end
+
+ def self.inflate(data)
+ data.sub("deflated ", "")
+ end
+ end
+
+ type = declare_encrypts_with compressor: MyCompressor
+
+ assert_equal MyCompressor, type.scheme.to_h[:encryptor].compressor
+ end
+
+ test "should create a encryptor well when compress is false" do
+ type = declare_encrypts_with compress: false
+
+ assert_not type.scheme.to_h[:encryptor].compress?
+ end
+
private
def assert_invalid_declaration(**options)
assert_raises ActiveRecord::Encryption::Errors::Configuration do
diff --git a/activerecord/test/models/book_encrypted.rb b/activerecord/test/models/book_encrypted.rb
index 942e26b210d..22e61c490a1 100644
--- a/activerecord/test/models/book_encrypted.rb
+++ b/activerecord/test/models/book_encrypted.rb
@@ -56,3 +56,19 @@ class EncryptedBookWithSerializedBinary < ActiveRecord::Base
serialize :logo, coder: JSON
encrypts :logo
end
+
+class EncryptedBookWithCustomCompressor < ActiveRecord::Base
+ module CustomCompressor
+ def self.deflate(value)
+ "[compressed] #{value}"
+ end
+
+ def self.inflate(value)
+ value
+ end
+ end
+
+ self.table_name = "encrypted_books"
+
+ encrypts :name, compressor: CustomCompressor
+end
diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb
index f1aaf11e0da..3eb53417c0f 100644
--- a/activerecord/test/schema/schema.rb
+++ b/activerecord/test/schema/schema.rb
@@ -161,7 +161,7 @@ ActiveRecord::Schema.define do
create_table :encrypted_books, id: :integer, force: true do |t|
t.references :author
t.string :format
- t.column :name, :string, default: ""
+ t.column :name, :string, default: "", limit: 1024
t.column :original_name, :string
t.column :logo, :binary
diff --git a/guides/source/active_record_encryption.md b/guides/source/active_record_encryption.md
index e649b38e89d..94d59ab15f8 100644
--- a/guides/source/active_record_encryption.md
+++ b/guides/source/active_record_encryption.md
@@ -298,6 +298,42 @@ 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 setting `compress: false` for encrypted attributes:
+
+```ruby
+class Article < ApplicationRecord
+ encrypts :content, compress: false
+end
+```
+
+You can also configure the algorithm used for the compression. The default compressor is `Zlib`. You can implement your own compressor by creating a class or module that responds to `#deflate(data)` and `#inflate(data)`.
+
+```ruby
+require "zstd-ruby"
+
+module ZstdCompressor
+ def self.deflate(data)
+ Zstd.compress(data)
+ end
+
+ def self.inflate(data)
+ Zstd.decompress(data)
+ end
+end
+
+class User
+ encrypts :name, compressor: ZstdCompressor
+end
+```
+
+You can configure the compressor globally:
+
+```ruby
+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 +533,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 `deflate` and `inflate`. 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.
diff --git a/guides/source/configuring.md b/guides/source/configuring.md
index 8ea2844c898..f2f4ca89ef2 100644
--- a/guides/source/configuring.md
+++ b/guides/source/configuring.md
@@ -1708,6 +1708,12 @@ The default value depends on the `config.load_defaults` target version:
| (original) | `true` |
| 7.1 | `false` |
+#### `config.active_record.encryption.compressor`
+
+Sets the compressor used by Active Record Encryption. The default value is `Zlib`.
+
+You can use your own compressor by setting this to a class that responds to `deflate` and `inflate`.
+
#### `config.active_record.protocol_adapters`
When using a URL to configure the database connection, this option provides a mapping from the protocol to the underlying