active_storage_validations 3.0.6 → 4.0.0

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/README.md +178 -115
  4. data/Rakefile +13 -15
  5. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +0 -1
  6. data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +31 -0
  7. data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +44 -0
  8. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +15 -24
  9. data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +41 -7
  10. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +36 -10
  11. data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +3 -9
  12. data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +5 -4
  13. data/lib/active_storage_validations/analyzer/video_analyzer.rb +0 -1
  14. data/lib/active_storage_validations/analyzer.rb +35 -3
  15. data/lib/active_storage_validations/content_type_validator.rb +24 -6
  16. data/lib/active_storage_validations/dimension_validator.rb +4 -2
  17. data/lib/active_storage_validations/form_builder.rb +43 -0
  18. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +8 -0
  19. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +4 -0
  20. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -1
  21. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +12 -0
  22. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +8 -0
  23. data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +8 -0
  24. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +4 -0
  25. data/lib/active_storage_validations/matchers/pages_validator_matcher.rb +8 -0
  26. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +8 -0
  27. data/lib/active_storage_validations/matchers/shared/asv_except_onable.rb +57 -0
  28. data/lib/active_storage_validations/matchers/shared/asv_spoofing_protectable.rb +44 -0
  29. data/lib/active_storage_validations/matchers/shared/asv_timeoutable.rb +32 -0
  30. data/lib/active_storage_validations/matchers.rb +9 -1
  31. data/lib/active_storage_validations/railtie.rb +6 -0
  32. data/lib/active_storage_validations/shared/asv_analyzable.rb +66 -8
  33. data/lib/active_storage_validations/shared/asv_attachable.rb +2 -9
  34. data/lib/active_storage_validations/shared/asv_commandable.rb +95 -0
  35. data/lib/active_storage_validations/shared/asv_optionable.rb +5 -1
  36. data/lib/active_storage_validations/version.rb +1 -1
  37. data/lib/active_storage_validations.rb +22 -0
  38. metadata +25 -72
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb09e2b76b2325f8dab17bd4774b91bb1f041766e3705e563f338951d04e191a
4
- data.tar.gz: 56ed87a99def7941a8d17e4433980c947c8771c3dd42141d0f1f13702c8556c2
3
+ metadata.gz: 21b87628a4e532d945c76ceca7d37444c7524f15ed11b587a4bab0a5eae7eaef
4
+ data.tar.gz: 90fd47c4a439b00503e8997de2e344ace1771d15206a1e147fb785b94280abd4
5
5
  SHA512:
6
- metadata.gz: 84fb802a4958ef5b3e00e088383768540849a883b3f2bd757b19ed24c0516d1fd8a498aaaac93ef0e02d6105dca0c6058e5a85ac3a4d1a1b0d79cd1e682dfb45
7
- data.tar.gz: de96e6b525ca4ff0e9e3342b9bd1719c199e956b807467c62d6f063e649f80348e9b323525b1c99f203b025e08a995bc8534b1fd01c434579eca5526ee2ced61
6
+ metadata.gz: b851e77dcdd882cb2bcae49dc3871c5d5d5920409cb94bba357a77ecd55ff34efd0266fe52bb0866bf31db52eb324bc0ef80749d2805dd27b97d7c89a2adcb51
7
+ data.tar.gz: 422c62e2832c2adf34686832ec76ecc570f093133110838234c854c2ede5485324ebf9865aeb911f590a9c003485fd2d6535cced5e74d6ec686a562157ce983a
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2018-2025 Igor Kasyanchuk
1
+ Copyright 2018-2026 Igor Kasyanchuk
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # Active Storage Validations
5
5
 
