safe_image 0.2.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 +212 -10
- data/README.md +176 -168
- 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 +43 -41
- 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 -281
- 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 +250 -48
- 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 +367 -97
- data/lib/safe_image/result.rb +71 -23
- data/lib/safe_image/runner.rb +69 -24
- data/lib/safe_image/sandbox.rb +44 -191
- data/lib/safe_image/staged_output.rb +44 -0
- data/lib/safe_image/svg_metadata.rb +186 -55
- 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 -294
- metadata +48 -26
- data/lib/safe_image/discourse_compat.rb +0 -452
- data/lib/safe_image/svg_sanitizer.rb +0 -102
- data/lib/safe_image/vips_glue.rb +0 -361
data/lib/safe_image/result.rb
CHANGED
|
@@ -1,28 +1,76 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SafeImage
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
:width,
|
|
8
|
-
:height,
|
|
9
|
-
:size,
|
|
10
|
-
:animated,
|
|
11
|
-
:orientation
|
|
12
|
-
)
|
|
4
|
+
# Public metadata return value. Optional fields (`animated`, `orientation`) are
|
|
5
|
+
# nil unless the caller opts into the extra probes.
|
|
6
|
+
Info = Data.define(:path, :type, :width, :height, :size, :animated, :orientation)
|
|
13
7
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
8
|
+
# Public image-operation return value. `output`/`output_format` are nil for
|
|
9
|
+
# metadata-only probes; image-producing operations always include them. `tier`
|
|
10
|
+
# records the concrete execution tier (for example :native_encode,
|
|
11
|
+
# :jpegtran_lossless or :jpegtran_fallback_reencode) so non-fatal degradation
|
|
12
|
+
# is observable without parsing the backend label.
|
|
13
|
+
Result =
|
|
14
|
+
Data.define(
|
|
15
|
+
:input,
|
|
16
|
+
:output,
|
|
17
|
+
:input_format,
|
|
18
|
+
:output_format,
|
|
19
|
+
:width,
|
|
20
|
+
:height,
|
|
21
|
+
:filesize,
|
|
22
|
+
:backend,
|
|
23
|
+
:duration_ms,
|
|
24
|
+
:optimizer,
|
|
25
|
+
:tier
|
|
26
|
+
) do
|
|
27
|
+
def self.build(
|
|
28
|
+
input:,
|
|
29
|
+
output: nil,
|
|
30
|
+
input_format:,
|
|
31
|
+
output_format: nil,
|
|
32
|
+
width:,
|
|
33
|
+
height:,
|
|
34
|
+
backend:,
|
|
35
|
+
duration_ms:,
|
|
36
|
+
filesize: nil,
|
|
37
|
+
optimizer: nil,
|
|
38
|
+
encoder: nil,
|
|
39
|
+
tier: nil
|
|
40
|
+
)
|
|
41
|
+
new(
|
|
42
|
+
input: input.to_s,
|
|
43
|
+
output: output&.to_s,
|
|
44
|
+
input_format: input_format,
|
|
45
|
+
output_format: output_format,
|
|
46
|
+
width: width,
|
|
47
|
+
height: height,
|
|
48
|
+
filesize: filesize || result_filesize(input, output),
|
|
49
|
+
backend: BackendLabel.build(backend, encoder: encoder),
|
|
50
|
+
duration_ms: duration_ms,
|
|
51
|
+
optimizer: optimizer,
|
|
52
|
+
tier: tier
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.metadata(input:, input_format:, width:, height:, backend:, duration_ms:)
|
|
57
|
+
build(
|
|
58
|
+
input: input,
|
|
59
|
+
input_format: input_format,
|
|
60
|
+
width: width,
|
|
61
|
+
height: height,
|
|
62
|
+
filesize: File.size(input),
|
|
63
|
+
backend: backend,
|
|
64
|
+
duration_ms: duration_ms,
|
|
65
|
+
tier: :metadata
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.result_filesize(input, output)
|
|
70
|
+
path = output || input
|
|
71
|
+
path ? File.size(path) : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def success? = true
|
|
75
|
+
end
|
|
28
76
|
end
|
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,13 +34,24 @@ 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__)
|
|
28
43
|
IMAGEMAGICK_POLICY_FILE = File.join(IMAGEMAGICK_POLICY_PATH, "policy.xml").freeze
|
|
29
44
|
BASE_ENV = {
|
|
30
45
|
"PATH" => TRUSTED_PATH,
|
|
31
|
-
"VIPS_BLOCK_UNTRUSTED" => "1"
|
|
46
|
+
"VIPS_BLOCK_UNTRUSTED" => "1",
|
|
47
|
+
# Cap glibc's per-thread malloc arenas. Multithreaded tools (oxipng's
|
|
48
|
+
# rayon pool, ImageMagick's OpenMP) otherwise reserve an arena per thread
|
|
49
|
+
# — up to 8x64MB of *address space* per core — which, combined with the
|
|
50
|
+
# sandbox's RLIMIT_AS memory cap, spuriously fails the tool under
|
|
51
|
+
# concurrency even though real memory use is tiny. AS counts reservations,
|
|
52
|
+
# not RSS; bounding arenas is the standard mitigation and costs nothing
|
|
53
|
+
# for these compute-bound tools.
|
|
54
|
+
"MALLOC_ARENA_MAX" => "2"
|
|
32
55
|
}.freeze
|
|
33
56
|
|
|
34
57
|
def run!(argv, timeout: DEFAULT_TIMEOUT, env: {}, sandbox: false, read: [], write: [])
|
|
@@ -62,7 +85,13 @@ module SafeImage
|
|
|
62
85
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
86
|
if remaining <= 0
|
|
64
87
|
kill_process_group(wait_thr.pid)
|
|
65
|
-
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
|
+
)
|
|
66
95
|
end
|
|
67
96
|
|
|
68
97
|
readable, = IO.select(streams.keys, nil, nil, remaining)
|
|
@@ -75,7 +104,13 @@ module SafeImage
|
|
|
75
104
|
buffer << chunk
|
|
76
105
|
if buffer.bytesize > MAX_OUTPUT_BYTES
|
|
77
106
|
kill_process_group(wait_thr.pid)
|
|
78
|
-
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
|
+
)
|
|
79
114
|
end
|
|
80
115
|
rescue IO::WaitReadable
|
|
81
116
|
next
|
|
@@ -95,7 +130,13 @@ module SafeImage
|
|
|
95
130
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
131
|
if remaining <= 0
|
|
97
132
|
kill_process_group(wait_thr.pid)
|
|
98
|
-
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
|
+
)
|
|
99
140
|
end
|
|
100
141
|
status = wait_thr.join(remaining)&.value
|
|
101
142
|
end
|
|
@@ -106,15 +147,16 @@ module SafeImage
|
|
|
106
147
|
raise
|
|
107
148
|
end
|
|
108
149
|
|
|
109
|
-
return
|
|
150
|
+
return stdout, stderr if status&.success?
|
|
110
151
|
|
|
111
152
|
raise CommandError.new(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
)
|
|
118
160
|
end
|
|
119
161
|
|
|
120
162
|
def kill_process_group(pid)
|
|
@@ -122,17 +164,18 @@ module SafeImage
|
|
|
122
164
|
rescue Errno::ESRCH, Errno::EPERM
|
|
123
165
|
ensure
|
|
124
166
|
begin
|
|
125
|
-
sleep
|
|
167
|
+
sleep TERMINATE_GRACE_SECONDS
|
|
126
168
|
Process.kill("KILL", -pid)
|
|
127
169
|
rescue Errno::ESRCH, Errno::EPERM
|
|
128
170
|
end
|
|
129
171
|
end
|
|
130
172
|
|
|
131
173
|
def command_env(tmpdir, env = {})
|
|
132
|
-
allowed =
|
|
133
|
-
key
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
136
179
|
|
|
137
180
|
BASE_ENV.merge(
|
|
138
181
|
"MAGICK_CONFIGURE_PATH" => IMAGEMAGICK_POLICY_PATH,
|
|
@@ -163,10 +206,12 @@ module SafeImage
|
|
|
163
206
|
name = name.to_s
|
|
164
207
|
return name if name.include?(File::SEPARATOR) && File.file?(name) && File.executable?(name)
|
|
165
208
|
|
|
166
|
-
TRUSTED_PATH
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
170
215
|
|
|
171
216
|
nil
|
|
172
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,208 +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
|
-
result = run_worker!(operation, { args: args, kwargs: kwargs })
|
|
65
|
-
operation == "type" && result ? result.to_sym : result
|
|
28
|
+
def landlock_command_error
|
|
29
|
+
Landlock::CommandError
|
|
66
30
|
end
|
|
67
31
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
require "landlock"
|
|
73
|
-
config = SafeImage.config
|
|
74
|
-
payload = JSON.dump(
|
|
75
|
-
{
|
|
76
|
-
operation: operation,
|
|
77
|
-
request: request,
|
|
78
|
-
# The worker is a fresh process and must be configured like the
|
|
79
|
-
# parent — minus landlock, since it already runs inside the sandbox.
|
|
80
|
-
config: { backend: config.backend, max_pixels: config.max_pixels }
|
|
81
|
-
}
|
|
82
|
-
)
|
|
83
|
-
code = <<~'RUBY'
|
|
84
|
-
require "json"
|
|
85
|
-
require "safe_image"
|
|
86
|
-
|
|
87
|
-
def deep_symbolize(value)
|
|
88
|
-
case value
|
|
89
|
-
when Hash
|
|
90
|
-
value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
|
|
91
|
-
when Array
|
|
92
|
-
value.map { |v| deep_symbolize(v) }
|
|
93
|
-
else
|
|
94
|
-
value
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
payload = JSON.parse(ARGV.fetch(0), symbolize_names: true)
|
|
99
|
-
operation = payload.fetch(:operation).to_s
|
|
100
|
-
allowed_operations = %w[
|
|
101
|
-
probe thumbnail type size dimensions info orientation dominant_color optimize resize crop downsize convert convert_to_jpeg fix_orientation
|
|
102
|
-
convert_favicon_to_png frame_count animated? letter_avatar optimize_image! sanitize_svg!
|
|
103
|
-
]
|
|
104
|
-
raise ArgumentError, "unsupported sandbox operation: #{operation}" unless allowed_operations.include?(operation)
|
|
105
|
-
|
|
106
|
-
request = payload.fetch(:request)
|
|
107
|
-
args = request[:args] || []
|
|
108
|
-
kwargs = deep_symbolize(request[:kwargs] || {})
|
|
32
|
+
def landlock_abi
|
|
33
|
+
Landlock.abi_version
|
|
34
|
+
end
|
|
109
35
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
landlock: false,
|
|
114
|
-
max_pixels: config.fetch(:max_pixels)
|
|
115
|
-
)
|
|
36
|
+
def default_read_paths
|
|
37
|
+
%w[/usr /lib /lib64 /etc /bin /sbin /opt].select { |path| File.exist?(path) }
|
|
38
|
+
end
|
|
116
39
|
|
|
117
|
-
|
|
40
|
+
def default_execute_paths
|
|
41
|
+
%w[/usr /lib /lib64 /bin /sbin /opt].select { |path| File.exist?(path) }
|
|
42
|
+
end
|
|
118
43
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
puts JSON.dump({ __type: "Info", data: result.to_h })
|
|
123
|
-
else
|
|
124
|
-
puts JSON.dump({ __type: "Value", data: result })
|
|
125
|
-
end
|
|
126
|
-
RUBY
|
|
44
|
+
def landlock_capture!(argv, **options)
|
|
45
|
+
Landlock.capture!(argv.map(&:to_s), allow_all_known: true, **options)
|
|
46
|
+
end
|
|
127
47
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"SAFE_IMAGE_SANDBOX_CHILD" => "1",
|
|
132
|
-
"GEM_HOME" => ENV["GEM_HOME"].to_s,
|
|
133
|
-
"GEM_PATH" => ENV["GEM_PATH"].to_s,
|
|
134
|
-
"RUBYLIB" => $LOAD_PATH.select { |p| p && File.directory?(p) }.join(File::PATH_SEPARATOR)
|
|
135
|
-
)
|
|
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)
|
|
136
51
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
env: worker_env,
|
|
148
|
-
inherit_env: false,
|
|
149
|
-
timeout: Runner::DEFAULT_TIMEOUT,
|
|
150
|
-
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,
|
|
151
62
|
seccomp_deny_network: true,
|
|
152
63
|
max_output_bytes: 512 * 1024,
|
|
153
64
|
truncate_output: false
|
|
154
65
|
)
|
|
155
|
-
|
|
156
|
-
if response[:__type] == "Result"
|
|
157
|
-
data = response.fetch(:data)
|
|
158
|
-
Result.new(**data)
|
|
159
|
-
elsif response[:__type] == "Info"
|
|
160
|
-
data = response.fetch(:data)
|
|
161
|
-
Info.new(**data)
|
|
162
|
-
else
|
|
163
|
-
response[:data]
|
|
164
|
-
end
|
|
165
|
-
end
|
|
66
|
+
[result.stdout, result.stderr]
|
|
166
67
|
rescue LoadError
|
|
167
68
|
raise Error, "landlock sandbox requested but the landlock gem is unavailable"
|
|
168
|
-
rescue
|
|
69
|
+
rescue landlock_command_error => e
|
|
169
70
|
raise CommandError.new(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def sandbox_paths(request, operation)
|
|
179
|
-
read = []
|
|
180
|
-
write = []
|
|
181
|
-
|
|
182
|
-
values = []
|
|
183
|
-
values.concat(Array(request[:args]))
|
|
184
|
-
values.concat(Array(request.dig(:kwargs)&.values))
|
|
185
|
-
values.flatten.compact.each do |value|
|
|
186
|
-
next unless value.is_a?(String)
|
|
187
|
-
next if value.empty? || value.include?("\0")
|
|
188
|
-
|
|
189
|
-
expanded = File.expand_path(value) rescue next
|
|
190
|
-
if File.exist?(expanded)
|
|
191
|
-
read << expanded
|
|
192
|
-
elsif looks_like_path?(value)
|
|
193
|
-
write << File.dirname(expanded)
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# Common keyword names for generated outputs. Include the containing dir
|
|
198
|
-
# even when a stale file already exists, because operations may replace it.
|
|
199
|
-
kwargs = request[:kwargs] || {}
|
|
200
|
-
%i[output to path].each do |key|
|
|
201
|
-
next unless kwargs[key].is_a?(String)
|
|
202
|
-
write << File.dirname(File.expand_path(kwargs[key]))
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# In-place mutators need write permission for an existing input path too.
|
|
206
|
-
if %w[optimize optimize_image! sanitize_svg! fix_orientation].include?(operation.to_s)
|
|
207
|
-
first = Array(request[:args]).first
|
|
208
|
-
if first.is_a?(String) && File.exist?(first)
|
|
209
|
-
expanded = File.expand_path(first)
|
|
210
|
-
write << expanded
|
|
211
|
-
write << File.dirname(expanded)
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
{ read: read.uniq, write: write.uniq }
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def looks_like_path?(value)
|
|
219
|
-
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
|
+
)
|
|
220
78
|
end
|
|
221
79
|
|
|
222
80
|
def runtime_read_paths
|
|
@@ -229,12 +87,11 @@ module SafeImage
|
|
|
229
87
|
paths << RbConfig::CONFIG["vendorarchdir"]
|
|
230
88
|
# An --enable-shared Ruby installed outside the default read roots
|
|
231
89
|
# (e.g. GitHub Actions' /opt/hostedtoolcache builds) keeps libruby in
|
|
232
|
-
# libdir;
|
|
233
|
-
# before any Ruby code runs.
|
|
90
|
+
# libdir; helper/tool subprocesses need read access before they can start.
|
|
234
91
|
paths << RbConfig::CONFIG["libdir"]
|
|
235
92
|
paths << File.dirname(RbConfig.ruby)
|
|
236
93
|
# Pango/fontconfig need the font directories and configs for the native
|
|
237
|
-
# letter_avatar text rendering inside the
|
|
94
|
+
# letter_avatar text rendering inside the helper.
|
|
238
95
|
paths << "/etc/fonts"
|
|
239
96
|
paths << "/usr/share/fonts"
|
|
240
97
|
paths << "/usr/local/share/fonts"
|
|
@@ -246,17 +103,13 @@ module SafeImage
|
|
|
246
103
|
paths.flatten.compact.map(&:to_s).reject(&:empty?).select { |path| File.exist?(path) }.uniq
|
|
247
104
|
end
|
|
248
105
|
|
|
249
|
-
# Sandbox failures often happen before the child can
|
|
250
|
-
# denied shared-library read kills it at dynamic-link time);
|
|
251
|
-
#
|
|
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.
|
|
252
109
|
def failure_detail(error)
|
|
253
110
|
detail = error.stderr.to_s.strip
|
|
254
111
|
detail = "exit status #{error.status&.exitstatus.inspect}" if detail.empty?
|
|
255
112
|
detail[0, 2000]
|
|
256
113
|
end
|
|
257
|
-
|
|
258
|
-
def symbolize(hash)
|
|
259
|
-
hash.transform_keys(&:to_sym)
|
|
260
|
-
end
|
|
261
114
|
end
|
|
262
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
|