safe_image 0.1.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 +351 -0
- data/README.md +637 -288
- data/SECURITY.md +27 -7
- data/lib/safe_image/discourse_compat.rb +256 -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 +380 -1
- data/lib/safe_image/optimizer.rb +79 -4
- data/lib/safe_image/processor.rb +24 -70
- data/lib/safe_image/remote.rb +188 -10
- data/lib/safe_image/runner.rb +10 -2
- data/lib/safe_image/sandbox.rb +82 -29
- data/lib/safe_image/svg_css.rb +314 -0
- data/lib/safe_image/svg_metadata.rb +183 -27
- data/lib/safe_image/svg_sanitizer.rb +524 -43
- 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/zygote.rb +619 -0
- data/lib/safe_image.rb +154 -35
- metadata +47 -15
- data/ext/safe_image_native/extconf.rb +0 -8
- data/ext/safe_image_native/safe_image_native.c +0 -392
data/lib/safe_image/remote.rb
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "ipaddr"
|
|
5
|
-
require "net/http"
|
|
6
|
-
require "resolv"
|
|
7
5
|
require "tempfile"
|
|
8
6
|
require "time"
|
|
9
7
|
require "tmpdir"
|
|
10
8
|
require "uri"
|
|
9
|
+
# net/http and resolv are required lazily inside the methods that fetch, not at
|
|
10
|
+
# load time: requiring them pulls in resolv, which (Ruby 3.4+) reads
|
|
11
|
+
# /etc/resolv.conf eagerly. The Landlock-sandboxed worker loads this file but
|
|
12
|
+
# never performs remote fetches, and on hosts where /etc/resolv.conf is a
|
|
13
|
+
# symlink into /run the sandbox denies that read and the worker dies at boot.
|
|
11
14
|
|
|
12
15
|
module SafeImage
|
|
13
16
|
module Remote
|
|
@@ -39,10 +42,50 @@ module SafeImage
|
|
|
39
42
|
"image/avif" => ".avif",
|
|
40
43
|
"image/x-icon" => ".ico",
|
|
41
44
|
"image/vnd.microsoft.icon" => ".ico",
|
|
45
|
+
"image/jxl" => ".jxl",
|
|
42
46
|
"image/svg+xml" => ".svg"
|
|
43
47
|
}.freeze
|
|
44
48
|
|
|
45
|
-
EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .svg].freeze
|
|
49
|
+
EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif .avif .ico .jxl .svg].freeze
|
|
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
|
|
46
89
|
|
|
47
90
|
BLOCKED_IP_RANGES = [
|
|
48
91
|
# IPv4 special-use / non-public ranges. Default remote fetching is for
|
|
@@ -113,7 +156,7 @@ module SafeImage
|
|
|
113
156
|
)
|
|
114
157
|
file.flush
|
|
115
158
|
|
|
116
|
-
ext =
|
|
159
|
+
ext = response.fetch(:ext)
|
|
117
160
|
path = file.path
|
|
118
161
|
if File.extname(path) != ext
|
|
119
162
|
renamed = path.sub(/\.bin\z/, ext)
|
|
@@ -132,8 +175,18 @@ module SafeImage
|
|
|
132
175
|
end
|
|
133
176
|
|
|
134
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)
|
|
135
|
-
|
|
136
|
-
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
|
|
137
190
|
end
|
|
138
191
|
end
|
|
139
192
|
|
|
@@ -146,12 +199,97 @@ module SafeImage
|
|
|
146
199
|
end
|
|
147
200
|
|
|
148
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)
|
|
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
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def dominant_color(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)
|
|
149
211
|
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|
|
|
150
|
-
SafeImage.
|
|
212
|
+
SafeImage.dominant_color(path, max_pixels: max_pixels)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
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
|
|
151
288
|
end
|
|
152
289
|
end
|
|
153
290
|
|
|
154
|
-
def request(uri, io:, max_bytes:, max_redirects:, open_timeout:, read_timeout:, total_timeout:, started_at:, allow_private:, allowed_ports:, headers: {})
|
|
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)
|
|
292
|
+
require "net/http"
|
|
155
293
|
raise ArgumentError, "too many redirects" if max_redirects < 0
|
|
156
294
|
check_deadline!(started_at, total_timeout)
|
|
157
295
|
ipaddr = validate_uri!(uri, allow_private: allow_private, allowed_ports: allowed_ports)
|
|
@@ -170,6 +308,7 @@ module SafeImage
|
|
|
170
308
|
|
|
171
309
|
bytes = 0
|
|
172
310
|
content_type = nil
|
|
311
|
+
ext = nil
|
|
173
312
|
|
|
174
313
|
http.request(request) do |response|
|
|
175
314
|
check_deadline!(started_at, total_timeout)
|
|
@@ -192,25 +331,63 @@ module SafeImage
|
|
|
192
331
|
started_at: started_at,
|
|
193
332
|
allow_private: allow_private,
|
|
194
333
|
allowed_ports: allowed_ports,
|
|
195
|
-
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
|
|
196
337
|
)
|
|
197
338
|
when Net::HTTPSuccess
|
|
198
339
|
content_length = response["content-length"].to_i
|
|
199
340
|
raise LimitError, "remote image exceeds #{max_bytes} bytes" if content_length > max_bytes
|
|
200
341
|
|
|
201
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
|
|
202
351
|
response.read_body do |chunk|
|
|
203
352
|
check_deadline!(started_at, total_timeout)
|
|
204
353
|
bytes += chunk.bytesize
|
|
205
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
|
|
206
363
|
io.write(chunk)
|
|
364
|
+
on_progress&.call(bytes)
|
|
207
365
|
end
|
|
366
|
+
verify_signature!(ext, head) if !head_checked && !head.empty?
|
|
208
367
|
else
|
|
209
368
|
raise Error, "remote image request failed: HTTP #{response.code}"
|
|
210
369
|
end
|
|
211
370
|
end
|
|
212
371
|
|
|
213
|
-
{ 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"
|
|
214
391
|
end
|
|
215
392
|
|
|
216
393
|
def parse_uri(url)
|
|
@@ -228,6 +405,7 @@ module SafeImage
|
|
|
228
405
|
end
|
|
229
406
|
return nil if allow_private
|
|
230
407
|
|
|
408
|
+
require "resolv"
|
|
231
409
|
resolver = Resolv::DNS.new
|
|
232
410
|
resolver.timeouts = [2, 2]
|
|
233
411
|
addresses = resolver.getaddresses(uri.host).map(&:to_s)
|
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: [])
|
|
@@ -40,7 +48,7 @@ module SafeImage
|
|
|
40
48
|
Dir.mktmpdir("safe-image-command-") do |tmpdir|
|
|
41
49
|
child_env = command_env(tmpdir, env)
|
|
42
50
|
|
|
43
|
-
if sandbox || SafeImage.
|
|
51
|
+
if sandbox || SafeImage.sandbox?
|
|
44
52
|
return Sandbox.capture_command!(argv, read: read, write: [*write, tmpdir], timeout: timeout, env: child_env)
|
|
45
53
|
end
|
|
46
54
|
|
data/lib/safe_image/sandbox.rb
CHANGED
|
@@ -16,7 +16,7 @@ module SafeImage
|
|
|
16
16
|
}.freeze
|
|
17
17
|
|
|
18
18
|
OPERATIONS = %w[
|
|
19
|
-
probe thumbnail type size dimensions info orientation optimize resize crop downsize convert convert_to_jpeg fix_orientation
|
|
19
|
+
probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
|
|
20
20
|
convert_favicon_to_png frame_count animated? letter_avatar optimize_image!
|
|
21
21
|
sanitize_svg!
|
|
22
22
|
].freeze
|
|
@@ -50,7 +50,7 @@ module SafeImage
|
|
|
50
50
|
raise Error, "landlock sandbox requested but the landlock gem is unavailable"
|
|
51
51
|
rescue Landlock::SafeExec::CommandError => e
|
|
52
52
|
raise CommandError.new(
|
|
53
|
-
"sandboxed command failed",
|
|
53
|
+
"sandboxed command failed: #{failure_detail(e)}",
|
|
54
54
|
command: argv,
|
|
55
55
|
status: e.status&.exitstatus,
|
|
56
56
|
stdout: e.stdout,
|
|
@@ -61,24 +61,34 @@ 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
|
|
|
68
|
-
def thumbnail(request)
|
|
69
|
-
public_call!(
|
|
70
|
-
:thumbnail,
|
|
71
|
-
args: [],
|
|
72
|
-
kwargs: request.merge(execution: :inline)
|
|
73
|
-
)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
74
|
def run_worker!(operation, request)
|
|
77
75
|
operation = operation.to_s
|
|
78
76
|
raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
|
|
79
77
|
|
|
80
78
|
require "landlock"
|
|
81
|
-
|
|
79
|
+
config = SafeImage.config
|
|
80
|
+
payload = JSON.dump(
|
|
81
|
+
{
|
|
82
|
+
operation: operation,
|
|
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),
|
|
87
|
+
# The worker is a fresh process and must be configured like the
|
|
88
|
+
# parent — minus landlock, since it already runs inside the sandbox.
|
|
89
|
+
config: { backend: config.backend, max_pixels: config.max_pixels }
|
|
90
|
+
}
|
|
91
|
+
)
|
|
82
92
|
code = <<~'RUBY'
|
|
83
93
|
require "json"
|
|
84
94
|
require "safe_image"
|
|
@@ -86,6 +96,8 @@ module SafeImage
|
|
|
86
96
|
def deep_symbolize(value)
|
|
87
97
|
case value
|
|
88
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)
|
|
89
101
|
value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
|
|
90
102
|
when Array
|
|
91
103
|
value.map { |v| deep_symbolize(v) }
|
|
@@ -97,18 +109,23 @@ module SafeImage
|
|
|
97
109
|
payload = JSON.parse(ARGV.fetch(0), symbolize_names: true)
|
|
98
110
|
operation = payload.fetch(:operation).to_s
|
|
99
111
|
allowed_operations = %w[
|
|
100
|
-
probe thumbnail type size dimensions info orientation optimize resize crop downsize convert convert_to_jpeg fix_orientation
|
|
112
|
+
probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
|
|
101
113
|
convert_favicon_to_png frame_count animated? letter_avatar optimize_image! sanitize_svg!
|
|
102
114
|
]
|
|
103
115
|
raise ArgumentError, "unsupported sandbox operation: #{operation}" unless allowed_operations.include?(operation)
|
|
104
116
|
|
|
105
|
-
request = payload.fetch(:request)
|
|
117
|
+
request = deep_symbolize(payload.fetch(:request))
|
|
106
118
|
args = request[:args] || []
|
|
107
|
-
kwargs =
|
|
119
|
+
kwargs = request[:kwargs] || {}
|
|
108
120
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
config = payload.fetch(:config)
|
|
122
|
+
SafeImage.configure!(
|
|
123
|
+
backend: config.fetch(:backend).to_sym,
|
|
124
|
+
landlock: false,
|
|
125
|
+
max_pixels: config.fetch(:max_pixels)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
result = SafeImage.__send__(operation, *args, **kwargs)
|
|
112
129
|
|
|
113
130
|
if defined?(SafeImage::Result) && result.is_a?(SafeImage::Result)
|
|
114
131
|
puts JSON.dump({ __type: "Result", data: result.to_h })
|
|
@@ -146,22 +163,13 @@ module SafeImage
|
|
|
146
163
|
max_output_bytes: 512 * 1024,
|
|
147
164
|
truncate_output: false
|
|
148
165
|
)
|
|
149
|
-
|
|
150
|
-
if response[:__type] == "Result"
|
|
151
|
-
data = response.fetch(:data)
|
|
152
|
-
Result.new(**data)
|
|
153
|
-
elsif response[:__type] == "Info"
|
|
154
|
-
data = response.fetch(:data)
|
|
155
|
-
Info.new(**data)
|
|
156
|
-
else
|
|
157
|
-
response[:data]
|
|
158
|
-
end
|
|
166
|
+
decode_payload(JSON.parse(stdout, symbolize_names: true))
|
|
159
167
|
end
|
|
160
168
|
rescue LoadError
|
|
161
169
|
raise Error, "landlock sandbox requested but the landlock gem is unavailable"
|
|
162
170
|
rescue Landlock::SafeExec::CommandError => e
|
|
163
171
|
raise CommandError.new(
|
|
164
|
-
"sandboxed worker failed",
|
|
172
|
+
"sandboxed worker failed: #{failure_detail(e)}",
|
|
165
173
|
command: [RbConfig.ruby, "-e", "..."],
|
|
166
174
|
status: e.status&.exitstatus,
|
|
167
175
|
stdout: e.stdout,
|
|
@@ -169,6 +177,31 @@ module SafeImage
|
|
|
169
177
|
)
|
|
170
178
|
end
|
|
171
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
|
+
|
|
172
205
|
def sandbox_paths(request, operation)
|
|
173
206
|
read = []
|
|
174
207
|
write = []
|
|
@@ -221,7 +254,18 @@ module SafeImage
|
|
|
221
254
|
paths << RbConfig::CONFIG["rubyarchdir"]
|
|
222
255
|
paths << RbConfig::CONFIG["sitearchdir"]
|
|
223
256
|
paths << RbConfig::CONFIG["vendorarchdir"]
|
|
257
|
+
# An --enable-shared Ruby installed outside the default read roots
|
|
258
|
+
# (e.g. GitHub Actions' /opt/hostedtoolcache builds) keeps libruby in
|
|
259
|
+
# libdir; without read access the worker dies at dynamic-link time
|
|
260
|
+
# before any Ruby code runs.
|
|
261
|
+
paths << RbConfig::CONFIG["libdir"]
|
|
224
262
|
paths << File.dirname(RbConfig.ruby)
|
|
263
|
+
# Pango/fontconfig need the font directories and configs for the native
|
|
264
|
+
# letter_avatar text rendering inside the worker.
|
|
265
|
+
paths << "/etc/fonts"
|
|
266
|
+
paths << "/usr/share/fonts"
|
|
267
|
+
paths << "/usr/local/share/fonts"
|
|
268
|
+
paths << "/var/cache/fontconfig"
|
|
225
269
|
paths
|
|
226
270
|
end
|
|
227
271
|
|
|
@@ -229,6 +273,15 @@ module SafeImage
|
|
|
229
273
|
paths.flatten.compact.map(&:to_s).reject(&:empty?).select { |path| File.exist?(path) }.uniq
|
|
230
274
|
end
|
|
231
275
|
|
|
276
|
+
# Sandbox failures often happen before the child can run any Ruby (e.g. a
|
|
277
|
+
# denied shared-library read kills it at dynamic-link time); without the
|
|
278
|
+
# child's stderr in the message they are undiagnosable from a CI log.
|
|
279
|
+
def failure_detail(error)
|
|
280
|
+
detail = error.stderr.to_s.strip
|
|
281
|
+
detail = "exit status #{error.status&.exitstatus.inspect}" if detail.empty?
|
|
282
|
+
detail[0, 2000]
|
|
283
|
+
end
|
|
284
|
+
|
|
232
285
|
def symbolize(hash)
|
|
233
286
|
hash.transform_keys(&:to_sym)
|
|
234
287
|
end
|