jr-paperclip 7.3.0 → 8.0.0.beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/{test.yml → tests.yml} +19 -9
  3. data/.rubocop.yml +2 -1
  4. data/CONTRIBUTING.md +1 -1
  5. data/Gemfile +1 -0
  6. data/NEWS +16 -1
  7. data/README.md +119 -8
  8. data/UPGRADING +5 -0
  9. data/VIPS_MIGRATION_GUIDE.md +131 -0
  10. data/features/basic_integration.feature +27 -0
  11. data/features/step_definitions/attachment_steps.rb +17 -0
  12. data/gemfiles/7.0.gemfile +1 -0
  13. data/gemfiles/7.1.gemfile +1 -0
  14. data/gemfiles/7.2.gemfile +1 -0
  15. data/gemfiles/8.0.gemfile +1 -0
  16. data/gemfiles/8.1.gemfile +1 -0
  17. data/lib/paperclip/attachment.rb +3 -2
  18. data/lib/paperclip/errors.rb +4 -5
  19. data/lib/paperclip/geometry.rb +3 -3
  20. data/lib/paperclip/geometry_detector_factory.rb +52 -12
  21. data/lib/paperclip/helpers.rb +18 -0
  22. data/lib/paperclip/processor.rb +36 -4
  23. data/lib/paperclip/thumbnail.rb +568 -62
  24. data/lib/paperclip/version.rb +1 -1
  25. data/lib/paperclip.rb +26 -9
  26. data/paperclip.gemspec +3 -2
  27. data/spec/paperclip/attachment_definitions_spec.rb +300 -0
  28. data/spec/paperclip/attachment_spec.rb +1 -1
  29. data/spec/paperclip/geometry_detector_spec.rb +81 -32
  30. data/spec/paperclip/geometry_spec.rb +8 -5
  31. data/spec/paperclip/helpers_spec.rb +49 -0
  32. data/spec/paperclip/lazy_thumbnail_compatibility_spec.rb +266 -0
  33. data/spec/paperclip/processor_spec.rb +35 -1
  34. data/spec/paperclip/style_spec.rb +58 -0
  35. data/spec/paperclip/thumbnail_custom_options_spec.rb +173 -0
  36. data/spec/paperclip/thumbnail_loader_options_spec.rb +53 -0
  37. data/spec/paperclip/thumbnail_security_spec.rb +42 -0
  38. data/spec/paperclip/thumbnail_spec.rb +1127 -172
  39. metadata +36 -4
