active_cipher_storage 1.0.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 561cba3b474dd5c35b19324c325216fe452e2670e1d1bea42f719f368331672a
4
- data.tar.gz: 9e776a018104013b07e2da60e18eeb7ead76d2fa37d0bce929f0fe3b0cfab11f
3
+ metadata.gz: fabc72461d7687d4fe0f17c6d22979b637012b3d160b0d5b21dbd775facd6498
4
+ data.tar.gz: 0a16cf1345eaf8a7962fe086113fc04e9c76350445769a8c809213c9535cfc44
5
5
  SHA512:
6
- metadata.gz: a9da8b50775c261cd00047bc1af09f57aad8523e672e4c70e544ca49ce3166294778c53fdbd55a9c89fb2ff16aeb6d8b3cdb8bce5403261701c685f8895be74e
7
- data.tar.gz: 23f93a25b27f7a6bad2a233ffebe0134b0a513426931cbaf2f70beec923b8152d2beff878017403da5305c1119cf3707281f22699d03d459a0c476912e197366
6
+ metadata.gz: 7bc1e4f3f4721a294bd28043a9672843b5a2bd26b932b2cd7390f16cbf79bec98b3b62bfd9169e6f9267f429d9fccf896309d4dae7e389bc3a6c8284c44988dc
7
+ data.tar.gz: 22e3bc0328636ee8078cd679ace9f49c5ab83617ba814191f2c7d80886e19429061d7bc193f751af2202175d1af444b3b3a5d8c45fc6d9ab8ae77eeb510529f6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.1] - 2026-04-25
11
+
12
+ ### Changed
13
+
14
+ - Back gem configuration with Rails-style ActiveSupport options while preserving the existing public configuration API.
15
+ - Document the Active Storage upload encryption flag and plaintext read compatibility behavior.
16
+
17
+ ### Fixed
18
+
19
+ - Reject reordered streaming frames and trailing bytes after the final encrypted frame.
20
+ - Validate S3 multipart chunk sizes before upload so invalid part sizes fail early.
21
+ - Mark plaintext Active Storage uploads explicitly when encryption is disabled.
22
+
10
23
  ## [1.0.0] - 2026-04-25
11
24
 
12
25
  ### Added
@@ -20,5 +33,6 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
20
33
  - Header-only key rotation for re-wrapping encrypted DEKs.
21
34
  - Unit and integration coverage for crypto, providers, Active Storage, S3, multipart upload, streaming, metadata, and key rotation.
22
35
 
23
- [Unreleased]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.0...HEAD
36
+ [Unreleased]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.1...HEAD
37
+ [1.0.1]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.0...v1.0.1
24
38
  [1.0.0]: https://github.com/codebyjass/active-cipher-storage/releases/tag/v1.0.0
data/README.md CHANGED
@@ -10,8 +10,6 @@ ActiveCipherStorage supports three upload paths:
10
10
  - **Direct S3 clients** — service objects and non-Rails apps can call `put_encrypted`, `get_decrypted`, and `stream_decrypted`.
11
11
  - **Frontend chunk uploads** — the frontend sends plaintext chunks to your backend; the backend encrypts those chunks and uploads encrypted S3 multipart parts.
12
12
 
13
- ---
14
-
15
13
  ## Contents
16
14
 
