safe_image 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -10
  3. data/README.md +176 -168
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +43 -41
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -281
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +250 -48
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +367 -97
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +69 -24
  30. data/lib/safe_image/sandbox.rb +44 -191
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +186 -55
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -294
  37. metadata +48 -26
  38. data/lib/safe_image/discourse_compat.rb +0 -452
  39. data/lib/safe_image/svg_sanitizer.rb +0 -102
  40. data/lib/safe_image/vips_glue.rb +0 -361
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module SafeImage
6
+ module OperationBackends
7
+ # libvips operation orchestration. This is the configured-backend path for
8
+ # untrusted raster bytes; optional cjpegli/jpegtran tiers run only after
9
+ # SafeImage has decoded or validated the input.
10
+ class Vips < Base
11
+ NATIVE_CONVERT_DEFAULT_QUALITY = QualityDefaults::NATIVE_CONVERT_JPEG
12
+
13
+ def resize(input:, output:, width:, height:, quality:, optimize:, max_pixels:, chroma_subsampling:)
14
+ input, output = input_output!(input, output)
15
+ max_pixels = resolved_max_pixels(max_pixels)
16
+ result =
17
+ Processor.new(max_pixels: max_pixels, chroma_subsampling: chroma_subsampling, config: config).thumbnail(
18
+ input: input,
19
+ output: output,
20
+ width: width,
21
+ height: height,
22
+ quality: quality || QualityDefaults::JPEG,
23
+ optimize: optimize
24
+ )
25
+ result.with(tier: result.backend.include?("cjpegli") ? :cjpegli : :resize)
26
+ end
27
+
28
+ def crop(input:, output:, width:, height:, quality:, optimize:, max_pixels:, chroma_subsampling:)
29
+ input, output = input_output!(input, output)
30
+ max_pixels = resolved_max_pixels(max_pixels)
31
+ probe = operation_probe(input, max_pixels: max_pixels)
32
+ output = safe_output!(output)
33
+ format = Formats.extension(output)
34
+
35
+ info =
36
+ if jpegli_for_generated_jpeg?(format)
37
+ JpegliBackend.encode_generated_jpeg(
38
+ output: output,
39
+ quality: quality || JpegliBackend::DEFAULT_QUALITY,
40
+ chroma_subsampling: chroma_subsampling,
41
+ input_format: probe.input_format
42
+ ) do |tmp_path|
43
+ VipsBackend.crop_north(
44
+ input: probe.input,
45
+ output: tmp_path,
46
+ width: width,
47
+ height: height,
48
+ format: "png",
49
+ quality: 100,
50
+ max_pixels: max_pixels
51
+ )
52
+ end
53
+ else
54
+ VipsBackend.crop_north(
55
+ input: probe.input,
56
+ output: output,
57
+ width: width,
58
+ height: height,
59
+ format: format,
60
+ quality: quality || QualityDefaults::JPEG,
61
+ max_pixels: max_pixels
62
+ )
63
+ end
64
+ optimize_output(output, quality) if optimize
65
+ result_from_info(probe.input, output, info, :vips, tier: info[:encoder] == "cjpegli" ? :cjpegli : :crop)
66
+ end
67
+
68
+ def downsize(input:, output:, dimensions:, optimize:, max_pixels:, quality:, chroma_subsampling:)
69
+ input, output = input_output!(input, output)
70
+ max_pixels = resolved_max_pixels(max_pixels)
71
+ probe = operation_probe(input, max_pixels: max_pixels)
72
+ output = safe_output!(output)
73
+ format = Formats.extension(output)
74
+ info =
75
+ if jpegli_for_generated_jpeg?(format)
76
+ JpegliBackend.encode_generated_jpeg(
77
+ output: output,
78
+ quality: quality,
79
+ chroma_subsampling: chroma_subsampling,
80
+ input_format: probe.input_format
81
+ ) do |tmp_path|
82
+ VipsBackend.downsize(
83
+ input: probe.input,
84
+ output: tmp_path,
85
+ dimensions: dimensions,
86
+ format: "png",
87
+ quality: 100,
88
+ max_pixels: max_pixels
89
+ )
90
+ end
91
+ else
92
+ VipsBackend.downsize(
93
+ input: probe.input,
94
+ output: output,
95
+ dimensions: dimensions,
96
+ format: format,
97
+ quality: quality,
98
+ max_pixels: max_pixels
99
+ )
100
+ end
101
+ optimize_output(output, nil) if optimize
102
+ result_from_info(probe.input, output, info, :vips, tier: info[:encoder] == "cjpegli" ? :cjpegli : :downsize)
103
+ end
104
+
105
+ def convert(input:, output:, format:, quality:, optimize:, max_pixels:, chroma_subsampling:)
106
+ input, output = input_output!(input, output)
107
+ max_pixels = resolved_max_pixels(max_pixels)
108
+ input = PathSafety.ensure_regular_file!(input).to_s
109
+ normalized_format = Formats.normalize(format)
110
+
111
+ if jpegli_for_convert?(input, normalized_format)
112
+ info =
113
+ jpegli_convert_after_native_decode(
114
+ input: input,
115
+ output: output,
116
+ quality: quality || JpegliBackend::DEFAULT_QUALITY,
117
+ max_pixels: max_pixels,
118
+ chroma_subsampling: chroma_subsampling
119
+ )
120
+ return result_from_info(input, output, info, :vips, tier: :cjpegli)
121
+ end
122
+
123
+ info =
124
+ write_through_tempfile(output) do |tmp_path|
125
+ Native.convert(input, tmp_path, normalized_format, quality || NATIVE_CONVERT_DEFAULT_QUALITY, max_pixels)
126
+ end
127
+ optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
128
+ result_from_info(input, output, info, :vips, tier: :native_convert)
129
+ end
130
+
131
+ def fix_orientation(input:, output:, max_pixels:, quality:)
132
+ input, output = input_output!(input, output)
133
+ max_pixels = resolved_max_pixels(max_pixels)
134
+ input = PathSafety.ensure_regular_file!(input).to_s
135
+ format = Formats.extension(input)
136
+ # Validates the format against the native loader allowlist and enforces
137
+ # the pixel cap before any pixel decode.
138
+ orient = VipsBackend.orientation(input, max_pixels: max_pixels)
139
+
140
+ # Lossless tier: jpegtran transforms JPEG DCT coefficients directly, so
141
+ # there is no generation loss. -perfect refuses when the dimensions are
142
+ # not MCU-aligned; only that expected refusal falls through to the
143
+ # observable re-encode tier.
144
+ tier = :native_reencode
145
+ if format == "jpg" && orient > 1 && Runner.available?("jpegtran")
146
+ begin
147
+ return jpegtran_fix_orientation(input, output, orient)
148
+ rescue CommandError => e
149
+ raise unless Optimizer.jpegtran_perfect_reject?(e)
150
+
151
+ tier = :jpegtran_fallback_reencode
152
+ end
153
+ end
154
+
155
+ quality = quality.nil? ? QualityDefaults::FIX_ORIENTATION_REENCODE_JPEG : Integer(quality)
156
+ raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
157
+
158
+ info =
159
+ write_through_tempfile(output) { |tmp_path| Native.resize(input, tmp_path, 1.0, format, quality, max_pixels) }
160
+ result_from_info(input, output, info, :vips, tier: tier)
161
+ end
162
+
163
+ def convert_favicon_to_png(input:, output:, optimize:, max_pixels:)
164
+ input, output = input_output!(input, output)
165
+ max_pixels = resolved_max_pixels(max_pixels)
166
+ # Pure-Ruby ICO parse; libvips only encodes the extracted pixels.
167
+ info = Ico.convert_to_png(input, output, max_pixels: max_pixels)
168
+ optimize_output(output, nil) if optimize
169
+ result_from_info(input, output, info, :ico_vips, tier: :ico_ruby)
170
+ end
171
+
172
+ def letter_avatar(output:, size:, background_rgb:, letter:, pointsize:, font:)
173
+ output = safe_output!(output)
174
+ request = {
175
+ output: output,
176
+ size: size,
177
+ background_rgb: background_rgb,
178
+ letter: letter,
179
+ pointsize: pointsize,
180
+ font: font
181
+ }
182
+ result_from_info("generated", output, VipsBackend.letter_avatar(**request), :vips, tier: :letter_avatar)
183
+ end
184
+
185
+ private
186
+
187
+ def operation_probe(path, max_pixels:)
188
+ Processor.new(max_pixels: max_pixels, config: config).probe(Pathname.new(path).expand_path.to_s)
189
+ end
190
+
191
+ def backend_frame_count(path, max_pixels:)
192
+ VipsBackend.frame_count(path, max_pixels: max_pixels)
193
+ end
194
+
195
+ def jpegli_for_convert?(input, normalized_format)
196
+ normalized_format == "jpg" && JpegliBackend.available? && JpegliBackend.suitable_direct_input?(input)
197
+ end
198
+
199
+ # cjpegli is an output-quality tool, not a configuration choice: installed
200
+ # means used for JPEG output on the native path. It encodes only pixels
201
+ # this gem already decoded, so it is not part of the untrusted-input
202
+ # surface the backend choice controls.
203
+ def jpegli_for_generated_jpeg?(format)
204
+ Formats.normalize(format) == "jpg" && JpegliBackend.available?
205
+ end
206
+
207
+ def jpegli_convert_after_native_decode(input:, output:, quality:, max_pixels:, chroma_subsampling:)
208
+ JpegliBackend.encode_generated_jpeg(
209
+ output: output,
210
+ quality: quality,
211
+ chroma_subsampling: chroma_subsampling
212
+ ) { |tmp_path| Native.convert(input, tmp_path, "png", 100, max_pixels) }
213
+ end
214
+
215
+ def jpegtran_fix_orientation(input, output, orient)
216
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
217
+ info =
218
+ write_through_tempfile(output) do |tmp_path|
219
+ Runner.run!(
220
+ [
221
+ "jpegtran",
222
+ "-copy",
223
+ "none",
224
+ "-perfect",
225
+ *Optimizer::JPEGTRAN_OPERATIONS.fetch(orient),
226
+ "-outfile",
227
+ tmp_path,
228
+ input
229
+ ],
230
+ read: [input],
231
+ write: [tmp_path, File.dirname(tmp_path)]
232
+ )
233
+ Native.probe(tmp_path)
234
+ end
235
+ result_from_info(
236
+ input,
237
+ output,
238
+ {
239
+ input_format: "jpg",
240
+ output_format: "jpg",
241
+ width: info.fetch(:width),
242
+ height: info.fetch(:height),
243
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
244
+ },
245
+ :jpegtran,
246
+ tier: :jpegtran_lossless
247
+ )
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "operation_backends/base"
4
+ require_relative "operation_backends/vips"
5
+ require_relative "operation_backends/image_magick"
6
+
7
+ module SafeImage
8
+ # Factory for operation orchestration strategies. Transform and metadata
9
+ # operation objects select one strategy per call from the supplied config; the
10
+ # strategy owns the backend-specific pipeline details.
11
+ module OperationBackends
12
+ module_function
13
+
14
+ def for(config)
15
+ case config.backend
16
+ when :vips
17
+ Vips.new(config: config)
18
+ when :imagemagick
19
+ ImageMagick.new(config: config)
20
+ else
21
+ raise ArgumentError, "unknown backend: #{config.backend.inspect}"
22
+ end
23
+ end
24
+ end
25
+
26
+ private_constant :OperationBackends
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeImage
4
+ # Shared plumbing for public-operation implementations. Concrete operation
5
+ # classes contain the inline implementation; child-process sandboxing happens
6
+ # at the helper/command boundary, not by proxying these Ruby objects.
7
+ class OperationSet
8
+ attr_reader :config
9
+
10
+ def initialize(config:)
11
+ @config = config
12
+ end
13
+
14
+ private
15
+
16
+ def resolved_max_pixels(max_pixels)
17
+ SafeImage.resolved_max_pixels(max_pixels, config: config)
18
+ end
19
+ end
20
+
21
+ private_constant :OperationSet
22
+ end
@@ -1,78 +1,280 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tempfile"
3
+ require "fileutils"
4
4
 
