safe_image 0.2.0 → 0.3.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 +193 -0
- data/README.md +166 -11
- data/lib/safe_image/discourse_compat.rb +2 -13
- data/lib/safe_image/ico.rb +1 -1
- data/lib/safe_image/native.rb +24 -15
- data/lib/safe_image/optimizer.rb +79 -4
- data/lib/safe_image/processor.rb +1 -1
- data/lib/safe_image/remote.rb +174 -8
- data/lib/safe_image/runner.rb +9 -1
- data/lib/safe_image/sandbox.rb +41 -14
- data/lib/safe_image/svg_css.rb +314 -0
- data/lib/safe_image/svg_metadata.rb +179 -53
- data/lib/safe_image/svg_sanitizer.rb +524 -43
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/zygote.rb +619 -0
- data/lib/safe_image.rb +12 -0
- metadata +18 -2
data/lib/safe_image/optimizer.rb
CHANGED
|
@@ -8,7 +8,20 @@ module SafeImage
|
|
|
8
8
|
|
|
9
9
|
MAX_PNGQUANT_SIZE = 500_000
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# EXIF orientation values mapped onto jpegtran's lossless transforms.
|
|
12
|
+
JPEGTRAN_OPERATIONS = {
|
|
13
|
+
2 => ["-flip", "horizontal"],
|
|
14
|
+
3 => ["-rotate", "180"],
|
|
15
|
+
4 => ["-flip", "vertical"],
|
|
16
|
+
5 => ["-transpose"],
|
|
17
|
+
6 => ["-rotate", "90"],
|
|
18
|
+
7 => ["-transverse"],
|
|
19
|
+
8 => ["-rotate", "270"]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# assume_upright: skips the JPEG orientation check; only for callers
|
|
23
|
+
# optimising output this gem just encoded (which is always upright).
|
|
24
|
+
def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, timeout: Runner::DEFAULT_TIMEOUT, strict: true, assume_upright: false)
|
|
12
25
|
path = PathSafety.ensure_regular_file!(path)
|
|
13
26
|
|
|
14
27
|
ext = path.extname.delete_prefix(".").downcase
|
|
@@ -16,9 +29,26 @@ module SafeImage
|
|
|
16
29
|
|
|
17
30
|
before = File.size(path)
|
|
18
31
|
tools = []
|
|
32
|
+
rotated_from = nil
|
|
33
|
+
trimmed = false
|
|
19
34
|
|
|
20
35
|
case ext
|
|
21
36
|
when "jpg"
|
|
37
|
+
# Stripping metadata deletes the EXIF orientation tag, so an oriented
|
|
38
|
+
# image must have the rotation baked into its pixels first or it ships
|
|
39
|
+
# sideways. jpegtran does that losslessly; without it, leave the file
|
|
40
|
+
# untouched rather than strip-without-rotate.
|
|
41
|
+
orientation = strip_metadata && !assume_upright ? jpeg_orientation(path) : 1
|
|
42
|
+
if orientation > 1
|
|
43
|
+
unless Runner.available?("jpegtran")
|
|
44
|
+
raise Error, "jpegtran is required to optimize a JPEG with EXIF orientation" if strict
|
|
45
|
+
return { format: ext, before_bytes: before, after_bytes: before, saved_bytes: 0, tools: tools, rotated_from: nil, trimmed: false }
|
|
46
|
+
end
|
|
47
|
+
trimmed = upright!(path, orientation, timeout: timeout)
|
|
48
|
+
rotated_from = orientation
|
|
49
|
+
tools << "jpegtran"
|
|
50
|
+
end
|
|
51
|
+
|
|
22
52
|
if Runner.available?("jpegoptim")
|
|
23
53
|
argv = ["jpegoptim", "--quiet"]
|
|
24
54
|
argv << (strip_metadata ? "--strip-all" : "--strip-none")
|
|
@@ -39,8 +69,18 @@ module SafeImage
|
|
|
39
69
|
argv = ["pngquant", "--force", "--skip-if-larger", "--output", tmp_path.to_s]
|
|
40
70
|
argv << "--quality=#{quality}" if quality # e.g. "65-90"
|
|
41
71
|
argv << path.to_s
|
|
42
|
-
|
|
43
|
-
|
|
72
|
+
skipped = false
|
|
73
|
+
begin
|
|
74
|
+
Runner.run!(argv, timeout: timeout)
|
|
75
|
+
rescue CommandError => e
|
|
76
|
+
# 98: --skip-if-larger declined the result; 99: --quality not
|
|
77
|
+
# met. Both mean "keep the original", not a failure — and the
|
|
78
|
+
# pre-created tempfile is still empty, so it must not win the
|
|
79
|
+
# size comparison below.
|
|
80
|
+
raise unless [98, 99].include?(e.status)
|
|
81
|
+
skipped = true
|
|
82
|
+
end
|
|
83
|
+
if !skipped && tmp_path.file? && File.size(tmp_path).positive? && File.size(tmp_path) < File.size(path)
|
|
44
84
|
FileUtils.mv(tmp_path, path)
|
|
45
85
|
tools << "pngquant"
|
|
46
86
|
end
|
|
@@ -71,8 +111,43 @@ module SafeImage
|
|
|
71
111
|
before_bytes: before,
|
|
72
112
|
after_bytes: after,
|
|
73
113
|
saved_bytes: before - after,
|
|
74
|
-
tools: tools
|
|
114
|
+
tools: tools,
|
|
115
|
+
rotated_from: rotated_from,
|
|
116
|
+
trimmed: trimmed
|
|
75
117
|
}
|
|
76
118
|
end
|
|
119
|
+
|
|
120
|
+
def jpeg_orientation(path)
|
|
121
|
+
case SafeImage.config.backend
|
|
122
|
+
when :vips then VipsBackend.orientation(path.to_s)
|
|
123
|
+
when :imagemagick then ImageMagickBackend.orientation(path.to_s)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Applies the orientation's lossless jpegtran transform in place, dropping
|
|
128
|
+
# the metadata in the same pass (-copy none; this path only runs when
|
|
129
|
+
# strip_metadata is set). -perfect refuses dimensions that are not
|
|
130
|
+
# MCU-aligned; the -trim retry drops the partial edge blocks (under one
|
|
131
|
+
# MCU, at most 15px) instead of hiding a lossy re-encode here. Returns
|
|
132
|
+
# true when the fallback trimmed.
|
|
133
|
+
def upright!(path, orientation, timeout:)
|
|
134
|
+
transform = JPEGTRAN_OPERATIONS.fetch(orientation)
|
|
135
|
+
tmp = Tempfile.new([path.basename(".*").to_s, ".jpegtran.jpg"], path.dirname.to_s)
|
|
136
|
+
tmp_path = Pathname.new(tmp.path)
|
|
137
|
+
tmp.close
|
|
138
|
+
begin
|
|
139
|
+
trimmed = false
|
|
140
|
+
begin
|
|
141
|
+
Runner.run!(["jpegtran", "-copy", "none", "-perfect", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
|
|
142
|
+
rescue CommandError
|
|
143
|
+
Runner.run!(["jpegtran", "-copy", "none", "-trim", *transform, "-outfile", tmp_path.to_s, path.to_s], timeout: timeout)
|
|
144
|
+
trimmed = true
|
|
145
|
+
end
|
|
146
|
+
FileUtils.mv(tmp_path, path)
|
|
147
|
+
trimmed
|
|
148
|
+
ensure
|
|
149
|
+
FileUtils.rm_f(tmp_path)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
77
152
|
end
|
|
78
153
|
end
|
data/lib/safe_image/processor.rb
CHANGED
|
@@ -75,7 +75,7 @@ module SafeImage
|
|
|
75
75
|
|
|
76
76
|
opt_info = nil
|
|
77
77
|
if optimize && OPTIMIZABLE_OUTPUTS.include?(out_format)
|
|
78
|
-
opt_info = Optimizer.optimize(output, mode: optimize_mode, strip_metadata: true, quality: out_format == "jpg" ? quality : nil)
|
|
78
|
+
opt_info = Optimizer.optimize(output, mode: optimize_mode, strip_metadata: true, quality: out_format == "jpg" ? quality : nil, assume_upright: true)
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
Result.new(
|
data/lib/safe_image/remote.rb
CHANGED
|
@@ -48,6 +48,45 @@ module SafeImage
|
|
|
48
48
|
|
|
49
49
|
EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .jxl .svg].freeze
|
|
50
50
|
|
|
51
|
+
# First-bytes signatures per downloaded extension, checked as soon as the
|
|
52
|
+
# first SIGNATURE_HEAD_BYTES of the body arrive so an obviously mislabeled
|
|
53
|
+
# response is dropped without downloading the rest. Each entry lists
|
|
54
|
+
# alternative candidates; a candidate is a list of [offset, bytes] pairs
|
|
55
|
+
# that must all match. The check rejects only on a definite mismatch of
|
|
56
|
+
# every candidate against fully-available bytes, so it can never reject an
|
|
57
|
+
# image the configured backend could decode — decoders sniff these same
|
|
58
|
+
# magic bytes to pick a loader. SVG has no usable signature and is exempt.
|
|
59
|
+
SIGNATURES = {
|
|
60
|
+
".jpg" => [[[0, "\xFF\xD8\xFF".b]]],
|
|
61
|
+
".jpeg" => [[[0, "\xFF\xD8\xFF".b]]],
|
|
62
|
+
".png" => [[[0, "\x89PNG\r\n\x1A\n".b]]],
|
|
63
|
+
".gif" => [[[0, "GIF8".b]]],
|
|
64
|
+
".webp" => [[[0, "RIFF".b], [8, "WEBP".b]]],
|
|
65
|
+
".ico" => [[[0, "\x00\x00\x01\x00".b]]],
|
|
66
|
+
".heic" => [[[4, "ftyp".b]]],
|
|
67
|
+
".heif" => [[[4, "ftyp".b]]],
|
|
68
|
+
".avif" => [[[4, "ftyp".b]]],
|
|
69
|
+
".jxl" => [[[0, "\xFF\x0A".b]], [[0, "\x00\x00\x00\x0CJXL \r\n\x87\n".b]]]
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
72
|
+
SIGNATURE_HEAD_BYTES = 12
|
|
73
|
+
|
|
74
|
+
# The metadata helpers probe the partially-downloaded file at these
|
|
75
|
+
# growing byte thresholds and abort the transfer once the answer is
|
|
76
|
+
# stable, instead of always downloading up to max_bytes.
|
|
77
|
+
PREFIX_PROBE_INITIAL_BYTES = 64 * 1024
|
|
78
|
+
PREFIX_PROBE_GROWTH_FACTOR = 4
|
|
79
|
+
|
|
80
|
+
# SVG is excluded from prefix probing: SvgMetadata enforces a total-size
|
|
81
|
+
# cap (MAX_SVG_BYTES) that probing a prefix would bypass, and remote SVGs
|
|
82
|
+
# are small enough that downloading them fully costs little.
|
|
83
|
+
PREFIX_PROBE_EXTENSIONS = (EXTENSIONS - [".svg"]).freeze
|
|
84
|
+
|
|
85
|
+
# Sentinel a metadata_fetch block returns when the prefix parsed but its
|
|
86
|
+
# answer could still change with more data (e.g. "not animated", which a
|
|
87
|
+
# truncated file can report for an animated one).
|
|
88
|
+
CONTINUE_DOWNLOAD = Object.new.freeze
|
|
89
|
+
|
|
51
90
|
BLOCKED_IP_RANGES = [
|
|
52
91
|
# IPv4 special-use / non-public ranges. Default remote fetching is for
|
|
53
92
|
# public Internet images only; callers probing trusted internal URLs must
|
|
@@ -117,7 +156,7 @@ module SafeImage
|
|
|
117
156
|
)
|
|
118
157
|
file.flush
|
|
119
158
|
|
|
120
|
-
ext =
|
|
159
|
+
ext = response.fetch(:ext)
|
|
121
160
|
path = file.path
|
|
122
161
|
if File.extname(path) != ext
|
|
123
162
|
renamed = path.sub(/\.bin\z/, ext)
|
|
@@ -136,8 +175,18 @@ module SafeImage
|
|
|
136
175
|
end
|
|
137
176
|
|
|
138
177
|
def info(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil, animated: false, orientation: false)
|
|
139
|
-
|
|
140
|
-
SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
|
|
178
|
+
metadata_fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path, eof|
|
|
179
|
+
result = SafeImage.info(path, max_pixels: max_pixels, animated: animated, orientation: orientation)
|
|
180
|
+
# A truncated file can undercount frames but never overcount, so
|
|
181
|
+
# "animated" is final as soon as it is true; "not animated" is only
|
|
182
|
+
# provable from the complete file. Type, dimensions and orientation
|
|
183
|
+
# come from the header the successful probe just parsed, so they
|
|
184
|
+
# cannot change with more data.
|
|
185
|
+
if animated && result.animated != true && !eof
|
|
186
|
+
CONTINUE_DOWNLOAD
|
|
187
|
+
else
|
|
188
|
+
result
|
|
189
|
+
end
|
|
141
190
|
end
|
|
142
191
|
end
|
|
143
192
|
|
|
@@ -150,8 +199,11 @@ module SafeImage
|
|
|
150
199
|
end
|
|
151
200
|
|
|
152
201
|
def animated?(url, max_bytes: DEFAULT_MAX_BYTES, max_redirects: DEFAULT_MAX_REDIRECTS, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, total_timeout: DEFAULT_TOTAL_TIMEOUT, allow_private: false, allowed_ports: DEFAULT_ALLOWED_PORTS, headers: {}, max_pixels: nil)
|
|
153
|
-
|
|
154
|
-
SafeImage.animated?(path, max_pixels: max_pixels)
|
|
202
|
+
metadata_fetch(url, max_bytes: max_bytes, max_redirects: max_redirects, open_timeout: open_timeout, read_timeout: read_timeout, total_timeout: total_timeout, allow_private: allow_private, allowed_ports: allowed_ports, headers: headers) do |path, eof|
|
|
203
|
+
answer = SafeImage.animated?(path, max_pixels: max_pixels)
|
|
204
|
+
next CONTINUE_DOWNLOAD if !eof && answer != true
|
|
205
|
+
|
|
206
|
+
answer
|
|
155
207
|
end
|
|
156
208
|
end
|
|
157
209
|
|
|
@@ -161,7 +213,82 @@ module SafeImage
|
|
|
161
213
|
end
|
|
162
214
|
end
|
|
163
215
|
|
|
164
|
-
|
|
216
|
+
# Single-GET download that re-attempts the local metadata probe as bytes
|
|
217
|
+
# arrive (at PREFIX_PROBE_INITIAL_BYTES, then growing by
|
|
218
|
+
# PREFIX_PROBE_GROWTH_FACTOR) and aborts the transfer as soon as the
|
|
219
|
+
# block's answer is final. The block receives (path, eof) and must return
|
|
220
|
+
# CONTINUE_DOWNLOAD while its answer could still change with more data.
|
|
221
|
+
#
|
|
222
|
+
# Safety contract: any error from a pre-EOF probe means "not enough bytes
|
|
223
|
+
# yet" — it is swallowed and the download continues, so the complete file
|
|
224
|
+
# always gets the last word with exactly the validation and error
|
|
225
|
+
# behaviour of the full-download path (validate_downloaded_image! plus an
|
|
226
|
+
# un-rescued final probe). A file that never early-exits is handled
|
|
227
|
+
# byte-for-byte like Remote.fetch handles it.
|
|
228
|
+
def metadata_fetch(url, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, allow_private:, allowed_ports:, headers:, &compute)
|
|
229
|
+
uri = parse_uri(url)
|
|
230
|
+
started_at = monotonic_time
|
|
231
|
+
|
|
232
|
+
Tempfile.create(["safe-image-remote", ".bin"], binmode: true) do |file|
|
|
233
|
+
original_path = file.path
|
|
234
|
+
path = original_path
|
|
235
|
+
ext = nil
|
|
236
|
+
next_probe_at = PREFIX_PROBE_INITIAL_BYTES
|
|
237
|
+
|
|
238
|
+
begin
|
|
239
|
+
early = catch(:metadata_answer) do
|
|
240
|
+
request(
|
|
241
|
+
uri,
|
|
242
|
+
io: file,
|
|
243
|
+
max_bytes: max_bytes,
|
|
244
|
+
max_redirects: max_redirects,
|
|
245
|
+
open_timeout: open_timeout,
|
|
246
|
+
read_timeout: read_timeout,
|
|
247
|
+
total_timeout: total_timeout,
|
|
248
|
+
started_at: started_at,
|
|
249
|
+
allow_private: allow_private,
|
|
250
|
+
allowed_ports: allowed_ports,
|
|
251
|
+
headers: headers,
|
|
252
|
+
# The extension is known before the body: give the tempfile its
|
|
253
|
+
# final name up front so probes dispatch on the right loader.
|
|
254
|
+
on_headers: ->(response_ext) do
|
|
255
|
+
ext = response_ext
|
|
256
|
+
renamed = original_path.sub(/\.bin\z/, ext)
|
|
257
|
+
FileUtils.mv(original_path, renamed)
|
|
258
|
+
path = renamed
|
|
259
|
+
end,
|
|
260
|
+
on_progress: ->(bytes) do
|
|
261
|
+
next unless PREFIX_PROBE_EXTENSIONS.include?(ext)
|
|
262
|
+
next if bytes < next_probe_at
|
|
263
|
+
|
|
264
|
+
next_probe_at *= PREFIX_PROBE_GROWTH_FACTOR while bytes >= next_probe_at
|
|
265
|
+
file.flush
|
|
266
|
+
answer =
|
|
267
|
+
begin
|
|
268
|
+
compute.call(path, false)
|
|
269
|
+
rescue StandardError
|
|
270
|
+
CONTINUE_DOWNLOAD
|
|
271
|
+
end
|
|
272
|
+
throw :metadata_answer, [answer] unless CONTINUE_DOWNLOAD.equal?(answer)
|
|
273
|
+
end
|
|
274
|
+
)
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if early
|
|
279
|
+
early.first
|
|
280
|
+
else
|
|
281
|
+
file.flush
|
|
282
|
+
validate_downloaded_image!(path, ext)
|
|
283
|
+
compute.call(path, true)
|
|
284
|
+
end
|
|
285
|
+
ensure
|
|
286
|
+
FileUtils.rm_f(path) unless path == original_path
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def request(uri, io:, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, started_at:, allow_private:, allowed_ports:, headers: {}, on_headers: nil, on_progress: nil)
|
|
165
292
|
require "net/http"
|
|
166
293
|
raise ArgumentError, "too many redirects" if max_redirects < 0
|
|
167
294
|
check_deadline!(started_at, total_timeout)
|
|
@@ -181,6 +308,7 @@ module SafeImage
|
|
|
181
308
|
|
|
182
309
|
bytes = 0
|
|
183
310
|
content_type = nil
|
|
311
|
+
ext = nil
|
|
184
312
|
|
|
185
313
|
http.request(request) do |response|
|
|
186
314
|
check_deadline!(started_at, total_timeout)
|
|
@@ -203,25 +331,63 @@ module SafeImage
|
|
|
203
331
|
started_at: started_at,
|
|
204
332
|
allow_private: allow_private,
|
|
205
333
|
allowed_ports: allowed_ports,
|
|
206
|
-
headers: redirect_headers(headers, from: uri, to: redirected)
|
|
334
|
+
headers: redirect_headers(headers, from: uri, to: redirected),
|
|
335
|
+
on_headers: on_headers,
|
|
336
|
+
on_progress: on_progress
|
|
207
337
|
)
|
|
208
338
|
when Net::HTTPSuccess
|
|
209
339
|
content_length = response["content-length"].to_i
|
|
210
340
|
raise LimitError, "remote image exceeds #{max_bytes} bytes" if content_length > max_bytes
|
|
211
341
|
|
|
212
342
|
content_type = response["content-type"].to_s.split(";", 2).first.to_s.downcase
|
|
343
|
+
# Everything the content-type and extension-agreement checks need is
|
|
344
|
+
# in the headers: reject unsupported or mismatched responses before
|
|
345
|
+
# reading a single body byte.
|
|
346
|
+
ext = extension_for(uri, content_type)
|
|
347
|
+
on_headers&.call(ext)
|
|
348
|
+
|
|
349
|
+
head = "".b
|
|
350
|
+
head_checked = false
|
|
213
351
|
response.read_body do |chunk|
|
|
214
352
|
check_deadline!(started_at, total_timeout)
|
|
215
353
|
bytes += chunk.bytesize
|
|
216
354
|
raise LimitError, "remote image exceeds #{max_bytes} bytes" if bytes > max_bytes
|
|
355
|
+
unless head_checked
|
|
356
|
+
head << chunk
|
|
357
|
+
if head.bytesize >= SIGNATURE_HEAD_BYTES
|
|
358
|
+
verify_signature!(ext, head)
|
|
359
|
+
head_checked = true
|
|
360
|
+
head = nil
|
|
361
|
+
end
|
|
362
|
+
end
|
|
217
363
|
io.write(chunk)
|
|
364
|
+
on_progress&.call(bytes)
|
|
218
365
|
end
|
|
366
|
+
verify_signature!(ext, head) if !head_checked && !head.empty?
|
|
219
367
|
else
|
|
220
368
|
raise Error, "remote image request failed: HTTP #{response.code}"
|
|
221
369
|
end
|
|
222
370
|
end
|
|
223
371
|
|
|
224
|
-
{ uri: uri, content_type: content_type, bytes: bytes }
|
|
372
|
+
{ uri: uri, content_type: content_type, ext: ext, bytes: bytes }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Rejects a body whose first bytes definitively cannot belong to the
|
|
376
|
+
# format the response claimed. Formats without a fixed signature (SVG)
|
|
377
|
+
# and bytes not yet downloaded are never grounds for rejection.
|
|
378
|
+
def verify_signature!(ext, head)
|
|
379
|
+
candidates = SIGNATURES[ext]
|
|
380
|
+
return unless candidates
|
|
381
|
+
|
|
382
|
+
compatible = candidates.any? do |candidate|
|
|
383
|
+
candidate.all? do |offset, bytes|
|
|
384
|
+
slice = head.byteslice(offset, bytes.bytesize).to_s
|
|
385
|
+
slice.empty? || slice == bytes.byteslice(0, slice.bytesize)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
return if compatible
|
|
389
|
+
|
|
390
|
+
raise InvalidImageError, "remote image first bytes do not match #{ext.delete_prefix(".")} signature"
|
|
225
391
|
end
|
|
226
392
|
|
|
227
393
|
def parse_uri(url)
|
data/lib/safe_image/runner.rb
CHANGED
|
@@ -28,7 +28,15 @@ module SafeImage
|
|
|
28
28
|
IMAGEMAGICK_POLICY_FILE = File.join(IMAGEMAGICK_POLICY_PATH, "policy.xml").freeze
|
|
29
29
|
BASE_ENV = {
|
|
30
30
|
"PATH" => TRUSTED_PATH,
|
|
31
|
-
"VIPS_BLOCK_UNTRUSTED" => "1"
|
|
31
|
+
"VIPS_BLOCK_UNTRUSTED" => "1",
|
|
32
|
+
# Cap glibc's per-thread malloc arenas. Multithreaded tools (oxipng's
|
|
33
|
+
# rayon pool, ImageMagick's OpenMP) otherwise reserve an arena per thread
|
|
34
|
+
# — up to 8x64MB of *address space* per core — which, combined with the
|
|
35
|
+
# sandbox's RLIMIT_AS memory cap, spuriously fails the tool under
|
|
36
|
+
# concurrency even though real memory use is tiny. AS counts reservations,
|
|
37
|
+
# not RSS; bounding arenas is the standard mitigation and costs nothing
|
|
38
|
+
# for these compute-bound tools.
|
|
39
|
+
"MALLOC_ARENA_MAX" => "2"
|
|
32
40
|
}.freeze
|
|
33
41
|
|
|
34
42
|
def run!(argv, timeout: DEFAULT_TIMEOUT, env: {}, sandbox: false, read: [], write: [])
|
data/lib/safe_image/sandbox.rb
CHANGED
|
@@ -61,7 +61,13 @@ module SafeImage
|
|
|
61
61
|
def public_call!(operation, args:, kwargs:)
|
|
62
62
|
operation = operation.to_s
|
|
63
63
|
raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
|
|
64
|
-
|
|
64
|
+
request = { args: args, kwargs: kwargs }
|
|
65
|
+
result =
|
|
66
|
+
if Zygote.enabled?
|
|
67
|
+
Zygote.call!(operation, request)
|
|
68
|
+
else
|
|
69
|
+
run_worker!(operation, request)
|
|
70
|
+
end
|
|
65
71
|
operation == "type" && result ? result.to_sym : result
|
|
66
72
|
end
|
|
67
73
|
|
|
@@ -74,7 +80,10 @@ module SafeImage
|
|
|
74
80
|
payload = JSON.dump(
|
|
75
81
|
{
|
|
76
82
|
operation: operation,
|
|
77
|
-
|
|
83
|
+
# JSON has no symbol type; wrap symbol values so the worker can restore
|
|
84
|
+
# them (e.g. id_namespace: :standalone must not arrive as the string
|
|
85
|
+
# "standalone", which resolve_namespace would treat as a real namespace).
|
|
86
|
+
request: deep_encode_symbols(request),
|
|
78
87
|
# The worker is a fresh process and must be configured like the
|
|
79
88
|
# parent — minus landlock, since it already runs inside the sandbox.
|
|
80
89
|
config: { backend: config.backend, max_pixels: config.max_pixels }
|
|
@@ -87,6 +96,8 @@ module SafeImage
|
|
|
87
96
|
def deep_symbolize(value)
|
|
88
97
|
case value
|
|
89
98
|
when Hash
|
|
99
|
+
# {"__sym__" => "x"} is a symbol value the parent wrapped for transport.
|
|
100
|
+
return value[:__sym__].to_sym if value.size == 1 && value[:__sym__].is_a?(String)
|
|
90
101
|
value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
|
|
91
102
|
when Array
|
|
92
103
|
value.map { |v| deep_symbolize(v) }
|
|
@@ -103,9 +114,9 @@ module SafeImage
|
|
|
103
114
|
]
|
|
104
115
|
raise ArgumentError, "unsupported sandbox operation: #{operation}" unless allowed_operations.include?(operation)
|
|
105
116
|
|
|
106
|
-
request = payload.fetch(:request)
|
|
117
|
+
request = deep_symbolize(payload.fetch(:request))
|
|
107
118
|
args = request[:args] || []
|
|
108
|
-
kwargs =
|
|
119
|
+
kwargs = request[:kwargs] || {}
|
|
109
120
|
|
|
110
121
|
config = payload.fetch(:config)
|
|
111
122
|
SafeImage.configure!(
|
|
@@ -152,16 +163,7 @@ module SafeImage
|
|
|
152
163
|
max_output_bytes: 512 * 1024,
|
|
153
164
|
truncate_output: false
|
|
154
165
|
)
|
|
155
|
-
|
|
156
|
-
if response[:__type] == "Result"
|
|
157
|
-
data = response.fetch(:data)
|
|
158
|
-
Result.new(**data)
|
|
159
|
-
elsif response[:__type] == "Info"
|
|
160
|
-
data = response.fetch(:data)
|
|
161
|
-
Info.new(**data)
|
|
162
|
-
else
|
|
163
|
-
response[:data]
|
|
164
|
-
end
|
|
166
|
+
decode_payload(JSON.parse(stdout, symbolize_names: true))
|
|
165
167
|
end
|
|
166
168
|
rescue LoadError
|
|
167
169
|
raise Error, "landlock sandbox requested but the landlock gem is unavailable"
|
|
@@ -175,6 +177,31 @@ module SafeImage
|
|
|
175
177
|
)
|
|
176
178
|
end
|
|
177
179
|
|
|
180
|
+
# Rebuilds a worker's {__type:, data:} JSON reply into the value the
|
|
181
|
+
# caller would have received inline.
|
|
182
|
+
def decode_payload(response)
|
|
183
|
+
case response[:__type]
|
|
184
|
+
when "Result" then Result.new(**response.fetch(:data))
|
|
185
|
+
when "Info" then Info.new(**response.fetch(:data))
|
|
186
|
+
else response[:data]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# JSON cannot represent symbols, so wrap symbol values as {"__sym__" => name}
|
|
191
|
+
# for the worker's deep_symbolize to restore. Mirrors that decoder.
|
|
192
|
+
def deep_encode_symbols(value)
|
|
193
|
+
case value
|
|
194
|
+
when Symbol
|
|
195
|
+
{ "__sym__" => value.to_s }
|
|
196
|
+
when Hash
|
|
197
|
+
value.transform_values { |v| deep_encode_symbols(v) }
|
|
198
|
+
when Array
|
|
199
|
+
value.map { |v| deep_encode_symbols(v) }
|
|
200
|
+
else
|
|
201
|
+
value
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
178
205
|
def sandbox_paths(request, operation)
|
|
179
206
|
read = []
|
|
180
207
|
write = []
|