file_validators 3.0.0.beta1 → 3.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 6738e978bc3966d2acd83590b2e77b240e005bed
4
- data.tar.gz: 9fccf7c9b07a08f867582ed3c65aee9606018bd7
2
+ SHA256:
3
+ metadata.gz: 7536b453f528e82937d43e4349c6cb99f990832023a5567d125b817b59f3b71a
4
+ data.tar.gz: e33270b079d15c08aed6a2b3e1f2aab9f61d39fc222f9c42e15a0a9a34f82885
5
5
  SHA512:
6
- metadata.gz: 59033aade86b442a9bd3eb3f8cbc61cf38c66452af71ba58471998e3c015a8b0ead2eba3275fd23a988c61f5cc8719b0dee7af0b099c0c25f7322d2be7775616
7
- data.tar.gz: 90e211e12d9f5b3d9dc6aebecb4b3b84b853538d61eeb6ae8c014c9618d06fae927d0af7509aa5e616598034a54995e45a6e10fee8c03ad88c401e4a231c01bb
6
+ metadata.gz: 2c47e7fe802dc65553c2dfa5a72c2d05d97d4d14dcdf2c6826f7ae18e586a8754b1b4dc07c20abec2038b55c6d1766df96c19f968217630270586c92bab4101f
7
+ data.tar.gz: b8b0c693d4314614c2cd8f72a699b48c666487c4b44221f25daf9f70cc0d27d1b09766b9ef8e035ed14690ea9d24aac10cdbcc62d1e89bf2a683e46a7e39fec0
@@ -0,0 +1 @@
1
+ ruby 2.3.1
@@ -1,25 +1,39 @@
1
1
  language: ruby
2
2
 
3
3
  rvm:
4
- - 2.0
5
4
  - 2.2.3
5
+ - 2.5.0
6
6
  - ruby-head
7
7
  - jruby-9.0.4.0
8
8
  - jruby-9.1.7.0
9
9
 
10
10
  gemfile:
11
+ - gemfiles/activemodel_5.2.gemfile
11
12
  - gemfiles/activemodel_5.0.gemfile
12
13
  - gemfiles/activemodel_4.2.gemfile
13
- - gemfiles/activemodel_4.1.gemfile
14
14
  - gemfiles/activemodel_4.0.gemfile
15
15
  - gemfiles/activemodel_3.2.gemfile
16
16
 
17
17
  matrix:
18
18
  exclude:
19
- - rvm: 2.0
20
- gemfile: gemfiles/activemodel_5.0.gemfile
19
+ - rvm: 2.5.0
20
+ gemfile: gemfiles/activemodel_3.2.gemfile
21
+ - rvm: 2.5.0
22
+ gemfile: gemfiles/activemodel_4.0.gemfile
23
+ - rvm: 2.5.0
24
+ gemfile: gemfiles/activemodel_4.2.gemfile
25
+
26
+ - rvm: ruby-head
27
+ gemfile: gemfiles/activemodel_3.2.gemfile
28
+ - rvm: ruby-head
29
+ gemfile: gemfiles/activemodel_4.0.gemfile
30
+ - rvm: ruby-head
31
+ gemfile: gemfiles/activemodel_4.2.gemfile
32
+
21
33
  - rvm: jruby-9.0.4.0
22
34
  gemfile: gemfiles/activemodel_5.0.gemfile
35
+ - rvm: jruby-9.0.4.0
36
+ gemfile: gemfiles/activemodel_5.2.gemfile
23
37
 
24
38
  allow_failures:
25
39
  - rvm: ruby-head
data/Appraisals CHANGED
@@ -23,3 +23,7 @@ end
23
23
  appraise 'activemodel-5.0' do
24
24
  gem 'activemodel', '5.0.1'
25
25
  end