5
5
  module SafeImage
6
6
  module Optimizer
7
7
  module_function
8
8
 
9
+ # pngquant's lossy trial is worthwhile only for small generated PNGs; above
10
+ # this size the extra decode/quantize pass is comparatively expensive. The
11
+ # lossless PNG optimizer still runs for larger PNGs.
9
12
  MAX_PNGQUANT_SIZE = 500_000
10
13
 
11
- def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true)
12
- path = PathSafety.ensure_regular_file!(path)
14
+ # EXIF orientation values mapped onto jpegtran's lossless transforms.
15
+ JPEGTRAN_OPERATIONS = {
16
+ 2 => %w[-flip horizontal],
17
+ 3 => %w[-rotate 180],
18
+ 4 => %w[-flip vertical],
19
+ 5 => ["-transpose"],
20
+ 6 => %w[-rotate 90],
21
+ 7 => ["-transverse"],
22
+ 8 => %w[-rotate 270]
23
+ }.freeze
24
+
25
+ def jpegtran_perfect_reject?(error)
26
+ error.is_a?(CommandError) && error.category == :exit_status && error.status.to_i == 1 &&
27
+ Array(error.command).include?("-perfect")
28
+ end
13
29
 
14
- ext = path.extname.delete_prefix(".").downcase
15
- ext = "jpg" if ext == "jpeg"
30
+ # assume_upright: skips the JPEG orientation check; only for callers
31
+ # optimising output this gem just encoded (which is always upright).
32
+ def optimize(
33
+ input:,
34
+ output:,
35
+ mode: :lossless,
36
+ strip_metadata: true,
37
+ quality: nil,
38
+ timeout: Runner::DEFAULT_TIMEOUT,
39
+ strict: true,
40
+ assume_upright: false
41
+ )
42
+ input, output = PathSafety.ensure_distinct_file_paths!(input, output)
43
+ ext = normalized_extension(input)
16
44
 
