active_storage_validations 3.0.5 → 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 (39) 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/processable_file_validator.rb +60 -1
  32. data/lib/active_storage_validations/railtie.rb +6 -0
  33. data/lib/active_storage_validations/shared/asv_analyzable.rb +66 -8
  34. data/lib/active_storage_validations/shared/asv_attachable.rb +9 -10
  35. data/lib/active_storage_validations/shared/asv_commandable.rb +95 -0
  36. data/lib/active_storage_validations/shared/asv_optionable.rb +5 -1
  37. data/lib/active_storage_validations/version.rb +1 -1
  38. data/lib/active_storage_validations.rb +22 -0
  39. metadata +25 -58
@@ -7,8 +7,11 @@ require_relative "shared/asv_active_storageable"
7
7
  require_relative "shared/asv_allow_blankable"
8
8
  require_relative "shared/asv_attachable"
9
9
  require_relative "shared/asv_contextable"
10
+ require_relative "shared/asv_except_onable"
10
11
  require_relative "shared/asv_messageable"
11
12
  require_relative "shared/asv_rspecable"
13
+ require_relative "shared/asv_spoofing_protectable"
14
+ require_relative "shared/asv_timeoutable"
12
15
  require_relative "shared/asv_validatable"
13
16
 
14
17
  module ActiveStorageValidations
@@ -22,15 +25,21 @@ module ActiveStorageValidations
22
25
  include ASVAllowBlankable
23
26
  include ASVAttachable
24
27
  include ASVContextable
28
+ include ASVExceptOnable
25
29
  include ASVMessageable
26
30
  include ASVRspecable
31
+ include ASVSpoofingProtectable
32
+ include ASVTimeoutable
27
33
  include ASVValidatable
28
34
 
29
35
  def initialize(attribute_name)
30
36
  initialize_allow_blankable
31
37
  initialize_contextable
38
+ initialize_except_onable
32
39
  initialize_messageable
33
40
  initialize_rspecable
41
+ initialize_spoofing_protectable
42
+ initialize_timeoutable
34
43
  @attribute_name = attribute_name
35
44
  @allowed_content_types = @rejected_content_types = []
36
45
  end
@@ -62,7 +71,10 @@ module ActiveStorageValidations
62
71
 
63
72
  is_a_valid_active_storage_attribute? &&
64
73
  is_context_valid? &&
74
+ is_except_on_valid? &&
65
75
  is_allowing_blank? &&
76
+ is_timeout_valid? &&
77
+ is_spoofing_protection_valid? &&
66
78
  is_custom_message_valid? &&
67
79
  all_allowed_content_types_allowed? &&
68
80
  all_rejected_content_types_rejected?
@@ -4,8 +4,10 @@ require_relative "shared/asv_active_storageable"
4
4
  require_relative "shared/asv_allow_blankable"
5
5
  require_relative "shared/asv_attachable"
6
6
  require_relative "shared/asv_contextable"
7
+ require_relative "shared/asv_except_onable"
7
8
  require_relative "shared/asv_messageable"
8
9
  require_relative "shared/asv_rspecable"
10
+ require_relative "shared/asv_timeoutable"
9
11
  require_relative "shared/asv_validatable"
10
12
 
11
13
  module ActiveStorageValidations
@@ -19,15 +21,19 @@ module ActiveStorageValidations
19
21
  include ASVAllowBlankable
20
22
  include ASVAttachable
21
23
  include ASVContextable
24
+ include ASVExceptOnable
22
25
  include ASVMessageable
23
26
  include ASVRspecable
27
+ include ASVTimeoutable
24
28
  include ASVValidatable
25
29
 
26
30
  def initialize(attribute_name)
27
31
  initialize_allow_blankable
28
32
  initialize_contextable
33
+ initialize_except_onable
29
34
  initialize_messageable
30
35
  initialize_rspecable
36
+ initialize_timeoutable
31
37
  @attribute_name = attribute_name
32
38
  @width_min = @width_max = @height_min = @height_max = nil
33
39
  end
@@ -87,7 +93,9 @@ module ActiveStorageValidations
87
93
 
88
94
  is_a_valid_active_storage_attribute? &&
89
95
  is_context_valid? &&
96
+ is_except_on_valid? &&
90
97
  is_allowing_blank? &&
98
+ is_timeout_valid? &&
91
99
  is_custom_message_valid? &&
92
100
  width_not_smaller_than_min? &&
