active_storage_validations 3.0.5 → 4.1.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/README.md +227 -116
  4. data/Rakefile +13 -15
  5. data/config/locales/da.yml +2 -0
  6. data/config/locales/de.yml +2 -0
  7. data/config/locales/en-GB.yml +2 -0
  8. data/config/locales/en.yml +2 -0
  9. data/config/locales/es.yml +2 -0
  10. data/config/locales/fr.yml +2 -0
  11. data/config/locales/it.yml +2 -0
  12. data/config/locales/ja.yml +2 -0
  13. data/config/locales/nl.yml +2 -0
  14. data/config/locales/pl.yml +2 -0
  15. data/config/locales/pt-BR.yml +2 -0
  16. data/config/locales/ru.yml +2 -0
  17. data/config/locales/sv.yml +2 -0
  18. data/config/locales/tr.yml +2 -0
  19. data/config/locales/uk.yml +2 -0
  20. data/config/locales/vi.yml +2 -0
  21. data/config/locales/zh-CN.yml +2 -0
  22. data/lib/active_storage_validations/analyzer/audio_analyzer.rb +0 -1
  23. data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +31 -0
  24. data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +44 -0
  25. data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +15 -24
  26. data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +41 -7
  27. data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +36 -10
  28. data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +3 -9
  29. data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +5 -4
  30. data/lib/active_storage_validations/analyzer/video_analyzer.rb +0 -1
  31. data/lib/active_storage_validations/analyzer.rb +35 -3
  32. data/lib/active_storage_validations/content_type_validator.rb +24 -6
  33. data/lib/active_storage_validations/dimension_validator.rb +4 -2
  34. data/lib/active_storage_validations/form_builder.rb +43 -0
  35. data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +8 -0
  36. data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +4 -0
  37. data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -1
  38. data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +12 -0
  39. data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +8 -0
  40. data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +8 -0
  41. data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +4 -0
  42. data/lib/active_storage_validations/matchers/pages_validator_matcher.rb +8 -0
  43. data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +8 -0
  44. data/lib/active_storage_validations/matchers/shared/asv_except_onable.rb +57 -0
  45. data/lib/active_storage_validations/matchers/shared/asv_spoofing_protectable.rb +44 -0
  46. data/lib/active_storage_validations/matchers/shared/asv_timeoutable.rb +32 -0
  47. data/lib/active_storage_validations/matchers/with_audio_validator_matcher.rb +114 -0
  48. data/lib/active_storage_validations/matchers.rb +10 -1
  49. data/lib/active_storage_validations/processable_file_validator.rb +60 -1
  50. data/lib/active_storage_validations/railtie.rb +6 -0
  51. data/lib/active_storage_validations/shared/asv_analyzable.rb +66 -8
  52. data/lib/active_storage_validations/shared/asv_attachable.rb +9 -10
  53. data/lib/active_storage_validations/shared/asv_commandable.rb +95 -0
  54. data/lib/active_storage_validations/shared/asv_optionable.rb +5 -1
  55. data/lib/active_storage_validations/version.rb +1 -1
  56. data/lib/active_storage_validations/with_audio_validator.rb +37 -0
  57. data/lib/active_storage_validations.rb +23 -0
  58. metadata +27 -58
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shared/asv_active_storageable"
4
+ require_relative "shared/asv_allow_blankable"
5
+ require_relative "shared/asv_attachable"
6
+ require_relative "shared/asv_contextable"
7
+ require_relative "shared/asv_except_onable"
8
+ require_relative "shared/asv_messageable"
9
+ require_relative "shared/asv_rspecable"
10
+ require_relative "shared/asv_timeoutable"
11
+ require_relative "shared/asv_validatable"
12
+
13
+ module ActiveStorageValidations
14
+ module Matchers
15
+ def validate_with_audio_of(attribute_name)
16
+ WithAudioValidatorMatcher.new(attribute_name)
17
+ end
18
+
19
+ class WithAudioValidatorMatcher
20
+ include ASVActiveStorageable
21
+ include ASVAllowBlankable
22
+ include ASVAttachable
23
+ include ASVContextable
24
+ include ASVExceptOnable
25
+ include ASVMessageable
26
+ include ASVRspecable
27
+ include ASVTimeoutable
28
+ include ASVValidatable
29
+
30
+ def initialize(attribute_name)
31
+ initialize_allow_blankable
32
+ initialize_contextable
33
+ initialize_except_onable
34
+ initialize_messageable
35
+ initialize_rspecable
36
+ initialize_timeoutable
37
+ @attribute_name = attribute_name
38
+ @expected_audio = true
39
+ end
40
+
41
+ def description
42
+ "validate that :#{@attribute_name} #{audio_expectation}"
43
+ end
44
+
45
+ def failure_message
46
+ "is expected to validate that :#{@attribute_name} #{audio_expectation}"
47
+ end
48
+
49
+ def without_audio
50
+ @expected_audio = false
51
+ self
52
+ end
53
+
54
+ def matches?(subject)
55
+ @subject = subject.is_a?(Class) ? subject.new : subject
56
+
57
+ is_a_valid_active_storage_attribute? &&
58
+ is_context_valid? &&
59
+ is_except_on_valid? &&
60
+ is_allowing_blank? &&
61
+ is_timeout_valid? &&
62
+ is_custom_message_valid? &&
63
+ is_valid_with_expected_audio? &&
64
+ is_invalid_with_unexpected_audio?
65
+ end
66
+
67
+ private
68
+
69
+ def audio_expectation
70
+ @expected_audio ? "has an audio track" : "has no audio track"
71
+ end
72
+
73
+ def is_valid_with_expected_audio?
74
+ validation_passes_with_audio?(@expected_audio)
75
+ end
76
+
77
+ def is_invalid_with_unexpected_audio?
78
+ !validation_passes_with_audio?(!@expected_audio)
79
+ end
80
+
81
+ def is_custom_message_valid?
82
+ return true unless @custom_message
83
+
84
+ with_audio_metadata(false) do
85
+ attach_file(video_file)
86
+ validate
87
+ detach_file
88
+ has_an_error_message_which_is_custom_message?
89
+ end
90
+ end
91
+
92
+ def validation_passes_with_audio?(audio)
93
+ with_audio_metadata(audio) do
94
+ attach_file(video_file)
95
+ validate
96
+ detach_file
97
+ is_valid?
98
+ end
99
+ end
100
+
101
+ def with_audio_metadata(audio, &block)
102
+ Matchers.mock_metadata(io, { audio: audio }, &block)
103
+ end
104
+
105
+ def video_file
106
+ {
107
+ io: io,
108
+ filename: "test.mp4",
109
+ content_type: "video/mp4"
110
+ }
111
+ end
112
+ end
113
+ end
114
+ end
@@ -7,6 +7,7 @@ require "active_storage_validations/matchers/limit_validator_matcher"
7
7
  require "active_storage_validations/matchers/content_type_validator_matcher"