45
+ StagedOutput.replace(output, suffix: ".safe-image.#{ext}") do |tmp_path|
46
+ FileUtils.cp(input, tmp_path)
47
+ optimize_working_file!(
48
+ tmp_path,
49
+ mode: mode,
50
+ strip_metadata: strip_metadata,
51
+ quality: quality,
52
+ timeout: timeout,
53
+ strict: strict,
54
+ assume_upright: assume_upright
55
+ )
56
+ end
57
+ end
58
+
59
+ def optimize_working_file!(
60
+ path,
61
+ mode: :lossless,
62
+ strip_metadata: true,
63
+ quality: nil,
64
+ timeout: Runner::DEFAULT_TIMEOUT,
65
+ strict: true,
66
+ assume_upright: false
67
+ )
68
+ path = PathSafety.ensure_regular_file!(path)
69
+ ext = normalized_extension(path)
17
70
  before = File.size(path)
18
- tools = []
71
+ state = { tools: [], rotated_from: nil, trimmed: false }
19
72
 
20
73
  case ext
21
74
  when "jpg"
22
- if Runner.available?("jpegoptim")
23
- argv = ["jpegoptim", "--quiet"]
24
- argv << (strip_metadata ? "--strip-all" : "--strip-none")
25
- argv << "--max=#{Integer(quality)}" if quality
26
- argv << path.to_s
27
- Runner.run!(argv, timeout: timeout)
28
- tools << "jpegoptim"
29
- else
30
- raise Error, "jpegoptim is required for strict JPEG optimisation" if strict
31
- end
75
+ skipped =
76
+ optimize_jpeg!(
77
+ path,
78
+ state,
79
+ strip_metadata: strip_metadata,
80
+ quality: quality,
81
+ timeout: timeout,
82
+ strict: strict,
83
+ assume_upright: assume_upright,
84
+ before: before
85
+ )
86
+ return skipped if skipped
32
87
  when "png"