93
101
  width_larger_than_min? &&
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_comparison_validator_matcher"
4
+ require_relative "shared/asv_timeoutable"
4
5
 
5
6
  module ActiveStorageValidations
6
7
  module Matchers
@@ -9,6 +10,13 @@ module ActiveStorageValidations
9
10
  end
10
11
 
11
12
  class DurationValidatorMatcher < BaseComparisonValidatorMatcher
13
+ include ASVTimeoutable
14
+
15
+ def initialize(attribute_name)
16
+ super
17
+ initialize_timeoutable
18
+ end
19
+
12
20
  def description
13
21
  "validate file duration of :#{@attribute_name}"
14
22
  end
@@ -4,6 +4,7 @@ require_relative "shared/asv_active_storageable"
4
4
  require_relative "shared/asv_allow_blankable"
5
5
  require_relative "shared/asv_attachable"
6
6
  require_relative "shared/asv_contextable"
7
+ require_relative "shared/asv_except_onable"
7
8
  require_relative "shared/asv_messageable"
8
9
  require_relative "shared/asv_rspecable"
9
10
  require_relative "shared/asv_validatable"
@@ -19,6 +20,7 @@ module ActiveStorageValidations
19
20
  include ASVAllowBlankable
20
21
  include ASVAttachable
21
22
  include ASVContextable
23
+ include ASVExceptOnable
22
24
  include ASVMessageable
23
25
  include ASVRspecable
24
26
  include ASVValidatable
@@ -26,6 +28,7 @@ module ActiveStorageValidations
26
28
  def initialize(attribute_name)
27
29
  initialize_allow_blankable
28
30
  initialize_contextable
31
+ initialize_except_onable
29
32
  initialize_messageable
30
33
  initialize_rspecable
31
34
  @attribute_name = attribute_name
@@ -57,6 +60,7 @@ module ActiveStorageValidations
57
60
 
58
61
  is_a_valid_active_storage_attribute? &&
59
62
  is_context_valid? &&
63
+ is_except_on_valid? &&
60
64
  is_custom_message_valid? &&
61
65
  file_count_not_smaller_than_min? &&
62
66
  file_count_equal_min? &&
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_comparison_validator_matcher"
4
+ require_relative "shared/asv_timeoutable"
4
5
 
5
6
  module ActiveStorageValidations
6
7
  module Matchers
@@ -9,6 +10,13 @@ module ActiveStorageValidations
9
10
  end
10
11
 
11
12
  class PagesValidatorMatcher < BaseComparisonValidatorMatcher
13
+ include ASVTimeoutable
14
+
15
+ def initialize(attribute_name)
16
+ super
17
+ initialize_timeoutable
18
+ end
19
+
12
20
  def description
13
21
  "validate file number of pages of :#{@attribute_name}"
14
22
  end
@@ -4,8 +4,10 @@ require_relative "shared/asv_active_storageable"
4
4
  require_relative "shared/asv_allow_blankable"
5
5
  require_relative "shared/asv_attachable"
6
6
  require_relative "shared/asv_contextable"
7
+ require_relative "shared/asv_except_onable"
7
8
  require_relative "shared/asv_messageable"
8
9
  require_relative "shared/asv_rspecable"
10
+ require_relative "shared/asv_timeoutable"
9
11
  require_relative "shared/asv_validatable"
10
12
 
11
13
  module ActiveStorageValidations
@@ -19,15 +21,19 @@ module ActiveStorageValidations
19
21
  include ASVAllowBlankable
20
22
  include ASVAttachable
21
23
  include ASVContextable
24
+ include ASVExceptOnable
22
25
  include ASVMessageable
23
26
  include ASVRspecable
27
+ include ASVTimeoutable
24
28
  include ASVValidatable
25
29
 
26
30
  def initialize(attribute_name)
27
31
  initialize_allow_blankable
28
32
  initialize_contextable
33
+ initialize_except_onable
29
34
  initialize_messageable
30
35
  initialize_rspecable
36
+ initialize_timeoutable
31
37
  @attribute_name = attribute_name
32
38
  end
33
39
 
@@ -44,6 +50,8 @@ module ActiveStorageValidations
44
50
 
45
51
  is_a_valid_active_storage_attribute? &&
46
52
  is_context_valid? &&
53
+ is_except_on_valid? &&
54
+ is_timeout_valid? &&
47
55
  is_custom_message_valid? &&
48
56
  is_valid_when_image_processable? &&