@@ -0,0 +1,266 @@
1
+ require "spec_helper"
2
+
3
+ # This spec tests compatibility with Mastodon's LazyThumbnail pattern.
4
+ # LazyThumbnail is a subclass of Paperclip::Thumbnail that conditionally
5
+ # skips processing when the image doesn't need transformation.
6
+ # See: https://github.com/mastodon/mastodon/blob/main/lib/paperclip/lazy_thumbnail.rb
7
+
8
+ module Paperclip
9
+ class LazyThumbnail < Paperclip::Thumbnail
10
+ def make
11
+ return File.open(@file.path) unless needs_convert?
12
+
13
+ if options[:geometry]
14
+ min_side = [@current_geometry.width, @current_geometry.height].min.to_i
15
+ options[:geometry] = "#{min_side}x#{min_side}#" if @target_geometry.square? && min_side < @target_geometry.width
16
+ elsif options[:pixels]
17
+ width = Math.sqrt(options[:pixels] * (@current_geometry.width.to_f / @current_geometry.height)).round.to_i
18
+ height = Math.sqrt(options[:pixels] * (@current_geometry.height.to_f / @current_geometry.width)).round.to_i
19
+ options[:geometry] = "#{width}x#{height}>"
20
+ end
21
+
22
+ Paperclip::Thumbnail.make(file, options, attachment)
23
+ end
24
+
25
+ private
26
+
27
+ def needs_convert?
28
+ needs_different_geometry? || needs_different_format? || needs_metadata_stripping?
29
+ end
30
+
31
+ def needs_different_geometry?
32
+ (options[:geometry] && @current_geometry.width != @target_geometry.width && @current_geometry.height != @target_geometry.height) ||
33
+ (options[:pixels] && @current_geometry.width * @current_geometry.height > options[:pixels])
34
+ end
35
+
36
+ def needs_different_format?
37
+ @format.present? && @current_format != @format
38
+ end
39
+
40
+ def needs_metadata_stripping?
41
+ @attachment.respond_to?(:instance) && @attachment.instance.respond_to?(:local?) && @attachment.instance.local?
42
+ end
43
+ end
44
+ end
45
+
46
+ describe Paperclip::LazyThumbnail do
47
+ let(:file) { File.new(fixture_file("5k.png"), "rb") }
48
+ let(:rotated_file) { File.new(fixture_file("rotated.jpg"), "rb") }
49
+ let(:attachment) { double("Attachment", options: {}, instance: double(local?: false)) }
50
+
51
+ after do
52
+ file.close
53
+ rotated_file.close rescue nil
54
+ end
55
+
56
+ describe "basic compatibility" do
57
+ it "inherits from Paperclip::Thumbnail" do
58
+ expect(Paperclip::LazyThumbnail.superclass).to eq(Paperclip::Thumbnail)
59
+ end
60
+
61
+ it "can access current_geometry" do
62
+ processor = described_class.new(file, { geometry: "100x100" }, attachment)
63
+ expect(processor.current_geometry).to be_a(Paperclip::Geometry)
64
+ expect(processor.current_geometry.width).to be > 0
65
+ expect(processor.current_geometry.height).to be > 0
66
+ end
67
+
68
+ it "can access target_geometry" do
69
+ processor = described_class.new(file, { geometry: "100x100#" }, attachment)
70
+ expect(processor.target_geometry).to be_a(Paperclip::Geometry)
71
+ expect(processor.target_geometry.width).to eq(100)
72
+ expect(processor.target_geometry.height).to eq(100)
73
+ end
74
+
75
+ it "target_geometry responds to square?" do
76
+ processor = described_class.new(file, { geometry: "100x100#" }, attachment)
77
+ expect(processor.target_geometry).to respond_to(:square?)
78
+ expect(processor.target_geometry.square?).to be true
79
+ end
80
+
81
+ it "can access format and current_format" do
82
+ processor = described_class.new(file, { geometry: "100x100", format: :jpg }, attachment)
83
+ expect(processor.format).to eq(:jpg)
84
+ end
85
+ end
86
+
87
+ describe "with ImageMagick backend" do
88
+ let(:attachment) { double("Attachment", options: { backend: :image_magick }, instance: double(local?: false)) }
89
+
90
+ it "processes images when geometry change is needed" do
91
+ processor = described_class.new(file, { geometry: "50x50#" }, attachment)
92
+ result = processor.make
93
+
94
+ expect(File.exist?(result.path)).to be true
95
+ dimensions = `identify -format "%wx%h" "#{result.path}"`.strip
96
+ expect(dimensions).to eq("50x50")
97
+ end
98
+
99
+ it "processes images when format change is needed" do
100
+ processor = described_class.new(file, { geometry: "100x100", format: :jpg }, attachment)
101
+ result = processor.make
102
+
103
+ expect(File.exist?(result.path)).to be true
104
+ expect(result.path).to end_with(".jpg")
105
+ end
106
+
107
+ it "skips processing when no changes needed" do
108
+ # Create a processor where geometry matches source
109
+ processor = described_class.new(file, { geometry: "434x66" }, attachment)
110
+
111
+ # Since the source is 434x66 (5k.png dimensions), and target is same,
112
+ # needs_different_geometry? returns false
113
+ result = processor.make
114
+
115
+ # Result should be the original file (File.open(@file.path))
116
+ expect(File.exist?(result.path)).to be true
117
+ end
118
+
119
+ it "handles square geometry optimization" do
120
+ # Source is 434x66, min_side is 66
121
+ # Requesting 100x100# square, but min_side (66) < target width (100)
122
+ # So geometry should be adjusted to "66x66#"
123
+ processor = described_class.new(file, { geometry: "100x100#" }, attachment)
124
+ result = processor.make
125
+
126
+ expect(File.exist?(result.path)).to be true
127
+ dimensions = `identify -format "%wx%h" "#{result.path}"`.strip
128
+ expect(dimensions).to eq("66x66")
129
+ end
130
+ end
131
+
132
+ describe "with libvips backend" do
133
+ let(:attachment) { double("Attachment", options: { backend: :vips }, instance: double(local?: false)) }
134
+
135
+ before do
136
+ begin
137
+ require "vips"
138
+ rescue LoadError
139
+ skip "libvips not installed"
140
+ end
141
+ end
142
+
143
+ it "processes images when geometry change is needed" do
144
+ processor = described_class.new(file, { geometry: "50x50#" }, attachment)
145
+ result = processor.make
146
+
147
+ expect(File.exist?(result.path)).to be true
148
+ dimensions = `identify -format "%wx%h" "#{result.path}"`.strip
149
+ expect(dimensions).to eq("50x50")
150
+ end
151
+
152
+ it "processes images when format change is needed" do
153
+ processor = described_class.new(file, { geometry: "100x100", format: :jpg }, attachment)
154
+ result = processor.make
155
+
156
+ expect(File.exist?(result.path)).to be true
157
+ expect(result.path).to end_with(".jpg")
158
+ end
159
+
160
+ it "skips processing when no changes needed" do
161
+ processor = described_class.new(file, { geometry: "434x66" }, attachment)
162
+ result = processor.make
163
+ expect(File.exist?(result.path)).to be true
164
+ end
165
+
166
+ it "handles square geometry optimization" do
167
+ processor = described_class.new(file, { geometry: "100x100#" }, attachment)
168
+ result = processor.make
169
+
170
+ expect(File.exist?(result.path)).to be true
171
+ dimensions = `identify -format "%wx%h" "#{result.path}"`.strip
172
+ expect(dimensions).to eq("66x66")
173
+ end
174
+ end
175
+
176
+ describe "pixels-based resizing (Mastodon-specific)" do
177
+ let(:attachment) { double("Attachment", options: {}, instance: double(local?: false)) }
178
+
179
+ context "with ImageMagick backend" do
180
+ let(:attachment) { double("Attachment", options: { backend: :image_magick }, instance: double(local?: false)) }
181
+
182
+ it "resizes based on maximum pixel count" do
183
+ # Source is 434x66 = 28,644 pixels
184
+ # Request max 10,000 pixels
185
+ processor = described_class.new(file, { pixels: 10_000 }, attachment)
186
+ result = processor.make
187
+
188
+ expect(File.exist?(result.path)).to be true
189
+ dimensions = `identify -format "%wx%h" "#{result.path}"`.strip
190
+ width, height = dimensions.split("x").map(&:to_i)
191
+ expect(width * height).to be <= 10_000
192
+ end
193
+
194
+ it "skips processing when image is already small enough" do
195
+ # Source is 434x66 = 28,644 pixels
196
+ # Request max 50,000 pixels - no resize needed
197
+ processor = described_class.new(file, { pixels: 50_000 }, attachment)
198
+ result = processor.make
199
+
200
+ expect(File.exist?(result.path)).to be true
201
+ end
202
+ end
203
+
204
+ context "with libvips backend" do
205
+ let(:attachment) { double("Attachment", options: { backend: :vips }, instance: double(local?: false)) }
206
+
207
+ before do
208
+ begin
209
+ require "vips"
210
+ rescue LoadError
211
+ skip "libvips not installed"
212
+ end
213
+ end
214
+
215
+ it "resizes based on maximum pixel count" do
216
+ processor = described_class.new(file, { pixels: 10_000 }, attachment)
217
+ result = processor.make
218
+
219
+ expect(File.exist?(result.path)).to be true
220
+ dimensions = `identify -format "%wx%h" "#{result.path}"`.strip
221
+ width, height = dimensions.split("x").map(&:to_i)
222
+ expect(width * height).to be <= 10_000
223
+ end
224
+ end
225
+ end
226
+
227
+ describe "metadata stripping trigger" do
228
+ it "processes when attachment instance is local" do
229
+ local_instance = double(local?: true)
230
+ local_attachment = double("Attachment", options: {}, instance: local_instance)
231
+
232
+ processor = described_class.new(file, { geometry: "434x66" }, local_attachment)
233
+
234
+ # Even with matching geometry, should process because local? is true
235
+ # This is verified by the fact that make doesn't just return the original file
236
+ result = processor.make
237
+ expect(File.exist?(result.path)).to be true
238
+ end
239
+ end
240
+
241
+ describe "Thumbnail.make class method" do
242
+ it "is available and works correctly" do
243
+ expect(Paperclip::Thumbnail).to respond_to(:make)
244
+
245
+ result = Paperclip::Thumbnail.make(file, { geometry: "50x50#" }, attachment)
246
+ expect(File.exist?(result.path)).to be true
247
+
248
+ dimensions = `identify -format "%wx%h" "#{result.path}"`.strip
249
+ expect(dimensions).to eq("50x50")
250
+ end
251
+
252
+ it "respects backend option" do
253
+ begin
254
+ require "vips"
255
+ rescue LoadError
256
+ skip "libvips not installed"
257
+ end
258
+
259
+ result = Paperclip::Thumbnail.make(file, { geometry: "50x50#", backend: :vips }, attachment)
260
+ expect(File.exist?(result.path)).to be true
261
+
262
+ dimensions = `identify -format "%wx%h" "#{result.path}"`.strip
263
+ expect(dimensions).to eq("50x50")
264
+ end
265
+ end
266
+ end
@@ -11,16 +11,50 @@ describe Paperclip::Processor do
11
11
  context "Calling #convert" do