33
- if mode.to_sym == :lossy && before < MAX_PNGQUANT_SIZE
34
- if Runner.available?("pngquant")
35
- tmp = Tempfile.new([path.basename(".*").to_s, ".pngquant.png"], path.dirname.to_s)
36
- tmp_path = Pathname.new(tmp.path)
37
- tmp.close
38
- begin
39
- argv = ["pngquant", "--force", "--skip-if-larger", "--output", tmp_path.to_s]
40
- argv << "--quality=#{quality}" if quality # e.g. "65-90"
41
- argv << path.to_s
42
- Runner.run!(argv, timeout: timeout)
43
- if tmp_path.file? && File.size(tmp_path) < File.size(path)
44
- FileUtils.mv(tmp_path, path)
45
- tools << "pngquant"
46
- end
47
- ensure
48
- FileUtils.rm_f(tmp_path)
49
- end
50
- elsif strict
51
- raise Error, "pngquant is required for strict lossy PNG optimisation"
52
- end
88
+ optimize_png!(
89
+ path,
90
+ state,
91
+ mode: mode,
92
+ strip_metadata: strip_metadata,
93
+ quality: quality,
94
+ timeout: timeout,
95
+ strict: strict,
96
+ before: before
97
+ )
98
+ else
99
+ raise UnsupportedFormatError, "unsupported optimize format: #{ext.inspect}"
100
+ end
101
+
102
+ build_result(ext, before, File.size(path), state)
103
+ end
104
+
105
+ def optimize_jpeg!(path, state, strip_metadata:, quality:, timeout:, strict:, assume_upright:, before:)
106
+ # Stripping metadata deletes the EXIF orientation tag, so an oriented
107
+ # image must have the rotation baked into its pixels first or it ships
108
+ # sideways. jpegtran does that losslessly; without it, leave the file
109
+ # untouched rather than strip-without-rotate.
110
+ orientation = strip_metadata && !assume_upright ? jpeg_orientation(path) : 1
111
+ if orientation > 1
112
+ unless Runner.available?("jpegtran")
113
+ raise Error, "jpegtran is required to optimize a JPEG with EXIF orientation" if strict
114
+ return skipped_result("jpg", before, state.fetch(:tools))
53
115
  end