49
57
  is_invalid_when_file_not_processable?
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module ActiveStorageValidations
6
+ module Matchers
7
+ module ASVExceptOnable
8
+ extend ActiveSupport::Concern
9
+
10
+ def initialize_except_onable
11
+ @except_on = nil
12
+ end
13
+
14
+ def except_on(context)
15
+ @except_on = context
16
+ self
17
+ end
18
+
19
+ private
20
+
21
+ def is_except_on_valid?
22
+ return true if !@except_on && attribute_validators.none? { |validator| validator.options[:except_on] }
23
+
24
+ ensure_except_on_present!
25
+ ensure_except_on_valid!
26
+
27
+ if @except_on.is_a?(Array)
28
+ (validator_except_on_contexts & @except_on.map(&:to_s)) == validator_except_on_contexts || raise_except_on_not_listed_error
29
+ elsif @except_on.is_a?(Symbol)
30
+ validator_except_on_contexts.include?(@except_on.to_s) || raise_except_on_not_listed_error
31
+ end
32
+ end
33
+
34
+ def ensure_except_on_present!
35
+ raise ArgumentError, "This validator matcher needs the #except_on option to work since its validator has one" if !@except_on && attribute_validators.all? { |validator| validator.options[:except_on] }
36
+ end
37
+
38
+ def ensure_except_on_valid!
39
+ raise ArgumentError, "This validator matcher option only allows a symbol or an array" if !(@except_on.is_a?(Symbol) || @except_on.is_a?(Array))
40
+ end
41
+
42
+ def validator_except_on_contexts
43
+ attribute_validators.map do |validator|
44
+ case validator.options[:except_on]
45
+ when Array then validator.options[:except_on].map { |context| context.to_s }
46
+ when NilClass then nil
47
+ else validator.options[:except_on].to_s
48
+ end
49
+ end.flatten.compact
50
+ end
51
+
52
+ def raise_except_on_not_listed_error
53
+ raise ArgumentError, "One of the provided contexts to the #except_on method is not found in any of the listed contexts for this attribute"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module ActiveStorageValidations
6
+ module Matchers
7
+ module ASVSpoofingProtectable
8
+ extend ActiveSupport::Concern
9
+
10
+ UNSET = Object.new.freeze
11
+
12
+ def initialize_spoofing_protectable
13
+ @spoofing_protection = UNSET
14
+ end
15
+
16
+ def spoofing_protection(value = true)
17
+ @spoofing_protection = value
18
+ self
19
+ end
20
+
21
+ private
22
+
23
+ def is_spoofing_protection_valid?
24
+ return true if @spoofing_protection.equal?(UNSET)
25
+
26
+ expected = normalize_spoofing_protection(@spoofing_protection)
27
+ attribute_validators.any? do |validator|
28
+ next false unless validator.options.key?(:spoofing_protection)
29
+
30
+ normalize_spoofing_protection(validator.options[:spoofing_protection]) == expected
31
+ end
32
+ end
33
+
34
+ def normalize_spoofing_protection(value)
35
+ case value
36
+ when true, :file then :file
37
+ when :magika then :magika
38
+ when false then false
39
+ else value
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module ActiveStorageValidations
6
+ module Matchers
7
+ module ASVTimeoutable
8
+ extend ActiveSupport::Concern
9
+
10
+ UNSET = Object.new.freeze
11
+
12
+ def initialize_timeoutable
13
+ @timeout = UNSET
14
+ end
15
+
16
+ def timeout(value)
17
+ @timeout = value
18
+ self
19
+ end
20
+
21
+ private
22
+
23
+ def is_timeout_valid?
24
+ return true if @timeout.equal?(UNSET)
25
+
26
+ attribute_validators.any? do |validator|
27
+ validator.options.key?(:timeout) && validator.options[:timeout] == @timeout
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -29,7 +29,15 @@ module ActiveStorageValidations
29
29
 
30
30
  def self.mock_metadata(attachment, metadata = {})
31
31
  asv_metadata_available_keys = { width: nil, height: nil, duration: nil, content_type: nil }
32
- mock = Struct.new(:metadata).new(asv_metadata_available_keys.merge(metadata)) # ensure all keys are present, and it does not raise while trying to access them
32
+ merged = asv_metadata_available_keys.merge(metadata)
33
+ # Stubbing Analyzer.new also covers content-type sniffers (File / Magika),
34
+ # which call #content_type — not #metadata. Default to the dummy file type
35
+ # so stacked validators with spoofing_protection still pass under the mock.
36
+ detected_content_type = merged[:content_type].presence || "image/png"
37
+ mock = Struct.new(:metadata, :content_type).new(
38
+ merged,
39
+ { content_type: detected_content_type, content_type_backend: "file" }
40
+ )
33
41
 