12
12
  it "runs the convert command with Terrapin" do
13
13
  Paperclip.options[:log_command] = false
14
+ allow(Paperclip).to receive(:imagemagick7?).and_return(false)
15
+ Paperclip.options[:is_windows] = false
14
16
  expect(Terrapin::CommandLine).to receive(:new).with("convert", "stuff", {}).and_return(double(run: nil))
15
17
  Paperclip::Processor.new("filename").convert("stuff")
16
18
  end
19
+
20
+ it "runs the magick convert command when ImageMagick 7 is present" do
21
+ Paperclip.options[:log_command] = false
22
+ allow(Paperclip).to receive(:imagemagick7?).and_return(true)
23
+ expect(Terrapin::CommandLine).to receive(:new).with("magick", "stuff", {}).and_return(double(run: nil))
24
+ Paperclip::Processor.new("filename").convert("stuff")
25
+ end
17
26
  end
18
27
 
19
28
  context "Calling #identify" do
20
- it "runs the identify command with Terrapin" do
29
+ it "runs the identify command" do
21
30
  Paperclip.options[:log_command] = false
31
+ allow(Paperclip).to receive(:imagemagick7?).and_return(false)
32
+ Paperclip.options[:is_windows] = false
22
33
  expect(Terrapin::CommandLine).to receive(:new).with("identify", "stuff", {}).and_return(double(run: nil))