54
116
 
55
- if Runner.available?("oxipng")
56
- argv = ["oxipng", "--quiet", "-o", "3"]
57
- argv.concat(["--strip", strip_metadata ? "safe" : "none"])
117
+ state[:trimmed] = upright_working_file!(path, orientation, timeout: timeout)
118
+ state[:rotated_from] = orientation
119
+ state[:tools] << "jpegtran"
120
+ end
121
+
122
+ if Runner.available?("jpegoptim")
123
+ argv = %w[jpegoptim --quiet]
124
+ argv << (strip_metadata ? "--strip-all" : "--strip-none")
125
+ argv << "--max=#{Integer(quality)}" if quality
126
+ argv << path.to_s
127
+ Runner.run!(argv, timeout: timeout, read: [path.to_s], write: [path.to_s, File.dirname(path.to_s)])
128
+ state[:tools] << "jpegoptim"
129
+ else
130
+ raise Error, "jpegoptim is required for strict JPEG optimisation" if strict
131
+ end
132
+
133
+ nil
134
+ end
135
+
136
+ def optimize_png!(path, state, mode:, strip_metadata:, quality:, timeout:, strict:, before:)
137
+ # Discourse/image_optim parity: run the lossless PNG optimizer before the
138
+ # optional lossy pngquant pass.
139
+ png_lossless_optimizer!(path, state, strip_metadata: strip_metadata, timeout: timeout, strict: strict)
140
+ if mode.to_sym == :lossy && before < MAX_PNGQUANT_SIZE
141
+ pngquant!(path, state, quality: quality, timeout: timeout, strict: strict)
142
+ end
143
+ end
144
+
145
+ def pngquant!(path, state, quality:, timeout:, strict:)
146
+ if Runner.available?("pngquant")
147
+ StagedOutput.with_temp_path_near(path, suffix: ".pngquant.png") do |tmp_path|
148
+ argv = ["pngquant", "--force", "--skip-if-larger", "--output", tmp_path.to_s]
149
+ argv << "--quality=#{quality}" if quality # e.g. "65-90"
58
150
  argv << path.to_s
