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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -12
  3. data/README.md +140 -287
  4. data/SECURITY.md +22 -11
  5. data/docs/architecture.md +63 -0
  6. data/ext/safe_image_vips_helper/extconf.rb +43 -0
  7. data/ext/safe_image_vips_helper/safe_image_vips_helper.c +1007 -0
  8. data/lib/safe_image/api/metadata.rb +85 -0
  9. data/lib/safe_image/api/transform.rb +152 -0
  10. data/lib/safe_image/backend_label.rb +24 -0
  11. data/lib/safe_image/formats.rb +96 -0
  12. data/lib/safe_image/ico.rb +42 -40
  13. data/lib/safe_image/image_magick_backend.rb +219 -162
  14. data/lib/safe_image/jpegli_backend.rb +64 -44
  15. data/lib/safe_image/metadata_operations.rb +155 -0
  16. data/lib/safe_image/native.rb +96 -290
  17. data/lib/safe_image/native_helper.rb +281 -0
  18. data/lib/safe_image/operation_backends/base.rb +83 -0
  19. data/lib/safe_image/operation_backends/image_magick.rb +123 -0
  20. data/lib/safe_image/operation_backends/vips.rb +251 -0
  21. data/lib/safe_image/operation_backends.rb +27 -0
  22. data/lib/safe_image/operation_set.rb +22 -0
  23. data/lib/safe_image/optimizer.rb +225 -98
  24. data/lib/safe_image/path_safety.rb +11 -0
  25. data/lib/safe_image/processor.rb +122 -85
  26. data/lib/safe_image/quality_defaults.rb +23 -0
  27. data/lib/safe_image/remote.rb +248 -144
  28. data/lib/safe_image/result.rb +71 -23
  29. data/lib/safe_image/runner.rb +60 -23
  30. data/lib/safe_image/sandbox.rb +44 -218
  31. data/lib/safe_image/staged_output.rb +44 -0
  32. data/lib/safe_image/svg_metadata.rb +74 -69
  33. data/lib/safe_image/transform_operations.rb +139 -0
  34. data/lib/safe_image/version.rb +1 -1
  35. data/lib/safe_image/vips_backend.rb +13 -12
  36. data/lib/safe_image.rb +62 -306
  37. metadata +43 -37
  38. data/lib/safe_image/discourse_compat.rb +0 -441
  39. data/lib/safe_image/svg_css.rb +0 -314
  40. data/lib/safe_image/svg_sanitizer.rb +0 -583
  41. data/lib/safe_image/vips_glue.rb +0 -361
  42. data/lib/safe_image/zygote.rb +0 -619
@@ -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(message, command:, status: nil, stdout: "", stderr: "")
12
- super(message)
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("command timed out after #{timeout}s", command: argv, stdout: stdout, stderr: stderr)
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("command output exceeded #{MAX_OUTPUT_BYTES} bytes", command: argv, stdout: stdout, stderr: stderr)
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("command timed out after #{timeout}s", command: argv, stdout: stdout, stderr: stderr)
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 [stdout, stderr] if status&.success?
150
+ return stdout, stderr if status&.success?
118
151
 
119
152
  raise CommandError.new(
120
- "command failed: #{argv.first} exited #{status&.exitstatus}",
121
- command: argv,
122
- status: status&.exitstatus,
123
- stdout: stdout,
124
- stderr: stderr
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 0.2
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 = env.each_with_object({}) do |(key, value), hash|
141
- key = key.to_s
142
- hash[key] = value.to_s if ALLOWED_ENV_KEYS.include?(key)
143
- end
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.split(File::PATH_SEPARATOR).each do |dir|
175
- path = File.join(dir, name)
176
- return path if File.file?(path) && File.executable?(path)
177
- end
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
@@ -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
- Landlock::SafeExec.supported?
19
+ landlock_supported?
27
20
  rescue LoadError
28
21
  false
29
22
  end
30
23
 
31
- def capture_command!(argv, read:, write:, timeout: Runner::DEFAULT_TIMEOUT, env: nil, rlimits: DEFAULT_RLIMITS)
32
- require "landlock"
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 public_call!(operation, args:, kwargs:)
62
- operation = operation.to_s
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 run_worker!(operation, request)
75
- operation = operation.to_s
76
- raise ArgumentError, "unsupported sandbox operation: #{operation}" unless OPERATIONS.include?(operation)
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
- 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
- )
36
+ def default_read_paths
37
+ %w[/usr /lib /lib64 /etc /bin /sbin /opt].select { |path| File.exist?(path) }
38
+ end
127
39
 
128
- result = SafeImage.__send__(operation, *args, **kwargs)
40
+ def default_execute_paths
41
+ %w[/usr /lib /lib64 /bin /sbin /opt].select { |path| File.exist?(path) }
42
+ end
129
43
 
130
- if defined?(SafeImage::Result) && result.is_a?(SafeImage::Result)
131
- puts JSON.dump({ __type: "Result", data: result.to_h })
132
- elsif defined?(SafeImage::Info) && result.is_a?(SafeImage::Info)
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
- paths = sandbox_paths(request, operation)
140
- Dir.mktmpdir("safe-image-worker-") do |tmpdir|
141
- worker_env = Runner.command_env(tmpdir).merge(
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
- stdout, = Landlock::SafeExec.capture!(
149
- RbConfig.ruby,
150
- "-I#{File.expand_path("../../", __dir__)}",
151
- "-rjson",
152
- "-e",
153
- code,
154
- payload,
155
- read: existing_paths([*Landlock::SafeExec.default_read_paths, *runtime_read_paths, *paths.fetch(:read), tmpdir]),
156
- write: existing_paths([*paths.fetch(:write), tmpdir]),
157
- execute: existing_paths([*Landlock::SafeExec.default_execute_paths, File.dirname(RbConfig.ruby)]),
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
- decode_payload(JSON.parse(stdout, symbolize_names: true))
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 Landlock::SafeExec::CommandError => e
69
+ rescue landlock_command_error => e
171
70
  raise CommandError.new(
172
- "sandboxed worker failed: #{failure_detail(e)}",
173
- command: [RbConfig.ruby, "-e", "..."],
174
- status: e.status&.exitstatus,
175
- stdout: e.stdout,
176
- stderr: e.stderr
177
- )
178
- end
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; without read access the worker dies at dynamic-link time
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 worker.
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 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.
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