23
34
  Paperclip::Processor.new("filename").identify("stuff")
24
35
  end
36
+
37
+ it "runs the magick identify command when ImageMagick 7 is present" do
38
+ Paperclip.options[:log_command] = false
39
+ allow(Paperclip).to receive(:imagemagick7?).and_return(true)
40
+ expect(Terrapin::CommandLine).to receive(:new).with("magick identify", "stuff", {}).and_return(double(run: nil))
41
+ Paperclip::Processor.new("filename").identify("stuff")
42
+ end
43
+ end
44
+
45
+ context "Calling #vips" do
46
+ it "runs the vips command with Paperclip.run" do
47
+ Paperclip.options[:log_command] = false
48
+ expect(Paperclip).to receive(:run).with("vips", "stuff", { :some => "args" })
49
+ Paperclip::Processor.new("filename").vips("stuff", { :some => "args" })
50
+ end
51
+ end
52
+
53
+ context "Calling #vipsheader" do
54
+ it "runs the vipsheader command with Paperclip.run" do
55
+ Paperclip.options[:log_command] = false
56
+ expect(Paperclip).to receive(:run).with("vipsheader", "stuff", { :some => "args" })
57
+ Paperclip::Processor.new("filename").vipsheader("stuff", { :some => "args" })
58
+ end
25
59
  end
26
60
  end
@@ -212,6 +212,64 @@ describe Paperclip::Style do
212
212
  end
213
213
  end
214
214
 