34
42
  stub_method(ActiveStorageValidations::Analyzer, :new, mock) do
35
43
  yield
@@ -19,6 +19,28 @@ module ActiveStorageValidations
19
19
  ].freeze
20
20
  METADATA_KEYS = %i[].freeze
21
21
 
22
+ # Content types whose libvips loaders are marked untrusted / unfuzzed
23
+ # (VIPS_OPERATION_UNTRUSTED). After Rails calls `Vips.block_untrusted(true)`,
24
+ # analysis of these types returns no width/height (empty metadata here),
25
+ # which is not the same as a corrupt / unprocessable file.
26
+ #
27
+ # Matches the formats called out in the Active Storage security release
28
+ # changelogs (7.2.3.2 / 8.0.5.1 / 8.1.3.1):
29
+ # https://github.com/rails/rails/blob/v8.1.3.1/activestorage/CHANGELOG.md
30
+ # https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm
31
+ VIPS_UNTRUSTED_CONTENT_TYPES = %w[
32
+ image/bmp
33
+ image/vnd.microsoft.icon
34
+ image/vnd.adobe.photoshop
35
+ image/svg+xml
36
+ image/jxl
37
+ image/jp2
38
+ image/x-portable-anymap
39
+ image/x-portable-bitmap
40
+ image/x-portable-graymap
41
+ image/x-portable-pixmap
42
+ ].freeze
43
+
22
44
  def validate_each(record, attribute, _value)
23
45
  return if no_attachments?(record, attribute)
24
46
 
@@ -28,10 +50,47 @@ module ActiveStorageValidations
28
50
  private
29
51
 
30
52
  def is_valid?(record, attribute, attachable, metadata)
31
- return if !metadata.empty?
53
+ return if metadata.present?
54
+ return if vips_unanalyzable_by_policy?(attachable)
32
55
 
33
56
  errors_options = initialize_error_options(options, attachable)
34
57
  add_error(record, attribute, ERROR_TYPES.first, **errors_options)
35
58
  end
