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.
@@ -0,0 +1,619 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "rbconfig"
6
+ require "tmpdir"
7
+
8
+ module SafeImage
9
+ # Raised when a worker's request/reply channel breaks (closed or broken pipe,
10
+ # truncated reply, protocol garbage, or a reply-deadline overrun) as opposed
11
+ # to the operation itself failing. It is a CommandError so callers catching
12
+ # the documented error still handle it, but the pool treats it specially:
13
+ # the worker is discarded, never returned to the idle set. Process liveness
14
+ # is the wrong reuse signal — a worker can be alive with a dead pipe (e.g. a
15
+ # concurrent reconfigure closed it) — so channel health drives the decision.
16
+ class WorkerBroken < CommandError; end
17
+
18
+ # Pool of persistent pre-booted sandbox workers. The exec-per-operation
19
+ # worker in Sandbox.run_worker! pays ~55ms of Ruby boot + requires (plus
20
+ # ~27ms of libvips dlopen/init on the vips backend) on every call; a zygote
21
+ # pays it once, then serves each operation from a fork (~1ms) that sandboxes
22
+ # ITSELF before touching any untrusted input. After IDLE_SECONDS without work
23
+ # a zygote exits on its own, so no resident process outlives a burst.
24
+ #
25
+ # Concurrency: each zygote serves one operation at a time over its pipe (its
26
+ # forked child does the work, but replies stream back over one pipe). To let
27
+ # N threads run sandboxed operations at once — the exec worker had unbounded
28
+ # per-thread concurrency, a single zygote would serialise them — workers are
29
+ # pooled. A call checks out an idle worker (or spawns one, up to
30
+ # MAX_WORKERS), and returns it when done; offered concurrency past the cap
31
+ # blocks until a worker frees, which also bounds concurrent libvips memory.
32
+ #
33
+ # Trust model (same as the exec worker): a zygote is a fresh Ruby process
34
+ # booted with a scrubbed env that only ever parses requests from the parent —
35
+ # never untrusted bytes. Untrusted input is only opened in the forked
36
+ # grandchild, after it has applied rlimits, a per-operation Landlock policy
37
+ # (filesystem allowlist; all TCP denied via a handled-but-unmatchable port
38
+ # rule on ABI >= 4; abstract-unix-socket and signal scopes on ABI >= 6), and
39
+ # — where the landlock gem exposes it — the helper's deny-all-network seccomp
40
+ # filter (closing the non-TCP/UDP gap the in-process Landlock policy alone
41
+ # leaves open). Forking is sound because a zygote never runs an operation
42
+ # itself: libvips is initialised but quiescent (zero native threads) at every
43
+ # fork.
44
+ module Zygote
45
+ module_function
46
+
47
+ # How long an idle zygote lingers before exiting. Idling is cheap — ~16MB
48
+ # private memory (the ~48MB RSS is mostly shared library pages), flat
49
+ # across operations, zero CPU (blocked in select) — and a parent that
50
+ # exits takes its zygotes with it immediately via stdin EOF, so the window
51
+ # is generous. Overridable via the SAFE_IMAGE_ZYGOTE_IDLE_SECONDS env var.
52
+ IDLE_SECONDS = 300
53
+
54
+ # Max concurrent sandboxed operations (= resident workers under load).
55
+ # Overridable via SAFE_IMAGE_ZYGOTE_WORKERS. The cap is backpressure: a
56
+ # burst of 50 uploads runs at most this many libvips decodes at once.
57
+ DEFAULT_MAX_WORKERS = 8
58
+
59
+ SPAWN_TIMEOUT = 30
60
+ # The parent's reply deadline is the worker's own operation timeout plus
61
+ # this grace: the worker enforces Runner::DEFAULT_TIMEOUT around the forked
62
+ # child (killing it and replying), and the grace covers the worker's reply
63
+ # serialization and child reaping so the parent only gives up — and kills
64
+ # the worker — when the worker itself has wedged, not merely when the
65
+ # operation ran long.
66
+ RESPONSE_GRACE = 10
67
+ MAX_RESPONSE_BYTES = 512 * 1024
68
+
69
+ # generation: the pool generation a worker was born into; shutdown!/fork
70
+ # bump the generation so a worker checked out under the old config is
71
+ # retired (never re-pooled) when it returns. owner_pid: the process that
72
+ # spawned it, so a worker inherited across fork is never killed by the
73
+ # child (it belongs to the parent).
74
+ # tmproot: a parent-created directory the worker puts its per-operation
75
+ # tmpdirs under. The worker removes it on graceful exit (at_exit); the
76
+ # parent removes it when it kills the worker, so a SIGKILL mid-operation
77
+ # (where the worker cannot clean up) does not leak the op's tmpdir.
78
+ Worker = Struct.new(:pid, :stdin, :stdout, :last_used, :generation, :owner_pid, :tmproot)
79
+
80
+ @mutex = Mutex.new
81
+ @free = ConditionVariable.new
82
+ @idle = [] # checked-in Workers of the current generation, MRU last
83
+ @count = 0 # live workers of the current generation: idle + checked out
84
+ @generation = 0 # bumped by shutdown!/fork to retire outstanding workers
85
+ @key = nil # [pid, backend, max_pixels] the pool was built for
86
+
87
+ def enabled?
88
+ ENV["SAFE_IMAGE_ZYGOTE"] != "0" && Process.respond_to?(:fork)
89
+ end
90
+
91
+ def max_workers
92
+ n = ENV["SAFE_IMAGE_ZYGOTE_WORKERS"].to_i
93
+ n.positive? ? n : DEFAULT_MAX_WORKERS
94
+ end
95
+
96
+ # Exposed for tests/diagnostics: the idle worker that a serial caller keeps
97
+ # reusing (nil mid-operation or when the pool is empty).
98
+ def pid
99
+ @mutex.synchronize { @idle.last&.pid }
100
+ end
101
+
102
+ def pids
103
+ @mutex.synchronize { @idle.map(&:pid) }
104
+ end
105
+
106
+ def pool_size
107
+ @mutex.synchronize { @count }
108
+ end
109
+
110
+ def shutdown!
111
+ @mutex.synchronize do
112
+ @idle.each { |w| close_worker(w, kill: w.owner_pid == Process.pid) }
113
+ @idle.clear
114
+ @count = 0
115
+ @key = nil
116
+ # Retire any worker still checked out: its generation no longer matches,
117
+ # so checkin/discard will close it instead of returning it to the pool.
118
+ # This is what stops a worker booted under the old config from serving
119
+ # an operation after a reconfigure.
120
+ @generation += 1
121
+ @free.broadcast
122
+ end
123
+ end
124
+
125
+ def call!(operation, request)
126
+ payload = JSON.dump(
127
+ operation: operation.to_s,
128
+ request: Sandbox.deep_encode_symbols(request),
129
+ paths: Sandbox.sandbox_paths(request, operation),
130
+ timeout: Runner::DEFAULT_TIMEOUT
131
+ )
132
+
133
+ attempts = 0
134
+ begin
135
+ attempts += 1
136
+ worker = checkout
137
+ # Every path below returns the worker to the pool exactly once
138
+ # (checkin if reusable, discard otherwise) so a slot is never leaked.
139
+ begin
140
+ worker.stdin.puts(payload)
141
+ rescue Errno::EPIPE, IOError
142
+ # The channel is gone before the request landed — the worker
143
+ # idle-exited, or a concurrent reconfigure closed its pipe. Nothing
144
+ # ran, so discard it and respawn once, transparently.
145
+ discard(worker)
146
+ retry if attempts == 1
147
+ raise CommandError.new("sandbox zygote is not accepting requests", command: ["zygote"])
148
+ rescue StandardError
149
+ discard(worker)
150
+ raise
151
+ end
152
+
153
+ begin
154
+ reply = read_reply(worker)
155
+ rescue WorkerBroken
156
+ # The channel broke (closed/broken pipe, truncated reply, protocol
157
+ # error, deadline). The worker is unusable regardless of whether its
158
+ # process is still alive — drop it, never return it to the pool.
159
+ discard(worker)
160
+ raise
161
+ rescue StandardError
162
+ # The worker replied with an operation failure (oxipng exited 1, ...)
163
+ # and is otherwise healthy, so return it to the pool for reuse.
164
+ checkin(worker)
165
+ raise
166
+ end
167
+ checkin(worker)
168
+ reply
169
+ end
170
+ end
171
+
172
+ # Block until a worker is available, spawning one (outside the lock) when
173
+ # the pool is below the cap.
174
+ def checkout
175
+ loop do
176
+ gen = nil
177
+ @mutex.synchronize do
178
+ drop_foreign_pool!
179
+ while (w = @idle.pop)
180
+ return w if worker_usable?(w)
181
+
182
+ drop_worker(w)
183
+ end
184
+ if @count < max_workers
185
+ @count += 1
186
+ @key ||= pool_key
187
+ gen = @generation
188
+ else
189
+ @free.wait(@mutex)
190
+ end
191
+ end
192
+ next unless gen
193
+
194
+ begin
195
+ return spawn_worker(gen)
196
+ rescue StandardError
197
+ @mutex.synchronize do
198
+ # Release the reserved slot, but only against the generation it was
199
+ # reserved under — a concurrent shutdown! may have zeroed @count.
200
+ @count -= 1 if gen == @generation
201
+ @free.signal
202
+ end
203
+ raise
204
+ end
205
+ end
206
+ end
207
+
208
+ def checkin(worker)
209
+ @mutex.synchronize do
210
+ if worker.generation == @generation
211
+ worker.last_used = monotonic
212
+ @idle.push(worker)
213
+ @free.signal
214
+ else
215
+ # Retired by a shutdown!/reconfigure while it was checked out.
216
+ drop_worker(worker)
217
+ end
218
+ end
219
+ end
220
+
221
+ def discard(worker)
222
+ @mutex.synchronize { drop_worker(worker) }
223
+ end
224
+
225
+ # Close a worker and release its pool slot. The slot is only counted
226
+ # against the current generation — a worker retired by shutdown!/fork
227
+ # belongs to a generation whose @count was already zeroed, so its return
228
+ # must not push @count negative. A worker spawned by another process
229
+ # (inherited across fork) is closed but never killed.
230
+ def drop_worker(worker)
231
+ close_worker(worker, kill: worker.owner_pid == Process.pid)
232
+ @count -= 1 if worker.generation == @generation
233
+ @free.signal
234
+ end
235
+
236
+ # A pool inherited across fork belongs to the parent: drop our copies of
237
+ # its pipes without killing the parent's processes, retire the generation
238
+ # (so a worker checked out across the fork is not reused), and rebuild
239
+ # lazily.
240
+ def drop_foreign_pool!
241
+ return unless @key && @key[0] != Process.pid
242
+
243
+ @idle.each { |w| close_worker(w, kill: false) }
244
+ @idle.clear
245
+ @count = 0
246
+ @key = nil
247
+ @generation += 1
248
+ end
249
+
250
+ def pool_key
251
+ config = SafeImage.config
252
+ [Process.pid, config.backend, config.max_pixels]
253
+ end
254
+
255
+ def worker_usable?(worker)
256
+ worker.generation == @generation &&
257
+ alive?(worker.pid) &&
258
+ (monotonic - worker.last_used) < idle_seconds
259
+ end
260
+
261
+ # Falls back to the default on a missing, non-numeric, or non-positive
262
+ # value rather than raising or letting a negative reach the worker's
263
+ # IO.select idle timeout (which would raise there).
264
+ def idle_seconds
265
+ raw = ENV["SAFE_IMAGE_ZYGOTE_IDLE_SECONDS"]
266
+ return IDLE_SECONDS unless raw
267
+
268
+ value = begin
269
+ Float(raw)
270
+ rescue ArgumentError, TypeError
271
+ nil
272
+ end
273
+ value&.positive? ? value : IDLE_SECONDS
274
+ end
275
+
276
+ def monotonic
277
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
278
+ end
279
+
280
+ def spawn_worker(generation)
281
+ require "landlock"
282
+ config = SafeImage.config
283
+ tmproot = Dir.mktmpdir("safe-image-zygote-")
284
+ boot = JSON.dump(
285
+ config: { backend: config.backend, max_pixels: config.max_pixels },
286
+ idle_seconds: idle_seconds,
287
+ tmproot: tmproot,
288
+ rlimits: Sandbox::DEFAULT_RLIMITS,
289
+ execute: Sandbox.existing_paths([*Landlock::SafeExec.default_execute_paths, File.dirname(RbConfig.ruby)]),
290
+ max_response_bytes: MAX_RESPONSE_BYTES
291
+ )
292
+
293
+ env = Runner.command_env(Dir.tmpdir).merge(
294
+ "SAFE_IMAGE_SANDBOX_CHILD" => "1",
295
+ "GEM_HOME" => ENV["GEM_HOME"].to_s,
296
+ "GEM_PATH" => ENV["GEM_PATH"].to_s,
297
+ "RUBYLIB" => $LOAD_PATH.select { |p| p && File.directory?(p) }.join(File::PATH_SEPARATOR)
298
+ )
299
+
300
+ in_r, in_w = IO.pipe
301
+ out_r, out_w = IO.pipe
302
+ pid = Process.spawn(
303
+ env,
304
+ RbConfig.ruby,
305
+ "-I#{File.expand_path("../../", __dir__)}",
306
+ "-rjson",
307
+ "-e",
308
+ ZYGOTE_PROGRAM,
309
+ boot,
310
+ in: in_r, out: out_w, unsetenv_others: true, pgroup: true
311
+ )
312
+ Process.detach(pid)
313
+ in_r.close
314
+ out_w.close
315
+ in_w.sync = true
316
+
317
+ worker = Worker.new(pid, in_w, out_r, monotonic, generation, Process.pid, tmproot)
318
+ ready = read_line(worker, SPAWN_TIMEOUT)
319
+ raise CommandError.new("sandbox zygote failed to boot", command: ["zygote"]) unless ready && JSON.parse(ready)["ready"]
320
+
321
+ worker
322
+ rescue StandardError
323
+ if worker
324
+ close_worker(worker, kill: true)
325
+ else
326
+ in_w&.close rescue nil
327
+ out_r&.close rescue nil
328
+ FileUtils.remove_entry(tmproot) if tmproot && File.directory?(tmproot)
329
+ end
330
+ raise
331
+ end
332
+
333
+ def read_reply(worker)
334
+ line = read_line(worker, Runner::DEFAULT_TIMEOUT + RESPONSE_GRACE)
335
+ raise WorkerBroken.new("sandbox zygote died mid-operation", command: ["zygote"]) if line.nil?
336
+
337
+ reply = JSON.parse(line, symbolize_names: true)
338
+ unless reply[:ok]
339
+ # The worker ran the operation and reported its failure: it is healthy
340
+ # and reusable, so this is a plain CommandError, not WorkerBroken.
341
+ raise CommandError.new(
342
+ "sandboxed operation failed: #{reply[:error].to_s[0, 2000]}",
343
+ command: ["zygote"],
344
+ status: reply[:status],
345
+ stderr: reply[:stderr].to_s
346
+ )
347
+ end
348
+ Sandbox.decode_payload(JSON.parse(reply.fetch(:body), symbolize_names: true))
349
+ rescue JSON::ParserError => e
350
+ kill_worker(worker)
351
+ raise WorkerBroken.new("sandbox zygote protocol error: #{e.message}", command: ["zygote"])
352
+ end
353
+
354
+ # Blocking line read with a deadline. Every channel-level failure — overrun,
355
+ # oversize, or the stdout being closed under us by a concurrent
356
+ # reconfigure — raises WorkerBroken so the caller discards the worker rather
357
+ # than returning a dead pipe to the pool.
358
+ def read_line(worker, timeout)
359
+ deadline = monotonic + timeout
360
+ buffer = +""
361
+ loop do
362
+ remaining = deadline - monotonic
363
+ if remaining <= 0
364
+ kill_worker(worker)
365
+ raise WorkerBroken.new("sandboxed operation timed out", command: ["zygote"])
366
+ end
367
+
368
+ chunk =
369
+ begin
370
+ next unless IO.select([worker.stdout], nil, nil, remaining)
371
+
372
+ worker.stdout.read_nonblock(65_536, exception: false)
373
+ rescue IOError
374
+ raise WorkerBroken.new("sandbox zygote channel closed", command: ["zygote"])
375
+ end
376
+
377
+ case chunk
378
+ when :wait_readable then next
379
+ when nil
380
+ return buffer.empty? ? nil : buffer
381
+ else
382
+ buffer << chunk
383
+ return buffer if buffer.end_with?("\n")
384
+ # 2x: the reply line wraps a body the zygote already caps at
385
+ # MAX_RESPONSE_BYTES, plus JSON escaping overhead.
386
+ if buffer.bytesize > MAX_RESPONSE_BYTES * 2
387
+ kill_worker(worker)
388
+ raise WorkerBroken.new("sandbox zygote response exceeded #{MAX_RESPONSE_BYTES} bytes", command: ["zygote"])
389
+ end
390
+ end
391
+ end
392
+ end
393
+
394
+ def alive?(pid)
395
+ Process.kill(0, pid)
396
+ true
397
+ rescue Errno::ESRCH, Errno::EPERM
398
+ false
399
+ end
400
+
401
+ def kill_worker(worker)
402
+ return unless worker&.pid
403
+
404
+ begin
405
+ # Kills the zygote's process group. An idle zygote (no in-flight
406
+ # operation) has no other group members, so this reaps it cleanly. A
407
+ # zygote killed mid-operation does NOT take its active operation child
408
+ # with it this way — that child setpgrp'd into its own group — so the
409
+ # child carries PR_SET_PDEATHSIG=SIGKILL to die when the zygote dies.
410
+ Process.kill("KILL", -worker.pid)
411
+ rescue Errno::ESRCH, Errno::EPERM
412
+ nil
413
+ end
414
+ end
415
+
416
+ def close_worker(worker, kill:)
417
+ kill_worker(worker) if kill
418
+ worker.stdin&.close rescue nil
419
+ worker.stdout&.close rescue nil
420
+ # Only our own workers' tmp roots are ours to remove; an inherited
421
+ # (kill: false) worker's belongs to the parent. A gracefully-exited
422
+ # worker has already removed its own via at_exit; this catches the
423
+ # SIGKILLed-mid-operation case.
424
+ FileUtils.remove_entry(worker.tmproot) if kill && worker.tmproot && File.directory?(worker.tmproot)
425
+ rescue StandardError
426
+ nil
427
+ end
428
+
429
+ # The resident process. Boots the gem once, then serves requests from
430
+ # stdin: one fork per request, sandboxed in the fork, one JSON reply line
431
+ # per request on stdout. Exits when idle or when the parent closes stdin.
432
+ ZYGOTE_PROGRAM = <<~'RUBY'
433
+ require "safe_image"
434
+ require "landlock"
435
+ require "tmpdir"
436
+ require "fileutils"
437
+
438
+ def deep_symbolize(value)
439
+ case value
440
+ when Hash
441
+ return value[:__sym__].to_sym if value.size == 1 && value[:__sym__].is_a?(String)
442
+ value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
443
+ when Array
444
+ value.map { |v| deep_symbolize(v) }
445
+ else
446
+ value
447
+ end
448
+ end
449
+
450
+ ALLOWED_OPERATIONS = %w[
451
+ probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
452
+ convert_favicon_to_png frame_count animated? letter_avatar optimize_image! sanitize_svg!
453
+ ]
454
+
455
+ boot = JSON.parse(ARGV.fetch(0), symbolize_names: true)
456
+ config = boot.fetch(:config)
457
+ SafeImage.configure!(backend: config.fetch(:backend).to_sym, landlock: false, max_pixels: config.fetch(:max_pixels))
458
+
459
+ idle = boot.fetch(:idle_seconds)
460
+ rlimits = boot.fetch(:rlimits)
461
+ execute_paths = boot.fetch(:execute)
462
+ max_bytes = boot.fetch(:max_response_bytes)
463
+ tmproot = boot.fetch(:tmproot)
464
+ read_defaults = Landlock::SafeExec.default_read_paths +
465
+ SafeImage::Sandbox.runtime_read_paths
466
+
467
+ # Runs on graceful exit (idle timeout / parent stdin EOF) but not in the
468
+ # op child, which leaves via exit! — so only the long-lived zygote cleans
469
+ # its tmp root, and the parent covers the SIGKill case.
470
+ at_exit { FileUtils.remove_entry(tmproot) if File.directory?(tmproot) rescue nil }
471
+
472
+ zygote_pid = Process.pid
473
+
474
+ # libc prctl(2) for PR_SET_PDEATHSIG, so an operation child dies with the
475
+ # zygote even after it setpgrp's out of the zygote's process group (where
476
+ # a parent-side group-kill can no longer reach it). nil if unavailable;
477
+ # the CPU rlimit remains a backstop either way.
478
+ prctl =
479
+ begin
480
+ require "fiddle"
481
+ Fiddle::Function.new(
482
+ Fiddle::Handle::DEFAULT["prctl"],
483
+ [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG],
484
+ Fiddle::TYPE_INT
485
+ )
486
+ rescue StandardError
487
+ nil
488
+ end
489
+ pr_set_pdeathsig = 1
490
+ sigkill = 9
491
+
492
+ $stdout.sync = true
493
+ $stdout.puts(JSON.dump(ready: true, pid: Process.pid))
494
+
495
+ loop do
496
+ exit 0 unless IO.select([$stdin], nil, nil, idle)
497
+ line = $stdin.gets
498
+ exit 0 if line.nil?
499
+
500
+ req = JSON.parse(line, symbolize_names: true)
501
+ operation = req.fetch(:operation)
502
+ raise ArgumentError, "unsupported sandbox operation: #{operation}" unless ALLOWED_OPERATIONS.include?(operation)
503
+
504
+ tmpdir = Dir.mktmpdir("op-", tmproot)
505
+ out_r, out_w = IO.pipe
506
+ err_r, err_w = IO.pipe
507
+
508
+ pid = fork do
509
+ out_r.close
510
+ err_r.close
511
+ $stdin.reopen(File::NULL)
512
+ $stdout.reopen(err_w)
513
+ $stderr.reopen(err_w)
514
+ Process.setpgrp # own group, so the zygote's per-op timeout kill (-pid) reaps tools too
515
+
516
+ # Die if the zygote dies: once we setpgrp out of its group a
517
+ # parent-side group-kill can no longer reach us, so request a SIGKILL
518
+ # on the zygote's death. PR_SET_PDEATHSIG only fires on a *future*
519
+ # parent death, so re-check the zygote is still our parent to close
520
+ # the fork→prctl race where it died in between.
521
+ prctl&.call(pr_set_pdeathsig, sigkill, 0, 0, 0)
522
+ exit!(1) unless Process.ppid == zygote_pid
523
+
524
+ ENV["TMPDIR"] = tmpdir
525
+ ENV["HOME"] = tmpdir
526
+ ENV["XDG_CACHE_HOME"] = tmpdir
527
+ ENV["MAGICK_TEMPORARY_PATH"] = tmpdir
528
+
529
+ Process.setrlimit(:CPU, rlimits.fetch(:cpu_seconds))
530
+ Process.setrlimit(:AS, rlimits.fetch(:memory_bytes))
531
+ Process.setrlimit(:FSIZE, rlimits.fetch(:file_size_bytes))
532
+ Process.setrlimit(:NOFILE, rlimits.fetch(:open_files))
533
+
534
+ abi = Landlock.abi_version
535
+ # Port 1 is never used: handling the TCP rights with an unmatchable
536
+ # rule denies all TCP connect/bind.
537
+ net = abi >= 4 ? { connect_tcp: [1], bind_tcp: [1] } : {}
538
+ scope = abi >= 6 ? %i[abstract_unix_socket signal] : []
539
+ existing = ->(paths) { paths.compact.map(&:to_s).reject(&:empty?).select { |p| File.exist?(p) }.uniq }
540
+ Landlock.restrict!(
541
+ read: existing.call(read_defaults + req.dig(:paths, :read) + [tmpdir]),
542
+ write: existing.call(req.dig(:paths, :write) + [tmpdir]),
543
+ execute: existing.call(execute_paths),
544
+ scope: scope,
545
+ **net
546
+ )
547
+ # landlock >= the version that ships it: the helper's deny-all
548
+ # seccomp filter, self-applied — closes the UDP gap the in-process
549
+ # Landlock policy alone leaves open.
550
+ Landlock.seccomp_deny_network! if Landlock.respond_to?(:seccomp_deny_network!)
551
+
552
+ request = deep_symbolize(req.fetch(:request))
553
+ result = SafeImage.__send__(operation, *(request[:args] || []), **(request[:kwargs] || {}))
554
+
555
+ body =
556
+ if defined?(SafeImage::Result) && result.is_a?(SafeImage::Result)
557
+ { __type: "Result", data: result.to_h }
558
+ elsif defined?(SafeImage::Info) && result.is_a?(SafeImage::Info)
559
+ { __type: "Info", data: result.to_h }
560
+ else
561
+ { __type: "Value", data: result }
562
+ end
563
+ out_w.write(JSON.dump(body))
564
+ out_w.close
565
+ exit!(0)
566
+ rescue Exception => e # rubocop:disable Lint/RescueException -- the fork must never escape into the zygote loop
567
+ err_w.write("#{e.class}: #{e.message}") rescue nil
568
+ exit!(1)
569
+ end
570
+
571
+ out_w.close
572
+ err_w.close
573
+
574
+ body = +""
575
+ stderr = +""
576
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + req.fetch(:timeout)
577
+ timed_out = false
578
+ readers = [out_r, err_r]
579
+ until readers.empty?
580
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
581
+ if remaining <= 0
582
+ timed_out = true
583
+ Process.kill("KILL", -pid) rescue nil
584
+ break
585
+ end
586
+ ready = IO.select(readers, nil, nil, remaining) or next
587
+ ready[0].each do |io|
588
+ chunk = io.read_nonblock(65_536, exception: false)
589
+ if chunk.nil?
590
+ readers.delete(io)
591
+ elsif chunk != :wait_readable
592
+ (io == out_r ? body : stderr) << chunk
593
+ if body.bytesize + stderr.bytesize > max_bytes
594
+ timed_out = false
595
+ Process.kill("KILL", -pid) rescue nil
596
+ readers.clear
597
+ stderr = "operation output exceeded #{max_bytes} bytes"
598
+ end
599
+ end
600
+ end
601
+ end
602
+ _, status = Process.waitpid2(pid)
603
+ out_r.close
604
+ err_r.close
605
+ FileUtils.remove_entry(tmpdir) rescue nil
606
+
607
+ if timed_out
608
+ $stdout.puts(JSON.dump(ok: false, error: "operation timed out", stderr: stderr[0, 8192], status: nil))
609
+ elsif status.success? && !body.empty?
610
+ $stdout.puts(JSON.dump(ok: true, body: body))
611
+ else
612
+ detail = stderr.strip
613
+ detail = "exit status #{status.exitstatus.inspect}" if detail.empty?
614
+ $stdout.puts(JSON.dump(ok: false, error: detail[0, 8192], stderr: stderr[0, 8192], status: status.exitstatus))
615
+ end
616
+ end
617
+ RUBY
618
+ end
619
+ end
data/lib/safe_image.rb CHANGED
@@ -35,9 +35,11 @@ require_relative "safe_image/native"
35
35
  require_relative "safe_image/result"
