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.
@@ -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 = extension_for(response.fetch(:uri), response.fetch(:content_type))
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
- 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|
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.animated?(path, max_pixels: max_pixels)
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)
@@ -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.sandbox_enabled?
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
 
@@ -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
- result = run_worker!(operation, { args: args, kwargs: kwargs })
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
- payload = JSON.dump({ operation: operation, request: request })
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 = deep_symbolize(request[:kwargs] || {})
119
+ kwargs = request[:kwargs] || {}
108
120
 
109
- result = SafeImage.with_sandbox_disabled do
110
- SafeImage.__send__(operation, *args, **kwargs)
111
- end
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
- response = JSON.parse(stdout, symbolize_names: true)
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