8
8
  require "active_storage_validations/matchers/dimension_validator_matcher"
9
9
  require "active_storage_validations/matchers/duration_validator_matcher"
10
+ require "active_storage_validations/matchers/with_audio_validator_matcher"
10
11
  require "active_storage_validations/matchers/size_validator_matcher"
11
12
  require "active_storage_validations/matchers/total_size_validator_matcher"
12
13
  require "active_storage_validations/matchers/pages_validator_matcher"
@@ -29,7 +30,15 @@ module ActiveStorageValidations
29
30
 
30
31
  def self.mock_metadata(attachment, metadata = {})
31
32
  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
33
+ merged = asv_metadata_available_keys.merge(metadata)
34
+ # Stubbing Analyzer.new also covers content-type sniffers (File / Magika),
35
+ # which call #content_type — not #metadata. Default to the dummy file type
36
+ # so stacked validators with spoofing_protection still pass under the mock.
37
+ detected_content_type = merged[:content_type].presence || "image/png"
38
+ mock = Struct.new(:metadata, :content_type).new(
39
+ merged,
40
+ { content_type: detected_content_type, content_type_backend: "file" }
41
+ )
33
42
 
34
43
  stub_method(ActiveStorageValidations::Analyzer, :new, mock) do
35
44
  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)
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageValidations
4
+ # ActiveStorageValidations::ASVCommandable
5
+ #
6
+ # Runs external analyzer commands with an optional deadline and process-group
7
+ # kill. Used by analyzers that shell out (ffprobe, pdfinfo, file, identify).
8
+ module ASVCommandable
9
+ extend ActiveSupport::Concern
10
+
11
+ CommandResult = Struct.new(:stdout, :stderr, :status, :timed_out, keyword_init: true) do
12
+ def success?
13
+ !timed_out && status.respond_to?(:success?) && status.success?
14
+ end
15
+ end
16
+
17
+ TERM_GRACE_SECONDS = 1
18
+
19
+ private
20
+
21
+ def run_command(*argv, payload: nil)
22
+ timeout = timeout_in_seconds
23
+ stdout_reader, stdout_writer = IO.pipe
24
+ stderr_reader, stderr_writer = IO.pipe
25
+
26
+ pid = Process.spawn(*argv.map(&:to_s), out: stdout_writer, err: stderr_writer, pgroup: true)
27
+ stdout_writer.close
28
+ stderr_writer.close
29
+
30
+ # Drain pipes concurrently so a chatty child cannot fill the buffer and
31
+ # deadlock while we wait / kill it.
32
+ stdout_thread = Thread.new { stdout_reader.read }
33
+ stderr_thread = Thread.new { stderr_reader.read }
34
+
35
+ status, timed_out = wait_for_command(pid, timeout)
36
+ mark_timed_out!(payload, argv.first) if timed_out
37
+
38
+ CommandResult.new(
39
+ stdout: stdout_thread.value,
40
+ stderr: stderr_thread.value,
41
+ status: status,
42
+ timed_out: timed_out
43
+ )
44
+ ensure
45
+ close_pipe(stdout_reader)
46
+ close_pipe(stderr_reader)
47
+ close_pipe(stdout_writer)
48
+ close_pipe(stderr_writer)
49
+ end
50
+
51
+ # Waits via a reaper thread + Thread#join(timeout) so fast commands return
52
+ # as soon as they exit (no polling sleep). On timeout, TERM then always KILL
53
+ # the process group — KILL is unconditional because the direct child can exit
54
+ # on TERM while a grandchild forked after the group signal is still alive.
55
+ # The same waiter reaps so we never leave zombies.
56
+ def wait_for_command(pid, timeout)
57
+ return [ Process.wait2(pid)[1], false ] if timeout.nil?
58
+
59
+ waiter = Thread.new do
60
+ Process.wait2(pid)
61
+ rescue Errno::ECHILD
62
+ nil
63
+ end
64
+
65
+ if waiter.join(timeout)
66
+ return [ wait_status_from(waiter), false ]
67
+ end
68
+
69
+ signal_process_group(pid, :TERM)
70
+ waiter.join(TERM_GRACE_SECONDS)
71
+ signal_process_group(pid, :KILL)
72
+ waiter.join
73
+
74
+ [ wait_status_from(waiter), true ]
75
+ end
76
+
77
+ def wait_status_from(waiter)
78
+ waiter.value&.last
79
+ end
80
+
81
+ def signal_process_group(pid, signal)
82
+ Process.kill(signal, -pid)
83
+ rescue Errno::ESRCH
84
+ # Already exited; waiter will reap.
85
+ end
86
+
87
+ def close_pipe(io)
88
+ io&.close unless io.nil? || io.closed?
89
+ end
90
+
91
+ def monotonic_time
92
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+ end
94
+ end
95
+ end
@@ -22,7 +22,11 @@ module ActiveStorageValidations
22
22
  when Array
