safe_image 0.1.0 → 0.2.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.
data/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ Safe Image is a hardened image-processing boundary for untrusted uploads, not a
4
4
 
5
5
  ## Supported versions
6
6
 
7
- Security fixes are expected to land on `main` until the gem has tagged releases. Once releases exist, report against the latest released version unless you can reproduce on `main` as well.
7
+ Only the latest released gem version is supported; security fixes land on `main` and ship as the next release. Report against the latest released version unless you can reproduce on `main` as well.
8
8
 
9
9
  ## Threat model
10
10
 
@@ -13,24 +13,44 @@ Safe Image assumes image input may be attacker-controlled. The library is design
13
13
  - shell-free external command execution using argv arrays
14
14
  - allowlisted command environment
15
15
  - bounded command output and process-group timeout cleanup
16
- - explicit libvips loader selection for supported raster formats
17
- - no silent fallback from libvips to generic ImageMagick decoding
16
+ - explicit libvips loader selection for supported raster formats, with
17
+ libvips' untrusted-operation block enabled and the ImageMagick loader
18
+ classes blocked by name
19
+ - a runtime libvips binding (Fiddle) that exposes only the specific
20
+ operations the gem invokes — there is no generic operation access
21
+ - no silent fallback from libvips to generic ImageMagick decoding; the
22
+ backend is a single explicit `SafeImage.configure!` decision and formats it
23
+ cannot decode fail closed
24
+ - decompression-bomb ceilings enforced from container/header metadata before
25
+ any pixel decode (128MP default, plus dedicated SVG and ICO caps)
18
26
  - restrictive ImageMagick policy disabling delegates, filters, `@file`, remote URL coders, Ghostscript-backed formats, and dangerous pseudo-formats
27
+ - risky container formats parsed in memory-safe Ruby rather than C: SVG
28
+ (bounded REXML metadata and allowlist sanitising) and ICO (bounds-checked
29
+ directory/DIB parsing); extracted pixels are re-encoded through libvips and
30
+ embedded payload bytes are never copied through verbatim
31
+ - letter avatar text rendering escapes the user-derived glyph before Pango
32
+ markup parsing, and fonts come from an allowlist (the default font is
33
+ bundled with the gem)
19
34
  - symlink rejection for untrusted local input/output paths
20
35
  - remote fetch SSRF hardening: scheme/port restrictions, special-use IP blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, header allowlists, content-type/extension agreement, and probe-before-yield
21
- - bounded SVG metadata parsing and conservative SVG sanitising without handing SVG to ImageMagick for probing
22
36
  - optional Linux Landlock/seccomp subprocess sandboxing
23
37
 
38
+ One deliberate exception to libvips' untrusted-operation block: the libjxl
39
+ loader and saver are re-enabled because JPEG XL is part of the supported
40
+ input surface. JXL inputs still pass extension routing, the pixel cap, and
41
+ (optionally) the Landlock sandbox, but libjxl does parse attacker-controlled
42
+ bytes in-process like the other raster decoders below.
43
+
24
44
  ## Non-goals
25
45
 
26
- Safe Image does not claim that parsing hostile images in-process is memory-safe. Raster decoders such as libjpeg, libpng, libwebp, libheif, libvips loaders, and ImageMagick coders still parse attacker-controlled bytes. A decoder memory-corruption bug or pathological resource-consumption bug is still possible.
46
+ Safe Image does not claim that parsing hostile images in-process is memory-safe. Raster decoders such as libjpeg, libpng, libwebp, libheif, libjxl, libnsgif, libvips loaders, and ImageMagick coders still parse attacker-controlled bytes. A decoder memory-corruption bug or pathological resource-consumption bug is still possible.
27
47
 
28
48
  The honest claim is defense-in-depth:
29
49
 
30
50
  - without Landlock: centralized and hardened image processing with major delegate/protocol/policy foot-guns removed
31
51
  - with Landlock: the same hardening plus a kernel containment boundary around subprocess-based public operations
32
52
 