59
+
60
+ def vips_unanalyzable_by_policy?(attachable)
61
+ image_processor == :vips &&
62
+ self.class.vips_untrusted_operations_blocked? &&
63
+ VIPS_UNTRUSTED_CONTENT_TYPES.include?(attachable_content_type(attachable))
64
+ end
65
+
66
+ class << self
67
+ # Detect whether libvips is currently blocking untrusted operations.
68
+ # There is no public getter for `Vips.block_untrusted`, so we probe whether
69
+ # an untrusted loader (svgload) is available via `vips_foreign_find_load`.
70
+ def vips_untrusted_operations_blocked?
71
+ return @vips_untrusted_operations_blocked if defined?(@vips_untrusted_operations_blocked)
72
+
73
+ @vips_untrusted_operations_blocked = detect_vips_untrusted_operations_blocked?
74
+ end
75
+
76
+ def reset_vips_untrusted_operations_blocked_cache!
77
+ remove_instance_variable(:@vips_untrusted_operations_blocked) if defined?(@vips_untrusted_operations_blocked)
78
+ end
79
+
80
+ private
81
+
82
+ def detect_vips_untrusted_operations_blocked?
83
+ require "vips"
84
+ require "tempfile"
85
+
86
+ Tempfile.create([ "asv_vips_untrusted_probe", ".svg" ]) do |file|
87
+ file.write('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
88
+ file.flush
89
+ ::Vips.vips_foreign_find_load(file.path).nil?
90
+ end
91
+ rescue LoadError, StandardError
92
+ false
93
+ end
94
+ end
36
95
  end
37
96
  end
@@ -11,6 +11,12 @@ module ActiveStorageValidations
11
11
  end
12
12
  end
13
13
 
14
+ initializer "active_storage_validations.form_builder" do
15
+ ActiveSupport.on_load(:action_view) do
16
+ ActionView::Helpers::FormBuilder.prepend(ActiveStorageValidations::FormBuilder)
17
+ end
18
+ end
19
+
14
20
  initializer "active_storage_validations.extend_active_storage_blob" do
15
21
  ActiveSupport.on_load(:active_storage_blob) do
16
22
  include ActiveStorageValidations::ASVBlobMetadatable
@@ -17,15 +17,62 @@ module ActiveStorageValidations
17
17
  # attachable with the corresponding analyzer and set the metadata in the
18
18
  # blob.
19
19
  def metadata_for(blob, attachable, metadata_keys)
20
+ if content_type_metadata_keys?(metadata_keys)
21
+ return content_type_metadata_for(blob, attachable)
22
+ end
23
+
20
24
  return blob.active_storage_validations_metadata if blob_has_asv_metadata?(blob, metadata_keys)
21
25
 
26
+ new_metadata = generate_metadata_for(attachable, metadata_keys) || {}
27
+ blob.merge_into_active_storage_validations_metadata(new_metadata)
28
+ blob.save!
29
+
30
+ blob.active_storage_validations_metadata
31
+ end
32
+
33
+ def content_type_metadata_keys?(metadata_keys)
34
+ metadata_keys == ActiveStorageValidations::ContentTypeValidator::METADATA_KEYS
35
+ end
36
+
37
+ # Same cache semantics as +metadata_for+, plus backend matching.
38
+ # Legacy blobs that only have +asv_content_type+ (no backend key) are treated
39
+ # as +:file+ — so existing file-backend analyses keep hitting the cache and
40
+ # are not re-analyzed / rewritten.
41
+ def content_type_metadata_for(blob, attachable)
42
+ backend = spoofing_protection_backend
43
+ cached = blob.active_storage_validations_metadata
44
+ return cached if content_type_cache_hit?(cached, backend)
45
+
46
+ metadata_keys = ActiveStorageValidations::ContentTypeValidator::METADATA_KEYS
22
47
  new_metadata = generate_metadata_for(attachable, metadata_keys)
48
+ return failed_content_type_metadata(backend) if content_type_analysis_failed?(new_metadata)
49
+
23
50
  blob.merge_into_active_storage_validations_metadata(new_metadata)
24
51
  blob.save!
25
52
 
26
53
  blob.active_storage_validations_metadata
27
54
  end
28
55
 
56
+ def content_type_analysis_failed?(new_metadata)
57
+ new_metadata.blank? || new_metadata[:content_type].blank?
58
+ end
59
+
60
+ # Do not return / persist another backend's cached type after a failed sniff
61
+ # (timeout, unsupported file, invalid Magika JSON, …). Fail closed for this
62
+ # request; leave blob metadata unchanged so a later attempt can retry.
63
+ def failed_content_type_metadata(backend)
64
+ { content_type: nil, content_type_backend: backend.to_s }
65
+ end
66
+
67
+ def content_type_cache_hit?(cached, backend)
68
+ return false unless cached.present? && cached[:content_type].present?
69
+
70
+ # Missing / blank backend => legacy file analysis (pre-Magika support).
71
+ # Those blobs keep hitting the cache for +:file+ and are not rewritten.
72
+ stored_backend = (cached[:content_type_backend].presence || "file").to_s
73
+ stored_backend == backend.to_s
74
+ end
75
+
29
76
  def blob_has_asv_metadata?(blob, metadata_keys)
30
77
  return false unless blob.active_storage_validations_metadata.present?
31
78
 
@@ -33,7 +80,7 @@ module ActiveStorageValidations
33
80
  end
34
81
 
35
82
  def generate_metadata_for(attachable, metadata_keys)
36
- if metadata_keys == ActiveStorageValidations::ContentTypeValidator::METADATA_KEYS
83
+ if content_type_metadata_keys?(metadata_keys)
37
84
  content_type_analyzer_for(attachable).content_type
38
85
  else
39
86
  metadata_analyzer_for(attachable).metadata
@@ -52,15 +99,15 @@ module ActiveStorageValidations
52
99
  end
53
100
 
54
101
  def pdf_analyzer_for(attachable)
55
- ActiveStorageValidations::Analyzer::PdfAnalyzer.new(attachable)
102
+ ActiveStorageValidations::Analyzer::PdfAnalyzer.new(attachable, **analyzer_timeout_options)
56
103
  end
57
104
 
58
105
  def image_analyzer_for(attachable)
59
106
  case image_processor
60
107
  when :mini_magick
61
- ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable)
108
+ ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable, **analyzer_timeout_options)
62
109
  when :vips
63
- ActiveStorageValidations::Analyzer::ImageAnalyzer::Vips.new(attachable)
110
+ ActiveStorageValidations::Analyzer::ImageAnalyzer::Vips.new(attachable, **analyzer_timeout_options)
64
111
  end
65
112
  end
66
113
 
