file_validators 3.0.0.beta1 → 3.0.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
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