33
- If your deployment needs a hard isolation boundary, enable sandbox execution and run image processing away from your main web worker process.
53
+ If your deployment needs a hard isolation boundary, configure `landlock: true` and run image processing away from your main web worker process.
34
54
 
35
55
  ## Reporting vulnerabilities
36
56
 
@@ -40,7 +60,7 @@ Include:
40
60
 
41
61
  - affected version or commit
42
62
  - input file or minimized reproducer, if shareable
43
- - operation/API called
63
+ - operation/API called and the backend in use (libvips, ImageMagick, cjpegli)
44
64
  - expected vs actual result
45
65
  - whether Landlock sandboxing was enabled
46
66
  - host OS, kernel, libvips, ImageMagick, and optimizer tool versions
@@ -6,27 +6,37 @@ require "tempfile"
6
6
 
7
7
  module SafeImage
8
8
  # Compatibility-shaped API for the operations Discourse currently performs in
9
- # OptimizedImage, UploadCreator, ShrinkUploadedImage and FileHelper.
9
+ # OptimizedImage, UploadCreator, ShrinkUploadedImage and FileHelper. The
10
+ # backend is decided once by SafeImage.configure!; these methods only
11
+ # dispatch to it.
10
12
  module DiscourseCompat
11
13
  module_function
12
14
 