6
- [![MiniTest](https://github.com/igorkasyanchuk/active_storage_validations/workflows/MiniTest/badge.svg)](https://github.com/igorkasyanchuk/active_storage_validations/actions)
6
+ [![CI](https://github.com/igorkasyanchuk/active_storage_validations/actions/workflows/main.yml/badge.svg)](https://github.com/igorkasyanchuk/active_storage_validations/actions/workflows/main.yml)
7
7
  [![RailsJazz](https://github.com/igorkasyanchuk/rails_time_travel/blob/main/docs/my_other.svg?raw=true)](https://www.railsjazz.com)
8
8
  [![https://www.patreon.com/igorkasyanchuk](https://github.com/igorkasyanchuk/rails_time_travel/blob/main/docs/patron.svg?raw=true)](https://www.patreon.com/igorkasyanchuk)
9
9
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  Active Storage Validations is a gem that allows you to add validations for Active Storage attributes.
13
13
 
14
- This gems is doing it right for you! Just use `validates :avatar, attached: true, content_type: 'image/png'` and that's it!
14
+ This gem is doing it right for you! Just use `validates :avatar, attached: true, content_type: 'image/png'` and that's it!
15
15
 
16
16
  ## Table of Contents
17
17
 
@@ -22,6 +22,7 @@ This gems is doing it right for you! Just use `validates :avatar, attached: true
22
22
  - [Using video and audio metadata validators](#using-video-and-audio-metadata-validators)
23
23
  - [Using pdf metadata validators](#using-pdf-metadata-validators)
24
24
  - [Using content type spoofing protection validator option](#using-content-type-spoofing-protection-validator-option)
25
+ - [Configuration](#configuration)
25
26
  - [Validators](#validators)
26
27
  - [Attached](#attached)
27
28
  - [Limit](#limit)
@@ -33,8 +34,7 @@ This gems is doing it right for you! Just use `validates :avatar, attached: true
33
34
  - [Aspect ratio](#aspect-ratio)
34
35
  - [Processable file](#processable-file)
35
36
  - [Pages](#pages)
36
- - [Upgrading from 1.x to 2.x](#upgrading-from-1x-to-2x)
37
- - [Upgrading from 2.x to 3.x](#upgrading-from-2x-to-3x)
37
+ - [Upgrading](#upgrading)
38
38
  - [Internationalization (I18n)](#internationalization-i18n)
39
39
  - [Test matchers](#test-matchers)
40
40
  - [Contributing](#contributing)
@@ -44,7 +44,7 @@ This gems is doing it right for you! Just use `validates :avatar, attached: true
44
44
 
45
45
  ### Installation
46
46
 
47
- Active Storage Validations work with Rails 6.1.4 onwards. Add this line to your application's Gemfile:
47
+ Active Storage Validations work with Rails 7.0.1 onwards and Ruby 3.3 onwards. Add this line to your application's Gemfile:
48
48
 
49
49
  ```ruby
50
50
  gem 'active_storage_validations'
@@ -65,12 +65,14 @@ Once you have installed the gem, I18n error messages will be added automatically
65
65
  Optionally, to use the image metadata validators (`dimension`, `aspect_ratio` and `processable_file`), you will have to add one of the corresponding gems:
66
66
 
67
67
  ```ruby
68
- gem 'mini_magick', '>= 4.9.5'
69
- # Or
70
68
  gem 'ruby-vips', '>= 2.1.0'
69
+ # Or
70
+ gem 'mini_magick', '>= 4.9.5'
71
71
  ```
72
72
 
73
- Plus, you have to be sure to have the corresponding command-line tool installed on your system. For example, to use `mini_magick` gem, you need to have `imagemagick` installed on your system (both on your local and in your CI / production environments).
73
+ Plus, you have to be sure to have the corresponding command-line tool installed on your system (`libvips` for `ruby-vips`, or ImageMagick for `mini_magick` both locally and in CI / production).
74
+
75
+ We recommend **libvips** (`ruby-vips` + `config.active_storage.variant_processor = :vips`) for these validators. Rails already defaults Active Storage variants to libvips ([`ActiveStorage::Variant`](https://api.rubyonrails.org/classes/ActiveStorage/Variant.html)), and our [image processor benchmarks](benchmark/BASELINE.md#image-processors--vips-vs-mini_magick) show cold metadata analysis is about **8× faster** than MiniMagick/ImageMagick on the same machine and fixtures. Warm validations (cached `asv_*` metadata) are similar for both.
74
76
 
75
77
  ### Using video and audio metadata validators
76
78
 
@@ -82,9 +84,51 @@ To use the pdf metadata validators (`dimension`, `aspect_ratio`, `processable_fi
82
84
 
83
85
  ### Using content type spoofing protection validator option
84
86
 
85
- To use the `spoofing_protection` option with the `content_type` validator, you only need to have the UNIX `file` command on your system.
87
+ To use the `spoofing_protection` option with the `content_type` validator:
88
+
89
+ - Default backend (`true` / `:file`): the UNIX [`file`](https://en.wikipedia.org/wiki/File_(command)) command (usually preinstalled on UNIX systems)
90
+ - Magika backend (`:magika`): the [Google Magika](https://github.com/google/magika) CLI — install via `brew install magika`, their [install script](https://securityresearch.google/magika/getting-started/installation/), `cargo install --locked magika-cli`, or `pipx install magika`
91
+
92
+ Both backends are optional system tools (not Ruby gems). Prefer `:magika` when you can install the CLI — it is generally more accurate than `file`, especially on textual / ambiguous formats. Be sure to install Magika in CI / production if you enable `:magika`.
86
93
 
87
- If you want some inspiration about how to add `imagemagick`, `libvips`, `ffmpeg` or `poppler` to your docker image, you can check how we do it for the gem CI (https://github.com/igorkasyanchuk/active_storage_validations/blob/master/.github/workflows/main.yml)
94
+ If you want some inspiration about how to add `imagemagick`, `libvips`, `ffmpeg`, `poppler` or `magika` to your docker image, you can check how we do it for the gem CI (https://github.com/igorkasyanchuk/active_storage_validations/blob/master/.github/workflows/main.yml)
95
+
96
+ ### Configuration
97
+
98
+ Optional global settings can go in an initializer. Example template:
99
+
100
+ ```ruby
101
+ # config/initializers/active_storage_validations.rb
102
+ ActiveStorageValidations.configure do |config|
103
+ # Infer HTML accept= on file_field from content_type validators (default: true)
104
+ # config.infer_file_field_accept = false
105
+
106
+ # Max time for external analyzer commands: ffprobe, pdfinfo, file, magika, ImageMagick identify, libvips
107
+ # (default: 10.seconds; set to nil to disable)
108
+ # config.command_timeout = 10.seconds
109
+ end
110
+
111
+ # Optional: monitor analyzer timeouts
112
+ # ActiveSupport::Notifications.subscribe("timeout.active_storage_validations") do |*args|
113
+ # event = ActiveSupport::Notifications::Event.new(*args)
114
+ # Rails.logger.warn("[ASV] command timeout: #{event.payload}")
115
+ # end
116
+ ```
117
+
118
+ `command_timeout` bounds metadata analysis used by `dimension`, `aspect_ratio`, `duration`, `pages`, `processable_file`, and `content_type` (with `spoofing_protection`). When a command times out, analysis fails closed and the validator adds its usual error (`file_not_processable` / `media_metadata_missing` / content-type errors) — there is no separate timeout error message.
119
+
120
+ The 10s default is enough for typical uploads. Raise it (or set `nil`) if you analyze very large videos/PDFs, especially on slow or network storage — otherwise those files can start failing validation after upgrade. See [upgrade to 4.x](docs/upgrade_to_4.md#analyzer-command-timeout).
121
+
122
+ Per-validator override (applies to the analysis triggered by that validator; the first analysis for a blob is cached):
123
+
124
+ ```ruby
125
+ validates :video, duration: { less_than: 5.minutes, timeout: 30.seconds }
126
+ ```
127
+
128
+ Notes:
129
+ - Setting `command_timeout` (or per-validator `timeout:`) to `nil` disables the deadline
130
+ - ImageMagick analysis runs `identify` through the same killable command runner (MiniMagick is only used to build the argv)
131
+ - For libvips, a timeout may not immediately free the Ruby thread stuck in FFI/C; the validation still fails closed and emits `timeout.active_storage_validations`
88
132
 
89
133
  ## Validators
90
134
 
@@ -205,10 +249,11 @@ Validates if the attachment has an allowed content type.
205
249
 
206
250
  #### Options
207
251
 
208
- The `content_type` validator has 3 possible options:
252
+ The `content_type` validator has several possible options:
209
253
  - `with`: defines the allowed content type (string, symbol or regex)
210
254
  - `in`: defines the allowed content types (array of strings or symbols)
211
- - `spoofing_protection`: enables content type spoofing protection (boolean, defaults to `false`)
255
+ - `spoofing_protection`: enables content type spoofing protection (`false` by default). Allowed values: `true` / `:file` (UNIX `file` CLI), `:magika` (Google Magika CLI)
256
+ - `timeout`: overrides the global analyzer [command timeout](#configuration) when spoofing protection runs `file` or `magika`
212
257
 
213
258
  As mentioned above, this validator can define content types in several ways:
214
259
  - String: `image/png` or `png`
@@ -226,32 +271,55 @@ class User < ApplicationRecord
226
271
  validates :avatar, content_type: :png # only allows PNG images, same as { with: :png }
227
272
  validates :avatar, content_type: /\Avideo\/.*\z/ # only allows video files
228
273
  validates :avatar, content_type: ['image/png', 'image/jpeg'] # only allows PNG and JPEG images
229
- validates :avatar, content_type: { in: [:png, :jpeg], spoofing_protection: true } # only allows PNG, JPEG and their variants, with spoofing protection enabled
274
+ validates :avatar, content_type: { in: [:png, :jpeg], spoofing_protection: true } # UNIX `file` backend (same as :file)
275
+ validates :avatar, content_type: { in: [:png, :jpeg], spoofing_protection: :magika } # Google Magika CLI backend
276
+ # Stronger protection for media/PDF: sniff + parse
277
+ validates :avatar, content_type: { in: [:png, :jpeg], spoofing_protection: true }, processable_file: true
230
278
  end
231
279
  ```
232
280
 
233
- #### Best practices
281
+ #### HTML `accept` attribute (FormBuilder)
234
282
 
235
- When using the `content_type` validator, it is recommended to reflect the allowed content types in the html [`accept`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept) attribute in the corresponding file field in your views. This will prevent users from trying to upload files with not allowed content types (however it is only an UX improvement, a malicious user can still try to upload files with not allowed content types therefore the backend validation).
283
+ When using Rails' `FormBuilder#file_field`, the gem automatically sets the HTML [`accept`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept) attribute from your `content_type` validators. This improves UX by filtering selectable files in the browser dialog. It is only a frontend hint: a malicious user can still submit disallowed types, so keep the backend validation.
236
284
 
237
- For example, if you want to allow PNG and JPEG images only, you can do this:
238
285
  ```ruby
239
286
  class User < ApplicationRecord
240
- ACCEPTED_CONTENT_TYPES = ['image/png', 'image/jpeg'].freeze
241
-
242
287
  has_one_attached :avatar
243
288
 
244
- validates :avatar, content_type: ACCEPTED_CONTENT_TYPES
289
+ validates :avatar, content_type: ['image/png', 'image/jpeg']
245
290
  end
246
291
  ```
247
292
 
248
293
  ```erb
249
294
  <%= form_with model: @user do |f| %>
250
- <%= f.file_field :avatar,
251
- accept: ACCEPTED_CONTENT_TYPES.join(',') %>
295
+ <%= f.file_field :avatar %>
296
+ <%# => <input type="file" accept="image/png,image/jpeg" ...> %>
252
297
  <% end %>
253
298
  ```
254
299
 
300
+ Explicit `accept` values are never overridden. You can also disable inference:
301
+
302
+ ```erb
303
+ <%# Per field %>
304
+ <%= f.file_field :avatar, infer_accept: false %>
305
+
306
+ <%# Or set a custom accept value %>
307
+ <%= f.file_field :avatar, accept: "image/*" %>
308
+ ```
309
+
310
+ ```ruby
311
+ # Globally — see [Configuration](#configuration) for a full initializer template
312
+ ActiveStorageValidations.infer_file_field_accept = false
313
+
314
+ # Equivalent:
315
+ ActiveStorageValidations.configure { |config| config.infer_file_field_accept = false }
316
+ ```
317
+
318
+ Notes:
319
+ - Only broad MIME-type regexes of the form `/\Aimage\/.*\z/` (or `video` / `audio` / etc.) are inferred, as `image/*`
320
+ - Other regexes (e.g. `/\Aimage\/(png|gif)\z/`) and Proc / dynamic `content_type` options are skipped, since they cannot be reliably represented in `accept`
321
+ - Conditional validators (`if:` / `unless:`) are not evaluated: their content types are always included in `accept`, even when the condition would skip the validator for that record. Backend validation is unchanged; use `infer_accept: false` (or a custom `accept:`) if the picker must match the active conditions
322
+
255
323
  #### Content type shorthands
256
324
 
257
325
  If you choose to use a content_type 'shorthand' (like `png`), note that it will be converted to a full content type using `Marcel::MimeType.for` under the hood. Therefore, you should check if the content_type is registered by [`Marcel::EXTENSIONS`](https://github.com/rails/marcel/blob/main/lib/marcel/tables.rb). If it's not, you can register it by adding the following code to your `config/initializers/mime_types.rb` file:
@@ -264,7 +332,15 @@ Be sure to at least include one the `extensions`, `parents` or `magic` option, o
264
332
 
265
333
  #### Content type spoofing protection
266
334
 
267
- By default, the gem does not prevent content type spoofing. You can enable it by setting the `spoofing_protection` option to `true` in your validator options.
335
+ By default, the gem does not prevent content type spoofing. Enable it with `spoofing_protection`:
336
+
337
+ ```ruby
338
+ validates :avatar, content_type: { in: [:png, :jpeg], spoofing_protection: true } # => :file (UNIX file CLI)
339
+ validates :avatar, content_type: { in: [:png, :jpeg], spoofing_protection: :file } # explicit
340
+ validates :avatar, content_type: { in: [:png, :jpeg], spoofing_protection: :magika } # Google Magika CLI
341
+ ```
342
+
343
+ Override binary paths with `ActiveStorage.paths[:file]` / `ActiveStorage.paths[:magika]` if needed.
268
344
 
269
345
  <details>
270
346
  <summary>
@@ -279,9 +355,22 @@ File content type spoofing happens when an ill-intentioned user uploads a file w
279
355
  How do we prevent it?
280
356
  </summary>
281
357
 
282
- The spoofing protection relies on both the UNIX `file` command and `Marcel` gem. Be careful, since it needs to load the whole file io to perform the analysis, it will use a lot of RAM for very large files. Therefore it could be a wise decision not to enable it in this case.
358
+ Spoofing protection compares the declared Active Storage content type to a type detected by a sniffer CLI, then uses `Marcel` parent types so near-matches still pass:
359
+
360
+ - `:file` (default) — UNIX `file` / libmagic, mostly magic bytes / headers. Zero extra install on most UNIX systems.
361
+ - `:magika` — [Google Magika](https://github.com/google/magika) CLI (ML sniffer; samples begin/middle/end of the file). Generally more accurate than `file`, especially on textual / ambiguous formats (Google reports ~99% F1 vs ~88% for `file --mime` on overlapping types). Prefer `:magika` when you can install the CLI.
362
+
363
+ Neither backend fully parses the file. They do **not** load the whole file into RAM. For already-persisted blobs (e.g. remote storage), the analyzer still downloads the blob to a local tempfile before sniffing. That download is streamed in chunks to disk, but it can still be costly for very large files. Local path uploads are analyzed in place.
364
+
365
+ Detected types are cached on the blob as `asv_content_type` + `asv_content_type_backend`. Switching backend re-analyzes. Legacy blobs that only have `asv_content_type` (no backend key) are treated as `:file` and keep using the cache — they are not re-analyzed.
283
366
 
284
- Take note that the `file` analyzer will not find the exactly same content type as the ActiveStorage blob (ActiveStorage content type detection relies on a different logic using first 4kb of content + filename + extension). To handle this issue, we consider a close parent content type to be a match. For example, for an ActiveStorage blob which content type is `video/x-ms-wmv`, the `file` analyzer will probably detect a `video/x-ms-asf` content type, this will be considered as a valid match because these 2 content types are closely related. The correlation mapping is based on `Marcel::TYPE_PARENTS` table.
367
+ Sniffers will not always return the exact same MIME as Active Storage (AS uses first ~4kb + filename + extension). Close parent types are accepted via `Marcel::TYPE_PARENTS` (e.g. `video/x-ms-wmv` vs `video/x-ms-asf`).
368
+
369
+ For stronger protection on images / video / audio / PDF, combine sniffing with parse validation:
370
+
371
+ ```ruby
372
+ validates :avatar, content_type: { in: [:png, :jpeg], spoofing_protection: true }, processable_file: true
373
+ ```
285
374
  </details>
286
375
 
287
376
  <details>
@@ -289,9 +378,11 @@ Take note that the `file` analyzer will not find the exactly same content type a
289
378
  Edge cases
290
379
  </summary>
291
380
 
292
- The difficulty to accurately predict a mime type may generate false positives, if so there are two solutions available:
293
- - If the ActiveStorage blob content type is closely related to the detected content type using the `file` analyzer, you can enhance `Marcel::TYPE_PARENTS` mapping using `Marcel::MimeType.extend "application/x-rar-compressed", parents: %(application/x-rar)` in the `config/initializers/mime_types.rb` file. (Please drop an issue so we can add it to the gem for everyone!)
294
- - If the ActiveStorage blob content type is not closely related, you still can disable the content type spoofing protection in the validator, if so, please drop us an issue so we can fix it for everyone!
381
+ The difficulty to accurately predict a mime type may generate false positives, if so there are several solutions available:
382
+ - Try the other sniffer backend (`:file` vs `:magika`)
383
+ - For media/PDF that sniffers misidentify but that open correctly, add `processable_file: true`
384
+ - If the ActiveStorage blob content type is closely related to the detected content type, enhance `Marcel::TYPE_PARENTS` mapping using `Marcel::MimeType.extend "application/x-rar-compressed", parents: %(application/x-rar)` in the `config/initializers/mime_types.rb` file. (Please drop an issue so we can add it to the gem for everyone!)
385
+ - If needed, disable spoofing protection in the validator, and please drop us an issue so we can fix it for everyone!
295
386
  </details>
296
387
 
297
388
 
@@ -326,7 +417,7 @@ Validates each attached file size.
326
417
 
327
418
  #### Options
328
419
 
329
- The `size` validator has 5 possible options:
420
+ The `size` validator has 6 possible options:
330
421
  - `less_than`: defines the strict maximum allowed file size
331
422
  - `less_than_or_equal_to`: defines the maximum allowed file size
332
423
  - `greater_than`: defines the strict minimum allowed file size
@@ -384,7 +475,7 @@ Validates the total file size for several files.
384
475
 
385
476
  #### Options
386
477
 
387
- The `total_size` validator has 5 possible options:
478
+ The `total_size` validator has 6 possible options:
388
479
  - `less_than`: defines the strict maximum allowed total file size
389
480
  - `less_than_or_equal_to`: defines the maximum allowed total file size
390
481
  - `greater_than`: defines the strict minimum allowed total file size
@@ -434,7 +525,7 @@ The `total_size` validator error messages expose 4 values that you can use:
434
525
 
435
526
  Validates the dimension of the attached image / video files.
436
527
  It can also be used for pdf files, but it will only analyze the pdf first page, and will assume a DPI of 72.
437
- (be sure to have the right dependencies installed as mentioned in [installation](#installation))
528
+ (be sure to have the right dependencies installed as mentioned in [Getting started](#getting-started))
438
529
 
439
530
  #### Options
440
531
 
@@ -449,6 +540,7 @@ The `dimension` validator has several possible options:
449
540
  - `in`: defines the allowed height range (range)
450
541
  - `min`: defines the minimum allowed width and height (range)
451
542
  - `max`: defines the maximum allowed width and height (range)
543
+ - `timeout`: overrides the global analyzer [command timeout](#configuration) for this validation
452
544
 
453
545
  #### Examples
454
546
 
@@ -499,17 +591,18 @@ The `dimension` validator error messages expose 6 values that you can use:
499
591
  ### Duration
500
592
 
501
593
  Validates the duration of the attached audio / video files.
502
- (be sure to have the right dependencies installed as mentioned in [installation](#installation))
594
+ (be sure to have the right dependencies installed as mentioned in [Using video and audio metadata validators](#using-video-and-audio-metadata-validators))
503
595
 
504
596
  #### Options
505
597
 
506
- The `duration` validator has 5 possible options:
598
+ The `duration` validator has several possible options:
507
599
  - `less_than`: defines the strict maximum allowed file duration
508
600
  - `less_than_or_equal_to`: defines the maximum allowed file duration
509
601
  - `greater_than`: defines the strict minimum allowed file duration
510
602
  - `greater_than_or_equal_to`: defines the minimum allowed file duration
511
603
  - `between`: defines the allowed file duration range
512
604
  - `equal_to`: defines the allowed duration
605
+ - `timeout`: overrides the global analyzer [command timeout](#configuration) for this validation
513
606
 
514
607
  #### Examples
515
608
 
@@ -524,6 +617,7 @@ class User < ApplicationRecord
524
617
  validates :intro_song, duration: { greater_than_or_equal_to: 1.second } # restricts the file duration to >= 1 second
525
618
  validates :intro_song, duration: { between: 1.second..2.minutes } # restricts the file duration to between 1 second and 2 minutes
526
619
  validates :intro_song, duration: { equal_to: 1.minute } # restricts the duration to exactly 1 minute
620
+ validates :intro_song, duration: { less_than: 5.minutes, timeout: 5.seconds } # custom analyzer timeout
527
621
  end
528
622
  ```
529
623
 
@@ -554,13 +648,14 @@ The `duration` validator error messages expose 4 values that you can use:
554
648
 
555
649
  Validates the aspect ratio of the attached image / video files.
556
650
  It can also be used for pdf files, but it will only analyze the pdf first page.
557
- (be sure to have the right dependencies installed as mentioned in [installation](#installation))
651
+ (be sure to have the right dependencies installed as mentioned in [Getting started](#getting-started))
558
652
 
559
653
  #### Options
560
654
 
561
655
  The `aspect_ratio` validator has several options:
562
656
  - `with`: defines the allowed aspect ratio (e.g. `:is_16/9`)
563
657
  - `in`: defines the allowed aspect ratios (e.g. `%i[square landscape]`)
658
+ - `timeout`: overrides the global analyzer [command timeout](#configuration) for this validation
564
659
 
565
660
  This validator can define aspect ratios in several ways:
566
661
  - Symbols:
@@ -607,11 +702,12 @@ The `aspect_ratio` validator error messages expose 4 values that you can use:
607
702
  ### Processable file
608
703
 
609
704
  Validates if the attached files can be processed by MiniMagick or Vips (image), ffmpeg (video/audio) or poppler (pdf).
610
- (be sure to have the right dependencies installed as mentioned in [installation](#installation))
705
+ (be sure to have the right dependencies installed as mentioned in [Getting started](#getting-started))
611
706
 
612
707
  #### Options
613
708
 
614
- The `processable_file` validator has no options.
709
+ The `processable_file` validator supports:
710
+ - `timeout`: overrides the global analyzer [command timeout](#configuration) for this validation
615
711
 
616
712
  #### Examples
617
713
 
@@ -621,9 +717,14 @@ class User < ApplicationRecord
621
717
  has_one_attached :avatar
622
718
 
623
719
  validates :avatar, processable_file: true # ensures that the file is processable by MiniMagick or Vips (image) or ffmpeg (video/audio)
720
+ validates :avatar, processable_file: { timeout: 5.seconds }
624
721
  end
625
722
  ```
626
723
 
724
+ #### Notes
725
+
726
+ Rails 7.2.3.2+ / 8.0.5.1+ / 8.1.3.1+ call `Vips.block_untrusted(true)`, so libvips refuses to load formats marked untrusted (e.g. SVG, BMP). Analysis then returns empty metadata by design. `processable_file` does **not** treat that as failure for those content types. `dimension` / `aspect_ratio` still need width/height — use MiniMagick for those formats, or skip those validators for them.
727
+
627
728
  #### Error messages (I18n)
628
729
 
629
730
  ```yml
@@ -641,17 +742,18 @@ The `processable_file` validator error messages expose 1 value that you can use:
641
742
  ### Pages
642
743
 
643
744
  Validates each attached pdf file number of pages.
644
- (be sure to have the right dependencies installed as mentioned in [installation](#installation))
745
+ (be sure to have the right dependencies installed as mentioned in [Using pdf metadata validators](#using-pdf-metadata-validators))
645
746
 
646
747
  #### Options
647
748
 
648
- The `pages` validator has 6 possible options:
749
+ The `pages` validator has several possible options:
649
750
  - `less_than`: defines the strict maximum allowed number of pages
650
751
  - `less_than_or_equal_to`: defines the maximum allowed number of pages
651
752
  - `greater_than`: defines the strict minimum allowed number of pages
652
753
  - `greater_than_or_equal_to`: defines the minimum allowed number of pages
653
754
  - `between`: defines the allowed number of pages range
654
755
  - `equal_to`: defines the allowed number of pages
756
+ - `timeout`: overrides the global analyzer [command timeout](#configuration) for this validation
655
757
 
656
758
  #### Examples
657
759
 
@@ -692,64 +794,11 @@ The `pages` validator error messages expose 5 values that you can use:
692
794
 
693
795
  ---
694
796
 
695
- ## Upgrading from 1.x to 2.x
696
-
697
- If you are upgrading from 1.x to 2.x, you will be pleased to note that a lot of things have been added and improved!
698
-
699
- Added features:
700
- - `duration` validator has been added for audio / video files
701
- - `dimension` validator now supports videos
702
- - `aspect_ratio` validator now supports videos
703
- - `processable_image` validator is now `processable_file` validator and supports image/video/audio
704
- - Major performance improvement have been added: we now only perform the expensive io analysis operation on the newly attached files. For previously attached files, we validate them using Rails `ActiveStorage::Blob#metadata` internal mecanism ([more here](https://github.com/rails/rails/blob/main/activestorage/app/models/active_storage/blob/analyzable.rb)).
705
- - All error messages have been given an upgrade and new variables that you can use
706
-
707
- But this major version bump also comes with some breaking changes. Below are the main breaking changes you need to be aware of:
708
- - Error messages
709
- - We advise you to replace all the v1 translations by the new v2 rather than changing them one by one. A majority of messages have been completely rewritten to be more consistent and easier to understand.
710
- - If you wish to change them one by one, here is the list of changes to make:
711
- - Some validator errors have been totally changed:
712
- - `limit` validator keys have been totally reworked
713
- - `dimension` validator keys have been totally reworked
714
- - `content_type` validator keys have been totally reworked
715
- - `processable_image` validator keys have been totally reworked
716
- - Some keys have been changed:
717
- - `image_metadata_missing` has been replaced by `media_metadata_missing`
718
- - `aspect_ratio_is_not` has been replaced by `aspect_ratio_not_x_y`
719
- - Some error messages variables names have been changed to improve readability:
720
- - `aspect_ratio` validator:
721
- - `aspect_ratio` has been replaced by `authorized_aspect_ratios`
722
- - `content_type` validator:
723
- - `authorized_types` has been replaced by `authorized_human_content_types`
724
- - `size` validator:
725
- - `min_size` has been replaced by `min`
726
- - `max_size` has been replaced by `max`
727
- - `total_size` validator:
728
- - `min_size` has been replaced by `min`
729
- - `max_size` has been replaced by `max`
730
-
731
- - `content_type` validator
732
- - The `:in` option now only accepts 'valid' content types (ie content types deemed by Marcel as valid).
733
- - The check was mistakenly only performed on the `:with` option previously. Therefore, invalid content types were accepted in the `:in` option, which is not the expected behavior.
734
- - This might break some cases when you had for example `content_type: ['image/png', 'image/jpg']`, because `image/jpg` is not a valid content type, it should be replaced by `image/jpeg`.
735
- - An `ArgumentError` is now raised if `image/jpg` is used to make it easier to fix. You should now only use `image/jpeg`.
736
-
737
- - `processable_image` validator
738
- - The validator has been replaced by `processable_file` validator, be sure to replace `processable_image: true` to `processable_file: true`
739
- - The associated matcher has also been updated accordingly, be sure to replace `validate_processable_image_of` to `validate_processable_file_of`
740
-
741
- ## Upgrading from 2.x to 3.x
742
-
743
- Version 3 comes with the ability to support single page pdf `dimension` / `aspect_ratio` analysis, we had to make a breaking change:
744
- - To analyze PDFs, you must install the `poppler` PDF processing dependency
745
- - It's a Rails-supported PDF processing dependency (https://guides.rubyonrails.org/active_storage_overview.html#requirements)
746
- - To install it, check their documentation at this [link](https://pdf2image.readthedocs.io/en/latest/installation.html).
747
- - To check if it's installed, execute `pdftoppm -h`.
748
- - To install this tool in your CI / production environments, you can check how we do it in our own CI (https://github.com/igorkasyanchuk/active_storage_validations/blob/master/.github/workflows/main.yml)
749
-
750
- We also added the `pages` validator to validate pdf number of pages, and the `equal_to` option to `duration`, `size` and `total_size` validators.
751
-
752
- Note that, if you do not perform these metadata validations on pdfs, the gem will work the same as in version 2.
797
+ ## Upgrading
798
+
799
+ - [Upgrading to 2.x](docs/upgrade_to_2.md)
800
+ - [Upgrading to 3.x](docs/upgrade_to_3.md)
801
+ - [Upgrading to 4.x](docs/upgrade_to_4.md)
753
802
 
754
803
  ## Internationalization (I18n)
755
804
 
@@ -802,9 +851,11 @@ describe User do
802
851
  it { is_expected.to validate_limits_of(:avatar).max(5) }
803
852
 
804
853
  # content_type:
805
- # #allowing, #rejecting
854
+ # #allowing, #rejecting, #spoofing_protection
806
855
  it { is_expected.to validate_content_type_of(:avatar).allowing('image/png', 'image/gif') } # possible to use an Array or *splatted array
807
856
  it { is_expected.to validate_content_type_of(:avatar).rejecting('text/plain', 'text/xml') } # possible to use an Array or *splatted array
857
+ it { is_expected.to validate_content_type_of(:avatar).allowing('image/png').spoofing_protection } # true / :file
858
+ it { is_expected.to validate_content_type_of(:avatar).allowing('image/png').spoofing_protection(:magika) }
808
859
 
809
860
  # dimension:
810
861
  # #width, #height, #width_min, #height_min, #width_max, #height_max, #width_between, #height_between
@@ -856,7 +907,7 @@ end
856
907
  ```
857
908
  (Note that matcher methods are chainable)
858
909
 
859
- All matchers can currently be customized with Rails validation options:
910
+ All matchers can currently be customized with these options:
860
911
 
861
912
  ```ruby
862
913
  describe User do
@@ -867,8 +918,16 @@ describe User do
867
918
  it { is_expected.to validate_attached_of(:avatar).on(:update) }
868
919
  it { is_expected.to validate_attached_of(:avatar).on(%i[update custom]) }
869
920
 
921
+ # :except_on (Rails >= 8.0)
922
+ it { is_expected.to validate_attached_of(:avatar).except_on(:update) }
923
+ it { is_expected.to validate_attached_of(:avatar).except_on(%i[update custom]) }
924
+
870
925
  # :message
871
926
  it { is_expected.to validate_dimensions_of(:avatar).width(250).with_message('Invalid dimensions.') }
927
+
928
+ # :timeout (analyzer command timeout — metadata validators + content_type with spoofing)
929
+ it { is_expected.to validate_duration_of(:video).less_than(5.minutes).timeout(30.seconds) }
930
+ it { is_expected.to validate_processable_file_of(:avatar).timeout(5.seconds) }
872
931
  end
873
932
  ```
874
933
 
@@ -899,40 +958,44 @@ Then you can use the matchers with the syntax specified in the RSpec section, ju
899
958
 
900
959
  If you want to contribute to the project, you will have to fork the repository and create a new branch from the `master` branch. Then build your feature, or fix the issue, and create a pull request. Be sure to add tests for your changes.
901
960
 
961
+ AI coding agents: see [AGENTS.md](AGENTS.md) for architecture, test commands, and contribution patterns. Commit and PR titles follow [Conventional Commits](.cursor/rules/git.mdc).
962
+
902
963
  Before submitting your pull request, run the tests to make sure everything works as expected.
903
964
 
904
965
  To run the gem tests, launch the following commands in the root folder of gem repository:
905
966
 
906
- * `BUNDLE_GEMFILE=gemfiles/rails_6_1_4.gemfile bundle exec rake test` to run for Rails 6.1.4
907
- * `BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rake test` to run for Rails 7.0
908
- * `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test` to run for Rails 7.1
909
- * `BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake test` to run for Rails 7.2
910
- * `BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle exec rake test` to run for Rails 8.0
911
- * `BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle exec rake test` to run for Rails 8.1
912
- * `BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle exec rake test` to run for Rails main
967
+ * `BUNDLE_GEMFILE=gemfiles/rails_7_0_1.gemfile bundle exec rake spec` to run for Rails 7.0.1
968
+ * `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake spec` to run for Rails 7.1
969
+ * `BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake spec` to run for Rails 7.2
970
+ * `BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle exec rake spec` to run for Rails 8.0
971
+ * `BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle exec rake spec` to run for Rails 8.1
972
+ * `BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle exec rake spec` to run for Rails main
913
973
 
914
974
  Snippet to run in console:
915
975
 
916
976
  ```bash
917
- BUNDLE_GEMFILE=gemfiles/rails_6_1_4.gemfile bundle
918
- BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle
977
+ BUNDLE_GEMFILE=gemfiles/rails_7_0_1.gemfile bundle
919
978
  BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle
920
979
  BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle
921
980
  BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle
922
981
  BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle
923
982
  BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle
924
- BUNDLE_GEMFILE=gemfiles/rails_6_1_4.gemfile bundle exec rake test
925
- BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rake test
926
- BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test
927
- BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake test
928
- BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle exec rake test
929
- BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle exec rake test
930
- BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle exec rake test
983
+ BUNDLE_GEMFILE=gemfiles/rails_7_0_1.gemfile bundle exec rake spec
984
+ BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake spec
985
+ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake spec
986
+ BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle exec rake spec
987
+ BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle exec rake spec
988
+ BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle exec rake spec
931
989
  ```
932
990
 
933
991
  Tips:
934
- - To focus a specific test, use the `focus` class method provided by [minitest-focus](https://github.com/minitest/minitest-focus)
935
- - To focus a specific file, use the TEST option provided by minitest, e.g. to only run `size_validator_test.rb` file you will launch the following command: `bundle exec rake test TEST=test/validators/size_validator_test.rb`
992
+ - To focus examples, tag them with `:focus` (or use `fit` / `fdescribe`); RSpec is configured with `filter_run_when_matching :focus`
993
+ - To run a specific file: `bundle exec rspec spec/validators/size_validator_spec.rb`
994
+ - Image processor: CI runs both via `IMAGE_PROCESSOR=vips` / `IMAGE_PROCESSOR=mini_magick`. Locally, unset means validators use MiniMagick (ASV default) and both analyzer unit specs run; setting the env selects that processor for validators and excludes the other processor’s tagged examples (so they do not show as pending)
995
+
996
+ ### Benchmarks
997
+
998
+ Optional wall-clock / ips suite for metadata validators (cold analysis vs cached `asv_*` hits) lives under [`benchmark/`](benchmark/). See [`benchmark/README.md`](benchmark/README.md) for setup, how to run, and how to refresh [`benchmark/BASELINE.md`](benchmark/BASELINE.md). CI runs the suite informationally (no fail-on-regression).
936
999
 
937
1000
 
938
1001
  ## Additional information
data/Rakefile CHANGED
@@ -1,29 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  begin
4
- require 'bundler/setup'
4
+ require "bundler/setup"
5
5
  rescue LoadError
6
- puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
7
7
  end
8
8
 
9
- require 'rdoc/task'
9
+ require "rdoc/task"
10
10
 
11
11
  RDoc::Task.new(:rdoc) do |rdoc|
12
- rdoc.rdoc_dir = 'rdoc'
13
- rdoc.title = 'ActiveStorageValidations'
14
- rdoc.options << '--line-numbers'
15
- rdoc.rdoc_files.include('README.md')
16
- rdoc.rdoc_files.include('lib/**/*.rb')
12
+ rdoc.rdoc_dir = "rdoc"
13
+ rdoc.title = "ActiveStorageValidations"
14
+ rdoc.options << "--line-numbers"
15
+ rdoc.rdoc_files.include("README.md")
16
+ rdoc.rdoc_files.include("lib/**/*.rb")
17
17
  end
18
18
 
19
- require 'bundler/gem_tasks'
19
+ require "bundler/gem_tasks"
20
20
 
21
- require 'rake/testtask'
21
+ require "rspec/core/rake_task"
22
22
 
23
- Rake::TestTask.new(:test) do |t|
24
- t.libs << 'test'
25
- t.pattern = 'test/**/*_test.rb'
26
- t.verbose = false
23
+ RSpec::Core::RakeTask.new(:spec) do |t|
24
+ t.pattern = "spec/**/*_spec.rb"
27
25
  end
28
26
 
29
- task default: :test
27
+ task default: :spec
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
3
  require_relative "shared/asv_ff_probable"
5
4
 
6
5
  module ActiveStorageValidations