59
- Runner.run!(argv, timeout: timeout)
60
- tools << "oxipng"
61
- else
62
- raise Error, "oxipng is required for strict PNG optimisation" if strict
151
+ skipped = false
152
+ begin
153
+ Runner.run!(argv, timeout: timeout, read: [path.to_s], write: [tmp_path.to_s, File.dirname(tmp_path.to_s)])
154
+ rescue CommandError => e
155
+ # 98: --skip-if-larger declined the result; 99: --quality not met.
156
+ # Both mean "keep the original", not a failure — and the pre-created
157
+ # tempfile is still empty, so it must not win the size comparison.
158
+ raise if [98, 99].none? { |status| status == e.status }
159
+ skipped = true
160
+ end
161
+ if !skipped && tmp_path.file? && File.size(tmp_path).positive? && File.size(tmp_path) < File.size(path)
162
+ FileUtils.mv(tmp_path, path)
163
+ state[:tools] << "pngquant"
164
+ end
63
165
  end
64
- else
65
- raise UnsupportedFormatError, "unsupported optimize format: #{ext.inspect}"
166
+ elsif strict
167
+ raise Error, "pngquant is required for strict lossy PNG optimisation"
66
168
  end
169
+ end
170
+
171
+ def png_lossless_optimizer!(path, state, strip_metadata:, timeout:, strict:)
172
+ if Runner.available?("oxipng")
173
+ oxipng!(path, state, strip_metadata: strip_metadata, timeout: timeout)
174
+ elsif Runner.available?("optipng")
175
+ optipng!(path, state, strip_metadata: strip_metadata, timeout: timeout)
176
+ elsif strict
177
+ raise Error, "oxipng or optipng is required for strict PNG optimisation"
178
+ end
179
+ end
67
180
 
68
- after = File.size(path)
181
+ def oxipng!(path, state, strip_metadata:, timeout:)
182
+ argv = %w[oxipng --quiet -o 3]
183
+ argv.concat(["--strip", strip_metadata ? "safe" : "none"])
184
+ argv << path.to_s
185
+ Runner.run!(argv, timeout: timeout, read: [path.to_s], write: [path.to_s, File.dirname(path.to_s)])
186
+ state[:tools] << "oxipng"
187
+ end
188
+
189
+ def optipng!(path, state, strip_metadata:, timeout:)
190
+ argv = %w[optipng -quiet -o2]
191
+ argv.concat(%w[-strip all]) if strip_metadata
192
+ argv << path.to_s
193
+ Runner.run!(argv, timeout: timeout, read: [path.to_s], write: [path.to_s, File.dirname(path.to_s)])
194
+ state[:tools] << "optipng"
195
+ end
196
+
197
+ def build_result(format, before, after, state)
69
198
  {
70
- format: ext,
199
+ format: format,
71
200
  before_bytes: before,
72
201
  after_bytes: after,
73
202
  saved_bytes: before - after,
74
- tools: tools
203
+ tools: state.fetch(:tools),
204
+ rotated_from: state.fetch(:rotated_from),
205
+ trimmed: state.fetch(:trimmed)
206
+ }
207
+ end
208
+
209
+ def skipped_result(format, bytes, tools)
210
+ {
211
+ format: format,
212
+ before_bytes: bytes,
213
+ after_bytes: bytes,
214
+ saved_bytes: 0,
215
+ tools: tools,
216
+ rotated_from: nil,
217
+ trimmed: false
75
218
  }