13
- def resize(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)
14
- if backend.to_sym == :vips
15
- return SafeImage.thumbnail(
16
- input: from,
17
- output: to,
18
- width: width,
19
- height: height,
20
- quality: quality || 85,
21
- backend: backend,
22
- optimize: optimize,
23
- max_pixels: max_pixels,
24
- encoder: encoder,
25
- chroma_subsampling: chroma_subsampling
26
- )
15
+ def resize(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
16
+ max_pixels = SafeImage.resolved_max_pixels(max_pixels)
17
+ case SafeImage.config.backend
18
+ when :vips
19
+ vips_resize(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
20
+ when :imagemagick
21
+ imagemagick_resize(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels)
27
22
  end
23
+ end
28
24
 
29
- probe = compat_probe(from, backend: :imagemagick, max_pixels: max_pixels)
25
+ def vips_resize(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:)
26
+ SafeImage.thumbnail(
27
+ input: from,
28
+ output: to,
29
+ width: width,
30
+ height: height,
31
+ quality: quality || 85,
32
+ optimize: optimize,
33
+ max_pixels: max_pixels,
34
+ chroma_subsampling: chroma_subsampling
35
+ )
36
+ end
37
+
38
+ def imagemagick_resize(from, to, width, height, quality:, optimize:, max_pixels:)
39
+ probe = compat_probe(from, max_pixels: max_pixels)
30
40
  output = PathSafety.ensure_safe_output_path!(to).to_s
31
41
  info = ImageMagickBackend.thumbnail(
32
42
  input: probe.input,
@@ -36,17 +46,27 @@ module SafeImage
36
46
  format: File.extname(output).delete_prefix(".").downcase,
37
47
  quality: quality
38
48
  )
39
- Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: quality) if optimize
49
+ optimize_output(output, quality) if optimize
40
50
  result_from_info(probe.input, output, info, "imagemagick")
41
51
  end
42
52
 
43
- def crop(from, to, width, height, quality: nil, backend: :imagemagick, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)
44
- probe = compat_probe(from, backend: backend, max_pixels: max_pixels)
53
+ def crop(from, to, width, height, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
54
+ max_pixels = SafeImage.resolved_max_pixels(max_pixels)
55
+ case SafeImage.config.backend
56
+ when :vips
57
+ vips_crop(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
58
+ when :imagemagick
59
+ imagemagick_crop(from, to, width, height, quality: quality, optimize: optimize, max_pixels: max_pixels)
60
+ end
61
+ end
62
+
63
+ def vips_crop(from, to, width, height, quality:, optimize:, max_pixels:, chroma_subsampling:)
64
+ probe = compat_probe(from, max_pixels: max_pixels)
45
65
  output = PathSafety.ensure_safe_output_path!(to).to_s
46
66
  format = File.extname(output).delete_prefix(".").downcase
47
67
 
48
68
  info =
49
- if backend.to_sym == :vips && use_jpegli_for_generated_jpeg?(format, backend, encoder)
69
+ if use_jpegli_for_generated_jpeg?(format)
50
70
  with_temp_png(output) do |tmp_path|
51
71
  VipsBackend.crop_north(
52
72
  input: probe.input,
@@ -65,7 +85,7 @@ module SafeImage
65
85
  input_format: probe.input_format
66
86
  )
67
87
  end
68
- elsif backend.to_sym == :vips
88
+ else
69
89
  VipsBackend.crop_north(
70
90
  input: probe.input,
71
91
  output: output,
@@ -75,27 +95,43 @@ module SafeImage
75
95
  quality: quality || 85,
76
96
  max_pixels: max_pixels
77
97
  )
78
- else
79
- ImageMagickBackend.resize_like(
80
- input: probe.input,
81
- output: output,
82
- width: width,
83
- height: height,
84
- format: format,
85
- quality: quality,
86
- crop: :north
87
- )
88
98
  end
89
- Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: quality) if optimize
90
- result_from_info(probe.input, output, info, compat_backend_name(backend, info))
99
+ optimize_output(output, quality) if optimize
100
+ result_from_info(probe.input, output, info, compat_backend_name(:vips, info))
101
+ end
102
+
103
+ def imagemagick_crop(from, to, width, height, quality:, optimize:, max_pixels:)
104
+ probe = compat_probe(from, max_pixels: max_pixels)
105
+ output = PathSafety.ensure_safe_output_path!(to).to_s
106
+ info = ImageMagickBackend.resize_like(
107
+ input: probe.input,
108
+ output: output,
109
+ width: width,
110
+ height: height,
111
+ format: File.extname(output).delete_prefix(".").downcase,
112
+ quality: quality,
113
+ crop: :north
114
+ )
115
+ optimize_output(output, quality) if optimize
116
+ result_from_info(probe.input, output, info, "imagemagick")
91
117
  end
92
118
 
93
- def downsize(from, to, dimensions, backend: :imagemagick, optimize: true, max_pixels: nil, quality: 85, encoder: :auto, chroma_subsampling: :auto)
94
- probe = compat_probe(from, backend: backend, max_pixels: max_pixels)
119
+ def downsize(from, to, dimensions, optimize: true, max_pixels: nil, quality: 85, chroma_subsampling: :auto)
120
+ max_pixels = SafeImage.resolved_max_pixels(max_pixels)
121
+ case SafeImage.config.backend
122
+ when :vips
123
+ vips_downsize(from, to, dimensions, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
124
+ when :imagemagick
125
+ imagemagick_downsize(from, to, dimensions, optimize: optimize, max_pixels: max_pixels)
126
+ end
127
+ end
128
+
129
+ def vips_downsize(from, to, dimensions, quality:, optimize:, max_pixels:, chroma_subsampling:)
130
+ probe = compat_probe(from, max_pixels: max_pixels)
95
131
  output = PathSafety.ensure_safe_output_path!(to).to_s
96
132
  format = File.extname(output).delete_prefix(".").downcase
97
133
  info =
98
- if backend.to_sym == :vips && use_jpegli_for_generated_jpeg?(format, backend, encoder)
134
+ if use_jpegli_for_generated_jpeg?(format)
99
135
  with_temp_png(output) do |tmp_path|
100
136
  VipsBackend.downsize(
101
137
  input: probe.input,
@@ -113,7 +149,7 @@ module SafeImage
113
149
  input_format: probe.input_format
114
150
  )
115
151
  end
116
- elsif backend.to_sym == :vips
152
+ else
117
153
  VipsBackend.downsize(
118
154
  input: probe.input,
119
155
  output: output,
@@ -122,59 +158,89 @@ module SafeImage
122
158
  quality: quality,
123
159
  max_pixels: max_pixels
124
160
  )
125
- else
126
- ImageMagickBackend.downsize(
127
- input: probe.input,
128
- output: output,
129
- dimensions: dimensions,
130
- format: format
131
- )
132
161
  end
133
- Optimizer.optimize(output, mode: :lossless, strip_metadata: true) if optimize
134
- result_from_info(probe.input, output, info, compat_backend_name(backend, info))
162
+ optimize_output(output, nil) if optimize
163
+ result_from_info(probe.input, output, info, compat_backend_name(:vips, info))
164
+ end
165
+
166
+ def imagemagick_downsize(from, to, dimensions, optimize:, max_pixels:)
167
+ probe = compat_probe(from, max_pixels: max_pixels)
168
+ output = PathSafety.ensure_safe_output_path!(to).to_s
169
+ info = ImageMagickBackend.downsize(
170
+ input: probe.input,
171
+ output: output,
172
+ dimensions: dimensions,
173
+ format: File.extname(output).delete_prefix(".").downcase
174
+ )
175
+ optimize_output(output, nil) if optimize
176
+ result_from_info(probe.input, output, info, "imagemagick")
177
+ end
178
+
179
+ # Post-processing applies only to the formats the optimizer tools
180
+ # understand; other outputs (gif, jxl, ...) skip the pass.
181
+ def optimize_output(output, quality)
182
+ format = File.extname(output).delete_prefix(".").downcase
183
+ format = "jpg" if format == "jpeg"
184
+ return unless Processor::OPTIMIZABLE_OUTPUTS.include?(format)
185
+ Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: quality)
135
186
  end
136
187
 
137
- def convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)
138
- probe = compat_probe(from, backend: :imagemagick, max_pixels: max_pixels)
188
+ # JPEG default when the caller passes no quality: matches what ImageMagick
189
+ # uses for sources without quality tables, rather than libvips' Q75.
190
+ NATIVE_CONVERT_DEFAULT_QUALITY = 92
191
+
192
+ def convert(from, to, format:, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
193
+ max_pixels = SafeImage.resolved_max_pixels(max_pixels)
139
194
  output = PathSafety.ensure_safe_output_path!(to).to_s
195
+
196
+ case SafeImage.config.backend
197
+ when :vips
198
+ native_convert(from, output, format: format, quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
199
+ when :imagemagick
200
+ imagemagick_convert(from, output, format: format, quality: quality, optimize: optimize, max_pixels: max_pixels)
201
+ end
202
+ end
203
+
204
+ def imagemagick_convert(from, output, format:, quality:, optimize:, max_pixels:)
205
+ probe = compat_probe(from, max_pixels: max_pixels)
140
206
  normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
207
+ info = ImageMagickBackend.convert(input: probe.input, output: output, format: format, quality: quality)
208
+ optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
209
+ result_from_info(probe.input, output, info, "imagemagick")
210
+ end
141
211
 
142
- info =
143
- if use_jpegli_for_convert?(probe.input, normalized_format, encoder)
144
- JpegliBackend.convert(
145
- input: probe.input,
146
- output: output,
147
- quality: quality || JpegliBackend::DEFAULT_QUALITY,
148
- chroma_subsampling: chroma_subsampling
149
- )
150
- else
151
- if encoder.to_sym == :cjpegli
152
- raise UnsupportedFormatError, "cjpegli cannot directly encode #{File.extname(probe.input).delete_prefix(".").downcase.inspect}; use encoder: :auto or another encoder"
153
- end
154
- ImageMagickBackend.convert(input: probe.input, output: output, format: format, quality: quality)
155
- end
212
+ def native_convert(from, output, format:, quality:, optimize:, max_pixels:, chroma_subsampling:)
213
+ input = PathSafety.ensure_regular_file!(from).to_s
214
+ normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
215
+
216
+ if use_jpegli_for_convert?(input, normalized_format)
217
+ info = JpegliBackend.convert(
218
+ input: input,
219
+ output: output,
220
+ quality: quality || JpegliBackend::DEFAULT_QUALITY,
221
+ chroma_subsampling: chroma_subsampling
222
+ )
223
+ return result_from_info(input, output, info, "cjpegli")
224
+ end
156
225
 
157
- Optimizer.optimize(output, mode: :lossless, strip_metadata: true, quality: normalized_format == "jpg" ? quality : nil) if optimize && info[:encoder] != "cjpegli"
158
- result_from_info(probe.input, output, info, info[:encoder] == "cjpegli" ? "cjpegli" : "imagemagick")
226
+ info = write_through_tempfile(output) do |tmp_path|
227
+ Native.convert(input, tmp_path, normalized_format, quality || NATIVE_CONVERT_DEFAULT_QUALITY, max_pixels)
228
+ end
229
+ optimize_output(output, normalized_format == "jpg" ? quality : nil) if optimize
230
+ result_from_info(input, output, info, "libvips-direct")
159
231
  end
160
232
 
161
- def use_jpegli_for_convert?(input, normalized_format, encoder)
162
- encoder = encoder.to_sym
163
- return false unless normalized_format == "jpg"
164
- return false if encoder == :imagemagick
165
- raise ArgumentError, "unknown encoder: #{encoder.inspect}" unless %i[auto cjpegli].include?(encoder)
166
- return true if encoder == :cjpegli && JpegliBackend.suitable_direct_input?(input)
167
- encoder == :auto && JpegliBackend.available? && JpegliBackend.suitable_direct_input?(input)
233
+ def use_jpegli_for_convert?(input, normalized_format)
234
+ normalized_format == "jpg" && JpegliBackend.available? && JpegliBackend.suitable_direct_input?(input)
168
235
  end
169
236
 
170
- def use_jpegli_for_generated_jpeg?(format, backend, encoder)
171
- encoder = encoder.to_sym
237
+ # cjpegli is an output-quality tool, not a configuration choice: installed
238
+ # means used for JPEG output on the native path. It encodes only pixels
239
+ # this gem already decoded, so it is not part of the untrusted-input
240
+ # surface the backend choice controls.
241
+ def use_jpegli_for_generated_jpeg?(format)
172
242
  normalized_format = format.to_s.downcase == "jpeg" ? "jpg" : format.to_s.downcase
173
- return false unless normalized_format == "jpg"
174
- return false if %i[vips imagemagick magick].include?(encoder)
175
- raise ArgumentError, "unknown encoder: #{encoder.inspect}" unless %i[auto cjpegli].include?(encoder)
176
- raise ArgumentError, "encoder: :cjpegli currently requires backend: :vips" if encoder == :cjpegli && backend.to_sym != :vips
177
- encoder == :cjpegli || (backend.to_sym == :vips && JpegliBackend.available?)
243
+ normalized_format == "jpg" && JpegliBackend.available?
178
244
  end
179
245
 
180
246
  def with_temp_png(output)
@@ -194,44 +260,147 @@ module SafeImage
194
260
  info[:encoder] == "cjpegli" ? "#{base}+cjpegli" : base
195
261
  end
196
262
 
197
- def convert_to_jpeg(from, to, quality: nil, optimize: true, max_pixels: nil, encoder: :auto, chroma_subsampling: :auto)
198
- convert(from, to, format: "jpg", quality: quality, optimize: optimize, max_pixels: max_pixels, encoder: encoder, chroma_subsampling: chroma_subsampling)
263
+ def convert_to_jpeg(from, to, quality: nil, optimize: true, max_pixels: nil, chroma_subsampling: :auto)
264
+ convert(from, to, format: "jpg", quality: quality, optimize: optimize, max_pixels: max_pixels, chroma_subsampling: chroma_subsampling)
199
265
  end
200
266
 
201
- def fix_orientation(from, to = from, max_pixels: nil)
202
- probe = compat_probe(from, backend: :imagemagick, max_pixels: max_pixels)
267
+ # EXIF orientation values mapped onto jpegtran's lossless transforms.
268
+ JPEGTRAN_OPERATIONS = {
269
+ 2 => ["-flip", "horizontal"],
270
+ 3 => ["-rotate", "180"],
271
+ 4 => ["-flip", "vertical"],
272
+ 5 => ["-transpose"],
273
+ 6 => ["-rotate", "90"],
274
+ 7 => ["-transverse"],
275
+ 8 => ["-rotate", "270"]
276
+ }.freeze
277
+
278
+ def fix_orientation(from, to = from, max_pixels: nil, quality: nil)
279
+ max_pixels = SafeImage.resolved_max_pixels(max_pixels)
203
280
  output = PathSafety.ensure_safe_output_path!(to).to_s
281
+
282
+ case SafeImage.config.backend
283
+ when :vips
284
+ native_fix_orientation(from, output, max_pixels: max_pixels, quality: quality)
285
+ when :imagemagick
286
+ imagemagick_fix_orientation(from, output, max_pixels: max_pixels)
287
+ end
288
+ end
289
+
290
+ def imagemagick_fix_orientation(from, output, max_pixels:)
291
+ probe = compat_probe(from, max_pixels: max_pixels)
204
292
  info = ImageMagickBackend.fix_orientation(input: probe.input, output: output)
205
293
  result_from_info(probe.input, output, info, "imagemagick")
206
294
  end
207
295
 
296
+ def native_fix_orientation(from, output, max_pixels:, quality:)
297
+ input = PathSafety.ensure_regular_file!(from).to_s
298
+ format = File.extname(input).delete_prefix(".").downcase
299
+ format = "jpg" if format == "jpeg"
300
+ # Validates the format against the native loader allowlist and enforces
301
+ # the pixel cap before any pixel decode.
302
+ orient = VipsBackend.orientation(input, max_pixels: max_pixels)
303
+
304
+ # Lossless tier: jpegtran transforms JPEG DCT coefficients directly, so
305
+ # there is no generation loss. -perfect refuses when the dimensions are
306
+ # not MCU-aligned; fall through to the re-encode tier.
307
+ if format == "jpg" && orient > 1 && Runner.available?("jpegtran")
308
+ begin
309
+ return jpegtran_fix_orientation(input, output, orient)
310
+ rescue CommandError
311
+ nil
312
+ end
313
+ end
314
+
315
+ quality = quality.nil? ? 95 : Integer(quality)
316
+ raise ArgumentError, "quality must be 1..100" unless (1..100).cover?(quality)
317
+ info = write_through_tempfile(output) do |tmp_path|
318
+ Native.resize(input, tmp_path, 1.0, format, quality, max_pixels)
319
+ end
320
+ result_from_info(input, output, info, "libvips-direct")
321
+ end
322
+
323
+ def jpegtran_fix_orientation(input, output, orient)
324
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
325
+ info = write_through_tempfile(output) do |tmp_path|
326
+ Runner.run!(["jpegtran", "-copy", "none", "-perfect", *JPEGTRAN_OPERATIONS.fetch(orient), "-outfile", tmp_path, input])
327
+ Native.probe(tmp_path)
328
+ end
329
+ result_from_info(
330
+ input,
331
+ output,
332
+ {
333
+ input_format: "jpg",
334
+ output_format: "jpg",
335
+ width: info.fetch(:width),
336
+ height: info.fetch(:height),
337
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
338
+ },
339
+ "jpegtran"
340
+ )
341
+ end
342
+
343
+ # Writes via a sibling tempfile and renames into place, so in-place calls
344
+ # (to == from) never feed an output path that libvips is still reading
345
+ # from as input.
346
+ def write_through_tempfile(output)
347
+ tmp_path = File.join(File.dirname(output), ".safe-image-#{Process.pid}-#{output.object_id}#{File.extname(output)}")
348
+ PathSafety.ensure_safe_output_path!(tmp_path)
349
+ result = yield tmp_path
350
+ FileUtils.mv(tmp_path, output)
351
+ result
352
+ ensure
353
+ FileUtils.rm_f(tmp_path)
354
+ end
355
+
208
356
  def convert_favicon_to_png(from, to, optimize: true, max_pixels: nil)
209
- frame_count(from, max_pixels: max_pixels) if max_pixels
357
+ max_pixels = SafeImage.resolved_max_pixels(max_pixels)
210
358
  output = PathSafety.ensure_safe_output_path!(to).to_s
211
- info = ImageMagickBackend.convert_ico_to_png(input: Pathname.new(from).expand_path.to_s, output: output)
359
+
360
+ case SafeImage.config.backend
361
+ when :vips
362
+ # Pure-Ruby ICO parse; libvips only encodes the extracted pixels.
363
+ info = Ico.convert_to_png(from, output, max_pixels: max_pixels)
364
+ backend_name = "ico-ruby+libvips"
365
+ when :imagemagick
366
+ info = ImageMagickBackend.convert_ico_to_png(input: Pathname.new(from).expand_path.to_s, output: output)
367
+ backend_name = "imagemagick"
368
+ end
212
369
  Optimizer.optimize(output, mode: :lossless, strip_metadata: true) if optimize
213
- result_from_info(from, output, info, "imagemagick")
370
+ result_from_info(from, output, info, backend_name)
214
371
  end
215
372
 
216
373
  def frame_count(path, max_pixels: nil)
217
- ImageMagickBackend.frame_count(path, max_pixels: max_pixels)
374
+ max_pixels = SafeImage.resolved_max_pixels(max_pixels)
375
+ # ico directories are counted by the pure-Ruby parser on either backend;
376
+ # everything else is a header-only count.
377
+ return Ico.frame_count(path, max_pixels: max_pixels) if File.extname(PathSafety.local_path(path)).downcase == ".ico"
378
+
379
+ case SafeImage.config.backend
380
+ when :vips
381
+ VipsBackend.frame_count(path, max_pixels: max_pixels)
382
+ when :imagemagick
383
+ ImageMagickBackend.frame_count(path, max_pixels: max_pixels)
384
+ end
218
385
  end
219
386
 
220
387
  def animated?(path, max_pixels: nil)
221
388
  frame_count(path, max_pixels: max_pixels).to_i > 1
222
389
  end
223
390
 
224
- def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "NimbusSans-Regular")
391
+ def letter_avatar(output:, size:, background_rgb:, letter:, pointsize: 280, font: "DejaVu-Sans")
225
392
  output = PathSafety.ensure_safe_output_path!(output).to_s
226
- info = ImageMagickBackend.letter_avatar(
227
- output: output,
228
- size: size,
229
- background_rgb: background_rgb,
230
- letter: letter,
231
- pointsize: pointsize,
232
- font: font
233
- )
234
- result_from_info("generated", output, info, "imagemagick")
393
+ request = { output: output, size: size, background_rgb: background_rgb, letter: letter, pointsize: pointsize, font: font }
394
+
395
+ info, backend_name =
396
+ case SafeImage.config.backend
397
+ when :vips
398
+ [VipsBackend.letter_avatar(**request), "libvips-direct"]
399
+ when :imagemagick
400
+ [ImageMagickBackend.letter_avatar(**request), "imagemagick"]
401
+ end
402
+
403
+ result_from_info("generated", output, info, backend_name)
235
404
  end
236
405
 
237
406
  def optimize_image!(path, allow_lossy_png: false, strip_metadata: true, quality: nil, strict: true)
@@ -244,9 +413,9 @@ module SafeImage
244
413
  )
245
414
  end
246
415
 
247
- def compat_probe(path, backend:, max_pixels: nil)
416
+ def compat_probe(path, max_pixels: nil)
248
417
  path = Pathname.new(path).expand_path.to_s
249
- if backend.to_sym == :vips
418
+ if SafeImage.config.backend == :vips
250
419
  SafeImage.probe(path, max_pixels: max_pixels)
251
420
  else
252
421
  info = ImageMagickBackend.probe(path, max_pixels: max_pixels)