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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/README.md +475 -281
- data/SECURITY.md +27 -7
- data/lib/safe_image/discourse_compat.rb +267 -98
- data/lib/safe_image/fonts/DEJAVU-LICENSE +187 -0
- data/lib/safe_image/fonts/DejaVuSans.ttf +0 -0
- data/lib/safe_image/ico.rb +286 -0
- data/lib/safe_image/image_magick_backend.rb +39 -3
- data/lib/safe_image/imagemagick_policy/policy.xml +8 -1
- data/lib/safe_image/jpegli_backend.rb +3 -1
- data/lib/safe_image/native.rb +371 -1
- data/lib/safe_image/processor.rb +23 -69
- data/lib/safe_image/remote.rb +15 -3
- data/lib/safe_image/runner.rb +1 -1
- data/lib/safe_image/sandbox.rb +42 -16
- data/lib/safe_image/svg_metadata.rb +59 -29
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +57 -0
- data/lib/safe_image/vips_glue.rb +361 -0
- data/lib/safe_image.rb +143 -36
- metadata +30 -14
- data/ext/safe_image_native/extconf.rb +0 -8
- data/ext/safe_image_native/safe_image_native.c +0 -392
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
|
-
|
|
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
|
-
-
|
|
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,
|
|
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,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
result_from_info(probe.input, output, info, compat_backend_name(
|
|
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,
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
result_from_info(probe.input, output, info, compat_backend_name(
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
162
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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,
|
|
198
|
-
convert(from, to, format: "jpg", quality: quality, optimize: optimize, max_pixels: max_pixels,
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
357
|
+
max_pixels = SafeImage.resolved_max_pixels(max_pixels)
|
|
210
358
|
output = PathSafety.ensure_safe_output_path!(to).to_s
|
|
211
|
-
|
|
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,
|
|
370
|
+
result_from_info(from, output, info, backend_name)
|
|
214
371
|
end
|
|
215
372
|
|
|
216
373
|
def frame_count(path, max_pixels: nil)
|
|
217
|
-
|
|
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: "
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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,
|
|
416
|
+
def compat_probe(path, max_pixels: nil)
|
|
248
417
|
path = Pathname.new(path).expand_path.to_s
|
|
249
|
-
if backend
|
|
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)
|