215
+ context "A style rule with per-style backend selection" do
216
+ before do
217
+ @attachment = attachment path: ":basename.:extension",
218
+ styles: {
219
+ vips_style: {
220
+ geometry: "800x800>",
221
+ backend: :vips
222
+ },
223
+ magick_style: {
224
+ geometry: "100x100#",
225
+ backend: :image_magick
226
+ },
227
+ default_style: {
228
+ geometry: "200x200"
229
+ }
230
+ }
231
+ end
232
+
233
+ it "passes backend option through to processor_options for vips style" do
234
+ assert_equal :vips, @attachment.styles[:vips_style].processor_options[:backend]
235
+ end
236
+
237
+ it "passes backend option through to processor_options for image_magick style" do
238
+ assert_equal :image_magick, @attachment.styles[:magick_style].processor_options[:backend]
239
+ end
240
+
241
+ it "does not include backend in processor_options when not specified" do
242
+ expect(@attachment.styles[:default_style].processor_options).not_to have_key(:backend)
243
+ end
244
+
245
+ it "includes geometry correctly for each style" do
246
+ assert_equal "800x800>", @attachment.styles[:vips_style].geometry
247
+ assert_equal "100x100#", @attachment.styles[:magick_style].geometry
248
+ assert_equal "200x200", @attachment.styles[:default_style].geometry
249
+ end
250
+
251
+ it "allows accessing backend via hash notation" do
252
+ assert_equal :vips, @attachment.styles[:vips_style][:backend]
253
+ assert_equal :image_magick, @attachment.styles[:magick_style][:backend]
254
+ end
255
+ end
256
+
257
+ context "A style rule with backend as a proc" do
258
+ before do
259
+ @attachment = attachment path: ":basename.:extension",
260
+ styles: {
261
+ dynamic_backend: {
262
+ geometry: "500x500",
263
+ backend: lambda { |_a| :vips }
264
+ }
265
+ }
266
+ end
267
+
268
+ it "evaluates proc when processor_options are requested" do
269
+ assert_equal :vips, @attachment.styles[:dynamic_backend].processor_options[:backend]
270
+ end
271
+ end
272
+
215
273
  context "A style rule supplied with default format" do
216
274
  before do
217
275
  @attachment = attachment default_format: :png,