17
15
  1. [How it works](#how-it-works)
@@ -36,8 +34,6 @@ ActiveCipherStorage supports three upload paths:
36
34
  17. [License](#license)
37
35
  18. [Ruby and Rails compatibility](#ruby-and-rails-compatibility)
38
36
 
39
- ---
40
-
41
37
  ## How it works
42
38
 
43
39
  Every encrypted file is self-contained. No external metadata store is needed.
@@ -72,8 +68,6 @@ Decryption reverses the flow: the KMS provider unwraps the DEK from the header,
72
68
 
73
69
  Every encrypted payload uses the same self-describing format, whether it came from Active Storage, the direct S3 adapter, or the backend chunk upload API.
74
70
 
75
- ---
76
-
77
71
  ## Installation
78
72
 
79
73
  ```ruby
@@ -91,8 +85,6 @@ gem "aws-sdk-s3"
91
85
  bundle install
92
86
  ```
93
87
 
94
- ---
95
-
96
88
  ## Rails / Active Storage setup
97
89
 
98
90
  ### 1. Configure a KMS provider
@@ -113,6 +105,7 @@ ActiveCipherStorage.configure do |config|
113
105
 
114
106
  # Tuning (optional)
115
107
  config.chunk_size = 5 * 1024 * 1024 # 5 MiB per chunk (default)
108
+ config.encrypt_uploads = true # set false to store new Active Storage uploads as plaintext
116
109
  end
117
110
  ```
118
111
 
@@ -163,9 +156,9 @@ url = rails_blob_url(user.document)
163
156
 
164
157
  Active Storage transparently encrypts on upload and decrypts on download. Existing plaintext objects are still readable: if a blob does not start with the `ACS\x01` magic header, the service returns it unchanged.
165
158
 
166
- Direct Active Storage browser uploads are intentionally disabled because they bypass the backend encryption layer.
159
+ `config.encrypt_uploads` controls new Active Storage writes only. When disabled, new uploads are stored as plaintext and marked with `"encrypted": false` metadata. Reads continue to auto-detect by payload header, so existing encrypted blobs still decrypt correctly and existing plaintext blobs still download unchanged.
167
160
 
168
- ---
161
+ Direct Active Storage browser uploads are intentionally disabled because they bypass the backend encryption layer.
169
162
 
170
163
  ## Standalone S3 usage
171
164
 
@@ -202,8 +195,6 @@ s3 = ActiveCipherStorage::Adapters::S3Adapter.new(
202
195
  )
203
196
  ```
204
197
 
205
- ---
206
-
207
198
  ## Chunked multipart upload
208
199
 
209
200
  For large files where the frontend sends data in separate HTTP requests, use `EncryptedMultipartUpload`. Each frontend chunk is encrypted by the backend as an authenticated ACS frame and buffered until the S3 multipart minimum part size is met, then flushed as an encrypted S3 multipart part.
@@ -280,8 +271,6 @@ uploader = ActiveCipherStorage::EncryptedMultipartUpload.new(
280
271
 
281
272
  **Security:** The plaintext DEK is never stored in the session. Only the KMS-wrapped encrypted DEK is persisted; it is decrypted fresh for each chunk and zeroed immediately after use.
282
273
 
283
- ---
284
-
285
274
  ## Streaming download
286
275
 
287
276
  `stream_decrypted` pipes S3 bytes through the decryptor and yields plaintext chunks on the fly. Memory usage is bounded by one ACS chunk (default 5 MiB) regardless of file size.
@@ -317,8 +306,6 @@ end
317
306
 
318
307
  Use `stream_decrypted` for chunked ACS objects. If the object is non-chunked, call `get_decrypted`; streaming a non-chunked or non-ACS/plaintext object raises `InvalidFormat` with a clear error.
319
308
 
320
- ---
321
-
322
309
  ## Manual encrypt / decrypt
323
310
 
324
311
  Use `Cipher` (in-memory) or `StreamCipher` (chunked, constant memory):
@@ -354,8 +341,6 @@ File.open("large.bin.enc", "rb") do |input|
354
341
  end
355
342
  ```
356
343
 
357
- ---
358
-
359
344
  ## Blob metadata
360
345
 
361
346
  When using the Rails Active Storage adapter, encryption metadata is automatically written to `ActiveStorage::Blob#metadata` after each upload:
@@ -404,8 +389,6 @@ end
404
389
 
405
390
  Only the encrypted DEK in the file header is rewritten — the IV, ciphertext, and auth tags are copied byte-for-byte. This makes rotation O(header size) in data transferred per file, not O(file size). For AWS KMS → AWS KMS rotations, the plaintext DEK never leaves KMS (uses `ReEncrypt` API).
406
391
 
407
- ---
408
-
409
392
  ## KMS providers
410
393
 
411
394
  ### Environment-variable provider
@@ -477,8 +460,6 @@ The `provider_id` is embedded in every encrypted file. Routing at decrypt time i
477
460
 
478
461
  Implement `rotate_data_key(encrypted_key)` as well if the provider can re-wrap encrypted DEKs without exposing plaintext key material.
479
462
 
480
- ---
481
-
482
463
  ## Key rotation
483
464
 
484
465
  ### AWS KMS automatic rotation
@@ -516,8 +497,6 @@ new_provider = ActiveCipherStorage::Providers::EnvProvider.new(env_var: "NEW_KEY
516
497
  new_dek = new_provider.rotate_data_key(encrypted_dek, old_provider: old_provider)
517
498
  ```
518
499
 
519
- ---
520
-
521
500
  ## Configuration reference
522
501
 
523
502
  ```ruby
@@ -532,13 +511,15 @@ ActiveCipherStorage.configure do |config|
532
511
  # Must be >= 5 MiB for S3 multipart uploads (except the last part).
533
512
  config.chunk_size = 5 * 1024 * 1024
534
513
 
514
+ # Controls new Active Storage uploads only. Downloads always auto-detect
515
+ # encrypted vs. plaintext payloads by the ACS header.
516
+ config.encrypt_uploads = true
517
+
535
518
  # Logger instance. Defaults to STDOUT at WARN level.
536
519
  config.logger = Rails.logger
537
520
  end
538
521
  ```
539
522
 
540
- ---
541
-
542
523
  ## Encryption format
543
524
 
544
525
  Every encrypted payload is a self-describing binary blob:
@@ -575,8 +556,6 @@ CHUNKED PAYLOAD (repeated until final frame)
575
556
  - Auth tag failure raises `DecryptionError` immediately — no partial plaintext is returned.
576
557
  - Unsupported format versions, algorithms, and header flags raise `InvalidFormat` instead of being parsed permissively.
577
558
 
578
- ---
579
-
580
559
  ## Security notes
581
560
 
582
561
  | Risk | Mitigation |
@@ -587,8 +566,6 @@ CHUNKED PAYLOAD (repeated until final frame)
587
566
  | Partial-read oracle | `DecryptionError` is always raised from `cipher.final`; no partial plaintext is ever returned. |
588
567
  | Accidental plaintext upload | All upload paths go through the cipher layer; there is no bypass. |
589
568
 
590
- ---
591
-
592
569
  ## Testing
593
570
 
594
571
  ```bash
@@ -604,8 +581,6 @@ bundle exec rake spec:integration
604
581
 
605
582
  Integration tests use in-memory fakes for both Active Storage and S3 — no real AWS credentials or S3 bucket required.
606
583
 
607
- ---
608
-
609
584
  ## Contributing
610
585
 
611
586
  Contributions are welcome. Please read `CONTRIBUTING.md` before opening a pull request.
@@ -618,22 +593,16 @@ bundle exec rspec
618
593
 
619
594
  Do not commit secrets, credentials, `.env` files, local coverage output, or generated gems.
620
595
 
621
- ---
622
-
623
596
  ## Security reports
624
597
 
625
598
  Please do not open public GitHub issues for vulnerabilities. Follow `SECURITY.md` and use GitHub private vulnerability reporting if it is available for the repository:
626
599
 
627
600
  https://github.com/codebyjass/active-cipher-storage/security/advisories/new
628
601
 
629
- ---
630
-
631
602
  ## License
632
603
 
633
604
  The gem is available as open source under the terms of the MIT License. See `LICENSE`.
634
605
 
635
- ---
636
-
637
606
  ## Ruby and Rails compatibility
638
607
 
639
608
  | | Version |
@@ -38,6 +38,7 @@ Gem::Specification.new do |spec|
38
38
  spec.require_paths = ["lib"]
39
39
 
40
40
  # Core — no runtime dep on Rails or AWS
41
+ spec.add_dependency "activesupport", ">= 7.0", "< 9.0"
41
42
  spec.add_dependency "concurrent-ruby", "~> 1.2"
42
43
 
43
44
  # Optional integrations — loaded only when the relevant adapter is used
@@ -33,6 +33,17 @@ module ActiveCipherStorage
33
33
 
34
34
  def upload(key, io, checksum: nil, content_type: nil, filename: nil,
35
35
  disposition: nil, custom_metadata: {})
36
+ unless ActiveCipherStorage.configuration.encrypt_uploads
37
+ @inner.upload(key, io,
38
+ checksum: checksum,
39
+ content_type: content_type,
40
+ filename: filename,
41
+ disposition: disposition,
42
+ custom_metadata: custom_metadata)
43
+ BlobMetadata.write_plaintext(key)
44
+ return
45
+ end
46
+
36
47
  @inner.upload(key, encrypt_io(io),
37
48
  checksum: nil, # checksum is over plaintext; skip for ciphertext
38
49
  content_type: "application/octet-stream",
@@ -65,7 +65,7 @@ module ActiveCipherStorage
65
65
  end
66
66
 
67
67
  def multipart_put(key, io, **options)
68
- require "aws-sdk-s3"
68
+ validate_multipart_chunk_size!
69
69
  upload_id = s3.create_multipart_upload(bucket: @bucket, key: key,
70
70
  **upload_options(options)).upload_id
71
71
  parts = stream_multipart_parts(key, io, upload_id)
@@ -147,6 +147,14 @@ module ActiveCipherStorage
147
147
  c
148
148
  end
149
149
 
150
+ def validate_multipart_chunk_size!
151
+ min_size = Configuration::MINIMUM_S3_MULTIPART_PART_SIZE
152
+ return if @config.chunk_size >= min_size
153
+
154
+ raise ArgumentError,
155
+ "chunk_size must be at least 5 MiB for S3 multipart uploads"
156
+ end
157
+
150
158
  def s3
151
159
  @s3 ||= begin
152
160
  require "aws-sdk-s3"
@@ -180,10 +188,16 @@ module ActiveCipherStorage
180
188
  @dek = nil
181
189
  @header_done = false
182
190
  @done = false
191
+ @expected_seq = 1
183
192
  end
184
193
 
185
194
  def push(bytes, &block)
186
- return if @done
195
+ if @done
196
+ raise Errors::InvalidFormat, "Trailing bytes after final frame" unless bytes.empty?
197
+
198
+ return
199
+ end
200
+
187
201
  @buffer += bytes.b
188
202
  try_parse_header unless @header_done
189
203
  drain_frames(&block) if @header_done
@@ -191,6 +205,7 @@ module ActiveCipherStorage
191
205
 
192
206
  def finish!
193
207
  raise Errors::InvalidFormat, "Stream ended before final frame" unless @done
208
+ raise Errors::InvalidFormat, "Trailing bytes after final frame" unless @buffer.empty?
194
209
  ensure
195
210
  zero_bytes!(@dek)
196
211
  end
@@ -227,12 +242,21 @@ module ActiveCipherStorage
227
242
  frame = Format.read_chunk(StringIO.new(@buffer.byteslice(0, frame_size)))
228
243
  @buffer = (@buffer.byteslice(frame_size..) || "".b).b
229
244
 
245
+ validate_frame_sequence!(frame[:seq])
230
246
  plaintext = decrypt_frame(frame)
231
247
  block.call(plaintext) unless plaintext.empty?
232
248
  @done = (frame[:seq] == Format::FINAL_SEQ)
249
+ @expected_seq += 1 unless @done
233
250
  end
234
251
  end
235
252
 
253
+ def validate_frame_sequence!(seq)
254
+ return if [Format::FINAL_SEQ, @expected_seq].include?(seq)
255
+
256
+ raise Errors::InvalidFormat,
257
+ "Unexpected chunk sequence: expected #{@expected_seq}, got #{seq}"
258
+ end
259
+
236
260
  def decrypt_frame(frame)
237
261
  c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO)
238
262
  c.decrypt
@@ -31,6 +31,26 @@ module ActiveCipherStorage
31
31
  )
32
32
  end
33
33
 
34
+ def self.write_plaintext(storage_key)
35
+ return unless active_storage_available?
36
+
37
+ blob = ActiveStorage::Blob.find_by(key: storage_key)
38
+ return unless blob
39
+
40
+ blob.update_columns(
41
+ metadata: blob.metadata.merge(
42
+ "encrypted" => false,
43
+ "cipher_version" => nil,
44
+ "provider_id" => nil,
45
+ "kms_key_id" => nil
46
+ ).compact
47
+ )
48
+ rescue => e
49
+ ActiveCipherStorage.configuration.logger.warn(
50
+ "[ActiveCipherStorage] Could not write plaintext blob metadata for #{storage_key}: #{e.message}"
51
+ )
52
+ end
53
+
34
54
  def self.update_after_rotation(storage_key, new_provider)
35
55
  return unless active_storage_available?
36
56
 
@@ -1,4 +1,5 @@
1
1
  require "logger"
2
+ require "active_support/ordered_options"
2
3
 
3
4
  module ActiveCipherStorage
4
5
  class Configuration
@@ -7,40 +8,81 @@ module ActiveCipherStorage
7
8
 
8
9
  # Bytes per plaintext chunk in streaming mode (default 5 MiB — matches the
9
10
  # minimum S3 multipart part size, so each chunk maps to exactly one part).
11
+ MINIMUM_S3_MULTIPART_PART_SIZE = 5 * 1024 * 1024
10
12
  DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024
11
13
 
12
- attr_reader :provider
13
- attr_accessor :algorithm, :chunk_size, :logger
14
+ attr_reader :config
14
15
 
15
16
  def initialize
16
- @algorithm = "aes-256-gcm"
17
- @chunk_size = DEFAULT_CHUNK_SIZE
18
- @provider = nil
19
- @logger = Logger.new($stdout, level: Logger::WARN)
17
+ @config = ActiveSupport::OrderedOptions.new
18
+ self.algorithm = "aes-256-gcm"
19
+ self.chunk_size = DEFAULT_CHUNK_SIZE
20
+ self.encrypt_uploads = true
21
+ self.logger = Logger.new($stdout, level: Logger::WARN)
22
+ end
23
+
24
+ def algorithm
25
+ config.algorithm
26
+ end
27
+
28
+ def algorithm=(value)
29
+ config.algorithm = value
30
+ end
31
+
32
+ def chunk_size
33
+ config.chunk_size
34
+ end
35
+
36
+ def chunk_size=(value)
37
+ config.chunk_size = value
38
+ end
39
+
40
+ def encrypt_uploads
41
+ config.encrypt_uploads
42
+ end
43
+
44
+ def encrypt_uploads=(value)
45
+ config.encrypt_uploads = value
46
+ end
47
+
48
+ def logger
49
+ config.logger
50
+ end
51
+
52
+ def logger=(value)
53
+ config.logger = value
54
+ end
55
+
56
+ def provider
57
+ config.provider
20
58
  end
21
59
 
22
60
  # Accept a provider instance or a symbol shorthand (:env, :aws_kms).
23
61
  def provider=(value)
24
- @provider = case value
25
- when Symbol then resolve_provider(value)
26
- when Providers::Base then value
27
- else
28
- raise ArgumentError,
29
- "provider must be a Providers::Base instance or " \
30
- "one of :env, :aws_kms — got #{value.inspect}"
31
- end
62
+ config.provider = case value
63
+ when Symbol then resolve_provider(value)
64
+ when Providers::Base then value
65
+ else
66
+ raise ArgumentError,
67
+ "provider must be a Providers::Base instance or " \
68
+ "one of :env, :aws_kms — got #{value.inspect}"
69
+ end
32
70
  end
33
71
 
34
72
  def validate!
35
73
  raise ProviderError, "No KMS provider configured. " \
36
- "Set ActiveCipherStorage.configuration.provider." unless @provider
74
+ "Set ActiveCipherStorage.configuration.provider." unless provider
37
75
 
38
- unless ALGORITHMS.include?(@algorithm)
39
- raise ArgumentError, "Unsupported algorithm: #{@algorithm.inspect}. " \
76
+ unless ALGORITHMS.include?(algorithm)
77
+ raise ArgumentError, "Unsupported algorithm: #{algorithm.inspect}. " \
40
78
  "Supported: #{ALGORITHMS.join(', ')}"
41
79
  end
42
80
 
43
- raise ArgumentError, "chunk_size must be positive" unless @chunk_size.positive?
81
+ raise ArgumentError, "chunk_size must be positive" unless chunk_size.positive?
82
+
83
+ return if [true, false].include?(encrypt_uploads)
84
+
85
+ raise ArgumentError, "encrypt_uploads must be true or false"
44
86
  end
45
87
 
46
88
  private
@@ -31,6 +31,7 @@ module ActiveCipherStorage
31
31
  @config = config || ActiveCipherStorage.configuration
32
32
  @store = store || MemorySessionStore.new
33
33
  @config.validate!
34
+ validate_multipart_chunk_size!
34
35
  end
35
36
 
36
37
  # Starts a new multipart upload. Returns an opaque session_id.
@@ -166,6 +167,14 @@ module ActiveCipherStorage
166
167
  @store.write(id, data, expires_in: SESSION_TTL)
167
168
  end
168
169
 
170
+ def validate_multipart_chunk_size!
171
+ min_size = Configuration::MINIMUM_S3_MULTIPART_PART_SIZE
172
+ return if @config.chunk_size >= min_size
173
+
174
+ raise ArgumentError,
175
+ "chunk_size must be at least 5 MiB for S3 multipart uploads"
176
+ end
177
+
169
178
  # Thread-safe in-memory session store backed by Concurrent::Map.
170
179
  # Replace with a Rails.cache wrapper for multi-process deployments.
171
180
  class MemorySessionStore
@@ -47,13 +47,19 @@ module ActiveCipherStorage
47
47
  raise Errors::InvalidFormat, "Payload is not chunked; use Cipher#decrypt" unless header.chunked
48
48
 
49
49
  key = @provider.decrypt_data_key(header.encrypted_dek)
50
+ expected_seq = 1
50
51
  loop do
51
52
  frame = Format.read_chunk(input_io)
52
53
  raise Errors::InvalidFormat, "Unexpected end of stream — missing final frame" if frame.nil?
53
54
 
55
+ validate_frame_sequence!(frame[:seq], expected_seq)
54
56
  output_io.write(decrypt_chunk(frame[:ciphertext], key, frame[:iv], frame[:auth_tag], frame[:seq]))
55
57
  break if frame[:seq] == Format::FINAL_SEQ
58
+
59
+ expected_seq += 1
56
60
  end
61
+
62
+ ensure_no_trailing_bytes!(input_io)
57
63
  ensure
58
64
  zero_bytes!(key)
59
65
  end
@@ -89,6 +95,20 @@ module ActiveCipherStorage
89
95
  "Authentication failed on chunk seq=#{seq} — data may be tampered"
90
96
  end
91
97
 
98
+ def validate_frame_sequence!(seq, expected_seq)
99
+ return if seq == Format::FINAL_SEQ || seq == expected_seq
100
+
101
+ raise Errors::InvalidFormat,
102
+ "Unexpected chunk sequence: expected #{expected_seq}, got #{seq}"
103
+ end
104
+
105
+ def ensure_no_trailing_bytes!(input_io)
106
+ trailing = input_io.read(1)
107
+ return if trailing.nil? || trailing.empty?
108
+
109
+ raise Errors::InvalidFormat, "Trailing bytes after final frame"
110
+ end
111
+
92
112
  def build_cipher(mode, key, iv, auth_tag, seq)
93
113
  c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO)
94
114
  mode == :encrypt ? c.encrypt : c.decrypt
@@ -1,3 +1,3 @@
1
1
  module ActiveCipherStorage
2
- VERSION = "1.0.0"
2
+ VERSION = "1.0.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_cipher_storage
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaspreet Singh
@@ -10,6 +10,26 @@ bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-04-25 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
13
33
  - !ruby/object:Gem::Dependency
14
34
  name: concurrent-ruby
15
35
  requirement: !ruby/object:Gem::Requirement