26
+
27
+ appraise 'activemodel-5.2' do
28
+ gem 'activemodel', '5.2.1'
29
+ end
@@ -1,3 +1,8 @@
1
+ # 3.0.0.beta2
2
+
3
+ * [#32](https://github.com/musaffa/file_validators/pull/32) Removed terrapin. Added options for choosing MIME type analyzers with `:tool` option.
4
+ * Rubocop style guide
5
+
1
6
  # 3.0.0.beta1
2
7
 
3
8
  * [#29](https://github.com/musaffa/file_validators/pull/29) Upgrade cocaine to terrapin
data/README.md CHANGED
@@ -142,8 +142,13 @@ validates :video, file_content_type: { allow: lambda { |record| record.content_t
142
142
  can be a String or a Regexp. It also accepts `proc`. See `:allow` options examples.
143
143
  * `mode`: `:strict` or `:relaxed`. `:strict` mode can detect content type based on the contents
144
144
  of the files. It also detects media type spoofing (see more in [security](#security)).
145
- `:relaxed` mode uses file name to detect the content type using `mime-types` gem.
146
- If mode option is not set then the validator uses form supplied content type.
145
+ `:file` analyzer is used in `:strict` model. `:relaxed` mode uses file name to detect
146
+ the content type. `mime_types` analyzer is used in `relaxed` mode. If mode option is not
147
+ set then the validator uses form supplied content type.
148
+ * `tool`: `:file`, `:fastimage`, `:filemagic`, `:mimemagic`, `:marcel`, `:mime_types`, `:mini_mime`.
149
+ You can choose one of these built-in MIME type analyzers. You have to install the analyzer gem you choose.
150
+ By default supplied content type is used to determine the MIME type. This option takes precedence
151
+ over `mode` option.
147
152
  ```ruby
148
153
  validates :avatar, file_content_type: { allow: 'image/jpeg', mode: :strict }
149
154
  validates :avatar, file_content_type: { allow: 'image/jpeg', mode: :relaxed }
@@ -180,8 +185,8 @@ It will not allow a file having `image/jpeg` content type to be saved as `text/p
180
185
  type mismatch, for example `text` of `text/plain` and `image` of `image/jpeg`. So it will not prevent
181
186
  `image/jpeg` from saving as `image/png` as both have the same `image` media type.
182
187
 
183
- **note**: This security feature is disabled by default. To enable it, first add `terrapin` gem in
184
- your Gemfile and then add `mode: :strict` option in [content type validations](#file-content-type-validator).
188
+ **note**: This security feature is disabled by default. To enable it, add `mode: :strict` option
189
+ in [content type validations](#file-content-type-validator).
185
190
  `:strict` mode may not work in direct file uploading systems as the file is not passed along with the form.
186
191
 
187
192
  ## i18n Translations
@@ -201,7 +206,7 @@ of the file matches anyone of them. takes `types` as replacement.
201
206
 
202
207
  This gem provides `en` translations for this errors under `errors.messages` namespace.
203
208
  If you want to override and/or create other locales, you can
204
- check [this](https://github.com/musaffa/file_validators/blob/master/lib/file_validators/locale/en.yml) out to see how translations are done.
209
+ check [this](https://github.com/musaffa/file_validators/blob/master/lib/file_validators/locale/en.yml) out to see how translations are done.
205
210
 
206
211
  You can override all of them with the `:message` option.
207
212
 
@@ -23,9 +23,12 @@ Gem::Specification.new do |s|
23
23
  s.add_dependency 'mime-types', '>= 1.0'
24
24
 
25
25
  s.add_development_dependency 'coveralls'
26
+ s.add_development_dependency 'fastimage'
27
+ s.add_development_dependency 'marcel', '~> 0.3' if RUBY_VERSION >= '2.2.0'
28
+ s.add_development_dependency 'mimemagic', '>= 0.3.2'
29
+ s.add_development_dependency 'mini_mime', '~> 1.0'
26
30
  s.add_development_dependency 'rack-test'
27
31
  s.add_development_dependency 'rake'
28
32
  s.add_development_dependency 'rspec', '~> 3.5.0'
29
33
  s.add_development_dependency 'rubocop', '~> 0.58.2'
30
- s.add_development_dependency 'terrapin', '~> 0.6'
31
34
  end
@@ -1,11 +1,9 @@
1
- # frozen_string_literal: true
2
-
3
1
  # This file was generated by Appraisal
4
2
 
5
- source 'https://rubygems.org'
3
+ source "https://rubygems.org"
6
4
 
7
- gem 'appraisal'
8
- gem 'activemodel', '3.2.22.5'
9
- gem 'rack', '1.6.5'
5
+ gem "appraisal"
6
+ gem "activemodel", "3.2.22.5"
7
+ gem "rack", "1.6.5"
10
8
 
11
- gemspec path: '../'
9
+ gemspec :path => "../"
@@ -1,11 +1,9 @@
1
- # frozen_string_literal: true
2
-
3
1
  # This file was generated by Appraisal
4
2
 
5
- source 'https://rubygems.org'
3
+ source "https://rubygems.org"
6
4
 
7
- gem 'appraisal'
8
- gem 'activemodel', '4.0.13'
9
- gem 'rack', '1.6.5'
5
+ gem "appraisal"
6
+ gem "activemodel", "4.0.13"
7
+ gem "rack", "1.6.5"
10
8
 
11
- gemspec path: '../'
9
+ gemspec :path => "../"
@@ -1,11 +1,9 @@
1
- # frozen_string_literal: true
2
-
3
1
  # This file was generated by Appraisal
4
2
 
5
- source 'https://rubygems.org'
3
+ source "https://rubygems.org"
6
4
 
7
- gem 'appraisal'
8
- gem 'activemodel', '4.1.6'
9
- gem 'rack', '1.6.5'
5
+ gem "appraisal"
6
+ gem "activemodel", "4.1.6"
7
+ gem "rack", "1.6.5"
10
8
 
11
- gemspec path: '../'
9
+ gemspec :path => "../"
@@ -1,11 +1,9 @@
1
- # frozen_string_literal: true
2
-
3
1
  # This file was generated by Appraisal
4
2
 
5
- source 'https://rubygems.org'
3
+ source "https://rubygems.org"
6
4
 
7
- gem 'appraisal'
8
- gem 'activemodel', '4.2.7.1'
9
- gem 'rack', '1.6.5'
5
+ gem "appraisal"
6
+ gem "activemodel", "4.2.7.1"
7
+ gem "rack", "1.6.5"
10
8
 
11
- gemspec path: '../'
9
+ gemspec :path => "../"
@@ -1,10 +1,8 @@
1
- # frozen_string_literal: true
2
-
3
1
  # This file was generated by Appraisal
4
2
 
5
- source 'https://rubygems.org'
3
+ source "https://rubygems.org"
6
4
 
7
- gem 'appraisal'
8
- gem 'activemodel', '5.0.1'
5
+ gem "appraisal"
6
+ gem "activemodel", "5.0.1"
9
7
 
10
- gemspec path: '../'
8
+ gemspec :path => "../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "activemodel", "5.2.1"
7
+
8
+ gemspec :path => "../"
@@ -4,12 +4,9 @@ require 'active_model'
4
4
  require 'ostruct'
5
5
 
6
6
  module FileValidators
7
- module Utils
8
- extend ActiveSupport::Autoload
9
-
10
- autoload :ContentTypeDetector
11
- autoload :MediaTypeSpoofDetector
12
- end
7
+ extend ActiveSupport::Autoload
8
+ autoload :Error
9
+ autoload :MimeTypeAnalyzer
13
10
  end
14
11
 
15
12
  Dir[File.dirname(__FILE__) + '/file_validators/validators/*.rb'].each { |file| require file }
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FileValidators
4
+ class Error < StandardError
5
+ end
6
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Extracted from shrine/plugins/determine_mime_type.rb
4
+ module FileValidators
5
+ class MimeTypeAnalyzer
6
+ SUPPORTED_TOOLS = %i[fastimage file filemagic mimemagic marcel mime_types mini_mime].freeze
7
+ MAGIC_NUMBER = 256 * 1024
8
+
9
+ def initialize(tool)
10
+ raise Error, "unknown mime type analyzer #{tool.inspect}, supported analyzers are: #{SUPPORTED_TOOLS.join(',')}" unless SUPPORTED_TOOLS.include?(tool)
11
+
12
+ @tool = tool
13
+ end
14
+
15
+ def call(io)
16
+ mime_type = send(:"extract_with_#{@tool}", io)
17
+ io.rewind
18
+
19
+ mime_type
20
+ end
21
+
22
+ private
23
+
24
+ def extract_with_file(io)
25
+ require 'open3'
26
+
27
+ return nil if io.eof? # file command returns "application/x-empty" for empty files
28
+
29
+ Open3.popen3(*%W[file --mime-type --brief -]) do |stdin, stdout, stderr, thread|
30
+ begin
31
+ IO.copy_stream(io, stdin.binmode)
32
+ rescue Errno::EPIPE
33
+ end
34
+ stdin.close
35
+
36
+ status = thread.value
37
+
38
+ raise Error, "file command failed to spawn: #{stderr.read}" if status.nil?
39
+ raise Error, "file command failed: #{stderr.read}" unless status.success?
40
+ $stderr.print(stderr.read)
41
+
42
+ stdout.read.strip
43
+ end
44
+ rescue Errno::ENOENT
45
+ raise Error, 'file command-line tool is not installed'
46
+ end
47
+
48
+ def extract_with_fastimage(io)
49
+ require 'fastimage'
50
+
51
+ type = FastImage.type(io)
52
+ "image/#{type}" if type
53
+ end
54
+
55
+ def extract_with_filemagic(io)
56
+ require 'filemagic'
57
+
58
+ return nil if io.eof? # FileMagic returns "application/x-empty" for empty files
59
+
60
+ FileMagic.open(FileMagic::MAGIC_MIME_TYPE) do |filemagic|
61
+ filemagic.buffer(io.read(MAGIC_NUMBER))
62
+ end
63
+ end
64
+
65
+ def extract_with_mimemagic(io)
66
+ require 'mimemagic'
67
+
68
+ mime = MimeMagic.by_magic(io)
69
+ mime.type if mime
70
+ end
71
+
72
+ def extract_with_marcel(io)
73
+ require 'marcel'
74
+
75
+ return nil if io.eof? # marcel returns "application/octet-stream" for empty files
76
+
77
+ Marcel::MimeType.for(io)
78
+ end
79
+
80
+ def extract_with_mime_types(io)
81
+ require 'mime/types'
82
+
83
+ if filename = extract_filename(io)
84
+ mime_type = MIME::Types.of(filename).first
85
+ mime_type.content_type if mime_type
86
+ end
87
+ end
88
+
89
+ def extract_with_mini_mime(io)
90
+ require 'mini_mime'
91
+
92
+ if filename = extract_filename(io)
93
+ info = MiniMime.lookup_by_filename(filename)
94
+ info.content_type if info
95
+ end
96
+ end
97
+
98
+ def extract_filename(io)
99
+ if io.respond_to?(:original_filename)
100
+ io.original_filename
101
+ elsif io.respond_to?(:path)
102
+ File.basename(io.path)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -4,21 +4,30 @@ module ActiveModel
4
4
  module Validations
5
5
  class FileContentTypeValidator < ActiveModel::EachValidator
6
6
  CHECKS = %i[allow exclude].freeze
7
+ SUPPORTED_MODES = { relaxed: :mime_types, strict: :file }.freeze
7
8
 
8
9
  def self.helper_method_name
9
10
  :validates_file_content_type
10
11
  end
11
12
 
12
13
  def validate_each(record, attribute, value)
13
- values = parse_values(value)
14
+ begin
15
+ values = parse_values(value)
16
+ rescue JSON::ParserError
17
+ record.errors.add attribute, :invalid
18
+ return
19
+ end
20
+
14
21
  return if values.empty?
15
22
 
16
23
  mode = option_value(record, :mode)
24
+ tool = option_value(record, :tool) || SUPPORTED_MODES[mode]
25
+
17
26
  allowed_types = option_content_types(record, :allow)
18
27
  forbidden_types = option_content_types(record, :exclude)
19
28
 
20
29
  values.each do |val|
21
- content_type = get_content_type(val, mode)
30
+ content_type = get_content_type(val, tool)
22
31
  validate_whitelist(record, attribute, content_type, allowed_types)
23
32
  validate_blacklist(record, attribute, content_type, forbidden_types)
24
33
  end
@@ -46,31 +55,9 @@ module ActiveModel
46
55
  Array.wrap(value).reject(&:blank?)
47
56
  end
48
57
 
49
- def get_file_path(value)
50
- if value.try(:path)
51
- value.path
52
- else
53
- raise ArgumentError, 'value must return a file path in order to validate file content type'
54
- end
55
- end
56
-
57
- def get_file_name(value)
58
- if value.try(:original_filename)
59
- value.original_filename
60
- else
61
- File.basename(get_file_path(value))
62
- end
63
- end
64
-
65
- def get_content_type(value, mode)
66
- case mode
67
- when :strict
68
- file_path = get_file_path(value)
69
- file_name = get_file_name(value)
70
- FileValidators::Utils::ContentTypeDetector.new(file_path, file_name).detect
71
- when :relaxed
72
- file_name = get_file_name(value)
73
- MIME::Types.type_for(file_name).first
58
+ def get_content_type(value, tool)
59
+ if tool.present?
60
+ FileValidators::MimeTypeAnalyzer.new(tool).call(value)
74
61
  else
75
62
  value = OpenStruct.new(value) if value.is_a?(Hash)
76
63
  value.content_type
@@ -124,6 +111,10 @@ module ActiveModel
124
111
  # :relaxed validates the content type based on the file name using
125
112
  # the mime-types gem. It's only for sanity check.
126
113
  # If mode is not set then it uses form supplied content type.
114
+ # * +tool+: :file, :fastimage, :filemagic, :mimemagic, :marcel, :mime_types, :mini_mime
115
+ # You can choose a different built-in MIME type analyzer
116
+ # By default supplied content type is used to determine the MIME type
117
+ # This option have precedence over mode option
127
118
  # * +if+: A lambda or name of an instance method. Validation will only
128
119
  # be run is this lambda or method returns true.
129
120
  # * +unless+: Same as +if+ but validates if lambda or method returns false.
@@ -14,7 +14,13 @@ module ActiveModel
14
14
  end
15
15
 
16
16
  def validate_each(record, attribute, value)
17
- values = parse_values(value)
17
+ begin
18
+ values = parse_values(value)
19
+ rescue JSON::ParserError
20
+ record.errors.add attribute, :invalid
21
+ return
22
+ end
23
+
18
24
  return if values.empty?
19
25
 
20
26
  options.slice(*CHECKS.keys).each do |option, option_value|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FileValidators
4
- VERSION = '3.0.0.beta1'
4
+ VERSION = '3.0.0.beta2'
5
5
  end
@@ -252,6 +252,31 @@ describe 'File Content Type integration with ActiveModel' do
252
252
  end
253
253
  end
254
254
 
255
+ context ':tool option' do
256
+ before :all do
257
+ Person.class_eval do
258
+ Person.reset_callbacks(:validate)
259
+ validates :avatar, file_content_type: { allow: 'image/jpeg', tool: :marcel }
260
+ end
261
+ end
262
+
263
+ subject { Person.new }
264
+
265
+ context 'with valid file' do
266
+ it 'validates the file' do
267
+ subject.avatar = Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg')
268
+ expect(subject).to be_valid
269
+ end
270
+ end
271
+
272
+ context 'with spoofed file' do
273
+ it 'invalidates the file' do
274
+ subject.avatar = Rack::Test::UploadedFile.new(@spoofed_file_path, 'image/jpeg')
275
+ expect(subject).not_to be_valid
276
+ end
277
+ end
278
+ end
279
+
255
280
  context ':mode option' do
256
281
  context 'strict mode' do
257
282
  before :all do
@@ -364,6 +389,11 @@ describe 'File Content Type integration with ActiveModel' do
364
389
  before { subject.avatar = '' }
365
390
  it { is_expected.to be_valid }
366
391
  end
392
+
393
+ context 'invalid json string' do
394
+ before { subject.avatar = '{filename":"img140910_88338.jpg","content_type":"image/jpeg","size":13150}' }
395
+ it { is_expected.not_to be_valid }
396
+ end
367
397
  end
368
398
 
369
399
  context 'image data as hash' do
@@ -240,10 +240,15 @@ describe 'File Size Validator integration with ActiveModel' do
240
240
  it { is_expected.to be_valid }
241
241
  end
242
242
 
243
- context 'empty json string' do
243
+ context 'empty string' do
244
244
  before { subject.avatar = '' }
245
245
  it { is_expected.to be_valid }
246
246
  end
247
+
248
+ context 'invalid json string' do
249
+ before { subject.avatar = '{filename":"img140910_88338.GIF","content_type":"image/gif","size":33150}' }
250
+ it { is_expected.not_to be_valid }
251
+ end
247
252
  end
248
253
 
249
254
  context 'image data as hash' do
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'rack/test/uploaded_file'
5
+
6
+ describe FileValidators::MimeTypeAnalyzer do
7
+ it 'rises error when tool is invalid' do
8
+ expect { described_class.new(:invalid) }.to raise_error(FileValidators::Error)
9
+ end
10
+
11
+ before :all do
12
+ @cute_path = File.join(File.dirname(__FILE__), '../../fixtures/cute.jpg')
13
+ @spoofed_file_path = File.join(File.dirname(__FILE__), '../../fixtures/spoofed.jpg')
14
+ end
15
+
16
+ let(:cute_image) { Rack::Test::UploadedFile.new(@cute_path, 'image/jpeg') }
17
+ let(:spoofed_file) { Rack::Test::UploadedFile.new(@spoofed_file_path, 'image/jpeg') }
18
+
19
+ describe ':file analyzer' do
20
+ let(:analyzer) { described_class.new(:file) }
21
+
22
+ it 'determines MIME type from file contents' do
23
+ expect(analyzer.call(cute_image)).to eq('image/jpeg')
24
+ end
25
+
26
+ it 'returns text/plain for unidentified MIME types' do
27
+ expect(analyzer.call(fakeio('a' * 5 * 1024 * 1024))).to eq('text/plain')
28
+ end
29
+
30
+ it 'is able to determine MIME type for spoofed files' do
31
+ expect(analyzer.call(spoofed_file)).to eq('text/plain')
32
+ end
33
+
34
+ it 'is able to determine MIME type for non-files' do
35
+ expect(analyzer.call(fakeio(cute_image.read))).to eq('image/jpeg')
36
+ end
37
+
38
+ it 'returns nil for empty IOs' do
39
+ expect(analyzer.call(fakeio(''))).to eq(nil)
40
+ end
41
+
42
+ it 'raises error if file command is not found' do
43
+ allow(Open3).to receive(:popen3).and_raise(Errno::ENOENT)
44
+ expect { analyzer.call(fakeio) }.to raise_error(FileValidators::Error, 'file command-line tool is not installed')
45
+ end
46
+ end
47
+
48
+ describe ':fastimage analyzer' do
49
+ let(:analyzer) { described_class.new(:fastimage) }
50
+
51
+ it 'extracts MIME type of any IO' do
52
+ expect(analyzer.call(cute_image)).to eq('image/jpeg')
53
+ end
54
+
55
+ it 'returns nil for unidentified MIME types' do
56
+ expect(analyzer.call(fakeio('😃'))).to eq nil
57
+ end
58
+
59
+ it 'returns nil for empty IOs' do
60
+ expect(analyzer.call(fakeio(''))).to eq nil
61
+ end
62
+ end
63
+
64
+ describe ':mimemagic analyzer' do
65
+ let(:analyzer) { described_class.new(:mimemagic) }
66
+
67
+ it 'extracts MIME type of any IO' do
68
+ expect(analyzer.call(cute_image)).to eq('image/jpeg')
69
+ end
70
+
71
+ it 'returns nil for unidentified MIME types' do
72
+ expect(analyzer.call(fakeio('😃'))).to eq nil
73
+ end
74
+
75
+ it 'returns nil for empty IOs' do
76
+ expect(analyzer.call(fakeio(''))).to eq nil
77
+ end
78
+ end
79
+
80
+ if RUBY_VERSION >= '2.2.0'
81
+ describe ':marcel analyzer' do
82
+ let(:analyzer) { described_class.new(:marcel) }
83
+
84
+ it 'extracts MIME type of any IO' do
85
+ expect(analyzer.call(cute_image)).to eq('image/jpeg')
86
+ end
87
+
88
+ it 'returns application/octet-stream for unidentified MIME types' do
89
+ expect(analyzer.call(fakeio('😃'))).to eq 'application/octet-stream'
90
+ end
91
+
92
+ it 'returns nil for empty IOs' do
93
+ expect(analyzer.call(fakeio(''))).to eq nil
94
+ end
95
+ end
96
+ end
97
+
98
+ describe ':mime_types analyzer' do
99
+ let(:analyzer) { described_class.new(:mime_types) }
100
+
101
+ it 'extract MIME type from the file extension' do
102
+ expect(analyzer.call(fakeio(filename: 'image.png'))).to eq('image/png')
103
+ expect(analyzer.call(cute_image)).to eq('image/jpeg')
104
+ end
105
+
106
+ it 'extracts MIME type from file extension when IO is empty' do
107
+ expect(analyzer.call(fakeio('', filename: 'image.png'))).to eq('image/png')
108
+ end
109
+
110
+ it 'returns nil on unknown extension' do
111
+ expect(analyzer.call(fakeio(filename: 'file.foo'))).to eq(nil)
112
+ end
113
+
114
+ it 'returns nil when input is not a file' do
115
+ expect(analyzer.call(fakeio)).to eq(nil)
116
+ end
117
+ end
118
+
119
+ describe ':mini_mime analyzer' do
120
+ let(:analyzer) { described_class.new(:mini_mime) }
121
+
122
+ it 'extract MIME type from the file extension' do
123
+ expect(analyzer.call(fakeio(filename: 'image.png'))).to eq('image/png')
124
+ expect(analyzer.call(cute_image)).to eq('image/jpeg')
125
+ end
126
+
127
+ it 'extracts MIME type from file extension when IO is empty' do
128
+ expect(analyzer.call(fakeio('', filename: 'image.png'))).to eq('image/png')
129
+ end
130
+
131
+ it 'returns nil on unkown extension' do
132
+ expect(analyzer.call(fakeio(filename: 'file.foo'))).to eq(nil)
133
+ end
134
+
135
+ it 'returns nil when input is not a file' do
136
+ expect(analyzer.call(fakeio)).to eq(nil)
137
+ end
138
+ end
139
+ end
@@ -18,6 +18,8 @@ I18n.enforce_available_locales = false
18
18
  Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each { |f| require f }
19
19
 
20
20
  RSpec.configure do |config|
21
+ config.include Helpers
22
+
21
23
  # Suppress stdout in the console
22
24
  config.before { allow($stdout).to receive(:write) }
23
25
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'stringio'
5
+
6
+ class FakeIO
7
+ attr_reader :original_filename, :content_type
8
+
9
+ def initialize(content, filename: nil, content_type: nil)
10
+ @io = StringIO.new(content)
11
+ @original_filename = filename
12
+ @content_type = content_type
13
+ end
14
+
15
+ extend Forwardable
16
+ delegate %i[read rewind eof? close size] => :@io
17
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helpers
4
+ def fakeio(content = 'file', **options)
5
+ FakeIO.new(content, **options)
6
+ end
7
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: file_validators
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.beta1
4
+ version: 3.0.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ahmad Musaffa
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-08-20 00:00:00.000000000 Z
11
+ date: 2020-11-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -53,7 +53,7 @@ dependencies:
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
55
  - !ruby/object:Gem::Dependency
56
- name: rack-test
56
+ name: fastimage
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - ">="
@@ -67,61 +67,103 @@ dependencies:
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: rake
70
+ name: marcel
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: mimemagic
71
85
  requirement: !ruby/object:Gem::Requirement
72
86
  requirements:
73
87
  - - ">="
74
88
  - !ruby/object:Gem::Version
75
- version: '0'
89
+ version: 0.3.2
76
90
  type: :development
77
91
  prerelease: false
78
92
  version_requirements: !ruby/object:Gem::Requirement
79
93
  requirements:
80
94
  - - ">="
81
95
  - !ruby/object:Gem::Version
82
- version: '0'
96
+ version: 0.3.2
83
97
  - !ruby/object:Gem::Dependency
84
- name: rspec
98
+ name: mini_mime
85
99
  requirement: !ruby/object:Gem::Requirement
86
100
  requirements:
87
101
  - - "~>"
88
102
  - !ruby/object:Gem::Version
89
- version: 3.5.0
103
+ version: '1.0'
90
104
  type: :development
91
105
  prerelease: false
92
106
  version_requirements: !ruby/object:Gem::Requirement
93
107
  requirements:
94
108
  - - "~>"
95
109
  - !ruby/object:Gem::Version
96
- version: 3.5.0
110
+ version: '1.0'
97
111
  - !ruby/object:Gem::Dependency
98
- name: rubocop
112
+ name: rack-test
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rake
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rspec
99
141
  requirement: !ruby/object:Gem::Requirement
100
142
  requirements:
101
143
  - - "~>"
102
144
  - !ruby/object:Gem::Version
103
- version: 0.58.2
145
+ version: 3.5.0
104
146
  type: :development
105
147
  prerelease: false
106
148
  version_requirements: !ruby/object:Gem::Requirement
107
149
  requirements:
108
150
  - - "~>"
109
151
  - !ruby/object:Gem::Version
110
- version: 0.58.2
152
+ version: 3.5.0
111
153
  - !ruby/object:Gem::Dependency
112
- name: terrapin
154
+ name: rubocop
113
155
  requirement: !ruby/object:Gem::Requirement
114
156
  requirements:
115
157
  - - "~>"
116
158
  - !ruby/object:Gem::Version
117
- version: '0.6'
159
+ version: 0.58.2
118
160
  type: :development
119
161
  prerelease: false
120
162
  version_requirements: !ruby/object:Gem::Requirement
121
163
  requirements:
122
164
  - - "~>"
123
165
  - !ruby/object:Gem::Version
124
- version: '0.6'
166
+ version: 0.58.2
125
167
  description: Adds file validators to ActiveModel
126
168
  email:
127
169
  - musaffa_csemm@yahoo.com
@@ -132,6 +174,7 @@ files:
132
174
  - ".gitignore"
133
175
  - ".rspec"
134
176
  - ".rubocop.yml"
177
+ - ".tool-versions"
135
178
  - ".travis.yml"
136
179
  - Appraisals
137
180
  - CHANGELOG.md
@@ -146,10 +189,11 @@ files:
146
189
  - gemfiles/activemodel_4.1.gemfile
147
190
  - gemfiles/activemodel_4.2.gemfile
148
191
  - gemfiles/activemodel_5.0.gemfile
192
+ - gemfiles/activemodel_5.2.gemfile
149
193
  - lib/file_validators.rb
194
+ - lib/file_validators/error.rb
150
195
  - lib/file_validators/locale/en.yml
151
- - lib/file_validators/utils/content_type_detector.rb
152
- - lib/file_validators/utils/media_type_spoof_detector.rb
196
+ - lib/file_validators/mime_type_analyzer.rb
153
197
  - lib/file_validators/validators/file_content_type_validator.rb
154
198
  - lib/file_validators/validators/file_size_validator.rb
155
199
  - lib/file_validators/version.rb
@@ -161,12 +205,13 @@ files:
161
205
  - spec/integration/combined_validators_integration_spec.rb
162
206
  - spec/integration/file_content_type_validation_integration_spec.rb
163
207
  - spec/integration/file_size_validator_integration_spec.rb
164
- - spec/lib/file_validators/utils/content_type_detector_spec.rb
165
- - spec/lib/file_validators/utils/media_type_spoof_detector_spec.rb
208
+ - spec/lib/file_validators/mime_type_analyzer_spec.rb
166
209
  - spec/lib/file_validators/validators/file_content_type_validator_spec.rb
167
210
  - spec/lib/file_validators/validators/file_size_validator_spec.rb
168
211
  - spec/locale/en.yml
169
212
  - spec/spec_helper.rb
213
+ - spec/support/fakeio.rb
214
+ - spec/support/helpers.rb
170
215
  - spec/support/matchers/allow_content_type.rb
171
216
  - spec/support/matchers/allow_file_size.rb
172
217
  homepage: https://github.com/musaffa/file_validators
@@ -188,8 +233,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
233
  - !ruby/object:Gem::Version
189
234
  version: 1.3.1
190
235
  requirements: []
191
- rubyforge_project:
192
- rubygems_version: 2.5.1
236
+ rubygems_version: 3.1.2
193
237
  signing_key:
194
238
  specification_version: 4
195
239
  summary: ActiveModel file validators
@@ -202,11 +246,12 @@ test_files:
202
246
  - spec/integration/combined_validators_integration_spec.rb
203
247
  - spec/integration/file_content_type_validation_integration_spec.rb
204
248
  - spec/integration/file_size_validator_integration_spec.rb
205
- - spec/lib/file_validators/utils/content_type_detector_spec.rb
206
- - spec/lib/file_validators/utils/media_type_spoof_detector_spec.rb
249
+ - spec/lib/file_validators/mime_type_analyzer_spec.rb
207
250
  - spec/lib/file_validators/validators/file_content_type_validator_spec.rb
208
251
  - spec/lib/file_validators/validators/file_size_validator_spec.rb
209
252
  - spec/locale/en.yml
210
253
  - spec/spec_helper.rb
254
+ - spec/support/fakeio.rb
255
+ - spec/support/helpers.rb
211
256
  - spec/support/matchers/allow_content_type.rb
212
257
  - spec/support/matchers/allow_file_size.rb
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'logger'
4
-
5
- begin
6
- require 'terrapin'
7
- rescue LoadError
8
- puts "file_validators requires 'terrapin' gem as you are using" \
9
- ' file content type validations in strict mode'
10
- end
11
-
12
- module FileValidators
13
- module Utils
14
- class ContentTypeDetector
15
- EMPTY_CONTENT_TYPE = 'inode/x-empty'
16
- DEFAULT_CONTENT_TYPE = 'application/octet-stream'
17
-
18
- attr_accessor :file_path, :file_name
19
-
20
- def initialize(file_path, file_name)
21
- @file_path = file_path
22
- @file_name = file_name
23
- end
24
-
25
- # content type detection strategy:
26
- #
27
- # 1. invalid file_path: returns 'application/octet-stream'
28
- # 2. empty file: returns 'inode/x-empty'
29
- # 3. valid file: returns the content type using file command
30
- # 4. valid file but file commoand raises error: returns 'application/octet-stream'
31
-
32
- def detect
33
- if !File.exist?(file_path)
34
- DEFAULT_CONTENT_TYPE
35
- elsif File.zero?(file_path)
36
- EMPTY_CONTENT_TYPE
37
- else
38
- content_type_from_content
39
- end
40
- end
41
-
42
- private
43
-
44
- def content_type_from_content
45
- content_type = type_from_file_command
46
-
47
- if FileValidators::Utils::MediaTypeSpoofDetector.new(content_type, file_name).spoofed?
48
- logger.warn('A file with a spoofed media type has been detected by the file validators.')
49
- else
50
- content_type
51
- end
52
- end
53
-
54
- def type_from_file_command
55
- Terrapin::CommandLine.new('file', '-b --mime-type :file').run(file: @file_path).strip
56
- rescue Terrapin::CommandLineError => e
57
- logger.info(e.message)
58
- DEFAULT_CONTENT_TYPE
59
- end
60
-
61
- def logger
62
- Logger.new(STDOUT)
63
- end
64
- end
65
- end
66
- end
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'mime/types'
4
-
5
- module FileValidators
6
- module Utils
7
- class MediaTypeSpoofDetector
8
- def initialize(content_type, file_name)
9
- @content_type = content_type
10
- @file_name = file_name
11
- end
12
-
13
- # media type spoof detection strategy:
14
- #
15
- # 1. it will not identify as spoofed if file name doesn't have any extension
16
- # 2. it will identify as spoofed if any of the file extension's media types
17
- # matches the media type of the content type. So it will return true for
18
- # `text` of `text/plain` mismatch with `image` of `image/jpeg`, but return false
19
- # for `image` of `image/png` match with `image` of `image/jpeg`.
20
-
21
- def spoofed?
22
- extension? && media_type_mismatch?
23
- end
24
-
25
- private
26
-
27
- def extension?
28
- # the following code replaced File.extname(@file_name).present? because it cannot
29
- # return the extension of a extension-only file names, e.g. '.html', '.jpg' etc
30
- @file_name.split('.').length > 1
31
- end
32
-
33
- def media_type_mismatch?
34
- supplied_media_types.none? { |type| type == detected_media_type }
35
- end
36
-
37
- def supplied_media_types
38
- MIME::Types.type_for(@file_name).collect(&:media_type)
39
- end
40
-
41
- def detected_media_type
42
- @content_type.split('/').first
43
- end
44
- end
45
- end
46
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
- require 'tempfile'
5
-
6
- describe FileValidators::Utils::ContentTypeDetector do
7
- it 'returns the empty content type when the file is empty' do
8
- tempfile = Tempfile.new('empty')
9
- expect(described_class.new(tempfile.path, tempfile.path).detect).to eql('inode/x-empty')
10
- tempfile.close
11
- end
12
-
13
- it 'returns a content type based on the content of the file' do
14
- tempfile = Tempfile.new('something')
15
- tempfile.write('This is a file.')
16
- tempfile.rewind
17
- expect(described_class.new(tempfile.path, tempfile.path).detect).to eql('text/plain')
18
- tempfile.close
19
- end
20
-
21
- it 'returns a sensible default when the file path is empty' do
22
- expect(described_class.new('', '').detect).to eql('application/octet-stream')
23
- end
24
-
25
- it 'returns a sensible default if the file path is invalid' do
26
- file_path = '/path/to/nothing'
27
- expect(described_class.new(file_path, file_path).detect).to eql('application/octet-stream')
28
- end
29
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- describe FileValidators::Utils::MediaTypeSpoofDetector do
6
- it 'rejects a file with an extension .html and identifies as jpeg' do
7
- expect(described_class.new('image/jpeg', 'sample.html')).to be_spoofed
8
- end
9
-
10
- it 'does not reject a file with an extension .jpg and identifies as png' do
11
- expect(described_class.new('image/png', 'sample.jpg')).not_to be_spoofed
12
- end
13
-
14
- it 'does not reject a file with an extension .txt and identifies as text' do
15
- expect(described_class.new('text/plain', 'sample.txt')).not_to be_spoofed
16
- end
17
-
18
- it 'does not reject a file that does not have any name' do
19
- expect(described_class.new('text/plain', '')).not_to be_spoofed
20
- end
21
-
22
- it 'does not reject a file that does not have any extension' do
23
- expect(described_class.new('text/plain', 'sample')).not_to be_spoofed
24
- end
25
-
26
- it 'rejects a file that does not have a basename but has an extension with mismatched media type' do
27
- expect(described_class.new('image/jpeg', '.html')).to be_spoofed
28
- end
29
-
30
- it 'does not reject a file that does not have a basename but has an extension with valid media type' do
31
- expect(described_class.new('image/png', '.jpg')).not_to be_spoofed
32
- end
33
- end