36
36
  require_relative "safe_image/runner"
37
37
  require_relative "safe_image/sandbox"
38
+ require_relative "safe_image/zygote"
38
39
  require_relative "safe_image/path_safety"
39
40
  require_relative "safe_image/optimizer"
40
41
  require_relative "safe_image/svg_metadata"
42
+ require_relative "safe_image/svg_css"
41
43
  require_relative "safe_image/svg_sanitizer"
42
44
  require_relative "safe_image/remote"
43
45
  require_relative "safe_image/ico"
@@ -86,6 +88,10 @@ module SafeImage
86
88
  raise Error, "landlock: true requested but the Landlock sandbox is unavailable on this host"
87
89
  end
88
90
 
91
+ # The zygote bakes the backend and max_pixels in at boot; a reconfigure
92
+ # must not serve from a stale one.
93
+ Zygote.shutdown!
94
+
89
95
  @config = Config.new(backend: backend, landlock: landlock, max_pixels: max_pixels)
90
96
  end
91
97
 
@@ -374,6 +380,12 @@ module SafeImage
374
380
  end
375
381
 
376
382
  def sanitize_svg!(*args, **kwargs)
383
+ # Validate the required id_namespace in the parent (after the configured
384
+ # check) so omitting/malformed values raise ArgumentError consistently —
385
+ # otherwise, under the sandbox, the worker raises and it surfaces as a
386
+ # sandbox CommandError instead of the documented ArgumentError.
387
+ config
388
+ SvgSanitizer.resolve_namespace(kwargs.fetch(:id_namespace, SvgSanitizer::NAMESPACE_REQUIRED))
377
389
  maybe_sandbox(:sanitize_svg!, args: args, kwargs: kwargs) { SvgSanitizer.sanitize!(*args, **kwargs) }
378
390
  end
379
391
  end