23
23
  options.map { |option| flatten_options(record, option, available_checks) }
24
24
  else
25
- options.is_a?(Proc) ? options.call(record) : options
25
+ if options.is_a?(Proc)
26
+ options.arity == 0 ? options.call : options.call(record)
27
+ else
28
+ options
29
+ end
26
30
  end
27
31
  end
28
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageValidations
4
- VERSION = "3.0.5"
4
+ VERSION = "4.1.0"
5
5
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shared/asv_active_storageable"
4
+ require_relative "shared/asv_analyzable"
5
+ require_relative "shared/asv_attachable"
6
+ require_relative "shared/asv_errorable"
7
+ require_relative "shared/asv_symbolizable"
8
+
9
+ module ActiveStorageValidations
10
+ class WithAudioValidator < ActiveModel::EachValidator # :nodoc
11
+ include ASVActiveStorageable
12
+ include ASVAnalyzable
13
+ include ASVAttachable
14
+ include ASVErrorable
15
+ include ASVSymbolizable
16
+
17
+ ERROR_TYPES = %i[audio_missing audio_present].freeze
18
+ METADATA_KEYS = %i[audio].freeze
19
+
20
+ def validate_each(record, attribute, _value)
21
+ return if no_attachments?(record, attribute)
22
+
23
+ validate_changed_files_from_metadata(record, attribute, METADATA_KEYS)
24
+ end
25
+
26
+ private
27
+
28
+ def is_valid?(record, attribute, attachable, metadata)
29
+ expected_audio = options.fetch(:with, true)
30
+ return if metadata&.fetch(:audio, false) == expected_audio
31
+
32
+ errors_options = initialize_error_options(options, attachable)
33
+ error_type = expected_audio ? :audio_missing : :audio_present
34
+ add_error(record, attribute, error_type, **errors_options)
35
+ end
36
+ end
37
+ end
@@ -2,8 +2,29 @@
2
2
 