76
219
  end
220
+
221
+ def normalized_extension(path)
222
+ Formats.extension(path)
223
+ end
224
+
225
+ def jpeg_orientation(path)
226
+ case SafeImage.config.backend
227
+ when :vips
228
+ VipsBackend.orientation(path.to_s)
229
+ when :imagemagick
230
+ ImageMagickBackend.orientation(path.to_s)
231
+ end
232
+ end
233
+
234
+ # Applies the orientation's lossless jpegtran transform to the working copy,
235
+ # dropping the metadata in the same pass (-copy none; this path only runs
236
+ # when strip_metadata is set). -perfect refuses dimensions that are not
237
+ # MCU-aligned; the -trim retry drops the partial edge blocks (under one MCU,
238
+ # at most 15px) instead of hiding a lossy re-encode here. Returns true when
239
+ # the fallback trimmed.
240
+ def upright_working_file!(path, orientation, timeout:)
241
+ transform = JPEGTRAN_OPERATIONS.fetch(orientation)
242
+ StagedOutput.with_temp_path_near(path, suffix: ".jpegtran.jpg") do |tmp_path|
243
+ trimmed = false
244
+ begin
245
+ Runner.run!(
246
+ ["jpegtran", "-copy", "none", "-perfect", *transform, "-outfile", tmp_path.to_s, path.to_s],
247
+ timeout: timeout,
248
+ read: [path.to_s],
249
+ write: [tmp_path.to_s, File.dirname(tmp_path.to_s)]
250
+ )
251
+ rescue CommandError => e
252
+ raise unless jpegtran_perfect_reject?(e)
253
+
254
+ Runner.run!(
255
+ ["jpegtran", "-copy", "none", "-trim", *transform, "-outfile", tmp_path.to_s, path.to_s],
256
+ timeout: timeout,
257
+ read: [path.to_s],
258
+ write: [tmp_path.to_s, File.dirname(tmp_path.to_s)]
259
+ )
260
+ trimmed = true
261
+ end
262
+ FileUtils.mv(tmp_path, path)
263
+ trimmed
264
+ end
265
+ end
266
+
267
+ private_class_method :optimize_working_file!,
268
+ :optimize_jpeg!,
269
+ :optimize_png!,
270
+ :pngquant!,
271
+ :png_lossless_optimizer!,
272
+ :oxipng!,
273
+ :optipng!,
274
+ :build_result,
275
+ :skipped_result,
276
+ :normalized_extension,
277
+ :jpeg_orientation,
278
+ :upright_working_file!
77
279
  end
78
280
  end
@@ -37,10 +37,21 @@ module SafeImage
37
37
  reject_symlink_components!(path.dirname)
38
38
  if File.exist?(path.to_s)
39
39
  raise UnsafePathError, "output path is a symlink: #{path}" if File.lstat(path.to_s).symlink?
40
+ raise UnsafePathError, "output path is a directory: #{path}" if File.directory?(path.to_s)
40
41
  end
41
42
  path
42
43
  end
43
44
 
45
+ def ensure_distinct_file_paths!(input, output)
46
+ input = ensure_regular_file!(input)
47
+ output = ensure_safe_output_path!(output)
48
+ same_path = input.to_s == output.to_s
49
+ same_file = output.exist? && File.identical?(input.to_s, output.to_s)
50
+ raise UnsafePathError, "input and output must be different paths" if same_path || same_file
51
+
52
+ [input, output]
53
+ end
54
+
44
55
  def ensure_imagemagick_safe!(path)
45
56
  path = local_path(path)
46
57
  raise UnsafePathError, "path contains NUL" if path.include?("\0")