@@ -0,0 +1,173 @@
1
+ require "spec_helper"
2
+
3
+ describe Paperclip::Thumbnail do
4
+ context "with ImageMagick specific + options" do
5
+ before do
6
+ @file = File.new(fixture_file("5k.png"), "rb")
7
+ @attachment = double("Attachment", options: {})
8
+ end
9
+
10
+ after { @file.close }
11
+
12
+ it "correctly applies options starting with +" do
13
+ # The user's specific options - with Shellwords, quoted values are parsed correctly
14
+ convert_options = '-coalesce +profile "!icc,*" +set date:modify +set date:create +set date:timestamp'
15
+
16
+ thumb = Paperclip::Thumbnail.new(@file, {
17
+ geometry: "100x100",
18
+ convert_options: convert_options,
19
+ backend: :image_magick,
20
+ }, @attachment)
21
+
22
+ # Spy on the pipeline to verify the correct methods/arguments are called on it.
23
+ allow(thumb).to receive(:apply_single_option).and_call_original
24
+ expect { thumb.make }.not_to raise_error
25
+
26
+ # With Shellwords parsing, the outer quotes are stripped from "!icc,*" -> !icc,*
27
+ expect(thumb).to have_received(:apply_single_option).with(anything, "coalesce", nil, "-")
28
+ expect(thumb).to have_received(:apply_single_option).with(anything, "profile", "!icc,*", "+")
29
+ expect(thumb).to have_received(:apply_single_option).with(anything, "set", "date:modify", "+")
30
+ expect(thumb).to have_received(:apply_single_option).with(anything, "set", "date:create", "+")
31
+ expect(thumb).to have_received(:apply_single_option).with(anything, "set", "date:timestamp", "+")
32
+ end
33
+ end
34
+
35
+ context "with convert_options having multiple arguments for a flag" do
36
+ before do
37
+ @file = File.new(fixture_file("5k.png"), "rb")
38
+ @attachment = double("Attachment", options: {})
39
+ end
40
+
41
+ after { @file.close }
42
+
43
+ it "handles -set with two arguments correctly" do
44
+ # User provided example
45
+ convert_options = "-coalesce -set my_prop 123123"
46
+
47
+ thumb = Paperclip::Thumbnail.new(@file, {
48
+ geometry: "100x100",
49
+ convert_options: convert_options,
50
+ backend: :image_magick,
51
+ }, @attachment)
52
+
53
+ result = nil
54
+ expect { result = thumb.make }.not_to raise_error
55
+
56
+ # Verify property is set
57
+ require "shellwords"
58
+ output = `identify -verbose #{Shellwords.escape(result.path)}`
59
+ expect(output).to include("my_prop: 123123")
60
+ end
61
+ end
62
+
63
+ context "with convert_options having multiple arguments followed by other options" do
64
+ before do
65
+ @file = File.new(fixture_file("5k.png"), "rb")
66
+ @attachment = double("Attachment", options: {})
67
+ end
68
+
69
+ after { @file.close }
70
+
71
+ it "handles -set with two arguments followed by another option" do
72
+ # User provided example scenario
73
+ convert_options = "-set my_prop 123123 -auto-orient"
74
+
75
+ thumb = Paperclip::Thumbnail.new(@file, {
76
+ geometry: "100x100",
77
+ convert_options: convert_options,
78
+ backend: :image_magick,
79
+ }, @attachment)
80
+
81
+ result = nil
82
+ expect { result = thumb.make }.not_to raise_error
83
+
84
+ require "shellwords"
85
+ output = `identify -verbose #{Shellwords.escape(result.path)}`
86
+ expect(output).to include("my_prop: 123123")
87
+ end
88
+ end
89
+
90
+ context "with quoted values in convert_options (Shellwords parsing)" do
91
+ before do
92
+ @file = File.new(fixture_file("5k.png"), "rb")
93
+ @attachment = double("Attachment", options: {})
94
+ end
95
+
96
+ after { @file.close }
97
+
98
+ it "handles single-quoted values correctly" do
99
+ # Shellwords should parse 'Hello World' as a single token
100
+ convert_options = "-set my_comment 'Hello World'"
101
+
102
+ thumb = Paperclip::Thumbnail.new(@file, {
103
+ geometry: "100x100",
104
+ convert_options: convert_options,
105
+ backend: :image_magick,
106
+ }, @attachment)
107
+
108
+ allow(thumb).to receive(:apply_single_option).and_call_original
109
+ result = nil
110
+ expect { result = thumb.make }.not_to raise_error
111
+
112
+ # Verify that 'Hello World' was passed as a single value (without quotes)
113
+ expect(thumb).to have_received(:apply_single_option).with(anything, "set", "my_comment", "-")
114
+
115
+ # Check output includes the set property
116
+ require "shellwords"
117
+ output = `identify -verbose #{Shellwords.escape(result.path)}`
118
+ expect(output).to include("my_comment: Hello World")
119
+ end
120
+
121
+ it "handles double-quoted values correctly" do
122
+ convert_options = '-set my_comment "Hello World"'
123
+
124
+ thumb = Paperclip::Thumbnail.new(@file, {
125
+ geometry: "100x100",
126
+ convert_options: convert_options,
127
+ backend: :image_magick,
128
+ }, @attachment)
129
+
130
+ result = nil
131
+ expect { result = thumb.make }.not_to raise_error
132
+
133
+ require "shellwords"
134
+ output = `identify -verbose #{Shellwords.escape(result.path)}`
135
+ expect(output).to include("my_comment: Hello World")
136
+ end
137
+
138
+ it "handles escaped spaces correctly" do
139
+ convert_options = '-set my_comment Hello\ World'
140
+
141
+ thumb = Paperclip::Thumbnail.new(@file, {
142
+ geometry: "100x100",
143
+ convert_options: convert_options,
144
+ backend: :image_magick,
145
+ }, @attachment)
146
+
147
+ result = nil
148
+ expect { result = thumb.make }.not_to raise_error
149
+
150
+ require "shellwords"
151
+ output = `identify -verbose #{Shellwords.escape(result.path)}`
152
+ expect(output).to include("my_comment: Hello World")
153
+ end
154
+
155
+ it "handles multiple quoted options correctly" do
156
+ convert_options = "-set first_prop 'First Value' -set second_prop 'Second Value'"
157
+
158
+ thumb = Paperclip::Thumbnail.new(@file, {
159
+ geometry: "100x100",
160
+ convert_options: convert_options,
161
+ backend: :image_magick,
162
+ }, @attachment)
163
+
164
+ result = nil
165
+ expect { result = thumb.make }.not_to raise_error
166
+
167
+ require "shellwords"
168
+ output = `identify -verbose #{Shellwords.escape(result.path)}`
169
+ expect(output).to include("first_prop: First Value")
170
+ expect(output).to include("second_prop: Second Value")
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,53 @@
1
+ require "spec_helper"
2
+
3
+ describe Paperclip::Thumbnail do
4
+ describe "#parse_loader_options" do
5
+ let(:file) { File.new(fixture_file("5k.png"), "rb") }
6
+ let(:thumb) { Paperclip::Thumbnail.new(file, geometry: "50x50") }
7
+
8
+ after { file.close }
9
+
10
+ it "correctly parses positive numeric values" do
11
+ options = "-density 300"
12
+ result = thumb.send(:parse_loader_options, options)
13
+ expect(result).to eq({ density: "300" })
14
+ end
15
+
16
+ it "correctly parses negative numeric values" do
17
+ # Some hypothetical loader option that might take a negative value
18
+ options = "-something -90"
19
+ result = thumb.send(:parse_loader_options, options)
20
+ expect(result).to eq({ something: "-90" })
21
+ end
22
+
23
+ it "still treats non-numeric tokens starting with - as new options" do
24
+ options = "-density 300 -strip"
25
+ result = thumb.send(:parse_loader_options, options)
26
+ expect(result).to eq({ density: "300", strip: true })
27
+ end
28
+
29
+ it "handles single-quoted values correctly" do
30
+ options = "-comment 'Hello World'"
31
+ result = thumb.send(:parse_loader_options, options)
32
+ expect(result).to eq({ comment: "Hello World" })
33
+ end
34
+
35
+ it "handles double-quoted values correctly" do
36
+ options = '-path "/some/path with spaces"'
37
+ result = thumb.send(:parse_loader_options, options)
38
+ expect(result).to eq({ path: "/some/path with spaces" })
39
+ end
40
+
41
+ it "handles escaped spaces correctly" do
42
+ options = '-label Hello\ World'
43
+ result = thumb.send(:parse_loader_options, options)
44
+ expect(result).to eq({ label: "Hello World" })
45
+ end
46
+
47
+ it "handles multiple options with quoted values" do
48
+ options = "-first 'Value One' -second 'Value Two'"
49
+ result = thumb.send(:parse_loader_options, options)
50
+ expect(result).to eq({ first: "Value One", second: "Value Two" })
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,42 @@
1
+ require "spec_helper"
2
+
3
+ describe Paperclip::Thumbnail do
4
+ context "Security" do
5
+ old_backend = Paperclip.options[:backend]
6
+
7
+ before do
8
+ @file = File.new(fixture_file("5k.png"), "rb")
9
+ @attachment = double("Attachment", options: {})
10
+ Paperclip.options[:backend] = :image_magick
11
+ end
12
+
13
+ after do
14
+ @file.close
15
+ Paperclip.options[:backend] = old_backend
16
+ end
17
+
18
+ it "allows safe convert options" do
19
+ thumb = Paperclip::Thumbnail.new(@file, { geometry: "100x100", convert_options: "-strip" }, @attachment)
20
+
21
+ expect(Paperclip).to_not receive(:log).with(/Warning: Option strip is not allowed/)
22
+ thumb.make
23
+ end
24
+
25
+ it "blocks unsafe convert options" do
26
+ # -write is not in the ALLOWED_IMAGEMAGICK_OPTIONS list
27
+ thumb = Paperclip::Thumbnail.new(@file, { geometry: "100x100", convert_options: "-write /tmp/hacked.png" },
28
+ @attachment)
29
+
30
+ expect(Paperclip).to receive(:log).with("Warning: Option write is not allowed.")
31
+ thumb.make
32
+ end
33
+
34
+ it "allows options with underscores in the whitelist when passed with hyphens" do
35
+ # 'auto_orient' is in the list. User passes '-auto-orient'.
36
+ thumb = Paperclip::Thumbnail.new(@file, { geometry: "100x100", convert_options: "-auto-orient" }, @attachment)
37
+
38
+ expect(Paperclip).to_not receive(:log).with(/Warning: Option auto-orient is not allowed/)
39
+ thumb.make
40
+ end
41
+ end
42
+ end