@@ -71,19 +118,30 @@ module ActiveStorageValidations
71
118
  end
72
119
 
73
120
  def video_analyzer_for(attachable)
74
- ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable)
121
+ ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable, **analyzer_timeout_options)
75
122
  end
76
123
 
77
124
  def audio_analyzer_for(attachable)
78
- ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable)
125
+ ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable, **analyzer_timeout_options)
79
126
  end
80
127
 
81
128
  def fallback_analyzer_for(attachable)
82
- ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable)
129
+ ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable, **analyzer_timeout_options)
83
130
  end
84
131
 
85
132
  def content_type_analyzer_for(attachable)
86
- ActiveStorageValidations::Analyzer::ContentTypeAnalyzer.new(attachable)
133
+ case spoofing_protection_backend
134
+ when :magika
135
+ ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::Magika.new(attachable, **analyzer_timeout_options)
136
+ else
137
+ ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::File.new(attachable, **analyzer_timeout_options)
138
+ end
139
+ end
140
+
141
+ # Passes raw validator +timeout:+ through to analyzers. Kept out of
142
+ # AVAILABLE_CHECKS so ASVOptionable does not flatten it as a comparison bound.
143
+ def analyzer_timeout_options
144
+ options.key?(:timeout) ? { timeout: options[:timeout] } : {}
87
145
  end
88
146
  end
89
147
  end
@@ -44,7 +44,7 @@ module ActiveStorageValidations
44
44
  return to_enum(:attachables_and_blobs, record, attribute) if changes.blank? || !block_given?
45
45
 
46
46
  if changes.is_a?(ActiveStorage::Attached::Changes::CreateMany)
47
- changes.attachables.zip(changes.blobs).uniq { |_, blob| blob&.id }.each do |attachable, blob|
47
+ changes.attachables.zip(changes.blobs).uniq { |attachable, blob| attachable_blob_key(attachable, blob) }.each do |attachable, blob|
48
48
  yield attachable, blob
49
49
  end
50
50
  else
@@ -52,6 +52,12 @@ module ActiveStorageValidations
52
52
  end
53
53
  end
54
54
 
55
+ def attachable_blob_key(attachable, blob)
56
+ return [ :blob_id, blob.id ] if blob&.id
57
+
58
+ [ :attachable, attachable.object_id ]
59
+ end
60
+
55
61
  def changes_for(record, attribute)
56
62
  if record.public_send(attribute).is_a?(ActiveStorage::Attached::One)
57
63
  record.attachment_changes[attribute.to_s].presence || record.public_send(attribute)
@@ -120,14 +126,14 @@ module ActiveStorageValidations
120
126
  def attachable_io(attachable, max_byte_size: nil)
121
127
  io = case attachable
122
128
  when ActiveStorage::Blob
123
- (max_byte_size && supports_blob_download_chunk?) ? attachable.download_chunk(0...max_byte_size) : attachable.download
129
+ max_byte_size ? attachable.download_chunk(0...max_byte_size) : attachable.download
124
130
  when ActionDispatch::Http::UploadedFile
125
131
  max_byte_size ? attachable.read(max_byte_size) : attachable.read
126
132
  when Rack::Test::UploadedFile
127
133
  max_byte_size ? attachable.read(max_byte_size) : attachable.read
128
134
  when String
129
135
  blob = ActiveStorage::Blob.find_signed!(attachable)
130
- (max_byte_size && supports_blob_download_chunk?) ? blob.download_chunk(0...max_byte_size) : blob.download
136
+ max_byte_size ? blob.download_chunk(0...max_byte_size) : blob.download
131
137
  when Hash
132
138
  max_byte_size ? attachable[:io].read(max_byte_size) : attachable[:io].read
133
139
  when File
@@ -208,13 +214,6 @@ module ActiveStorageValidations
208
214
  end
209
215
  alias :supports_pathname_attachment? :supports_file_attachment?
210
216
 
211
- # Check if the current Rails version supports ActiveStorage::Blob#download_chunk
212
- #
213
- # https://github.com/rails/rails/blob/7-0-stable/activestorage/CHANGELOG.md#rails-700alpha1-september-15-2021
214
- def supports_blob_download_chunk?
215
- Rails.gem_version >= Gem::Version.new("7.0.0.alpha1")
216
- end
217
-
218
217
  # Retrieve the content_type from the file name only
219
218
  def marcel_content_type_from_filename(attachable)
220
219
  Marcel::MimeType.for(name: attachable_filename(attachable).to_s)