3
3
  require "active_model"
4
4
  require "active_support/concern"
5
+ require "active_support/core_ext/module/attribute_accessors"
6
+ require "active_support/core_ext/numeric/time"
7
+
8
+ module ActiveStorageValidations
9
+ # When true (default), FormBuilder#file_field automatically sets the HTML
10
+ # +accept+ attribute from +content_type+ validators. Can also be overridden
11
+ # per field with +infer_accept:+.
12
+ mattr_accessor :infer_file_field_accept, instance_accessor: false, default: true
13
+
14
+ # Maximum time allowed for external analyzer commands (ffprobe, pdfinfo, file,
15
+ # magika, MiniMagick, libvips). Set to +nil+ to disable. Per-validator +timeout:+
16
+ # overrides this value for the analysis that validator triggers.
17
+ mattr_accessor :command_timeout, instance_accessor: false, default: 10.seconds
18
+
19
+ def self.configure
20
+ yield self
21
+ end
22
+ end
5
23
 
6
24
  require "active_storage_validations/analyzer"
25
+ require "active_storage_validations/analyzer/content_type_analyzer"
26
+ require "active_storage_validations/analyzer/content_type_analyzer/file"
27
+ require "active_storage_validations/analyzer/content_type_analyzer/magika"
7
28
  require "active_storage_validations/analyzer/image_analyzer"
8
29
  require "active_storage_validations/analyzer/image_analyzer/image_magick"
9
30
  require "active_storage_validations/analyzer/image_analyzer/vips"
@@ -20,11 +41,13 @@ require "active_storage_validations/content_type_validator"
20
41
  require "active_storage_validations/limit_validator"
21
42
  require "active_storage_validations/dimension_validator"
22
43
  require "active_storage_validations/duration_validator"
44
+ require "active_storage_validations/with_audio_validator"
23
45
  require "active_storage_validations/aspect_ratio_validator"
24
46
  require "active_storage_validations/processable_file_validator"
25
47
  require "active_storage_validations/size_validator"
26
48
  require "active_storage_validations/total_size_validator"
27
49
  require "active_storage_validations/pages_validator"
28
50
 
51
+ require "active_storage_validations/form_builder"
29
52
  require "active_storage_validations/engine"
30
53
  require "active_storage_validations/railtie"