safe_image 0.3.0 → 0.4.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 +21 -12
- data/README.md +140 -287
- data/SECURITY.md +22 -11
- data/docs/architecture.md +63 -0
- data/ext/safe_image_vips_helper/extconf.rb +43 -0
- data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
- data/lib/safe_image/api/metadata.rb +85 -0
- data/lib/safe_image/api/transform.rb +152 -0
- data/lib/safe_image/backend_label.rb +24 -0
- data/lib/safe_image/formats.rb +96 -0
- data/lib/safe_image/ico.rb +42 -40
- data/lib/safe_image/image_magick_backend.rb +219 -162
- data/lib/safe_image/jpegli_backend.rb +64 -44
- data/lib/safe_image/metadata_operations.rb +155 -0
- data/lib/safe_image/native.rb +96 -290
- data/lib/safe_image/native_helper.rb +281 -0
- data/lib/safe_image/operation_backends/base.rb +83 -0
- data/lib/safe_image/operation_backends/image_magick.rb +123 -0
- data/lib/safe_image/operation_backends/vips.rb +251 -0
- data/lib/safe_image/operation_backends.rb +27 -0
- data/lib/safe_image/operation_set.rb +22 -0
- data/lib/safe_image/optimizer.rb +225 -98
- data/lib/safe_image/path_safety.rb +11 -0
- data/lib/safe_image/processor.rb +122 -85
- data/lib/safe_image/quality_defaults.rb +23 -0
- data/lib/safe_image/remote.rb +248 -144
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +60 -23
- data/lib/safe_image/sandbox.rb +44 -218
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +74 -69
- data/lib/safe_image/transform_operations.rb +139 -0
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +13 -12
- data/lib/safe_image.rb +62 -306
- metadata +43 -37
- data/lib/safe_image/discourse_compat.rb +0 -441
- data/lib/safe_image/svg_css.rb +0 -314
- data/lib/safe_image/svg_sanitizer.rb +0 -583
- data/lib/safe_image/vips_glue.rb +0 -361
- data/lib/safe_image/zygote.rb +0 -619
data/lib/safe_image/runner.rb
CHANGED
|
@@ -6,14 +6,26 @@ require "timeout"
|
|
|
6
6
|
|
|
7
7
|
module SafeImage
|
|
8
8
|
class CommandError < Error
|
|
9
|
-
attr_reader :command, :status, :stdout, :stderr
|
|
10
|
-
|
|
11
|
-
def initialize(
|
|
12
|
-
|
|
9
|
+
attr_reader :command, :status, :stdout, :stderr, :category, :operation
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
message,
|
|
13
|
+
command:,
|
|
14
|
+
status: nil,
|
|
15
|
+
stdout: "",
|
|
16
|
+
stderr: "",
|
|
17
|
+
category: :command,
|
|
18
|
+
operation: nil,
|
|
19
|
+
original_error_class: nil,
|
|
20
|
+
stderr_tail: nil
|
|
21
|
+
)
|
|
22
|
+
super(message, original_error_class: original_error_class, stderr_tail: stderr_tail)
|
|
13
23
|
@command = command
|
|
14
24
|
@status = status
|
|
15
25
|
@stdout = stdout
|
|
16
26
|
@stderr = stderr
|
|
27
|
+
@category = category&.to_sym
|
|
28
|
+
@operation = operation&.to_sym
|
|
17
29
|
end
|
|
18
30
|
end
|
|
19
31
|
|
|
@@ -22,6 +34,9 @@ module SafeImage
|
|
|
22
34
|
|
|
23
35
|
DEFAULT_TIMEOUT = 20
|
|
24
36
|
MAX_OUTPUT_BYTES = 512 * 1024
|
|
37
|
+
# Give well-behaved tools a short flush window after TERM before KILL keeps
|
|
38
|
+
# the timeout hard without leaking process groups.
|
|
39
|
+
TERMINATE_GRACE_SECONDS = 0.2
|
|
25
40
|
TRUSTED_PATH = "/usr/bin:/bin:/usr/local/bin".freeze
|
|
26
41
|
ALLOWED_ENV_KEYS = %w[LANG LC_ALL LC_CTYPE TZ].freeze
|
|
27
42
|
IMAGEMAGICK_POLICY_PATH = File.expand_path("imagemagick_policy", __dir__)
|
|
@@ -70,7 +85,13 @@ module SafeImage
|
|
|
70
85
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
86
|
if remaining <= 0
|
|
72
87
|
kill_process_group(wait_thr.pid)
|
|
73
|
-
raise CommandError.new(
|
|
88
|
+
raise CommandError.new(
|
|
89
|
+
"command timed out after #{timeout}s",
|
|
90
|
+
command: argv,
|
|
91
|
+
stdout: stdout,
|
|
92
|
+
stderr: stderr,
|
|
93
|
+
category: :timeout
|
|
94
|
+
)
|
|
74
95
|
end
|
|
75
96
|
|
|
76
97
|
readable, = IO.select(streams.keys, nil, nil, remaining)
|
|
@@ -83,7 +104,13 @@ module SafeImage
|
|
|
83
104
|
buffer << chunk
|
|
84
105
|
if buffer.bytesize > MAX_OUTPUT_BYTES
|
|
85
106
|
kill_process_group(wait_thr.pid)
|
|
86
|
-
raise CommandError.new(
|
|
107
|
+
raise CommandError.new(
|
|
108
|
+
"command output exceeded #{MAX_OUTPUT_BYTES} bytes",
|
|
109
|
+
command: argv,
|
|
110
|
+
stdout: stdout,
|
|
111
|
+
stderr: stderr,
|
|
112
|
+
category: :output_limit
|
|
113
|
+
)
|
|
87
114
|
end
|
|
88
115
|
rescue IO::WaitReadable
|
|
89
116
|
next
|
|
@@ -103,7 +130,13 @@ module SafeImage
|
|
|
103
130
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
104
131
|
if remaining <= 0
|
|
105
132
|
kill_process_group(wait_thr.pid)
|
|
106
|
-
raise CommandError.new(
|
|
133
|
+
raise CommandError.new(
|
|
134
|
+
"command timed out after #{timeout}s",
|
|
135
|
+
command: argv,
|
|
136
|
+
stdout: stdout,
|
|
137
|
+
stderr: stderr,
|
|
138
|
+
category: :timeout
|
|
139
|
+
)
|
|
107
140
|
end
|
|
108
141
|
status = wait_thr.join(remaining)&.value
|
|
109
142
|
end
|
|
@@ -114,15 +147,16 @@ module SafeImage
|
|
|
114
147
|
raise
|
|
115
148
|
end
|
|
116
149
|
|
|
117
|
-
return
|
|
150
|
+
return stdout, stderr if status&.success?
|
|
118
151
|
|
|
119
152
|
raise CommandError.new(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
153
|
+
"command failed: #{argv.first} exited #{status&.exitstatus}",
|
|
154
|
+
command: argv,
|
|
155
|
+
status: status&.exitstatus,
|
|
156
|
+
stdout: stdout,
|
|
157
|
+
stderr: stderr,
|
|
158
|
+
category: :exit_status
|
|
159
|
+
)
|
|
126
160
|
end
|
|
127
161
|
|
|
128
162
|
def kill_process_group(pid)
|
|
@@ -130,17 +164,18 @@ module SafeImage
|
|
|
130
164
|
rescue Errno::ESRCH, Errno::EPERM
|
|
131
165
|
ensure
|
|
132
166
|
begin
|
|
133
|
-
sleep
|
|
167
|
+
sleep TERMINATE_GRACE_SECONDS
|
|
134
168
|
Process.kill("KILL", -pid)
|
|
135
169
|
rescue Errno::ESRCH, Errno::EPERM
|
|
136
170
|
end
|
|
137
171
|
end
|
|
138
172
|
|
|
139
173
|
def command_env(tmpdir, env = {})
|
|
140
|
-
allowed =
|
|
141
|
-
key
|
|
142
|
-
|
|
143
|
-
|
|
174
|
+
allowed =
|
|
175
|
+
env.each_with_object({}) do |(key, value), hash|
|
|
176
|
+
key = key.to_s
|
|
177
|
+
hash[key] = value.to_s if ALLOWED_ENV_KEYS.include?(key)
|
|
178
|
+
end
|
|
144
179
|
|
|
145
180
|
BASE_ENV.merge(
|
|
146
181
|
"MAGICK_CONFIGURE_PATH" => IMAGEMAGICK_POLICY_PATH,
|
|
@@ -171,10 +206,12 @@ module SafeImage
|
|
|
171
206
|
name = name.to_s
|
|
172
207
|
return name if name.include?(File::SEPARATOR) && File.file?(name) && File.executable?(name)
|
|
173
208
|
|
|
174
|
-
TRUSTED_PATH
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
209
|
+
TRUSTED_PATH
|
|
210
|
+
.split(File::PATH_SEPARATOR)
|
|
211
|
+
.each do |dir|
|
|
212
|
+
path = File.join(dir, name)
|
|
213
|
+
return path if File.file?(path) && File.executable?(path)
|
|
214
|
+
end
|
|
178
215
|
|
|
179
216
|
nil
|
|
180
217
|
end
|
data/lib/safe_image/sandbox.rb
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "rbconfig"
|
|
5
4
|
require "tmpdir"
|
|
6
5
|
|
|
@@ -15,235 +14,67 @@ module SafeImage
|
|
|
15
14
|
open_files: 256
|
|
16
15
|
}.freeze
|
|
17
16
|
|
|
18
|
-
OPERATIONS = %w[
|
|
19
|
-
probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
|
|
20
|
-
convert_favicon_to_png frame_count animated? letter_avatar optimize_image!
|
|
21
|
-
sanitize_svg!
|
|
22
|
-
].freeze
|
|
23
|
-
|
|
24
17
|
def available?
|
|
25
18
|
require "landlock"
|
|
26
|
-
|
|
19
|
+
landlock_supported?
|
|
27
20
|
rescue LoadError
|
|
28
21
|
false
|
|
29
22
|
end
|
|
30
23
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
env ||= Runner.command_env(Dir.tmpdir)
|
|
34
|
-
|
|
35
|
-
result = Landlock::SafeExec.capture!(
|
|
36
|
-
*argv.map(&:to_s),
|
|
37
|
-
read: existing_paths([*Landlock::SafeExec.default_read_paths, *runtime_read_paths, *read]),
|
|
38
|
-
write: existing_paths(write),
|
|
39
|
-
execute: existing_paths([*Landlock::SafeExec.default_execute_paths, File.dirname(RbConfig.ruby)]),
|
|
40
|
-
env: env.merge("SAFE_IMAGE_SANDBOX_CHILD" => "1"),
|
|
41
|
-
inherit_env: false,
|
|
42
|
-
timeout: timeout,
|
|
43
|
-
rlimits: rlimits,
|
|
44
|
-
seccomp_deny_network: true,
|
|
45
|
-
max_output_bytes: 512 * 1024,
|
|
46
|
-
truncate_output: false
|
|
47
|
-
)
|
|
48
|
-
[result.stdout, result.stderr]
|
|
49
|
-
rescue LoadError
|
|
50
|
-
raise Error, "landlock sandbox requested but the landlock gem is unavailable"
|
|
51
|
-
rescue Landlock::SafeExec::CommandError => e
|
|
52
|
-
raise CommandError.new(
|
|
53
|
-
"sandboxed command failed: #{failure_detail(e)}",
|
|
54
|
-
command: argv,
|
|
55
|
-
status: e.status&.exitstatus,
|
|
56
|
-
stdout: e.stdout,
|
|
57
|
-
stderr: e.stderr
|
|
58
|
-
)
|
|
24
|
+
def landlock_supported?
|
|
25
|
+
Landlock.supported?
|
|
59
26
|
end
|
|
60
27
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
|
|
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
|
|
71
|
-
operation == "type" && result ? result.to_sym : result
|
|
28
|
+
def landlock_command_error
|
|
29
|
+
Landlock::CommandError
|
|
72
30
|
end
|
|
73
31
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
require "landlock"
|
|
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
|
-
)
|
|
92
|
-
code = <<~'RUBY'
|
|
93
|
-
require "json"
|
|
94
|
-
require "safe_image"
|
|
95
|
-
|
|
96
|
-
def deep_symbolize(value)
|
|
97
|
-
case value
|
|
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)
|
|
101
|
-
value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
|
|
102
|
-
when Array
|
|
103
|
-
value.map { |v| deep_symbolize(v) }
|
|
104
|
-
else
|
|
105
|
-
value
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
payload = JSON.parse(ARGV.fetch(0), symbolize_names: true)
|
|
110
|
-
operation = payload.fetch(:operation).to_s
|
|
111
|
-
allowed_operations = %w[
|
|
112
|
-
probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
|
|
113
|
-
convert_favicon_to_png frame_count animated? letter_avatar optimize_image! sanitize_svg!
|
|
114
|
-
]
|
|
115
|
-
raise ArgumentError, "unsupported sandbox operation: #{operation}" unless allowed_operations.include?(operation)
|
|
116
|
-
|
|
117
|
-
request = deep_symbolize(payload.fetch(:request))
|
|
118
|
-
args = request[:args] || []
|
|
119
|
-
kwargs = request[:kwargs] || {}
|
|
32
|
+
def landlock_abi
|
|
33
|
+
Landlock.abi_version
|
|
34
|
+
end
|
|
120
35
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
landlock: false,
|
|
125
|
-
max_pixels: config.fetch(:max_pixels)
|
|
126
|
-
)
|
|
36
|
+
def default_read_paths
|
|
37
|
+
%w[/usr /lib /lib64 /etc /bin /sbin /opt].select { |path| File.exist?(path) }
|
|
38
|
+
end
|
|
127
39
|
|
|
128
|
-
|
|
40
|
+
def default_execute_paths
|
|
41
|
+
%w[/usr /lib /lib64 /bin /sbin /opt].select { |path| File.exist?(path) }
|
|
42
|
+
end
|
|
129
43
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
puts JSON.dump({ __type: "Info", data: result.to_h })
|
|
134
|
-
else
|
|
135
|
-
puts JSON.dump({ __type: "Value", data: result })
|
|
136
|
-
end
|
|
137
|
-
RUBY
|
|
44
|
+
def landlock_capture!(argv, **options)
|
|
45
|
+
Landlock.capture!(argv.map(&:to_s), allow_all_known: true, **options)
|
|
46
|
+
end
|
|
138
47
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
"SAFE_IMAGE_SANDBOX_CHILD" => "1",
|
|
143
|
-
"GEM_HOME" => ENV["GEM_HOME"].to_s,
|
|
144
|
-
"GEM_PATH" => ENV["GEM_PATH"].to_s,
|
|
145
|
-
"RUBYLIB" => $LOAD_PATH.select { |p| p && File.directory?(p) }.join(File::PATH_SEPARATOR)
|
|
146
|
-
)
|
|
48
|
+
def capture_command!(argv, read:, write:, timeout: Runner::DEFAULT_TIMEOUT, env: nil, rlimits: DEFAULT_RLIMITS)
|
|
49
|
+
require "landlock"
|
|
50
|
+
env ||= Runner.command_env(Dir.tmpdir)
|
|
147
51
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
env: worker_env,
|
|
159
|
-
inherit_env: false,
|
|
160
|
-
timeout: Runner::DEFAULT_TIMEOUT,
|
|
161
|
-
rlimits: DEFAULT_RLIMITS,
|
|
52
|
+
result =
|
|
53
|
+
landlock_capture!(
|
|
54
|
+
argv,
|
|
55
|
+
read: existing_paths([*default_read_paths, *runtime_read_paths, *read]),
|
|
56
|
+
write: existing_paths(write),
|
|
57
|
+
execute: existing_paths([*default_execute_paths, File.dirname(RbConfig.ruby)]),
|
|
58
|
+
env: env.merge("SAFE_IMAGE_SANDBOX_CHILD" => "1"),
|
|
59
|
+
unsetenv_others: true,
|
|
60
|
+
timeout: timeout,
|
|
61
|
+
rlimits: rlimits,
|
|
162
62
|
seccomp_deny_network: true,
|
|
163
63
|
max_output_bytes: 512 * 1024,
|
|
164
64
|
truncate_output: false
|
|
165
65
|
)
|
|
166
|
-
|
|
167
|
-
end
|
|
66
|
+
[result.stdout, result.stderr]
|
|
168
67
|
rescue LoadError
|
|
169
68
|
raise Error, "landlock sandbox requested but the landlock gem is unavailable"
|
|
170
|
-
rescue
|
|
69
|
+
rescue landlock_command_error => e
|
|
171
70
|
raise CommandError.new(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
205
|
-
def sandbox_paths(request, operation)
|
|
206
|
-
read = []
|
|
207
|
-
write = []
|
|
208
|
-
|
|
209
|
-
values = []
|
|
210
|
-
values.concat(Array(request[:args]))
|
|
211
|
-
values.concat(Array(request.dig(:kwargs)&.values))
|
|
212
|
-
values.flatten.compact.each do |value|
|
|
213
|
-
next unless value.is_a?(String)
|
|
214
|
-
next if value.empty? || value.include?("\0")
|
|
215
|
-
|
|
216
|
-
expanded = File.expand_path(value) rescue next
|
|
217
|
-
if File.exist?(expanded)
|
|
218
|
-
read << expanded
|
|
219
|
-
elsif looks_like_path?(value)
|
|
220
|
-
write << File.dirname(expanded)
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Common keyword names for generated outputs. Include the containing dir
|
|
225
|
-
# even when a stale file already exists, because operations may replace it.
|
|
226
|
-
kwargs = request[:kwargs] || {}
|
|
227
|
-
%i[output to path].each do |key|
|
|
228
|
-
next unless kwargs[key].is_a?(String)
|
|
229
|
-
write << File.dirname(File.expand_path(kwargs[key]))
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
# In-place mutators need write permission for an existing input path too.
|
|
233
|
-
if %w[optimize optimize_image! sanitize_svg! fix_orientation].include?(operation.to_s)
|
|
234
|
-
first = Array(request[:args]).first
|
|
235
|
-
if first.is_a?(String) && File.exist?(first)
|
|
236
|
-
expanded = File.expand_path(first)
|
|
237
|
-
write << expanded
|
|
238
|
-
write << File.dirname(expanded)
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
{ read: read.uniq, write: write.uniq }
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def looks_like_path?(value)
|
|
246
|
-
value.start_with?("/", "./", "../") || File.extname(value) != ""
|
|
71
|
+
"sandboxed command failed: #{failure_detail(e)}",
|
|
72
|
+
command: argv,
|
|
73
|
+
status: e.status&.exitstatus,
|
|
74
|
+
stdout: e.stdout,
|
|
75
|
+
stderr: e.stderr,
|
|
76
|
+
category: :sandbox_command
|
|
77
|
+
)
|
|
247
78
|
end
|
|
248
79
|
|
|
249
80
|
def runtime_read_paths
|
|
@@ -256,12 +87,11 @@ module SafeImage
|
|
|
256
87
|
paths << RbConfig::CONFIG["vendorarchdir"]
|
|
257
88
|
# An --enable-shared Ruby installed outside the default read roots
|
|
258
89
|
# (e.g. GitHub Actions' /opt/hostedtoolcache builds) keeps libruby in
|
|
259
|
-
# libdir;
|
|
260
|
-
# before any Ruby code runs.
|
|
90
|
+
# libdir; helper/tool subprocesses need read access before they can start.
|
|
261
91
|
paths << RbConfig::CONFIG["libdir"]
|
|
262
92
|
paths << File.dirname(RbConfig.ruby)
|
|
263
93
|
# Pango/fontconfig need the font directories and configs for the native
|
|
264
|
-
# letter_avatar text rendering inside the
|
|
94
|
+
# letter_avatar text rendering inside the helper.
|
|
265
95
|
paths << "/etc/fonts"
|
|
266
96
|
paths << "/usr/share/fonts"
|
|
267
97
|
paths << "/usr/local/share/fonts"
|
|
@@ -273,17 +103,13 @@ module SafeImage
|
|
|
273
103
|
paths.flatten.compact.map(&:to_s).reject(&:empty?).select { |path| File.exist?(path) }.uniq
|
|
274
104
|
end
|
|
275
105
|
|
|
276
|
-
# Sandbox failures often happen before the child can
|
|
277
|
-
# denied shared-library read kills it at dynamic-link time);
|
|
278
|
-
#
|
|
106
|
+
# Sandbox failures often happen before the child can write structured output
|
|
107
|
+
# (e.g. a denied shared-library read kills it at dynamic-link time); include
|
|
108
|
+
# stderr so CI logs remain diagnosable.
|
|
279
109
|
def failure_detail(error)
|
|
280
110
|
detail = error.stderr.to_s.strip
|
|
281
111
|
detail = "exit status #{error.status&.exitstatus.inspect}" if detail.empty?
|
|
282
112
|
detail[0, 2000]
|
|
283
113
|
end
|
|
284
|
-
|
|
285
|
-
def symbolize(hash)
|
|
286
|
-
hash.transform_keys(&:to_sym)
|
|
287
|
-
end
|
|
288
114
|
end
|
|
289
115
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module SafeImage
|
|
8
|
+
# Helpers for same-directory temporary outputs. External tools often require a
|
|
9
|
+
# path, not an fd, so the helper intentionally creates and closes the tempfile
|
|
10
|
+
# before yielding its path. The temp path is always next to the destination so
|
|
11
|
+
# the final move stays on the same filesystem.
|
|
12
|
+
module StagedOutput
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def replace(output, suffix: nil)
|
|
16
|
+
output_path = PathSafety.ensure_safe_output_path!(output)
|
|
17
|
+
output_path.dirname.mkpath
|
|
18
|
+
with_temp_path(output_path, suffix: suffix || output_path.extname) do |tmp_path|
|
|
19
|
+
result = yield tmp_path, output_path
|
|
20
|
+
FileUtils.mv(tmp_path, output_path)
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_temp_path_near(output, suffix:)
|
|
26
|
+
output_path = PathSafety.ensure_safe_output_path!(output)
|
|
27
|
+
output_path.dirname.mkpath
|
|
28
|
+
with_temp_path(output_path, suffix: suffix) { |tmp_path| yield tmp_path, output_path }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def with_temp_path(output_path, suffix:)
|
|
32
|
+
Tempfile.create([output_path.basename(".*").to_s, suffix], output_path.dirname.to_s) do |tmp|
|
|
33
|
+
tmp_path = Pathname.new(tmp.path)
|
|
34
|
+
tmp.close
|
|
35
|
+
yield tmp_path
|
|
36
|
+
ensure
|
|
37
|
+
FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
private_class_method :with_temp_path
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_constant :StagedOutput
|
|
44
|
+
end
|